| 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,37 +19,30 @@ import { |
| 19 | PreviewAutomationSnapshot, | 19 | PreviewAutomationSnapshot, |
| 20 | PreviewAutomationStatus, | 20 | PreviewAutomationStatus, |
| 21 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 22 | import { BrowserWindow } from "electron"; | ||
| 23 | import * as Effect from "effect/Effect"; | 22 | import * as Effect from "effect/Effect"; |
| 24 | import * as Schema from "effect/Schema"; | 23 | import * as Schema from "effect/Schema"; |
| 25 | import * as NodeURL from "node:url"; | 24 | import * as NodeURL from "node:url"; |
| 26 | 25 | ||
| 26 | import * as ElectronWindow from "../../electron/ElectronWindow.ts"; | ||
| 27 | import * as PreviewManager from "../../preview/Manager.ts"; | 27 | import * as PreviewManager from "../../preview/Manager.ts"; |
| 28 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; | 28 | import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; |
| 29 | import * as IpcChannels from "../channels.ts"; | 29 | import * as IpcChannels from "../channels.ts"; |
| 30 | import * as DesktopIpc from "../DesktopIpc.ts"; | 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 | export const installPreviewEventForwarding = Effect.fn( | 32 | export const installPreviewEventForwarding = Effect.fn( |
| 41 | "desktop.ipc.preview.installEventForwarding", | 33 | "desktop.ipc.preview.installEventForwarding", |
| 42 | )(function* () { | 34 | )(function* () { |
| 35 | const electronWindow = yield* ElectronWindow.ElectronWindow; | ||
| 43 | const manager = yield* PreviewManager.PreviewManager; | 36 | const manager = yield* PreviewManager.PreviewManager; |
| 44 | yield* manager.subscribeStateChanges((tabId, state) => { | 37 | yield* manager.subscribeStateChanges((tabId, state) => |
| 45 | broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); | 38 | electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), |
| 46 | }); | 39 | ); |
| 47 | yield* manager.subscribeRecordingFrames((frame) => { | 40 | yield* manager.subscribeRecordingFrames((frame) => |
| 48 | broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); | 41 | electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), |
| 49 | }); | 42 | ); |
| 50 | yield* manager.subscribePointerEvents((event) => { | 43 | yield* manager.subscribePointerEvents((event) => |
| 51 | broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); | 44 | electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), |
| 52 | }); | 45 | ); |
| 53 | }); | 46 | }); |
| 54 | 47 | ||
| 55 | export const createTab = DesktopIpc.makeIpcMethod({ | 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,6 +5,7 @@ import * as Exit from "effect/Exit"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Fiber from "effect/Fiber"; | 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| 8 | import * as Logger from "effect/Logger"; | ||
| 8 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 9 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| 10 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| ... | @@ -13,6 +14,7 @@ import { TestClock } from "effect/testing"; | ... | @@ -13,6 +14,7 @@ import { TestClock } from "effect/testing"; |
| 13 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 14 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 14 | 15 | ||
| 15 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | 16 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 17 | import * as ElectronWindow from "../electron/ElectronWindow.ts"; | ||
| 16 | import * as BrowserSession from "./BrowserSession.ts"; | 18 | import * as BrowserSession from "./BrowserSession.ts"; |
| 17 | import * as PreviewManager from "./Manager.ts"; | 19 | import * as PreviewManager from "./Manager.ts"; |
| 18 | 20 | ||
| ... | @@ -130,6 +132,72 @@ describe("PreviewManager", () => { | ... | @@ -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 | effectIt.effect("queues navigation until the webview registers", () => | 201 | effectIt.effect("queues navigation until the webview registers", () => |
| 134 | withManager((manager) => | 202 | withManager((manager) => |
| 135 | Effect.gen(function* () { | 203 | Effect.gen(function* () { |
| ... | @@ -411,7 +479,11 @@ describe("PreviewManager", () => { | ... | @@ -411,7 +479,11 @@ describe("PreviewManager", () => { |
| 411 | }, | 479 | }, |
| 412 | } as never); | 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 | yield* manager.createTab("tab_1"); | 487 | yield* manager.createTab("tab_1"); |
| 416 | yield* manager.registerWebview("tab_1", 42); | 488 | yield* manager.registerWebview("tab_1", 42); |
| 417 | const click = yield* manager | 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,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { |
| 281 | return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; | 281 | return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; |
| 282 | }; | 282 | }; |
| 283 | 283 | ||
| 284 | type Listener = (tabId: string, state: PreviewTabState) => void; | 284 | type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>; |
| 285 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; | 285 | type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>; |
| 286 | 286 | ||
| 287 | type PreviewInputSignal = | 287 | type PreviewInputSignal = |
| 288 | | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } | 288 | | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } |
| ... | @@ -313,7 +313,7 @@ interface BrowserDiagnostics { | ... | @@ -313,7 +313,7 @@ interface BrowserDiagnostics { |
| 313 | readonly requests: ReadonlyMap<string, { url: string; method: string }>; | 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 | interface ExpectedAgentInput { | 318 | interface ExpectedAgentInput { |
| 319 | readonly signal: PreviewInputSignal; | 319 | readonly signal: PreviewInputSignal; |
| ... | @@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 442 | return copy; | 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 | const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { | 462 | const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { |
| 446 | const listeners = yield* Ref.get(listenersRef); | 463 | const listeners = yield* Ref.get(listenersRef); |
| 447 | yield* Effect.forEach( | 464 | yield* Effect.forEach( |
| 448 | listeners, | 465 | listeners, |
| 449 | (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), | 466 | (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)), |
| 450 | { discard: true }, | 467 | { discard: true }, |
| 451 | ); | 468 | ); |
| 452 | }); | 469 | }); |
| ... | @@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 739 | }; | 756 | }; |
| 740 | yield* Effect.forEach( | 757 | yield* Effect.forEach( |
| 741 | listeners, | 758 | listeners, |
| 742 | (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), | 759 | (listener) => |
| 760 | deliverEvent("recording-frame", frame.tabId, () => listener(frame)), | ||
| 743 | { discard: true }, | 761 | { discard: true }, |
| 744 | ); | 762 | ); |
| 745 | } | 763 | } |
| ... | @@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function | ... | @@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1918 | const listeners = yield* Ref.get(pointerEventListenersRef); | 1936 | const listeners = yield* Ref.get(pointerEventListenersRef); |
| 1919 | yield* Effect.forEach( | 1937 | yield* Effect.forEach( |
| 1920 | listeners, | 1938 | listeners, |
| 1921 | (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), | 1939 | (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), |
| 1922 | { discard: true }, | 1940 | { discard: true }, |
| 1923 | ); | 1941 | ); |
| 1924 | }); | 1942 | }); |