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
26 changes: 26 additions & 0 deletions docs/desktop-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,32 @@ DesktopWebview.Backend.capabilities()
# }
```

## Event bridge

`DesktopWebview.EventBridge` owns the Transport event subscription when the
backend is active. It translates host notifications into elixir-desktop messages:

| Host event | Delivery |
|------------|----------|
| `event.window.close_requested` | `GenServer.cast(window, :close_window)` |
| `event.window.focus` | `GenServer.cast(window, :frame_activated)` |
| `event.system.open_url` | `Desktop.Env.notify_subscribers({:open_url, [url]})` |
| `event.system.open_file` | `Desktop.Env.notify_subscribers({:open_file, [path]})` |
| `event.system.reopen` | `{:reopen_app, []}` to `Desktop.Env` |
| `event.menu.click` | `GenServer.cast(menu, {:trigger_event, onclick})` |
| `event.webview.new_window` | `system.open_url` (external browser) |

Do **not** subscribe `Desktop.Env` directly to Transport — raw `{:edw_event, ...}`
messages are not in the Env contract.

## Dialogs

```elixir
DesktopWebview.Dialog.choose_file(title: "Pick a file", default_path: path)
DesktopWebview.Dialog.choose_directory(title: "Pick a folder")
DesktopWebview.Dialog.prompt("Title", "Message", "default")
```

## Permissions

Hybrid policy (see `docs/protocol.md`): set defaults with
Expand Down
9 changes: 6 additions & 3 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,13 @@ DesktopWebView --edw-port=0 -- --foo bar

## Lifetime

- **`reconnect` (default):** host keeps listening after BEAM/client disconnect.
Elixir may reconnect and call `initialize` again. Window state may be reset
depending on host implementation; E2E asserts documented behavior.
- **`reconnect` (default for packaged host-first):** host keeps listening after
BEAM/client disconnect. Elixir may reconnect and call `initialize` again.
Window state may be reset depending on host implementation; E2E asserts
documented behavior.
- **`coupled`:** client disconnect → host exits; host exit → BEAM child is terminated.
- **`--edw-no-beam` (dev):** host exits when the Elixir client disconnects, even
if lifetime is `reconnect` — the VM owns the host process.

## Binaries

Expand Down
37 changes: 33 additions & 4 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ Notification (no `id`):
3. Client connects and calls `initialize`.
4. Client drives windows/menus/… ; host emits `event.*` notifications and may send
requests (e.g. `permission.request`) that the client must answer.
5. Default lifetime: host keeps listening after disconnect (`reconnect`).
`--edw-lifetime=coupled` exits the host when the client disconnects (and kills
BEAM when the host exits in packaged mode).
5. Default lifetime: host keeps listening after disconnect (`reconnect`) in
host-first packaged mode. `--edw-lifetime=coupled` exits 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.

## Behavioral semantics

Expand All @@ -91,6 +92,8 @@ section disagree, **fix the host** and keep this section as the contract.
MUST call `initialize` again. The host MAY reset RPC session state (pending
request ids); it SHOULD keep existing window/webview resources addressable by
the same ids until the client destroys them (macOS currently keeps them).
**Exception:** `--edw-no-beam` (BEAM-first/dev) still exits the host on
disconnect — there is no host-owned BEAM to reconnect to.
- On **coupled** lifetime, client disconnect terminates the host; host exit
terminates the BEAM child if the host spawned it.

Expand Down Expand Up @@ -119,6 +122,8 @@ section disagree, **fix the host** and keep this section as the contract.
### Menus and tray

- `menu.create` / `menu.update` take a full DOM snapshot (not incremental diffs).
After `menu.update`, hosts MUST re-bind any tray that references that `menu_id`
(Desktop.Menu mounts empty then updates on mount).
- Item activation → `event.menu.click` with the `onclick` attribute string from
the DOM (may be empty).
- Tray is a status/notification-area icon with an optional menu.
Expand Down Expand Up @@ -237,6 +242,17 @@ Events: `event.webview.new_window` (`url`), `event.webview.error`, `event.webvie

Events: `event.menu.click` (`menu_id`, `onclick`), `event.tray.click`.

### Dialog

| 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.

### Notification / media / system

