From 05314711476a4cf33c13ad5188ed6aabd717a483 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 22 May 2026 13:05:16 +0200 Subject: [PATCH 1/6] Embed mobile bridge adapter --- AGENTS.md | 12 +- CHANGELOG.md | 9 + guides/faq.md | 10 + lib/desktop.ex | 17 +- lib/desktop/backend/browser.ex | 161 ++++++++++ lib/desktop/backend/json.ex | 317 ++++++++++++++++++++ lib/desktop/backend/null.ex | 18 ++ lib/desktop/backend/wx.ex | 432 +++++++++++++++++++++++++++ lib/desktop/bridge/codec.ex | 59 ++++ lib/desktop/bridge/mock.ex | 29 ++ lib/desktop/bridge/protocol.ex | 24 ++ lib/desktop/bridge/transport.ex | 223 ++++++++++++++ lib/desktop/env.ex | 25 +- lib/desktop/fallback.ex | 242 ++------------- lib/desktop/image.ex | 54 +--- lib/desktop/menu.ex | 12 +- lib/desktop/menu/adapters/browser.ex | 19 ++ lib/desktop/menu/adapters/json.ex | 180 +++++++++++ lib/desktop/platform.ex | 80 +++++ lib/desktop/platform/backend.ex | 7 + lib/desktop/platform/content.ex | 28 ++ lib/desktop/platform/media.ex | 19 ++ lib/desktop/platform/menu.ex | 50 ++++ lib/desktop/platform/notification.ex | 21 ++ lib/desktop/platform/server.ex | 63 ++++ lib/desktop/platform/system.ex | 30 ++ lib/desktop/platform/window.ex | 47 +++ lib/desktop/window.ex | 166 +++++----- mix.exs | 9 +- test/desktop/bridge_codec_test.exs | 19 ++ test/desktop/platform_test.exs | 42 +++ 31 files changed, 2029 insertions(+), 395 deletions(-) create mode 100644 lib/desktop/backend/browser.ex create mode 100644 lib/desktop/backend/json.ex create mode 100644 lib/desktop/backend/null.ex create mode 100644 lib/desktop/backend/wx.ex create mode 100644 lib/desktop/bridge/codec.ex create mode 100644 lib/desktop/bridge/mock.ex create mode 100644 lib/desktop/bridge/protocol.ex create mode 100644 lib/desktop/bridge/transport.ex create mode 100644 lib/desktop/menu/adapters/browser.ex create mode 100644 lib/desktop/menu/adapters/json.ex create mode 100644 lib/desktop/platform.ex create mode 100644 lib/desktop/platform/backend.ex create mode 100644 lib/desktop/platform/content.ex create mode 100644 lib/desktop/platform/media.ex create mode 100644 lib/desktop/platform/menu.ex create mode 100644 lib/desktop/platform/notification.ex create mode 100644 lib/desktop/platform/server.ex create mode 100644 lib/desktop/platform/system.ex create mode 100644 lib/desktop/platform/window.ex create mode 100644 test/desktop/bridge_codec_test.exs create mode 100644 test/desktop/platform_test.exs diff --git a/AGENTS.md b/AGENTS.md index e49714c..1bb14ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,4 +31,14 @@ wxWidgets GUI support requires `libwxgtk-webview3.2-dev` and `xvfb` on headless ### Running without a display -All commands that load the `:wx` application (compile, test, iex) need a display. Use `xvfb-run` prefix on headless servers. The `NO_WX` environment variable can skip wx initialization but will limit functionality. +All commands that load the `:wx` application (compile, test, iex) need a display. Use `xvfb-run` prefix on headless servers. The `NO_WX` environment variable selects the `Desktop.Backend.Browser` platform backend (OS browser fallback, no native window). + +### Platform backends + +| Backend | When | +|---|---| +| `Desktop.Backend.Wx` | Desktop host targets with OTP `:wx` (default) | +| `Desktop.Backend.Json` | `Mix.target()` is `:android` or `:ios` — JSON bridge via `BRIDGE_PORT` | +| `Desktop.Backend.Browser` | `NO_WX=1` or `:wx` unavailable | + +Override with `config :desktop, :backend, :wx | :json | :browser | :auto`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f90992..6dacbba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Changes in 1.6 (unreleased) + +- Internal `Desktop.Platform` layer with domain behaviours (Window, Content, Notification, Media, System) and backends (`Desktop.Backend.Wx`, `Desktop.Backend.Json`, `Desktop.Backend.Browser`) +- Bridge JSON/TCP transport embedded as `Desktop.Bridge.Transport` (legacy `[module, method, args]` wire format preserved for native hosts) +- Removed separate `{:wx, hex: :bridge}` dependency on Android/iOS; mobile builds use `Desktop.Backend.Json` directly +- Optional `config :desktop, :backend, :auto | :wx | :json | :browser` override +- Menu adapters: `Desktop.Menu.Adapter.Json` and `Desktop.Menu.Adapter.Browser` +- Public `Desktop.*` APIs unchanged (`Desktop.Window`, `Desktop.Env`, `Desktop.Menu`, etc.) + ## Changes in 1.5 - Support for iOS hibernation and wakeup diff --git a/guides/faq.md b/guides/faq.md index eae7241..895240b 100644 --- a/guides/faq.md +++ b/guides/faq.md @@ -1,5 +1,15 @@ # Frequently Asked Questions (FAQ) +## How does the mobile (Android/iOS) bridge work? + +On mobile targets, `desktop` uses `Desktop.Backend.Json` instead of OTP `:wx`. The Elixir side speaks the legacy JSON protocol over TCP to your native host app (set `BRIDGE_PORT` to the listening port). The separate `bridge` hex package is no longer required — transport lives in `Desktop.Bridge.Transport`. + +Override the backend with `config :desktop, :backend, :json` in `config/config.exs` if needed. + +## How do I run without wxWidgets? + +Set `NO_WX=1` to use `Desktop.Backend.Browser`: URLs open in the OS default browser and window/menu APIs degrade gracefully. Use `xvfb-run` on headless Linux when testing the Wx backend instead. + ## How do I release and distribute my Desktop application? ### Creating an Installer diff --git a/lib/desktop.ex b/lib/desktop.ex index efd703a..de439e4 100644 --- a/lib/desktop.ex +++ b/lib/desktop.ex @@ -99,11 +99,18 @@ defmodule Desktop do code else _ -> - :wx.set_env(Desktop.Env.wx_env()) - locale = :wxLocale.new(:wxLocale.getSystemLanguage()) - # This is in the form "xx_XX" - # we have seen windows returns an empty string though... - :wxLocale.getCanonicalName(locale) |> List.to_string() |> String.downcase() + case Desktop.Platform.System.locale() do + nil -> + with env when env != nil <- Desktop.Env.wx_env() do + Desktop.Platform.System.set_env(env) + end + + locale = :wxLocale.new(:wxLocale.getSystemLanguage()) + :wxLocale.getCanonicalName(locale) |> List.to_string() |> String.downcase() + + locale -> + locale + end end end diff --git a/lib/desktop/backend/browser.ex b/lib/desktop/backend/browser.ex new file mode 100644 index 0000000..8473e5b --- /dev/null +++ b/lib/desktop/backend/browser.ex @@ -0,0 +1,161 @@ +defmodule Desktop.Backend.Browser do + @moduledoc false + + alias Desktop.OS + + @behaviour Desktop.Platform.Backend + @behaviour Desktop.Platform.Window + @behaviour Desktop.Platform.Content + @behaviour Desktop.Platform.Notification + @behaviour Desktop.Platform.Media + @behaviour Desktop.Platform.System + + @impl true + def capabilities do + %{ + window: false, + content: :os_browser, + notification: :log, + menu: :none, + taskbar: false + } + end + + # System + + @impl true + def init_env, do: {nil, nil} + + @impl true + def subscribe_events, do: :ok + + @impl true + def set_env(_env), do: :ok + + @impl true + def get_env, do: nil + + @impl true + def locale, do: nil + + @impl true + def connect_menu(_object, _command, _callback, _id), do: :ok + + @impl true + def wx_available?, do: false + + # Window + + @impl true + def open(_opts), do: {:ok, nil, nil} + + @impl true + def destroy_frame(_frame), do: :ok + + @impl true + def connect(_frame, _event, _fun), do: :ok + + @impl true + def show(_frame, _opts), do: :ok + + @impl true + def hide(_frame), do: :ok + + @impl true + def set_title(_frame, _title), do: :ok + + @impl true + def set_min_size(_frame, _size), do: :ok + + @impl true + def set_icon(_frame, _icon), do: :ok + + @impl true + def set_menubar(_frame, _menubar), do: :ok + + @impl true + def iconize(_frame, _iconize), do: :ok + + @impl true + def is_shown?(_frame), do: false + + @impl true + def is_active?(_frame), do: false + + @impl true + def raise_window(_frame), do: :ok + + @impl true + def update_apple_menu(_title, _frame, _menubar), do: :ok + + @impl true + def new_menubar, do: nil + + @impl true + def on_crash_destroy(_frame), do: :ok + + @impl true + def close_event_veto(_inev), do: :ok + + # Content + + @impl true + def attach(_frame), do: nil + + @impl true + def load_url(_content, _frame, url), do: OS.launch_default_browser(url) + + @impl true + def current_url(_content, last_url), do: last_url + + @impl true + def content_show(_content, _frame, url, _), do: OS.launch_default_browser(url) + + @impl true + def rebuild(_frame, _url), do: nil + + @impl true + def put_webview_backend(name) do + Desktop.Env.put(:webview_backend, name) + :ok + end + + # Notification + + @impl true + def new(_title, _type), do: nil + + @impl true + def notification_show(nil, message, _timeout, title) do + require Logger + Logger.notice("NOTIFICATION: #{title}: #{message}") + :ok + end + + def notification_show(_notification, message, _timeout, title) do + notification_show(nil, message, 0, title) + end + + @impl true + def close(_notification), do: :ok + + # Media + + @impl true + def load_image(_app, _path), do: {:error, :unsupported} + + @impl true + def new_icon(_app, _path), do: {:error, :unsupported} + + @impl true + def new_icon_from(_image), do: {:error, :unsupported} + + @impl true + def default_icon, do: {:ok, nil} + + @impl true + def media_destroy(_image), do: :ok + + @impl true + def object_type(_image), do: :unknown +end diff --git a/lib/desktop/backend/json.ex b/lib/desktop/backend/json.ex new file mode 100644 index 0000000..20ffc97 --- /dev/null +++ b/lib/desktop/backend/json.ex @@ -0,0 +1,317 @@ +defmodule Desktop.Backend.Json do + @moduledoc false + + alias Desktop.Bridge.{Protocol, Transport} + alias Desktop.{Wx, OS} + + @behaviour Desktop.Platform.Backend + @behaviour Desktop.Platform.Window + @behaviour Desktop.Platform.Content + @behaviour Desktop.Platform.Notification + @behaviour Desktop.Platform.Media + @behaviour Desktop.Platform.System + + @impl true + def capabilities do + %{ + window: true, + content: :native, + notification: :native, + menu: :native, + taskbar: false + } + end + + # System + + @impl true + def init_env do + wx = Transport.ensure_started() + {wx, :ok} + end + + @impl true + def subscribe_events do + Transport.subscribe_events(self()) + :ok + end + + @impl true + def set_env(_env), do: :ok + + @impl true + def get_env, do: :ok + + @impl true + def locale do + Protocol.call(:wxLocale, :getCanonicalName, [ + Protocol.new(:wxLocale, [:wxLocale.getSystemLanguage()]) + ]) + |> List.to_string() + |> String.downcase() + end + + @impl true + def connect_menu(object, command, callback, id) do + opts = [{:callback, fn _, _ -> callback.() end}] + opts = if id == nil, do: opts, else: [{:id, id} | opts] + Protocol.connect(:wxMenu, object, command, opts) + :ok + end + + @impl true + def wx_available?, do: true + + # Window + + @impl true + def open(opts) do + wx = Keyword.fetch!(opts, :wx) + title = Keyword.fetch!(opts, :title) + size = Keyword.get(opts, :size, {600, 500}) + + frame = + Protocol.new(:wxFrame, [ + wx, + Wx.wxID_ANY(), + title, + [{:size, size}, {:style, Wx.wxDEFAULT_FRAME_STYLE()}] + ]) + + min_size = Keyword.get(opts, :min_size) + if min_size, do: Protocol.call(:wxFrame, :setMinSize, [frame, min_size]) + + Protocol.call(:wxFrame, :setSizer, [ + frame, + Protocol.new(:wxBoxSizer, [Wx.wxHORIZONTAL()]) + ]) + + icon = Keyword.get(opts, :icon) + + {:ok, icon} = + case icon do + nil -> Desktop.Platform.Media.default_icon() + other -> {:ok, other} + end + + Protocol.call(:wxTopLevelWindow, :setIcon, [frame, icon]) + webview = Desktop.Platform.Content.attach(frame) + {:ok, frame, webview} + end + + @impl true + def destroy_frame(frame), do: Protocol.destroy(:wxFrame, frame) + + @impl true + def connect(frame, :close_window, fun) do + Protocol.connect(:wxFrame, frame, :close_window, callback: fun) + :ok + end + + def connect(frame, :activate, fun) do + Protocol.connect(:wxFrame, frame, :activate, callback: fun) + :ok + end + + @impl true + def show(frame, opts) do + Protocol.call(:wxFrame, :show, [frame, [show: Keyword.get(opts, :show, true)]]) + :ok + end + + @impl true + def hide(frame) do + Protocol.call(:wxFrame, :hide, [frame]) + :ok + end + + @impl true + def set_title(frame, title) do + Protocol.call(:wxFrame, :setTitle, [frame, String.to_charlist(title)]) + :ok + end + + @impl true + def set_min_size(frame, size), do: Protocol.call(:wxFrame, :setMinSize, [frame, size]) || :ok + + @impl true + def set_icon(frame, icon) do + Protocol.call(:wxTopLevelWindow, :setIcon, [frame, icon]) + :ok + end + + @impl true + def set_menubar(frame, menubar) do + Protocol.call(:wxFrame, :setMenuBar, [frame, menubar]) + :ok + end + + @impl true + def iconize(frame, iconize) do + Protocol.call(:wxTopLevelWindow, :iconize, [frame, [iconize: iconize]]) + :ok + end + + @impl true + def is_shown?(frame), do: Protocol.call(:wxWindow, :isShown, [frame]) || false + + @impl true + def is_active?(frame), do: Protocol.call(:wxTopLevelWindow, :isActive, [frame]) || true + + @impl true + def raise_window(frame) do + Protocol.call(:wxWindow, :raise, [frame]) + :ok + end + + @impl true + def update_apple_menu(_title, _frame, _menubar), do: :ok + + @impl true + def new_menubar, do: Protocol.new(:wxMenuBar, []) + + @impl true + def on_crash_destroy(frame), do: destroy_frame(frame) + + @impl true + def close_event_veto(_inev), do: :ok + + # Content + + @impl true + def attach(frame) do + webview = + Protocol.new(:wxWebView, [frame, -1, [style: Wx.wxNO_BORDER()]]) + + Protocol.connect(:wxWebView, webview, :webview_newwindow) + Protocol.connect(:wxWebView, webview, :webview_error) + Protocol.call(:wxWebView, :enableContextMenu, [webview, [enable: false]]) + webview + end + + @impl true + def load_url(nil, _frame, url), do: OS.launch_default_browser(url) + + def load_url(webview, _frame, url) do + Protocol.call(:wxWebView, :loadURL, [webview, url]) + :ok + end + + @impl true + def current_url(nil, last_url), do: last_url + + def current_url(webview, _last_url) do + case Protocol.call(:wxWebView, :getCurrentURL, [webview]) do + url when is_list(url) -> List.to_string(url) + other -> other + end + end + + @impl true + def content_show(nil, _frame, url, _), do: OS.launch_default_browser(url) + + def content_show(webview, frame, url, _only_open) do + if url, do: Protocol.call(:wxWebView, :loadURL, [webview, url]) + Protocol.call(:wxFrame, :show, [frame, [show: true]]) + Protocol.call(:wxTopLevelWindow, :centerOnScreen, [frame]) + :ok + end + + @impl true + def rebuild(nil, _url), do: nil + + def rebuild(frame, url) do + webview = attach(frame) + if url, do: Protocol.call(:wxWebView, :loadURL, [webview, url]) + webview + end + + @impl true + def put_webview_backend(name) do + Desktop.Env.put(:webview_backend, name) + :ok + end + + # Notification + + @impl true + def new(title, type) do + flag = + case type do + :info -> Wx.wxICON_INFORMATION() + :warning -> Wx.wxICON_WARNING() + :error -> Wx.wxICON_ERROR() + end + + Protocol.new(:wxNotificationMessage, [title, [flags: flag]]) + end + + @impl true + def notification_show(nil, message, _timeout, title) do + require Logger + Logger.notice("NOTIFICATION: #{title}: #{message}") + :ok + end + + def notification_show(notification, message, timeout, title) do + if title, + do: Protocol.call(:wxNotificationMessage, :setTitle, [notification, to_charlist(title)]) + + Protocol.call(:wxNotificationMessage, :setMessage, [notification, to_charlist(message)]) + Protocol.call(:wxNotificationMessage, :show, [notification, [timeout: timeout]]) + :ok + end + + @impl true + def close(notification) do + Protocol.call(:wxNotificationMessage, :close, [notification]) + :ok + end + + # Media + + @impl true + def load_image(app, path) do + image = Protocol.new(:wxImage, [get_abs_path(app, path)]) + {:ok, image} + end + + @impl true + def new_icon(app, path) do + with {:ok, image} <- load_image(app, path), do: new_icon_from(image) + end + + @impl true + def new_icon_from(image) do + case object_type(image) do + :wxImage -> + bitmap = Protocol.new(:wxBitmap, [image]) + icon = Protocol.new(:wxIcon, []) + Protocol.call(:wxIcon, :copyFromBitmap, [icon, bitmap]) + media_destroy(bitmap) + {:ok, icon} + + :wxIcon -> + {:ok, image} + + _ -> + {:ok, image} + end + end + + @impl true + def default_icon, do: {:ok, Protocol.call(:wxArtProvider, :getIcon, ["wxART_EXECUTABLE_FILE"])} + + @impl true + def media_destroy(image) do + type = object_type(image) + Protocol.destroy(type, image) + :ok + end + + @impl true + def object_type(image), do: Transport.bridge_call(:wx, :getObjectType, [image]) + + defp get_abs_path(_, "/" <> path), do: path + defp get_abs_path(app, name), do: Application.app_dir(app, ["priv", name]) +end diff --git a/lib/desktop/backend/null.ex b/lib/desktop/backend/null.ex new file mode 100644 index 0000000..f8a0c9e --- /dev/null +++ b/lib/desktop/backend/null.ex @@ -0,0 +1,18 @@ +defmodule Desktop.Backend.Null do + @moduledoc false + + def wx_call(module, method, args \\ []) do + if wx_enabled?() and Code.ensure_loaded?(module) and + function_exported?(module, method, length(args)) do + apply(module, method, args) + end + end + + def wx_enabled? do + System.get_env("NO_WX") == nil + end + + def module?(module) do + Code.ensure_compiled(module) == {:module, module} + end +end diff --git a/lib/desktop/backend/wx.ex b/lib/desktop/backend/wx.ex new file mode 100644 index 0000000..3cabda1 --- /dev/null +++ b/lib/desktop/backend/wx.ex @@ -0,0 +1,432 @@ +defmodule Desktop.Backend.Wx do + @moduledoc false + + alias Desktop.{Wx, OS} + alias Desktop.Backend.Null + + @behaviour Desktop.Platform.Backend + @behaviour Desktop.Platform.Window + @behaviour Desktop.Platform.Content + @behaviour Desktop.Platform.Notification + @behaviour Desktop.Platform.Media + @behaviour Desktop.Platform.System + + @impl true + def capabilities do + %{ + window: true, + content: :webview, + notification: :wx, + menu: :wx, + taskbar: true + } + end + + # System + + @impl true + def init_env do + wx = Null.wx_call(:wx, :new, [[]]) + Null.wx_call(:wx, :subscribe_events) + {wx, Null.wx_call(:wx, :get_env)} + end + + @impl true + def subscribe_events, do: Null.wx_call(:wx, :subscribe_events) || :ok + + @impl true + def set_env(env), do: Null.wx_call(:wx, :set_env, [env]) || :ok + + @impl true + def get_env, do: Null.wx_call(:wx, :get_env) + + @impl true + def locale do + locale = Null.wx_call(:wxLocale, :new, [:wxLocale.getSystemLanguage()]) + Null.wx_call(:wxLocale, :getCanonicalName, [locale]) |> List.to_string() |> String.downcase() + end + + @impl true + def connect_menu(object, command, callback, id) do + opts = [{:callback, fn _, _ -> callback.() end}] + opts = if id == nil, do: opts, else: [{:id, id} | opts] + Null.wx_call(:wxMenu, :connect, [object, command, opts]) + end + + @impl true + def wx_available?, do: Null.wx_enabled?() and Null.module?(:wx) + + # Window + + @impl true + def open(opts) do + wx = Keyword.fetch!(opts, :wx) + title = Keyword.fetch!(opts, :title) + size = Keyword.get(opts, :size, {600, 500}) + min_size = Keyword.get(opts, :min_size) + icon = Keyword.get(opts, :icon) + + frame = + Null.wx_call(:wxFrame, :new, [ + wx, + Wx.wxID_ANY(), + title, + [{:size, size}, {:style, Wx.wxDEFAULT_FRAME_STYLE()}] + ]) + + if min_size, do: Null.wx_call(:wxFrame, :setMinSize, [frame, min_size]) + + Null.wx_call(:wxFrame, :setSizer, [ + frame, + Null.wx_call(:wxBoxSizer, :new, [Wx.wxHORIZONTAL()]) + ]) + + {:ok, icon} = + case icon do + nil -> default_icon() + other -> {:ok, other} + end + + Null.wx_call(:wxTopLevelWindow, :setIcon, [frame, icon]) + webview = Desktop.Platform.Content.attach(frame) + {:ok, frame, webview} + end + + @impl true + def destroy_frame(frame), do: Null.wx_call(:wxFrame, :destroy, [frame]) || :ok + + @impl true + def connect(frame, :close_window, fun) do + Null.wx_call(:wxFrame, :connect, [frame, :close_window, callback: fun]) + :ok + end + + def connect(frame, :activate, fun) do + Null.wx_call(:wxFrame, :connect, [frame, :activate, callback: fun]) + :ok + end + + @impl true + def show(frame, opts) do + show = Keyword.get(opts, :show, true) + Null.wx_call(:wxFrame, :show, [frame, [show: show]]) + :ok + end + + @impl true + def hide(frame) do + Null.wx_call(:wxFrame, :hide, [frame]) + :ok + end + + @impl true + def set_title(frame, title) do + Null.wx_call(:wxFrame, :setTitle, [frame, String.to_charlist(title)]) + :ok + end + + @impl true + def set_min_size(frame, size), do: Null.wx_call(:wxFrame, :setMinSize, [frame, size]) || :ok + + @impl true + def set_icon(frame, icon) do + Null.wx_call(:wxTopLevelWindow, :setIcon, [frame, icon]) + :ok + end + + @impl true + def set_menubar(frame, menubar) do + Null.wx_call(:wxFrame, :setMenuBar, [frame, menubar]) + :ok + end + + @impl true + def iconize(frame, iconize) do + Null.wx_call(:wxTopLevelWindow, :iconize, [frame, [iconize: iconize]]) + :ok + end + + @impl true + def is_shown?(frame), do: Null.wx_call(:wxWindow, :isShown, [frame]) || false + + @impl true + def is_active?(frame), do: Null.wx_call(:wxTopLevelWindow, :isActive, [frame]) || false + + @impl true + def raise_window(frame) do + OS.raise_frame(frame) + :ok + end + + @impl true + def update_apple_menu(title, frame, menubar) do + menu = Null.wx_call(:wxMenuBar, :oSXGetAppleMenu, [menubar]) + Null.wx_call(:wxMenu, :setTitle, [menu, title]) + + for item <- Null.wx_call(:wxMenu, :getMenuItems, [menu]) || [] do + if Null.wx_call(:wxMenuItem, :getId, [item]) == Wx.wxID_EXIT() do + Null.wx_call(:wxMenuItem, :setText, [item, "Quit #{title}\tCtrl+Q"]) + else + Null.wx_call(:wxMenu, :delete, [menu, item]) + end + end + + Null.wx_call(:wxFrame, :connect, [frame, :command_menu_selected]) + :ok + end + + @impl true + def new_menubar, do: Null.wx_call(:wxMenuBar, :new, []) + + @impl true + def on_crash_destroy(frame), do: destroy_frame(frame) + + @impl true + def close_event_veto(inev) do + if Null.wx_call(:wxCloseEvent, :canVeto, [inev]) do + Null.wx_call(:wxCloseEvent, :veto, [inev]) + end + + :ok + end + + # Content + + @impl true + def attach(frame) do + with :ok <- check_has_webview(), + sizer <- clear_windows(frame), + {:ok, webview} <- do_webview_new(frame) do + Null.wx_call(:wxWebView, :connect, [webview, :webview_newwindow]) + Null.wx_call(:wxWebView, :connect, [webview, :webview_error]) + Null.wx_call(:wxWebView, :enableContextMenu, [webview, [enable: false]]) + + Null.wx_call(:wxBoxSizer, :add, [sizer, webview, [proportion: 1, flag: Wx.wxEXPAND()]]) + Null.wx_call(:wxSizer, :layout, [sizer]) + Null.wx_call(:wxSizer, :show, [sizer, true]) + Null.wx_call(:wxFrame, :refresh, [frame]) + webview + else + {:error, _} -> nil + end + end + + @impl true + def load_url(nil, _frame, url), do: OS.launch_default_browser(url) + + def load_url(webview, _frame, url) do + Null.wx_call(:wxWebView, :loadURL, [webview, url]) + :ok + end + + @impl true + def current_url(nil, last_url), do: last_url + + def current_url(webview, _last_url) do + case Null.wx_call(:wxWebView, :getCurrentURL, [webview]) do + url when is_list(url) -> List.to_string(url) + other -> other + end + end + + @impl true + def content_show(nil, _frame, url, _), do: OS.launch_default_browser(url) + + def content_show(webview, frame, url, _only_open) do + if url, do: Null.wx_call(:wxWebView, :loadURL, [webview, url]) + + if Null.wx_call(:wxTopLevelWindow, :isIconized, [frame]) do + Null.wx_call(:wxTopLevelWindow, :iconize, [frame, [iconize: false]]) + end + + if not Null.wx_call(:wxWindow, :isShown, [frame]) do + Null.wx_call(:wxWindow, :show, [frame, [show: true]]) + Null.wx_call(:wxTopLevelWindow, :centerOnScreen, [frame]) + end + + OS.raise_frame(frame) + :ok + end + + @impl true + def rebuild(nil, _url), do: nil + + def rebuild(frame, url) do + webview = attach(frame) + if url, do: Null.wx_call(:wxWebView, :loadURL, [webview, url]) + webview + end + + @impl true + def put_webview_backend(name) do + Desktop.Env.put(:webview_backend, name) + :ok + end + + # Notification + + @impl true + def new(title, type) do + if Null.module?(:wxNotificationMessage) do + flag = + case type do + :info -> Wx.wxICON_INFORMATION() + :warning -> Wx.wxICON_WARNING() + :error -> Wx.wxICON_ERROR() + end + + notification = Null.wx_call(:wxNotificationMessage, :new, [title, [flags: flag]]) + + if notification_events_available?() do + for event <- [ + :notification_message_click, + :notification_message_dismissed, + :notification_message_action + ] do + Null.wx_call(:wxNotificationMessage, :connect, [notification, event]) + end + end + + notification + end + end + + @impl true + def notification_show(nil, message, _timeout, title) do + require Logger + Logger.notice("NOTIFICATION: #{title}: #{message}") + :ok + end + + def notification_show(notification, message, timeout, title) do + if title, + do: Null.wx_call(:wxNotificationMessage, :setTitle, [notification, to_charlist(title)]) + + Null.wx_call(:wxNotificationMessage, :setMessage, [notification, to_charlist(message)]) + Null.wx_call(:wxNotificationMessage, :show, [notification, [timeout: timeout]]) + :ok + end + + @impl true + def close(notification) do + Null.wx_call(:wxNotificationMessage, :close, [notification]) + :ok + end + + # Media + + @impl true + def load_image(app, path) do + image = Null.wx_call(:wxImage, :new, [get_abs_path(app, path)]) + + image = + if Null.wx_call(:wxImage, :isOk, [image]) do + image + else + fallback = Null.wx_call(:wxArtProvider, :getBitmap, ["wxART_ERROR"]) + image = Null.wx_call(:wxBitmap, :convertToImage, [fallback]) + Null.wx_call(:wxBitmap, :destroy, [fallback]) + image + end + + {:ok, image} + end + + @impl true + def new_icon(app, path) do + with {:ok, image} <- load_image(app, path), do: new_icon_from(image) + end + + @impl true + def new_icon_from(image) do + case object_type(image) do + :wxImage -> + bitmap = Null.wx_call(:wxBitmap, :new, [image]) + icon = Null.wx_call(:wxIcon, :new, []) + Null.wx_call(:wxIcon, :copyFromBitmap, [icon, bitmap]) + media_destroy(bitmap) + {:ok, icon} + + :wxBitmap -> + icon = Null.wx_call(:wxIcon, :new, []) + Null.wx_call(:wxIcon, :copyFromBitmap, [icon, image]) + {:ok, icon} + + :wxIcon -> + {:ok, image} + end + end + + @impl true + def default_icon, do: {:ok, Null.wx_call(:wxArtProvider, :getIcon, ["wxART_EXECUTABLE_FILE"])} + + @impl true + def media_destroy(image) do + module = object_type(image) + Null.wx_call(module, :destroy, [image]) + :ok + end + + @impl true + def object_type(image), do: Null.wx_call(:wx, :getObjectType, [image]) + + defp check_has_webview do + if Null.module?(:wxWebView), do: :ok, else: {:error, :no_webview} + end + + defp clear_windows(frame) do + sizer = Null.wx_call(:wxFrame, :getSizer, [frame]) + Null.wx_call(:wxSizer, :clear, [sizer, [delete_windows: true]]) + sizer + end + + defp backend_available?(backend) do + try do + Null.wx_call(:wxWebView, :isBackendAvailable, [String.to_charlist(backend)]) + rescue + _ -> false + end + end + + defp do_webview_new(frame) do + env = System.get_env("WX_WEBVIEW_BACKEND", "none") + + cond do + backend_available?(env) -> + do_webview_new(frame, backend: String.to_charlist(env)) + + backend_available?("wxWebViewChromium") -> + do_webview_new(frame, backend: ~c"wxWebViewChromium") + + backend_available?("wxWebViewEdge") -> + do_webview_new(frame, backend: ~c"wxWebViewEdge") + + OS.type() == Windows -> + {:error, :missing_edge} + + true -> + do_webview_new(frame, []) + end + end + + defp do_webview_new(frame, opts) do + put_webview_backend(Keyword.get(opts, :backend, "default") |> to_string()) + + try do + {:ok, Null.wx_call(:wxWebView, :new, [frame, -1, [{:style, Wx.wxNO_BORDER()} | opts]])} + rescue + _ -> {:error, :no_webview} + end + end + + defp notification_events_available? do + {Wx.wxMAJOR_VERSION(), Wx.wxMINOR_VERSION(), Wx.wxRELEASE_NUMBER()} + |> case do + {major, minor, _} when major >= 3 and minor >= 1 -> true + _ -> false + end + end + + defp get_abs_path(_, "/" <> path), do: path + defp get_abs_path(app, name), do: Application.app_dir(app, ["priv", name]) +end diff --git a/lib/desktop/bridge/codec.ex b/lib/desktop/bridge/codec.ex new file mode 100644 index 0000000..1e192df --- /dev/null +++ b/lib/desktop/bridge/codec.ex @@ -0,0 +1,59 @@ +defmodule Desktop.Bridge.Codec do + @moduledoc false + + def encode!(var) do + pre_encode!(var) + |> Jason.encode!() + end + + def decode!(json) do + Jason.decode!(json) |> decode() + end + + def pre_encode!(var) do + case var do + tuple when is_tuple(tuple) -> + pre_encode!(%{_type: :tuple, value: Tuple.to_list(tuple)}) + + pid when is_pid(pid) -> + pre_encode!(%{_type: :pid, value: List.to_string(:erlang.pid_to_list(pid))}) + + fun when is_function(fun) -> + pre_encode!(%{_type: :fun, value: Desktop.Bridge.Transport.register_fun(fun)}) + + list when is_list(list) -> + Enum.map(list, &pre_encode!/1) + + map when is_map(map) -> + Enum.reduce(map, %{}, fn {key, value}, map -> + Map.put(map, pre_encode!(key), pre_encode!(value)) + end) + + atom when is_atom(atom) -> + ":" <> Atom.to_string(atom) + + other -> + other + end + end + + defp decode(list) when is_list(list), do: Enum.map(list, &decode/1) + defp decode(":" <> name), do: String.to_atom(name) + + defp decode(map) when is_map(map) do + Enum.reduce(map, %{}, fn {key, value}, ret -> + Map.put(ret, decode(key), decode(value)) + end) + |> decode_map() + end + + defp decode(other), do: other + + defp decode_map(%{_type: :tuple, value: tuple}), do: List.to_tuple(tuple) + + defp decode_map(%{_type: :pid, value: pid}) do + :erlang.list_to_pid(String.to_charlist(pid)) + end + + defp decode_map(other), do: other +end diff --git a/lib/desktop/bridge/mock.ex b/lib/desktop/bridge/mock.ex new file mode 100644 index 0000000..5596dc8 --- /dev/null +++ b/lib/desktop/bridge/mock.ex @@ -0,0 +1,29 @@ +defmodule Desktop.Bridge.Mock do + @moduledoc false + + def send(_pid, message) do + handle_message(message, self()) + end + + def handle_message(<>, from) do + response = handle_method(Desktop.Bridge.Codec.decode!(json)) + reply = Desktop.Bridge.Codec.encode!(response) + Kernel.send(from, {:tcp, __MODULE__, <>}) + end + + def handle_method([:wx, :getObjectType, [arg]]), do: Keyword.get(arg, :type) + def handle_method([:wxLocale | _]), do: ~c"en" + + def handle_method([type, :new | args]), + do: [id: System.unique_integer([:positive]), type: type, args: args] + + def handle_method([:wxMenuBar, :getMenuCount | _]), do: 0 + + def handle_method([_module, method | _]) do + case Atom.to_string(method) do + <<"is", _::binary>> -> true + <<"set", _::binary>> -> true + _other -> :ok + end + end +end diff --git a/lib/desktop/bridge/protocol.ex b/lib/desktop/bridge/protocol.ex new file mode 100644 index 0000000..c5a321e --- /dev/null +++ b/lib/desktop/bridge/protocol.ex @@ -0,0 +1,24 @@ +defmodule Desktop.Bridge.Protocol do + @moduledoc false + + alias Desktop.Bridge.Transport + + @doc """ + Legacy bridge RPC: `[module, method, args]` JSON encoding. + """ + def call(module, method, args \\ []) do + Transport.bridge_call(module, method, args) + end + + def new(module, args \\ []) do + Transport.bridge_call(module, :new, args) + end + + def connect(module, object, event, opts \\ []) do + Transport.bridge_call(module, :connect, [object, event, opts]) + end + + def destroy(module, object) do + Transport.bridge_call(module, :destroy, [object]) + end +end diff --git a/lib/desktop/bridge/transport.ex b/lib/desktop/bridge/transport.ex new file mode 100644 index 0000000..df2c94b --- /dev/null +++ b/lib/desktop/bridge/transport.ex @@ -0,0 +1,223 @@ +defmodule Desktop.Bridge.Transport do + @moduledoc false + use GenServer + require Logger + + @name __MODULE__ + + defstruct port: nil, + socket: nil, + send: nil, + requests: %{}, + funs: %{}, + events: [], + subscribers: [], + last_url: nil + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: @name) + end + + def ensure_started do + case Process.whereis(@name) do + nil -> + {:ok, pid} = start_link([]) + pid + + pid -> + pid + end + end + + def register_fun(fun) do + GenServer.call(@name, {:register_fun, fun}) + end + + def subscribe_events(pid) do + GenServer.call(@name, {:subscribe_events, pid}) + end + + def bridge_call(:wx, :batch, [fun]), do: fun.() + def bridge_call(:wx, :set_env, _args), do: :ok + def bridge_call(:wx, :get_env, _args), do: :ok + def bridge_call(:wx, :getObjectType, [obj]), do: Keyword.get(obj, :type) + + def bridge_call(:wxWebView, :loadURL, [obj, uri]) do + GenServer.cast(@name, {:last_url, uri}) + do_bridge_call(:wxWebView, :loadURL, [obj, uri]) + end + + def bridge_call(:wx, :new, _args), do: ensure_started() + + def bridge_call(type, :new, args) do + [id: System.unique_integer([:positive]), type: type, args: args] + end + + def bridge_call(_type, :getId, args), do: Keyword.get(args, :id) + + def bridge_call(module, :connect, args) do + ref = System.unique_integer([:positive]) + 10 + json = Desktop.Bridge.Codec.encode!([module, :connect, args]) + + GenServer.cast(@name, {:bridge_call, ref, json}) + :ok + end + + def bridge_call(module, method, args) do + do_bridge_call(module, method, args) + end + + defp do_bridge_call(module, method, args) do + ref = System.unique_integer([:positive]) + 10 + json = Desktop.Bridge.Codec.encode!([module, method, args]) + + case GenServer.call(@name, {:bridge_call, ref, json}) do + response when is_binary(response) -> Desktop.Bridge.Codec.decode!(response) + other -> other + end + end + + @impl true + def init(_opts) do + port = String.to_integer(System.get_env("BRIDGE_PORT", "0")) + + {socket, send} = + if port == 0 do + {Desktop.Bridge.Mock, &Desktop.Bridge.Mock.send/2} + else + {:ok, socket} = + :gen_tcp.connect(~c"127.0.0.1", port, packet: 4, active: true, mode: :binary) + + {socket, &:gen_tcp.send/2} + end + + {:ok, + %__MODULE__{ + port: port, + socket: socket, + send: send + }} + end + + @impl true + def handle_cast({:bridge_call, ref, json}, state) do + case handle_call({:bridge_call, ref, json}, nil, state) do + {:reply, _ret, state} -> {:noreply, state} + {:noreply, state} -> {:noreply, state} + end + end + + def handle_cast({:last_url, uri}, %__MODULE__{} = state) do + {:noreply, %__MODULE__{state | last_url: uri}} + end + + @impl true + def handle_call( + {:subscribe_events, pid}, + _from, + state = %__MODULE__{events: events, subscribers: subs} + ) do + for event <- events do + send(pid, event) + end + + {:reply, :ok, %__MODULE__{state | events: [], subscribers: [pid | subs]}} + end + + def handle_call( + {:bridge_call, ref, json}, + from, + state = %__MODULE__{socket: socket, requests: reqs, send: send} + ) do + if socket do + message = <> + send.(socket, message) + {:noreply, %__MODULE__{state | requests: Map.put(reqs, ref, {from, message})}} + else + {:reply, ":ok", state} + end + end + + def handle_call({:register_fun, fun}, _from, state = %__MODULE__{funs: funs}) do + ref = System.unique_integer([:positive]) + {:reply, ref, %__MODULE__{state | funs: Map.put(funs, ref, fun)}} + end + + @impl true + def handle_info( + {:tcp, _port, <<0::unsigned-size(64), json::binary>>}, + state = %__MODULE__{subscribers: subs, events: events} + ) do + event = Desktop.Bridge.Codec.decode!(json) + + if subs == [] do + {:noreply, %__MODULE__{state | events: events ++ [event]}} + else + for sub <- subs, do: send(sub, event) + {:noreply, state} + end + end + + def handle_info( + {:tcp, _port, <<1::unsigned-size(64), fun_ref::unsigned-size(64), json::binary>>}, + state = %__MODULE__{funs: funs} + ) do + args = Desktop.Bridge.Codec.decode!(json) + + case Map.get(funs, fun_ref) do + nil -> :ok + fun -> spawn(fn -> apply(fun, args) end) + end + + {:noreply, state} + end + + def handle_info({:tcp, _port, <<2::unsigned-size(64), json::binary>>}, state) do + json = Desktop.Bridge.Codec.decode!(json) + payload = json[:payload] + pid = json[:pid] + + if is_pid(pid), do: send(pid, payload) + + {:noreply, state} + end + + def handle_info( + {:tcp, _port, <>}, + state = %__MODULE__{requests: reqs} + ) do + {from, message} = Map.fetch!(reqs, ref) + + if json == "use_mock" do + Desktop.Bridge.Mock.send(Desktop.Bridge.Mock, message) + {:noreply, state} + else + if from, do: GenServer.reply(from, json) + {:noreply, %__MODULE__{state | requests: Map.delete(reqs, ref)}} + end + end + + def handle_info({:tcp_error, socket, reason}, state = %__MODULE__{socket: socket}) do + Logger.error("Bridge connection failed: #{inspect(reason)}") + {:noreply, try_reconnect(state)} + end + + def handle_info({:tcp_closed, socket}, state = %__MODULE__{socket: socket}) do + Logger.error("Bridge connection closed") + {:noreply, try_reconnect(state)} + end + + def handle_info(_other, state), do: {:noreply, state} + + defp try_reconnect(state = %__MODULE__{port: port, last_url: last_url}) do + case :gen_tcp.connect(~c"127.0.0.1", port, [packet: 4, active: true, mode: :binary], 1_000) do + {:ok, socket} -> + if last_url, do: spawn(fn -> bridge_call(:wxWebView, :loadURL, [nil, last_url]) end) + %__MODULE__{state | socket: socket} + + {:error, _} -> + Process.sleep(1_000) + try_reconnect(state) + end + end +end diff --git a/lib/desktop/env.ex b/lib/desktop/env.ex index ad2ce74..7906ed6 100644 --- a/lib/desktop/env.ex +++ b/lib/desktop/env.ex @@ -30,12 +30,12 @@ defmodule Desktop.Env do @doc false @impl true def init(_arg) do - wx = Desktop.Fallback.wx_new([]) - Desktop.Fallback.wx_subscribe() + {wx, wx_env} = Desktop.Platform.System.init_env() + Desktop.Platform.System.subscribe_events() {:ok, %Env{ - wx_env: Desktop.Fallback.wx_get_env(), + wx_env: wx_env, wx: wx, map: %{}, waiters: %{}, @@ -112,9 +112,7 @@ defmodule Desktop.Env do end def handle_call({:connect, object, command, callback, id}, _from, d) do - opts = [{:callback, fn _, _ -> callback.() end}] - opts = if id == nil, do: opts, else: [{:id, id} | opts] - ret = :wxMenu.connect(object, command, opts) + ret = Desktop.Platform.System.connect_menu(object, command, callback, id) {:reply, ret, d} end @@ -134,10 +132,8 @@ defmodule Desktop.Env do @impl true def handle_info({:reopen_app, []}, state = %Env{windows: windows}) do - # Handling MacOS event when the app icon is clicked again case windows do [window | _] -> - # Avoiding constant reopen loops Debouncer.immediate2({Desktop, :reopen}, fn -> Desktop.Window.show(window) end, 500) [] -> @@ -147,18 +143,9 @@ defmodule Desktop.Env do {:noreply, state} end - # Reconnect is a mobile Bridge specific event issued - # when the Server Sockets need to be re-restablished def handle_info(:reconnect, state = %Env{}) do - # There seems to be an iOS bug where listening ports - # are "zombied" after hibernation and not restarted - # correctly. To rectify this situation we're re-creating them - # all here - if Desktop.OS.type() == IOS do for endpoint <- endpoints() do - IO.puts("reconnect: #{inspect(endpoint)}") - if Kernel.function_exported?(:ranch, :suspend_listener, 1) do apply(:ranch, :suspend_listener, [endpoint]) apply(:ranch, :resume_listener, [endpoint]) @@ -241,7 +228,7 @@ defmodule Desktop.Env do """ def wx_use_env() do with env when env != nil <- wx_env() do - :wx.set_env(env) + Desktop.Platform.System.set_env(env) end end @@ -296,8 +283,6 @@ defmodule Desktop.Env do end defp init_sni() do - # We're protecting the main application from any possible - # side effects of the SNI startup using a monitored (not linked) process: {task, ref} = spawn_monitor(fn -> exit(do_init_sni()) end) receive do diff --git a/lib/desktop/fallback.ex b/lib/desktop/fallback.ex index 9c600d3..0744fee 100644 --- a/lib/desktop/fallback.ex +++ b/lib/desktop/fallback.ex @@ -1,253 +1,65 @@ defmodule Desktop.Fallback do - require Logger - alias Desktop.{Wx, OS} - @moduledoc """ Fallback handles version differences in the :wx modules needed for showing the WebView and Desktop notifications and it uses the highest available feature level while trying to stays backwards compatible to older :wx versions. - """ - - def webview_new(frame) do - with :ok <- check_has_webview(), - sizer <- clear_windows(frame), - {:ok, webview} <- do_webview_new(frame) do - call(:wxWebView, :connect, [webview, :webview_newwindow]) - call(:wxWebView, :connect, [webview, :webview_error]) - call(:wxWebView, :enableContextMenu, [webview, [enable: false]]) - - :wxBoxSizer.add(sizer, webview, proportion: 1, flag: Wx.wxEXPAND()) - :wxSizer.layout(sizer) - :wxSizer.show(sizer, true) - :wxFrame.refresh(frame) - webview - else - {:error, reason} -> - Logger.warning(reason) - nil - end - end - - defp check_has_webview() do - if module?(:wxWebView) do - :ok - else - {:error, "Missing support for wxWebView - upgrade to OTP/24. Will show OS browser instead"} - end - end - - defp clear_windows(frame) do - sizer = :wxFrame.getSizer(frame) - :wxSizer.clear(sizer, delete_windows: true) - sizer - end - - defp backend_available?(backend) do - try do - call(:wxWebView, :isBackendAvailable, [String.to_charlist(backend)]) - rescue - _ in ErlangError -> false - end - end - defp do_webview_new(frame) do - cond do - backend_available?(webview_backend_env()) -> - do_webview_new(frame, backend: String.to_charlist(webview_backend_env())) - - backend_available?("wxWebViewChromium") -> - do_webview_new(frame, backend: ~c"wxWebViewChromium") - - backend_available?("wxWebViewEdge") -> - do_webview_new(frame, backend: ~c"wxWebViewEdge") - - OS.type() == Windows -> - error_missing_edge(frame) - - true -> - do_webview_new(frame, []) - end - end - - defp do_webview_new(frame, opts) do - Desktop.Env.put(:webview_backend, Keyword.get(opts, :backend, "default")) + Delegates to `Desktop.Platform` backends. + """ - try do - {:ok, call(:wxWebView, :new, [frame, -1, [{:style, Desktop.Wx.wxNO_BORDER()} | opts]])} - rescue - _ -> {:error, "Your erlang-wx is missing wxWebView support. Will show OS browser instead"} - end - end + alias Desktop.Platform - def webview_backend_env() do - System.get_env("WX_WEBVIEW_BACKEND", "none") - end + def webview_new(frame), do: Platform.Content.attach(frame) - defp error_missing_edge(frame) do - win = :wxHtmlWindow.new(frame, []) - - :wxHtmlWindow.setPage(win, """ - - -

