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/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 diff --git a/docs/protocol.md b/docs/protocol.md index cd0cbf5..5116aa4 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. @@ -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. @@ -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 | @@ -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) 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..871be4c 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 -> @@ -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 @@ -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 @@ -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} -> @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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}} @@ -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}) @@ -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 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..11f80c3 --- /dev/null +++ b/lib/desktop_webview/event_bridge.ex @@ -0,0 +1,174 @@ +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.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}) + 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/launcher.ex b/lib/desktop_webview/launcher.ex index c5a8551..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 @@ -112,8 +114,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/lib/desktop_webview/menu/adapter.ex b/lib/desktop_webview/menu/adapter.ex index 3efe46f..e225546 100644 --- a/lib/desktop_webview/menu/adapter.ex +++ b/lib/desktop_webview/menu/adapter.ex @@ -74,12 +74,31 @@ 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 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 + + # 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 @@ -114,7 +133,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 +147,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..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; @@ -805,6 +806,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..ae8aebe 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -13,10 +13,19 @@ 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? 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. + private(set) var readyToTerminate = false init(config: HostConfig) { self.config = config @@ -67,14 +76,35 @@ final class HostController: NSObject { } private func clientDisconnected() { - if config.lifetime == .coupled { - beamProcess?.terminate() - NSApp.terminate(nil) + // 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 } + /// 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)" @@ -240,8 +270,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) } @@ -314,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) @@ -342,6 +380,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] ?? [:] @@ -350,11 +392,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": @@ -514,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) } @@ -584,35 +674,57 @@ 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 { 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" } 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)]) @@ -623,17 +735,31 @@ final class HostController: NSObject { return .bool(false) } if let iconId = params?["icon_id"]?.stringValue, let img = icons[iconId] { - item.button?.image = img + // 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 = 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) } if let menuId = params?["menu_id"]?.stringValue, let menu = menus[menuId] { item.menu = menu + trayMenus[menuId] = id } return .bool(true) } @@ -660,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)]) } 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/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..611fc91 --- /dev/null +++ b/test/event_bridge_test.exs @@ -0,0 +1,51 @@ +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 "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"}}) + Process.sleep(50) + assert Process.alive?(bridge) + end +end