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
67 changes: 67 additions & 0 deletions apps/desktop/src/ipc/DesktopIpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import { assert, it } from "@effect/vitest";
import { vi } from "vite-plus/test";

vi.mock("electron", () => ({}));

import * as ElectronWindow from "../electron/ElectronWindow.ts";
import * as DesktopIpc from "./DesktopIpc.ts";

it.effect("answers only the main window's top frame", () =>
Effect.gen(function* () {
const mainFrame = {};
const invoke = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
const sync = new Map<string, DesktopIpc.DesktopIpcSyncListener>();
const ipc = yield* DesktopIpc.DesktopIpc.pipe(
Effect.provide(
DesktopIpc.layer({
removeHandler: (channel) => void invoke.delete(channel),
handle: (channel, listener) => void invoke.set(channel, listener),
removeAllListeners: (channel) => void sync.delete(channel),
on: (channel, listener) => void sync.set(channel, listener),
}).pipe(
Layer.provide(
Layer.mock(ElectronWindow.ElectronWindow, {
main: Effect.succeedSome({
webContents: { id: 1, mainFrame },
} as unknown as Electron.BrowserWindow),
}),
),
),
),
);
yield* ipc.handle(
DesktopIpc.makeIpcMethod({
channel: "token",
payload: Schema.Void,
result: Schema.String,
handler: () => Effect.succeed("secret"),
}),
);
yield* ipc.handleSync({ channel: "token-sync", handler: () => Effect.succeed("secret") });

const senders = {
app: { sender: { id: 1 }, senderFrame: mainFrame },
appSubframe: { sender: { id: 1 }, senderFrame: {} },
previewGuest: { sender: { id: 2 }, senderFrame: {} },
};
const results: Record<string, unknown> = {};
for (const [name, sender] of Object.entries(senders)) {
const event = { ...sender, returnValue: "unset" as unknown };
sync.get("token-sync")!(event);
results[name] = [
yield* Effect.promise(() =>
Promise.resolve(invoke.get("token")!(sender, undefined)).catch(() => "rejected"),
),
event.returnValue,
];
}
assert.deepEqual(results, {
app: ["secret", "secret"],
appSubframe: ["rejected", undefined],
previewGuest: ["rejected", undefined],
});
}).pipe(Effect.scoped),
);
54 changes: 51 additions & 3 deletions apps/desktop/src/ipc/DesktopIpc.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";

import * as ElectronWindow from "../electron/ElectronWindow.ts";

export interface DesktopIpcInvokeEvent {
readonly sender: { readonly id: number };
readonly senderFrame?: unknown;
}

export interface DesktopIpcSyncEvent {
export interface DesktopIpcSyncEvent extends DesktopIpcInvokeEvent {
returnValue: unknown;
}

export type DesktopIpcSenderGuard = (event: DesktopIpcInvokeEvent) => Effect.Effect<boolean>;

export type DesktopIpcHandleListener = (
event: DesktopIpcInvokeEvent,
raw: unknown,
Expand Down Expand Up @@ -52,6 +58,15 @@ export class DesktopIpcUnregistrationError extends Schema.TaggedError<DesktopIpc
}
}

export class DesktopIpcUntrustedSenderError extends Schema.TaggedError<DesktopIpcUntrustedSenderError>()(
"DesktopIpcUntrustedSenderError",
{ channel: Schema.String },
) {
override get message(): string {
return `Rejected ${this.channel} from a sender that is not the app window.`;
}
}

export const DesktopIpcError = Schema.Union([
DesktopIpcRegistrationError,
DesktopIpcUnregistrationError,
Expand Down Expand Up @@ -80,7 +95,11 @@ export class DesktopIpc extends Context.Service<
}
>()("@t3tools/desktop/ipc/DesktopIpc") {}

export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
// ipcMain is shared by every webContents, including preview guests that load arbitrary pages.
export const make = (
ipcMain: DesktopIpcMain,
isTrustedSender: DesktopIpcSenderGuard,
): DesktopIpc["Service"] =>
DesktopIpc.of({
handle: Effect.fn("desktop.ipc.registerInvoke")(function* <E, R>({
channel,
Expand All @@ -98,6 +117,9 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
runPromise(
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ channel });
if (!(yield* isTrustedSender(event))) {
return yield* new DesktopIpcUntrustedSenderError({ channel });
}
return yield* handler(raw, event);
}).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
),
Expand Down Expand Up @@ -131,6 +153,11 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
event.returnValue = runSync(
Effect.gen(function* () {
yield* Effect.annotateCurrentSpan({ channel });
// Throwing here would surface as an uncaught main-process exception.
if (!(yield* isTrustedSender(event))) {
yield* Effect.logWarning("Rejected sync IPC from an untrusted sender.");
return undefined;
}
return yield* handler();
}).pipe(
Effect.annotateLogs({ channel }),
Expand All @@ -152,7 +179,28 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
}),
});

