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
48 changes: 48 additions & 0 deletions packages/desktop/src/main/ipc-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions packages/desktop/src/renderer/ipc-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
})
}
Expand Down
11 changes: 11 additions & 0 deletions packages/desktop/src/shared/ipc-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading