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
6 changes: 5 additions & 1 deletion apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(installCliCommand);
yield* ipc.handle(uninstallCliCommand);
for (const previewMethod of PreviewIpc.methods) {
yield* ipc.handle(previewMethod);
// Explicit types preserve the error/service union of these heterogeneous preview methods.
yield* ipc.handle<
Effect.Error<ReturnType<(typeof PreviewIpc.methods)[number]["handler"]>>,
Effect.Services<ReturnType<(typeof PreviewIpc.methods)[number]["handler"]>>
>(previewMethod);
}
yield* ipc.handle(PreviewIpc.listBrowserImportSources);
yield* ipc.handle(PreviewIpc.importBrowserCookies);
Expand Down
98 changes: 98 additions & 0 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
PreviewAutomationStatus,
PreviewCaptureFailure,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
Expand All @@ -14,6 +15,8 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import * as PreviewManager from "../../preview/Manager.ts";
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
import * as PreviewIpc from "./preview.ts";
import * as DesktopIpc from "../DesktopIpc.ts";
import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";

const { fromPartition } = vi.hoisted(() => ({
fromPartition: vi.fn(() => {
Expand All @@ -33,7 +36,102 @@ vi.mock("electron", () => ({
},
}));

const decodeCaptureFailure = Schema.decodeUnknownEffect(PreviewCaptureFailure);

describe("preview IPC methods", () => {
const invokeCapture = (
method: typeof PreviewIpc.startRecording | typeof PreviewIpc.captureScreenshot,
manager: Partial<PreviewManager.PreviewManager["Service"]>,
) =>
Effect.scoped(
Effect.gen(function* () {
let listener: DesktopIpc.DesktopIpcHandleListener | undefined;
const ipc = DesktopIpc.make({
removeHandler: vi.fn(),
handle: (_channel, registered) => {
listener = registered;
},
removeAllListeners: vi.fn(),
on: vi.fn(),
});
yield* ipc.handle(method);
return yield* Effect.promise(async () =>
structuredClone(await listener!({ sender: { id: 7 } }, { tabId: "capture-tab" })),
);
}).pipe(
Effect.provideService(
PreviewManager.PreviewManager,
manager as PreviewManager.PreviewManager["Service"],
),
Effect.provideService(DesktopClientSettings.DesktopClientSettings, {
get: Effect.succeedNone,
set: () => Effect.void,
}),
),
);

effectIt.effect.each([
{
cause: new Error("Current display surface not available for capture"),
reason: "capture-unavailable",
guidance: ["target tab", "unlocked", "display is on", "window is restored"],
},
{
cause: new Cause.TimeoutError(),
reason: "capture-timeout",
guidance: ["timed out during capture-page"],
},
{
cause: new Error("Current display surface not available for capture: private page data"),
reason: "capture-failed",
guidance: ["Preview capture failed during capture-page"],
},
])(
"transports a safe $reason screenshot diagnostic through registered IPC",
({ cause, reason, guidance }) =>
Effect.gen(function* () {
const result = yield* invokeCapture(PreviewIpc.captureScreenshot, {
captureScreenshot: (tabId) =>
Effect.fail(
new PreviewManager.PreviewOperationError({
operation: "captureScreenshot.capturePage",
tabId,
artifactPath: "/private/capture.png",
cause,
}),
),
});
expect(result).toEqual({
_tag: "PreviewCaptureFailure",
reason,
stage: "capture-page",
});
const failure = yield* decodeCaptureFailure(result);
for (const phrase of guidance) expect(failure.message).toContain(phrase);
}),
);

effectIt.effect("preserves recording arm failures without erasing successful void results", () =>
Effect.gen(function* () {
const result = yield* invokeCapture(PreviewIpc.startRecording, {
startRecording: (tabId) =>
Effect.fail(new PreviewManager.PreviewTabNotFoundError({ tabId })),
});
expect(result).toEqual({
_tag: "PreviewCaptureFailure",
reason: "renderer-unavailable",
stage: "recording-start",
});
const failure = yield* decodeCaptureFailure(result);
expect(failure.message).toContain("Reopen the preview tab");
expect(
yield* invokeCapture(PreviewIpc.startRecording, {
startRecording: () => Effect.void,
}),
).toBeUndefined();
}),
);

beforeEach(() => {
fromPartition.mockClear();
});
Expand Down
17 changes: 13 additions & 4 deletions apps/desktop/src/ipc/methods/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
DesktopPreviewTabInputSchema,
DesktopPreviewWebviewConfigSchema,
PreviewAnnotationSubmissionResultSchema,
PreviewCaptureFailure,
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
PreviewForwardedShortcut,
Expand All @@ -32,6 +33,7 @@ import * as NodeURL from "node:url";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import * as BrowserImport from "../../preview/BrowserImport/BrowserImport.ts";
import * as PreviewManager from "../../preview/Manager.ts";
import { normalizeCaptureFailure } from "../../preview/captureErrors.ts";
import * as DesktopClientSettings from "../../settings/DesktopClientSettings.ts";
import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
import * as IpcChannels from "../channels.ts";
Expand Down Expand Up @@ -207,7 +209,8 @@ export const cancelPickElement = tabMethod(
export const startRecording = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_RECORDING_START_CHANNEL,
payload: DesktopPreviewTabInputSchema,
result: Schema.Void,
// Void encodes any value as undefined, so the tagged failure must be tried first.
result: Schema.Union([PreviewCaptureFailure, Schema.Void]),
handler: Effect.fn("desktop.ipc.preview.startRecording")(function* ({ tabId }) {
const manager = yield* PreviewManager.PreviewManager;
const store = yield* DesktopClientSettings.DesktopClientSettings;
Expand All @@ -216,7 +219,11 @@ export const startRecording = DesktopIpc.makeIpcMethod({
showKeyPresses: value.browserRecordingShowKeyPresses,
showMousePresses: value.browserRecordingShowMousePresses,
}));
yield* manager.startRecording(tabId, Option.getOrUndefined(options));
return yield* manager
.startRecording(tabId, Option.getOrUndefined(options))
.pipe(
Effect.catch((error) => Effect.succeed(normalizeCaptureFailure(error, "recording-start"))),
);
}),
});
export const stopRecording = tabMethod(
Expand Down Expand Up @@ -390,10 +397,12 @@ export const setAnnotationSendEnabled = DesktopIpc.makeIpcMethod({
export const captureScreenshot = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_CAPTURE_SCREENSHOT_CHANNEL,
payload: DesktopPreviewTabInputSchema,
result: DesktopPreviewScreenshotArtifactSchema,
result: Schema.Union([DesktopPreviewScreenshotArtifactSchema, PreviewCaptureFailure]),
handler: Effect.fn("desktop.ipc.preview.captureScreenshot")(function* ({ tabId }) {
const manager = yield* PreviewManager.PreviewManager;
return yield* manager.captureScreenshot(tabId);
return yield* manager
.captureScreenshot(tabId)
.pipe(Effect.catch((error) => Effect.succeed(normalizeCaptureFailure(error, "screenshot"))));
}),
});

Expand Down
38 changes: 38 additions & 0 deletions apps/desktop/src/preview/captureErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { PreviewCaptureFailure } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import type { PreviewManagerError } from "./Manager.ts";

/** Classify before Electron replaces a rejected IPC Error with its message. */
export function normalizeCaptureFailure(
error: PreviewManagerError,
stage: PreviewCaptureFailure["stage"],
): PreviewCaptureFailure {
let reason: PreviewCaptureFailure["reason"] = "capture-failed";
switch (error._tag) {
case "PreviewTabNotFoundError":
case "PreviewWebContentsNotFoundError":
case "PreviewMainWindowClosedError":
reason = "renderer-unavailable";
break;
case "PreviewWebviewNotInitializedError":
case "PreviewRecordingCaptureUnavailableError":
reason = "capture-unavailable";
break;
case "PreviewRecordingArmConflictError":
reason = "recording-conflict";
break;
case "PreviewOperationError":
if (error.operation === "captureScreenshot.capturePage") {
stage = "capture-page";
if (
error.cause instanceof Error &&
error.cause.message === "Current display surface not available for capture"
) {
reason = "capture-unavailable";
}
}
if (Cause.isTimeoutError(error.cause)) reason = "capture-timeout";
break;
}
return new PreviewCaptureFailure({ reason, stage });
}
2 changes: 1 addition & 1 deletion apps/web/src/browser/ElectronBrowserHost.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ vi.mock(import("~/components/preview/previewBridge"), async (original) => ({
closeTab: mocks.closeTab,
registerWebview: mocks.registerWebview,
getPreviewConfig: mocks.getPreviewConfig,
} as unknown as DesktopPreviewBridge,
} as never,
}));

vi.mock(import("~/components/preview/usePreviewBridge"), async (original) => ({
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/browser/HostedBrowserWebview.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { act } from "react";
import "~/index.css";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
import type { previewBridge } from "~/components/preview/previewBridge";

const mocks = vi.hoisted(() => ({
getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
Expand Down Expand Up @@ -41,7 +42,7 @@ vi.mock(import("~/components/preview/previewBridge"), async (original) => ({
closeTab: mocks.closeTab,
registerWebview: mocks.registerWebview,
getPreviewConfig: mocks.getPreviewConfig,
} as unknown as DesktopPreviewBridge,
} as unknown as NonNullable<typeof previewBridge>,
}));

vi.mock(import("~/components/preview/usePreviewBridge"), async (original) => ({
Expand Down
Loading
Loading