Skip to content
Open
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
88 changes: 88 additions & 0 deletions src/main/src/ipc.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof rehydrateSerializedError>[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<Envelope>;

// Capture the handler registered via ipcMain.handle so the test can invoke it
// directly and inspect the envelope it returns.
const handlers = new Map<string, InvokeHandler>();

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<Envelope> {
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");
});
});
20 changes: 14 additions & 6 deletions src/main/src/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { rehydrateSerializedError } from "@vortex/shared";
import { rehydrateSerializedError, serializeError } from "@vortex/shared";
import type {
RendererChannels,
MainChannels,
Expand Down Expand Up @@ -137,11 +137,19 @@ function mainHandle<C extends keyof InvokeChannels>(
| AssertSerializable<Awaited<ReturnType<InvokeChannels[C]>>>,
logOptions: LogOptions = false,
): void {
ipcMain.handle(channel, (event, ...args: SerializableArgs<Parameters<InvokeChannels[C]>>) => {
ipcLogger(logOptions, channel, event, args);
assertTrustedSender(event);
return listener(event, ...args);
});
ipcMain.handle(
channel,
async (event, ...args: SerializableArgs<Parameters<InvokeChannels[C]>>) => {
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<C extends keyof MainChannels>(
Expand Down
26 changes: 22 additions & 4 deletions src/preload/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { serializeError } from "@vortex/shared";
import { rehydrateSerializedError, serializeError } from "@vortex/shared";
import type {
AppInitMetadata,
RendererChannels,
Expand All @@ -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";
Expand Down Expand Up @@ -261,11 +263,24 @@ function expose<K extends keyof PreloadWindow>(key: K, value: PreloadWindow[K])
}
}

function rendererInvoke<C extends keyof InvokeChannels>(
async function rendererInvoke<C extends keyof InvokeChannels>(
channel: C,
...args: SerializableArgs<Parameters<InvokeChannels[C]>>
): Promise<AssertSerializable<Awaited<ReturnType<InvokeChannels[C]>>>> {
return ipcRenderer.invoke(channel, ...args);
type Result = WireResult<AssertSerializable<Awaited<ReturnType<InvokeChannels[C]>>>>;
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<Awaited<ReturnType<InvokeChannels[C]>>>;
}

function isWireResult(value: unknown): value is WireResult<unknown> {
return (
typeof value === "object" && value !== null && "ok" in value && typeof value.ok === "boolean"
);
}

function rendererSend<C extends keyof RendererChannels>(
Expand Down Expand Up @@ -315,7 +330,10 @@ function rendererCallback<C extends keyof CallbackChannels>(
) => {
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, {
Expand Down
15 changes: 15 additions & 0 deletions src/shared/src/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends Error>(
err: unknown,
ctor: new (...args: never[]) => T,
): err is T {
return err instanceof ctor || (err instanceof Error && err.name === ctor.name);
}
14 changes: 8 additions & 6 deletions src/shared/src/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = { ok: true; value: T } | { ok: false; error: Serializable };
export type WireResult<T> = { ok: true; value: T } | { ok: false; error: Serializable };

export interface CallbackChannels {
"example:ping": (ping: string) => Promise<{ pong: string }>;
Expand All @@ -154,7 +156,7 @@ export type RendererCallbackChannels = {
[C in keyof CallbackChannels as `callback:${C}`]: CallbackChannels[C] extends (
...args: infer _Args
) => Promise<infer Return>
? (collationId: number, result: WireCallbackResult<Return>) => void
? (collationId: number, result: WireResult<Return>) => void
: never;
};

Expand Down
Loading