From 7af4669b519d9ea88f7eed8ab0b5ecea8874627a Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 28 Aug 2026 18:51:17 +0200 Subject: [PATCH 1/2] Fix macOS crash on notification activate and emit click events. Set isReleasedWhenClosed=false, deminiaturize before raise/show, and add test.notification.emit_* RPCs so E2E can verify iconize/raise and EventBridge notification click delivery without SEGVing AppKit. Co-authored-by: Cursor --- docs/desktop-integration.md | 6 +++ docs/protocol.md | 2 + native/linux/src/host_controller.cpp | 16 +++++++ .../DesktopWebView/HostController.swift | 31 ++++++++++--- .../Sources/DesktopWebView/WebWindow.swift | 4 ++ native/windows/src/host_controller.cpp | 10 +++++ test/e2e/e2e_test.exs | 43 +++++++++++++++++++ test/event_bridge_test.exs | 12 ++++++ 8 files changed, 119 insertions(+), 5 deletions(-) diff --git a/docs/desktop-integration.md b/docs/desktop-integration.md index 07fafed..631dc47 100644 --- a/docs/desktop-integration.md +++ b/docs/desktop-integration.md @@ -62,8 +62,14 @@ backend is active. It translates host notifications into elixir-desktop messages | `event.system.open_file` | `Desktop.Env.notify_subscribers({:open_file, [path]})` | | `event.system.reopen` | `{:reopen_app, []}` to `Desktop.Env` | | `event.menu.click` | `GenServer.cast(menu, {:trigger_event, onclick})` | +| `event.notification.click` | `send(window, {:edw_notification, id, :click})` | +| `event.notification.dismiss` | `send(window, {:edw_notification, id, :dismiss})` | | `event.webview.new_window` | `system.open_url` (external browser) | +`Desktop.Window` handles `{:edw_notification, id, action}` and runs the callback +registered via `Desktop.Window.show_notification/3`. Pass a stable `:id` so the +host notification id matches the Window map key. + Do **not** subscribe `Desktop.Env` directly to Transport — raw `{:edw_event, ...}` messages are not in the Env contract. diff --git a/docs/protocol.md b/docs/protocol.md index 2e1b2e9..cca0b76 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -342,5 +342,7 @@ Release binaries used by apps must leave this off. If called while disabled → | `test.permission.simulate` | `origin`, `type` | triggers `permission.request` | | `test.disconnect` | — | host closes the TCP connection | | `test.crash` | — | host process exits non-zero (E2E only) | +| `test.notification.emit_click` | `notification_id` | emits `event.notification.click` (same path as OS click) | +| `test.notification.emit_dismiss` | `notification_id` | emits `event.notification.dismiss` | Production code paths must not call `test.*`. diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp index 2b611f1..f49cda2 100644 --- a/native/linux/src/host_controller.cpp +++ b/native/linux/src/host_controller.cpp @@ -1013,5 +1013,21 @@ JsonNode* HostController::handle_test(const std::string& method, JsonNode* param if (method == "test.crash") { _exit(2); } + if (method == "test.notification.emit_click" || method == "test.notification.emit_dismiss") { + const char* ev = + method == "test.notification.emit_click" ? "event.notification.click" + : "event.notification.dismiss"; + std::string nid; + if (params && json_object_has_member(params, "notification_id")) { + nid = json_object_get_string_member(params, "notification_id"); + } + JsonObject* o = json_object_new(); + json_object_set_string_member(o, "notification_id", nid.c_str()); + JsonNode* n = json_node_alloc(); + json_node_init_object(n, o); + json_object_unref(o); + server_.notify(ev, n); + return jsonutil::rpc_ok(id, jsonutil::bool_node(true)); + } return jsonutil::rpc_error(id, -32601, "Unknown test method"); } diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 1c42a14..87e8e05 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -409,8 +409,7 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { return .bool(w.window.isKeyWindow) case "window.raise": let w = try win(params) - NSApp.activate(ignoringOtherApps: true) - w.window.makeKeyAndOrderFront(nil) + raiseWindow(w) return .bool(true) case "window.close_veto": _ = try win(params) @@ -650,11 +649,24 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { return .ok(id: id, result: .bool(true)) case "test.crash": exit(2) + case "test.notification.emit_click": + return emitNotificationEvent("event.notification.click", params: params, id: id) + case "test.notification.emit_dismiss": + return emitNotificationEvent("event.notification.dismiss", params: params, id: id) default: return .fail(id: id, code: -32601, message: "Unknown test method") } } + private func emitNotificationEvent(_ method: String, params: JSONValue?, id: JSONValue?) -> JSONRPC.Response { + let nid = params?["notification_id"]?.stringValue ?? "" + server.notify( + method: method, + params: .object(["notification_id": .string(nid)]) + ) + return .ok(id: id, result: .bool(true)) + } + // MARK: - helpers private func win(_ params: JSONValue?) throws -> WebWindowController { @@ -698,8 +710,7 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { } windows[windowId] = ctrl webviews[webviewId] = windowId - ctrl.window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) + raiseWindow(ctrl) return .object(["window_id": .string(windowId), "webview_id": .string(webviewId)]) } @@ -716,13 +727,23 @@ final class HostController: NSObject, UNUserNotificationCenterDelegate { private func windowShow(_ params: JSONValue?, show: Bool) throws -> JSONValue { let w = try win(params) if show { - w.window.makeKeyAndOrderFront(nil) + raiseWindow(w) } else { w.window.orderOut(nil) } return .bool(true) } + /// Deminiaturize before activate/orderFront so AppKit does not start competing + /// `_NSWindowTransformAnimation`s (notification-click / dock activate path). + private func raiseWindow(_ w: WebWindowController) { + if w.window.isMiniaturized { + w.window.deminiaturize(nil) + } + NSApp.activate(ignoringOtherApps: true) + w.window.makeKeyAndOrderFront(nil) + } + private func menuCreate(_ params: JSONValue?) throws -> JSONValue { let id = nextId("m") let dom = params?["dom"] diff --git a/native/macos/Sources/DesktopWebView/WebWindow.swift b/native/macos/Sources/DesktopWebView/WebWindow.swift index 31dd9c0..dabbb9e 100644 --- a/native/macos/Sources/DesktopWebView/WebWindow.swift +++ b/native/macos/Sources/DesktopWebView/WebWindow.swift @@ -23,6 +23,10 @@ final class WebWindowController: NSObject, NSWindowDelegate, WKUIDelegate, WKNav backing: .buffered, defer: false ) + // Programmatic NSWindows default to isReleasedWhenClosed = true, which + // races AppKit's _NSWindowTransformAnimation on activate/raise and can + // SIGSEGV when a notification click brings the app forward. + window.isReleasedWhenClosed = false window.title = title window.center() diff --git a/native/windows/src/host_controller.cpp b/native/windows/src/host_controller.cpp index ea7f339..6f267f4 100644 --- a/native/windows/src/host_controller.cpp +++ b/native/windows/src/host_controller.cpp @@ -1152,5 +1152,15 @@ jsonutil::Json HostController::handle_test(const std::string& method, const json if (method == "test.crash") { ExitProcess(2); } + if (method == "test.notification.emit_click" || method == "test.notification.emit_dismiss") { + const char* ev = method == "test.notification.emit_click" ? "event.notification.click" + : "event.notification.dismiss"; + std::string nid; + if (params.contains("notification_id") && params["notification_id"].is_string()) { + nid = params["notification_id"].get(); + } + server_.notify(ev, jsonutil::Json{{"notification_id", nid}}); + return jsonutil::rpc_ok(id, true); + } return jsonutil::rpc_error(id, -32601, "Unknown test method"); } diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index 2e00f18..faccb74 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -99,6 +99,27 @@ defmodule DesktopWebview.E2ETest do assert {:ok, true} = Transport.call("window.destroy", %{"window_id" => wid}) end + test "window iconize raise hide show does not crash host" do + assert {:ok, %{"window_id" => wid}} = + Transport.call("window.open", %{ + "title" => "RaiseMe", + "width" => 480, + "height" => 320 + }) + + assert {:ok, true} = + Transport.call("window.iconize", %{"window_id" => wid, "iconize" => true}) + + Process.sleep(200) + assert {:ok, true} = Transport.call("window.raise", %{"window_id" => wid}) + Process.sleep(200) + assert {:ok, true} = Transport.call("window.hide", %{"window_id" => wid}) + assert {:ok, true} = Transport.call("window.show", %{"window_id" => wid, "show" => true}) + assert {:ok, true} = Transport.call("window.raise", %{"window_id" => wid}) + assert {:ok, "pong"} = Transport.call("test.ping", %{}) + assert {:ok, true} = Transport.call("window.destroy", %{"window_id" => wid}) + end + test "menu create and notification" do dom = %{ "tag" => "menubar", @@ -124,10 +145,32 @@ defmodule DesktopWebview.E2ETest do assert {:ok, %{"notification_id" => nid}} = Transport.call("notification.show", %{ + "id" => "e2e-note-1", "title" => "Hello", "message" => "World" }) + assert nid == "e2e-note-1" + + # Transport is restarted in setup; restart EventBridge so it resubscribes. + if pid = Process.whereis(DesktopWebview.EventBridge) do + Process.exit(pid, :kill) + Process.sleep(20) + end + + DesktopWebview.EventBridge.ensure_started() + DesktopWebview.EventBridge.register_notification(nid, self()) + + assert {:ok, true} = + Transport.call("test.notification.emit_click", %{"notification_id" => nid}) + + assert_receive {:edw_notification, ^nid, :click}, 1000 + + assert {:ok, true} = + Transport.call("test.notification.emit_dismiss", %{"notification_id" => nid}) + + assert_receive {:edw_notification, ^nid, :dismiss}, 1000 + assert {:ok, true} = Transport.call("notification.close", %{"notification_id" => nid}) assert {:ok, true} = Transport.call("tray.destroy", %{"tray_id" => tid}) assert {:ok, true} = Transport.call("menu.destroy", %{"menu_id" => mid}) diff --git a/test/event_bridge_test.exs b/test/event_bridge_test.exs index 611fc91..3f22c6b 100644 --- a/test/event_bridge_test.exs +++ b/test/event_bridge_test.exs @@ -33,6 +33,18 @@ defmodule DesktopWebview.EventBridgeTest do assert_receive {:"$gen_cast", {:trigger_event, "quit"}}, 500 end + test "notification.click delivers edw_notification", %{bridge: bridge} do + EventBridge.register_notification("nid-1", self()) + send(bridge, {:edw_event, "event.notification.click", %{"notification_id" => "nid-1"}}) + assert_receive {:edw_notification, "nid-1", :click}, 500 + end + + test "notification.dismiss delivers edw_notification", %{bridge: bridge} do + EventBridge.register_notification("nid-2", self()) + send(bridge, {:edw_event, "event.notification.dismiss", %{"notification_id" => "nid-2"}}) + assert_receive {:edw_notification, "nid-2", :dismiss}, 500 + end + test "quit invokes configured quit_fun", %{bridge: bridge} do test = self() Application.put_env(:desktop_webview, :quit_fun, fn -> send(test, :quit_requested) end) From 3d5ed146b606f40d073884c9f5c9562a6c4f07d1 Mon Sep 17 00:00:00 2001 From: Dominic Letz Date: Fri, 28 Aug 2026 19:15:13 +0200 Subject: [PATCH 2/2] Fix Linux test.notification.emit_* JsonNode vs JsonObject use. handle_test receives JsonNode*; use params_obj and object_get_string like the other test RPC handlers so the Linux host builds again. Co-authored-by: Cursor --- native/linux/src/host_controller.cpp | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/native/linux/src/host_controller.cpp b/native/linux/src/host_controller.cpp index f49cda2..090011b 100644 --- a/native/linux/src/host_controller.cpp +++ b/native/linux/src/host_controller.cpp @@ -1017,11 +1017,9 @@ JsonNode* HostController::handle_test(const std::string& method, JsonNode* param const char* ev = method == "test.notification.emit_click" ? "event.notification.click" : "event.notification.dismiss"; - std::string nid; - if (params && json_object_has_member(params, "notification_id")) { - nid = json_object_get_string_member(params, "notification_id"); - } - JsonObject* o = json_object_new(); + JsonObject* p = params_obj(params); + std::string nid = jsonutil::object_get_string(p, "notification_id").value_or(""); + JsonObject* o = jsonutil::object_new(); json_object_set_string_member(o, "notification_id", nid.c_str()); JsonNode* n = json_node_alloc(); json_node_init_object(n, o);