Skip to content
Source
Sign inSign up
Open History to see the last commit

DesktopIpc.ts

322 lines9.3 KB

1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Schema from "effect/Schema";
6import * as Scope from "effect/Scope";
7​
8import * as ElectronWindow from "../electron/ElectronWindow.ts";
9​
10export interface DesktopIpcInvokeEvent {
11 readonly sender: { readonly id: number };
12 readonly senderFrame?: unknown;
14​
15export interface DesktopIpcSyncEvent extends DesktopIpcInvokeEvent {
16 returnValue: unknown;
18​
19export type DesktopIpcSenderGuard = (event: DesktopIpcInvokeEvent) => Effect.Effect<boolean>;
20​
21export type DesktopIpcHandleListener = (
22 event: DesktopIpcInvokeEvent,
23 raw: unknown,
24) => unknown | Promise<unknown>;
25​
26export type DesktopIpcSyncListener = (event: DesktopIpcSyncEvent) => void;
27​
28export interface DesktopIpcMain {
29 removeHandler(channel: string): void;
30 handle(channel: string, listener: DesktopIpcHandleListener): void;
31 removeAllListeners(channel: string): void;
32 on(channel: string, listener: DesktopIpcSyncListener): void;
34​
35export class DesktopIpcRegistrationError extends Schema.TaggedError<DesktopIpcRegistrationError>()(
36 "DesktopIpcRegistrationError",
37 {
38 handlerKind: Schema.Literals(["invoke", "sync"]),
39 channel: Schema.String,
40 cause: Schema.Defect(),
41 },
42) {
43 override get message(): string {
44 return `Failed to register the ${this.handlerKind} IPC handler for ${this.channel}.`;
45 }
47​
48export class DesktopIpcUnregistrationError extends Schema.TaggedError<DesktopIpcUnregistrationError>()(
49 "DesktopIpcUnregistrationError",
50 {
51 handlerKind: Schema.Literals(["invoke", "sync"]),
52 channel: Schema.String,
53 cause: Schema.Defect(),
54 },
55) {
56 override get message(): string {
57 return `Failed to unregister the ${this.handlerKind} IPC handler for ${this.channel}.`;
58 }
60​
61export class DesktopIpcUntrustedSenderError extends Schema.TaggedError<DesktopIpcUntrustedSenderError>()(
62 "DesktopIpcUntrustedSenderError",
63 { channel: Schema.String },
64) {
65 override get message(): string {
66 return `Rejected ${this.channel} from a sender that is not the app window.`;
67 }
69​
70export const DesktopIpcError = Schema.Union([
71 DesktopIpcRegistrationError,
72 DesktopIpcUnregistrationError,
73]);
74export type DesktopIpcError = typeof DesktopIpcError.Type;
75​
76export interface DesktopIpcMethod<E, R> {
77 readonly channel: string;
78 readonly handler: (raw: unknown, event?: DesktopIpcInvokeEvent) => Effect.Effect<unknown, E, R>;
80​
81export interface DesktopSyncIpcMethod<E, R> {
82 readonly channel: string;
83 readonly handler: () => Effect.Effect<unknown, E, R>;
85​
86export class DesktopIpc extends Context.Service<
87 DesktopIpc,
88 {
89 readonly handle: <E, R>(
90 input: DesktopIpcMethod<E, R>,
91 ) => Effect.Effect<void, DesktopIpcRegistrationError, R | Scope.Scope>;
92 readonly handleSync: <E, R>(
93 input: DesktopSyncIpcMethod<E, R>,
94 ) => Effect.Effect<void, DesktopIpcRegistrationError, R | Scope.Scope>;
95 }
96>()("@t3tools/desktop/ipc/DesktopIpc") {}
97​
98// ipcMain is shared by every webContents, including preview guests that load arbitrary pages.
99export const make = (
100 ipcMain: DesktopIpcMain,
101 isTrustedSender: DesktopIpcSenderGuard,
102): DesktopIpc["Service"] =>
103 DesktopIpc.of({
104 handle: Effect.fn("desktop.ipc.registerInvoke")(function* <E, R>({
105 channel,
106 handler,
107 }: DesktopIpcMethod<E, R>) {
108 yield* Effect.annotateCurrentSpan({ channel });
109 const context = yield* Effect.context<R>();
110 const runPromise = Effect.runPromiseWith(context);
111​
112 yield* Effect.acquireRelease(
113 Effect.try({
114 try: () => {
115 ipcMain.removeHandler(channel);
116 ipcMain.handle(channel, (event, raw) =>
117 runPromise(
118 Effect.gen(function* () {
119 yield* Effect.annotateCurrentSpan({ channel });
120 if (!(yield* isTrustedSender(event))) {
121 return yield* new DesktopIpcUntrustedSenderError({ channel });
122 }
123 return yield* handler(raw, event);
124 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
125 ),
126 );
127 },
128 catch: (cause) =>
129 new DesktopIpcRegistrationError({ handlerKind: "invoke", channel, cause }),
130 }),
131 () =>
132 Effect.try({
133 try: () => ipcMain.removeHandler(channel),
134 catch: (cause) =>
135 new DesktopIpcUnregistrationError({ handlerKind: "invoke", channel, cause }),
136 }).pipe(Effect.orDie),
137 );
138 }),
139​
140 handleSync: Effect.fn("desktop.ipc.registerSync")(function* <E, R>({
141 channel,
142 handler,
143 }: DesktopSyncIpcMethod<E, R>) {
144 yield* Effect.annotateCurrentSpan({ channel });
145 const context = yield* Effect.context<R>();
146 const runSync = Effect.runSyncWith(context);
147​
148 yield* Effect.acquireRelease(
149 Effect.try({
150 try: () => {
151 ipcMain.removeAllListeners(channel);
152 ipcMain.on(channel, (event) => {
153 event.returnValue = runSync(
154 Effect.gen(function* () {
155 yield* Effect.annotateCurrentSpan({ channel });
156 // Throwing here would surface as an uncaught main-process exception.
157 if (!(yield* isTrustedSender(event))) {
158 yield* Effect.logWarning("Rejected sync IPC from an untrusted sender.");
159 return undefined;
160 }
161 return yield* handler();
162 }).pipe(
163 Effect.annotateLogs({ channel }),
164 Effect.withSpan("desktop.ipc.invokeSync"),
165 ),
166 );
167 });
168 },
169 catch: (cause) =>
170 new DesktopIpcRegistrationError({ handlerKind: "sync", channel, cause }),
171 }),
172 () =>
173 Effect.try({
174 try: () => ipcMain.removeAllListeners(channel),
175 catch: (cause) =>
176 new DesktopIpcUnregistrationError({ handlerKind: "sync", channel, cause }),
177 }).pipe(Effect.orDie),
178 );
179 }),
180 });
181​
182export const layer = (ipcMain: DesktopIpcMain) =>
183 Layer.effect(
184 DesktopIpc,
185 Effect.gen(function* () {
186 const electronWindow = yield* ElectronWindow.ElectronWindow;
187 return make(ipcMain, (event) =>
188 Effect.map(
189 electronWindow.main,
190 (main) =>
191 Option.isSome(main) &&
192 main.value.webContents.id === event.sender.id &&
193 main.value.webContents.mainFrame === event.senderFrame,
194 ),
195 );
196 }),
197 );
198​
199export const layerTest = (ipcMain: DesktopIpcMain) =>
200 Layer.succeed(
201 DesktopIpc,
202 make(ipcMain, () => Effect.succeed(true)),
203 );
204​
205export interface DesktopIpcMethodRegistration<
206 Payload,
207 EncodedPayload,
208 Result,
209 EncodedResult,
210 E,
211 R,
212 PayloadDecodingServices = never,
213 PayloadEncodingServices = never,
214 ResultDecodingServices = never,
215 ResultEncodingServices = never,
216> {
217 readonly channel: string;
218 readonly payload: Schema.Codec<
219 Payload,
220 EncodedPayload,
221 PayloadDecodingServices,
222 PayloadEncodingServices
223 >;
224 readonly result: Schema.Codec<
225 Result,
226 EncodedResult,
227 ResultDecodingServices,
228 ResultEncodingServices
229 >;
230 readonly handler: (input: Payload, event?: DesktopIpcInvokeEvent) => Effect.Effect<Result, E, R>;
232​
233export const makeIpcMethod = <
234 Payload,
235 EncodedPayload,
236 Result,
237 EncodedResult,
238 E,
239 R,
240 PayloadDecodingServices = never,
241 PayloadEncodingServices = never,
242 ResultDecodingServices = never,
243 ResultEncodingServices = never,
244>(
245 method: DesktopIpcMethodRegistration<
246 Payload,
247 EncodedPayload,
248 Result,
249 EncodedResult,
250 E,
251 R,
252 PayloadDecodingServices,
253 PayloadEncodingServices,
254 ResultDecodingServices,
255 ResultEncodingServices
256 >,
257): DesktopIpcMethod<
258 E | Schema.SchemaError,
259 R | PayloadDecodingServices | ResultEncodingServices
260> => {
261 const decode = Schema.decodeUnknownEffect(method.payload);
262 const encode = Schema.encodeUnknownEffect(method.result);
263​
264 return {
265 channel: method.channel,
266 handler: (raw, event) =>
267 decode(raw).pipe(
268 Effect.flatMap((input) => method.handler(input, event)),
269 Effect.flatMap(encode),
270 Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }),
271 ),
272 };
273};
274​
275export interface DesktopSyncIpcMethodRegistration<
276 Result,
277 EncodedResult,
278 E,
279 R,
280 ResultDecodingServices = never,
281 ResultEncodingServices = never,
282> {
283 readonly channel: string;
284 readonly result: Schema.Codec<
285 Result,
286 EncodedResult,
287 ResultDecodingServices,
288 ResultEncodingServices
289 >;
290 readonly handler: () => Effect.Effect<Result, E, R>;
292​
293export const makeSyncIpcMethod = <
294 Result,
295 EncodedResult,
296 E,
297 R,
298 ResultDecodingServices = never,
299 ResultEncodingServices = never,
300>(
301 method: DesktopSyncIpcMethodRegistration<
302 Result,
303 EncodedResult,
304 E,
305 R,
306 ResultDecodingServices,
307 ResultEncodingServices
308 >,
309): DesktopSyncIpcMethod<E | Schema.SchemaError, R | ResultEncodingServices> => {
310 const encode = Schema.encodeUnknownEffect(method.result);
311​
312 return {
313 channel: method.channel,
314 handler: () =>
315 method
316 .handler()
317 .pipe(
318 Effect.flatMap(encode),
319 Effect.withSpan("desktop.ipc.method", { attributes: { channel: method.channel } }),
320 ),
321 };
322};