| Method | Params | Result |
Expand All @@ -248,10 +264,23 @@ Events: `event.menu.click` (`menu_id`, `onclick`), `event.tray.click`.
| `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"|"ask"` | `true` |

Events: `event.notification.click`, `event.notification.dismiss`,
`event.system.open_url`, `event.system.open_file`, `event.system.reopen`.
`event.system.open_url`, `event.system.open_file`, `event.system.reopen`,
`event.system.quit`.

### Application 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 call
`Desktop.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 `EventBridge` maps `event.system.quit` → `Desktop.Window.quit/0`.

### Permissions (hybrid)

Expand Down
3 changes: 3 additions & 0 deletions docs/status/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ manual-only with justification).
| Permission policy hybrid | done | |
| Microphone in webview | done | E2E via test RPC + fixture |
| Camera in webview | done | E2E via test RPC + fixture |
| Dialog choose file/dir | done | `NSOpenPanel` (manual; blocks RPC) |
| Dialog prompt | done | `NSAlert` + text field (manual) |
| EventBridge Env/Window/Menu | done | Elixir unit coverage |
| Test RPC channel | done | `--edw-test-rpc` |
| Universal binary in priv | done | CI |
| Ad-hoc codesign | done | |
127 changes: 96 additions & 31 deletions lib/desktop_webview/backend.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ defmodule DesktopWebview.Backend do
@behaviour Desktop.Platform.Media
@behaviour Desktop.Platform.System

alias DesktopWebview.{Launcher, Transport}
alias DesktopWebview.{EventBridge, Launcher, Transport}

@impl true
def capabilities do
Expand All @@ -28,6 +28,7 @@ defmodule DesktopWebview.Backend do
@impl true
def init_env do
Transport.ensure_started()
EventBridge.ensure_started()

case connect_or_launch() do
:ok ->
Expand All @@ -54,10 +55,12 @@ defmodule DesktopWebview.Backend do
Application.get_env(:desktop_webview, :auto_launch, true) ->
case Launcher.start(
test_rpc: Application.get_env(:desktop_webview, :test_rpc, false),
lifetime: Application.get_env(:desktop_webview, :lifetime, :reconnect)
lifetime: Application.get_env(:desktop_webview, :lifetime, :coupled)
) do
{:ok, launcher} ->
Transport.attach_launcher(launcher)
# Desktop apps: if the host process dies, halt BEAM (orphan prevention).
Application.put_env(:desktop_webview, :halt_on_host_exit, true)

case Transport.connect("127.0.0.1", launcher.listen_port) do
{:ok, _} -> :ok
Expand All @@ -75,7 +78,8 @@ defmodule DesktopWebview.Backend do

@impl true
def subscribe_events do
Transport.subscribe(self())
# EventBridge owns the Transport subscription and fans out Env/Window/Menu messages.
EventBridge.ensure_started()
:ok
end

Expand Down Expand Up @@ -148,6 +152,7 @@ defmodule DesktopWebview.Backend do
case Transport.call("window.open", params) do
{:ok, %{"window_id" => wid, "webview_id" => vid}} ->
Process.put({:edw_webview, wid}, vid)
EventBridge.register_window(wid, self())
{:ok, wid, vid}

{:ok, other} ->
Expand All @@ -165,16 +170,19 @@ defmodule DesktopWebview.Backend do
end

@impl true
def connect(frame, event, fun) do
# Store in process dictionary for Env-style fanout; Window GenServer also subscribes.
handlers = Process.get({:edw_handlers, frame}, %{})
Process.put({:edw_handlers, frame}, Map.put(handlers, event, fun))
def connect(frame, _event, _fun) do
EventBridge.register_window(frame, self())
:ok
end

@impl true
def show(frame, opts) do
_ = Transport.call("window.show", %{"window_id" => frame, "show" => Keyword.get(opts, :show, true)})
_ =
Transport.call("window.show", %{
"window_id" => frame,
"show" => Keyword.get(opts, :show, true)
})

:ok
end

Expand All @@ -192,7 +200,9 @@ defmodule DesktopWebview.Backend do

@impl true
def set_min_size(frame, {w, h}) do
_ = Transport.call("window.set_min_size", %{"window_id" => frame, "width" => w, "height" => h})
_ =
Transport.call("window.set_min_size", %{"window_id" => frame, "width" => w, "height" => h})

:ok
end

Expand Down Expand Up @@ -253,7 +263,10 @@ defmodule DesktopWebview.Backend do

@impl true
def new_menubar do
case Transport.call("menu.create", %{"kind" => "menubar", "dom" => %{"tag" => "menubar", "attrs" => %{}, "children" => []}}) do
case Transport.call("menu.create", %{
"kind" => "menubar",
"dom" => %{"tag" => "menubar", "attrs" => %{}, "children" => []}
}) do
{:ok, %{"menu_id" => id}} -> {:menu, id}
_ -> {:menu, nil}
end
Expand Down Expand Up @@ -330,8 +343,17 @@ defmodule DesktopWebview.Backend do

@impl true
def put_webview_backend(name) do
if Process.whereis(Desktop.Env) do
Desktop.Env.put(:webview_backend, name)
# Desktop.Env.init/1 calls init_env/0, so a sync GenServer.call here would be a
# self-call. Defer when we are still inside Env.init.
case Process.whereis(Desktop.Env) do
nil ->
:ok

pid when pid == self() ->
spawn(fn -> Desktop.Env.put(:webview_backend, name) end)

_pid ->
Desktop.Env.put(:webview_backend, name)
end

:ok
Expand All @@ -352,27 +374,32 @@ defmodule DesktopWebview.Backend do
end

def notification_show({:notification, default_title, type}, message, timeout, title) do
_ =
Transport.call("notification.show", %{
"title" => to_string(title || default_title),
"message" => to_string(message),
"timeout" => timeout,
"type" => to_string(type)
})

:ok
register_notification_show(%{
"title" => to_string(title || default_title),
"message" => to_string(message),
"timeout" => timeout,
"type" => to_string(type)
})
end

def notification_show(id, message, timeout, title) when is_binary(id) do
_ =
Transport.call("notification.show", %{
"id" => id,
"title" => to_string(title || ""),
"message" => to_string(message),
"timeout" => timeout
})
register_notification_show(%{
"id" => id,
"title" => to_string(title || ""),
"message" => to_string(message),
"timeout" => timeout
})
end

:ok
defp register_notification_show(params) do
case Transport.call("notification.show", params) do
{:ok, %{"notification_id" => nid}} when is_binary(nid) ->
EventBridge.register_notification(nid, self())
:ok

_ ->
:ok
end
end

@impl true
Expand All @@ -388,8 +415,8 @@ defmodule DesktopWebview.Backend do
# —— Media ——

@impl true
def load_image(_app, path) do
abs = Path.expand(path)
def load_image(app, path) do
abs = resolve_priv_path(app, path)

case Transport.call("icon.create", %{"path" => abs}) do
{:ok, %{"icon_id" => id}} -> {:ok, {:image, id}}
Expand All @@ -415,6 +442,22 @@ defmodule DesktopWebview.Backend do
end
end

defp resolve_priv_path(app, path) when is_binary(path) do
expanded = Path.expand(path)

cond do
Path.type(path) == :absolute ->
path

File.exists?(expanded) ->
expanded

true ->
# Desktop.Window passes filenames like "diode.png" (same as wx backend).
Application.app_dir(app, Path.join("priv", path))
end
end

@impl true
def media_destroy({:image, id}) do
_ = Transport.call("icon.destroy", %{"icon_id" => id})
Expand All @@ -433,6 +476,28 @@ defmodule DesktopWebview.Backend do
def object_type({:icon, _}), do: :wxIcon
def object_type(_), do: :unknown

def create_icon_from_png_base64(b64) when is_binary(b64) do
case Transport.call("icon.create", %{"png_base64" => b64}) do
{:ok, %{"icon_id" => id}} -> {:ok, {:icon, id}}
{:error, reason} -> {:error, reason}
end
end

@doc """
Enable or disable the webview context menu for the content handle returned by `attach/1`.
"""
def set_context_menu(webview, enabled) when is_binary(webview) do
_ =
Transport.call("webview.set_context_menu", %{
"webview_id" => webview,
"enabled" => !!enabled
})

:ok
end

def set_context_menu(_, _), do: :ok

defp icon_id({:icon, id}), do: id
defp icon_id({:image, id}), do: id
defp icon_id(id) when is_binary(id), do: id
Expand Down
Loading
Loading