From eee0168e3df7ed47cc38424a2e9de8e43f81f236 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 13:21:22 +0000 Subject: [PATCH 1/3] fix(macos): associate wx notifications with main frame - Call wxNotificationMessage:setParent/2 on macOS so Notification Center ties the toast to the app window (wx default main window can be wrong for BEAM). - Log when show/2 returns false with pointers to issue #38 and settings. - Add ELIXIR_DESKTOP_OS=macos for testable macOS branches; add OS.macos?/0. - Guard notification_new/show when NO_WX yields nil notification refs. Refs https://github.com/elixir-desktop/desktop/issues/38 Co-authored-by: Dominic Letz --- lib/desktop/fallback.ex | 39 +++++++++++++++++++++++------ lib/desktop/os.ex | 12 +++++++++ lib/desktop/window.ex | 10 ++++++-- test/fallback_notification_test.exs | 25 ++++++++++++++++++ test/os_test.exs | 20 +++++++++++++++ 5 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 test/fallback_notification_test.exs create mode 100644 test/os_test.exs diff --git a/lib/desktop/fallback.ex b/lib/desktop/fallback.ex index 9c600d3..4fa3c33 100644 --- a/lib/desktop/fallback.ex +++ b/lib/desktop/fallback.ex @@ -2,6 +2,13 @@ defmodule Desktop.Fallback do require Logger alias Desktop.{Wx, OS} + @notification_show_failed """ + wxWidgets failed to show a desktop notification (wxNotificationMessage:show/2 returned false). \ + On macOS, ensure notifications are enabled for the app in System Settings, use a packaged .app \ + with matching bundle id / Info.plist for the Erlang VM, and see \ + https://github.com/elixir-desktop/desktop/issues/38 \ + """ + @moduledoc """ Fallback handles version differences in the :wx modules needed for showing the WebView and Desktop notifications and it uses the highest available @@ -170,7 +177,7 @@ defmodule Desktop.Fallback do webview end - def notification_new(title, type) do + def notification_new(title, type, parent \\ nil) do if module?(:wxNotificationMessage) do flag = case type do @@ -180,8 +187,9 @@ defmodule Desktop.Fallback do end notification = call(:wxNotificationMessage, :new, [title, [flags: flag]]) + notification_set_parent(notification, parent) - if notification_events_available?() do + if notification != nil and notification_events_available?() do for event <- [ :notification_message_click, :notification_message_dismissed, @@ -190,9 +198,11 @@ defmodule Desktop.Fallback 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" - ) + if notification != nil do + Logger.warning( + "Missing support for wxNotificationMessage Events - upgrade to wxWidgets 3.1 - messages won't be clickable" + ) + end end notification @@ -204,13 +214,20 @@ defmodule Desktop.Fallback do end def notification_show(notification, message, timeout, title \\ nil) do - if module?(:wxNotificationMessage) do + if module?(:wxNotificationMessage) and notification != nil 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]]) + + case call(:wxNotificationMessage, :show, [notification, [timeout: timeout]]) do + false -> + Logger.warning(@notification_show_failed) + + _ -> + :ok + end else Logger.notice("NOTIFICATION: #{title}: #{message}") end @@ -244,6 +261,14 @@ defmodule Desktop.Fallback do end end + defp notification_set_parent(notification, parent) do + if notification != nil and parent != nil and OS.macos?() and + module?(:wxNotificationMessage) and + Kernel.function_exported?(:wxNotificationMessage, :setParent, 2) do + call(:wxNotificationMessage, :setParent, [notification, parent]) + 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 diff --git a/lib/desktop/os.ex b/lib/desktop/os.ex index 81a282b..dcebaaf 100644 --- a/lib/desktop/os.ex +++ b/lib/desktop/os.ex @@ -11,6 +11,10 @@ defmodule Desktop.OS do - Windows - Linux + `ELIXIR_DESKTOP_OS` can be set to `android`, `ios`, or `macos` to force the + corresponding type (useful in tests). On real devices, omit it and the OS is + detected from `:os.type()`. + """ @doc """ @@ -45,6 +49,11 @@ defmodule Desktop.OS do "ios" -> IOS + # Lets CI and Linux developers run macOS-specific branches (e.g. notification wiring) + # without a Darwin host. Not used in production builds. + "macos" -> + MacOS + _ -> case :os.type() do {:unix, :darwin} -> MacOS @@ -93,6 +102,9 @@ defmodule Desktop.OS do end end + @doc false + def macos?(), do: type() == MacOS + defp kill_heart() do heart = Process.whereis(:heart) diff --git a/lib/desktop/window.ex b/lib/desktop/window.ex index c768d24..860ef3c 100644 --- a/lib/desktop/window.ex +++ b/lib/desktop/window.ex @@ -196,7 +196,7 @@ defmodule Desktop.Window do wx_menubar end - if OS.type() == MacOS do + if OS.macos?() do update_apple_menu(window_title, frame, wx_menubar || :wxMenuBar.new()) end @@ -468,6 +468,12 @@ defmodule Desktop.Window do * `:callback` - A function to be executed when the user clicks on the notification. + On macOS, notifications are associated with the main `wxFrame` via + `wxNotificationMessage:setParent/2` so they follow the same app identity as + the visible window. If nothing appears, check System Settings → Notifications + and (for distributed apps) bundle id / Info.plist alignment for the VM + (see GitHub issue #38). + ## Examples iex> Desktop.Window.show_notification(pid, "Hello, world!") @@ -658,7 +664,7 @@ defmodule Desktop.Window do {n, _} = note = case Map.get(noties, id, nil) do - nil -> {Fallback.notification_new(title || window_title, type), callback} + nil -> {Fallback.notification_new(title || window_title, type, frame), callback} {note, _} -> {note, callback} end diff --git a/test/fallback_notification_test.exs b/test/fallback_notification_test.exs new file mode 100644 index 0000000..edb6d32 --- /dev/null +++ b/test/fallback_notification_test.exs @@ -0,0 +1,25 @@ +defmodule Desktop.FallbackNotificationTest do + use ExUnit.Case + + describe "notification_new/3" do + test "parent option does not crash when wx is disabled (NO_WX)" do + old_no_wx = System.get_env("NO_WX") + old_os = System.get_env("ELIXIR_DESKTOP_OS") + + on_exit(fn -> + restore_env("NO_WX", old_no_wx) + restore_env("ELIXIR_DESKTOP_OS", old_os) + end) + + System.put_env("NO_WX", "1") + System.put_env("ELIXIR_DESKTOP_OS", "macos") + + assert Desktop.OS.macos?() + # Parent is ignored when notification object cannot be created; must not raise. + assert Desktop.Fallback.notification_new("Title", :info, :not_a_wx_window) == nil + end + end + + defp restore_env(key, nil), do: System.delete_env(key) + defp restore_env(key, value), do: System.put_env(key, value) +end diff --git a/test/os_test.exs b/test/os_test.exs new file mode 100644 index 0000000..2bd8025 --- /dev/null +++ b/test/os_test.exs @@ -0,0 +1,20 @@ +defmodule Desktop.OSTest do + use ExUnit.Case + + describe "type/0 and ELIXIR_DESKTOP_OS" do + test "macos override forces MacOS for tests and CI" do + old = System.get_env("ELIXIR_DESKTOP_OS") + + try do + System.put_env("ELIXIR_DESKTOP_OS", "macos") + assert Desktop.OS.type() == MacOS + after + if old do + System.put_env("ELIXIR_DESKTOP_OS", old) + else + System.delete_env("ELIXIR_DESKTOP_OS") + end + end + end + end +end From 59bf27480e8d731cb442ce017aade4fd201da19a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 14:31:30 +0000 Subject: [PATCH 2/3] fix: repair notification cast compile error and harden nil handling Bind frame in show_notification cast (was undefined), recreate wx notification when a stored id maps to nil, no-op notification_close/1 for nil, fix doc typo, and drop duplicate :dismiss callback on non-Linux. Co-authored-by: Dominic Letz --- lib/desktop/fallback.ex | 2 ++ lib/desktop/window.ex | 16 ++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/lib/desktop/fallback.ex b/lib/desktop/fallback.ex index 4fa3c33..1c7c1b0 100644 --- a/lib/desktop/fallback.ex +++ b/lib/desktop/fallback.ex @@ -233,6 +233,8 @@ defmodule Desktop.Fallback do end end + def notification_close(nil), do: :ok + def notification_close(notification) do call(:wxNotificationMessage, :close, [notification]) end diff --git a/lib/desktop/window.ex b/lib/desktop/window.ex index 860ef3c..d251958 100644 --- a/lib/desktop/window.ex +++ b/lib/desktop/window.ex @@ -449,7 +449,7 @@ defmodule Desktop.Window do * `:type` - One of `:info` `:error` `:warn` these will change how the notification will be displayed. The default is `:info` - * `:title` - An alternative title for the notificaion, + * `:title` - An alternative title for the notification, when none is provided the current window title is used. * `:timeout` - A timeout hint specifying how long the notification @@ -554,8 +554,6 @@ defmodule Desktop.Window do if OS.type() == Linux do notification(ui, obj, :action) - else - notification(ui, obj, :dismiss) end {:noreply, ui} @@ -659,13 +657,19 @@ defmodule Desktop.Window do def handle_cast( {:show_notification, message, id, type, title, callback, timeout}, - ui = %Window{notifications: noties, title: window_title} + ui = %Window{notifications: noties, title: window_title, frame: frame} ) do {n, _} = note = case Map.get(noties, id, nil) do - nil -> {Fallback.notification_new(title || window_title, type, frame), callback} - {note, _} -> {note, callback} + nil -> + {Fallback.notification_new(title || window_title, type, frame), callback} + + {nil, _} -> + {Fallback.notification_new(title || window_title, type, frame), callback} + + {note, _} -> + {note, callback} end Fallback.notification_show(n, message, timeout, title || window_title) From 7b75f22fbfe5684cb4a1f9cbf4792114777f7d94 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 1 May 2026 14:23:57 +0000 Subject: [PATCH 3/3] fix(dialyzer): pass string module suffix to Igniter.Project.Module Igniter.Project.Module.module_name/2 is specced as (Igniter.t(), String.t()). Using the atom MainWindow broke Dialyzer (invalid call, no_return on igniter/1, and spurious unused_fun on private helpers). Use "MainWindow" consistently. Unblocks CI for https://github.com/elixir-desktop/desktop/pull/69 Co-authored-by: Dominic Letz --- lib/mix/tasks/desktop.install.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/mix/tasks/desktop.install.ex b/lib/mix/tasks/desktop.install.ex index e3b0bce..7099f67 100644 --- a/lib/mix/tasks/desktop.install.ex +++ b/lib/mix/tasks/desktop.install.ex @@ -50,7 +50,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do menu = Igniter.Project.Module.module_name(igniter, "Menu") menubar = Igniter.Project.Module.module_name(igniter, "MenuBar") gettext = Igniter.Libs.Phoenix.web_module_name(igniter, "Gettext") - main_window = Igniter.Project.Module.module_name(igniter, MainWindow) + main_window = Igniter.Project.Module.module_name(igniter, "MainWindow") igniter |> Igniter.compose_task("igniter.add", ["desktop"]) @@ -63,7 +63,7 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do quote do [ app: unquote(app), - id: unquote(Igniter.Project.Module.module_name(igniter, MainWindow)), + id: unquote(Igniter.Project.Module.module_name(igniter, "MainWindow")), title: unquote(to_string(app)), size: {600, 500}, menubar: unquote(menubar),