Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/porting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions docs/status/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
110 changes: 95 additions & 15 deletions native/macos/Sources/DesktopWebView/HostController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions test/e2e/e2e_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading