Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 11 additions & 18 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>): 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({
Expand Down
74 changes: 73 additions & 1 deletion apps/desktop/src/preview/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -130,6 +132,72 @@ describe("PreviewManager", () => {
),
);

effectIt.effect("isolates failed state listeners and continues delivery", () => {
const loggedErrors: Array<unknown> = [];
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<never>));
}
}
});
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* () {
Expand Down Expand Up @@ -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
Expand Down
30 changes: 24 additions & 6 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>;

type PreviewInputSignal =
| { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
Expand Down Expand Up @@ -313,7 +313,7 @@ interface BrowserDiagnostics {
readonly requests: ReadonlyMap<string, { url: string; method: string }>;
}

type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;

interface ExpectedAgentInput {
readonly signal: PreviewInputSignal;
Expand Down Expand Up @@ -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<void>,
) =>
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 },
);
});
Expand Down Expand Up @@ -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 },
);
}
Expand Down Expand Up @@ -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 },
);
});
Expand Down
Loading