export const layer = (ipcMain: DesktopIpcMain) => Layer.succeed(DesktopIpc, make(ipcMain));
export const layer = (ipcMain: DesktopIpcMain) =>
Layer.effect(
DesktopIpc,
Effect.gen(function* () {
const electronWindow = yield* ElectronWindow.ElectronWindow;
return make(ipcMain, (event) =>
Effect.map(
electronWindow.main,
(main) =>
Option.isSome(main) &&
main.value.webContents.id === event.sender.id &&
main.value.webContents.mainFrame === event.senderFrame,
),
);
}),
);

export const layerTest = (ipcMain: DesktopIpcMain) =>
Layer.succeed(
DesktopIpc,
make(ipcMain, () => Effect.succeed(true)),
);

export interface DesktopIpcMethodRegistration<
Payload,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/ipc/methods/notificationBadge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () =>
Effect.provideService(HostProcess.Platform, "linux"),
Effect.provide([
ElectronApp.layer,
DesktopIpc.layer({
DesktopIpc.layerTest({
handle: (channel, handler) => {
handlers.set(channel, handler);
},
Expand Down
17 changes: 10 additions & 7 deletions apps/desktop/src/ipc/methods/preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,17 @@ describe("preview IPC methods", () => {
Effect.scoped(
Effect.gen(function* () {
let listener: DesktopIpc.DesktopIpcHandleListener | undefined;
const ipc = DesktopIpc.make({
removeHandler: vi.fn(),
handle: (_channel, registered) => {
listener = registered;
const ipc = DesktopIpc.make(
{
removeHandler: vi.fn(),
handle: (_channel, registered) => {
listener = registered;
},
removeAllListeners: vi.fn(),
on: vi.fn(),
},
removeAllListeners: vi.fn(),
on: vi.fn(),
});
() => Effect.succeed(true),
);
yield* ipc.handle(method);
return yield* Effect.promise(async () =>
structuredClone(await listener!({ sender: { id: 7 } }, { tabId: "capture-tab" })),
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe("installRelayTelemetryEnabled", () => {
yield* installRelayTelemetryEnabled().pipe(
Effect.provideService(HostProcess.Environment, env),
Effect.provide(
Layer.mergeAll(DesktopIpc.layer(ipcMain), DesktopManagedPolicy.layerTest(policy)),
Layer.mergeAll(DesktopIpc.layerTest(ipcMain), DesktopManagedPolicy.layerTest(policy)),
),
);
const event = { returnValue: undefined as unknown };
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ const layerElectron = Layer.mergeAll(
ElectronTheme.layer,
ElectronUpdater.layer,
ElectronWindow.layer,
DesktopIpc.layer(Electron.ipcMain),
DesktopIpc.layer(Electron.ipcMain).pipe(Layer.provide(ElectronWindow.layer)),
);

const layerDesktopFoundation = Layer.mergeAll(
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,8 @@ export const make = Effect.gen(function* () {
void runPromise(revealWhenReady);
});

// Desktop IPC only answers the main window, so register it before its renderer can call in.
yield* electronWindow.setMain(window);
loadApplication();
if (environment.isDevelopment) {
window.webContents.openDevTools({ mode: "detach" });
Expand All @@ -925,7 +927,6 @@ export const make = Effect.gen(function* () {
const createMain = Effect.gen(function* () {
const window = yield* createWindow();
if (Option.isSome(window)) {
yield* electronWindow.setMain(window.value);
mainClosedDuringStartup = false;
yield* logWindowInfo("main window created");
}
Expand Down
Loading