Skip to content

Commit 8e62ad7

Browse files
authored
fix(desktop): omit undefined fields from renderer IPC payloads (#50599)
1 parent 956de96 commit 8e62ad7

3 files changed

Lines changed: 67 additions & 2 deletions

File tree

‎packages/desktop/src/main/ipc-transport.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,51 @@ import type { MessagePortMain, WebContents } from "electron"
55
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
66
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc"
77
import { Transferable } from "effect/unstable/workers"
8+
import { omitUndefined } from "../shared/ipc-transport"
9+
import { FilesOpenFilePicker } from "../shared/ipc-rpc/files"
810
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
911

1012
describe("desktop RPC transport", () => {
13+
test("decodes renderer payloads whose optional fields are undefined", async () => {
14+
let received: unknown
15+
const rpcs = RpcGroup.make(FilesOpenFilePicker)
16+
const handlers = rpcs.toLayer({
17+
FilesOpenFilePicker: ({ options }) =>
18+
Effect.sync(() => {
19+
received = options
20+
return null
21+
}),
22+
})
23+
const live = RpcServer.layer(rpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
24+
const runtime = ManagedRuntime.make(live)
25+
const handoff = await runtime.runPromise(IpcPortHandoff)
26+
const channel = new MessageChannel()
27+
handoff.bind(sender(1), serverPort(channel.port1))
28+
// What openAttachmentPickerDialog sends for the composer's attach button.
29+
const payload = { options: { multiple: true, title: undefined, defaultPath: "C:\\project", extensions: undefined } }
30+
31+
// The renderer posts the wire format itself, so a present-but-undefined key reaches the JSON codec.
32+
const rejected = await rawRequest(channel.port2, 0, payload)
33+
expect(rejected).toMatchObject({
34+
_tag: "Exit",
35+
exit: { _tag: "Failure", cause: [{ _tag: "Die", defect: expect.stringContaining('["options"]["title"]') }] },
36+
})
37+
38+
const accepted = await rawRequest(channel.port2, 1, omitUndefined(payload))
39+
expect(accepted).toMatchObject({ _tag: "Exit", exit: { _tag: "Success", value: null } })
40+
expect(received).toEqual({ multiple: true, defaultPath: "C:\\project" })
41+
42+
channel.port2.close()
43+
await runtime.dispose()
44+
})
45+
46+
test("omitting undefined fields leaves bytes and defined values alone", () => {
47+
const data = new Uint8Array([0, 255, 2])
48+
const result = omitUndefined({ data, nested: [{ keep: null, drop: undefined }], count: 0 }) as { data: Uint8Array }
49+
expect(result).toEqual({ data, nested: [{ keep: null }], count: 0 })
50+
expect(result.data).toBe(data)
51+
})
52+
1153
test("keeps multiple renderer ports independent", async () => {
1254
let received: unknown
1355
const handlers = TestRpcs.toLayer(
@@ -145,6 +187,12 @@ function clientProtocol(port: MessagePort) {
145187
)
146188
}
147189

190+
function rawRequest(port: MessageChannel["port2"], id: number, payload: unknown) {
191+
const response = new Promise<RpcMessage.FromServerEncoded>((resolve) => port.once("message", resolve))
192+
port.postMessage({ _tag: "Request", id, tag: "FilesOpenFilePicker", payload, headers: [] })
193+
return response
194+
}
195+
148196
function sender(id: number) {
149197
const events = new EventEmitter()
150198
return {

‎packages/desktop/src/renderer/ipc-client.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Effect } from "effect"
22
import type { RpcMessage } from "effect/unstable/rpc"
33
import type { DesktopRpcClient } from "../shared/ipc-rpc"
44
import type { DesktopEvent } from "../shared/ipc-rpc/events"
5-
import { IpcTransportPort } from "../shared/ipc-transport"
5+
import { IpcTransportPort, omitUndefined } from "../shared/ipc-transport"
66

77
// The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format
88
// 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"]) {
8080
const id = nextId++
8181
return new Promise<unknown>((resolve, reject) => {
8282
pending.set(id, { resolve, reject, chunk })
83-
const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] }
83+
const message: RpcMessage.RequestEncoded = {
84+
_tag: "Request",
85+
id,
86+
tag,
87+
payload: omitUndefined(payload),
88+
headers: [],
89+
}
8490
void port.then((p) => p.postMessage(message))
8591
})
8692
}

‎packages/desktop/src/shared/ipc-transport.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ export const IpcTransportPort = "desktop-rpc-port"
22
export const DragCancelEvent = "opencode:drag-cancel"
33
export const StorageSnapshotChannel = "desktop-storage-snapshot"
44

5+
// The main process decodes RPC payloads with the JSON codec, and JSON has no undefined: an
6+
// optional field the caller left as `undefined` decodes only when its key is absent. Structured
7+
// clone keeps the key, so drop such fields the way JSON.stringify would, leaving bytes untouched.
8+
export function omitUndefined(value: unknown): unknown {
9+
if (Array.isArray(value)) return value.map(omitUndefined)
10+
if (typeof value !== "object" || value === null || value instanceof Uint8Array) return value
11+
return Object.fromEntries(
12+
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, omitUndefined(item)]])),
13+
)
14+
}
15+
516
export type StorageSnapshot = Record<string, { items: Record<string, string>; revision: number }>
617

718
// The namespaces a window reads while its shell mounts. The preload asks for them before the page

0 commit comments

Comments
 (0)