Elixir Desktop Webview (EDW) wire protocol between the native host (server) and the BEAM client.
- TCP on
127.0.0.1(default). Port from--edw-port/ ini / ephemeral0. - Framing: 4-byte big-endian unsigned length + UTF-8 JSON body.
- Body: JSON-RPC 2.0 objects.
- Both peers may send requests (with
id) and notifications (noid). - After accept, the client MUST call
initializebefore other production methods.
Environment for the BEAM child (packaged mode): EDW_PORT, EDW_HOST (default 127.0.0.1).
Stdout discovery line (always, once listening):
listening <port>
Request:
{"jsonrpc":"2.0","id":1,"method":"window.open","params":{"title":"App","width":800,"height":600}}Success response:
{"jsonrpc":"2.0","id":1,"result":{"window_id":"w1","webview_id":"v1"}}Error response:
{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}Notification (no id):
{"jsonrpc":"2.0","method":"event.window.close_requested","params":{"window_id":"w1"}}| Code | Meaning |
|---|---|
| -32700 | Parse error |
| -32600 | Invalid request |
| -32601 | Method not found |
| -32602 | Invalid params |
| -32603 | Internal error |
| -32000 | Host / application error |
| -32001 | Not initialized |
| -32002 | Unknown resource id |
| -32003 | Test RPC disabled |
- Host listens and prints
listening <port>. - Optionally spawns BEAM (unless
--edw-no-beam). - Client connects and calls
initialize. - Client drives windows/menus/… ; host emits
event.*notifications and may send requests (e.g.permission.request) that the client must answer. - Default lifetime: host keeps listening after disconnect (
reconnect) in host-first packaged mode.--edw-lifetime=coupledexits the host when the client disconnects (and kills BEAM when the host exits in packaged mode). BEAM-first /--edw-no-beam(dev) always exits the host on client disconnect.
These rules are normative for every platform host. If macOS behavior and this section disagree, fix the host and keep this section as the contract.
- Opaque strings assigned by the host (e.g.
w1,v2,m3). Clients treat them as opaque; do not encode platform pointers in the id string for the wire. - One TCP connection owns many windows / webviews / menus / trays / icons / notifications. Do not require one process per window.
- After TCP accept, the first production call MUST be
initialize. Other methods →-32001. - When the RPC client goes away, or a host-owned BEAM process exits, the host
MUST destroy all session resources before a new client runs: trays,
windows, webviews, menus, icons, notifications, and permission policy.
After that reset,
initializeplustray.create/window.openmatches a first start (empty maps; no leftover status items or windows). Resource id counters MAY keep increasing. - On reconnect lifetime, when the client disconnects the host keeps
listening. It MUST still run the session reset above (do not keep native
windows or trays for the next BEAM). When a new client connects it MUST call
initializeagain.initializeMUST run the same session reset if any leftover resources remain (covers a new TCP client that replaces the old socket beforeonDisconnectruns). - A TCP replace (new client while the previous connection is cancelled) MUST reset session state only. It MUST NOT quit the host.
- Exception:
--edw-no-beam(BEAM-first/dev) still exits the host on a true disconnect with no new client — there is no host-owned BEAM to reconnect to. Reset session resources first so trays go away if host terminate is slow. - On coupled lifetime, client disconnect terminates the host; host exit terminates the BEAM child if the host spawned it. Reset session first.
- User/OS attempt to close a window MUST be vetoed by the native layer.
- Host emits
event.window.close_requestedwithwindow_id. - Host does not destroy the window until Elixir calls
window.close/window.destroy(or the process exits). window.close_vetoacknowledges the veto path for backends that need an explicit “we handled it” RPC; hosts may treat it as a no-op success.- Elixir /
Desktop.Windowdecides quit vs hide; the host must not callexitsolely because the last window received a close click.
webview.load_urlloads the given URL in that webview.- Attempts to open a new window / target=_blank SHOULD emit
event.webview.new_windowand open the URL via the OS default handler (system.open_urlbehavior), not create an unmanaged native window. - Context menu: default disabled after create;
webview.set_context_menutoggles when the engine allows. webview.rebuildreplaces the engine view inside the same window and returns a newwebview_id(old id becomes invalid).
- The macOS host installs a default
Editsubmenu on the main menu (Undo, Redo, Cut, Copy, Paste, Delete, Select All) with the standardCmd+Z,Cmd+Shift+Z,Cmd+X,Cmd+C,Cmd+V,Cmd+Aaccelerators. Actions are routed through the responder chain, so the first responder (typically the WKWebView's text-input view) handles them. Other platform hosts SHOULD install an equivalent default Edit menu so keyboard accelerators work in their web engines too (porting.md). menu.create/menu.updatetake a full DOM snapshot (not incremental diffs). Aftermenu.update, hosts MUST re-bind any tray that references thatmenu_id(Desktop.Menu mounts empty then updates on mount).- Item activation →
event.menu.clickwith theonclickattribute string from the DOM (may be empty). - Tray is a status/notification-area icon with an optional menu.
event.tray.clickis for icon clicks that are not menu item selections. menu.set_appleis macOS-specific. On other platforms returntrue(no-op).
icon.createaccepts filesystempathand/orpng_base64. Empty params MAY create a placeholder icon so callers can proceed.notification.showshould use the platform notification center when running as a real packaged app. CLI / unpackaged helpers SHOULD still attempt a visible fallback when the OS allows it (e.g. AppleScript on macOS) and MAY also log; they must still return anotification_id(E2E must not require a visible banner).
Applies to getUserMedia-style camera/microphone (and equivalents):
- Host checks
system.set_permission_policyfor{origin, type}. allow/deny→ answer the engine without prompting Elixir (OS permission dialogs such as TCC / Windows privacy may still appear).ask(default) → host sends JSON-RPC requestpermission.request; client replies{ "decision": "allow"|"deny"|"ask" }. A nestedaskmeans use the engine/OS prompt.test.permission.simulate(test RPC only) synthesizes apermission.requestwithout requiring real device hardware — required for CI.
Platform packaging notes (usage strings, manifests) live in packaging.md.
Hosts MAY accept only one concurrent TCP client (macOS does). A new connection
MAY replace the previous one; document if you support multiple clients. Replace
MUST reset session UI (see initialize and reconnect) and MUST NOT treat the
replaced socket as a host-quit signal.
Params: { "client": "desktop_webview", "version": "0.1.0" } (informational).
Result:
{
"protocol_version": 1,
"platform": "macos",
"capabilities": {
"window": true,
"webview": true,
"menu": true,
"tray": true,
"notification": true,
"permission": true,
"media": true,
"test_rpc": false
}
}| Method | Params | Result |
|---|---|---|
window.open |
title, width, height, min_width?, min_height?, icon_id? |
{window_id, webview_id} |
window.close |
window_id |
true |
window.show |
window_id, show? |
true |
window.hide |
window_id |
true |
window.set_title |
window_id, title |
true |
window.set_min_size |
window_id, width, height |
true |
window.set_icon |
window_id, icon_id |
true |
window.set_menubar |
window_id, menu_id |
true |
window.iconize |
window_id, iconize |
true |
window.shown |
window_id |
boolean |
window.active |
window_id |
boolean |
window.raise |
window_id |
true |
window.destroy |
window_id |
true |
window.close_veto |
window_id |
true |
Events: event.window.close_requested, event.window.focus, event.window.blur.
| Method | Params | Result |
|---|---|---|
webview.load_url |
webview_id, url |
true |
webview.reload |
webview_id |
true |
webview.current_url |
webview_id |
string | null |
webview.rebuild |
window_id |
{webview_id} |
webview.set_context_menu |
webview_id, enabled |
true |
Events: event.webview.new_window (url), event.webview.error, event.webview.finished.
| Method | Params | Result |
|---|---|---|
menu.create |
kind: "menubar" | "popup", dom |
{menu_id} |
menu.update |
menu_id, dom |
true |
menu.destroy |
menu_id |
true |
tray.create |
icon_id?, menu_id? |
{tray_id} |
tray.set_icon |
tray_id, icon_id |
true |
tray.set_menu |
tray_id, menu_id |
true |
tray.destroy |
tray_id |
true |
menu.set_apple |
app_name, window_id? |
true |
dom is a JSON encoding of the Desktop.Menu tree:
{"tag":"menubar","attrs":{},"children":[
{"tag":"menu","attrs":{"label":"File"},"children":[
{"tag":"item","attrs":{"onclick":"quit"},"children":["Quit"]},
{"tag":"hr","attrs":{},"children":[]}
]}
]}Events: event.menu.click (menu_id, onclick), event.tray.click.
| Method | Params | Result |
|---|---|---|
dialog.choose_file |
title?, default_path? |
{path} or null if cancelled |
dialog.choose_directory |
title?, default_path? |
{path} or null |
dialog.prompt |
title, message, default_value? |
{value} or null |
macOS: NSOpenPanel / NSAlert. Linux/Windows: may return error -32004 until ported.
AppKit dialogs run on the host main thread and block the RPC until dismissed.
| Method | Params | Result |
|---|---|---|
notification.show |
id?, title, message, timeout?, type? |
{notification_id} |
notification.close |
notification_id |
true |
icon.create |
path or png_base64 |
{icon_id} |
icon.destroy |
icon_id |
true |
system.open_url |
url |
true |
system.locale |
— | string | null |
system.os_description |
— | string |
system.prepare_quit |
— | true (host will exit after client disconnect) |
system.set_permission_policy |
origin, camera/microphone: `"allow" |
"deny" |
Events: event.notification.click, event.notification.dismiss,
event.system.open_url, event.system.open_file, event.system.reopen,
event.system.quit.
- macOS Quit menu / Cmd+Q / Dock Quit MUST NOT tear down only the host while leaving BEAM running.
- Host intercepts terminate, emits
event.system.quit, and waits (terminateLater) for the client to disconnect (Elixir should callDesktop.Window.quit/Desktop.OS.shutdown). - After client disconnect (or a short fallback timeout) the host finishes quitting. Packaged mode also terminates any BEAM child it spawned.
- Elixir
EventBridgemapsevent.system.quit→Desktop.Window.quit/0.
Host → client request:
{"jsonrpc":"2.0","id":42,"method":"permission.request","params":{
"origin":"http://127.0.0.1:4000","type":"microphone","webview_id":"v1"
}}Client response result: { "decision": "allow" | "deny" | "ask" }.
allow/deny: host answers WebKit without further UI (TCC may still apply).ask: host uses the OS / WebKit prompt.
Events: event.permission.changed.
Enabled only when the host was started with --edw-test-rpc.
Release binaries used by apps must leave this off. If called while disabled → -32003.
| Method | Params | Result / effect |
|---|---|---|
test.ping |
— | "pong" |
test.echo |
any | same params |
test.capabilities |
— | capability map |
test.window.list |
— | [{window_id, webview_id, title, url}] |
test.tray.list |
— | [{tray_id}] |
test.session.reset |
— | true — runs the same session wipe as BEAM stop (test RPC only) |
test.menu.list |
— | [{title, items:[{label, key, modifiers, action}]}] snapshot of the host's main menu. macOS-only; other hosts return -32601 until they implement the equivalent. |
test.webview.eval |
webview_id, script |
eval result (JSON-compatible) |
test.permission.simulate |
origin, type |
triggers permission.request |
test.disconnect |
— | host closes the TCP connection |
test.crash |
— | host process exits non-zero (E2E only) |
Production code paths must not call test.*.