Skip to content

feature: Enable Electron desktop apps via Uni RPC over IPC#610

Merged
xerial merged 5 commits into
mainfrom
feature/electron-desktop-app
Jun 25, 2026
Merged

feature: Enable Electron desktop apps via Uni RPC over IPC#610
xerial merged 5 commits into
mainfrom
feature/electron-desktop-app

Conversation

@xerial

@xerial xerial commented Jun 24, 2026

Copy link
Copy Markdown
Member

Summary

Enables Uni to build Electron desktop apps: a renderer (Chromium) calls services running in the main process (Node.js) using Uni's existing RPC stack, tunneled over Electron IPC (ipcRenderer.invoke / ipcMain.handle) instead of a network socket. Reusing the RPC wire format means service traits and clients are unchanged across HTTP and IPC.

Library — wvlet.uni.electron (Scala.js only)

  • ElectronRendererChannel + ElectronChannelFactory + ElectronRenderer.install() — renderer-side HttpAsyncChannel that marshals each request through a preload bridge (window.uniRPC.request). Async-only (a sandboxed renderer has no synchronous IPC).
  • ElectronRPCServer.serve(ipcMain, routers*) — main-side: registers ipcMain.handle("uni-rpc", …), dispatches via RPCDispatcher, bridges Rx[Response]js.Promise. Electron objects are handed in as values (no require("electron")) to avoid Scala.js linker coupling.
  • ElectronIPC — structured-clone-safe Request/Response ↔ payload marshaling.

Refactor

  • Extract RPCDispatcher (shared, cross-platform) from netty's RPCHandler so HTTP and IPC share one dispatch path. RPCHandler now delegates to it.

Bug fixes (surfaced by the first end-to-end RPC test)

  • RPCClient scalar params never serializedJSON.parse of a bare value ("world", 42) fails; switched to JSON.parseAny. No runtime RPC test existed before, so this was latent.
  • scalajs-java-securerandom was % Test — downstream JS apps couldn't link uni's main code (ULID → SecureRandom via the Weaver chain). Now a compile dep, so desktop apps link out of the box.

Example + docs

  • examples/electron-app/ — full reference: Scala.js api/main/renderer, electron-vite + @scala-js/vite-plugin-scalajs pipeline, preload bridge, electron-builder packaging.
  • docs/http/electron.md guide (wired into both sidebars) + adr/2026-06-24-electron-ipc-transport.md.

Verification

  • New ElectronRPCTest: round-trips sync + async RPC over a fake IPC bridge — 5/5.
  • uniJS http suite 345/345; netty 39/39; RPCRouterTest 7/7.
  • Example compiles and links to ES modules with the expected wireMainProcess / main exports.
  • scalafmtCheckAll clean; JVM/JS/Native compile; pnpm docs:build succeeds.

🤖 Generated with Claude Code

Adds a Scala.js Electron IPC transport so a renderer (Chromium) can call
services in the main process (Node.js) using Uni's existing RPC stack,
tunneled over ipcRenderer.invoke/ipcMain.handle instead of a network socket.

Why: Uni's RPC is transport-agnostic (RPCRouter/RPCClient exchange a JSON
envelope over a pluggable HttpChannel), so a desktop app shouldn't need an
embedded HTTP server. Reusing the same wire format keeps service traits and
clients unchanged across HTTP and IPC.

Library (wvlet.uni.electron, JS-only):
- ElectronRendererChannel + ElectronChannelFactory + ElectronRenderer.install:
  an HttpAsyncChannel that marshals requests through a preload bridge
  (window.uniRPC.request). Renderer is async-only (no sync IPC in a sandbox).
- ElectronRPCServer.serve(ipcMain, routers*): registers ipcMain.handle and
  bridges Rx[Response] to a js.Promise. Electron objects are handed in as
  values (no require("electron")) to avoid Scala.js linker coupling.
- ElectronIPC: structured-clone-safe Request/Response <-> payload marshaling.

