diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 1994c270024..2abf53ac284 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -19,37 +19,30 @@ import { PreviewAutomationSnapshot, PreviewAutomationStatus, } from "@t3tools/contracts"; -import { BrowserWindow } from "electron"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as NodeURL from "node:url"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import * as PreviewManager from "../../preview/Manager.ts"; import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts"; import * as IpcChannels from "../channels.ts"; import * as DesktopIpc from "../DesktopIpc.ts"; -const broadcast = (channel: string, ...args: ReadonlyArray): void => { - for (const window of BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) { - window.webContents.send(channel, ...args); - } - } -}; - export const installPreviewEventForwarding = Effect.fn( "desktop.ipc.preview.installEventForwarding", )(function* () { + const electronWindow = yield* ElectronWindow.ElectronWindow; const manager = yield* PreviewManager.PreviewManager; - yield* manager.subscribeStateChanges((tabId, state) => { - broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state); - }); - yield* manager.subscribeRecordingFrames((frame) => { - broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame); - }); - yield* manager.subscribePointerEvents((event) => { - broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event); - }); + yield* manager.subscribeStateChanges((tabId, state) => + electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state), + ); + yield* manager.subscribeRecordingFrames((frame) => + electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame), + ); + yield* manager.subscribePointerEvents((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event), + ); }); export const createTab = DesktopIpc.makeIpcMethod({ diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index cc83d5f2e37..acb0d783a82 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -5,6 +5,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -13,6 +14,7 @@ import { TestClock } from "effect/testing"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; @@ -130,6 +132,72 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { + const loggedErrors: Array = []; + const logger = Logger.make(({ message }) => { + for (const value of Array.isArray(message) ? message : [message]) { + if (typeof value === "object" && value !== null && "cause" in value) { + loggedErrors.push(Cause.squash(value.cause as Cause.Cause)); + } + } + }); + const deliveryError = new ElectronWindow.ElectronWindowOperationError({ + operation: "send-window-message", + platform: "darwin", + windowId: 42, + channel: "preview:state-change", + cause: new Error("renderer unavailable"), + }); + const delivered = vi.fn(); + + return withManager((manager) => + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.die(deliveryError)); + yield* manager.subscribeStateChanges((tabId, state) => + Effect.sync(() => { + delivered(tabId, state); + }), + ); + + const state = yield* manager.createTab("tab_listener_failure"); + + expect(delivered).toHaveBeenCalledOnce(); + expect(delivered).toHaveBeenCalledWith("tab_listener_failure", state); + expect(loggedErrors).toHaveLength(1); + expect(loggedErrors[0]).toBeInstanceOf(ElectronWindow.ElectronWindowOperationError); + expect(loggedErrors[0]).toMatchObject({ + operation: "send-window-message", + windowId: 42, + channel: "preview:state-change", + }); + }), + ).pipe( + Effect.provide( + Logger.layer([logger], { + mergeWithExisting: false, + }), + ), + ); + }); + + effectIt.effect("does not swallow state listener interruption", () => + withManager((manager) => + Effect.gen(function* () { + const exit = yield* Effect.scoped( + Effect.gen(function* () { + yield* manager.subscribeStateChanges(() => Effect.interrupt); + return yield* Effect.exit(manager.createTab("tab_interrupted_listener")); + }), + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasInterrupts(exit.cause)).toBe(true); + } + }), + ), + ); + effectIt.effect("queues navigation until the webview registers", () => withManager((manager) => Effect.gen(function* () { @@ -411,7 +479,11 @@ describe("PreviewManager", () => { }, } as never); - yield* manager.subscribePointerEvents((event) => activity.push(event.phase)); + yield* manager.subscribePointerEvents((event) => + Effect.sync(() => { + activity.push(event.phase); + }), + ); yield* manager.createTab("tab_1"); yield* manager.registerWebview("tab_1", 42); const click = yield* manager diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index bb3e1fcef93..6fd65cd25b5 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -281,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => { return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current; }; -type Listener = (tabId: string, state: PreviewTabState) => void; -type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void; +type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect; +type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect; type PreviewInputSignal = | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number } @@ -313,7 +313,7 @@ interface BrowserDiagnostics { readonly requests: ReadonlyMap; } -type PointerEventListener = (event: DesktopPreviewPointerEvent) => void; +type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect; interface ExpectedAgentInput { readonly signal: PreviewInputSignal; @@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return copy; }; + const deliverEvent = ( + eventKind: "state-change" | "recording-frame" | "pointer-event", + tabId: string, + delivery: () => Effect.Effect, + ) => + Effect.suspend(delivery).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("Desktop preview event listener failed.", { + eventKind, + tabId, + cause, + }), + ), + ); + const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) { const listeners = yield* Ref.get(listenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore), + (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)), { discard: true }, ); }); @@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }; yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore), + (listener) => + deliverEvent("recording-frame", frame.tabId, () => listener(frame)), { discard: true }, ); } @@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listeners = yield* Ref.get(pointerEventListenersRef); yield* Effect.forEach( listeners, - (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore), + (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)), { discard: true }, ); });