janela 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lib.mjs CHANGED
@@ -223,6 +223,18 @@ export function ffiManifest(shimLib, { platform = process.platform, macSdkPath =
223
223
  returns: "i32",
224
224
  },
225
225
  { name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
226
+ STR("wvSetMenu", "wv_set_menu"),
227
+ // Retained like wvOnInvoke: registered once, valid until the app exits.
228
+ // The clicked item's id rides in as a `string` param (format 3).
229
+ {
230
+ name: "wvOnMenu", symbol: "wv_on_menu",
231
+ params: [
232
+ "i32",
233
+ { callback: { id: "menu", params: ["string", { context: "menu" }], returns: "i32", lifetime: "retained" } },
234
+ { context: "menu" },
235
+ ],
236
+ returns: "i32",
237
+ },
226
238
  ];
227
239
 
228
240
  if (platform === "win32") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Desktop, iOS and Android apps in pure TypeScript, compiled to native. No Rust, no Node, no Electron.",
5
5
  "type": "module",
6
6
  "bin": {
package/runtime/janela.ts CHANGED
@@ -42,6 +42,8 @@ declare function wvDialog(
42
42
  filters: string,
43
43
  ): number;
44
44
  declare function wvSetFullscreen(h: number, on: number): number;
45
+ declare function wvSetMenu(h: number, spec: string): number;
46
+ declare function wvOnMenu(h: number, cb: (id: string) => number): number;
45
47
 
46
48
  const JOB_PENDING = 0;
47
49
  const JOB_OK = 1;
@@ -90,6 +92,7 @@ export type {
90
92
  DialogFilter,
91
93
  Events,
92
94
  FsCallback,
95
+ MenuEntry,
93
96
  OpenDialogOptions,
94
97
  SaveDialogOptions,
95
98
  WindowConfig,
@@ -99,6 +102,7 @@ export type {
99
102
  // A project's `import { defineCommands } from "janela/host"` is rewritten to
100
103
  // this module by the CLI before scriptc sees it.
101
104
  export { defineCommands, defineEvents } from "./types";
105
+ export { menuItem, menuSeparator, submenu } from "./types";
102
106
 
103
107
  import type {
104
108
  AsyncCommandHandler,
@@ -110,6 +114,7 @@ import type {
110
114
  DialogFilter,
111
115
  Events,
112
116
  FsCallback,
117
+ MenuEntry,
113
118
  OpenDialogOptions,
114
119
  SaveDialogOptions,
115
120
  WindowConfig,
@@ -156,6 +161,68 @@ const TIMER_JOBS = -1;
156
161
  * interface (being signature-only) never is. A class receiver works even as a
157
162
  * plain function parameter, which is what `setup(app)` is.
158
163
  */
164
+ // ---- menu flattening ---------------------------------------------------
165
+ //
166
+ // The shim renders menus but parses nothing structural: the tree is flattened
167
+ // here into one row per line with 0x1f between fields, which needs only a
168
+ // split on the native side. Keeping the parsing in TypeScript is the whole
169
+ // point — the native side stays a renderer.
170
+
171
+ // NSEventModifierFlags.
172
+ const MENU_SHIFT = 131072;
173
+ const MENU_CONTROL = 262144;
174
+ const MENU_OPTION = 524288;
175
+ const MENU_COMMAND = 1048576;
176
+
177
+ /** "CmdOrCtrl+Shift+O" -> "o<US>1179648". Unknown words are taken as the key. */
178
+ function parseAccel(accel: string): string {
179
+ const parts = accel.split("+");
180
+ let mods = 0;
181
+ let key = "";
182
+ for (let i = 0; i < parts.length; i++) {
183
+ const p = parts[i].toLowerCase();
184
+ if (p === "cmd" || p === "command" || p === "meta" || p === "super") {
185
+ mods = mods | MENU_COMMAND;
186
+ } else if (p === "cmdorctrl" || p === "commandorcontrol") {
187
+ // macOS is the only platform with custom menus so far, so this is
188
+ // Command here; the Windows and Linux renderers will read it as Control.
189
+ mods = mods | MENU_COMMAND;
190
+ } else if (p === "ctrl" || p === "control") {
191
+ mods = mods | MENU_CONTROL;
192
+ } else if (p === "alt" || p === "option") {
193
+ mods = mods | MENU_OPTION;
194
+ } else if (p === "shift") {
195
+ mods = mods | MENU_SHIFT;
196
+ } else if (p !== "") {
197
+ key = p;
198
+ }
199
+ }
200
+ return key + "\x1f" + mods;
201
+ }
202
+
203
+ /** 0x1f and newlines are the wire format's own delimiters. */
204
+ function menuSafe(v: string): string {
205
+ return v.split("\x1f").join(" ").split("\n").join(" ");
206
+ }
207
+
208
+ function flattenMenu(entries: MenuEntry[], rows: string[]): void {
209
+ for (let i = 0; i < entries.length; i++) {
210
+ const e = entries[i];
211
+ if (e.separator) {
212
+ rows.push("-");
213
+ continue;
214
+ }
215
+ const label = menuSafe(e.label);
216
+ if (e.items.length > 0) {
217
+ rows.push("S\x1f" + label);
218
+ flattenMenu(e.items, rows);
219
+ rows.push("E");
220
+ continue;
221
+ }
222
+ rows.push("I\x1f" + menuSafe(e.id) + "\x1f" + label + "\x1f" + parseAccel(e.accel));
223
+ }
224
+ }
225
+
159
226
  export class JanelaAppImpl<
160
227
  C extends CommandShapes = CommandShapes,
161
228
  E = Record<string, unknown>,
@@ -509,6 +576,44 @@ export class JanelaAppImpl<
509
576
  wvSetFullscreen(this.handle, on ? 1 : 0);
510
577
  }
511
578
 
579
+ /**
580
+ * Put submenus in the application menu.
581
+ *
582
+ * They are ADDED to the standard ones rather than replacing them, so a
583
+ * custom menu can never cost the app Cmd+Q or Cmd+V — replacing the bar
584
+ * wholesale is what would. Calling it again replaces only what a previous
585
+ * call added, so the menu can shrink as well as grow.
586
+ *
587
+ * Clicks arrive on `onMenu` with the entry's `id`.
588
+ *
589
+ * ```ts
590
+ * app.setMenu([
591
+ * { label: "File", items: [
592
+ * { label: "Open…", id: "open", accel: "CmdOrCtrl+O" },
593
+ * { separator: true },
594
+ * { label: "Close", id: "close" },
595
+ * ]},
596
+ * ]);
597
+ * app.onMenu((id) => { if (id === "open") … });
598
+ * ```
599
+ *
600
+ * Returns false where custom menus are not supported yet — everything but
601
+ * macOS. The standard menu is unaffected either way.
602
+ */
603
+ setMenu(entries: MenuEntry[]): boolean {
604
+ const rows: string[] = [];
605
+ flattenMenu(entries, rows);
606
+ return wvSetMenu(this.handle, rows.join("\n")) === 0;
607
+ }
608
+
609
+ /** Called with the `id` of the clicked menu entry. */
610
+ onMenu(cb: (id: string) => void): void {
611
+ wvOnMenu(this.handle, (id) => {
612
+ cb(id);
613
+ return 0;
614
+ });
615
+ }
616
+
512
617
  /**
513
618
  * Fire an event into the page; the payload is delivered as a value. Under a
514
619
  * contract, the name must be declared and the payload must match its type.
package/runtime/types.ts CHANGED
@@ -41,6 +41,55 @@ export type AsyncCommandHandler = (
41
41
  */
42
42
  export type FsCallback = (err: string | null, text: string) => void;
43
43
 
44
+ /**
45
+ * One entry in the application menu.
46
+ *
47
+ * Every field is required rather than optional, and the entries are built with
48
+ * `submenu`, `menuItem` and `menuSeparator` rather than written as literals.
49
+ * That is a scriptc constraint made into a nicer API: a record whose fields are
50
+ * all optional infers as `{ label: string | undefined, … }`, and an array
51
+ * mixing a submenu, an item and a separator is a union that will not re-tag
52
+ * into it ("union types must match exactly", SC2003). The helpers each return
53
+ * the same total shape, so the array is homogeneous and the call site reads as
54
+ * a tree.
55
+ *
56
+ * ```ts
57
+ * app.setMenu([
58
+ * submenu("File", [
59
+ * menuItem("Open…", "open", "CmdOrCtrl+O"),
60
+ * menuSeparator(),
61
+ * ]),
62
+ * ]);
63
+ * ```
64
+ */
65
+ export type MenuEntry = {
66
+ label: string;
67
+ /** Sent to `onMenu` when clicked. Empty for submenus and separators. */
68
+ id: string;
69
+ /**
70
+ * A shortcut like "CmdOrCtrl+O" or "CmdOrCtrl+Shift+S". Modifiers: Cmd,
71
+ * Ctrl, CmdOrCtrl, Alt/Option, Shift. Empty for none.
72
+ */
73
+ accel: string;
74
+ separator: boolean;
75
+ /** Children. Empty for a leaf. */
76
+ items: MenuEntry[];
77
+ };
78
+
79
+ /** A clickable entry. `accel` is "" for no shortcut. */
80
+ export function menuItem(label: string, id: string, accel: string): MenuEntry {
81
+ return { label: label, id: id, accel: accel, separator: false, items: [] };
82
+ }
83
+
84
+ /** A divider. */
85
+ export function menuSeparator(): MenuEntry {
86
+ return { label: "", id: "", accel: "", separator: true, items: [] };
87
+ }
88
+
89
+ /** A submenu holding other entries. Nestable. */
90
+ export function submenu(label: string, items: MenuEntry[]): MenuEntry {
91
+ return { label: label, id: "", accel: "", separator: false, items: items };
92
+ }
44
93
  /** A named group of extensions offered in a dialog's file-type popup. */
45
94
  export interface DialogFilter {
46
95
  name: string;
package/shim/wvshim.cc CHANGED
@@ -75,6 +75,15 @@ struct App {
75
75
  void *on_invoke_ctx = nullptr;
76
76
  void (*on_timer)(int32_t, void *) = nullptr;
77
77
  void *on_timer_ctx = nullptr;
78
+ int32_t (*on_menu)(const uint8_t *, size_t, void *) = nullptr;
79
+ void *on_menu_ctx = nullptr;
80
+
81
+ // Custom menu item ids, indexed by the NSMenuItem's tag. The menu bar is
82
+ // process-global on macOS, so only one app can own it — see g_menu_owner.
83
+ std::vector<std::string> menu_ids;
84
+ // How many submenus the standard bar installed, so setMenu appends after
85
+ // them and can remove only its own on a later call.
86
+ size_t std_menu_count = 0;
78
87
 
79
88
  // Staging for the in-flight request.
80
89
  std::string req; // JSON args array from JS
@@ -358,6 +367,284 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
358
367
 
359
368
  #if defined(__APPLE__)
360
369
 
370
+ // A standard macOS main menu.
371
+ //
372
+ // On macOS a Command-key shortcut is a MENU key equivalent, not a
373
+ // window-manager gesture the way Alt+F4 is on Windows. With no main menu
374
+ // nothing claims Cmd+Q, Cmd+C, Cmd+V, Cmd+Z or Cmd+A, and a webview app simply
375
+ // appears to ignore them — the window had zero menu bars, which is exactly
376
+ // what that looks like from the outside.
377
+ //
378
+ // webview.h leaves this to the embedder on purpose: webview/webview#127 is
379
+ // still open, and #237, which added precisely this Edit menu, was closed with
380
+ // "anyone who is impatient can always take this code on their own". Tauri does
381
+ // the same thing one layer up, through muda.
382
+ //
383
+ // Every item here is a STANDARD AppKit selector travelling up the responder
384
+ // chain, so there is nothing to call back into TypeScript for and no new FFI
385
+ // surface. WKWebView answers the editing ones itself. Custom menus — a
386
+ // declarative table passed down from the host — are a separate feature.
387
+ static void install_main_menu() {
388
+ using namespace webview::detail;
389
+ objc::autoreleasepool arp;
390
+
391
+ id app = objc::msg_send<id>(objc::get_class("NSApplication"),
392
+ objc::selector("sharedApplication"));
393
+ if (!app) return;
394
+ // Idempotent: an embedder that already installed a menu keeps it.
395
+ if (objc::msg_send<id>(app, objc::selector("mainMenu"))) return;
396
+
397
+ // NSEventModifierFlags. The default for a key equivalent is Command alone,
398
+ // so only the combinations need naming.
399
+ const NSUInteger kShift = 1UL << 17;
400
+ const NSUInteger kControl = 1UL << 18;
401
+ const NSUInteger kOption = 1UL << 19;
402
+ const NSUInteger kCommand = 1UL << 20;
403
+
404
+ auto str = [](const std::string &v) {
405
+ return cocoa::NSString_stringWithUTF8String(v);
406
+ };
407
+ auto alloc_init = [](const char *cls) {
408
+ return objc::msg_send<id>(
409
+ objc::msg_send<id>(objc::get_class(cls), objc::selector("alloc")),
410
+ objc::selector("init"));
411
+ };
412
+
413
+ // AppKit labels the first submenu from the bundle, not from this title, but
414
+ // "About <name>" and "Quit <name>" are ours to spell.
415
+ id process = objc::msg_send<id>(objc::get_class("NSProcessInfo"),
416
+ objc::selector("processInfo"));
417
+ const char *raw =
418
+ process ? objc::msg_send<const char *>(
419
+ objc::msg_send<id>(process, objc::selector("processName")),
420
+ objc::selector("UTF8String"))
421
+ : nullptr;
422
+ std::string name = raw ? raw : "App";
423
+
424
+ id menubar = alloc_init("NSMenu");
425
+
426
+ auto submenu = [&](const std::string &title) {
427
+ id holder = alloc_init("NSMenuItem");
428
+ id menu = objc::msg_send<id>(
429
+ objc::msg_send<id>(objc::get_class("NSMenu"), objc::selector("alloc")),
430
+ objc::selector("initWithTitle:"), str(title));
431
+ objc::msg_send<void>(holder, objc::selector("setSubmenu:"), menu);
432
+ objc::msg_send<void>(menubar, objc::selector("addItem:"), holder);
433
+ return menu;
434
+ };
435
+
436
+ auto item = [&](id menu, const std::string &title, const char *sel,
437
+ const std::string &key, NSUInteger mods) {
438
+ id it = objc::msg_send<id>(
439
+ objc::msg_send<id>(objc::get_class("NSMenuItem"),
440
+ objc::selector("alloc")),
441
+ objc::selector("initWithTitle:action:keyEquivalent:"), str(title),
442
+ sel ? objc::selector(sel) : nullptr, str(key));
443
+ if (mods) {
444
+ objc::msg_send<void>(
445
+ it, objc::selector("setKeyEquivalentModifierMask:"), mods);
446
+ }
447
+ objc::msg_send<void>(menu, objc::selector("addItem:"), it);
448
+ };
449
+ auto separator = [&](id menu) {
450
+ objc::msg_send<void>(menu, objc::selector("addItem:"),
451
+ objc::msg_send<id>(objc::get_class("NSMenuItem"),
452
+ objc::selector("separatorItem")));
453
+ };
454
+
455
+ id app_menu = submenu(name);
456
+ item(app_menu, "About " + name, "orderFrontStandardAboutPanel:", "", 0);
457
+ separator(app_menu);
458
+ item(app_menu, "Hide " + name, "hide:", "h", 0);
459
+ item(app_menu, "Hide Others", "hideOtherApplications:", "h",
460
+ kOption | kCommand);
461
+ item(app_menu, "Show All", "unhideAllApplications:", "", 0);
462
+ separator(app_menu);
463
+ // performClose:, not terminate: — measured, not assumed. Both quit and
464
+ // both exit 0, but terminate: exits the process itself, so the host never
465
+ // returns from wv_run and anything after app.run() is skipped;
466
+ // performClose: goes through the same window-close path the red button
467
+ // uses, the run loop unwinds, and the host prints its own "run returned 0".
468
+ // One shutdown path instead of two. muda picks terminate: because Tauri's
469
+ // app logic is native and multi-window; janela is single-window, so closing
470
+ // the window IS quitting. If janela ever grows multiple windows this has to
471
+ // become a real Quit that closes all of them.
472
+ //
473
+ // (An earlier note here claimed performClose: did not fire at all. That was
474
+ // a bad test: posted key events go to the FRONTMOST app, and the app was not
475
+ // active. With it activated first, both actions work.)
476
+ item(app_menu, "Quit " + name, "performClose:", "q", 0);
477
+
478
+ id edit = submenu("Edit");
479
+ item(edit, "Undo", "undo:", "z", 0);
480
+ item(edit, "Redo", "redo:", "z", kShift | kCommand);
481
+ separator(edit);
482
+ item(edit, "Cut", "cut:", "x", 0);
483
+ item(edit, "Copy", "copy:", "c", 0);
484
+ item(edit, "Paste", "paste:", "v", 0);
485
+ item(edit, "Select All", "selectAll:", "a", 0);
486
+
487
+ id view = submenu("View");
488
+ item(view, "Enter Full Screen", "toggleFullScreen:", "f",
489
+ kControl | kCommand);
490
+
491
+ id window = submenu("Window");
492
+ item(window, "Minimize", "performMiniaturize:", "m", 0);
493
+ item(window, "Close", "performClose:", "w", 0);
494
+
495
+ objc::msg_send<void>(app, objc::selector("setMainMenu:"), menubar);
496
+ // Lets AppKit add the standard Window-menu bookkeeping (window list,
497
+ // Bring All to Front).
498
+ objc::msg_send<void>(app, objc::selector("setWindowsMenu:"), window);
499
+ }
500
+
501
+ // How many submenus the standard bar has; custom ones are appended after these
502
+ // and only those are removed on a later setMenu.
503
+ static size_t standard_menu_count() {
504
+ using namespace webview::detail;
505
+ objc::autoreleasepool arp;
506
+ id app = objc::msg_send<id>(objc::get_class("NSApplication"),
507
+ objc::selector("sharedApplication"));
508
+ id menubar = app ? objc::msg_send<id>(app, objc::selector("mainMenu")) : nullptr;
509
+ if (!menubar) return 0;
510
+ return static_cast<size_t>(
511
+ objc::msg_send<NSUInteger>(menubar, objc::selector("numberOfItems")));
512
+ }
513
+
514
+ // ---- custom menus ------------------------------------------------------
515
+ //
516
+ // The host describes its menus declaratively in TypeScript and this renders
517
+ // them. Nothing here parses JSON: the runtime flattens the tree into one row
518
+ // per line, fields separated by 0x1f, which needs a split and nothing more.
519
+ //
520
+ // S<US>Label open a submenu
521
+ // E close it
522
+ // I<US>tag<US>Label<US>key<US>mods an item
523
+ // - a separator
524
+ //
525
+ // A click sends the item's tag back, which indexes App::menu_ids, and the id
526
+ // string goes up to TypeScript on the retained on_menu callback — the same
527
+ // shape as on_invoke. The menu bar is process-global on macOS, so exactly one
528
+ // app owns it at a time.
529
+ static int32_t g_menu_owner = -1;
530
+
531
+ static void menu_clicked(long tag) {
532
+ App *a = g_menu_owner >= 0 ? app_at(g_menu_owner) : nullptr;
533
+ if (!a || !a->on_menu) return;
534
+ if (tag < 0 || static_cast<size_t>(tag) >= a->menu_ids.size()) return;
535
+ const std::string &id = a->menu_ids[static_cast<size_t>(tag)];
536
+ a->on_menu(reinterpret_cast<const uint8_t *>(id.data()), id.size(),
537
+ a->on_menu_ctx);
538
+ }
539
+
540
+ // One shared target for every custom item; the tag says which one fired.
541
+ static id menu_target() {
542
+ static id instance = nullptr;
543
+ if (instance) return instance;
544
+ constexpr auto class_name = "JanelaMenuTarget";
545
+ // Registering the same class twice crashes, and this runs once per process
546
+ // anyway — the lookup keeps a reload honest.
547
+ Class cls = objc_lookUpClass(class_name);
548
+ if (!cls) {
549
+ cls = objc_allocateClassPair(
550
+ webview::detail::objc::get_class("NSObject"), class_name, 0);
551
+ class_addMethod(cls, webview::detail::objc::selector("janelaMenuAction:"),
552
+ (IMP)(+[](id, SEL, id sender) {
553
+ menu_clicked(webview::detail::objc::msg_send<long>(
554
+ sender, webview::detail::objc::selector("tag")));
555
+ }),
556
+ "v@:@");
557
+ objc_registerClassPair(cls);
558
+ }
559
+ instance = webview::detail::objc::msg_send<id>(
560
+ webview::detail::objc::msg_send<id>((id)cls,
561
+ webview::detail::objc::selector("alloc")),
562
+ webview::detail::objc::selector("init"));
563
+ return instance;
564
+ }
565
+
566
+ // Appends the host's submenus to the standard menu bar rather than replacing
567
+ // it: a custom menu must not be able to cost the app Cmd+Q and Cmd+V, which is
568
+ // what replacing the bar wholesale would do.
569
+ static int32_t apply_custom_menu(App *a, const std::string &spec) {
570
+ using namespace webview::detail;
571
+ objc::autoreleasepool arp;
572
+
573
+ id app = objc::msg_send<id>(objc::get_class("NSApplication"),
574
+ objc::selector("sharedApplication"));
575
+ id menubar = objc::msg_send<id>(app, objc::selector("mainMenu"));
576
+ if (!menubar) return -1;
577
+
578
+ // Drop whatever a previous call added, so setMenu is idempotent and can
579
+ // shrink the bar as well as grow it.
580
+ for (size_t n = objc::msg_send<NSUInteger>(menubar,
581
+ objc::selector("numberOfItems"));
582
+ n > a->std_menu_count; n--) {
583
+ objc::msg_send<void>(menubar, objc::selector("removeItemAtIndex:"),
584
+ static_cast<NSInteger>(n - 1));
585
+ }
586
+ a->menu_ids.clear();
587
+
588
+ std::vector<id> stack;
589
+ stack.push_back(menubar);
590
+
591
+ for (const std::string &line : split_on(spec, '\n')) {
592
+ if (line.empty()) continue;
593
+ std::vector<std::string> f = split_on(line, '\x1f');
594
+ const std::string &kind = f[0];
595
+
596
+ if (kind == "S" && f.size() >= 2) {
597
+ id holder = objc::msg_send<id>(
598
+ objc::msg_send<id>(objc::get_class("NSMenuItem"),
599
+ objc::selector("alloc")),
600
+ objc::selector("init"));
601
+ id menu = objc::msg_send<id>(
602
+ objc::msg_send<id>(objc::get_class("NSMenu"),
603
+ objc::selector("alloc")),
604
+ objc::selector("initWithTitle:"),
605
+ cocoa::NSString_stringWithUTF8String(f[1]));
606
+ objc::msg_send<void>(holder, objc::selector("setTitle:"),
607
+ cocoa::NSString_stringWithUTF8String(f[1]));
608
+ objc::msg_send<void>(holder, objc::selector("setSubmenu:"), menu);
609
+ objc::msg_send<void>(stack.back(), objc::selector("addItem:"), holder);
610
+ stack.push_back(menu);
611
+ } else if (kind == "E") {
612
+ if (stack.size() > 1) stack.pop_back();
613
+ } else if (kind == "-") {
614
+ if (stack.size() > 1) {
615
+ objc::msg_send<void>(
616
+ stack.back(), objc::selector("addItem:"),
617
+ objc::msg_send<id>(objc::get_class("NSMenuItem"),
618
+ objc::selector("separatorItem")));
619
+ }
620
+ } else if (kind == "I" && f.size() >= 5 && stack.size() > 1) {
621
+ a->menu_ids.push_back(f[1]);
622
+ long tag = static_cast<long>(a->menu_ids.size()) - 1;
623
+ id it = objc::msg_send<id>(
624
+ objc::msg_send<id>(objc::get_class("NSMenuItem"),
625
+ objc::selector("alloc")),
626
+ objc::selector("initWithTitle:action:keyEquivalent:"),
627
+ cocoa::NSString_stringWithUTF8String(f[2]),
628
+ objc::selector("janelaMenuAction:"),
629
+ cocoa::NSString_stringWithUTF8String(f[3]));
630
+ objc::msg_send<void>(it, objc::selector("setTarget:"), menu_target());
631
+ objc::msg_send<void>(it, objc::selector("setTag:"), tag);
632
+ // Always set the mask, including 0: AppKit's default for a key
633
+ // equivalent is Command, so leaving it alone would turn an accelerator
634
+ // with no modifiers into a Command shortcut.
635
+ NSUInteger mods = static_cast<NSUInteger>(strtoul(f[4].c_str(), nullptr, 10));
636
+ objc::msg_send<void>(it, objc::selector("setKeyEquivalentModifierMask:"),
637
+ mods);
638
+ objc::msg_send<void>(stack.back(), objc::selector("addItem:"), it);
639
+ }
640
+ }
641
+ g_menu_owner = -1;
642
+ for (int32_t i = 0; i < 8; i++) {
643
+ if (app_at(i) == a) { g_menu_owner = i; break; }
644
+ }
645
+ return 0;
646
+ }
647
+
361
648
  bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
362
649
  std::string &error) {
363
650
  (void)error;
@@ -608,6 +895,22 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
608
895
 
609
896
  #endif
610
897
 
898
+ #if !defined(__APPLE__)
899
+ // Windows and Linux need no menu for these shortcuts: Alt+F4 is a
900
+ // window-manager message the win32 backend already answers as WM_CLOSE, and
901
+ // the editing keys are handled inside WebView2 and WebKitGTK. A menu here
902
+ // would be a feature, not a fix.
903
+ static void install_main_menu() {}
904
+ static size_t standard_menu_count() { return 0; }
905
+
906
+ // Custom menus are macOS-only for now. Two things have to be solved first:
907
+ // webview.h's win32 loop never calls TranslateAcceleratorW, without which
908
+ // accelerators do not fire, and the GTK backend keeps both GTK 3 and GTK 4
909
+ // alive where GTK 4 removed GtkMenuBar. Reporting -1 lets the host say
910
+ // "unsupported here" rather than pretend it worked.
911
+ static int32_t apply_custom_menu(App *, const std::string &) { return -1; }
912
+ #endif
913
+
611
914
  // Runs on the UI thread with no TS frame beneath it — see the note on the job
612
915
  // pool above for why that matters.
613
916
  void dialog_on_ui_thread(webview_t, void *arg) {
@@ -728,6 +1031,10 @@ int32_t wv_create(int32_t debug) {
728
1031
  if (g_apps[i].used) continue;
729
1032
  webview_t w = webview_create(debug, nullptr);
730
1033
  if (!w) return -1;
1034
+ // After webview_create, which is what brings NSApplication into being.
1035
+ // Both are no-ops off macOS.
1036
+ install_main_menu();
1037
+ g_apps[i].std_menu_count = standard_menu_count();
731
1038
  // Field-wise reset: App holds a thread and atomics, so it is not
732
1039
  // copy-assignable from a temporary.
733
1040
  g_apps[i].binds.clear();
@@ -735,6 +1042,9 @@ int32_t wv_create(int32_t debug) {
735
1042
  g_apps[i].on_invoke_ctx = nullptr;
736
1043
  g_apps[i].on_timer = nullptr;
737
1044
  g_apps[i].on_timer_ctx = nullptr;
1045
+ g_apps[i].on_menu = nullptr;
1046
+ g_apps[i].on_menu_ctx = nullptr;
1047
+ g_apps[i].menu_ids.clear();
738
1048
  g_apps[i].req.clear();
739
1049
  g_apps[i].cur_id.clear();
740
1050
  g_apps[i].reply.clear();
@@ -1037,6 +1347,27 @@ int32_t wv_dialog(int32_t h, int32_t kind, int32_t flags, const uint8_t *tp,
1037
1347
 
1038
1348
  // ---- window control ---------------------------------------------------------
1039
1349
 
1350
+ // Renders the host's declarative menu. The spec is one row per line with 0x1f
1351
+ // between fields; the runtime flattens the tree, so nothing here parses JSON.
1352
+ // Returns -1 where custom menus are not supported yet (everything but macOS),
1353
+ // which the host reports rather than swallowing.
1354
+ int32_t wv_set_menu(int32_t h, const uint8_t *p, size_t n) {
1355
+ App *a = app_at(h);
1356
+ if (!a) return -1;
1357
+ return apply_custom_menu(a, to_str(p, n));
1358
+ }
1359
+
1360
+ // Retained, like wv_on_invoke: registered once and valid until the app exits.
1361
+ int32_t wv_on_menu(int32_t h,
1362
+ int32_t (*cb)(const uint8_t *, size_t, void *),
1363
+ void *ctx) {
1364
+ App *a = app_at(h);
1365
+ if (!a) return -1;
1366
+ a->on_menu = cb;
1367
+ a->on_menu_ctx = ctx;
1368
+ return 0;
1369
+ }
1370
+
1040
1371
  int32_t wv_set_fullscreen(int32_t h, int32_t on) {
1041
1372
  App *a = app_at(h);
1042
1373
  if (!a) return -1;