Refactor:
- Extract RPCDispatcher (shared, cross-platform) from netty's RPCHandler so
  HTTP and IPC share one dispatch path; RPCHandler now delegates to it.

Fixes uncovered by the first end-to-end RPC test:
- RPCClient scalar params (String/Int/...) never serialized: JSON.parse of a
  bare value fails; use JSON.parseAny.
- scalajs-java-securerandom was % Test, so downstream JS apps couldn't link
  uni's main code (ULID -> SecureRandom via the Weaver chain). Now compile.

Example + docs:
- examples/electron-app: full reference (sbt Scala.js api/main/renderer,
  electron-vite + vite-plugin-scalajs, preload bridge, electron-builder).
- docs/http/electron.md guide + adr/2026-06-24-electron-ipc-transport.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the feature New feature label Jun 24, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Electron IPC transport support for Uni RPC, allowing Scala.js desktop applications to tunnel RPC calls over Electron's IPC instead of a network socket. It extracts a transport-neutral RPCDispatcher from netty's RPCHandler and adds the JS-only wvlet.uni.electron package containing the IPC wire contract, renderer channel, and main-process server. A complete example application and documentation are also provided. The review feedback highlights several robustness and correctness improvements, including handling potential crashes and hanging promises in the main-process dispatcher, preserving multi-value headers during serialization, preventing synchronous type errors on null payloads, and ensuring synchronous exceptions in the renderer channel do not bypass reactive stream error handling.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +72 to +84
private def dispatch(dispatcher: RPCDispatcher, payload: js.Dynamic): js.Promise[js.Object] =
val request = ElectronIPC.toRequest(payload)
new js.Promise[js.Object]((resolve, _reject) =>
RxRunner.run(dispatcher.dispatch(request)) {
case OnNext(v) =>
resolve(ElectronIPC.fromResponse(v.asInstanceOf[Response]))
case OnError(e) =>
val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException(e.getMessage, e).toResponse
resolve(ElectronIPC.fromResponse(errorResponse))
case OnCompletion =>
// No value emitted: nothing to resolve with (should not happen for RPC).
}
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Uncaught exceptions in toRequest or RxRunner.run can crash the Node.js main process. Additionally, if the dispatcher returns an empty stream (e.g., if a service method returns Rx.empty), the promise will hang indefinitely because OnCompletion does not resolve or reject. Wrap the execution in a try-catch block inside the Promise constructor, and track whether a value was emitted to resolve with an error response if the stream completes empty.

  private def dispatch(dispatcher: RPCDispatcher, payload: js.Dynamic): js.Promise[js.Object] =
    new js.Promise[js.Object]((resolve, _reject) =>
      try
        val request = ElectronIPC.toRequest(payload)
        var emitted = false
        RxRunner.run(dispatcher.dispatch(request)) {
          case OnNext(v) =>
            emitted = true
            resolve(ElectronIPC.fromResponse(v.asInstanceOf[Response]))
          case OnError(e) =>
            val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException(e.getMessage, e).toResponse
            resolve(ElectronIPC.fromResponse(errorResponse))
          case OnCompletion =>
            if !emitted then
              val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException("No response emitted by RPC dispatcher").toResponse
              resolve(ElectronIPC.fromResponse(errorResponse))
        }
      catch
        case e: Throwable =>
          val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException(e.getMessage, e).toResponse
          resolve(ElectronIPC.fromResponse(errorResponse))
    )
References
  1. To prevent uncaught exceptions from crashing the process in asynchronous environments (like Node.js), wrap the entire body of response-writing methods in a try-catch block so that failed writes are caught centrally.
  2. When implementing cross-platform background tasks, catch all Throwable exceptions (including fatal ones) and record them as a failure result instead of rethrowing them to ensure single-threaded platforms like Scala.js do not throw immediately.

Comment on lines +88 to +95
private def headersToJS(headers: HttpMultiMap): js.Object =
val dict = js.Dictionary.empty[String]
headers
.entries
.foreach { case (k, v) =>
dict(k) = v
}
dict.asInstanceOf[js.Object]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Multi-value headers are currently overwritten and lost during serialization because headersToJS maps each header key to a single string value in a js.Dictionary. To preserve all values for multi-value headers, serialize them as arrays of strings.

  private def headersToJS(headers: HttpMultiMap): js.Object =
    val dict = js.Dictionary.empty[js.Array[String]]
    headers.entries.foreach { case (k, v) =>
      if !dict.contains(k) then
        dict(k) = js.Array()
      dict(k).push(v)
    }
    dict.asInstanceOf[js.Object]

Comment on lines +97 to +106
private def jsToHeaders(headers: js.Any): HttpMultiMap =
if js.isUndefined(headers) || headers == null then
HttpMultiMap.empty
else
val builder = HttpMultiMap.newBuilder
val dict = headers.asInstanceOf[js.Dictionary[Any]]
dict.foreach { case (k, v) =>
builder.add(k, String.valueOf(v))
}
builder.result()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To support deserializing multi-value headers that are serialized as arrays of strings, update jsToHeaders to check if the header value is an array and add each item individually.

  private def jsToHeaders(headers: js.Any): HttpMultiMap =
    if js.isUndefined(headers) || headers == null then
      HttpMultiMap.empty
    else
      val builder = HttpMultiMap.newBuilder
      val dict    = headers.asInstanceOf[js.Dictionary[js.Any]]
      dict.foreach { case (k, v) =>
        if js.Array.isArray(v) then
          v.asInstanceOf[js.Array[js.Any]].foreach { item =>
            builder.add(k, String.valueOf(item))
          }
        else
          builder.add(k, String.valueOf(v))
      }
      builder.result()

Comment on lines +57 to +65
def toRequest(payload: js.Dynamic): Request =
val method = HttpMethod.of(asString(payload.method)).getOrElse(HttpMethod.POST)
val uri = asString(payload.uri)
val body = asString(payload.body)
val base = Request(method, uri).withHeaders(jsToHeaders(payload.headers))
if body.isEmpty then
base
else
base.withJsonContent(body)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If payload is null or undefined, calling payload.method or payload.uri will throw a JS TypeError synchronously. Add a null/undefined check to make toRequest robust.

  def toRequest(payload: js.Dynamic): Request =
    if js.isUndefined(payload) || payload == null then
      Request(HttpMethod.POST, "")
    else
      val method = HttpMethod.of(asString(payload.method)).getOrElse(HttpMethod.POST)
      val uri    = asString(payload.uri)
      val body   = asString(payload.body)
      val base   = Request(method, uri).withHeaders(jsToHeaders(payload.headers))
      if body.isEmpty then
        base
      else
        base.withJsonContent(body)

Comment on lines +55 to +59
override def send(request: HttpRequest, config: HttpClientConfig): Rx[HttpResponse] =
val payload = ElectronIPC.toPayload(request)
val promise = bridge.applyDynamic("request")(payload).asInstanceOf[js.Promise[js.Dynamic]]
val future = promise.toFuture.map(ElectronIPC.toResponse)
Rx.future(future)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If bridge is null/undefined or calling request throws a synchronous exception, it will bypass the reactive stream's error handling and crash the caller. Wrap the call in a try-catch block and return a failed Rx to ensure idiomatic error propagation.

  override def send(request: HttpRequest, config: HttpClientConfig): Rx[HttpResponse] =
    try
      val payload = ElectronIPC.toPayload(request)
      val promise = bridge.applyDynamic("request")(payload).asInstanceOf[js.Promise[js.Dynamic]]
      val future  = promise.toFuture.map(ElectronIPC.toResponse)
      Rx.future(future)
    catch
      case e: Throwable =>
        Rx.future(scala.concurrent.Future.failed(e))
References
  1. When implementing cross-platform background tasks, catch all Throwable exceptions (including fatal ones) and record them as a failure result instead of rethrowing them.

Comment on lines +61 to +64
override def sendStreaming(request: HttpRequest, config: HttpClientConfig): Rx[Array[Byte]] =
throw NotImplementedError(
"Streaming responses are not supported over Electron IPC. Use a regular RPC call."
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Throwing NotImplementedError synchronously in sendStreaming bypasses the reactive stream's error handling. Return a failed Rx instead to allow callers to handle the error through the stream.

  override def sendStreaming(request: HttpRequest, config: HttpClientConfig): Rx[Array[Byte]] =
    Rx.future(scala.concurrent.Future.failed(NotImplementedError(
      "Streaming responses are not supported over Electron IPC. Use a regular RPC call."
    )))

xerial and others added 4 commits June 24, 2026 15:49
…end)

Caught by running the reference app in real Electron:
- Preload reference pointed at ../preload/index.js, but electron-vite emits an
  ESM preload as index.mjs under "type": "module". Fixed the path.
- ESM preloads require sandbox:false (contextBridge isolation is preserved),
  matching electron-vite's own template. Without it window.uniRPC is undefined.
- Declare pnpm.onlyBuiltDependencies (electron, esbuild) so `pnpm install`
  downloads the Electron binary instead of silently skipping the build script.
- Commit pnpm-lock.yaml for reproducible installs.

Verified headlessly against the built bundle: the counter round-trips
0 -> +10 -> 10 -> Reset -> 0, each value a live RPC call over Electron IPC.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Tailwind CSS (v4 via @tailwindcss/vite) to the renderer and restyles the
counter into a card layout: rounded panel, large indigo counter with a pop
animation on update, and colored +1 / +10 / Reset buttons with hover/active
states on a dark background.

Tailwind scans the Scala.js sources for class names via @source directives in
style.css (the className strings live in RendererApp.scala). CSP allows inline
styles since Vite/Tailwind inject <style> tags.

Verified: the built CSS bundle contains the utilities used by the Scala UI
(bg-indigo-600, text-7xl, rounded-2xl, hover:*, scale-110, …), and the styled
app launches in Electron with no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A standalone "Setup from Scratch" tutorial under docs/http that builds the
counter app from an empty directory: sbt projects, shared RPC service, main
process + IPC wiring, preload bridge, renderer, electron-vite build, run, and
package. Complements the existing /http/electron API reference (cross-linked
both ways) and distills the verified examples/electron-app.

Wired into both sidebars; uses __UNI_VERSION__ for the dependency line. Calls
out the gotchas the example hit (ESM preload + sandbox:false, securerandom,
async-only renderer). pnpm docs:build passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the ASCII renderer↔preload↔main diagrams in the Electron reference,
the tutorial, and the example README with Mermaid flowcharts.

Wires Mermaid into VitePress via vitepress-plugin-mermaid (withMermaid wraps the
config). GitHub renders the README's ```mermaid fence natively. pnpm docs:build
passes; the graphs are bundled into the page chunks and render client-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@xerial xerial merged commit 2531a1a into main Jun 25, 2026
13 checks passed
@xerial xerial deleted the feature/electron-desktop-app branch June 25, 2026 20:22
xerial added a commit that referenced this pull request Jun 25, 2026
## Summary

Follow-up to #610. The horizontal Mermaid flowcharts rendered **too
small** in VitePress's width-limited content column.

The renderer↔preload↔main exchange is really a **request/response
sequence**, so this switches the three diagrams to `sequenceDiagram`:
- Lays out **vertically** → fits the narrow content column (docs have
plenty of vertical space).
- Reads top-to-bottom in call order, making the IPC round-trip explicit
(request → `ipcRenderer.invoke` → dispatch → response → Promise).

No site-wide changes (no column widening, no zoom plugin) — the fix is
just the right diagram type for the content.

Applied to:
- `docs/http/electron.md` (reference)
- `docs/http/electron-tutorial.md` (tutorial)
- `examples/electron-app/README.md` (GitHub renders it natively)

## Verification

`pnpm docs:build` passes; `sequenceDiagram` and the (URL-encoded)
message text are bundled into the page chunks for client-side rendering.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant