From 8a3d5fa04e32d9d3b9808753f404f778dfc87d4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 4 Sep 2026 13:12:00 +0000 Subject: [PATCH] Normalize Env subscriber path/URL payloads to UTF-8 binaries. Wx charlists (including a bare charlist as the event list) are converted at the Env boundary on notify_subscribers and OS handle_info ingest so subscribers always receive String.t() paths. Document the subscriber_event types and cover binary, charlist, buffer-replay, and lifecycle events in tests. --- CHANGELOG.md | 3 +- lib/desktop/env.ex | 78 ++++++++++++++++++++++++++++++------ test/desktop/env_test.exs | 76 +++++++++++++++++++++++++++-------- test/desktop/env_wx_test.exs | 15 +++++++ 4 files changed, 143 insertions(+), 29 deletions(-) create mode 100644 test/desktop/env_wx_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index f986e47..42d75c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - `Desktop.Platform.Content.reload/1` and `Desktop.Window.reload/1` — backend-safe webview reload (replaces `:wxWebView.reload/1`) - 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) +- `Desktop.Env` subscriber path/URL payloads (`:open_url`, `:open_file`, `:print_file`) are canonical UTF-8 binaries; wx charlists are normalized at the Env boundary before buffer or delivery ## Changes in 1.5 @@ -28,7 +29,7 @@ ## Changes in 1.3 -- Added `Env.subscribe/1` to listen to MacOS application events such as `{:open_url, url}` when a url associated with the application is clicked. +- Added `Env.subscribe/0` to listen to MacOS application events such as `{:open_url, [url]}` when a url associated with the application is clicked. - Using (experimental) dbus support to render the systray icon on linux - Added `Menu.escape_attribute/1` - Added `Window.url/1` diff --git a/lib/desktop/env.ex b/lib/desktop/env.ex index 21cfaaf..adc5bcb 100644 --- a/lib/desktop/env.ex +++ b/lib/desktop/env.ex @@ -10,15 +10,43 @@ defmodule Desktop.Env do Also it has a global connect() method to allow binding of :wx event callbacks using this long lived process as reference. - Subscribers (see `subscribe/0`) also receive: + ## Subscriber events + Processes that call `subscribe/0` receive `t:subscriber_event/0` messages. + + Path and URL payloads are **canonical UTF-8 binaries** (`String.t()`). Wx and + other sources may pass charlists; `Desktop.Env` normalizes them at the boundary + (both on `notify_subscribers/1` and on OS `handle_info` ingest) before buffering + or delivering to subscribers. + + * `{:print_file, [path]}` / `{:open_file, [path]}` / `{:open_url, [url]}` — + zero or more path/URL binaries + * `{:new_file, []}` * `{:desktop, :window_activated, window_id}` — a `Desktop.Window` with registered - `id` has become the active frame (user brought the app window to the foreground). + `id` atom has become the active frame """ alias Desktop.Env use GenServer require Logger + @typedoc "UTF-8 path or URL string delivered to Env subscribers." + @type path_or_url :: String.t() + + @typedoc "OS application open/print events (paths and URLs are binaries)." + @type os_app_event :: + {:print_file, [path_or_url()]} + | {:open_file, [path_or_url()]} + | {:open_url, [path_or_url()]} + | {:new_file, []} + + @typedoc "Desktop lifecycle events." + @type desktop_event :: {:desktop, :window_activated, atom()} + + @typedoc "Messages delivered to processes that called `subscribe/0`." + @type subscriber_event :: os_app_event() | desktop_event() + + @path_tags [:open_url, :open_file, :print_file] + defstruct [:wx_env, :wx, :map, :waiters, :windows, :sni, :events, :subs] @doc false @@ -123,6 +151,8 @@ defmodule Desktop.Env do end def handle_cast({:notify_subscribers, message}, state = %Env{subs: subs}) do + message = normalize_subscriber_event(message) + for sub <- subs do send(sub, message) end @@ -158,6 +188,8 @@ defmodule Desktop.Env do def handle_info({_mac_event, list} = e, state = %Env{subs: subs, events: events}) when is_list(list) do + e = normalize_subscriber_event(e) + if subs == [] do {:noreply, %Env{state | events: events ++ [e]}} else @@ -266,30 +298,52 @@ defmodule Desktop.Env do end @doc """ - Wrapper around wx.subscribe() + Subscribe the calling process to OS and desktop lifecycle events. - Will send to the calling process events in the form: + Delivers `t:subscriber_event/0` messages. Path and URL lists are always + UTF-8 binaries (never charlists), including when the OS or bridge originally + supplied charlists. - * `{:print_file, [filename]}` - * `{:open_file, [filename]}` - * `{:open_url, [filename]}` - * `{:new_file, []}` - * `{:desktop, :window_activated, window_id}` — from `Desktop.Window` when the - frame becomes active (see `Desktop.Env` module doc). + * `{:print_file, [path_or_url]}` + * `{:open_file, [path_or_url]}` + * `{:open_url, [path_or_url]}` + * `{:new_file, []}` + * `{:desktop, :window_activated, window_id}` — `window_id` is the window `id` atom """ def subscribe() do GenServer.call(__MODULE__, {:subscribe, self()}) end @doc """ - Delivers a message to all processes that called `subscribe/0`. + Delivers a `t:subscriber_event/0` to all processes that called `subscribe/0`. - Used internally by `Desktop.Window` for lifecycle events (e.g. frame activation). + Used by `Desktop.Window` for lifecycle events and by native bridges (e.g. + EventBridge) for OS open-URL/file notifications. Callers may pass charlist + paths/URLs; they are normalized to binaries before delivery. """ def notify_subscribers(message) when is_tuple(message) do GenServer.cast(__MODULE__, {:notify_subscribers, message}) end + defp normalize_subscriber_event({tag, paths}) + when tag in @path_tags and is_list(paths) do + {tag, normalize_paths(paths)} + end + + defp normalize_subscriber_event(other), do: other + + # Bare wx charlist as the entire second element: {:open_url, ~c"https://..."}. + defp normalize_paths([c | _] = charlist) when is_integer(c) do + [List.to_string(charlist)] + end + + defp normalize_paths(paths) when is_list(paths) do + Enum.map(paths, &normalize_path/1) + end + + defp normalize_path(bin) when is_binary(bin), do: bin + defp normalize_path(list) when is_list(list), do: List.to_string(list) + defp init_sni() do {task, ref} = spawn_monitor(fn -> exit(do_init_sni()) end) diff --git a/test/desktop/env_test.exs b/test/desktop/env_test.exs index b2587d2..a369c9c 100644 --- a/test/desktop/env_test.exs +++ b/test/desktop/env_test.exs @@ -1,33 +1,77 @@ defmodule Desktop.EnvTest do - use ExUnit.Case, async: false + use Desktop.Test.DesktopCase, async: false describe "browser backend" do test "T-ENV-03: wx_use_env with nil wx_env is no-op" do + with_backend(:browser, fn -> + restart_desktop!() + assert :ok = Desktop.Env.wx_use_env() + end) + end + end + + describe "subscriber payload normalization" do + setup do previous = Application.get_env(:desktop, :backend, :auto) Application.put_env(:desktop, :backend, :browser) + restart_desktop!() + :ok = Desktop.Env.subscribe() - try do - {:ok, _} = Application.ensure_all_started(:desktop) - assert :ok = Desktop.Env.wx_use_env() - after + on_exit(fn -> Application.put_env(:desktop, :backend, previous) - end + end) + + :ok + end + + test "notify_subscribers keeps binary open_url paths" do + Desktop.Env.notify_subscribers({:open_url, ["ddrive://x"]}) + assert_receive {:open_url, [url]}, 1000 + assert url == "ddrive://x" + assert is_binary(url) + end + + test "notify_subscribers converts charlist open_url paths to binaries" do + Desktop.Env.notify_subscribers({:open_url, [~c"ddrive://x"]}) + assert_receive {:open_url, ["ddrive://x"]}, 1000 + end + + test "notify_subscribers converts list of charlist open_file paths" do + Desktop.Env.notify_subscribers({:open_file, [~c"/tmp/a", ~c"/tmp/b"]}) + assert_receive {:open_file, ["/tmp/a", "/tmp/b"]}, 1000 + end + + test "wx ingest with bare charlist second element becomes binary list" do + send(Desktop.Env, {:open_url, ~c"ddrive://bare"}) + assert_receive {:open_url, ["ddrive://bare"]}, 1000 end - end - describe "wx backend (T-ENV-04)" do - use Desktop.Test.WxCase + test "buffered OS events before any subscriber are normalized binaries" do + Application.put_env(:desktop, :backend, :browser) + restart_desktop!() + + # No subscribers yet — event is buffered and normalized on ingest. + send(Desktop.Env, {:open_url, [~c"ddrive://buffered"]}) + Process.sleep(50) - test "Env stores wx and wx_env after init" do - wx = Desktop.Env.wx() - env = Desktop.Env.wx_env() + :ok = Desktop.Env.subscribe() + assert_receive {:open_url, ["ddrive://buffered"]}, 1000 + end - assert wx != nil - assert env != nil + test "notify_subscribers leaves window_activated unchanged" do + Desktop.Env.notify_subscribers({:desktop, :window_activated, :TestWindow}) + assert_receive {:desktop, :window_activated, :TestWindow}, 1000 end - test "wx_use_env succeeds" do - assert :ok = Desktop.Env.wx_use_env() + test "notify_subscribers leaves new_file unchanged" do + Desktop.Env.notify_subscribers({:new_file, []}) + assert_receive {:new_file, []}, 1000 end end + + defp restart_desktop! do + _ = Application.stop(:desktop) + {:ok, _} = Application.ensure_all_started(:desktop) + :ok + end end diff --git a/test/desktop/env_wx_test.exs b/test/desktop/env_wx_test.exs new file mode 100644 index 0000000..f5c3492 --- /dev/null +++ b/test/desktop/env_wx_test.exs @@ -0,0 +1,15 @@ +defmodule Desktop.EnvWxTest do + use Desktop.Test.WxCase + + test "T-ENV-04: 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 "T-ENV-04: wx_use_env succeeds" do + assert :ok = Desktop.Env.wx_use_env() + end +end