mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
fix(desktop): omit undefined fields from renderer IPC payloads (#50599)
This commit is contained in:
committed by
GitHub
Unverified
parent
956de96d8b
commit
8e62ad7adc
@@ -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<RpcMessage.FromServerEncoded>((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 {
|
||||
|
||||
@@ -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<unknown>((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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<string, { items: Record<string, string>; revision: number }>
|
||||
|
||||
// The namespaces a window reads while its shell mounts. The preload asks for them before the page
|
||||
|
||||
Reference in New Issue
Block a user