Missing Edge Runtime

-

This demo requires the edge runtime to be installed

-

Please download it here and try again

-

- https://go.microsoft.com/fwlink/p/?LinkId=2124703 -

- - - """) - - :wxHtmlWindow.connect(win, :command_html_link_clicked, skip: true) - - {:error, - """ - Missing support for wxWebViewEdge. - Check your OTP install for edge support and download it here: - https://go.microsoft.com/fwlink/p/?LinkId=2124703 - """} - end + def webview_backend_env, do: System.get_env("WX_WEBVIEW_BACKEND", "none") def webview_can_fix(nil), do: false def webview_can_fix(webview) do - module?(:wxWebView) and OS.type() == Windows and - backend_available?("wxWebViewEdge") and - call(:wxWebView, :isShownOnScreen, [webview]) + Platform.backend() == Desktop.Backend.Wx and + Desktop.Backend.Null.module?(:wxWebView) and + Desktop.OS.windows?() and + webview_backend_available?("wxWebViewEdge") and + Desktop.Backend.Null.wx_call(:wxWebView, :isShownOnScreen, [webview]) end def webview_url(%Desktop.Window{webview: nil, last_url: last_url}), do: last_url - def webview_url(%Desktop.Window{webview: webview}) do - call(:wxWebView, :getCurrentURL, [webview]) + def webview_url(%Desktop.Window{webview: webview, last_url: last_url}) do + Platform.Content.current_url(webview, last_url) end - def webview_load(%Desktop.Window{webview: nil}, url) do - OS.launch_default_browser(url) + def webview_load(%Desktop.Window{} = window, url) do + Platform.Content.load_url(window.webview, window.frame, url) end - def webview_load(%Desktop.Window{webview: webview}, url) do - call(:wxWebView, :loadURL, [webview, url]) + def webview_show(%Desktop.Window{} = window, url, only_open) do + Platform.Content.show(window.webview, window.frame, url, only_open) end - def webview_show(%Desktop.Window{webview: nil}, url, _) do - OS.launch_default_browser(url) - end - - def webview_show( - %Desktop.Window{webview: webview, frame: frame, last_url: last}, - url, - only_open - ) do - if last == nil or not only_open do - call(:wxWebView, :loadURL, [webview, url]) - end - - if :wxTopLevelWindow.isIconized(frame) do - :wxTopLevelWindow.iconize(frame, iconize: false) - end - - if not :wxWindow.isShown(frame) do - :wxWindow.show(frame, show: true) - :wxTopLevelWindow.centerOnScreen(frame) - end - - OS.raise_frame(frame) - end - - def webview_rebuild(%Desktop.Window{webview: nil}), do: nil - def webview_rebuild(%Desktop.Window{frame: frame, last_url: url}) do - # url = call(:wxWebView, :getCurrentURL, [webview]) - webview = webview_new(frame) - Logger.info("Rebuilding WebView on Windows with url: #{inspect(url)}") - - if url != nil do - call(:wxWebView, :loadURL, [webview, url]) - end - - webview + Platform.Content.rebuild(frame, url) end - def notification_new(title, type) do - if module?(:wxNotificationMessage) do - flag = - case type do - :info -> Wx.wxICON_INFORMATION() - :warning -> Wx.wxICON_WARNING() - :error -> Wx.wxICON_ERROR() - end - - notification = call(:wxNotificationMessage, :new, [title, [flags: flag]]) - - if notification_events_available?() do - for event <- [ - :notification_message_click, - :notification_message_dismissed, - :notification_message_action - ] do - call(:wxNotificationMessage, :connect, [notification, event]) - end - else - Logger.warning( - "Missing support for wxNotificationMessage Events - upgrade to wxWidgets 3.1 - messages won't be clickable" - ) - end - - notification - else - Logger.warning( - "Missing support for wxNotificationMessage - upgrade to OTP/24. Messages will be logged only" - ) - end - end + def notification_new(title, type), do: Platform.Notification.new(title, type) def notification_show(notification, message, timeout, title \\ nil) do - if module?(:wxNotificationMessage) do - if title != nil do - call(:wxNotificationMessage, :setTitle, [notification, to_charlist(title)]) - end - - call(:wxNotificationMessage, :setMessage, [notification, to_charlist(message)]) - call(:wxNotificationMessage, :show, [notification, [timeout: timeout]]) - else - Logger.notice("NOTIFICATION: #{title}: #{message}") - end + Platform.Notification.show(notification, message, timeout, title) end - def notification_close(notification) do - call(:wxNotificationMessage, :close, [notification]) - end + def notification_close(notification), do: Platform.Notification.close(notification) - def wx_subscribe() do - call(:wx, :subscribe_events) - end - - def wx_new(opts) do - call(:wx, :new, [opts]) - end + def wx_subscribe, do: Platform.System.subscribe_events() - def wx_get_env() do - call(:wx, :get_env) - end + def wx_new(_opts), do: Platform.System.init_env() |> elem(0) - defp module?(module) do - Code.ensure_compiled(module) == {:module, module} - end + def wx_get_env, do: Platform.System.get_env() - defp notification_events_available?() do - {Wx.wxMAJOR_VERSION(), Wx.wxMINOR_VERSION(), Wx.wxRELEASE_NUMBER()} - |> case do - {major, minor, _} when major >= 3 and minor >= 1 -> true + defp webview_backend_available?(backend) do + try do + Desktop.Backend.Null.wx_call(:wxWebView, :isBackendAvailable, [String.to_charlist(backend)]) + rescue _ -> false end end - - defp call(module, method, args \\ []) do - if System.get_env("NO_WX") == nil and Code.ensure_loaded?(module) and - Kernel.function_exported?(module, method, length(args)) do - apply(module, method, args) - end - end end diff --git a/lib/desktop/image.ex b/lib/desktop/image.ex index 466a037..fe0d51f 100644 --- a/lib/desktop/image.ex +++ b/lib/desktop/image.ex @@ -2,57 +2,13 @@ defmodule Desktop.Image do require Logger @moduledoc false - def new(app, path) when is_binary(path) do - image = :wxImage.new(get_abs_path(app, path)) + alias Desktop.Platform.Media - image = - if :wxImage.isOk(image) do - image - else - Logger.error("Could not load image #{get_abs_path(app, path)}") - fallback = :wxArtProvider.getBitmap("wxART_ERROR") - image = :wxBitmap.convertToImage(fallback) - :wxBitmap.destroy(fallback) - image - end + def new(app, path), do: Media.load_image(app, path) - {:ok, image} - end + def new_icon(app, path), do: Media.new_icon(app, path) - def new_icon(app, path) do - {:ok, image} = new(app, path) - new_icon(image) - end + def new_icon(image), do: Media.new_icon_from(image) - def new_icon(image) do - case :wx.getObjectType(image) do - :wxImage -> - bitmap = :wxBitmap.new(image) - icon = :wxIcon.new() - :ok = :wxIcon.copyFromBitmap(icon, bitmap) - destroy(bitmap) - {:ok, icon} - - :wxBitmap -> - icon = :wxIcon.new() - :ok = :wxIcon.copyFromBitmap(icon, image) - {:ok, icon} - - :wxIcon -> - {:ok, image} - end - end - - def destroy(image) do - module = :wx.getObjectType(image) - module.destroy(image) - end - - defp get_abs_path(_, abs_path = "/" <> _) do - abs_path - end - - defp get_abs_path(app, name) when is_binary(name) do - Application.app_dir(app, ["priv", name]) - end + def destroy(image), do: Media.destroy(image) end diff --git a/lib/desktop/menu.ex b/lib/desktop/menu.ex index 2d098f6..9ff5177 100644 --- a/lib/desktop/menu.ex +++ b/lib/desktop/menu.ex @@ -290,9 +290,15 @@ defmodule Desktop.Menu do app = Keyword.get(init_opts, :app, nil) adapter_module = - case Keyword.get(init_opts, :adapter, Adapter.Wx) do - mod when mod in [Adapter.Wx, Adapter.DBus] -> mod - _ -> Adapter.Wx + case Keyword.get(init_opts, :adapter) do + mod when mod in [Adapter.Wx, Adapter.DBus, Adapter.Json, Adapter.Browser] -> + mod + + nil -> + Desktop.Platform.Menu.adapter(init_opts) + + _ -> + Desktop.Platform.Menu.adapter(init_opts) end adapter_opts = diff --git a/lib/desktop/menu/adapters/browser.ex b/lib/desktop/menu/adapters/browser.ex new file mode 100644 index 0000000..c5fbd20 --- /dev/null +++ b/lib/desktop/menu/adapters/browser.ex @@ -0,0 +1,19 @@ +defmodule Desktop.Menu.Adapter.Browser do + @moduledoc false + + defstruct [:menu_pid] + + @type t() :: %__MODULE__{menu_pid: pid() | nil} + + def new(opts) do + %__MODULE__{menu_pid: Keyword.get(opts, :menu_pid)} + end + + def create(adapter, _dom), do: adapter + def update_dom(adapter, _dom), do: adapter + def popup_menu(adapter), do: adapter + def recreate_menu(adapter, _dom), do: adapter + def menubar(_adapter), do: nil + def get_icon(_adapter), do: nil + def set_icon(adapter, _icon), do: {:ok, adapter} +end diff --git a/lib/desktop/menu/adapters/json.ex b/lib/desktop/menu/adapters/json.ex new file mode 100644 index 0000000..fde34d3 --- /dev/null +++ b/lib/desktop/menu/adapters/json.ex @@ -0,0 +1,180 @@ +defmodule Desktop.Menu.Adapter.Json do + @moduledoc false + + alias Desktop.Bridge.Protocol + alias Desktop.{Wx, OS} + alias Desktop.Wx.TaskBarIcon + + require Logger + + defstruct [ + :menu_pid, + :env, + :menubar, + :menubar_opts, + :taskbar_icon + ] + + @type t() :: %__MODULE__{ + menu_pid: pid() | nil, + env: any(), + menubar: any(), + menubar_opts: any(), + taskbar_icon: TaskBarIcon.t() | nil + } + + def new(opts) do + %__MODULE__{ + env: Keyword.get(opts, :env), + menu_pid: Keyword.get(opts, :menu_pid), + menubar: nil, + menubar_opts: Keyword.get(opts, :wx), + taskbar_icon: nil + } + end + + def create(adapter = %__MODULE__{menubar_opts: menubar_opts}, dom) do + create_menubar(adapter, menubar_opts, dom) + end + + def update_dom(adapter, dom), do: create_menu(adapter, dom) + def popup_menu(adapter), do: do_popup_menu(adapter, :taskbar_click) + def recreate_menu(adapter, dom), do: create_menu(adapter, dom) + def menubar(%__MODULE__{menubar: menubar}), do: menubar + def get_icon(%__MODULE__{taskbar_icon: nil}), do: nil + def get_icon(%__MODULE__{taskbar_icon: _}), do: nil + + def set_icon(%__MODULE__{menubar: nil}, _), do: {:error, "Cannot set icon on `nil` taskbar"} + def set_icon(adapter = %__MODULE__{taskbar_icon: nil}, nil), do: {:ok, adapter} + + def set_icon(adapter = %__MODULE__{taskbar_icon: taskbar_icon}, nil) do + TaskBarIcon.remove_icon(taskbar_icon) + {:ok, adapter} + end + + def set_icon(adapter = %__MODULE__{taskbar_icon: nil}, _icon), do: {:ok, adapter} + + def set_icon(adapter = %__MODULE__{taskbar_icon: taskbar_icon}, icon) do + TaskBarIcon.set_icon(taskbar_icon, icon) + {:ok, adapter} + end + + def handle_info(_event, adapter), do: {:noreply, adapter} + + defp do_popup_menu(adapter = %__MODULE__{taskbar_icon: taskbar_icon}, event) do + TaskBarIcon.popup_menu(taskbar_icon, event) + adapter + end + + defp create_menubar(adapter = %__MODULE__{}, {:taskbar, icon}, dom) do + menubar = Protocol.new(:wxMenuBar, []) + adapter = %{adapter | menubar: menubar} + + create_popup = fn -> create_popup_menu(adapter) end + + taskbar_icon = + if OS.mobile?() do + nil + else + case TaskBarIcon.create(create_popup) do + {:ok, taskbar_icon} -> + TaskBarIcon.set_icon(taskbar_icon, icon) + taskbar_icon + + _ -> + nil + end + end + + create_menu(adapter, dom) + %{adapter | taskbar_icon: taskbar_icon} + end + + defp create_menubar(adapter = %__MODULE__{}, wx_ref, dom) do + adapter = + if wx_ref do + %{adapter | menubar: wx_ref, taskbar_icon: nil} + else + %{adapter | menubar: nil, taskbar_icon: nil} + end + + create_menu(adapter, dom) + end + + defp create_popup_menu(adapter = %__MODULE__{menubar: menubar}) do + num_menus = Protocol.call(:wxMenuBar, :getMenuCount, [menubar]) || 0 + + for _ <- 1..num_menus do + menu = Protocol.call(:wxMenuBar, :remove, [menubar, 0]) + Protocol.destroy(:wxMenu, menu) + end + + if adapter.menu_pid, do: GenServer.cast(adapter.menu_pid, :recreate_menu) + menubar + end + + defp create_menu(adapter = %__MODULE__{menubar: menubar}, dom) do + menus = build_menus(dom) + + for {label, menu} <- menus do + Protocol.call(:wxMenuBar, :append, [menubar, menu, label]) + end + + adapter + end + + defp create_menu(adapter, _), do: adapter + + defp build_menus({:menubar, _, children}), do: build_menus(children) + defp build_menus({:menu, attrs, children}), do: [{attrs[:label], build_menu_items(children)}] + defp build_menus(list) when is_list(list), do: Enum.flat_map(list, &build_menus/1) + defp build_menus(_), do: [] + + defp build_menu_items(children) do + Enum.reduce(children, Protocol.new(:wxMenu, []), fn + {:hr, _, _}, menu -> + Protocol.call(:wxMenu, :appendSeparator, [menu]) + menu + + {:item, attrs, content}, menu -> + kind = item_kind(attrs[:type]) + + item = + Protocol.new(:wxMenuItem, id: Wx.wxID_ANY(), text: List.flatten(content), kind: kind) + + id = Protocol.call(:wxMenuItem, :getId, [item]) + Protocol.call(:wxMenu, :append, [menu, item]) + + if attr_true?(attrs[:checked]) do + Protocol.call(:wxMenuItem, :check, [item, [check: true]]) + end + + if attr_true?(attrs[:disabled]) do + Protocol.call(:wxMenu, :enable, [menu, id, false]) + end + + if attrs[:onclick] do + Protocol.connect(:wxMenu, menu, :command_menu_selected, + userData: attrs[:onclick], + id: id + ) + end + + menu + + {:menu, attrs, content}, menu -> + submenu = build_menu_items(content) + Protocol.call(:wxMenu, :append, [menu, submenu, attrs[:label]]) + menu + end) + end + + defp item_kind("radio"), do: Wx.wxITEM_RADIO() + defp item_kind("checkbox"), do: Wx.wxITEM_CHECK() + defp item_kind(_), do: Wx.wxITEM_NORMAL() + + defp attr_true?(nil), do: false + defp attr_true?(false), do: false + defp attr_true?(0), do: false + defp attr_true?(_), do: true +end diff --git a/lib/desktop/platform.ex b/lib/desktop/platform.ex new file mode 100644 index 0000000..39a1c40 --- /dev/null +++ b/lib/desktop/platform.ex @@ -0,0 +1,80 @@ +defmodule Desktop.Platform do + @moduledoc false + + alias Desktop.{Backend, OS} + + @type capabilities :: %{ + window: boolean(), + content: :webview | :native | :os_browser, + notification: :wx | :native | :log, + menu: :wx | :native | :dbus | :none, + taskbar: boolean() + } + + @doc """ + Returns the active backend module implementing Platform behaviours. + """ + @spec backend() :: module() + def backend do + case Application.get_env(:desktop, :backend, :auto) do + :wx -> Backend.Wx + :json -> Backend.Json + :browser -> Backend.Browser + :auto -> detect_backend() + mod when is_atom(mod) and mod != :auto -> mod + end + end + + @doc """ + Capability map for the active backend. + """ + @spec capabilities() :: capabilities() + def capabilities do + backend().capabilities() + end + + @doc """ + Returns the webview backend name string stored in Env (wx-specific). + """ + @spec webview_backend_name() :: String.t() + def webview_backend_name do + Desktop.Env.get(:webview_backend, "nil") + end + + @doc """ + GenServer wrapper used instead of `:wx_object` when wx is not the process driver. + """ + @spec window_server() :: module() + def window_server do + if backend() == Backend.Wx and wx_object_available?() do + :wx_object + else + Desktop.Platform.Server + end + end + + defp detect_backend do + cond do + mobile_target?() -> Backend.Json + browser_mode?() -> Backend.Browser + true -> Backend.Wx + end + end + + defp mobile_target? do + Mix.target() in [:android, :ios] or OS.mobile?() + end + + defp browser_mode? do + System.get_env("NO_WX") != nil or not wx_app_available?() + end + + defp wx_app_available? do + :application.get_application(:wx) != {:error, :not_loaded} and + Code.ensure_loaded?(:wx) + end + + defp wx_object_available? do + Code.ensure_loaded?(:wx_object) and function_exported?(:wx_object, :start_link, 4) + end +end diff --git a/lib/desktop/platform/backend.ex b/lib/desktop/platform/backend.ex new file mode 100644 index 0000000..e775fb4 --- /dev/null +++ b/lib/desktop/platform/backend.ex @@ -0,0 +1,7 @@ +defmodule Desktop.Platform.Backend do + @moduledoc false + + @type capabilities :: Desktop.Platform.capabilities() + + @callback capabilities() :: capabilities() +end diff --git a/lib/desktop/platform/content.ex b/lib/desktop/platform/content.ex new file mode 100644 index 0000000..20da79e --- /dev/null +++ b/lib/desktop/platform/content.ex @@ -0,0 +1,28 @@ +defmodule Desktop.Platform.Content do + @moduledoc false + + @callback attach(frame :: term()) :: term() | nil + @callback load_url(content :: term() | nil, frame :: term() | nil, url :: String.t() | nil) :: + :ok + @callback current_url(content :: term() | nil, last_url :: String.t() | nil) :: String.t() | nil + @callback content_show( + content :: term() | nil, + frame :: term() | nil, + url :: String.t() | nil, + only_open :: boolean() + ) :: :ok + @callback rebuild(frame :: term() | nil, last_url :: String.t() | nil) :: term() | nil + @callback put_webview_backend(name :: String.t()) :: :ok + + def attach(frame), do: impl().attach(frame) + def load_url(content, frame, url), do: impl().load_url(content, frame, url) + def current_url(content, last_url), do: impl().current_url(content, last_url) + + def show(content, frame, url, only_open), + do: impl().content_show(content, frame, url, only_open) + + def rebuild(frame, last_url), do: impl().rebuild(frame, last_url) + def put_webview_backend(name), do: impl().put_webview_backend(name) + + defp impl, do: Desktop.Platform.backend() +end diff --git a/lib/desktop/platform/media.ex b/lib/desktop/platform/media.ex new file mode 100644 index 0000000..c69b039 --- /dev/null +++ b/lib/desktop/platform/media.ex @@ -0,0 +1,19 @@ +defmodule Desktop.Platform.Media do + @moduledoc false + + @callback load_image(app :: atom(), path :: String.t()) :: {:ok, term()} | {:error, term()} + @callback new_icon(app :: atom(), path :: String.t()) :: {:ok, term()} | {:error, term()} + @callback new_icon_from(term()) :: {:ok, term()} | {:error, term()} + @callback default_icon() :: {:ok, term()} + @callback media_destroy(term()) :: :ok + @callback object_type(term()) :: atom() + + def load_image(app, path), do: impl().load_image(app, path) + def new_icon(app, path), do: impl().new_icon(app, path) + def new_icon_from(image), do: impl().new_icon_from(image) + def default_icon, do: impl().default_icon() + def destroy(image), do: impl().media_destroy(image) + def object_type(image), do: impl().object_type(image) + + defp impl, do: Desktop.Platform.backend() +end diff --git a/lib/desktop/platform/menu.ex b/lib/desktop/platform/menu.ex new file mode 100644 index 0000000..59bdc3e --- /dev/null +++ b/lib/desktop/platform/menu.ex @@ -0,0 +1,50 @@ +defmodule Desktop.Platform.Menu do + @moduledoc false + + alias Desktop.Menu.Adapter + + @doc """ + Selects the menu adapter module for the current platform capabilities. + """ + @spec adapter(keyword()) :: module() + def adapter(opts \\ []) do + caps = Desktop.Platform.capabilities() + + cond do + Keyword.get(opts, :adapter) in [Adapter.Wx, Adapter.DBus, Adapter.Json, Adapter.Browser] -> + Keyword.get(opts, :adapter) + + Keyword.get(opts, :sni) != nil and Desktop.Platform.backend() == Desktop.Backend.Wx -> + Adapter.DBus + + caps.menu == :native -> + Adapter.Json + + caps.menu == :wx -> + Adapter.Wx + + true -> + Adapter.Browser + end + end + + @spec menubar_opts(keyword()) :: term() + def menubar_opts(opts) do + caps = Desktop.Platform.capabilities() + + cond do + match?({:taskbar, _}, Keyword.get(opts, :wx)) -> + Keyword.get(opts, :wx) + + caps.menu == :wx -> + menubar_new() + + true -> + nil + end + end + + defp menubar_new do + Desktop.Platform.backend().new_menubar() + end +end diff --git a/lib/desktop/platform/notification.ex b/lib/desktop/platform/notification.ex new file mode 100644 index 0000000..c6c553d --- /dev/null +++ b/lib/desktop/platform/notification.ex @@ -0,0 +1,21 @@ +defmodule Desktop.Platform.Notification do + @moduledoc false + + @callback new(title :: String.t(), type :: atom()) :: term() | nil + @callback notification_show( + notification :: term() | nil, + message :: String.t(), + timeout :: integer(), + title :: String.t() | nil + ) :: :ok + @callback close(notification :: term() | nil) :: :ok + + def new(title, type), do: impl().new(title, type) + + def show(notification, message, timeout, title \\ nil), + do: impl().notification_show(notification, message, timeout, title) + + def close(notification), do: impl().close(notification) + + defp impl, do: Desktop.Platform.backend() +end diff --git a/lib/desktop/platform/server.ex b/lib/desktop/platform/server.ex new file mode 100644 index 0000000..430447e --- /dev/null +++ b/lib/desktop/platform/server.ex @@ -0,0 +1,63 @@ +defmodule Desktop.Platform.Server do + @moduledoc false + use GenServer + + defstruct frame: nil, state: nil, module: nil + + def start_link(name, module, args, _flags \\ []) do + name = + case name do + {:local, name} -> name + name -> name + end + + {:ok, pid} = GenServer.start_link(__MODULE__, {module, args}, name: name) + {:ref, 0, __MODULE__, pid} + end + + @impl true + def init({module, args}) do + {frame, state} = module.init(args) + {:ok, %__MODULE__{frame: frame, state: state, module: module}} + end + + @impl true + def handle_info(message, s = %__MODULE__{state: state, module: module}) do + if function_exported?(module, :handle_event, 2) and is_tuple(message) do + case module.handle_event(message, state) do + {:noreply, new_state} -> {:noreply, %{s | state: new_state}} + other -> other + end + else + dispatch_info(message, s) + end + end + + defp dispatch_info(message, s = %__MODULE__{state: state, module: module}) do + if function_exported?(module, :handle_info, 2) do + case module.handle_info(message, state) do + {:noreply, new_state} -> {:noreply, %{s | state: new_state}} + other -> other + end + else + {:noreply, s} + end + end + + @impl true + def handle_cast(message, s = %__MODULE__{state: state, module: module}) do + case module.handle_cast(message, state) do + {:noreply, new_state} -> {:noreply, %{s | state: new_state}} + other -> other + end + end + + @impl true + def handle_call(message, from, s = %__MODULE__{state: state, module: module}) do + case module.handle_call(message, from, state) do + {:noreply, new_state} -> {:noreply, %{s | state: new_state}} + {:reply, reply, new_state} -> {:reply, reply, %{s | state: new_state}} + other -> other + end + end +end diff --git a/lib/desktop/platform/system.ex b/lib/desktop/platform/system.ex new file mode 100644 index 0000000..904f06d --- /dev/null +++ b/lib/desktop/platform/system.ex @@ -0,0 +1,30 @@ +defmodule Desktop.Platform.System do + @moduledoc false + + @callback init_env() :: {wx :: term(), env :: term()} + @callback subscribe_events() :: :ok + @callback set_env(env :: term()) :: :ok + @callback get_env() :: term() + @callback locale() :: String.t() | nil + @callback connect_menu( + object :: term(), + command :: atom(), + callback :: function(), + id :: term() + ) :: + term() + @callback wx_available?() :: boolean() + + def init_env, do: impl().init_env() + def subscribe_events, do: impl().subscribe_events() + def set_env(env), do: impl().set_env(env) + def get_env, do: impl().get_env() + def locale, do: impl().locale() + + def connect_menu(object, command, callback, id \\ nil), + do: impl().connect_menu(object, command, callback, id) + + def wx_available?, do: impl().wx_available?() + + defp impl, do: Desktop.Platform.backend() +end diff --git a/lib/desktop/platform/window.ex b/lib/desktop/platform/window.ex new file mode 100644 index 0000000..68abfe7 --- /dev/null +++ b/lib/desktop/platform/window.ex @@ -0,0 +1,47 @@ +defmodule Desktop.Platform.Window do + @moduledoc false + + @type handle :: term() + @type content_handle :: term() | nil + + @callback open(keyword()) :: {:ok, handle(), content_handle()} | {:error, term()} + @callback destroy_frame(handle()) :: :ok + @callback connect(handle(), event :: atom(), (term() -> :ok)) :: :ok + @callback show(handle(), keyword()) :: :ok + @callback hide(handle()) :: :ok + @callback set_title(handle(), String.t()) :: :ok + @callback set_min_size(handle(), {integer(), integer()}) :: :ok + @callback set_icon(handle(), term()) :: :ok + @callback set_menubar(handle(), term()) :: :ok + @callback iconize(handle(), boolean()) :: :ok + @callback is_shown?(handle()) :: boolean() + @callback is_active?(handle()) :: boolean() + @callback raise_window(handle()) :: :ok + @callback update_apple_menu(String.t(), handle(), term()) :: :ok + @callback new_menubar() :: term() + @callback on_crash_destroy(handle()) :: :ok + @callback close_event_veto(term()) :: :ok + + def open(opts), do: impl().open(opts) + def destroy(frame), do: impl().destroy_frame(frame) + def connect(frame, event, fun), do: impl().connect(frame, event, fun) + def show(frame, opts \\ []), do: impl().show(frame, opts) + def hide(frame), do: impl().hide(frame) + def set_title(frame, title), do: impl().set_title(frame, title) + def set_min_size(frame, size), do: impl().set_min_size(frame, size) + def set_icon(frame, icon), do: impl().set_icon(frame, icon) + def set_menubar(frame, menubar), do: impl().set_menubar(frame, menubar) + def iconize(frame, iconize), do: impl().iconize(frame, iconize) + def is_shown?(frame), do: impl().is_shown?(frame) + def is_active?(frame), do: impl().is_active?(frame) + def raise_window(frame), do: impl().raise_window(frame) + + def update_apple_menu(title, frame, menubar), + do: impl().update_apple_menu(title, frame, menubar) + + def new_menubar, do: impl().new_menubar() + def on_crash_destroy(frame), do: impl().on_crash_destroy(frame) + def close_event_veto(inev), do: impl().close_event_veto(inev) + + defp impl, do: Desktop.Platform.backend() +end diff --git a/lib/desktop/window.ex b/lib/desktop/window.ex index c768d24..88f4415 100644 --- a/lib/desktop/window.ex +++ b/lib/desktop/window.ex @@ -87,10 +87,9 @@ defmodule Desktop.Window do """ - alias Desktop.{OS, Window, Wx, Menu, Fallback} + alias Desktop.{OS, Window, Wx, Menu, Fallback, Platform} require Logger - @enforce_keys [:frame] defstruct [ :module, :taskbar, @@ -118,94 +117,90 @@ defmodule Desktop.Window do @doc false def start_link(opts) do id = Keyword.fetch!(opts, :id) - {_ref, _num, _type, pid} = :wx_object.start_link({:local, id}, __MODULE__, opts, []) + + {_ref, _num, _type, pid} = + Platform.window_server().start_link({:local, id}, __MODULE__, opts, []) + {:ok, pid} end @doc false def init(options) do + caps = Platform.capabilities() window_title = options[:title] || Atom.to_string(options[:id]) size = options[:size] || {600, 500} min_size = options[:min_size] app = options[:app] icon = options[:icon] taskbar_icon = options[:taskbar_icon] - # not supported on mobile atm - menubar = unless OS.mobile?(), do: options[:menubar] - icon_menu = unless OS.mobile?(), do: options[:icon_menu] - hidden = unless OS.mobile?(), do: options[:hidden] + menubar = if caps.menu != :none, do: options[:menubar] + icon_menu = if caps.taskbar, do: options[:icon_menu] + hidden = if caps.window, do: options[:hidden] url = options[:url] on_close = options[:on_close] || :quit Desktop.Env.wx_use_env() GenServer.cast(Desktop.Env, {:register_window, self()}) - frame = - :wxFrame.new(Desktop.Env.wx(), Wx.wxID_ANY(), window_title, [ - {:size, size}, - {:style, Wx.wxDEFAULT_FRAME_STYLE()} - ]) - - OnCrash.call(fn reason -> - if reason != :normal do - Logger.error("Window crashed: #{inspect(reason)}") - Desktop.Env.wx_use_env() - :wxFrame.destroy(frame) - end - end) + wx = Desktop.Env.wx() + env = Desktop.Env.wx_env() - :wxFrame.connect(frame, :close_window, - callback: &close_window/2, - userData: self() - ) + {:ok, icon} = load_window_icon(app, icon) - unless OS.mobile?() do - :wxFrame.connect(frame, :activate, - callback: &frame_activate/2, - userData: self() + {:ok, frame, webview} = + Platform.Window.open( + wx: wx, + title: window_title, + size: size, + min_size: min_size, + icon: icon ) - end - - if min_size do - :wxFrame.setMinSize(frame, min_size) - end - - :wxFrame.setSizer(frame, :wxBoxSizer.new(Wx.wxHORIZONTAL())) - {:ok, icon} = - case icon do - nil -> {:ok, :wxArtProvider.getIcon("wxART_EXECUTABLE_FILE")} - filename -> Desktop.Image.new_icon(app, filename) + if frame do + OnCrash.call(fn reason -> + if reason != :normal do + Logger.error("Window crashed: #{inspect(reason)}") + Desktop.Env.wx_use_env() + Platform.Window.on_crash_destroy(frame) + end + end) + + Platform.Window.connect(frame, :close_window, &close_window/2) + + if caps.window and not OS.mobile?() do + Platform.Window.connect(frame, :activate, &frame_activate/2) end - - :wxTopLevelWindow.setIcon(frame, icon) - env = Desktop.Env.wx_env() + end wx_menubar = - if menubar do + if menubar and frame do {:ok, menu_pid} = Menu.start_link( module: menubar, app: app, env: env, - wx: :wxMenuBar.new() + adapter: Platform.Menu.adapter(), + wx: Platform.Menu.menubar_opts(wx: Platform.Window.new_menubar()) ) wx_menubar = Menu.menubar(menu_pid) - :wxFrame.setMenuBar(frame, wx_menubar) + Platform.Window.set_menubar(frame, wx_menubar) wx_menubar end - if OS.type() == MacOS do - update_apple_menu(window_title, frame, wx_menubar || :wxMenuBar.new()) + if OS.type() == MacOS and frame do + Platform.Window.update_apple_menu( + window_title, + frame, + wx_menubar || Platform.Window.new_menubar() + ) end taskbar = if icon_menu do sni_link = Desktop.Env.sni() - adapter = if sni_link != nil, do: Menu.Adapter.DBus - {:ok, taskbar_icon} = + {:ok, tray_icon} = case taskbar_icon do nil -> {:ok, icon} filename -> Desktop.Image.new_icon(app, filename) @@ -215,11 +210,11 @@ defmodule Desktop.Window do Menu.start_link( module: icon_menu, app: app, - adapter: adapter, + adapter: Platform.Menu.adapter(sni: sni_link), env: env, sni: sni_link, - icon: taskbar_icon, - wx: {:taskbar, taskbar_icon} + icon: tray_icon, + wx: {:taskbar, tray_icon} ) menu_pid @@ -228,7 +223,7 @@ defmodule Desktop.Window do ui = %Window{ frame: frame, id: options[:id], - webview: Fallback.webview_new(frame), + webview: webview, notifications: %{}, home_url: url, title: window_title, @@ -243,6 +238,10 @@ defmodule Desktop.Window do {frame, ui} end + defp load_window_icon(_app, nil), do: Platform.Media.default_icon() + + defp load_window_icon(app, filename), do: Desktop.Image.new_icon(app, filename) + @doc """ Returns the url currently shown of the Window. @@ -579,23 +578,27 @@ defmodule Desktop.Window do end def close_window(wx(userData: pid), inev) do - # if we don't veto vetoable events on MacOS the app freezes. - if :wxCloseEvent.canVeto(inev) do - :wxCloseEvent.veto(inev) - end - + Platform.Window.close_event_veto(inev) GenServer.cast(pid, :close_window) :ok end def frame_activate(wx(userData: pid), event) do - if :wxActivateEvent.getActive(event) do + if activate_event_active?(event) do GenServer.cast(pid, :frame_activated) end :ok end + defp activate_event_active?(event) do + if function_exported?(:wxActivateEvent, :getActive, 1) do + :wxActivateEvent.getActive(event) + else + true + end + end + @doc false def handle_cast(:frame_activated, ui = %Window{id: id}) do if id != nil do @@ -608,19 +611,10 @@ defmodule Desktop.Window do @doc false def handle_cast(:close_window, ui = %Window{frame: frame, taskbar: taskbar, on_close: on_close}) do if on_close == :hide do - :wxFrame.hide(frame) + if frame, do: Platform.Window.hide(frame) {:noreply, ui} else - # On macOS, there's no way to differentiate between following two events: - # - # * the window close event - # * the application close event - # - # So, this code assumes that if there's a close_window event coming in while - # the window is not actually shown, then it must be an application close event. - # - # On other platforms, this code should not have any relevance. - if not :wxFrame.isShown(frame) do + if frame and not Platform.Window.is_shown?(frame) do OS.shutdown() end @@ -628,7 +622,7 @@ defmodule Desktop.Window do OS.shutdown() {:noreply, ui} else - :wxFrame.hide(frame) + if frame, do: Platform.Window.hide(frame) {:noreply, ui} end end @@ -636,14 +630,14 @@ defmodule Desktop.Window do def handle_cast({:set_title, title}, ui = %Window{title: old, frame: frame}) do if title != old and frame != nil do - :wxFrame.setTitle(frame, String.to_charlist(title)) + Platform.Window.set_title(frame, title) end {:noreply, %Window{ui | title: title}} end def handle_cast({:iconize, iconize}, ui = %Window{frame: frame}) do - :wxTopLevelWindow.iconize(frame, iconize: iconize) + if frame, do: Platform.Window.iconize(frame, iconize) {:noreply, ui} end @@ -693,10 +687,7 @@ defmodule Desktop.Window do end def handle_cast(:hide, ui = %Window{frame: frame}) do - if frame do - :wxWindow.hide(frame) - end - + if frame, do: Platform.Window.hide(frame) {:noreply, ui} end @@ -704,7 +695,7 @@ defmodule Desktop.Window do def handle_call(:is_hidden?, _from, ui = %Window{frame: frame}) do ret = if frame do - not :wxWindow.isShown(frame) + not Platform.Window.is_shown?(frame) else false end @@ -714,7 +705,7 @@ defmodule Desktop.Window do @doc false def handle_call(:is_active?, _from, ui = %Window{frame: frame}) do - {:reply, :wxTopLevelWindow.isActive(frame), ui} + {:reply, frame == nil or Platform.Window.is_active?(frame), ui} end def handle_call(:url, _from, ui) do @@ -751,21 +742,4 @@ defmodule Desktop.Window do end |> URI.to_string() end - - defp update_apple_menu(title, frame, menubar) do - menu = :wxMenuBar.oSXGetAppleMenu(menubar) - :wxMenu.setTitle(menu, title) - - # Remove all items except for Quit since we don't yet handle the standard items - # like "Hide ", "Hide Others", "Show All", etc - for item <- :wxMenu.getMenuItems(menu) do - if :wxMenuItem.getId(item) == Wx.wxID_EXIT() do - :wxMenuItem.setText(item, "Quit #{title}\tCtrl+Q") - else - :wxMenu.delete(menu, item) - end - end - - :wxFrame.connect(frame, :command_menu_selected) - end end diff --git a/mix.exs b/mix.exs index 4b65d59..82a0bd7 100644 --- a/mix.exs +++ b/mix.exs @@ -87,14 +87,11 @@ defmodule Desktop.MixProject do {:phoenix_live_view, "> 1.0.0"}, {:plug, "> 1.0.0"}, {:gettext, "> 0.10.0"}, - {:igniter, "~> 0.6", optional: true} + {:igniter, "~> 0.6", optional: true}, + {:jason, "~> 1.2"} ] - if Mix.target() in [:android, :ios] do - desktop ++ [{:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]}] - else - desktop - end + desktop end defp docs do diff --git a/test/desktop/bridge_codec_test.exs b/test/desktop/bridge_codec_test.exs new file mode 100644 index 0000000..22fd5fe --- /dev/null +++ b/test/desktop/bridge_codec_test.exs @@ -0,0 +1,19 @@ +defmodule Desktop.BridgeCodecTest do + use ExUnit.Case + + alias Desktop.Bridge.Codec + + test "round-trip encode/decode" do + payload = [:wxFrame, :show, [[id: 1, type: :wxFrame], [show: true]]] + json = Codec.encode!(payload) + assert Codec.decode!(json) == payload + end + + test "mock handles legacy rpc" do + ref = 42 + json = Codec.encode!([:wxWebView, :isShown, [nil]]) + Desktop.Bridge.Mock.send(self(), <>) + + assert_receive {:tcp, Desktop.Bridge.Mock, <<^ref::unsigned-size(64), _reply::binary>>} + end +end diff --git a/test/desktop/platform_test.exs b/test/desktop/platform_test.exs new file mode 100644 index 0000000..672fa3e --- /dev/null +++ b/test/desktop/platform_test.exs @@ -0,0 +1,42 @@ +defmodule Desktop.PlatformTest do + use ExUnit.Case + + alias Desktop.Platform + alias Desktop.Backend.{Wx, Json, Browser} + + test "backend returns Wx on host by default" do + assert Platform.backend() == Wx + end + + test "capabilities for Wx backend" do + assert Wx.capabilities().window + assert Wx.capabilities().content == :webview + end + + test "capabilities for Browser backend" do + assert Browser.capabilities().content == :os_browser + assert Browser.capabilities().menu == :none + end + + test "capabilities for Json backend" do + assert Json.capabilities().content == :native + assert Json.capabilities().menu == :native + end + + test "config override backend" do + Application.put_env(:desktop, :backend, :browser) + + try do + assert Platform.backend() == Browser + after + Application.delete_env(:desktop, :backend) + end + end + + test "menu adapter selection" do + assert Platform.Menu.adapter() in [ + Desktop.Menu.Adapter.Wx, + Desktop.Menu.Adapter.Browser + ] + end +end From 4112b5497b283cc4036a54482ce9a5ce9bbe7481 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 22 May 2026 14:41:31 +0200 Subject: [PATCH 2/6] Fixes and test coverage --- .gitignore | 5 +- AGENTS.md | 5 +- CHANGELOG.md | 2 + README.md | 40 +++++ desktop_wx_stub.exs | 98 +++++++++++ docs/TEST_PLAN.md | 175 +++++++++++++++++++ guides/faq.md | 88 +++++++++- lib/desktop.ex | 15 +- lib/desktop/backend/json.ex | 12 +- lib/desktop/backend/wx.ex | 47 +++-- lib/desktop/menu/adapters/wx.ex | 7 +- lib/desktop/os.ex | 22 ++- lib/desktop/window.ex | 15 +- lib/desktop/wx.ex | 35 +--- lib/desktop/wx/compile.ex | 95 ++++++++++ lib/desktop/wx/records.ex | 35 ++++ mix.exs | 26 ++- src/desktop_wx.erl | 23 --- test/desktop/backend/browser_test.exs | 42 +++++ test/desktop/backend/json_test.exs | 50 ++++++ test/desktop/backend/wx_test.exs | 42 +++++ test/desktop/bridge_transport_test.exs | 67 +++++++ test/desktop/env_test.exs | 33 ++++ test/desktop/fallback_test.exs | 23 +++ test/desktop/guard_boolean_ops_test.exs | 25 +++ test/desktop/language_code_test.exs | 26 +++ test/desktop/menu_adapter_test.exs | 40 +++++ test/desktop/platform_test.exs | 66 ++++--- test/desktop/regression/boolean_ops_test.exs | 47 +++++ test/desktop/regression/wx_connect_test.exs | 59 +++++++ test/desktop/window_lifecycle_test.exs | 56 ++++++ test/support/desktop_case.ex | 69 ++++++++ test/support/guard_boolean_ops.exs | 32 ++++ test/support/menu_stub.ex | 3 + test/support/wx_case.ex | 25 +++ test/test_helper.exs | 2 +- 36 files changed, 1316 insertions(+), 136 deletions(-) create mode 100644 desktop_wx_stub.exs create mode 100644 docs/TEST_PLAN.md create mode 100644 lib/desktop/wx/compile.ex create mode 100644 lib/desktop/wx/records.ex delete mode 100644 src/desktop_wx.erl create mode 100644 test/desktop/backend/browser_test.exs create mode 100644 test/desktop/backend/json_test.exs create mode 100644 test/desktop/backend/wx_test.exs create mode 100644 test/desktop/bridge_transport_test.exs create mode 100644 test/desktop/env_test.exs create mode 100644 test/desktop/fallback_test.exs create mode 100644 test/desktop/guard_boolean_ops_test.exs create mode 100644 test/desktop/language_code_test.exs create mode 100644 test/desktop/menu_adapter_test.exs create mode 100644 test/desktop/regression/boolean_ops_test.exs create mode 100644 test/desktop/regression/wx_connect_test.exs create mode 100644 test/desktop/window_lifecycle_test.exs create mode 100644 test/support/desktop_case.ex create mode 100644 test/support/guard_boolean_ops.exs create mode 100644 test/support/menu_stub.ex create mode 100644 test/support/wx_case.ex diff --git a/.gitignore b/.gitignore index e63d315..eeba040 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ npm-debug.log /assets/node_modules/ # This is a library -/mix.lock \ No newline at end of file +/mix.lock + +# Generated on every mix load (desktop_wx_stub.exs) +/src/desktop_wx.erl \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1bb14ba..67ba3b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,10 @@ wxWidgets GUI support requires `libwxgtk-webview3.2-dev` and `xvfb` on headless | Install deps | `mix deps.get` | | Compile | `mix compile` | | Lint (all) | `mix lint` (runs compile --warnings-as-errors, format --check-formatted, credo --ignore refactor, dialyzer) | -| Tests | `xvfb-run mix test` | +| Tests (fast, no wx) | `mix test.fast` | +| Tests (wx only) | `xvfb-run -a mix test.wx` (or `DISPLAY=:99 mix test.wx` if xvfb is already running) | +| Tests (all) | `xvfb-run -a mix test` | +| Regression guard | `mix test.guard` | | Run code | `xvfb-run mix run -e ''` | | IEx shell | `xvfb-run iex -S mix` | diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dacbba..b27da79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Optional `config :desktop, :backend, :auto | :wx | :json | :browser` override - Menu adapters: `Desktop.Menu.Adapter.Json` and `Desktop.Menu.Adapter.Browser` - Public `Desktop.*` APIs unchanged (`Desktop.Window`, `Desktop.Env`, `Desktop.Menu`, etc.) +- Test suite: `mix test.fast`, `xvfb-run mix test.wx`, `mix test.guard` — see `docs/TEST_PLAN.md` +- Compile without OTP `:wx`: conditional `erl_src_paths` and `Desktop.Wx` fallbacks (no `wx.hrl` required) ## Changes in 1.5 diff --git a/README.md b/README.md index 5e6f55b..cb60d3c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,46 @@ Check out the [Getting your Environment Ready Guide](./guides/getting_started.md This repo’s [`.tool-versions`](./.tool-versions) pins Erlang and Elixir for contributors; [mise](https://mise.jdx.dev/) and [asdf](https://asdf-vm.com/) both understand that file. After activating your toolchain, run `mix desktop.check_toolchain` to confirm the running OTP major and Elixir version match `.tool-versions`. +## Platform backends + +`desktop` routes window, webview, menu, and notification calls through `Desktop.Platform` to one of three backends. The default is **automatic** selection (`:auto`); you can override it in config or via environment variables. + +| Backend | Typical use | +|---|---| +| `Desktop.Backend.Wx` | Native windows on Windows, macOS, and Linux (OTP `:wx` / wxWidgets) | +| `Desktop.Backend.Json` | Android and iOS — JSON/TCP bridge to your native host app (`BRIDGE_PORT`) | +| `Desktop.Backend.Browser` | Headless CI, servers without wx, or local dev without a GUI (`NO_WX=1`) | + +**Automatic selection** (`config :desktop, :backend, :auto` — the default): + +1. Mobile target (`Mix.target()` `:android` / `:ios`, or `Desktop.OS.mobile?/0`) → **Json** +2. Else `NO_WX` set or `:wx` not available → **Browser** +3. Else → **Wx** + +**Explicit override** in `config/config.exs`: + +```elixir +config :desktop, :backend, :wx # force wxWidgets (desktop) +config :desktop, :backend, :json # force JSON bridge (e.g. mobile host) +config :desktop, :backend, :browser # force OS-browser fallback +config :desktop, :backend, :auto # automatic (default) +``` + +You can also pass a **custom backend module** that implements the `Desktop.Platform.*` behaviour callbacks: + +```elixir +config :desktop, :backend, MyApp.DesktopBackend +``` + +**Environment variables:** + +| Variable | Effect | +|---|---| +| `NO_WX=1` | With `:auto`, selects the Browser backend (no native window) | +| `BRIDGE_PORT` | TCP port for the Json backend’s native host (default `0` = in-process mock for tests) | + +See the [FAQ](./guides/faq.md) for mobile bridge setup, headless testing, and capability details. + ## Status / Roadmap 1. ✅ Run elixir-desktop on Windows/MacOS/Linux diff --git a/desktop_wx_stub.exs b/desktop_wx_stub.exs new file mode 100644 index 0000000..4b0eeeb --- /dev/null +++ b/desktop_wx_stub.exs @@ -0,0 +1,98 @@ +# Generates src/desktop_wx.erl before any compiler runs. +# - MIX_TARGET host (or unset) + wx.hrl present → include_lib + ?wx macros +# - android / ios / no wx headers → header-free integer fallbacks + +defmodule Desktop.WxStub do + @constant_names ~w( + ID_ANY ID_EXIT DEFAULT_FRAME_STYLE NO_BORDER EXPAND HORIZONTAL VERTICAL + ITEM_SEPARATOR ITEM_NORMAL ITEM_CHECK ITEM_RADIO + ICON_WARNING ICON_ERROR ICON_QUESTION ICON_INFORMATION + MAJOR_VERSION MINOR_VERSION RELEASE_NUMBER IMAGE_QUALITY_HIGH + ) + + @fallback %{ + wxID_ANY: -1, + wxID_EXIT: 5006, + wxDEFAULT_FRAME_STYLE: 541_072_960, + wxNO_BORDER: 2_097_152, + wxEXPAND: 8192, + wxHORIZONTAL: 4, + wxVERTICAL: 8, + wxITEM_SEPARATOR: -1, + wxITEM_NORMAL: 0, + wxITEM_CHECK: 1, + wxITEM_RADIO: 2, + wxICON_WARNING: 256, + wxICON_ERROR: 512, + wxICON_QUESTION: 1024, + wxICON_INFORMATION: 2048, + wxMAJOR_VERSION: 0, + wxMINOR_VERSION: 0, + wxRELEASE_NUMBER: 0, + wxIMAGE_QUALITY_HIGH: 192 + } + + def write!(root \\ Path.dirname(__ENV__.file)) do + target = System.get_env("MIX_TARGET", "host") + path = Path.join([root, "src", "desktop_wx.erl"]) + File.mkdir_p!(Path.dirname(path)) + + body = + if host_target?() and wx_headers_exist?() do + hrl_body(target) + else + stub_body(target) + end + + File.write!(path, body) + end + + defp host_target? do + System.get_env("MIX_TARGET") in [nil, "host"] + end + + defp wx_headers_exist? do + case :code.lib_dir(:wx) do + path when is_list(path) -> + File.exists?(Path.join([List.to_string(path), "include", "wx.hrl"])) + + _ -> + false + end + end + + defp hrl_body(target) do + gets = + @constant_names + |> Enum.sort() + |> Enum.map(fn name -> "get(wx#{name}) -> ?wx#{name}" end) + |> Enum.join(";\n ") + + """ + -module(desktop_wx). + -include_lib("wx/include/wx.hrl"). + -export([get/1]). + + %% Generated by desktop_wx_stub.exs — do not edit. + %% MIX_TARGET=#{target} — values from wx/include/wx.hrl + + #{gets}. + """ + end + + defp stub_body(target) do + gets = for {name, value} <- @fallback, do: "get(#{name}) -> #{value}" + + """ + -module(desktop_wx). + -export([get/1]). + + %% Generated by desktop_wx_stub.exs — do not edit. + %% MIX_TARGET=#{target} — header-free fallbacks (no wx.hrl / :wx) + + #{Enum.join(gets, ";\n ")}. + """ + end +end + +Desktop.WxStub.write!() diff --git a/docs/TEST_PLAN.md b/docs/TEST_PLAN.md new file mode 100644 index 0000000..fedd392 --- /dev/null +++ b/docs/TEST_PLAN.md @@ -0,0 +1,175 @@ +# Desktop Platform — Test Design Plan + +This document defines test coverage for the Platform/Backend refactor, based on production regressions found in `desktop-example-app` and the gaps in the current suite (23 tests, mostly parser/toolchain/codec). + +## Regressions that must never recur + +| ID | Symptom | Root cause | Regression test ID | +|----|---------|------------|-------------------| +| R1 | `{:wx, :unknown_env}` on app start | `language_code/0` → `Wx.locale/0` before `:wx.set_env/1` | T-ENV-01, T-ENV-02 | +| R2 | `function_clause` in `wxEvtHandler.parse_opts` | `connect/3` passed bare `callback:` instead of `[callback:, userData:]` | T-CONN-01, T-CONN-02 | +| R3 | `{:badbool, :and, TodoApp.MenuBar}` | `if menubar and frame` — non-boolean `and` operand | T-BOOL-01, L0-GUARD | +| R4 | `{:badbool, :and, wx_ref}` on window close | `if frame and not is_shown?` | T-BOOL-02, T-WIN-03 | + +## Risk patterns to audit (grep / Credo) + +- `if and|or ` where LHS is not a comparison (module atom, wx ref, pid) +- `:wx*` calls without prior `Desktop.Env.wx_use_env/0` or `Backend.Wx.ensure_wx_env/0` +- `Platform.Window.connect/3` not passing `userData: self()` +- `handle_cast` branches assuming `frame` is boolean + +Safe patterns (do not “fix”): + +- `if caps.window and not OS.mobile?()` — both booleans +- `if title != old and frame != nil` — both comparisons +- `if frame, do:` — `if` accepts truthy values (not `and`/`or`) + +--- + +## Test architecture (4 layers) + +``` +L0 Static guards → mix test.guard / Credo (no display) +L1 Unit (no wx) → Browser backend, router, pure Window helpers +L2 Unit (mocked) → Bridge.Mock, handle_cast with fake wx_ref +L3 Integration (:wx) → xvfb-run mix test.wx +L4 App smoke → desktop-example-app (optional, manual/CI nightly) +``` + +| Layer | Command | Catches | +|-------|---------|---------| +| L0 | `mix test.guard` | Future `and`/`or` misuse in Window | +| L1 | `mix test.fast` | Router, Browser contracts, cast logic | +| L2 | `mix test.fast` | Bridge wire format, close_window without GTK | +| L3 | `xvfb-run mix test.wx` | Real wx env, connect, locale, window lifecycle | +| L4 | script / manual | Full TodoApp supervision order | + +--- + +## Test infrastructure + +### `test/support/desktop_case.ex` + +- `fake_frame/0` → `{:wx_ref, 1, :wxFrame, []}` +- `minimal_window/1` → `%Desktop.Window{...}` for cast tests +- `with_backend/2` — temporary `Application.put_env(:desktop, :backend, ...)` + +### `test/support/wx_case.ex` + +- `@moduletag :wx` +- Setup: ensure `Desktop.Env` started, `wx_use_env` available +- Document: requires `xvfb-run` on headless Linux + +### Mix aliases (`mix.exs`) + +```elixir +"test.fast": ["test --exclude wx"], +"test.wx": ["test --only wx"], +"test.guard": ["run test/support/guard_boolean_ops.exs"] +``` + +--- + +## Coverage matrix + +### Platform router — `test/desktop/platform_test.exs` (extend) + +| ID | Test | +|----|------| +| T-PLAT-01 | `backend/0` default Wx on host | +| T-PLAT-02 | `config :desktop, :backend` override `:browser`, `:json` | +| T-PLAT-03 | `NO_WX=1` → Browser | +| T-PLAT-04 | `Menu.adapter(sni: pid)` → DBus on Wx | +| T-PLAT-05 | `capabilities/0` consistent with active backend | +| T-PLAT-06 | `window_server/0` Wx vs Platform.Server | + +### Regression — `test/desktop/regression/boolean_ops_test.exs` (new) + +| ID | Test | +|----|------| +| T-BOOL-01 | Init guard: `menubar && frame` path does not raise with module + fake_frame | +| T-BOOL-02 | `handle_cast(:close_window)` with fake_frame + taskbar pid — no badbool (R4) | +| T-BOOL-03 | `handle_cast(:close_window)` with `frame: nil` — no crash | +| T-BOOL-04 | `on_close: :hide` branch calls hide, does not shutdown | + +### Regression — `test/desktop/regression/wx_connect_test.exs` (new) + +| ID | Test | +|----|------| +| T-CONN-01 | `@tag :wx` — `Backend.Wx.connect(frame, :close_window, fn -> :ok end)` does not raise | +| T-CONN-02 | Json + `BRIDGE_PORT=0` — connect RPC JSON third arg is keyword list with `:callback`, `:userData` | + +### Locale / Env — `test/desktop/language_code_test.exs`, `env_test.exs` (new) + +| ID | Test | +|----|------| +| T-ENV-01 | With Browser backend, `language_code/0` returns without wx (nil or string) | +| T-ENV-02 | `@tag :wx` — after `Env.start_link`, `language_code/0` no `:unknown_env` | +| T-ENV-03 | `wx_use_env/0` when `wx_env` is nil — no raise | +| T-ENV-04 | `@tag :wx` — `init` stores wx + wx_env | + +### Window lifecycle — `test/desktop/window_lifecycle_test.exs` (new) + +| ID | Test | +|----|------| +| T-WIN-01 | `prepare_url/1` (move/duplicate from `window_test.exs`) | +| T-WIN-02 | `handle_cast :hide` / `:set_title` with nil frame | +| T-WIN-03 | `handle_cast :close_window` all branches (taskbar nil / set, on_close hide/quit) | +| T-WIN-04 | `@tag :wx` — minimal Window `start_link` + cast `:close_window` alive | + +### Backend contracts — `test/desktop/backend/*_test.exs` (new) + +| File | ID | Scope | +|------|-----|-------| +| `browser_test.exs` | T-BRW-* | Every Window/Content/Notification/Media/System callback — no raise | +| `json_test.exs` | T-JSN-* | Mock transport: new/open/connect/loadURL handle shapes | +| `wx_test.exs` | T-WX-* | `@tag :wx` — open, connect, is_shown?, attach | + +### Bridge — `bridge_codec_test.exs` (extend) + `bridge_transport_test.exs` (new) + +| ID | Test | +|----|------| +| T-BRG-01 | Codec round-trip (existing) | +| T-BRG-02 | Mock RPC response | +| T-BRG-03 | `subscribe_events` delivers queued ref=0 event | +| T-BRG-04 | Connect cast JSON shape | +| T-BRG-05 | Callback ref=1 invokes registered fun | + +### Menu / Fallback — `menu_adapter_test.exs`, `fallback_test.exs` (new) + +| ID | Test | +|----|------| +| T-MENU-01 | Browser adapter menubar nil, set_icon ok | +| T-MENU-02 | Json adapter minimal menubar DOM | +| T-FALL-01 | `webview_load` with nil webview does not raise | + +### L0 guard — `test/support/guard_boolean_ops.exs` (new) + +| ID | Test | +|----|------| +| L0-GUARD | Fail if `lib/desktop/window.ex` matches `if\s+\w+\s+and\s+(not\s+)?` where LHS is not `==` / `!=` | + +--- + +## CI recommendation + +```bash +mix test.fast # default PR check +xvfb-run mix test.wx # required for wx-tagged tests +mix test.guard # optional static regression guard +``` + +Exclude: `test/mix/tasks/desktop.install_test.exs` (pre-existing). + +--- + +## Implementation order + +1. Infrastructure (`desktop_case`, `wx_case`, mix aliases) +2. Regression tests (BOOL, CONN) — highest ROI +3. Env + language_code +4. Window lifecycle + close_window +5. Backend contract smoke tests +6. Bridge transport +7. Menu + Fallback +8. L0 guard + AGENTS.md CI docs diff --git a/guides/faq.md b/guides/faq.md index 895240b..0be2d97 100644 --- a/guides/faq.md +++ b/guides/faq.md @@ -1,14 +1,96 @@ # Frequently Asked Questions (FAQ) +## How do I choose the platform backend? + +All UI operations go through `Desktop.Platform`, which delegates to a single **backend module**. Public APIs (`Desktop.Window`, `Desktop.Menu`, etc.) are unchanged — only the implementation underneath varies. + +### Automatic (default) + +```elixir +config :desktop, :backend, :auto +``` + +`Desktop.Platform.backend/0` picks: + +| Condition | Backend | +|---|---| +| `Mix.target()` is `:android` or `:ios`, or `Desktop.OS.mobile?/0` | `Desktop.Backend.Json` | +| `NO_WX` is set, or OTP `:wx` is not available | `Desktop.Backend.Browser` | +| Otherwise | `Desktop.Backend.Wx` | + +### Explicit config + +In `config/config.exs` (or environment-specific config): + +```elixir +config :desktop, :backend, :wx # native wxWidgets window + webview +config :desktop, :backend, :json # JSON/TCP bridge (mobile native host) +config :desktop, :backend, :browser # OS default browser, no native window +``` + +For a custom implementation, set the backend to a module that implements the `Desktop.Platform.Window`, `Content`, `Notification`, `Media`, `System`, and `Menu` behaviour callbacks: + +```elixir +config :desktop, :backend, MyApp.DesktopBackend +``` + +Restart the app after changing backend config — the router reads `Application.get_env(:desktop, :backend, :auto)` at runtime. + +### Environment variables + +- **`NO_WX=1`** — with `:auto`, forces `Desktop.Backend.Browser`. Useful for headless servers, CI without a display, or local development when wxWidgets is not installed. +- **`BRIDGE_PORT`** — TCP port where the native host listens for `Desktop.Backend.Json` RPC (see mobile bridge below). Use `0` for the in-process mock transport (tests). + +### Capabilities + +Inspect what the active backend supports: + +```elixir +Desktop.Platform.backend() # e.g. Desktop.Backend.Wx +Desktop.Platform.capabilities() # %{window: true, content: :webview, ...} +``` + +| Backend | `window` | `content` | `menu` | +|---|---|---|---| +| Wx | yes | `:webview` | `:wx` or `:dbus` (Linux SNI) | +| Json | yes | `:native` (host webview) | `:native` | +| Browser | no | `:os_browser` | `:none` | + +On Linux with DBus SNI available, Wx may use `:dbus` for the taskbar menu instead of in-window wx menus. + ## How does the mobile (Android/iOS) bridge work? -On mobile targets, `desktop` uses `Desktop.Backend.Json` instead of OTP `:wx`. The Elixir side speaks the legacy JSON protocol over TCP to your native host app (set `BRIDGE_PORT` to the listening port). The separate `bridge` hex package is no longer required — transport lives in `Desktop.Bridge.Transport`. +On mobile targets, `desktop` uses `Desktop.Backend.Json` instead of OTP `:wx`. The Elixir side speaks the legacy JSON protocol over TCP to your native host app. Set `BRIDGE_PORT` to the port your host app listens on. Transport is built in as `Desktop.Bridge.Transport` — the separate `bridge` hex package is no longer required. + +Override explicitly if needed: + +```elixir +config :desktop, :backend, :json +``` + +## Can I compile without the `:wx` OTP application? -Override the backend with `config :desktop, :backend, :json` in `config/config.exs` if needed. +Yes. Android/iOS release builds set `MIX_TARGET=android` (or `ios`). `desktop` then: + +- Regenerates **`src/desktop_wx.erl`** via `desktop_wx_stub.exs` when Mix loads the project: on **host** with `:wx` in OTP it uses `wx/include/wx.hrl`; on **android/ios** (or without wx headers) it writes header-free integer fallbacks. Erlang compilation of that file is skipped when `MIX_TARGET` is not `host`. +- Uses **`Desktop.Wx`** integer fallbacks instead of `wx.hrl` macros. +- Uses **`Desktop.Wx.Records`** stubs instead of `Record.extract(..., from_lib: "wx/include/wx.hrl")`. + +On a host-only build without `:wx` in OTP, the same fallbacks apply when `wx.hrl` is missing. + +Runtime uses `Desktop.Backend.Json` on mobile; you do not need wx installed to compile or release. ## How do I run without wxWidgets? -Set `NO_WX=1` to use `Desktop.Backend.Browser`: URLs open in the OS default browser and window/menu APIs degrade gracefully. Use `xvfb-run` on headless Linux when testing the Wx backend instead. +Set `NO_WX=1` to use `Desktop.Backend.Browser` under `:auto`: URLs open in the OS default browser and window/menu APIs degrade gracefully (notifications are logged). + +To **test** the Wx backend on headless Linux, keep wx enabled and use a virtual display instead: + +```bash +xvfb-run -a mix phx.server +``` + +Library contributors can run `mix test.fast` (no wx), `xvfb-run -a mix test.wx`, and `mix test.guard` — see `AGENTS.md` in the repo root. ## How do I release and distribute my Desktop application? diff --git a/lib/desktop.ex b/lib/desktop.ex index de439e4..1890cc3 100644 --- a/lib/desktop.ex +++ b/lib/desktop.ex @@ -99,18 +99,9 @@ defmodule Desktop do code else _ -> - case Desktop.Platform.System.locale() do - nil -> - with env when env != nil <- Desktop.Env.wx_env() do - Desktop.Platform.System.set_env(env) - end - - locale = :wxLocale.new(:wxLocale.getSystemLanguage()) - :wxLocale.getCanonicalName(locale) |> List.to_string() |> String.downcase() - - locale -> - locale - end + # Wx APIs require the process wx env from Desktop.Env (see :wx.set_env/1). + Desktop.Env.wx_use_env() + Desktop.Platform.System.locale() end end diff --git a/lib/desktop/backend/json.ex b/lib/desktop/backend/json.ex index 20ffc97..a7c705c 100644 --- a/lib/desktop/backend/json.ex +++ b/lib/desktop/backend/json.ex @@ -103,13 +103,8 @@ defmodule Desktop.Backend.Json do def destroy_frame(frame), do: Protocol.destroy(:wxFrame, frame) @impl true - def connect(frame, :close_window, fun) do - Protocol.connect(:wxFrame, frame, :close_window, callback: fun) - :ok - end - - def connect(frame, :activate, fun) do - Protocol.connect(:wxFrame, frame, :activate, callback: fun) + def connect(frame, event, fun) do + Protocol.connect(:wxFrame, frame, event, [callback: fun, userData: self()]) :ok end @@ -159,7 +154,10 @@ defmodule Desktop.Backend.Json do def is_active?(frame), do: Protocol.call(:wxTopLevelWindow, :isActive, [frame]) || true @impl true + def raise_window(nil), do: :ok + def raise_window(frame) do + Protocol.call(:wxTopLevelWindow, :setFocus, [frame]) Protocol.call(:wxWindow, :raise, [frame]) :ok end diff --git a/lib/desktop/backend/wx.ex b/lib/desktop/backend/wx.ex index 3cabda1..1aeed8e 100644 --- a/lib/desktop/backend/wx.ex +++ b/lib/desktop/backend/wx.ex @@ -42,10 +42,25 @@ defmodule Desktop.Backend.Wx do @impl true def locale do + ensure_wx_env() + locale = Null.wx_call(:wxLocale, :new, [:wxLocale.getSystemLanguage()]) Null.wx_call(:wxLocale, :getCanonicalName, [locale]) |> List.to_string() |> String.downcase() end + defp ensure_wx_env do + env = + case Process.whereis(Desktop.Env) do + nil -> + Null.wx_call(:wx, :get_env) + + _ -> + Desktop.Env.wx_env() + end + + if env != nil, do: Null.wx_call(:wx, :set_env, [env]) + end + @impl true def connect_menu(object, command, callback, id) do opts = [{:callback, fn _, _ -> callback.() end}] @@ -96,13 +111,9 @@ defmodule Desktop.Backend.Wx do def destroy_frame(frame), do: Null.wx_call(:wxFrame, :destroy, [frame]) || :ok @impl true - def connect(frame, :close_window, fun) do - Null.wx_call(:wxFrame, :connect, [frame, :close_window, callback: fun]) - :ok - end - - def connect(frame, :activate, fun) do - Null.wx_call(:wxFrame, :connect, [frame, :activate, callback: fun]) + def connect(frame, event, fun) do + opts = [callback: fun, userData: self()] + Null.wx_call(:wxFrame, :connect, [frame, event, opts]) :ok end @@ -153,8 +164,25 @@ defmodule Desktop.Backend.Wx do def is_active?(frame), do: Null.wx_call(:wxTopLevelWindow, :isActive, [frame]) || false @impl true + def raise_window(nil), do: :ok + def raise_window(frame) do - OS.raise_frame(frame) + case OS.type() do + MacOS -> + name = System.get_env("EMU", "beam.smp") + + fn -> + System.cmd("open", ["-a", name], stderr_to_stdout: true, parallelism: true) + end + |> spawn_link() + + _ -> + # Calling setFocus on wxDirDialog segfaults on macOS — handled above. + Desktop.Env.wx_use_env() + Null.wx_call(:wxTopLevelWindow, :setFocus, [frame]) + Null.wx_call(:wxWindow, :raise, [frame]) + end + :ok end @@ -244,8 +272,7 @@ defmodule Desktop.Backend.Wx do Null.wx_call(:wxTopLevelWindow, :centerOnScreen, [frame]) end - OS.raise_frame(frame) - :ok + raise_window(frame) end @impl true diff --git a/lib/desktop/menu/adapters/wx.ex b/lib/desktop/menu/adapters/wx.ex index be8bedd..c269efe 100644 --- a/lib/desktop/menu/adapters/wx.ex +++ b/lib/desktop/menu/adapters/wx.ex @@ -3,7 +3,8 @@ defmodule Desktop.Menu.Adapter.Wx do alias Desktop.{Wx, OS} alias Desktop.Wx.TaskBarIcon - require Record + require Desktop.Wx.Records + import Desktop.Wx.Records require Logger defstruct [ @@ -22,10 +23,6 @@ defmodule Desktop.Menu.Adapter.Wx do taskbar_icon: TaskBarIcon.t() | nil } - for tag <- [:wx, :wxCommand, :wxMenu] do - Record.defrecordp(tag, Record.extract(tag, from_lib: "wx/include/wx.hrl")) - end - def new(opts) do %__MODULE__{ env: Keyword.get(opts, :env), diff --git a/lib/desktop/os.ex b/lib/desktop/os.ex index 2f32847..3c7fe07 100644 --- a/lib/desktop/os.ex +++ b/lib/desktop/os.ex @@ -23,17 +23,10 @@ defmodule Desktop.OS do end @doc false - def raise_frame(frame) do - if type() == MacOS do - name = System.get_env("EMU", "beam.smp") + def raise_frame(frame) when frame in [nil, :undefined], do: :ok - fn -> System.cmd("open", ["-a", name], stderr_to_stdout: true, parallelism: true) end - |> spawn_link() - else - # Calling this on wxDirDialog segfaults on macos.. - :wxTopLevelWindow.setFocus(frame) - :wxWindow.raise(frame) - end + def raise_frame(frame) do + Desktop.Platform.Window.raise_window(frame) end @spec type :: Linux | MacOS | Windows | Android | IOS @@ -165,9 +158,14 @@ defmodule Desktop.OS do env: linux_env() ) + Windows -> + System.cmd("cmd", ["/c", "start", "", file], stderr_to_stdout: true, parallelism: true) + _other -> - Desktop.Env.wx_use_env() - :wx_misc.launchDefaultBrowser(file) + if Desktop.Platform.System.wx_available?() do + Desktop.Env.wx_use_env() + Desktop.Backend.Null.wx_call(:wx_misc, :launchDefaultBrowser, [file]) + end end end) end diff --git a/lib/desktop/window.ex b/lib/desktop/window.ex index 88f4415..3cc39d6 100644 --- a/lib/desktop/window.ex +++ b/lib/desktop/window.ex @@ -173,14 +173,14 @@ defmodule Desktop.Window do end wx_menubar = - if menubar and frame do + if menubar && frame do {:ok, menu_pid} = Menu.start_link( module: menubar, app: app, env: env, adapter: Platform.Menu.adapter(), - wx: Platform.Menu.menubar_opts(wx: Platform.Window.new_menubar()) + wx: Platform.Window.new_menubar() ) wx_menubar = Menu.menubar(menu_pid) @@ -188,7 +188,7 @@ defmodule Desktop.Window do wx_menubar end - if OS.type() == MacOS and frame do + if OS.type() == MacOS && frame do Platform.Window.update_apple_menu( window_title, frame, @@ -512,11 +512,8 @@ defmodule Desktop.Window do OS.shutdown() end - require Record - - for tag <- [:wx, :wxCommand, :wxClose] do - Record.defrecordp(tag, Record.extract(tag, from_lib: "wx/include/wx.hrl")) - end + require Desktop.Wx.Records + import Desktop.Wx.Records @doc false def handle_event(wx(event: {:wxWebView, :webview_newwindow, _, _, _target, url}), ui) do @@ -614,7 +611,7 @@ defmodule Desktop.Window do if frame, do: Platform.Window.hide(frame) {:noreply, ui} else - if frame and not Platform.Window.is_shown?(frame) do + if frame != nil && !Platform.Window.is_shown?(frame) do OS.shutdown() end diff --git a/lib/desktop/wx.ex b/lib/desktop/wx.ex index 36079e9..f4b77a9 100644 --- a/lib/desktop/wx.ex +++ b/lib/desktop/wx.ex @@ -1,32 +1,13 @@ defmodule Desktop.Wx do @moduledoc """ - Elixir version of the constants found in the wx.hrl file, reduced to what is needed in this sample only. - """ - @constants ~w( - ID_ANY ID_EXIT DEFAULT_FRAME_STYLE NO_BORDER EXPAND HORIZONTAL VERTICAL - ITEM_SEPARATOR ITEM_NORMAL ITEM_CHECK ITEM_RADIO - ICON_WARNING ICON_ERROR ICON_QUESTION ICON_INFORMATION - MAJOR_VERSION MINOR_VERSION RELEASE_NUMBER IMAGE_QUALITY_HIGH ) - - gets = - Enum.sort(@constants) - |> Enum.map(fn const -> "get(wx#{const}) -> ?wx#{const}" end) - |> Enum.join(";\n ") + wxWidgets constants used by `desktop`. - File.write!( - __DIR__ <> "/../../src/desktop_wx.erl", - """ - -module(desktop_wx). - -include_lib("wx/include/wx.hrl"). - -export([get/1]). - - #{gets}. - """ - ) + When OTP is built with the `:wx` application, values are taken from `wx/include/wx.hrl` + via the `desktop_wx` Erlang module. Otherwise compile-time fallbacks are used so the + library can build on mobile targets and minimal OTP installs without wxWidgets. + """ + @before_compile Desktop.Wx.Compile - @constants - |> Enum.map(&String.to_atom("wx" <> &1)) - |> Enum.each(fn prefixed_constant -> - def unquote(prefixed_constant)(), do: :desktop_wx.get(unquote(prefixed_constant)) - end) + @doc false + def wx_headers_available?, do: Desktop.Wx.Compile.wx_available?() end diff --git a/lib/desktop/wx/compile.ex b/lib/desktop/wx/compile.ex new file mode 100644 index 0000000..a6fe391 --- /dev/null +++ b/lib/desktop/wx/compile.ex @@ -0,0 +1,95 @@ +defmodule Desktop.Wx.Compile do + @moduledoc false + + @constants ~w( + ID_ANY ID_EXIT DEFAULT_FRAME_STYLE NO_BORDER EXPAND HORIZONTAL VERTICAL + ITEM_SEPARATOR ITEM_NORMAL ITEM_CHECK ITEM_RADIO + ICON_WARNING ICON_ERROR ICON_QUESTION ICON_INFORMATION + MAJOR_VERSION MINOR_VERSION RELEASE_NUMBER IMAGE_QUALITY_HIGH + ) + + @fallback %{ + wxID_ANY: -1, + wxID_EXIT: 5006, + wxDEFAULT_FRAME_STYLE: 541_072_960, + wxNO_BORDER: 2_097_152, + wxEXPAND: 8192, + wxHORIZONTAL: 4, + wxVERTICAL: 8, + wxITEM_SEPARATOR: -1, + wxITEM_NORMAL: 0, + wxITEM_CHECK: 1, + wxITEM_RADIO: 2, + wxICON_WARNING: 256, + wxICON_ERROR: 512, + wxICON_QUESTION: 1024, + wxICON_INFORMATION: 2048, + wxMAJOR_VERSION: 0, + wxMINOR_VERSION: 0, + wxRELEASE_NUMBER: 0, + wxIMAGE_QUALITY_HIGH: 192 + } + + defmacro __before_compile__(_env) do + write_stub_file!() + constant_defs() + end + + @doc false + def write_stub_file! do + root = Path.expand("../../..", __DIR__) + stub = Path.join(root, "desktop_wx_stub.exs") + + if File.exists?(stub) do + Code.require_file(stub) + Desktop.WxStub.write!(root) + end + end + + def wx_available? do + host_target?() and wx_headers_exist?() + end + + defp host_target? do + System.get_env("MIX_TARGET") in [nil, "host"] + end + + defp wx_headers_exist? do + case :code.lib_dir(:wx) do + path when is_list(path) -> + File.exists?(Path.join([List.to_string(path), "include", "wx.hrl"])) + + _ -> + false + end + end + + defp constant_defs do + if host_target?() and wx_headers_exist?() do + constant_defs_from_erlang() + else + constant_defs_from_fallback() + end + end + + defp constant_defs_from_erlang do + for const <- @constants do + name = String.to_atom("wx" <> const) + + quote do + def unquote(name)(), do: :desktop_wx.get(unquote(name)) + end + end + end + + defp constant_defs_from_fallback do + for const <- @constants do + name = String.to_atom("wx" <> const) + value = Map.fetch!(@fallback, name) + + quote do + def unquote(name)(), do: unquote(value) + end + end + end +end diff --git a/lib/desktop/wx/records.ex b/lib/desktop/wx/records.ex new file mode 100644 index 0000000..3a106c2 --- /dev/null +++ b/lib/desktop/wx/records.ex @@ -0,0 +1,35 @@ +defmodule Desktop.Wx.Records do + @moduledoc false + # wx event records for pattern matching in Window and Menu.Adapter.Wx. + # On MIX_TARGET=android|ios, use minimal stubs (wx.hrl is not on erlc's code path). + + @host_build System.get_env("MIX_TARGET") in [nil, "host"] + + @wx_hrl ( + if @host_build do + case :code.lib_dir(:wx) do + path when is_list(path) -> + path = Path.join([List.to_string(path), "include", "wx.hrl"]) + if File.exists?(path), do: path + + _ -> + nil + end + end + ) + + if @wx_hrl do + require Record + + for tag <- [:wx, :wxCommand, :wxClose, :wxMenu] do + Record.defrecord(tag, Record.extract(tag, from: @wx_hrl)) + end + else + require Record + + Record.defrecord(:wx, id: nil, obj: nil, userData: nil, event: nil) + Record.defrecord(:wxCommand, type: nil, cmdString: nil, commandInt: nil, extraLong: nil) + Record.defrecord(:wxClose, type: nil) + Record.defrecord(:wxMenu, type: nil) + end +end diff --git a/mix.exs b/mix.exs index 82a0bd7..fad228a 100644 --- a/mix.exs +++ b/mix.exs @@ -1,3 +1,5 @@ +Code.require_file("desktop_wx_stub.exs", __DIR__) + defmodule Desktop.MixProject do use Mix.Project @@ -7,7 +9,19 @@ defmodule Desktop.MixProject do @version "1.5.3" @url "https://github.com/elixir-desktop/desktop" + def cli do + [ + preferred_envs: [ + test: :test, + "test.fast": :test, + "test.wx": :test + ] + ] + end + def project do + Desktop.WxStub.write!() + [ app: :desktop, name: "Desktop", @@ -16,6 +30,7 @@ defmodule Desktop.MixProject do description: @description, elixir: "~> 1.11", elixirc_paths: elixirc_paths(Mix.env()), + erl_src_paths: erl_src_paths(), compilers: Mix.compilers(), aliases: aliases(), start_permanent: Mix.env() == :prod, @@ -33,6 +48,12 @@ defmodule Desktop.MixProject do defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] + # Compile src/desktop_wx.erl on host only (uses wx.hrl when available, else stub). + # Android/iOS set MIX_TARGET — skip Erlang (erlc has no :wx on the code path). + defp erl_src_paths do + if System.get_env("MIX_TARGET") in [nil, "host"], do: ["src"], else: [] + end + # Run "mix help compile.app" to learn about applications. def application do [ @@ -60,6 +81,9 @@ defmodule Desktop.MixProject do defp aliases() do [ + "test.fast": ["test --exclude wx"], + "test.wx": ["test --only wx"], + "test.guard": ["run test/support/guard_boolean_ops.exs"], lint: [ "compile --warnings-as-errors", "format --check-formatted", @@ -117,7 +141,7 @@ defmodule Desktop.MixProject do maintainers: ["Dominic Letz"], licenses: ["MIT"], links: %{github: @url}, - files: ~w(src lib LICENSE.md mix.exs README.md) + files: ~w(src lib LICENSE.md mix.exs README.md desktop_wx_stub.exs) ] end end diff --git a/src/desktop_wx.erl b/src/desktop_wx.erl deleted file mode 100644 index 5f20e95..0000000 --- a/src/desktop_wx.erl +++ /dev/null @@ -1,23 +0,0 @@ - -module(desktop_wx). - -include_lib("wx/include/wx.hrl"). - -export([get/1]). - - get(wxDEFAULT_FRAME_STYLE) -> ?wxDEFAULT_FRAME_STYLE; - get(wxEXPAND) -> ?wxEXPAND; - get(wxHORIZONTAL) -> ?wxHORIZONTAL; - get(wxICON_ERROR) -> ?wxICON_ERROR; - get(wxICON_INFORMATION) -> ?wxICON_INFORMATION; - get(wxICON_QUESTION) -> ?wxICON_QUESTION; - get(wxICON_WARNING) -> ?wxICON_WARNING; - get(wxID_ANY) -> ?wxID_ANY; - get(wxID_EXIT) -> ?wxID_EXIT; - get(wxIMAGE_QUALITY_HIGH) -> ?wxIMAGE_QUALITY_HIGH; - get(wxITEM_CHECK) -> ?wxITEM_CHECK; - get(wxITEM_NORMAL) -> ?wxITEM_NORMAL; - get(wxITEM_RADIO) -> ?wxITEM_RADIO; - get(wxITEM_SEPARATOR) -> ?wxITEM_SEPARATOR; - get(wxMAJOR_VERSION) -> ?wxMAJOR_VERSION; - get(wxMINOR_VERSION) -> ?wxMINOR_VERSION; - get(wxNO_BORDER) -> ?wxNO_BORDER; - get(wxRELEASE_NUMBER) -> ?wxRELEASE_NUMBER; - get(wxVERTICAL) -> ?wxVERTICAL. diff --git a/test/desktop/backend/browser_test.exs b/test/desktop/backend/browser_test.exs new file mode 100644 index 0000000..168bafe --- /dev/null +++ b/test/desktop/backend/browser_test.exs @@ -0,0 +1,42 @@ +defmodule Desktop.Backend.BrowserTest do + use ExUnit.Case, async: true + + alias Desktop.Backend.Browser + + test "capabilities" do + caps = Browser.capabilities() + assert caps.window == false + assert caps.content == :os_browser + assert caps.menu == :none + end + + test "T-BRW: all Window callbacks return without raise" do + assert {:ok, nil, nil} = Browser.open(wx: nil, title: "t", size: {100, 100}) + assert :ok = Browser.hide(nil) + assert :ok = Browser.show(nil, []) + assert :ok = Browser.set_title(nil, "x") + assert :ok = Browser.connect(nil, :close_window, fn -> :ok end) + assert Browser.is_shown?(nil) == false + assert Browser.is_active?(nil) == false + end + + test "T-BRW: Content callbacks" do + assert is_pid(Browser.content_show(nil, nil, "http://example.com", false)) + assert Browser.current_url(nil, "http://last") == "http://last" + assert Browser.rebuild(nil, nil) == nil + end + + test "T-BRW: Notification callbacks" do + assert :ok = Browser.notification_show(nil, "msg", 0, "title") + assert :ok = Browser.close(nil) + end + + test "T-BRW: System callbacks" do + assert {nil, nil} = Browser.init_env() + assert :ok = Browser.subscribe_events() + assert :ok = Browser.set_env(nil) + assert Browser.get_env() == nil + assert Browser.locale() == nil + refute Browser.wx_available?() + end +end diff --git a/test/desktop/backend/json_test.exs b/test/desktop/backend/json_test.exs new file mode 100644 index 0000000..ff8c595 --- /dev/null +++ b/test/desktop/backend/json_test.exs @@ -0,0 +1,50 @@ +defmodule Desktop.Backend.JsonTest do + use ExUnit.Case, async: false + + alias Desktop.Backend.Json + alias Desktop.Bridge.Transport + + setup do + previous = Application.get_env(:desktop, :backend, :auto) + Application.put_env(:desktop, :backend, :json) + System.put_env("BRIDGE_PORT", "0") + Transport.ensure_started() + + on_exit(fn -> + Application.put_env(:desktop, :backend, previous) + end) + + :ok + end + + test "capabilities" do + caps = Json.capabilities() + assert caps.content == :native + assert caps.menu == :native + end + + test "T-JSN: new frame handle shape" do + wx = Transport.ensure_started() + + {:ok, frame, webview} = + Json.open(wx: wx, title: "test", size: {400, 300}, icon: nil) + + assert frame[:type] == :wxFrame + assert is_integer(frame[:id]) + assert webview[:type] == :wxWebView + end + + test "T-JSN: connect returns ok" do + wx = Transport.ensure_started() + {:ok, frame, _} = Json.open(wx: wx, title: "t", size: {200, 200}, icon: nil) + + assert :ok = Json.connect(frame, :close_window, fn _, _ -> :ok end) + end + + test "T-JSN: loadURL via content_show" do + wx = Transport.ensure_started() + {:ok, frame, webview} = Json.open(wx: wx, title: "t", size: {200, 200}, icon: nil) + + assert :ok = Json.content_show(webview, frame, "http://localhost:4000", false) + end +end diff --git a/test/desktop/backend/wx_test.exs b/test/desktop/backend/wx_test.exs new file mode 100644 index 0000000..cc4c543 --- /dev/null +++ b/test/desktop/backend/wx_test.exs @@ -0,0 +1,42 @@ +defmodule Desktop.Backend.WxTest do + use Desktop.Test.WxCase + + alias Desktop.Backend.Wx + + test "capabilities" do + caps = Wx.capabilities() + assert caps.window + assert caps.content == :webview + end + + @tag timeout: 10_000 + test "T-WX: open and destroy frame" do + wx = Desktop.Env.wx() + + {:ok, frame, webview} = + Wx.open( + wx: wx, + title: ~c"wx test", + size: {320, 240}, + icon: nil + ) + + assert frame != nil + assert is_reference(webview) or is_tuple(webview) or webview == nil + assert :ok = Wx.destroy_frame(frame) + end + + @tag timeout: 10_000 + test "T-WX: connect close_window" do + wx = Desktop.Env.wx() + {:ok, frame, _} = Wx.open(wx: wx, title: ~c"t", size: {200, 200}, icon: nil) + + assert :ok = Wx.connect(frame, :close_window, fn _, _ -> :ok end) + Wx.destroy_frame(frame) + end + + test "T-WX: locale after ensure_wx_env" do + result = Wx.locale() + assert result == nil or is_binary(result) + end +end diff --git a/test/desktop/bridge_transport_test.exs b/test/desktop/bridge_transport_test.exs new file mode 100644 index 0000000..8a99d4c --- /dev/null +++ b/test/desktop/bridge_transport_test.exs @@ -0,0 +1,67 @@ +defmodule Desktop.Bridge.TransportTest do + use ExUnit.Case, async: false + + alias Desktop.Bridge.{Codec, Transport} + + setup do + System.put_env("BRIDGE_PORT", "0") + pid = Transport.ensure_started() + + on_exit(fn -> + if Process.alive?(pid), do: GenServer.stop(pid) + end) + + {:ok, transport: pid} + end + + test "T-BRG-03: subscribe_events delivers events" do + transport = Transport.ensure_started() + subscriber = self() + event = [:open_file, ["/tmp/test.txt"]] + + ref = 0 + json = Codec.encode!(event) + + send(transport, {:tcp, Desktop.Bridge.Mock, <>}) + + assert :ok = Transport.subscribe_events(subscriber) + assert_receive [:open_file, ["/tmp/test.txt"]], 1000 + end + + test "T-BRG-04: connect is cast and returns ok" do + frame = [id: 1, type: :wxFrame, args: []] + + assert :ok = + Transport.bridge_call(:wxFrame, :connect, [ + frame, + :close_window, + [callback: fn -> :ok end, userData: self()] + ]) + end + + test "T-BRG-05: callback ref 1 invokes registered fun" do + parent = self() + + fun = fn arg -> + send(parent, {:fun_called, arg}) + end + + ref = Transport.register_fun(fun) + json = Codec.encode!(["clicked"]) + + transport = Transport.ensure_started() + + send( + transport, + {:tcp, Desktop.Bridge.Mock, <<1::unsigned-size(64), ref::unsigned-size(64), json::binary>>} + ) + + Process.sleep(50) + assert_received {:fun_called, "clicked"} + end + + test "loadURL tracks last url" do + Transport.bridge_call(:wxWebView, :loadURL, [nil, "http://example.com/app"]) + assert :ok + end +end diff --git a/test/desktop/env_test.exs b/test/desktop/env_test.exs new file mode 100644 index 0000000..b2587d2 --- /dev/null +++ b/test/desktop/env_test.exs @@ -0,0 +1,33 @@ +defmodule Desktop.EnvTest do + use ExUnit.Case, async: false + + describe "browser backend" do + test "T-ENV-03: wx_use_env with nil wx_env is no-op" do + previous = Application.get_env(:desktop, :backend, :auto) + Application.put_env(:desktop, :backend, :browser) + + try do + {:ok, _} = Application.ensure_all_started(:desktop) + assert :ok = Desktop.Env.wx_use_env() + after + Application.put_env(:desktop, :backend, previous) + end + end + end + + describe "wx backend (T-ENV-04)" do + use Desktop.Test.WxCase + + test "Env stores wx and wx_env after init" do + wx = Desktop.Env.wx() + env = Desktop.Env.wx_env() + + assert wx != nil + assert env != nil + end + + test "wx_use_env succeeds" do + assert :ok = Desktop.Env.wx_use_env() + end + end +end diff --git a/test/desktop/fallback_test.exs b/test/desktop/fallback_test.exs new file mode 100644 index 0000000..43e4ff4 --- /dev/null +++ b/test/desktop/fallback_test.exs @@ -0,0 +1,23 @@ +defmodule Desktop.FallbackTest do + use Desktop.Test.DesktopCase, async: true + + alias Desktop.Fallback + + test "T-FALL-01: webview_load with nil webview does not raise" do + window = minimal_window(frame: nil, webview: nil, last_url: nil) + + with_backend(:browser, fn -> + assert Fallback.webview_load(window, "http://example.com") + end) + end + + test "wx_new returns pid or nil" do + with_backend(:browser, fn -> + assert Fallback.wx_new([]) == nil + end) + end + + test "notification_show with nil notification logs" do + assert :ok = Fallback.notification_show(nil, "hello", -1, "title") + end +end diff --git a/test/desktop/guard_boolean_ops_test.exs b/test/desktop/guard_boolean_ops_test.exs new file mode 100644 index 0000000..ae445d5 --- /dev/null +++ b/test/desktop/guard_boolean_ops_test.exs @@ -0,0 +1,25 @@ +defmodule Desktop.Guard.BooleanOpsTest do + use ExUnit.Case, async: true + + @window_file Path.expand("../../lib/desktop/window.ex", __DIR__) + + @forbidden [ + {~r/if\s+frame\s+and\s+/, "use frame != nil && ... (R4)"}, + {~r/if\s+menubar\s+and\s+/, "use menubar && frame (R3)"}, + {~r/if\s+frame\s+and\s+not\s+/, "use frame != nil && !... (R4)"} + ] + + test "L0-GUARD: window.ex has no known unsafe and/or patterns" do + content = File.read!(@window_file) + + violations = + for {pattern, message} <- @forbidden, + line <- String.split(content, "\n"), + Regex.match?(pattern, line) do + {message, String.trim(line)} + end + + assert violations == [], + "unsafe boolean ops:\n#{inspect(violations, pretty: true)}" + end +end diff --git a/test/desktop/language_code_test.exs b/test/desktop/language_code_test.exs new file mode 100644 index 0000000..092f57a --- /dev/null +++ b/test/desktop/language_code_test.exs @@ -0,0 +1,26 @@ +defmodule Desktop.LanguageCodeTest do + use ExUnit.Case, async: false + + alias Desktop + + test "T-ENV-01: language_code with Browser backend does not require wx" do + previous = Application.get_env(:desktop, :backend, :auto) + Application.put_env(:desktop, :backend, :browser) + + try do + result = Desktop.language_code() + assert result == nil or is_binary(result) + after + Application.put_env(:desktop, :backend, previous) + end + end + + describe "with wx (T-ENV-02)" do + use Desktop.Test.WxCase + + test "language_code after Env start does not raise unknown_env" do + result = Desktop.language_code() + assert result == nil or is_binary(result) + end + end +end diff --git a/test/desktop/menu_adapter_test.exs b/test/desktop/menu_adapter_test.exs new file mode 100644 index 0000000..84344f5 --- /dev/null +++ b/test/desktop/menu_adapter_test.exs @@ -0,0 +1,40 @@ +defmodule Desktop.MenuAdapterTest do + use ExUnit.Case, async: true + + alias Desktop.Menu.Adapter.{Browser, Json} + alias Desktop.Bridge.Transport + + test "T-MENU-01: Browser adapter is no-op" do + adapter = Browser.new(menu_pid: self()) + dom = {:menubar, %{}, []} + + adapter = Browser.create(adapter, dom) + adapter = Browser.update_dom(adapter, dom) + assert Browser.menubar(adapter) == nil + assert {:ok, ^adapter} = Browser.set_icon(adapter, nil) + end + + describe "Json adapter (T-MENU-02)" do + setup do + System.put_env("BRIDGE_PORT", "0") + Transport.ensure_started() + :ok + end + + test "minimal menubar dom" do + menubar = Desktop.Bridge.Protocol.new(:wxMenuBar, []) + + adapter = + Json.new( + menu_pid: self(), + env: :ok, + wx: menubar + ) + + dom = {:menubar, %{}, [{:menu, %{label: "File"}, [{:item, %{onclick: "quit"}, ["Quit"]}]}]} + + adapter = Json.create(adapter, dom) + assert adapter.menubar != nil + end + end +end diff --git a/test/desktop/platform_test.exs b/test/desktop/platform_test.exs index 672fa3e..7460087 100644 --- a/test/desktop/platform_test.exs +++ b/test/desktop/platform_test.exs @@ -1,42 +1,60 @@ defmodule Desktop.PlatformTest do - use ExUnit.Case + use ExUnit.Case, async: true + + import Desktop.Test.DesktopCase alias Desktop.Platform alias Desktop.Backend.{Wx, Json, Browser} - test "backend returns Wx on host by default" do - assert Platform.backend() == Wx + test "T-PLAT-01: backend returns Wx on host by default" do + with_backend(:auto, fn -> + assert Platform.backend() == Wx + end) end - test "capabilities for Wx backend" do - assert Wx.capabilities().window - assert Wx.capabilities().content == :webview + test "T-PLAT-02: config override browser" do + with_backend(:browser, fn -> + assert Platform.backend() == Browser + end) + end + + test "T-PLAT-03: config override json" do + with_backend(:json, fn -> + assert Platform.backend() == Json + end) end - test "capabilities for Browser backend" do - assert Browser.capabilities().content == :os_browser - assert Browser.capabilities().menu == :none + test "T-PLAT-04: NO_WX selects Browser" do + with_env("NO_WX", "1", fn -> + with_backend(:auto, fn -> + assert Platform.backend() == Browser + end) + end) end - test "capabilities for Json backend" do - assert Json.capabilities().content == :native - assert Json.capabilities().menu == :native + test "T-PLAT-05: capabilities for Wx backend" do + assert Wx.capabilities().window + assert Wx.capabilities().content == :webview end - test "config override backend" do - Application.put_env(:desktop, :backend, :browser) + test "T-PLAT-06: window_server on browser is Platform.Server" do + with_backend(:browser, fn -> + assert Platform.window_server() == Desktop.Platform.Server + end) + end - try do - assert Platform.backend() == Browser - after - Application.delete_env(:desktop, :backend) - end + test "menu adapter selection browser" do + with_backend(:browser, fn -> + assert Platform.Menu.adapter() == Desktop.Menu.Adapter.Browser + end) end - test "menu adapter selection" do - assert Platform.Menu.adapter() in [ - Desktop.Menu.Adapter.Wx, - Desktop.Menu.Adapter.Browser - ] + test "menu adapter selection wx" do + with_backend(:wx, fn -> + assert Platform.Menu.adapter() in [ + Desktop.Menu.Adapter.Wx, + Desktop.Menu.Adapter.Browser + ] + end) end end diff --git a/test/desktop/regression/boolean_ops_test.exs b/test/desktop/regression/boolean_ops_test.exs new file mode 100644 index 0000000..3298c38 --- /dev/null +++ b/test/desktop/regression/boolean_ops_test.exs @@ -0,0 +1,47 @@ +defmodule Desktop.Regression.BooleanOpsTest do + use Desktop.Test.DesktopCase, async: true + + alias Desktop.Window + + test "T-BOOL-01: menubar && frame guard does not badbool with module atom" do + menubar = Desktop.Test.MenuStub + frame = fake_frame() + + assert menubar && frame + end + + test "T-BOOL-02: close_window with wx ref and taskbar does not raise badbool" do + ui = + minimal_window( + frame: fake_frame(), + taskbar: self(), + on_close: :quit + ) + + with_backend(:browser, fn -> + assert {:noreply, ^ui} = Window.handle_cast(:close_window, ui) + end) + end + + test "T-BOOL-03: close_window with nil frame does not crash" do + ui = minimal_window(frame: nil, taskbar: self(), on_close: :quit) + + with_backend(:browser, fn -> + assert {:noreply, ^ui} = Window.handle_cast(:close_window, ui) + end) + end + + test "T-BOOL-04: close_window on_close hide hides without shutdown" do + ui = minimal_window(frame: fake_frame(), on_close: :hide) + + with_backend(:browser, fn -> + assert {:noreply, ^ui} = Window.handle_cast(:close_window, ui) + end) + end + + test "T-BOOL-05: hidden-close visibility expression uses boolean operators" do + frame = fake_frame() + assert frame != nil && !true == false + assert frame != nil && !false == true + end +end diff --git a/test/desktop/regression/wx_connect_test.exs b/test/desktop/regression/wx_connect_test.exs new file mode 100644 index 0000000..5d5c6e6 --- /dev/null +++ b/test/desktop/regression/wx_connect_test.exs @@ -0,0 +1,59 @@ +defmodule Desktop.Regression.WxConnectTest do + use ExUnit.Case, async: false + + alias Desktop.Bridge.Codec + + describe "Json connect RPC (T-CONN-02)" do + setup do + Desktop.Bridge.Transport.ensure_started() + :ok + end + + test "connect encodes callback and userData in args list" do + frame = [id: 1, type: :wxFrame, args: []] + pid = self() + fun = fn _, _ -> :ok end + + json = + Codec.encode!([ + :wxFrame, + :connect, + [frame, :close_window, [callback: fun, userData: pid]] + ]) + + decoded = Codec.decode!(json) + assert [:wxFrame, :connect, [^frame, :close_window, opts]] = decoded + assert Keyword.has_key?(opts, :callback) + assert Keyword.fetch!(opts, :userData) == pid + end + end + + describe "Wx connect (T-CONN-01)" do + use Desktop.Test.WxCase + + test "Backend.Wx.connect does not raise on close_window" do + previous = Application.get_env(:desktop, :backend, :auto) + Application.put_env(:desktop, :backend, :wx) + + try do + wx = Desktop.Env.wx() + {:ok, frame, _webview} = + Desktop.Platform.Window.open( + wx: wx, + title: ~c"connect test", + size: {200, 200}, + icon: nil + ) + + assert :ok = + Desktop.Backend.Wx.connect(frame, :close_window, fn _, _ -> + :ok + end) + + Desktop.Platform.Window.destroy(frame) + after + Application.put_env(:desktop, :backend, previous) + end + end + end +end diff --git a/test/desktop/window_lifecycle_test.exs b/test/desktop/window_lifecycle_test.exs new file mode 100644 index 0000000..1587eed --- /dev/null +++ b/test/desktop/window_lifecycle_test.exs @@ -0,0 +1,56 @@ +defmodule Desktop.WindowLifecycleTest do + use Desktop.Test.DesktopCase, async: true + + alias Desktop.Window + + test "T-WIN-01: prepare_url appends auth key" do + expected = "/some/?k=" <> Desktop.Auth.login_key() + assert Window.prepare_url("/some/") == expected + end + + test "T-WIN-02: hide and set_title with nil frame" do + ui = minimal_window(frame: nil) + + with_backend(:browser, fn -> + assert {:noreply, ^ui} = Window.handle_cast(:hide, ui) + assert {:noreply, _} = Window.handle_cast({:set_title, "New"}, ui) + end) + end + + test "T-WIN-03: close_window branches" do + with_backend(:browser, fn -> + ui_hide = minimal_window(frame: fake_frame(), on_close: :hide) + assert {:noreply, ^ui_hide} = Window.handle_cast(:close_window, ui_hide) + + ui_tray = + minimal_window(frame: fake_frame(), taskbar: self(), on_close: :quit) + + assert {:noreply, ^ui_tray} = Window.handle_cast(:close_window, ui_tray) + end) + end + + describe "T-WIN-04 wx integration" do + use Desktop.Test.WxCase + + @moduletag timeout: 10_000 + + test "start_link and close_window cast keeps process alive" do + name = :"window_test_#{System.unique_integer([:positive])}" + + {:ok, pid} = + Window.start_link( + app: :desktop, + id: name, + title: "Lifecycle", + size: {400, 300}, + hidden: true + ) + + assert Process.alive?(pid) + GenServer.cast(pid, :close_window) + Process.sleep(200) + assert Process.alive?(pid) + GenServer.stop(pid) + end + end +end diff --git a/test/support/desktop_case.ex b/test/support/desktop_case.ex new file mode 100644 index 0000000..8ee00eb --- /dev/null +++ b/test/support/desktop_case.ex @@ -0,0 +1,69 @@ +defmodule Desktop.Test.DesktopCase do + @moduledoc false + + defmacro __using__(_opts) do + quote do + use ExUnit.Case + + import Desktop.Test.DesktopCase + end + end + + def fake_frame(id \\ 1) do + {:wx_ref, id, :wxFrame, []} + end + + def fake_webview(id \\ 2) do + {:wx_ref, id, :wxWebView, []} + end + + def minimal_window(overrides \\ []) do + struct!( + %Desktop.Window{ + module: nil, + taskbar: nil, + frame: nil, + id: :test_window, + notifications: %{}, + webview: nil, + home_url: nil, + last_url: nil, + title: "Test", + on_close: :quit + }, + overrides + ) + end + + def with_backend(backend, fun) when is_function(fun, 0) do + previous = Application.get_env(:desktop, :backend, :auto) + + Application.put_env(:desktop, :backend, backend) + + try do + fun.() + after + Application.put_env(:desktop, :backend, previous) + end + end + + def with_env(key, value, fun) when is_function(fun, 0) do + previous = System.get_env(key) + + if value == nil do + System.delete_env(key) + else + System.put_env(key, value) + end + + try do + fun.() + after + if previous == nil do + System.delete_env(key) + else + System.put_env(key, previous) + end + end + end +end diff --git a/test/support/guard_boolean_ops.exs b/test/support/guard_boolean_ops.exs new file mode 100644 index 0000000..3e92801 --- /dev/null +++ b/test/support/guard_boolean_ops.exs @@ -0,0 +1,32 @@ +# Run via: mix test.guard +# Fails when known unsafe `and`/`or` patterns reappear in window.ex + +window_file = Path.expand("../../lib/desktop/window.ex", __DIR__) +content = File.read!(window_file) + +forbidden = [ + {~r/if\s+frame\s+and\s+/, + "use `frame != nil && ...` — wx refs are not boolean (R4)"}, + {~r/if\s+menubar\s+and\s+/, + "use `menubar && frame` — module atoms are not boolean (R3)"}, + {~r/if\s+frame\s+and\s+not\s+/, "use `frame != nil && !...` (R4)"} +] + +violations = + for {pattern, message} <- forbidden, + line <- String.split(content, "\n"), + Regex.match?(pattern, line) do + {message, String.trim(line)} + end + +if violations != [] do + IO.puts(:stderr, "guard_boolean_ops: FAILED\n") + + for {message, line} <- violations do + IO.puts(:stderr, " #{message}\n #{line}\n") + end + + System.halt(1) +else + IO.puts("guard_boolean_ops: OK") +end diff --git a/test/support/menu_stub.ex b/test/support/menu_stub.ex new file mode 100644 index 0000000..482356f --- /dev/null +++ b/test/support/menu_stub.ex @@ -0,0 +1,3 @@ +defmodule Desktop.Test.MenuStub do + @moduledoc false +end diff --git a/test/support/wx_case.ex b/test/support/wx_case.ex new file mode 100644 index 0000000..e4da813 --- /dev/null +++ b/test/support/wx_case.ex @@ -0,0 +1,25 @@ +defmodule Desktop.Test.WxCase do + @moduledoc false + + defmacro __using__(_opts) do + quote do + use ExUnit.Case + + @moduletag :wx + + setup _tags do + previous = Application.get_env(:desktop, :backend, :auto) + Application.put_env(:desktop, :backend, :wx) + + {:ok, _} = Application.ensure_all_started(:desktop) + Desktop.Env.wx_use_env() + + on_exit(fn -> + Application.put_env(:desktop, :backend, previous) + end) + + :ok + end + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..b17802e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1 @@ -ExUnit.start() +ExUnit.start(exclude: [:wx]) From b782ba17a17082d019fadc34972ca86af4369a98 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 22 May 2026 17:06:53 +0200 Subject: [PATCH 3/6] Fixup Platform abstraction --- AGENTS.md | 25 ++++++- README.md | 2 +- config/config.exs | 3 + guides/faq.md | 2 +- lib/desktop.ex | 5 +- lib/desktop/backend/browser.ex | 12 ++-- lib/desktop/backend/json.ex | 36 +++++++--- lib/desktop/backend/wx.ex | 50 ++++++++------ lib/desktop/bridge/mock.ex | 1 + lib/desktop/env.ex | 10 ++- lib/desktop/impl/host_browser.ex | 43 ++++++++++++ lib/desktop/menu.ex | 2 +- lib/desktop/os.ex | 30 ++------- lib/desktop/platform.ex | 4 +- lib/desktop/platform/helpers.ex | 13 ++++ lib/desktop/platform/system.ex | 15 ++++- lib/desktop/platform/window.ex | 39 ++++++----- lib/desktop/window.ex | 12 +--- mix.exs | 5 +- test/desktop/backend/browser_test.exs | 2 +- test/desktop/backend/json_test.exs | 4 ++ test/desktop/backend/wx_test.exs | 2 +- test/desktop/platform_test.exs | 17 +++++ .../desktop/regression/beam_wx_calls_test.exs | 48 +++++++++++++ test/support/guard_platform_abstraction.exs | 67 +++++++++++++++++++ 25 files changed, 349 insertions(+), 100 deletions(-) create mode 100644 lib/desktop/impl/host_browser.ex create mode 100644 lib/desktop/platform/helpers.ex create mode 100644 test/desktop/regression/beam_wx_calls_test.exs create mode 100644 test/support/guard_platform_abstraction.exs diff --git a/AGENTS.md b/AGENTS.md index 67ba3b4..d36a715 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,30 @@ All commands that load the `:wx` application (compile, test, iex) need a display | Backend | When | |---|---| | `Desktop.Backend.Wx` | Desktop host targets with OTP `:wx` (default) | -| `Desktop.Backend.Json` | `Mix.target()` is `:android` or `:ios` — JSON bridge via `BRIDGE_PORT` | +| `Desktop.Backend.Json` | `:mobile_target` compile config or `OS.mobile?/0` — JSON bridge via `BRIDGE_PORT` | | `Desktop.Backend.Browser` | `NO_WX=1` or `:wx` unavailable | Override with `config :desktop, :backend, :wx | :json | :browser | :auto`. + +### Platform abstraction (do not regress) + +Library code is layered: + +1. **Public API** — `Desktop`, `Desktop.Window`, `Desktop.OS` (legacy helpers). +2. **Platform facades** — `Desktop.Platform.*` (route to the active backend). +3. **Backends** — `Desktop.Backend.Wx`, `.Json`, `.Browser` (implement behaviour). + +**Rules for agents and contributors:** + +| Do | Don't | +|---|---| +| Call `Desktop.Platform.System.locale/0`, `.open_external_url/1`, `Desktop.Platform.Window.*`, etc. from library code | Call `Desktop.Backend.*` or `:wx*` modules directly from `Window`, `Menu`, `OS`, etc. | +| Keep `Desktop.OS.launch_default_browser/1` as a thin spawn wrapper to `Platform.System.open_external_url/1` only in `os.ex` | Re-implement browser launch, locale, or wx setup in `OS` with `case OS.type()` / `wx_available?` branches | +| Let `Desktop.Platform.Helpers.with_wx_env/1` (used by `Platform.System` and `Platform.Window`) call `Desktop.Env.wx_use_env/0` before backend work | Call `Desktop.Env.wx_use_env/0` from app/library modules; never guard it with `if Platform.System.wx_available?()` | +| Rely on `wx_use_env/0` being a safe no-op when `Desktop.Env` is down or `wx_env` is nil | Assume wx is loaded on Json/mobile or skip Platform because “it's only wx” | +| On **Json/mobile**, use `Protocol.new/call/connect` with **bridge atoms** (e.g. `[:getSystemLanguage]`) | Put evaluated BEAM calls in RPC args (e.g. `Protocol.new(:wxLocale, [:wxLocale.getSystemLanguage()])` or `:wxLocale.getSystemLanguage/0` in `json.ex`) | +| Put wx-specific logic in the matching **backend** module | Put Android/iOS `:ok` stubs or `Null.wx_call` in `OS` or `Window` | + +**Regression guards:** `mix test.guard` runs `guard_boolean_ops.exs` and `guard_platform_abstraction.exs` (forbidden patterns under `lib/`). + +**Tests:** `mix test.fast` — no wx; `xvfb-run -a mix test.wx` — wx backend; `mix test.guard` — static rules. diff --git a/README.md b/README.md index cb60d3c..e544785 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ This repo’s [`.tool-versions`](./.tool-versions) pins Erlang and Elixir for co **Automatic selection** (`config :desktop, :backend, :auto` — the default): -1. Mobile target (`Mix.target()` `:android` / `:ios`, or `Desktop.OS.mobile?/0`) → **Json** +1. Mobile target (`config :desktop, :mobile_target, true` at compile time, or `Desktop.OS.mobile?/0` at runtime) → **Json** 2. Else `NO_WX` set or `:wx` not available → **Browser** 3. Else → **Wx** diff --git a/config/config.exs b/config/config.exs index 95a3373..88b7fae 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,3 +1,6 @@ import Config config :phoenix, :json_library, Jason + +# Set at compile time from MIX_TARGET (no Mix at runtime in releases). +config :desktop, :mobile_target, Mix.target() in [:android, :ios] diff --git a/guides/faq.md b/guides/faq.md index 0be2d97..d73f0bd 100644 --- a/guides/faq.md +++ b/guides/faq.md @@ -14,7 +14,7 @@ config :desktop, :backend, :auto | Condition | Backend | |---|---| -| `Mix.target()` is `:android` or `:ios`, or `Desktop.OS.mobile?/0` | `Desktop.Backend.Json` | +| `config :desktop, :mobile_target, true` (compile time) or `Desktop.OS.mobile?/0` (runtime `ELIXIR_DESKTOP_OS`) | `Desktop.Backend.Json` | | `NO_WX` is set, or OTP `:wx` is not available | `Desktop.Backend.Browser` | | Otherwise | `Desktop.Backend.Wx` | diff --git a/lib/desktop.ex b/lib/desktop.ex index 1890cc3..11ab583 100644 --- a/lib/desktop.ex +++ b/lib/desktop.ex @@ -98,10 +98,7 @@ defmodule Desktop do # https://stackoverflow.com/questions/661935/how-to-detect-current-locale-in-mac-os-x-from-the-shell code else - _ -> - # Wx APIs require the process wx env from Desktop.Env (see :wx.set_env/1). - Desktop.Env.wx_use_env() - Desktop.Platform.System.locale() + _ -> Desktop.Platform.System.locale() end end diff --git a/lib/desktop/backend/browser.ex b/lib/desktop/backend/browser.ex index 8473e5b..35e95f1 100644 --- a/lib/desktop/backend/browser.ex +++ b/lib/desktop/backend/browser.ex @@ -1,8 +1,6 @@ defmodule Desktop.Backend.Browser do @moduledoc false - alias Desktop.OS - @behaviour Desktop.Platform.Backend @behaviour Desktop.Platform.Window @behaviour Desktop.Platform.Content @@ -44,6 +42,12 @@ defmodule Desktop.Backend.Browser do @impl true def wx_available?, do: false + @impl true + def open_external_url(url), do: Desktop.Impl.HostBrowser.open(url) + + @impl true + def activate_event_active?(_event), do: true + # Window @impl true @@ -103,13 +107,13 @@ defmodule Desktop.Backend.Browser do def attach(_frame), do: nil @impl true - def load_url(_content, _frame, url), do: OS.launch_default_browser(url) + def load_url(_content, _frame, url), do: open_external_url(url) @impl true def current_url(_content, last_url), do: last_url @impl true - def content_show(_content, _frame, url, _), do: OS.launch_default_browser(url) + def content_show(_content, _frame, url, _), do: open_external_url(url) @impl true def rebuild(_frame, _url), do: nil diff --git a/lib/desktop/backend/json.ex b/lib/desktop/backend/json.ex index a7c705c..11de319 100644 --- a/lib/desktop/backend/json.ex +++ b/lib/desktop/backend/json.ex @@ -2,7 +2,7 @@ defmodule Desktop.Backend.Json do @moduledoc false alias Desktop.Bridge.{Protocol, Transport} - alias Desktop.{Wx, OS} + alias Desktop.Wx @behaviour Desktop.Platform.Backend @behaviour Desktop.Platform.Window @@ -44,11 +44,20 @@ defmodule Desktop.Backend.Json do @impl true def locale do - Protocol.call(:wxLocale, :getCanonicalName, [ - Protocol.new(:wxLocale, [:wxLocale.getSystemLanguage()]) - ]) - |> List.to_string() - |> String.downcase() + # Pass :getSystemLanguage as a bridge atom — never call :wxLocale.getSystemLanguage/0 + # on the BEAM (that module is not loaded on Android/iOS releases). + locale = + Protocol.new(:wxLocale, [:getSystemLanguage]) + + case Protocol.call(:wxLocale, :getCanonicalName, [locale]) do + name when is_list(name) -> List.to_string(name) + name when is_binary(name) -> name + _ -> nil + end + |> case do + nil -> nil + code -> String.downcase(code) + end end @impl true @@ -60,7 +69,16 @@ defmodule Desktop.Backend.Json do end @impl true - def wx_available?, do: true + def wx_available?, do: false + + @impl true + def open_external_url(url) do + Protocol.call(:wx_misc, :launchDefaultBrowser, [String.to_charlist(url)]) + :ok + end + + @impl true + def activate_event_active?(_event), do: true # Window @@ -188,7 +206,7 @@ defmodule Desktop.Backend.Json do end @impl true - def load_url(nil, _frame, url), do: OS.launch_default_browser(url) + def load_url(nil, _frame, url), do: open_external_url(url) def load_url(webview, _frame, url) do Protocol.call(:wxWebView, :loadURL, [webview, url]) @@ -206,7 +224,7 @@ defmodule Desktop.Backend.Json do end @impl true - def content_show(nil, _frame, url, _), do: OS.launch_default_browser(url) + def content_show(nil, _frame, url, _), do: open_external_url(url) def content_show(webview, frame, url, _only_open) do if url, do: Protocol.call(:wxWebView, :loadURL, [webview, url]) diff --git a/lib/desktop/backend/wx.ex b/lib/desktop/backend/wx.ex index 1aeed8e..1db5468 100644 --- a/lib/desktop/backend/wx.ex +++ b/lib/desktop/backend/wx.ex @@ -42,25 +42,11 @@ defmodule Desktop.Backend.Wx do @impl true def locale do - ensure_wx_env() - - locale = Null.wx_call(:wxLocale, :new, [:wxLocale.getSystemLanguage()]) + lang = Null.wx_call(:wxLocale, :getSystemLanguage, []) + locale = Null.wx_call(:wxLocale, :new, [lang]) Null.wx_call(:wxLocale, :getCanonicalName, [locale]) |> List.to_string() |> String.downcase() end - defp ensure_wx_env do - env = - case Process.whereis(Desktop.Env) do - nil -> - Null.wx_call(:wx, :get_env) - - _ -> - Desktop.Env.wx_env() - end - - if env != nil, do: Null.wx_call(:wx, :set_env, [env]) - end - @impl true def connect_menu(object, command, callback, id) do opts = [{:callback, fn _, _ -> callback.() end}] @@ -71,6 +57,33 @@ defmodule Desktop.Backend.Wx do @impl true def wx_available?, do: Null.wx_enabled?() and Null.module?(:wx) + @impl true + def open_external_url(url) do + case OS.type() do + MacOS -> + Desktop.Impl.HostBrowser.open(url) + + Linux -> + Desktop.Impl.HostBrowser.open(url) + + Windows -> + Desktop.Impl.HostBrowser.open(url) + + _ -> + Null.wx_call(:wx_misc, :launchDefaultBrowser, [String.to_charlist(url)]) + :ok + end + end + + @impl true + def activate_event_active?(event) do + if function_exported?(:wxActivateEvent, :getActive, 1) do + :wxActivateEvent.getActive(event) + else + true + end + end + # Window @impl true @@ -178,7 +191,6 @@ defmodule Desktop.Backend.Wx do _ -> # Calling setFocus on wxDirDialog segfaults on macOS — handled above. - Desktop.Env.wx_use_env() Null.wx_call(:wxTopLevelWindow, :setFocus, [frame]) Null.wx_call(:wxWindow, :raise, [frame]) end @@ -240,7 +252,7 @@ defmodule Desktop.Backend.Wx do end @impl true - def load_url(nil, _frame, url), do: OS.launch_default_browser(url) + def load_url(nil, _frame, url), do: open_external_url(url) def load_url(webview, _frame, url) do Null.wx_call(:wxWebView, :loadURL, [webview, url]) @@ -258,7 +270,7 @@ defmodule Desktop.Backend.Wx do end @impl true - def content_show(nil, _frame, url, _), do: OS.launch_default_browser(url) + def content_show(nil, _frame, url, _), do: open_external_url(url) def content_show(webview, frame, url, _only_open) do if url, do: Null.wx_call(:wxWebView, :loadURL, [webview, url]) diff --git a/lib/desktop/bridge/mock.ex b/lib/desktop/bridge/mock.ex index 5596dc8..9f154fc 100644 --- a/lib/desktop/bridge/mock.ex +++ b/lib/desktop/bridge/mock.ex @@ -13,6 +13,7 @@ defmodule Desktop.Bridge.Mock do def handle_method([:wx, :getObjectType, [arg]]), do: Keyword.get(arg, :type) def handle_method([:wxLocale | _]), do: ~c"en" + def handle_method([:wx_misc, :launchDefaultBrowser | _]), do: :ok def handle_method([type, :new | args]), do: [id: System.unique_integer([:positive]), type: type, args: args] diff --git a/lib/desktop/env.ex b/lib/desktop/env.ex index 7906ed6..21cfaaf 100644 --- a/lib/desktop/env.ex +++ b/lib/desktop/env.ex @@ -227,9 +227,17 @@ defmodule Desktop.Env do Shortcut for `:wx.set_env(Desktop.Env.wx_env())` """ def wx_use_env() do - with env when env != nil <- wx_env() do + env = + case Process.whereis(__MODULE__) do + nil -> nil + _ -> wx_env() + end + + if env != nil do Desktop.Platform.System.set_env(env) end + + :ok end @doc false diff --git a/lib/desktop/impl/host_browser.ex b/lib/desktop/impl/host_browser.ex new file mode 100644 index 0000000..1266e64 --- /dev/null +++ b/lib/desktop/impl/host_browser.ex @@ -0,0 +1,43 @@ +defmodule Desktop.Impl.HostBrowser do + @moduledoc false + # Opens a URL or file path with the OS desktop handler (open / xdg-open / cmd). + + alias Desktop.OS + + @spec open(String.t()) :: :ok + def open(url) when is_binary(url) do + spawn(fn -> run_open(url) end) + :ok + end + + defp run_open(url) do + try do + case OS.type() do + MacOS -> + System.cmd("open", [url], stderr_to_stdout: true, parallelism: true) + + Linux -> + System.cmd("xdg-open", [url], + stderr_to_stdout: true, + parallelism: true, + env: linux_env() + ) + + Windows -> + System.cmd("cmd", ["/c", "start", "", url], stderr_to_stdout: true, parallelism: true) + + _ -> + :ok + end + catch + :exit, _ -> :ok + end + + :ok + end + + defp linux_env do + ~w(GDK_BACKEND LD_LIBRARY_PATH LD_PRELOAD GIO_MODULE_DIR GDK_PIXBUF_MODULE_FILE GST_PLUGIN_PATH GST_PLUGIN_SYSTEM_PATH GST_REGISTRY) + |> Enum.map(fn key -> {key, nil} end) + end +end diff --git a/lib/desktop/menu.ex b/lib/desktop/menu.ex index 9ff5177..e0b6f41 100644 --- a/lib/desktop/menu.ex +++ b/lib/desktop/menu.ex @@ -19,7 +19,7 @@ defmodule Desktop.Menu do case command do <<"open">> -> :not_implemented <<"quit">> -> Desktop.Window.quit() - <<"help">> -> Desktop.OS.launch_default_browser(\'https://google.com\') + <<"help">> -> Desktop.OS.open_url("https://google.com") <<"about">> -> :not_implemented end diff --git a/lib/desktop/os.ex b/lib/desktop/os.ex index 3c7fe07..960bd6c 100644 --- a/lib/desktop/os.ex +++ b/lib/desktop/os.ex @@ -138,36 +138,18 @@ defmodule Desktop.OS do end @doc """ - Replacement for the :wx_misc.launchDefaultBrowser function + Opens a URL or file path in the platform default handler. + + Delegates to `Desktop.Platform.System.open_external_url/1` on the active backend + (Json bridge RPC on mobile, host `open`/`xdg-open`/`cmd` or wx on desktop). """ def launch_default_browser(file) when is_list(file) do List.to_string(file) |> launch_default_browser() end - def launch_default_browser(file) do - spawn(fn -> - case type() do - MacOS -> - System.cmd("open", [file], stderr_to_stdout: true, parallelism: true) - - Linux -> - System.cmd("xdg-open", [file], - stderr_to_stdout: true, - parallelism: true, - env: linux_env() - ) - - Windows -> - System.cmd("cmd", ["/c", "start", "", file], stderr_to_stdout: true, parallelism: true) - - _other -> - if Desktop.Platform.System.wx_available?() do - Desktop.Env.wx_use_env() - Desktop.Backend.Null.wx_call(:wx_misc, :launchDefaultBrowser, [file]) - end - end - end) + def launch_default_browser(file) when is_binary(file) do + spawn(fn -> Desktop.Platform.System.open_external_url(file) end) end def open_url(url), do: launch_default_browser(url) diff --git a/lib/desktop/platform.ex b/lib/desktop/platform.ex index 39a1c40..88c2597 100644 --- a/lib/desktop/platform.ex +++ b/lib/desktop/platform.ex @@ -61,8 +61,10 @@ defmodule Desktop.Platform do end end + @mobile_target_compile Application.compile_env(:desktop, :mobile_target, false) + defp mobile_target? do - Mix.target() in [:android, :ios] or OS.mobile?() + Application.get_env(:desktop, :mobile_target, @mobile_target_compile) or OS.mobile?() end defp browser_mode? do diff --git a/lib/desktop/platform/helpers.ex b/lib/desktop/platform/helpers.ex new file mode 100644 index 0000000..744a786 --- /dev/null +++ b/lib/desktop/platform/helpers.ex @@ -0,0 +1,13 @@ +defmodule Desktop.Platform.Helpers do + @moduledoc false + + @doc """ + Ensures the current process can use wx APIs via the active backend. + + Safe on all backends (no-op when `Desktop.Env` is down or `wx_env` is nil). + """ + def with_wx_env(fun) when is_function(fun, 0) do + Desktop.Env.wx_use_env() + fun.() + end +end diff --git a/lib/desktop/platform/system.ex b/lib/desktop/platform/system.ex index 904f06d..c53876a 100644 --- a/lib/desktop/platform/system.ex +++ b/lib/desktop/platform/system.ex @@ -1,6 +1,8 @@ defmodule Desktop.Platform.System do @moduledoc false + alias Desktop.Platform.Helpers + @callback init_env() :: {wx :: term(), env :: term()} @callback subscribe_events() :: :ok @callback set_env(env :: term()) :: :ok @@ -14,17 +16,24 @@ defmodule Desktop.Platform.System do ) :: term() @callback wx_available?() :: boolean() + @callback open_external_url(String.t()) :: :ok + @callback activate_event_active?(event :: term()) :: boolean() def init_env, do: impl().init_env() def subscribe_events, do: impl().subscribe_events() def set_env(env), do: impl().set_env(env) def get_env, do: impl().get_env() - def locale, do: impl().locale() + def locale, do: Helpers.with_wx_env(fn -> impl().locale() end) - def connect_menu(object, command, callback, id \\ nil), - do: impl().connect_menu(object, command, callback, id) + def connect_menu(object, command, callback, id \\ nil) do + Helpers.with_wx_env(fn -> impl().connect_menu(object, command, callback, id) end) + end def wx_available?, do: impl().wx_available?() + def open_external_url(url), do: Helpers.with_wx_env(fn -> impl().open_external_url(url) end) + + def activate_event_active?(event), + do: Helpers.with_wx_env(fn -> impl().activate_event_active?(event) end) defp impl, do: Desktop.Platform.backend() end diff --git a/lib/desktop/platform/window.ex b/lib/desktop/platform/window.ex index 68abfe7..bc9bc1d 100644 --- a/lib/desktop/platform/window.ex +++ b/lib/desktop/platform/window.ex @@ -1,6 +1,8 @@ defmodule Desktop.Platform.Window do @moduledoc false + alias Desktop.Platform.Helpers + @type handle :: term() @type content_handle :: term() | nil @@ -22,26 +24,27 @@ defmodule Desktop.Platform.Window do @callback on_crash_destroy(handle()) :: :ok @callback close_event_veto(term()) :: :ok - def open(opts), do: impl().open(opts) - def destroy(frame), do: impl().destroy_frame(frame) - def connect(frame, event, fun), do: impl().connect(frame, event, fun) - def show(frame, opts \\ []), do: impl().show(frame, opts) - def hide(frame), do: impl().hide(frame) - def set_title(frame, title), do: impl().set_title(frame, title) - def set_min_size(frame, size), do: impl().set_min_size(frame, size) - def set_icon(frame, icon), do: impl().set_icon(frame, icon) - def set_menubar(frame, menubar), do: impl().set_menubar(frame, menubar) - def iconize(frame, iconize), do: impl().iconize(frame, iconize) - def is_shown?(frame), do: impl().is_shown?(frame) - def is_active?(frame), do: impl().is_active?(frame) - def raise_window(frame), do: impl().raise_window(frame) + def open(opts), do: Helpers.with_wx_env(fn -> impl().open(opts) end) + def destroy(frame), do: Helpers.with_wx_env(fn -> impl().destroy_frame(frame) end) + def connect(frame, event, fun), do: Helpers.with_wx_env(fn -> impl().connect(frame, event, fun) end) + def show(frame, opts \\ []), do: Helpers.with_wx_env(fn -> impl().show(frame, opts) end) + def hide(frame), do: Helpers.with_wx_env(fn -> impl().hide(frame) end) + def set_title(frame, title), do: Helpers.with_wx_env(fn -> impl().set_title(frame, title) end) + def set_min_size(frame, size), do: Helpers.with_wx_env(fn -> impl().set_min_size(frame, size) end) + def set_icon(frame, icon), do: Helpers.with_wx_env(fn -> impl().set_icon(frame, icon) end) + def set_menubar(frame, menubar), do: Helpers.with_wx_env(fn -> impl().set_menubar(frame, menubar) end) + def iconize(frame, iconize), do: Helpers.with_wx_env(fn -> impl().iconize(frame, iconize) end) + def is_shown?(frame), do: Helpers.with_wx_env(fn -> impl().is_shown?(frame) end) + def is_active?(frame), do: Helpers.with_wx_env(fn -> impl().is_active?(frame) end) + def raise_window(frame), do: Helpers.with_wx_env(fn -> impl().raise_window(frame) end) - def update_apple_menu(title, frame, menubar), - do: impl().update_apple_menu(title, frame, menubar) + def update_apple_menu(title, frame, menubar) do + Helpers.with_wx_env(fn -> impl().update_apple_menu(title, frame, menubar) end) + end - def new_menubar, do: impl().new_menubar() - def on_crash_destroy(frame), do: impl().on_crash_destroy(frame) - def close_event_veto(inev), do: impl().close_event_veto(inev) + def new_menubar, do: Helpers.with_wx_env(fn -> impl().new_menubar() end) + def on_crash_destroy(frame), do: Helpers.with_wx_env(fn -> impl().on_crash_destroy(frame) end) + def close_event_veto(inev), do: Helpers.with_wx_env(fn -> impl().close_event_veto(inev) end) defp impl, do: Desktop.Platform.backend() end diff --git a/lib/desktop/window.ex b/lib/desktop/window.ex index 3cc39d6..e8f6ff8 100644 --- a/lib/desktop/window.ex +++ b/lib/desktop/window.ex @@ -139,7 +139,6 @@ defmodule Desktop.Window do url = options[:url] on_close = options[:on_close] || :quit - Desktop.Env.wx_use_env() GenServer.cast(Desktop.Env, {:register_window, self()}) wx = Desktop.Env.wx() @@ -160,7 +159,6 @@ defmodule Desktop.Window do OnCrash.call(fn reason -> if reason != :normal do Logger.error("Window crashed: #{inspect(reason)}") - Desktop.Env.wx_use_env() Platform.Window.on_crash_destroy(frame) end end) @@ -517,7 +515,7 @@ defmodule Desktop.Window do @doc false def handle_event(wx(event: {:wxWebView, :webview_newwindow, _, _, _target, url}), ui) do - OS.launch_default_browser(url) + spawn(fn -> Platform.System.open_external_url(url) end) {:noreply, ui} end @@ -588,13 +586,7 @@ defmodule Desktop.Window do :ok end - defp activate_event_active?(event) do - if function_exported?(:wxActivateEvent, :getActive, 1) do - :wxActivateEvent.getActive(event) - else - true - end - end + defp activate_event_active?(event), do: Platform.System.activate_event_active?(event) @doc false def handle_cast(:frame_activated, ui = %Window{id: id}) do diff --git a/mix.exs b/mix.exs index fad228a..5a80cbf 100644 --- a/mix.exs +++ b/mix.exs @@ -83,7 +83,10 @@ defmodule Desktop.MixProject do [ "test.fast": ["test --exclude wx"], "test.wx": ["test --only wx"], - "test.guard": ["run test/support/guard_boolean_ops.exs"], + "test.guard": [ + "run test/support/guard_boolean_ops.exs", + "run test/support/guard_platform_abstraction.exs" + ], lint: [ "compile --warnings-as-errors", "format --check-formatted", diff --git a/test/desktop/backend/browser_test.exs b/test/desktop/backend/browser_test.exs index 168bafe..ffcdad4 100644 --- a/test/desktop/backend/browser_test.exs +++ b/test/desktop/backend/browser_test.exs @@ -21,7 +21,7 @@ defmodule Desktop.Backend.BrowserTest do end test "T-BRW: Content callbacks" do - assert is_pid(Browser.content_show(nil, nil, "http://example.com", false)) + assert :ok = Browser.content_show(nil, nil, "http://example.com", false) assert Browser.current_url(nil, "http://last") == "http://last" assert Browser.rebuild(nil, nil) == nil end diff --git a/test/desktop/backend/json_test.exs b/test/desktop/backend/json_test.exs index ff8c595..9545256 100644 --- a/test/desktop/backend/json_test.exs +++ b/test/desktop/backend/json_test.exs @@ -23,6 +23,10 @@ defmodule Desktop.Backend.JsonTest do assert caps.menu == :native end + test "T-JSN: locale via bridge RPC only" do + assert Json.locale() == "en" + end + test "T-JSN: new frame handle shape" do wx = Transport.ensure_started() diff --git a/test/desktop/backend/wx_test.exs b/test/desktop/backend/wx_test.exs index cc4c543..6bed0db 100644 --- a/test/desktop/backend/wx_test.exs +++ b/test/desktop/backend/wx_test.exs @@ -35,7 +35,7 @@ defmodule Desktop.Backend.WxTest do Wx.destroy_frame(frame) end - test "T-WX: locale after ensure_wx_env" do + test "T-WX: locale via Platform.System" do result = Wx.locale() assert result == nil or is_binary(result) end diff --git a/test/desktop/platform_test.exs b/test/desktop/platform_test.exs index 7460087..f5ddea9 100644 --- a/test/desktop/platform_test.exs +++ b/test/desktop/platform_test.exs @@ -57,4 +57,21 @@ defmodule Desktop.PlatformTest do ] end) end + + test "T-PLAT-07: mobile_target env selects Json without Mix" do + previous = Application.get_env(:desktop, :backend, :auto) + mobile? = Application.get_env(:desktop, :mobile_target) + + Application.put_env(:desktop, :backend, :auto) + Application.put_env(:desktop, :mobile_target, true) + System.put_env("ELIXIR_DESKTOP_OS", "android") + + try do + assert Platform.backend() == Json + after + Application.put_env(:desktop, :backend, previous) + Application.put_env(:desktop, :mobile_target, mobile?) + System.delete_env("ELIXIR_DESKTOP_OS") + end + end end diff --git a/test/desktop/regression/beam_wx_calls_test.exs b/test/desktop/regression/beam_wx_calls_test.exs new file mode 100644 index 0000000..8a5d1ad --- /dev/null +++ b/test/desktop/regression/beam_wx_calls_test.exs @@ -0,0 +1,48 @@ +defmodule Desktop.Regression.BeamWxCallsTest do + @moduledoc """ + Guards against calling OTP :wx modules on the BEAM when using Json/mobile backends. + """ + use ExUnit.Case, async: false + + alias Desktop.Backend.Json + alias Desktop.Bridge.Transport + alias Desktop.Platform.System, as: PlatformSystem + + setup do + previous_backend = Application.get_env(:desktop, :backend, :auto) + Application.put_env(:desktop, :backend, :json) + System.put_env("BRIDGE_PORT", "0") + System.put_env("ELIXIR_DESKTOP_OS", "android") + Transport.ensure_started() + + on_exit(fn -> + Application.put_env(:desktop, :backend, previous_backend) + System.delete_env("ELIXIR_DESKTOP_OS") + end) + + :ok + end + + test "Json backend reports wx unavailable on BEAM" do + refute Json.wx_available?() + end + + test "locale does not invoke :wxLocale on BEAM" do + assert Json.locale() == "en" + end + + test "open_external_url on Json uses bridge RPC" do + assert :ok = Json.open_external_url("https://example.com") + assert :ok = PlatformSystem.open_external_url("https://example.com") + end + + test "activate_event_active? on Json backend returns true without wx" do + assert Json.activate_event_active?(%{}) + assert PlatformSystem.activate_event_active?(%{}) + end + + test "launch_default_browser delegates to Platform on Android" do + assert is_pid(Desktop.OS.launch_default_browser("https://example.com")) + Process.sleep(50) + end +end diff --git a/test/support/guard_platform_abstraction.exs b/test/support/guard_platform_abstraction.exs new file mode 100644 index 0000000..800facc --- /dev/null +++ b/test/support/guard_platform_abstraction.exs @@ -0,0 +1,67 @@ +# Run via: mix test.guard +# Fails when platform abstraction rules are violated under lib/ + +lib_root = Path.expand("../../lib", __DIR__) + +wx_use_env_allowed = + ~w( + desktop/env.ex + desktop/platform/helpers.ex + desktop/platform/system.ex + desktop/platform/window.ex + ) + +forbidden_patterns = [ + {~r/if\s+Desktop\.Platform\.System\.wx_available\?\s*\(\)/, + "do not gate wx_use_env on wx_available? — call Platform APIs or wx_use_env (safe no-op)"}, + {~r/ensure_wx_env/, "use Desktop.Platform.Helpers.with_wx_env/1 via Platform facades, not ensure_wx_env"}, + {~r/:wxLocale\.getSystemLanguage\s*\(/, + "Json/mobile: pass :getSystemLanguage as a bridge atom to Protocol, never :wxLocale.getSystemLanguage/0"} +] + +violations = + Enum.flat_map(Path.wildcard(Path.join(lib_root, "**/*.ex")), fn path -> + rel = Path.relative_to(path, lib_root) + content = File.read!(path) + + pattern_hits = + for {pattern, message} <- forbidden_patterns, + line <- String.split(content, "\n"), + Regex.match?(pattern, line) do + {rel, message, String.trim(line)} + end + + extra = + cond do + String.contains?(content, "Desktop.Env.wx_use_env") and rel not in wx_use_env_allowed -> + [{rel, "call Desktop.Platform.* instead of Desktop.Env.wx_use_env/0 in app code", ""}] + + rel != "desktop/os.ex" and Regex.match?(~r/Desktop\.OS\.launch_default_browser/, content) -> + [ + {rel, + "use Desktop.Platform.System.open_external_url/1 (OS.launch_default_browser only in os.ex)", + ""} + ] + + true -> + [] + end + + pattern_hits ++ extra + end) + +if violations != [] do + IO.puts(:stderr, "guard_platform_abstraction: FAILED\n") + + for {file, message, line} <- violations do + IO.puts(:stderr, " #{file}: #{message}") + + if line != "" do + IO.puts(:stderr, " #{line}") + end + end + + System.halt(1) +else + IO.puts("guard_platform_abstraction: OK") +end From fae12f632088a8b0504809793e3bff33616a51de Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 22 May 2026 18:53:32 +0200 Subject: [PATCH 4/6] Fix linter issues --- desktop_wx_stub.exs | 99 +------------------ lib/desktop/backend/browser.ex | 4 +- lib/desktop/backend/json.ex | 6 +- lib/desktop/backend/wx.ex | 4 +- lib/desktop/bridge/transport.ex | 2 +- lib/desktop/fallback.ex | 4 +- lib/desktop/platform/content.ex | 21 ++-- lib/desktop/platform/media.ex | 17 ++-- lib/desktop/platform/notification.ex | 11 ++- lib/desktop/platform/window.ex | 23 +++-- lib/desktop/window.ex | 6 +- lib/desktop/wx/compile.ex | 7 +- lib/desktop/wx/records.ex | 20 ++-- lib/desktop/wx/stub.ex | 100 ++++++++++++++++++++ mix.exs | 10 +- test/desktop/backend/browser_test.exs | 4 +- test/desktop/regression/wx_connect_test.exs | 1 + test/support/guard_boolean_ops.exs | 6 +- test/support/guard_platform_abstraction.exs | 3 +- 19 files changed, 189 insertions(+), 159 deletions(-) create mode 100644 lib/desktop/wx/stub.ex diff --git a/desktop_wx_stub.exs b/desktop_wx_stub.exs index 4b0eeeb..e2f97a0 100644 --- a/desktop_wx_stub.exs +++ b/desktop_wx_stub.exs @@ -1,98 +1,3 @@ -# Generates src/desktop_wx.erl before any compiler runs. -# - MIX_TARGET host (or unset) + wx.hrl present → include_lib + ?wx macros -# - android / ios / no wx headers → header-free integer fallbacks - -defmodule Desktop.WxStub do - @constant_names ~w( - ID_ANY ID_EXIT DEFAULT_FRAME_STYLE NO_BORDER EXPAND HORIZONTAL VERTICAL - ITEM_SEPARATOR ITEM_NORMAL ITEM_CHECK ITEM_RADIO - ICON_WARNING ICON_ERROR ICON_QUESTION ICON_INFORMATION - MAJOR_VERSION MINOR_VERSION RELEASE_NUMBER IMAGE_QUALITY_HIGH - ) - - @fallback %{ - wxID_ANY: -1, - wxID_EXIT: 5006, - wxDEFAULT_FRAME_STYLE: 541_072_960, - wxNO_BORDER: 2_097_152, - wxEXPAND: 8192, - wxHORIZONTAL: 4, - wxVERTICAL: 8, - wxITEM_SEPARATOR: -1, - wxITEM_NORMAL: 0, - wxITEM_CHECK: 1, - wxITEM_RADIO: 2, - wxICON_WARNING: 256, - wxICON_ERROR: 512, - wxICON_QUESTION: 1024, - wxICON_INFORMATION: 2048, - wxMAJOR_VERSION: 0, - wxMINOR_VERSION: 0, - wxRELEASE_NUMBER: 0, - wxIMAGE_QUALITY_HIGH: 192 - } - - def write!(root \\ Path.dirname(__ENV__.file)) do - target = System.get_env("MIX_TARGET", "host") - path = Path.join([root, "src", "desktop_wx.erl"]) - File.mkdir_p!(Path.dirname(path)) - - body = - if host_target?() and wx_headers_exist?() do - hrl_body(target) - else - stub_body(target) - end - - File.write!(path, body) - end - - defp host_target? do - System.get_env("MIX_TARGET") in [nil, "host"] - end - - defp wx_headers_exist? do - case :code.lib_dir(:wx) do - path when is_list(path) -> - File.exists?(Path.join([List.to_string(path), "include", "wx.hrl"])) - - _ -> - false - end - end - - defp hrl_body(target) do - gets = - @constant_names - |> Enum.sort() - |> Enum.map(fn name -> "get(wx#{name}) -> ?wx#{name}" end) - |> Enum.join(";\n ") - - """ - -module(desktop_wx). - -include_lib("wx/include/wx.hrl"). - -export([get/1]). - - %% Generated by desktop_wx_stub.exs — do not edit. - %% MIX_TARGET=#{target} — values from wx/include/wx.hrl - - #{gets}. - """ - end - - defp stub_body(target) do - gets = for {name, value} <- @fallback, do: "get(#{name}) -> #{value}" - - """ - -module(desktop_wx). - -export([get/1]). - - %% Generated by desktop_wx_stub.exs — do not edit. - %% MIX_TARGET=#{target} — header-free fallbacks (no wx.hrl / :wx) - - #{Enum.join(gets, ";\n ")}. - """ - end -end - +# Standalone entry for regenerating src/desktop_wx.erl (also invoked from mix.exs). +[{Desktop.WxStub, _}] = Code.compile_file(Path.join(__DIR__, "lib/desktop/wx/stub.ex")) Desktop.WxStub.write!() diff --git a/lib/desktop/backend/browser.ex b/lib/desktop/backend/browser.ex index 35e95f1..97fa2eb 100644 --- a/lib/desktop/backend/browser.ex +++ b/lib/desktop/backend/browser.ex @@ -81,10 +81,10 @@ defmodule Desktop.Backend.Browser do def iconize(_frame, _iconize), do: :ok @impl true - def is_shown?(_frame), do: false + def shown?(_frame), do: false @impl true - def is_active?(_frame), do: false + def active?(_frame), do: false @impl true def raise_window(_frame), do: :ok diff --git a/lib/desktop/backend/json.ex b/lib/desktop/backend/json.ex index 11de319..88918f7 100644 --- a/lib/desktop/backend/json.ex +++ b/lib/desktop/backend/json.ex @@ -122,7 +122,7 @@ defmodule Desktop.Backend.Json do @impl true def connect(frame, event, fun) do - Protocol.connect(:wxFrame, frame, event, [callback: fun, userData: self()]) + Protocol.connect(:wxFrame, frame, event, callback: fun, userData: self()) :ok end @@ -166,10 +166,10 @@ defmodule Desktop.Backend.Json do end @impl true - def is_shown?(frame), do: Protocol.call(:wxWindow, :isShown, [frame]) || false + def shown?(frame), do: Protocol.call(:wxWindow, :isShown, [frame]) || false @impl true - def is_active?(frame), do: Protocol.call(:wxTopLevelWindow, :isActive, [frame]) || true + def active?(frame), do: Protocol.call(:wxTopLevelWindow, :isActive, [frame]) || true @impl true def raise_window(nil), do: :ok diff --git a/lib/desktop/backend/wx.ex b/lib/desktop/backend/wx.ex index 1db5468..b6a103a 100644 --- a/lib/desktop/backend/wx.ex +++ b/lib/desktop/backend/wx.ex @@ -171,10 +171,10 @@ defmodule Desktop.Backend.Wx do end @impl true - def is_shown?(frame), do: Null.wx_call(:wxWindow, :isShown, [frame]) || false + def shown?(frame), do: Null.wx_call(:wxWindow, :isShown, [frame]) || false @impl true - def is_active?(frame), do: Null.wx_call(:wxTopLevelWindow, :isActive, [frame]) || false + def active?(frame), do: Null.wx_call(:wxTopLevelWindow, :isActive, [frame]) || false @impl true def raise_window(nil), do: :ok diff --git a/lib/desktop/bridge/transport.ex b/lib/desktop/bridge/transport.ex index df2c94b..3bf1c99 100644 --- a/lib/desktop/bridge/transport.ex +++ b/lib/desktop/bridge/transport.ex @@ -107,7 +107,7 @@ defmodule Desktop.Bridge.Transport do end end - def handle_cast({:last_url, uri}, %__MODULE__{} = state) do + def handle_cast({:last_url, uri}, state = %__MODULE__{}) do {:noreply, %__MODULE__{state | last_url: uri}} end diff --git a/lib/desktop/fallback.ex b/lib/desktop/fallback.ex index 0744fee..3a6177f 100644 --- a/lib/desktop/fallback.ex +++ b/lib/desktop/fallback.ex @@ -29,11 +29,11 @@ defmodule Desktop.Fallback do Platform.Content.current_url(webview, last_url) end - def webview_load(%Desktop.Window{} = window, url) do + def webview_load(window = %Desktop.Window{}, url) do Platform.Content.load_url(window.webview, window.frame, url) end - def webview_show(%Desktop.Window{} = window, url, only_open) do + def webview_show(window = %Desktop.Window{}, url, only_open) do Platform.Content.show(window.webview, window.frame, url, only_open) end diff --git a/lib/desktop/platform/content.ex b/lib/desktop/platform/content.ex index 20da79e..28cd19a 100644 --- a/lib/desktop/platform/content.ex +++ b/lib/desktop/platform/content.ex @@ -1,6 +1,8 @@ defmodule Desktop.Platform.Content do @moduledoc false + alias Desktop.Platform.Helpers + @callback attach(frame :: term()) :: term() | nil @callback load_url(content :: term() | nil, frame :: term() | nil, url :: String.t() | nil) :: :ok @@ -14,15 +16,22 @@ defmodule Desktop.Platform.Content do @callback rebuild(frame :: term() | nil, last_url :: String.t() | nil) :: term() | nil @callback put_webview_backend(name :: String.t()) :: :ok - def attach(frame), do: impl().attach(frame) - def load_url(content, frame, url), do: impl().load_url(content, frame, url) - def current_url(content, last_url), do: impl().current_url(content, last_url) + def attach(frame), do: Helpers.with_wx_env(fn -> impl().attach(frame) end) + + def load_url(content, frame, url), + do: Helpers.with_wx_env(fn -> impl().load_url(content, frame, url) end) + + def current_url(content, last_url), + do: Helpers.with_wx_env(fn -> impl().current_url(content, last_url) end) def show(content, frame, url, only_open), - do: impl().content_show(content, frame, url, only_open) + do: Helpers.with_wx_env(fn -> impl().content_show(content, frame, url, only_open) end) + + def rebuild(frame, last_url), + do: Helpers.with_wx_env(fn -> impl().rebuild(frame, last_url) end) - def rebuild(frame, last_url), do: impl().rebuild(frame, last_url) - def put_webview_backend(name), do: impl().put_webview_backend(name) + def put_webview_backend(name), + do: Helpers.with_wx_env(fn -> impl().put_webview_backend(name) end) defp impl, do: Desktop.Platform.backend() end diff --git a/lib/desktop/platform/media.ex b/lib/desktop/platform/media.ex index c69b039..c56b03f 100644 --- a/lib/desktop/platform/media.ex +++ b/lib/desktop/platform/media.ex @@ -1,6 +1,8 @@ defmodule Desktop.Platform.Media do @moduledoc false + alias Desktop.Platform.Helpers + @callback load_image(app :: atom(), path :: String.t()) :: {:ok, term()} | {:error, term()} @callback new_icon(app :: atom(), path :: String.t()) :: {:ok, term()} | {:error, term()} @callback new_icon_from(term()) :: {:ok, term()} | {:error, term()} @@ -8,12 +10,15 @@ defmodule Desktop.Platform.Media do @callback media_destroy(term()) :: :ok @callback object_type(term()) :: atom() - def load_image(app, path), do: impl().load_image(app, path) - def new_icon(app, path), do: impl().new_icon(app, path) - def new_icon_from(image), do: impl().new_icon_from(image) - def default_icon, do: impl().default_icon() - def destroy(image), do: impl().media_destroy(image) - def object_type(image), do: impl().object_type(image) + def load_image(app, path), do: Helpers.with_wx_env(fn -> impl().load_image(app, path) end) + def new_icon(app, path), do: Helpers.with_wx_env(fn -> impl().new_icon(app, path) end) + + def new_icon_from(image), + do: Helpers.with_wx_env(fn -> impl().new_icon_from(image) end) + + def default_icon, do: Helpers.with_wx_env(fn -> impl().default_icon() end) + def destroy(image), do: Helpers.with_wx_env(fn -> impl().media_destroy(image) end) + def object_type(image), do: Helpers.with_wx_env(fn -> impl().object_type(image) end) defp impl, do: Desktop.Platform.backend() end diff --git a/lib/desktop/platform/notification.ex b/lib/desktop/platform/notification.ex index c6c553d..6a4e5ef 100644 --- a/lib/desktop/platform/notification.ex +++ b/lib/desktop/platform/notification.ex @@ -1,6 +1,8 @@ defmodule Desktop.Platform.Notification do @moduledoc false + alias Desktop.Platform.Helpers + @callback new(title :: String.t(), type :: atom()) :: term() | nil @callback notification_show( notification :: term() | nil, @@ -10,12 +12,15 @@ defmodule Desktop.Platform.Notification do ) :: :ok @callback close(notification :: term() | nil) :: :ok - def new(title, type), do: impl().new(title, type) + def new(title, type), do: Helpers.with_wx_env(fn -> impl().new(title, type) end) def show(notification, message, timeout, title \\ nil), - do: impl().notification_show(notification, message, timeout, title) + do: + Helpers.with_wx_env(fn -> + impl().notification_show(notification, message, timeout, title) + end) - def close(notification), do: impl().close(notification) + def close(notification), do: Helpers.with_wx_env(fn -> impl().close(notification) end) defp impl, do: Desktop.Platform.backend() end diff --git a/lib/desktop/platform/window.ex b/lib/desktop/platform/window.ex index bc9bc1d..2640541 100644 --- a/lib/desktop/platform/window.ex +++ b/lib/desktop/platform/window.ex @@ -16,8 +16,8 @@ defmodule Desktop.Platform.Window do @callback set_icon(handle(), term()) :: :ok @callback set_menubar(handle(), term()) :: :ok @callback iconize(handle(), boolean()) :: :ok - @callback is_shown?(handle()) :: boolean() - @callback is_active?(handle()) :: boolean() + @callback shown?(handle()) :: boolean() + @callback active?(handle()) :: boolean() @callback raise_window(handle()) :: :ok @callback update_apple_menu(String.t(), handle(), term()) :: :ok @callback new_menubar() :: term() @@ -26,16 +26,25 @@ defmodule Desktop.Platform.Window do def open(opts), do: Helpers.with_wx_env(fn -> impl().open(opts) end) def destroy(frame), do: Helpers.with_wx_env(fn -> impl().destroy_frame(frame) end) - def connect(frame, event, fun), do: Helpers.with_wx_env(fn -> impl().connect(frame, event, fun) end) + + def connect(frame, event, fun), + do: Helpers.with_wx_env(fn -> impl().connect(frame, event, fun) end) + def show(frame, opts \\ []), do: Helpers.with_wx_env(fn -> impl().show(frame, opts) end) def hide(frame), do: Helpers.with_wx_env(fn -> impl().hide(frame) end) def set_title(frame, title), do: Helpers.with_wx_env(fn -> impl().set_title(frame, title) end) - def set_min_size(frame, size), do: Helpers.with_wx_env(fn -> impl().set_min_size(frame, size) end) + + def set_min_size(frame, size), + do: Helpers.with_wx_env(fn -> impl().set_min_size(frame, size) end) + def set_icon(frame, icon), do: Helpers.with_wx_env(fn -> impl().set_icon(frame, icon) end) - def set_menubar(frame, menubar), do: Helpers.with_wx_env(fn -> impl().set_menubar(frame, menubar) end) + + def set_menubar(frame, menubar), + do: Helpers.with_wx_env(fn -> impl().set_menubar(frame, menubar) end) + def iconize(frame, iconize), do: Helpers.with_wx_env(fn -> impl().iconize(frame, iconize) end) - def is_shown?(frame), do: Helpers.with_wx_env(fn -> impl().is_shown?(frame) end) - def is_active?(frame), do: Helpers.with_wx_env(fn -> impl().is_active?(frame) end) + def shown?(frame), do: Helpers.with_wx_env(fn -> impl().shown?(frame) end) + def active?(frame), do: Helpers.with_wx_env(fn -> impl().active?(frame) end) def raise_window(frame), do: Helpers.with_wx_env(fn -> impl().raise_window(frame) end) def update_apple_menu(title, frame, menubar) do diff --git a/lib/desktop/window.ex b/lib/desktop/window.ex index e8f6ff8..17654bb 100644 --- a/lib/desktop/window.ex +++ b/lib/desktop/window.ex @@ -603,7 +603,7 @@ defmodule Desktop.Window do if frame, do: Platform.Window.hide(frame) {:noreply, ui} else - if frame != nil && !Platform.Window.is_shown?(frame) do + if frame != nil && !Platform.Window.shown?(frame) do OS.shutdown() end @@ -684,7 +684,7 @@ defmodule Desktop.Window do def handle_call(:is_hidden?, _from, ui = %Window{frame: frame}) do ret = if frame do - not Platform.Window.is_shown?(frame) + not Platform.Window.shown?(frame) else false end @@ -694,7 +694,7 @@ defmodule Desktop.Window do @doc false def handle_call(:is_active?, _from, ui = %Window{frame: frame}) do - {:reply, frame == nil or Platform.Window.is_active?(frame), ui} + {:reply, frame == nil or Platform.Window.active?(frame), ui} end def handle_call(:url, _from, ui) do diff --git a/lib/desktop/wx/compile.ex b/lib/desktop/wx/compile.ex index a6fe391..c17a905 100644 --- a/lib/desktop/wx/compile.ex +++ b/lib/desktop/wx/compile.ex @@ -38,12 +38,7 @@ defmodule Desktop.Wx.Compile do @doc false def write_stub_file! do root = Path.expand("../../..", __DIR__) - stub = Path.join(root, "desktop_wx_stub.exs") - - if File.exists?(stub) do - Code.require_file(stub) - Desktop.WxStub.write!(root) - end + Desktop.WxStub.write!(root) end def wx_available? do diff --git a/lib/desktop/wx/records.ex b/lib/desktop/wx/records.ex index 3a106c2..25aaff9 100644 --- a/lib/desktop/wx/records.ex +++ b/lib/desktop/wx/records.ex @@ -5,18 +5,16 @@ defmodule Desktop.Wx.Records do @host_build System.get_env("MIX_TARGET") in [nil, "host"] - @wx_hrl ( - if @host_build do - case :code.lib_dir(:wx) do - path when is_list(path) -> - path = Path.join([List.to_string(path), "include", "wx.hrl"]) - if File.exists?(path), do: path + @wx_hrl (if @host_build do + case :code.lib_dir(:wx) do + path when is_list(path) -> + path = Path.join([List.to_string(path), "include", "wx.hrl"]) + if File.exists?(path), do: path - _ -> - nil - end - end - ) + _ -> + nil + end + end) if @wx_hrl do require Record diff --git a/lib/desktop/wx/stub.ex b/lib/desktop/wx/stub.ex new file mode 100644 index 0000000..1df3923 --- /dev/null +++ b/lib/desktop/wx/stub.ex @@ -0,0 +1,100 @@ +defmodule Desktop.WxStub do + @moduledoc false + + # Generates src/desktop_wx.erl before any compiler runs. + # - MIX_TARGET host (or unset) + wx.hrl present → include_lib + ?wx macros + # - android / ios / no wx headers → header-free integer fallbacks + + @constant_names ~w( + ID_ANY ID_EXIT DEFAULT_FRAME_STYLE NO_BORDER EXPAND HORIZONTAL VERTICAL + ITEM_SEPARATOR ITEM_NORMAL ITEM_CHECK ITEM_RADIO + ICON_WARNING ICON_ERROR ICON_QUESTION ICON_INFORMATION + MAJOR_VERSION MINOR_VERSION RELEASE_NUMBER IMAGE_QUALITY_HIGH + ) + + @fallback %{ + wxID_ANY: -1, + wxID_EXIT: 5006, + wxDEFAULT_FRAME_STYLE: 541_072_960, + wxNO_BORDER: 2_097_152, + wxEXPAND: 8192, + wxHORIZONTAL: 4, + wxVERTICAL: 8, + wxITEM_SEPARATOR: -1, + wxITEM_NORMAL: 0, + wxITEM_CHECK: 1, + wxITEM_RADIO: 2, + wxICON_WARNING: 256, + wxICON_ERROR: 512, + wxICON_QUESTION: 1024, + wxICON_INFORMATION: 2048, + wxMAJOR_VERSION: 0, + wxMINOR_VERSION: 0, + wxRELEASE_NUMBER: 0, + wxIMAGE_QUALITY_HIGH: 192 + } + + @spec write!(Path.t()) :: :ok + def write!(root \\ Path.expand("../../..", __DIR__)) do + target = System.get_env("MIX_TARGET", "host") + path = Path.join([root, "src", "desktop_wx.erl"]) + File.mkdir_p!(Path.dirname(path)) + + body = + if host_target?() and wx_headers_exist?() do + hrl_body(target) + else + stub_body(target) + end + + File.write!(path, body) + :ok + end + + defp host_target? do + System.get_env("MIX_TARGET") in [nil, "host"] + end + + defp wx_headers_exist? do + case :code.lib_dir(:wx) do + path when is_list(path) -> + File.exists?(Path.join([List.to_string(path), "include", "wx.hrl"])) + + _ -> + false + end + end + + defp hrl_body(target) do + gets = + @constant_names + |> Enum.sort() + |> Enum.map(fn name -> "get(wx#{name}) -> ?wx#{name}" end) + |> Enum.join(";\n ") + + """ + -module(desktop_wx). + -include_lib("wx/include/wx.hrl"). + -export([get/1]). + + %% Generated by desktop_wx_stub.exs — do not edit. + %% MIX_TARGET=#{target} — values from wx/include/wx.hrl + + #{gets}. + """ + end + + defp stub_body(target) do + gets = for {name, value} <- @fallback, do: "get(#{name}) -> #{value}" + + """ + -module(desktop_wx). + -export([get/1]). + + %% Generated by desktop_wx_stub.exs — do not edit. + %% MIX_TARGET=#{target} — header-free fallbacks (no wx.hrl / :wx) + + #{Enum.join(gets, ";\n ")}. + """ + end +end diff --git a/mix.exs b/mix.exs index 5a80cbf..9ada8a9 100644 --- a/mix.exs +++ b/mix.exs @@ -1,5 +1,3 @@ -Code.require_file("desktop_wx_stub.exs", __DIR__) - defmodule Desktop.MixProject do use Mix.Project @@ -20,7 +18,7 @@ defmodule Desktop.MixProject do end def project do - Desktop.WxStub.write!() + ensure_desktop_wx_erl!() [ app: :desktop, @@ -44,6 +42,12 @@ defmodule Desktop.MixProject do ] end + defp ensure_desktop_wx_erl! do + script = Path.join(__DIR__, "desktop_wx_stub.exs") + {_, 0} = System.cmd("elixir", [script], env: System.get_env()) + :ok + end + # Specifies which paths to compile per environment. defp elixirc_paths(:test), do: ["lib", "test/support"] defp elixirc_paths(_), do: ["lib"] diff --git a/test/desktop/backend/browser_test.exs b/test/desktop/backend/browser_test.exs index ffcdad4..49c09f6 100644 --- a/test/desktop/backend/browser_test.exs +++ b/test/desktop/backend/browser_test.exs @@ -16,8 +16,8 @@ defmodule Desktop.Backend.BrowserTest do assert :ok = Browser.show(nil, []) assert :ok = Browser.set_title(nil, "x") assert :ok = Browser.connect(nil, :close_window, fn -> :ok end) - assert Browser.is_shown?(nil) == false - assert Browser.is_active?(nil) == false + assert Browser.shown?(nil) == false + assert Browser.active?(nil) == false end test "T-BRW: Content callbacks" do diff --git a/test/desktop/regression/wx_connect_test.exs b/test/desktop/regression/wx_connect_test.exs index 5d5c6e6..fe637a5 100644 --- a/test/desktop/regression/wx_connect_test.exs +++ b/test/desktop/regression/wx_connect_test.exs @@ -37,6 +37,7 @@ defmodule Desktop.Regression.WxConnectTest do try do wx = Desktop.Env.wx() + {:ok, frame, _webview} = Desktop.Platform.Window.open( wx: wx, diff --git a/test/support/guard_boolean_ops.exs b/test/support/guard_boolean_ops.exs index 3e92801..8ba0325 100644 --- a/test/support/guard_boolean_ops.exs +++ b/test/support/guard_boolean_ops.exs @@ -5,10 +5,8 @@ window_file = Path.expand("../../lib/desktop/window.ex", __DIR__) content = File.read!(window_file) forbidden = [ - {~r/if\s+frame\s+and\s+/, - "use `frame != nil && ...` — wx refs are not boolean (R4)"}, - {~r/if\s+menubar\s+and\s+/, - "use `menubar && frame` — module atoms are not boolean (R3)"}, + {~r/if\s+frame\s+and\s+/, "use `frame != nil && ...` — wx refs are not boolean (R4)"}, + {~r/if\s+menubar\s+and\s+/, "use `menubar && frame` — module atoms are not boolean (R3)"}, {~r/if\s+frame\s+and\s+not\s+/, "use `frame != nil && !...` (R4)"} ] diff --git a/test/support/guard_platform_abstraction.exs b/test/support/guard_platform_abstraction.exs index 800facc..5001b23 100644 --- a/test/support/guard_platform_abstraction.exs +++ b/test/support/guard_platform_abstraction.exs @@ -14,7 +14,8 @@ wx_use_env_allowed = forbidden_patterns = [ {~r/if\s+Desktop\.Platform\.System\.wx_available\?\s*\(\)/, "do not gate wx_use_env on wx_available? — call Platform APIs or wx_use_env (safe no-op)"}, - {~r/ensure_wx_env/, "use Desktop.Platform.Helpers.with_wx_env/1 via Platform facades, not ensure_wx_env"}, + {~r/ensure_wx_env/, + "use Desktop.Platform.Helpers.with_wx_env/1 via Platform facades, not ensure_wx_env"}, {~r/:wxLocale\.getSystemLanguage\s*\(/, "Json/mobile: pass :getSystemLanguage as a bridge atom to Protocol, never :wxLocale.getSystemLanguage/0"} ] From bf9752c0aa83a710513980c03554f454205a4b78 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 22 May 2026 18:55:24 +0200 Subject: [PATCH 5/6] Fix android issue --- lib/desktop/platform/menu.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/desktop/platform/menu.ex b/lib/desktop/platform/menu.ex index 59bdc3e..688c40a 100644 --- a/lib/desktop/platform/menu.ex +++ b/lib/desktop/platform/menu.ex @@ -45,6 +45,6 @@ defmodule Desktop.Platform.Menu do end defp menubar_new do - Desktop.Platform.backend().new_menubar() + Desktop.Platform.Window.new_menubar() end end From d0b4ddcb68a8faad61defba6199a4ff163530dba Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Mon, 29 Jun 2026 16:36:45 +0200 Subject: [PATCH 6/6] Fix bridge transport and platform server reliability issues. Connect to the mobile bridge asynchronously on startup, use non-blocking reconnect retries, wrap delegated GenServer return tuples in Platform.Server, and avoid spawn_link when raising macOS windows. Co-authored-by: Cursor --- lib/desktop/backend/wx.ex | 11 +++--- lib/desktop/bridge/transport.ex | 64 +++++++++++++++++++++------------ lib/desktop/platform/server.ex | 48 ++++++++++++++++--------- 3 files changed, 80 insertions(+), 43 deletions(-) diff --git a/lib/desktop/backend/wx.ex b/lib/desktop/backend/wx.ex index b6a103a..219fbb1 100644 --- a/lib/desktop/backend/wx.ex +++ b/lib/desktop/backend/wx.ex @@ -184,10 +184,13 @@ defmodule Desktop.Backend.Wx do MacOS -> name = System.get_env("EMU", "beam.smp") - fn -> - System.cmd("open", ["-a", name], stderr_to_stdout: true, parallelism: true) - end - |> spawn_link() + spawn(fn -> + try do + System.cmd("open", ["-a", name], stderr_to_stdout: true, parallelism: true) + rescue + _ -> :ok + end + end) _ -> # Calling setFocus on wxDirDialog segfaults on macOS — handled above. diff --git a/lib/desktop/bridge/transport.ex b/lib/desktop/bridge/transport.ex index 3bf1c99..4b0c907 100644 --- a/lib/desktop/bridge/transport.ex +++ b/lib/desktop/bridge/transport.ex @@ -81,22 +81,34 @@ defmodule Desktop.Bridge.Transport do def init(_opts) do port = String.to_integer(System.get_env("BRIDGE_PORT", "0")) - {socket, send} = - if port == 0 do - {Desktop.Bridge.Mock, &Desktop.Bridge.Mock.send/2} - else - {:ok, socket} = - :gen_tcp.connect(~c"127.0.0.1", port, packet: 4, active: true, mode: :binary) + if port == 0 do + {:ok, + %__MODULE__{ + port: port, + socket: Desktop.Bridge.Mock, + send: &Desktop.Bridge.Mock.send/2 + }} + else + {:ok, + %__MODULE__{ + port: port, + socket: nil, + send: &:gen_tcp.send/2 + }, {:continue, :connect}} + end + end - {socket, &:gen_tcp.send/2} - end + @impl true + def handle_continue(:connect, state = %__MODULE__{port: port}) do + case connect(port) do + {:ok, socket} -> + {:noreply, %__MODULE__{state | socket: socket}} - {:ok, - %__MODULE__{ - port: port, - socket: socket, - send: send - }} + {:error, reason} -> + Logger.error("Bridge connection failed on startup: #{inspect(reason)}, retrying...") + Process.send_after(self(), :reconnect, 1_000) + {:noreply, state} + end end @impl true @@ -199,25 +211,31 @@ defmodule Desktop.Bridge.Transport do def handle_info({:tcp_error, socket, reason}, state = %__MODULE__{socket: socket}) do Logger.error("Bridge connection failed: #{inspect(reason)}") - {:noreply, try_reconnect(state)} + Process.send_after(self(), :reconnect, 1_000) + {:noreply, %__MODULE__{state | socket: nil}} end def handle_info({:tcp_closed, socket}, state = %__MODULE__{socket: socket}) do Logger.error("Bridge connection closed") - {:noreply, try_reconnect(state)} + Process.send_after(self(), :reconnect, 1_000) + {:noreply, %__MODULE__{state | socket: nil}} end - def handle_info(_other, state), do: {:noreply, state} - - defp try_reconnect(state = %__MODULE__{port: port, last_url: last_url}) do - case :gen_tcp.connect(~c"127.0.0.1", port, [packet: 4, active: true, mode: :binary], 1_000) do + def handle_info(:reconnect, state = %__MODULE__{port: port, last_url: last_url}) do + case connect(port) do {:ok, socket} -> if last_url, do: spawn(fn -> bridge_call(:wxWebView, :loadURL, [nil, last_url]) end) - %__MODULE__{state | socket: socket} + {:noreply, %__MODULE__{state | socket: socket}} {:error, _} -> - Process.sleep(1_000) - try_reconnect(state) + Process.send_after(self(), :reconnect, 1_000) + {:noreply, state} end end + + def handle_info(_other, state), do: {:noreply, state} + + defp connect(port) do + :gen_tcp.connect(~c"127.0.0.1", port, [packet: 4, active: true, mode: :binary], 1_000) + end end diff --git a/lib/desktop/platform/server.ex b/lib/desktop/platform/server.ex index 430447e..74c061e 100644 --- a/lib/desktop/platform/server.ex +++ b/lib/desktop/platform/server.ex @@ -24,10 +24,8 @@ defmodule Desktop.Platform.Server do @impl true def handle_info(message, s = %__MODULE__{state: state, module: module}) do if function_exported?(module, :handle_event, 2) and is_tuple(message) do - case module.handle_event(message, state) do - {:noreply, new_state} -> {:noreply, %{s | state: new_state}} - other -> other - end + module.handle_event(message, state) + |> wrap_result(s) else dispatch_info(message, s) end @@ -35,10 +33,8 @@ defmodule Desktop.Platform.Server do defp dispatch_info(message, s = %__MODULE__{state: state, module: module}) do if function_exported?(module, :handle_info, 2) do - case module.handle_info(message, state) do - {:noreply, new_state} -> {:noreply, %{s | state: new_state}} - other -> other - end + module.handle_info(message, state) + |> wrap_result(s) else {:noreply, s} end @@ -46,18 +42,38 @@ defmodule Desktop.Platform.Server do @impl true def handle_cast(message, s = %__MODULE__{state: state, module: module}) do - case module.handle_cast(message, state) do - {:noreply, new_state} -> {:noreply, %{s | state: new_state}} - other -> other - end + module.handle_cast(message, state) + |> wrap_result(s) end @impl true def handle_call(message, from, s = %__MODULE__{state: state, module: module}) do - case module.handle_call(message, from, state) do - {:noreply, new_state} -> {:noreply, %{s | state: new_state}} - {:reply, reply, new_state} -> {:reply, reply, %{s | state: new_state}} - other -> other + module.handle_call(message, from, state) + |> wrap_result(s) + end + + defp wrap_result(result, s) do + case result do + {:reply, reply, new_state} -> + {:reply, reply, %{s | state: new_state}} + + {:reply, reply, new_state, extra} -> + {:reply, reply, %{s | state: new_state}, extra} + + {:noreply, new_state} -> + {:noreply, %{s | state: new_state}} + + {:noreply, new_state, extra} -> + {:noreply, %{s | state: new_state}, extra} + + {:stop, reason, new_state} -> + {:stop, reason, %{s | state: new_state}} + + {:stop, reason, reply, new_state} -> + {:stop, reason, reply, %{s | state: new_state}} + + other -> + other end end end