From 0d2b311769d4318f6379cd0523812bf1bb85cdb7 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 16:46:07 +0800 Subject: [PATCH 01/10] Add EventBridge and native dialog RPCs for elixir-desktop apps. Translate host events into Desktop.Env/Window/Menu messages, and expose dialog.choose_file/choose_directory/prompt on macOS (Linux stubs with -32004). Co-authored-by: Cursor --- docs/desktop-integration.md | 26 +++ docs/protocol.md | 11 ++ docs/status/macos.md | 3 + lib/desktop_webview/backend.ex | 90 +++++++--- lib/desktop_webview/dialog.ex | 55 ++++++ lib/desktop_webview/event_bridge.ex | 162 ++++++++++++++++++ lib/desktop_webview/menu/adapter.ex | 16 +- lib/desktop_webview/transport.ex | 18 +- native/linux/src/host_controller.cpp | 5 + .../DesktopWebView/HostController.swift | 43 +++++ test/dialog_test.exs | 11 ++ test/e2e/e2e_test.exs | 6 +- test/event_bridge_test.exs | 42 +++++ 13 files changed, 454 insertions(+), 34 deletions(-) create mode 100644 lib/desktop_webview/dialog.ex create mode 100644 lib/desktop_webview/event_bridge.ex create mode 100644 test/dialog_test.exs create mode 100644 test/event_bridge_test.exs diff --git a/docs/desktop-integration.md b/docs/desktop-integration.md index 5dda6c1..e530b28 100644 --- a/docs/desktop-integration.md +++ b/docs/desktop-integration.md @@ -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 diff --git a/docs/protocol.md b/docs/protocol.md index cd0cbf5..483f6fb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -237,6 +237,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 | diff --git a/docs/status/macos.md b/docs/status/macos.md index 21eccbb..e8af52f 100644 --- a/docs/status/macos.md +++ b/docs/status/macos.md @@ -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 | | diff --git a/lib/desktop_webview/backend.ex b/lib/desktop_webview/backend.ex index fd3b7a2..3b33143 100644 --- a/lib/desktop_webview/backend.ex +++ b/lib/desktop_webview/backend.ex @@ -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 @@ -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 -> @@ -75,7 +76,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 @@ -148,6 +150,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} -> @@ -165,16 +168,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 @@ -192,7 +198,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 @@ -253,7 +261,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 @@ -352,27 +363,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 @@ -433,6 +449,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 diff --git a/lib/desktop_webview/dialog.ex b/lib/desktop_webview/dialog.ex new file mode 100644 index 0000000..4183250 --- /dev/null +++ b/lib/desktop_webview/dialog.ex @@ -0,0 +1,55 @@ +defmodule DesktopWebview.Dialog do + @moduledoc """ + Native file / directory / text prompt dialogs via the DesktopWebView host. + """ + + alias DesktopWebview.Transport + + @timeout 600_000 + + def choose_file(opts \\ []) do + params = + %{} + |> maybe_put("title", opts[:title]) + |> maybe_put("default_path", opts[:default_path]) + + case Transport.call("dialog.choose_file", params, @timeout) do + {:ok, %{"path" => path}} when is_binary(path) -> path + {:ok, nil} -> nil + {:ok, _} -> nil + {:error, reason} -> {:error, reason} + end + end + + def choose_directory(opts \\ []) do + params = + %{} + |> maybe_put("title", opts[:title]) + |> maybe_put("default_path", opts[:default_path]) + + case Transport.call("dialog.choose_directory", params, @timeout) do + {:ok, %{"path" => path}} when is_binary(path) -> path + {:ok, nil} -> nil + {:ok, _} -> nil + {:error, reason} -> {:error, reason} + end + end + + def prompt(title, message, default \\ "") do + params = %{ + "title" => to_string(title), + "message" => to_string(message), + "default_value" => to_string(default) + } + + case Transport.call("dialog.prompt", params, @timeout) do + {:ok, %{"value" => value}} when is_binary(value) -> value + {:ok, nil} -> nil + {:ok, _} -> nil + {:error, reason} -> {:error, reason} + end + end + + defp maybe_put(map, _key, nil), do: map + defp maybe_put(map, key, value), do: Map.put(map, key, to_string(value)) +end diff --git a/lib/desktop_webview/event_bridge.ex b/lib/desktop_webview/event_bridge.ex new file mode 100644 index 0000000..488995f --- /dev/null +++ b/lib/desktop_webview/event_bridge.ex @@ -0,0 +1,162 @@ +defmodule DesktopWebview.EventBridge do + @moduledoc """ + Translates host `event.*` notifications into `Desktop.Env` / `Desktop.Window` / + `Desktop.Menu` messages so apps keep working without raw `{:edw_event, ...}` handling. + """ + use GenServer + + alias DesktopWebview.Transport + + @name __MODULE__ + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: @name) + end + + def ensure_started do + case Process.whereis(@name) do + nil -> + {:ok, _} = start_link([]) + :ok + + _ -> + :ok + end + end + + def register_window(window_id, pid) when is_binary(window_id) and is_pid(pid) do + ensure_started() + GenServer.cast(@name, {:register_window, window_id, pid}) + end + + def register_menu(menu_id, pid) when is_binary(menu_id) and is_pid(pid) do + ensure_started() + GenServer.cast(@name, {:register_menu, menu_id, pid}) + end + + def register_notification(notification_id, pid) + when is_binary(notification_id) and is_pid(pid) do + ensure_started() + GenServer.cast(@name, {:register_notification, notification_id, pid}) + end + + @impl true + def init(_opts) do + Transport.ensure_started() + Transport.subscribe(self()) + + {:ok, + %{ + windows: %{}, + menus: %{}, + notifications: %{} + }} + end + + @impl true + def handle_cast({:register_window, window_id, pid}, state) do + Process.monitor(pid) + {:noreply, %{state | windows: Map.put(state.windows, window_id, pid)}} + end + + def handle_cast({:register_menu, menu_id, pid}, state) do + Process.monitor(pid) + {:noreply, %{state | menus: Map.put(state.menus, menu_id, pid)}} + end + + def handle_cast({:register_notification, notification_id, pid}, state) do + Process.monitor(pid) + {:noreply, %{state | notifications: Map.put(state.notifications, notification_id, pid)}} + end + + @impl true + def handle_info({:edw_event, method, params}, state) do + {:noreply, dispatch(method, params, state)} + end + + def handle_info({:DOWN, _ref, :process, pid, _}, state) do + {:noreply, + %{ + state + | windows: Map.reject(state.windows, fn {_k, v} -> v == pid end), + menus: Map.reject(state.menus, fn {_k, v} -> v == pid end), + notifications: Map.reject(state.notifications, fn {_k, v} -> v == pid end) + }} + end + + def handle_info(_other, state), do: {:noreply, state} + + defp dispatch("event.window.close_requested", %{"window_id" => wid}, state) do + if pid = Map.get(state.windows, wid), do: GenServer.cast(pid, :close_window) + state + end + + defp dispatch("event.window.focus", %{"window_id" => wid}, state) do + if pid = Map.get(state.windows, wid), do: GenServer.cast(pid, :frame_activated) + state + end + + defp dispatch("event.system.open_url", params, state) do + url = params["url"] || params["path"] + + if is_binary(url) and Process.whereis(Desktop.Env) do + Desktop.Env.notify_subscribers({:open_url, [url]}) + end + + state + end + + defp dispatch("event.system.open_file", params, state) do + path = params["path"] || params["url"] + + if is_binary(path) and Process.whereis(Desktop.Env) do + Desktop.Env.notify_subscribers({:open_file, [path]}) + end + + state + end + + defp dispatch("event.system.reopen", _params, state) do + if env = Process.whereis(Desktop.Env) do + send(env, {:reopen_app, []}) + end + + state + end + + defp dispatch("event.menu.click", %{"menu_id" => menu_id, "onclick" => onclick}, state) do + if pid = Map.get(state.menus, menu_id) do + GenServer.cast(pid, {:trigger_event, onclick}) + end + + state + end + + defp dispatch("event.notification.click", %{"notification_id" => id}, state) do + notify_notification(state, id, :click) + end + + defp dispatch("event.notification.dismiss", %{"notification_id" => id}, state) do + notify_notification(state, id, :dismiss) + end + + defp dispatch("event.webview.new_window", params, state) do + url = params["url"] + + if is_binary(url) do + _ = Transport.call("system.open_url", %{"url" => url}) + end + + state + end + + defp dispatch(_method, _params, state), do: state + + defp notify_notification(state, id, action) do + if pid = Map.get(state.notifications, id) do + send(pid, {:edw_notification, id, action}) + end + + state + end +end diff --git a/lib/desktop_webview/menu/adapter.ex b/lib/desktop_webview/menu/adapter.ex index 3efe46f..fb5ca1b 100644 --- a/lib/desktop_webview/menu/adapter.ex +++ b/lib/desktop_webview/menu/adapter.ex @@ -80,6 +80,14 @@ defmodule DesktopWebview.Menu.Adapter do end end + case {result, adapter.menu_pid} do + {{:menu, id}, pid} when is_binary(id) and is_pid(pid) -> + DesktopWebview.EventBridge.register_menu(id, pid) + + _ -> + :ok + end + %{adapter | menubar: result, dom: dom} end @@ -114,7 +122,9 @@ defmodule DesktopWebview.Menu.Adapter do end def dom_to_json(list) when is_list(list), do: Enum.map(list, &dom_to_json/1) - def dom_to_json(other), do: %{"tag" => "unknown", "attrs" => %{}, "children" => [to_string(other)]} + + def dom_to_json(other), + do: %{"tag" => "unknown", "attrs" => %{}, "children" => [to_string(other)]} defp child_text(t) when is_binary(t), do: t defp child_text(t), do: to_string(t) @@ -126,7 +136,9 @@ defmodule DesktopWebview.Menu.Adapter do end) end - defp attrs_to_map(attrs) when is_map(attrs), do: Map.new(attrs, fn {k, v} -> {to_string(k), to_string(v)} end) + defp attrs_to_map(attrs) when is_map(attrs), + do: Map.new(attrs, fn {k, v} -> {to_string(k), to_string(v)} end) + defp attrs_to_map(_), do: %{} defp icon_id({:icon, id}), do: id diff --git a/lib/desktop_webview/transport.ex b/lib/desktop_webview/transport.ex index 4047543..0e6820e 100644 --- a/lib/desktop_webview/transport.ex +++ b/lib/desktop_webview/transport.ex @@ -63,16 +63,24 @@ defmodule DesktopWebview.Transport do def handle_call({:connect, host, port}, _from, state) do if state.socket, do: :gen_tcp.close(state.socket) - case :gen_tcp.connect(String.to_charlist(host), port, [:binary, active: true, packet: 4], 5_000) do + case :gen_tcp.connect( + String.to_charlist(host), + port, + [:binary, active: true, packet: 4], + 5_000 + ) do {:ok, socket} -> state = %{state | socket: socket, pending: %{}, initialized: false} id = state.next_id :ok = - send_json(socket, Codec.request(id, "initialize", %{ - "client" => "desktop_webview", - "version" => "0.1.0" - })) + send_json( + socket, + Codec.request(id, "initialize", %{ + "client" => "desktop_webview", + "version" => "0.1.0" + }) + ) case recv_result(socket, id, 5_000) do {:ok, result} -> diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp index 5f55917..fb0190e 100644 --- a/native/linux/src/host_controller.cpp +++ b/native/linux/src/host_controller.cpp @@ -805,6 +805,11 @@ JsonNode* HostController::dispatch(const std::string& method, JsonNode* params) return jsonutil::bool_node(true); } + if (method == "dialog.choose_file" || method == "dialog.choose_directory" || + method == "dialog.prompt") { + throw HostError{-32004, "dialog RPCs not implemented on Linux yet"}; + } + throw HostError{-32601, "Method not found: " + method}; } diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 2e68030..4d25b64 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -350,11 +350,54 @@ final class HostController: NSObject { permissionPolicy[origin] = map return .bool(true) + case "dialog.choose_file": + return dialogChoose(params, directories: false) + case "dialog.choose_directory": + return dialogChoose(params, directories: true) + case "dialog.prompt": + return dialogPrompt(params) + default: throw HostError(-32601, "Method not found: \(method)") } } + private func dialogChoose(_ params: JSONValue?, directories: Bool) -> JSONValue { + let panel = NSOpenPanel() + panel.canChooseFiles = !directories + panel.canChooseDirectories = directories + panel.allowsMultipleSelection = false + panel.canCreateDirectories = directories + if let title = params?["title"]?.stringValue { + panel.message = title + panel.title = title + } + if let path = params?["default_path"]?.stringValue, !path.isEmpty { + panel.directoryURL = URL(fileURLWithPath: path, isDirectory: true) + } + let result = panel.runModal() + if result == .OK, let url = panel.url { + return .object(["path": .string(url.path)]) + } + return .null + } + + private func dialogPrompt(_ params: JSONValue?) -> JSONValue { + let alert = NSAlert() + alert.messageText = params?["title"]?.stringValue ?? "" + alert.informativeText = params?["message"]?.stringValue ?? "" + alert.addButton(withTitle: "OK") + alert.addButton(withTitle: "Cancel") + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + field.stringValue = params?["default_value"]?.stringValue ?? "" + alert.accessoryView = field + let response = alert.runModal() + if response == .alertFirstButtonReturn { + return .object(["value": .string(field.stringValue)]) + } + return .null + } + private func handleTest(_ method: String, params: JSONValue?, id: JSONValue?) -> JSONRPC.Response? { switch method { case "test.ping": diff --git a/test/dialog_test.exs b/test/dialog_test.exs new file mode 100644 index 0000000..e339281 --- /dev/null +++ b/test/dialog_test.exs @@ -0,0 +1,11 @@ +defmodule DesktopWebview.DialogTest do + use ExUnit.Case, async: true + + test "module exports choose_file, choose_directory, prompt" do + {:module, _} = Code.ensure_loaded(DesktopWebview.Dialog) + assert function_exported?(DesktopWebview.Dialog, :choose_file, 1) + assert function_exported?(DesktopWebview.Dialog, :choose_directory, 1) + assert function_exported?(DesktopWebview.Dialog, :prompt, 2) + assert function_exported?(DesktopWebview.Dialog, :prompt, 3) + end +end diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index deb66b7..63123da 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -14,6 +14,7 @@ defmodule DesktopWebview.E2ETest do end {:ok, launcher} = Launcher.start(test_rpc: true, lifetime: :reconnect) + on_exit(fn -> # Best-effort kill of host process if is_port(launcher.port) and Port.info(launcher.port) do @@ -77,7 +78,9 @@ defmodule DesktopWebview.E2ETest do assert {:ok, list} = Transport.call("test.window.list", %{}) assert Enum.any?(list, &(&1["window_id"] == wid)) - assert {:ok, true} = Transport.call("window.set_title", %{"window_id" => wid, "title" => "E2E2"}) + assert {:ok, true} = + Transport.call("window.set_title", %{"window_id" => wid, "title" => "E2E2"}) + assert {:ok, true} = Transport.call("window.raise", %{"window_id" => wid}) assert {:ok, true} = Transport.call("window.hide", %{"window_id" => wid}) assert {:ok, true} = Transport.call("window.show", %{"window_id" => wid, "show" => true}) @@ -109,6 +112,7 @@ defmodule DesktopWebview.E2ETest do Transport.call("menu.create", %{"kind" => "menubar", "dom" => dom}) assert {:ok, %{"icon_id" => iid}} = Transport.call("icon.create", %{}) + assert {:ok, %{"tray_id" => tid}} = Transport.call("tray.create", %{"icon_id" => iid, "menu_id" => mid}) diff --git a/test/event_bridge_test.exs b/test/event_bridge_test.exs new file mode 100644 index 0000000..d7e1a90 --- /dev/null +++ b/test/event_bridge_test.exs @@ -0,0 +1,42 @@ +defmodule DesktopWebview.EventBridgeTest do + use ExUnit.Case, async: false + + alias DesktopWebview.EventBridge + + setup do + # Isolate from a previously started named EventBridge in the VM. + if pid = Process.whereis(EventBridge) do + Process.exit(pid, :kill) + # Wait for name free + Process.sleep(20) + end + + {:ok, bridge} = EventBridge.start_link([]) + %{bridge: bridge} + end + + test "close_requested casts :close_window to registered window", %{bridge: bridge} do + EventBridge.register_window("w1", self()) + send(bridge, {:edw_event, "event.window.close_requested", %{"window_id" => "w1"}}) + assert_receive {:"$gen_cast", :close_window}, 500 + end + + test "focus casts :frame_activated", %{bridge: bridge} do + EventBridge.register_window("w1", self()) + send(bridge, {:edw_event, "event.window.focus", %{"window_id" => "w1"}}) + assert_receive {:"$gen_cast", :frame_activated}, 500 + end + + test "menu.click triggers menu event", %{bridge: bridge} do + EventBridge.register_menu("m1", self()) + send(bridge, {:edw_event, "event.menu.click", %{"menu_id" => "m1", "onclick" => "quit"}}) + assert_receive {:"$gen_cast", {:trigger_event, "quit"}}, 500 + end + + test "open_url notifies Desktop.Env subscribers when Env is running", %{bridge: bridge} do + # Without Desktop.Env, dispatch is a no-op — just ensure no crash. + send(bridge, {:edw_event, "event.system.open_url", %{"url" => "ddrive://invite/abc"}}) + Process.sleep(50) + assert Process.alive?(bridge) + end +end From f1f7c084430b4d57cc412a6a70966e14727cc17b Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 18:20:57 +0800 Subject: [PATCH 02/10] Avoid Desktop.Env self-call during init_env. put_webview_backend ran via GenServer.call while Desktop.Env.init was still on the stack, which crashed desktop startup on macOS. Co-authored-by: Cursor --- lib/desktop_webview/backend.ex | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/desktop_webview/backend.ex b/lib/desktop_webview/backend.ex index 3b33143..dbd7d1a 100644 --- a/lib/desktop_webview/backend.ex +++ b/lib/desktop_webview/backend.ex @@ -341,8 +341,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 From c7452d5ee0ff290bd0f89053518fb9ae066e6ad1 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 18:34:23 +0800 Subject: [PATCH 03/10] Quit macOS app by shutting down BEAM, not only the host. Apple menu Quit called NSApplication.terminate, which killed DesktopWebView and left the Elixir node running. Intercept terminate, emit event.system.quit, let Desktop.Window.quit halt BEAM, then finish host teardown on disconnect. Also halt BEAM if the host process exits unexpectedly. Co-authored-by: Cursor --- docs/protocol.md | 15 ++++++++- lib/desktop_webview/backend.ex | 2 ++ lib/desktop_webview/event_bridge.ex | 12 +++++++ lib/desktop_webview/launcher.ex | 14 ++++++-- .../DesktopWebView/HostController.swift | 32 +++++++++++++++++-- .../macos/Sources/DesktopWebView/main.swift | 10 ++++++ test/event_bridge_test.exs | 9 ++++++ 7 files changed, 88 insertions(+), 6 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index 483f6fb..f2504e2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -259,10 +259,23 @@ AppKit dialogs run on the host main thread and block the RPC until dismissed. | `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) diff --git a/lib/desktop_webview/backend.ex b/lib/desktop_webview/backend.ex index dbd7d1a..0d03176 100644 --- a/lib/desktop_webview/backend.ex +++ b/lib/desktop_webview/backend.ex @@ -59,6 +59,8 @@ defmodule DesktopWebview.Backend do ) 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 diff --git a/lib/desktop_webview/event_bridge.ex b/lib/desktop_webview/event_bridge.ex index 488995f..11f80c3 100644 --- a/lib/desktop_webview/event_bridge.ex +++ b/lib/desktop_webview/event_bridge.ex @@ -124,6 +124,18 @@ defmodule DesktopWebview.EventBridge do state end + defp dispatch("event.system.quit", _params, state) do + # Host Quit / Cmd+Q — Elixir owns process lifetime (Desktop.OS.shutdown). + quit = + Application.get_env(:desktop_webview, :quit_fun, fn -> + _ = Transport.call("system.prepare_quit", %{}) + Desktop.Window.quit() + end) + + spawn(fn -> quit.() end) + state + end + defp dispatch("event.menu.click", %{"menu_id" => menu_id, "onclick" => onclick}, state) do if pid = Map.get(state.menus, menu_id) do GenServer.cast(pid, {:trigger_event, onclick}) diff --git a/lib/desktop_webview/launcher.ex b/lib/desktop_webview/launcher.ex index c5a8551..c8712f1 100644 --- a/lib/desktop_webview/launcher.ex +++ b/lib/desktop_webview/launcher.ex @@ -112,8 +112,18 @@ defmodule DesktopWebview.Launcher do defp drain_port(port) do receive do - {^port, {:data, _}} -> drain_port(port) - {^port, {:exit_status, _}} -> :ok + {^port, {:data, _}} -> + drain_port(port) + + {^port, {:exit_status, _}} -> + # Host process exited (Quit, crash, or Port.close). When enabled, stop BEAM + # so a killed UI host cannot leave an orphaned Elixir node. + if Application.get_env(:desktop_webview, :halt_on_host_exit, false) do + quit = Application.get_env(:desktop_webview, :quit_fun, &Desktop.Window.quit/0) + spawn(fn -> quit.() end) + end + + :ok end end end diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 4d25b64..b50600d 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -17,6 +17,10 @@ final class HostController: NSObject { private var permissionPolicy: [String: [String: String]] = [:] // origin -> type -> allow|deny|ask private var beamProcess: Process? private var appleMenuSet = false + /// True after the user/OS asked to quit; BEAM is expected to shut down next. + private(set) var quitRequested = false + /// When true, `applicationShouldTerminate` may finish tearing down the host. + private(set) var readyToTerminate = false init(config: HostConfig) { self.config = config @@ -67,14 +71,32 @@ final class HostController: NSObject { } private func clientDisconnected() { - if config.lifetime == .coupled { - beamProcess?.terminate() - NSApp.terminate(nil) + if quitRequested || config.lifetime == .coupled { + finishQuit() + return } // reconnect: keep windows; client will re-initialize initialized = false } + /// Ask Elixir to shut down (`event.system.quit`). Used by Quit menu / Cmd+Q. + func requestQuit() { + guard !quitRequested else { return } + quitRequested = true + server.notify(method: "event.system.quit", params: .object([:])) + // If BEAM never disconnects (already dead / hung), still exit the host. + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in + self?.finishQuit() + } + } + + func finishQuit() { + readyToTerminate = true + beamProcess?.terminate() + NSApp.reply(toApplicationShouldTerminate: true) + NSApp.terminate(nil) + } + func nextId(_ prefix: String) -> String { idCounter += 1 return "\(prefix)\(idCounter)" @@ -342,6 +364,10 @@ final class HostController: NSObject { case "system.os_description": let v = ProcessInfo.processInfo.operatingSystemVersionString return .string("macOS \(v)") + case "system.prepare_quit": + // Elixir is about to halt; mark so TCP disconnect finishes host teardown. + quitRequested = true + return .bool(true) case "system.set_permission_policy": guard let origin = params?["origin"]?.stringValue else { throw HostError(-32602, "origin") } var map = permissionPolicy[origin] ?? [:] diff --git a/native/macos/Sources/DesktopWebView/main.swift b/native/macos/Sources/DesktopWebView/main.swift index 1f9389b..3e3c1f8 100644 --- a/native/macos/Sources/DesktopWebView/main.swift +++ b/native/macos/Sources/DesktopWebView/main.swift @@ -24,6 +24,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + // Quit menu / Cmd+Q / Dock Quit all go through terminate(_:). Notify BEAM + // first so the Elixir process exits; only then tear down the host. + if host.readyToTerminate { + return .terminateNow + } + host.requestQuit() + return .terminateLater + } } let config = HostConfig.parse(argv: CommandLine.arguments) diff --git a/test/event_bridge_test.exs b/test/event_bridge_test.exs index d7e1a90..611fc91 100644 --- a/test/event_bridge_test.exs +++ b/test/event_bridge_test.exs @@ -33,6 +33,15 @@ defmodule DesktopWebview.EventBridgeTest do assert_receive {:"$gen_cast", {:trigger_event, "quit"}}, 500 end + test "quit invokes configured quit_fun", %{bridge: bridge} do + test = self() + Application.put_env(:desktop_webview, :quit_fun, fn -> send(test, :quit_requested) end) + on_exit(fn -> Application.delete_env(:desktop_webview, :quit_fun) end) + + send(bridge, {:edw_event, "event.system.quit", %{}}) + assert_receive :quit_requested, 500 + end + test "open_url notifies Desktop.Env subscribers when Env is running", %{bridge: bridge} do # Without Desktop.Env, dispatch is a no-op — just ensure no crash. send(bridge, {:edw_event, "event.system.open_url", %{"url" => "ddrive://invite/abc"}}) From 89436ed3e37903c01b11ec992baef03620d30815 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 18:41:59 +0800 Subject: [PATCH 04/10] Show Diode Collab as the macOS application menu name. AppKit titles the first main-menu item with the process name. The raw DesktopWebView binary plus set_menubar installing Zones first produced duplicate process-named menus. Keep the Apple menu first and set processName from menu.set_apple. Co-authored-by: Cursor --- .../DesktopWebView/HostController.swift | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index b50600d..079dff4 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -17,6 +17,9 @@ final class HostController: NSObject { private var permissionPolicy: [String: [String: String]] = [:] // origin -> type -> allow|deny|ask private var beamProcess: Process? private var appleMenuSet = false + /// Display name for the macOS application menu (e.g. "Diode Collab"). + private var appDisplayName = "DesktopWebView" + private var appleMenuItem: NSMenuItem? /// True after the user/OS asked to quit; BEAM is expected to shut down next. private(set) var quitRequested = false /// When true, `applicationShouldTerminate` may finish tearing down the host. @@ -262,8 +265,15 @@ final class HostController: NSObject { case "window.set_menubar": let w = try win(params) if let menuId = params?["menu_id"]?.stringValue, let menu = menus[menuId] { - // Convert popup-style menu into menubar if needed + // AppKit always titles the *first* main-menu item with the process + // name. Keep the application (Apple) menu first, then app menus — + // otherwise "Zones" is shown as "DesktopWebView" and a later Apple + // insert yields two process-named menus. let bar = NSMenu() + if let apple = ensureAppleMenuItem() { + apple.menu?.removeItem(apple) + bar.addItem(apple) + } for item in menu.items { bar.addItem(item.copy() as! NSMenuItem) } @@ -653,23 +663,44 @@ final class HostController: NSObject { private func setAppleMenu(_ params: JSONValue?) -> JSONValue { let name = params?["app_name"]?.stringValue ?? "App" - let appMenu = NSMenu() + appDisplayName = name + // Without an .app bundle, AppKit uses the executable name ("DesktopWebView") + // for the application menu title. Align the process name with the product. + ProcessInfo.processInfo.processName = name + + _ = ensureAppleMenuItem() + installMainMenuPreservingApple(extraItems: Array(NSApp.mainMenu?.items.dropFirst() ?? [])) + appleMenuSet = true + return .bool(true) + } + + /// Application menu (About / Quit). Always the first main-menu item on macOS. + private func ensureAppleMenuItem() -> NSMenuItem? { + let name = appDisplayName + let appMenu = NSMenu(title: name) appMenu.addItem(withTitle: "About \(name)", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") appMenu.addItem(NSMenuItem.separator()) appMenu.addItem(withTitle: "Quit \(name)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") - let bar = NSApp.mainMenu ?? NSMenu() - if bar.items.first?.submenu == nil || !appleMenuSet { - let appItem = NSMenuItem() - appItem.submenu = appMenu - if bar.items.isEmpty { - bar.addItem(appItem) - } else { - bar.insertItem(appItem, at: 0) - } - NSApp.mainMenu = bar - appleMenuSet = true + + let appItem = appleMenuItem ?? NSMenuItem(title: name, action: nil, keyEquivalent: "") + appItem.title = name + appItem.submenu = appMenu + appleMenuItem = appItem + return appItem + } + + private func installMainMenuPreservingApple(extraItems: [NSMenuItem]) { + let bar = NSMenu() + if let apple = ensureAppleMenuItem() { + // Detach from previous menu before re-adding. + apple.menu?.removeItem(apple) + bar.addItem(apple) } - return .bool(true) + for item in extraItems { + item.menu?.removeItem(item) + bar.addItem(item) + } + NSApp.mainMenu = bar } private func trayCreate(_ params: JSONValue?) -> JSONValue { From bc8f9a466ad03d8ec39d5d9c0668b09ab11592e1 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 18:48:22 +0800 Subject: [PATCH 05/10] Exit DesktopWebView when BEAM disconnects in no-beam/dev mode. reconnect lifetime left the host running after iex/mix stopped. Treat --edw-no-beam like coupled on client disconnect, and default Launcher lifetime to coupled for BEAM-first launches. Co-authored-by: Cursor --- docs/protocol.md | 9 ++++++--- lib/desktop_webview/backend.ex | 2 +- lib/desktop_webview/launcher.ex | 8 +++++--- native/linux/src/host_controller.cpp | 3 ++- native/macos/Sources/DesktopWebView/HostController.swift | 7 +++++-- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/docs/protocol.md b/docs/protocol.md index f2504e2..4aa70ff 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 @@ -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. diff --git a/lib/desktop_webview/backend.ex b/lib/desktop_webview/backend.ex index 0d03176..cfe0cfd 100644 --- a/lib/desktop_webview/backend.ex +++ b/lib/desktop_webview/backend.ex @@ -55,7 +55,7 @@ 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) diff --git a/lib/desktop_webview/launcher.ex b/lib/desktop_webview/launcher.ex index c8712f1..7b57144 100644 --- a/lib/desktop_webview/launcher.ex +++ b/lib/desktop_webview/launcher.ex @@ -77,9 +77,11 @@ defmodule DesktopWebview.Launcher do end defp lifetime_args(opts) do - case Keyword.get(opts, :lifetime, :reconnect) do - :coupled -> ["--edw-lifetime=coupled"] - _ -> ["--edw-lifetime=reconnect"] + # BEAM-first launches use --edw-no-beam; default to coupled so stopping the + # VM tears down the host (reconnect is for host-first packaged mode). + case Keyword.get(opts, :lifetime, :coupled) do + :reconnect -> ["--edw-lifetime=reconnect"] + _ -> ["--edw-lifetime=coupled"] end end diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp index fb0190e..f5b51c4 100644 --- a/native/linux/src/host_controller.cpp +++ b/native/linux/src/host_controller.cpp @@ -113,7 +113,8 @@ bool HostController::start() { } void HostController::client_disconnected() { - if (config_.lifetime == Lifetime::Coupled) { + // BEAM-first/dev (`--edw-no-beam`): exit with the Elixir client. + if (config_.lifetime == Lifetime::Coupled || config_.no_beam) { if (beam_pid_ > 0) { kill(beam_pid_, SIGTERM); beam_pid_ = 0; diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 079dff4..2d75490 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -74,11 +74,14 @@ final class HostController: NSObject { } private func clientDisconnected() { - if quitRequested || config.lifetime == .coupled { + // BEAM-first/dev (`--edw-no-beam`): the Elixir node owns the host. When it + // disconnects the UI must go away — reconnect only makes sense when the + // host owns BEAM and can accept a new client. + if quitRequested || config.lifetime == .coupled || config.noBeam { finishQuit() return } - // reconnect: keep windows; client will re-initialize + // Host-first + reconnect: keep windows; client will re-initialize initialized = false } From faf02d899aceb2fcb18dfbfa9a10d27195f4cee6 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 18:49:07 +0800 Subject: [PATCH 06/10] Document no-beam host exit on BEAM disconnect. Co-authored-by: Cursor --- docs/packaging.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/packaging.md b/docs/packaging.md index 0d56af1..f48f3dc 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -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 From f0ff526f307a33673d9c8d268518c1a10504888a Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 19:00:28 +0800 Subject: [PATCH 07/10] Rebind tray menus after menu.update so status item clicks work. Desktop.Menu creates the tray against an empty first DOM, then updates the menu object; NSStatusItem kept the empty menu. Reattach on update and call tray.set_menu from the Elixir adapter after mount/refresh. Co-authored-by: Cursor --- docs/protocol.md | 2 ++ lib/desktop_webview/menu/adapter.ex | 13 ++++++++++++- .../Sources/DesktopWebView/HostController.swift | 13 +++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/protocol.md b/docs/protocol.md index 4aa70ff..5116aa4 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -122,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. diff --git a/lib/desktop_webview/menu/adapter.ex b/lib/desktop_webview/menu/adapter.ex index fb5ca1b..e225546 100644 --- a/lib/desktop_webview/menu/adapter.ex +++ b/lib/desktop_webview/menu/adapter.ex @@ -74,7 +74,7 @@ defmodule DesktopWebview.Menu.Adapter do adapter.menubar _ -> - case Transport.call("menu.create", %{"kind" => "menubar", "dom" => json}) do + case Transport.call("menu.create", %{"kind" => "popup", "dom" => json}) do {:ok, %{"menu_id" => id}} -> {:menu, id} _ -> {:menu, nil} end @@ -88,6 +88,17 @@ defmodule DesktopWebview.Menu.Adapter do :ok end + # tray.create often runs against an empty first DOM; re-attach after mount/update. + if is_binary(adapter.taskbar_icon) do + case result do + {:menu, id} when is_binary(id) -> + _ = Transport.call("tray.set_menu", %{"tray_id" => adapter.taskbar_icon, "menu_id" => id}) + + _ -> + :ok + end + end + %{adapter | menubar: result, dom: dom} end diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 2d75490..4b56f12 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -13,6 +13,8 @@ final class HostController: NSObject { private var menus: [String: NSMenu] = [:] private var menuOnclicks: [String: [Int: String]] = [:] // menuId -> tag -> onclick private var trays: [String: NSStatusItem] = [:] + /// menu_id → tray_id bindings so menu.update reattaches the status item menu. + private var trayMenus: [String: String] = [:] private var icons: [String: NSImage] = [:] private var permissionPolicy: [String: [String: String]] = [:] // origin -> type -> allow|deny|ask private var beamProcess: Process? @@ -349,6 +351,7 @@ final class HostController: NSObject { return traySetMenu(params) case "tray.destroy": if let id = params?["tray_id"]?.stringValue, let item = trays.removeValue(forKey: id) { + trayMenus = trayMenus.filter { $0.value != id } NSStatusBar.system.removeStatusItem(item) } return .bool(true) @@ -596,6 +599,11 @@ final class HostController: NSObject { let menu = buildMenu(dom: params?["dom"], onclicks: &map, menuId: id) menus[id] = menu menuOnclicks[id] = map + // Desktop.Menu mounts with an empty DOM then updates — rebind trays that + // still hold the previous NSMenu instance. + if let trayId = trayMenus[id], let item = trays[trayId] { + item.menu = menu + } return .bool(true) } @@ -711,11 +719,13 @@ final class HostController: NSObject { let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let iconId = params?["icon_id"]?.stringValue, let img = icons[iconId] { item.button?.image = img + item.button?.image?.isTemplate = true } else { item.button?.title = "EDW" } if let menuId = params?["menu_id"]?.stringValue, let menu = menus[menuId] { item.menu = menu + trayMenus[menuId] = id } trays[id] = item return .object(["tray_id": .string(id)]) @@ -726,7 +736,9 @@ final class HostController: NSObject { return .bool(false) } if let iconId = params?["icon_id"]?.stringValue, let img = icons[iconId] { + img.isTemplate = true item.button?.image = img + item.button?.title = "" } return .bool(true) } @@ -737,6 +749,7 @@ final class HostController: NSObject { } if let menuId = params?["menu_id"]?.stringValue, let menu = menus[menuId] { item.menu = menu + trayMenus[menuId] = id } return .bool(true) } From bd9f605ff996beffcdb41e9cfc48ceb00d335b50 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 19:08:14 +0800 Subject: [PATCH 08/10] Keep full-color tray icons (do not mark as template). Template NSImages render as gray menu-bar silhouettes; Diode paints status colors into PNGs and needs those colors preserved. Co-authored-by: Cursor --- native/macos/Sources/DesktopWebView/HostController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 4b56f12..2fb365b 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -719,7 +719,6 @@ final class HostController: NSObject { let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let iconId = params?["icon_id"]?.stringValue, let img = icons[iconId] { item.button?.image = img - item.button?.image?.isTemplate = true } else { item.button?.title = "EDW" } @@ -736,7 +735,8 @@ final class HostController: NSObject { return .bool(false) } if let iconId = params?["icon_id"]?.stringValue, let img = icons[iconId] { - img.isTemplate = true + // Keep full-color app icons (Diode paints status colors into PNGs). + // Do not set isTemplate — that forces a gray menu-bar silhouette. item.button?.image = img item.button?.title = "" } From 0dc6b979166414aa27e511ad11eeb663a44390f0 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 19:27:13 +0800 Subject: [PATCH 09/10] Size macOS tray icons to 80% of menu-bar thickness. PNG pixel dimensions were used as AppKit point size (32pt), so padding in the bitmap did not change on-screen size. Set NSImage.size from NSStatusBar.thickness instead. Co-authored-by: Cursor --- .../Sources/DesktopWebView/HostController.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 2fb365b..151c696 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -718,7 +718,7 @@ final class HostController: NSObject { let id = nextId("tray") let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let iconId = params?["icon_id"]?.stringValue, let img = icons[iconId] { - item.button?.image = img + item.button?.image = prepareTrayImage(img) } else { item.button?.title = "EDW" } @@ -737,12 +737,22 @@ final class HostController: NSObject { if let iconId = params?["icon_id"]?.stringValue, let img = icons[iconId] { // Keep full-color app icons (Diode paints status colors into PNGs). // Do not set isTemplate — that forces a gray menu-bar silhouette. - item.button?.image = img + item.button?.image = prepareTrayImage(img) item.button?.title = "" } return .bool(true) } + /// PNG pixel size becomes NSImage point size by default (e.g. 32pt), which is + /// oversized next to other menu-bar icons. Force a status-item scale (~80% of + /// bar thickness ≈ 18pt on a 22pt bar). + private func prepareTrayImage(_ img: NSImage) -> NSImage { + let copy = img.copy() as! NSImage + let side = max(14.0, NSStatusBar.system.thickness * 0.8) + copy.size = NSSize(width: side, height: side) + return copy + } + private func traySetMenu(_ params: JSONValue?) -> JSONValue { guard let id = params?["tray_id"]?.stringValue, let item = trays[id] else { return .bool(false) From c61761d1f1377bdda752bc867c977ce47b792288 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 3 Aug 2026 19:43:50 +0800 Subject: [PATCH 10/10] Resolve window icons from app priv/ for Dock display. Desktop.Window passes filenames like diode.png; Path.expand looked in cwd, failed silently, and icon.create installed a blank NSImage used as NSApp.applicationIconImage. Mirror wx Application.app_dir(priv) lookup and error when a path cannot be loaded. Co-authored-by: Cursor --- lib/desktop_webview/backend.ex | 20 +++++++++++++++++-- .../DesktopWebView/HostController.swift | 5 ++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lib/desktop_webview/backend.ex b/lib/desktop_webview/backend.ex index cfe0cfd..871be4c 100644 --- a/lib/desktop_webview/backend.ex +++ b/lib/desktop_webview/backend.ex @@ -415,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}} @@ -442,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}) diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 151c696..ae8aebe 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -786,7 +786,10 @@ final class HostController: NSObject { private func iconCreate(_ params: JSONValue?) throws -> JSONValue { let id = nextId("icon") - if let path = params?["path"]?.stringValue, let img = NSImage(contentsOfFile: path) { + if let path = params?["path"]?.stringValue { + guard let img = NSImage(contentsOfFile: path) else { + throw HostError(-32002, "failed to load icon: \(path)") + } icons[id] = img return .object(["icon_id": .string(id)]) }