diff --git a/packages/desktop/src/main/ipc-transport.test.ts b/packages/desktop/src/main/ipc-transport.test.ts index 66b62f15d57d..79cf25fa72ab 100644 --- a/packages/desktop/src/main/ipc-transport.test.ts +++ b/packages/desktop/src/main/ipc-transport.test.ts @@ -5,9 +5,51 @@ import type { MessagePortMain, WebContents } from "electron" import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect" import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc" import { Transferable } from "effect/unstable/workers" +import { omitUndefined } from "../shared/ipc-transport" +import { FilesOpenFilePicker } from "../shared/ipc-rpc/files" import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport" describe("desktop RPC transport", () => { + test("decodes renderer payloads whose optional fields are undefined", async () => { + let received: unknown + const rpcs = RpcGroup.make(FilesOpenFilePicker) + const handlers = rpcs.toLayer({ + FilesOpenFilePicker: ({ options }) => + Effect.sync(() => { + received = options + return null + }), + }) + const live = RpcServer.layer(rpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive)) + const runtime = ManagedRuntime.make(live) + const handoff = await runtime.runPromise(IpcPortHandoff) + const channel = new MessageChannel() + handoff.bind(sender(1), serverPort(channel.port1)) + // What openAttachmentPickerDialog sends for the composer's attach button. + const payload = { options: { multiple: true, title: undefined, defaultPath: "C:\\project", extensions: undefined } } + + // The renderer posts the wire format itself, so a present-but-undefined key reaches the JSON codec. + const rejected = await rawRequest(channel.port2, 0, payload) + expect(rejected).toMatchObject({ + _tag: "Exit", + exit: { _tag: "Failure", cause: [{ _tag: "Die", defect: expect.stringContaining('["options"]["title"]') }] }, + }) + + const accepted = await rawRequest(channel.port2, 1, omitUndefined(payload)) + expect(accepted).toMatchObject({ _tag: "Exit", exit: { _tag: "Success", value: null } }) + expect(received).toEqual({ multiple: true, defaultPath: "C:\\project" }) + + channel.port2.close() + await runtime.dispose() + }) + + test("omitting undefined fields leaves bytes and defined values alone", () => { + const data = new Uint8Array([0, 255, 2]) + const result = omitUndefined({ data, nested: [{ keep: null, drop: undefined }], count: 0 }) as { data: Uint8Array } + expect(result).toEqual({ data, nested: [{ keep: null }], count: 0 }) + expect(result.data).toBe(data) + }) + test("keeps multiple renderer ports independent", async () => { let received: unknown const handlers = TestRpcs.toLayer( @@ -145,6 +187,12 @@ function clientProtocol(port: MessagePort) { ) } +function rawRequest(port: MessageChannel["port2"], id: number, payload: unknown) { + const response = new Promise((resolve) => port.once("message", resolve)) + port.postMessage({ _tag: "Request", id, tag: "FilesOpenFilePicker", payload, headers: [] }) + return response +} + function sender(id: number) { const events = new EventEmitter() return { diff --git a/packages/desktop/src/renderer/ipc-client.ts b/packages/desktop/src/renderer/ipc-client.ts index 521c9b12068e..4fa32dc1ea75 100644 --- a/packages/desktop/src/renderer/ipc-client.ts +++ b/packages/desktop/src/renderer/ipc-client.ts @@ -2,7 +2,7 @@ import type { Effect } from "effect" import type { RpcMessage } from "effect/unstable/rpc" import type { DesktopRpcClient } from "../shared/ipc-rpc" import type { DesktopEvent } from "../shared/ipc-rpc/events" -import { IpcTransportPort } from "../shared/ipc-transport" +import { IpcTransportPort, omitUndefined } from "../shared/ipc-transport" // The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format // directly. Messages cross by structured clone (no serialization layer, binary stays binary), every @@ -80,7 +80,13 @@ function request(tag: string, payload: unknown, chunk?: Pending["chunk"]) { const id = nextId++ return new Promise((resolve, reject) => { pending.set(id, { resolve, reject, chunk }) - const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] } + const message: RpcMessage.RequestEncoded = { + _tag: "Request", + id, + tag, + payload: omitUndefined(payload), + headers: [], + } void port.then((p) => p.postMessage(message)) }) } diff --git a/packages/desktop/src/shared/ipc-transport.ts b/packages/desktop/src/shared/ipc-transport.ts index 78d52309bb7c..fb60a7322168 100644 --- a/packages/desktop/src/shared/ipc-transport.ts +++ b/packages/desktop/src/shared/ipc-transport.ts @@ -2,6 +2,17 @@ export const IpcTransportPort = "desktop-rpc-port" export const DragCancelEvent = "opencode:drag-cancel" export const StorageSnapshotChannel = "desktop-storage-snapshot" +// The main process decodes RPC payloads with the JSON codec, and JSON has no undefined: an +// optional field the caller left as `undefined` decodes only when its key is absent. Structured +// clone keeps the key, so drop such fields the way JSON.stringify would, leaving bytes untouched. +export function omitUndefined(value: unknown): unknown { + if (Array.isArray(value)) return value.map(omitUndefined) + if (typeof value !== "object" || value === null || value instanceof Uint8Array) return value + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, omitUndefined(item)]])), + ) +} + export type StorageSnapshot = Record; revision: number }> // The namespaces a window reads while its shell mounts. The preload asks for them before the page