Electron Tutorial: Setup from Scratch
A step-by-step walkthrough that builds a small Electron desktop app with Uni and Scala.js from an empty directory. The UI (renderer) calls a service in the main process using Uni's RPC, tunneled over Electron IPC — no HTTP server, no open ports.
By the end you'll have a counter window whose +1 / +10 / Reset buttons each make a real RPC call to the main process. For the API reference behind each piece, see Desktop Apps (Electron). The finished project is in examples/electron-app.
What you're building
Electron runs three contexts; Uni occupies two and a tiny hand-written script bridges the third:
Prerequisites
- JDK 11+ and
sbton yourPATH(the Scala.js Vite plugin shells out tosbt) - Node.js 20+ and
pnpm(or npm)
Step 1: the sbt build
Three Scala.js modules: a shared api, the main process, and the renderer. All emit ES modules (so Vite can bundle them) and export functions instead of running a main:
// build.sbt
val uniVersion = sys.props.getOrElse("uni.version", "2026.1.21")
val scala3 = "3.8.4"
ThisBuild / scalaVersion := scala3
lazy val commonSettings = Seq(
scalaVersion := scala3,
scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) },
scalaJSUseMainModuleInitializer := false,
libraryDependencies += "org.wvlet.uni" %%% "uni" % uniVersion
)
lazy val api = project.in(file("api")).enablePlugins(ScalaJSPlugin).settings(commonSettings)
lazy val main = project.in(file("main")).enablePlugins(ScalaJSPlugin).settings(commonSettings).dependsOn(api)
lazy val renderer = project.in(file("renderer")).enablePlugins(ScalaJSPlugin).settings(commonSettings).dependsOn(api)// project/plugins.sbt
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.22.0")// project/build.properties
sbt.version=1.12.13Step 2: the shared service
Define the RPC service once; both processes depend on api. Returning Rx[A] keeps calls asynchronous, which suits IPC:
// api/src/main/scala/example/api/CounterApi.scala
package example.api
import wvlet.uni.rx.Rx
case class CounterState(value: Int)
trait CounterApi:
def get(): Rx[CounterState]
def increment(amount: Int): Rx[CounterState]
def reset(): Rx[CounterState]Step 3: the main process
Implement the service and register it on Electron's ipcMain. ipcMain is passed in as a value — this Scala module never has to require("electron"), which keeps it free of bundler coupling:
// main/src/main/scala/example/main/MainProcess.scala
package example.main
import example.api.{CounterApi, CounterState}
import wvlet.uni.electron.ElectronRPCServer
import wvlet.uni.http.rpc.RPCRouter
import wvlet.uni.rx.Rx
import scala.scalajs.js
import scala.scalajs.js.annotation.JSExportTopLevel
class CounterApiImpl extends CounterApi:
private var value = 0
def get(): Rx[CounterState] = Rx.single(CounterState(value))
def increment(amount: Int): Rx[CounterState] = { value += amount; Rx.single(CounterState(value)) }
def reset(): Rx[CounterState] = { value = 0; Rx.single(CounterState(value)) }
object MainProcess:
@JSExportTopLevel("wireMainProcess")
def wireMainProcess(ipcMain: js.Dynamic): Unit =
ElectronRPCServer.serve(ipcMain, RPCRouter.of[CounterApi](CounterApiImpl()))The Electron main entry (plain JS) creates the window and hands ipcMain to the Scala module once the app is ready:
// src/main/index.js
import { app, BrowserWindow, ipcMain } from 'electron'
import { join } from 'node:path'
import { wireMainProcess } from 'scalajs:main.js'
function createWindow() {
const win = new BrowserWindow({
width: 480,
height: 380,
webPreferences: {
// electron-vite emits an ESM preload (.mjs) because package.json is "type": "module".
preload: join(__dirname, '../preload/index.mjs'),
contextIsolation: true,
nodeIntegration: false,
// ESM preloads require the sandbox off; the contextBridge boundary still isolates the renderer.
sandbox: false
}
})
if (process.env.ELECTRON_RENDERER_URL) win.loadURL(process.env.ELECTRON_RENDERER_URL)
else win.loadFile(join(__dirname, '../renderer/index.html'))
}
app.whenReady().then(() => {
wireMainProcess(ipcMain) // register the RPC services before opening any window
createWindow()
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() })
})
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })Step 4: the preload bridge
The single function the renderer transport expects. Context isolation stays on — the renderer never gets direct Node access:
// src/preload/index.js
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('uniRPC', {
request: (payload) => ipcRenderer.invoke('uni-rpc', payload)
})Step 5: the renderer
Call ElectronRenderer.install() once at startup; afterward every async client (including generated RPC stubs) rides over IPC. Here we build the client directly from the shared trait and render the UI with uni's reactive DOM toolkit (wvlet.uni.dom):
// renderer/src/main/scala/example/renderer/RendererApp.scala
package example.renderer
import example.api.{CounterApi, CounterState}
import wvlet.uni.dom.all.*
import wvlet.uni.dom.all.given
import wvlet.uni.electron.ElectronRenderer
import wvlet.uni.http.Http
import wvlet.uni.http.rpc.RPCClient
import wvlet.uni.rx.{Rx, RxVar}
import wvlet.uni.surface.Surface
import scala.language.implicitConversions
import scala.scalajs.js.annotation.JSExportTopLevel
object RendererApp:
@JSExportTopLevel("main")
def main(): Unit =
ElectronRenderer.install() // wire window.uniRPC as the HTTP channel factory
CounterUI().renderTo("app")
class CounterUI extends RxElement:
private val rpc = RPCClient.build(Surface.of[CounterApi], Surface.methodsOf[CounterApi])
private lazy val client = Http.client.newAsyncClient
private val count = Rx.variable(0)
override def render: RxElement = div(
h1("Uni Counter"),
p(count.map(_.toString)), // re-renders when count changes
button(
onclick -> { () => rpc.callAsync[CounterState](client, "increment", Seq(1)).run(show) },
"+1"
)
)
private def show(s: CounterState): Unit = count := s.value
override def onMount(node: Any): Unit =
rpc.callAsync[CounterState](client, "get", Seq.empty).run(show)The renderer HTML loads the Scala.js module and provides the mount point:
<!-- src/renderer/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Uni Electron Counter</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.js"></script>
</body>
</html>// src/renderer/index.js
import { main } from 'scalajs:main.js'
main()Styling
The example styles the renderer with Tailwind CSS v4 via @tailwindcss/vite, pointing @source at the Scala sources so Tailwind finds the cls class strings used in RendererApp.scala. It's optional — plain CSS works too.
Step 6: the build pipeline
electron-vite drives the three Vite builds (main / preload / renderer). @scala-js/vite-plugin-scalajs links a Scala.js project and exposes it as import 'scalajs:main.js'; its projectID picks the sbt project:
// electron.vite.config.mjs
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import scalaJSPlugin from '@scala-js/vite-plugin-scalajs'
export default defineConfig({
main: { plugins: [externalizeDepsPlugin(), scalaJSPlugin({ cwd: '.', projectID: 'main' })] },
preload: { plugins: [externalizeDepsPlugin()] },
renderer: { plugins: [scalaJSPlugin({ cwd: '.', projectID: 'renderer' })] }
})// package.json
{
"name": "uni-electron-example",
"type": "module",
"main": "out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"package": "electron-vite build && electron-builder"
},
"devDependencies": {
"@scala-js/vite-plugin-scalajs": "^1.0.0",
"electron": "^34.0.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"vite": "^5.4.11"
},
"pnpm": { "onlyBuiltDependencies": ["electron", "esbuild"] }
}Step 7: run and package
pnpm install
pnpm dev # compiles Scala.js, starts the renderer dev server, launches Electron (hot reload)Click the buttons — each is an RPC call to the main process, which owns the counter. To ship installers (dmg / nsis / AppImage via electron-builder):
pnpm package # → installers under dist/Gotchas worth knowing
scalajs-java-securerandom— Uni's Scala.js code reachesjava.security.SecureRandom(via ULID in the serialization layer). Recent Uni releases pull it in transitively; on older releases add it yourself, or linking fails with "non-existent class java.security.SecureRandom":scalalibraryDependencies += ("org.scala-js" %%% "scalajs-java-securerandom" % "1.0.0").cross(CrossVersion.for3Use2_13)ESM preload path — under
"type": "module", electron-vite emits the preload asindex.mjs. Pointpreload:at../preload/index.mjs, and setsandbox: false(ESM preloads don't load in a sandboxed renderer;contextIsolationstill isolates it).Renderer is async-only — a sandboxed renderer has no synchronous IPC, so use
Http.client.newAsyncClient.newSyncClientthrows.
Where to go next
- Desktop Apps (Electron) — the transport reference (channels, server, marshaling).
- RPC — defining services, generated clients, error handling.
examples/electron-app— the complete, runnable project this tutorial distills.
