diff --git a/src/main/src/ipc.test.ts b/src/main/src/ipc.test.ts new file mode 100644 index 000000000..e03b81e44 --- /dev/null +++ b/src/main/src/ipc.test.ts @@ -0,0 +1,88 @@ +import { rehydrateSerializedError } from "@vortex/shared"; +import { DownloadError, UserCanceled, isErrorOfType } from "@vortex/shared/errors"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Match rehydrateSerializedError's own parameter type so the round-trip below +// uses a single, consistent SerializedError declaration. +type SerializedError = Parameters[0]; + +// The WireResult envelope betterIpcMain.handle wraps every reply in. +type Envelope = { ok: true; value: unknown } | { ok: false; error: SerializedError }; + +type InvokeHandler = (event: Electron.IpcMainInvokeEvent, ...args: unknown[]) => Promise; + +// Capture the handler registered via ipcMain.handle so the test can invoke it +// directly and inspect the envelope it returns. +const handlers = new Map(); + +vi.mock("electron", () => ({ + ipcMain: { + handle: (channel: string, fn: InvokeHandler) => { + handlers.set(channel, fn); + }, + }, +})); + +vi.mock("./logging", () => ({ log: vi.fn() })); + +import { betterIpcMain } from "./ipc"; + +// A falsy senderFrame short-circuits assertTrustedSender as trusted, so the test +// doesn't need to mock the full trusted-sender plumbing. +const trustedEvent = { + senderFrame: null, +} as unknown as Electron.IpcMainInvokeEvent; + +async function callHandler(channel: string): Promise { + const fn = handlers.get(channel); + if (fn === undefined) throw new Error(`no handler registered for ${channel}`); + return fn(trustedEvent); +} + +describe("betterIpcMain.handle envelope", () => { + beforeEach(() => handlers.clear()); + + it("wraps a successful result in an ok envelope", async () => { + betterIpcMain.handle("app:getName", () => "vortex"); + + const result = await callHandler("app:getName"); + + expect(result).toEqual({ ok: true, value: "vortex" }); + }); + + it("serializes a thrown UserCanceled so it round-trips with its type intact", async () => { + // Regression: errors thrown across the invoke boundary used to be flattened + // by Electron to a generic `Error` (name "Error"), so the renderer's + // isErrorOfType(err, UserCanceled) gate failed and cancellations leaked into + // telemetry. The envelope must carry the name so rehydration restores it. + betterIpcMain.handle("app:getName", () => { + throw new UserCanceled(); + }); + + const result = await callHandler("app:getName"); + if (!("error" in result)) throw new Error("expected failure envelope"); + + expect(result.error.name).toBe("UserCanceled"); + + // The renderer side rehydrates the serialized error before throwing it. + const rehydrated = rehydrateSerializedError(result.error); + expect(rehydrated.name).toBe("UserCanceled"); + expect(isErrorOfType(rehydrated, UserCanceled)).toBe(true); + }); + + it("preserves error.code across the envelope (for isEnvironmentalError checks)", async () => { + betterIpcMain.handle("app:getName", () => { + throw new DownloadError({ code: "cancellation" }, "Download cancelled"); + }); + + const result = await callHandler("app:getName"); + if (!("error" in result)) throw new Error("expected failure envelope"); + + expect(result.error.name).toBe("DownloadError"); + expect(result.error.code).toBe("cancellation"); + + const rehydrated = rehydrateSerializedError(result.error); + expect(isErrorOfType(rehydrated, DownloadError)).toBe(true); + expect((rehydrated as Error & { code?: string }).code).toBe("cancellation"); + }); +}); diff --git a/src/main/src/ipc.ts b/src/main/src/ipc.ts index db74a786c..6366843b1 100644 --- a/src/main/src/ipc.ts +++ b/src/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { rehydrateSerializedError } from "@vortex/shared"; +import { rehydrateSerializedError, serializeError } from "@vortex/shared"; import type { RendererChannels, MainChannels, @@ -137,11 +137,19 @@ function mainHandle( | AssertSerializable>>, logOptions: LogOptions = false, ): void { - ipcMain.handle(channel, (event, ...args: SerializableArgs>) => { - ipcLogger(logOptions, channel, event, args); - assertTrustedSender(event); - return listener(event, ...args); - }); + ipcMain.handle( + channel, + async (event, ...args: SerializableArgs>) => { + ipcLogger(logOptions, channel, event, args); + try { + assertTrustedSender(event); + const value = await listener(event, ...args); + return { ok: true, value }; + } catch (err) { + return { ok: false, error: serializeError(err) }; + } + }, + ); } function mainSend( diff --git a/src/preload/src/index.ts b/src/preload/src/index.ts index db045d5ee..7faae93dd 100644 --- a/src/preload/src/index.ts +++ b/src/preload/src/index.ts @@ -1,4 +1,4 @@ -import { serializeError } from "@vortex/shared"; +import { rehydrateSerializedError, serializeError } from "@vortex/shared"; import type { AppInitMetadata, RendererChannels, @@ -8,6 +8,8 @@ import type { SerializableArgs, AssertSerializable, Serializable, + SerializedError, + WireResult, } from "@vortex/shared/ipc"; import type { PreloadWindow } from "@vortex/shared/preload"; import type { PersistedHive } from "@vortex/shared/state"; @@ -261,11 +263,24 @@ function expose(key: K, value: PreloadWindow[K]) } } -function rendererInvoke( +async function rendererInvoke( channel: C, ...args: SerializableArgs> ): Promise>>> { - return ipcRenderer.invoke(channel, ...args); + type Result = WireResult>>>; + const reply: unknown = await ipcRenderer.invoke(channel, ...args); + if (isWireResult(reply)) { + const result = reply as Result; + if (result.ok) return result.value; + throw rehydrateSerializedError(result.error as unknown as SerializedError); + } + return reply as AssertSerializable>>; +} + +function isWireResult(value: unknown): value is WireResult { + return ( + typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean" + ); } function rendererSend( @@ -315,7 +330,10 @@ function rendererCallback( ) => { handler(collationId, ...args) .then((value) => { - ipcRenderer.send(`callback:${channel}`, collationId, { ok: true, value }); + ipcRenderer.send(`callback:${channel}`, collationId, { + ok: true, + value, + }); }) .catch((err: unknown) => { ipcRenderer.send(`callback:${channel}`, collationId, { diff --git a/src/shared/src/types/errors.ts b/src/shared/src/types/errors.ts index ee8af5fed..d297cd21b 100644 --- a/src/shared/src/types/errors.ts +++ b/src/shared/src/types/errors.ts @@ -298,3 +298,18 @@ export class DownloadIsHTML extends Error { export function isUserCanceled(err: unknown): boolean { return err instanceof UserCanceled; } + +/** + * Class-identity check that also survives the IPC boundary. An error that + * crossed the wire is rebuilt as a plain `Error` (its prototype is lost), so + * `instanceof` fails — but `error-serialization` preserves the original type on + * `err.name` (falling back to `constructor.name`), so we match on either. Any + * custom payload is carried across as own-enumerable properties and reattached, + * so callers reading those fields still work on a rehydrated instance. + */ +export function isErrorOfType( + err: unknown, + ctor: new (...args: never[]) => T, +): err is T { + return err instanceof ctor || (err instanceof Error && err.name === ctor.name); +} diff --git a/src/shared/src/types/ipc.ts b/src/shared/src/types/ipc.ts index ddb2a510a..5e66047b7 100644 --- a/src/shared/src/types/ipc.ts +++ b/src/shared/src/types/ipc.ts @@ -129,12 +129,14 @@ export interface SerializedError { } /** - * A callback reply is either a successful value or a serialized error. The - * error rides as an opaque {@link Serializable} (a {@link SerializedError} - * shape at runtime) so it satisfies the IPC serialization contract; the - * receiver casts and rehydrates it via `rehydrateSerializedError`. + * A reply envelope crossing an IPC boundary: either a successful value or a + * serialized error. Electron's native invoke serialization would + * otherwise reduce it to a generic `Error`, losing its type/name. + * The error rides as an opaque {@link Serializable} + * (a {@link SerializedError} shape at runtime) so it + * satisfies the IPC serialization contract. */ -export type WireCallbackResult = { ok: true; value: T } | { ok: false; error: Serializable }; +export type WireResult = { ok: true; value: T } | { ok: false; error: Serializable }; export interface CallbackChannels { "example:ping": (ping: string) => Promise<{ pong: string }>; @@ -154,7 +156,7 @@ export type RendererCallbackChannels = { [C in keyof CallbackChannels as `callback:${C}`]: CallbackChannels[C] extends ( ...args: infer _Args ) => Promise - ? (collationId: number, result: WireCallbackResult) => void + ? (collationId: number, result: WireResult) => void : never; };