diff --git a/docs/porting.md b/docs/porting.md index 00fa0e3..474d73f 100644 --- a/docs/porting.md +++ b/docs/porting.md @@ -34,7 +34,10 @@ Do **not** copy macOS UI code into other platforms — share only the protocol. 4. **One window + webview** — `window.open`, `webview.load_url` / `reload` / `current_url` 5. **Close veto** — native close → `event.window.close_requested`; do not destroy until Elixir says so 6. **Multi-window** — resource ids on one TCP connection -7. **Menus / tray / icons / notifications** +7. **Menus / tray / icons / notifications** — including a default `Edit` + submenu (Undo, Redo, Cut, Copy, Paste, Delete, Select All) with the standard + `Cmd+X/C/V/A` accelerators. Without it, web engines do not receive copy/paste + keyboard events. 8. **Permissions + mic/camera** (hybrid policy) 9. **OS events** — reopen / open URL / open file where the OS supports them 10. **Packaged BEAM spawn** + **CI artifact** on tag draft releases diff --git a/docs/protocol.md b/docs/protocol.md index b7e4bb8..7adfd65 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -121,6 +121,13 @@ section disagree, **fix the host** and keep this section as the contract. ### Menus and tray +- The macOS host installs a default `Edit` submenu on the main menu (Undo, Redo, + Cut, Copy, Paste, Delete, Select All) with the standard `Cmd+Z`, `Cmd+Shift+Z`, + `Cmd+X`, `Cmd+C`, `Cmd+V`, `Cmd+A` accelerators. Actions are routed through the + responder chain, so the first responder (typically the WKWebView's text-input + view) handles them. Other platform hosts SHOULD install an equivalent default + Edit menu so keyboard accelerators work in their web engines too + ([porting.md](porting.md)). - `menu.create` / `menu.update` take a full DOM snapshot (not incremental diffs). After `menu.update`, hosts MUST re-bind any tray that references that `menu_id` (Desktop.Menu mounts empty then updates on mount). @@ -312,6 +319,7 @@ Release binaries used by apps must leave this off. If called while disabled → | `test.echo` | any | same params | | `test.capabilities` | — | capability map | | `test.window.list` | — | `[{window_id, webview_id, title, url}]` | +| `test.menu.list` | — | `[{title, items:[{label, key, modifiers, action}]}]` snapshot of the host's main menu. macOS-only; other hosts return `-32601` until they implement the equivalent. | | `test.webview.eval` | `webview_id`, `script` | eval result (JSON-compatible) | | `test.permission.simulate` | `origin`, `type` | triggers `permission.request` | | `test.disconnect` | — | host closes the TCP connection | diff --git a/docs/status/macos.md b/docs/status/macos.md index e8af52f..5593a77 100644 --- a/docs/status/macos.md +++ b/docs/status/macos.md @@ -22,6 +22,7 @@ manual-only with justification). | New window → external open | done | | | Context menu disable | done | | | Menubar from DOM | done | | +| Default Edit menu (Cmd+C/V/X/A) | done | E2E via test.menu.list | | Tray / status item | done | | | Apple menu | done | | | Notifications | done | | diff --git a/native/macos/Sources/DesktopWebView/HostController.swift b/native/macos/Sources/DesktopWebView/HostController.swift index 1321741..1076f8f 100644 --- a/native/macos/Sources/DesktopWebView/HostController.swift +++ b/native/macos/Sources/DesktopWebView/HostController.swift @@ -64,6 +64,10 @@ final class HostController: NSObject { } NSApp.setActivationPolicy(.regular) + // Default Edit menu is required for keyboard accelerators (Cmd+C/V/X/A) to + // reach the WKWebView responder chain. installMainMenuPreservingApple keeps + // any pre-existing Apple menu item across this call. + installMainMenuPreservingApple(extraItems: [buildEditMenu()]) // Probe OS mic TCC early so the usage string from embedded Info.plist can // surface a System Settings prompt before CallLive getUserMedia. AVCaptureDevice.requestAccess(for: .audio) { _ in } @@ -328,22 +332,27 @@ final class HostController: NSObject { return .bool(true) case "window.set_menubar": let w = try win(params) - if let menuId = params?["menu_id"]?.stringValue, let menu = menus[menuId] { - // AppKit always titles the *first* main-menu item with the process - // name. Keep the application (Apple) menu first, then app menus — - // otherwise "Zones" is shown as "DesktopWebView" and a later Apple - // insert yields two process-named menus. - let bar = NSMenu() - if let apple = ensureAppleMenuItem() { - apple.menu?.removeItem(apple) - bar.addItem(apple) - } - for item in menu.items { - bar.addItem(item.copy() as! NSMenuItem) - } - w.window.menu = bar - NSApp.mainMenu = bar + // No menu_id means "don't touch the main menu"; the Edit menu + // installed at start() (or by set_menubar earlier) remains active. + guard let menuId = params?["menu_id"]?.stringValue, let menu = menus[menuId] else { + return .bool(true) + } + // AppKit always titles the *first* main-menu item with the process + // name. Keep the application (Apple) menu first, then the default + // Edit menu (so Cmd+C/V/X/A accelerators remain active), then the + // user-supplied menubar items. Installing the Edit menu from a copy + // is safe — each window gets its own mainMenu instance. + let bar = NSMenu() + if let apple = ensureAppleMenuItem() { + apple.menu?.removeItem(apple) + bar.addItem(apple) } + bar.addItem(buildEditMenu()) + for item in menu.items { + bar.addItem(item.copy() as! NSMenuItem) + } + w.window.menu = bar + NSApp.mainMenu = bar return .bool(true) case "window.iconize": let w = try win(params) @@ -528,6 +537,27 @@ final class HostController: NSObject { ]) } return .ok(id: id, result: .array(list)) + case "test.menu.list": + // Snapshot the current main menu (NSApp.mainMenu) so the E2E suite + // can assert that the host has installed a default Edit menu with + // the expected keyboard accelerators. Items without an action (the + // submenu root) are reported as `action = ""`. + let items: [JSONValue] = (NSApp.mainMenu?.items ?? []).map { item in + let sub: [JSONValue] = (item.submenu?.items ?? []).map { subItem in + let action = subItem.action.map(NSStringFromSelector) ?? "" + return .object([ + "label": .string(subItem.title), + "key": .string(subItem.keyEquivalent), + "modifiers": .number(Double(subItem.keyEquivalentModifierMask.rawValue)), + "action": .string(action) + ]) + } + return .object([ + "title": .string(item.title), + "items": .array(sub) + ]) + } + return .ok(id: id, result: .array(items)) case "test.webview.eval": guard let wvId = params?["webview_id"]?.stringValue, let script = params?["script"]?.stringValue, @@ -751,6 +781,56 @@ final class HostController: NSObject { return .bool(true) } + /// Default Edit menu with the standard macOS keyboard accelerators. The + /// actions are wired to the responder chain (`target = nil`), so WKWebView's + /// internal text input views implement them and receive `copy:` / `cut:` / + /// `paste:` / `selectAll:` etc. when the user presses Cmd+C/V/X/A. Without + /// this menu installed, `performKeyEquivalent` never fires the selectors. + private func buildEditMenu() -> NSMenuItem { + let menu = NSMenu(title: "Edit") + let cmd: NSEvent.ModifierFlags = .command + + let undo = NSMenuItem(title: "Undo", action: #selector(UndoManager.undo), keyEquivalent: "z") + undo.keyEquivalentModifierMask = cmd + undo.target = nil + menu.addItem(undo) + + let redo = NSMenuItem(title: "Redo", action: #selector(UndoManager.redo), keyEquivalent: "z") + redo.keyEquivalentModifierMask = cmd.union(.shift) + redo.target = nil + menu.addItem(redo) + + menu.addItem(NSMenuItem.separator()) + + let cut = NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + cut.keyEquivalentModifierMask = cmd + cut.target = nil + menu.addItem(cut) + + let copy = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + copy.keyEquivalentModifierMask = cmd + copy.target = nil + menu.addItem(copy) + + let paste = NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + paste.keyEquivalentModifierMask = cmd + paste.target = nil + menu.addItem(paste) + + let delete = NSMenuItem(title: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: String(Character(UnicodeScalar(NSBackspaceCharacter)!))) + delete.target = nil + menu.addItem(delete) + + let selectAll = NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + selectAll.keyEquivalentModifierMask = cmd + selectAll.target = nil + menu.addItem(selectAll) + + let editItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") + editItem.submenu = menu + return editItem + } + /// Application menu (About / Quit). Always the first main-menu item on macOS. private func ensureAppleMenuItem() -> NSMenuItem? { let name = appDisplayName diff --git a/test/e2e/e2e_test.exs b/test/e2e/e2e_test.exs index 63123da..190fd81 100644 --- a/test/e2e/e2e_test.exs +++ b/test/e2e/e2e_test.exs @@ -190,4 +190,43 @@ defmodule DesktopWebview.E2ETest do assert {:ok, list} = Transport.call("test.window.list", %{}) assert length(list) >= 2 end + + test "default edit menu is installed with copy/paste/cut/selectAll", %{platform: platform} do + # The macOS host installs a default Edit submenu and exposes it via + # test.menu.list. The Linux host uses GTK menu bars and does not yet + # implement test.menu.list; the Edit menu on Linux is required by + # docs/porting.md and will be added by a follow-up port. On non-macOS + # we verify the host's documented contract: test.menu.list is a + # macOS-only test RPC and returns "Unknown test method" elsewhere. + case platform do + "macos" -> + assert {:ok, menus} = Transport.call("test.menu.list", %{}) + assert is_list(menus) + + edit = Enum.find(menus, &(&1["title"] == "Edit")) + assert edit, "Edit menu missing from NSApp.mainMenu: #{inspect(menus)}" + + by_label = Map.new(edit["items"], fn item -> {item["label"], item} end) + + expected = %{ + "Cut" => {"x", "cut:"}, + "Copy" => {"c", "copy:"}, + "Paste" => {"v", "paste:"}, + "Select All" => {"a", "selectAll:"} + } + + for {label, {key, action}} <- expected do + item = Map.get(by_label, label) + assert item, "Edit menu missing item #{label}; got #{inspect(Map.keys(by_label))}" + assert item["key"] == key, "#{label} key equivalent expected #{key} got #{item["key"]}" + + assert item["action"] == action, + "#{label} action expected #{action} got #{item["action"]}" + end + + _ -> + assert {:error, %{"code" => -32601, "message" => "Unknown test method"}} = + Transport.call("test.menu.list", %{}) + end + end end