janela 0.16.1 → 0.17.1

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/janela.mjs CHANGED
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import {
23
23
  ANDROID_ABI, ANDROID_TARGET_SDK, androidConf, ffiManifest, iosConf,
24
24
  installCommand, libraryProfile, mimeFor, NAME_RE, packageManager as pmFor,
25
+ shimCacheKey,
25
26
  patchPeSubsystem, PeError, rewriteHostSpecifier, suggestName,
26
27
  } from "./lib.mjs";
27
28
 
@@ -730,10 +731,29 @@ function buildShim(cacheDir) {
730
731
  // part of an MSVC toolchain, and a lone object needs no archive index.
731
732
  const obj = join(cacheDir, win ? "wvshim.obj" : "wvshim.o");
732
733
  const lib = win ? obj : join(cacheDir, "libwvshim.a");
733
- if (existsSync(lib) && statSync(lib).mtimeMs > statSync(src).mtimeMs) return lib;
734
+ const inc = `-I${join(KIT, "vendor-webview", "core", "include")}`;
735
+
736
+ // Keyed on the shim's CONTENT, not its mtime.
737
+ //
738
+ // The mtime test this replaces was wrong in the one case that matters:
739
+ // upgrading janela. pnpm's store is content-addressed and hard-links files
740
+ // into node_modules with the STORE's mtime, which is older than a cached
741
+ // object built minutes earlier from the previous version — so the cache
742
+ // looked fresh and a stale archive was reused. The symptom is a link failure
743
+ // naming symbols the new shim exports and the old object does not
744
+ // ("Undefined symbols: _wv_set_menu, _wv_on_menu"), which reads as a broken
745
+ // install rather than a stale cache.
746
+ //
747
+ // The version is in the key too: a shim that happens to be byte-identical
748
+ // across versions still gets recompiled if anything else about the build
749
+ // moved, which is cheap insurance against the next variant of this.
750
+ const key = shimCacheKey(readFileSync(src), VERSION, process.platform, inc);
751
+ const keyFile = join(cacheDir, "wvshim.key");
752
+ if (existsSync(lib) && existsSync(keyFile) && readFileSync(keyFile, "utf8") === key) {
753
+ return lib;
754
+ }
734
755
 
735
756
  console.log("janela: compiling webview shim");
736
- const inc = `-I${join(KIT, "vendor-webview", "core", "include")}`;
737
757
  if (win) {
738
758
  console.log(`janela: building for ${winCcOrFail()}`);
739
759
  run([
@@ -743,6 +763,7 @@ function buildShim(cacheDir) {
743
763
  `-I${join(KIT, "vendor-webview", "compatibility", "mingw", "include")}`,
744
764
  "-DWIN32_LEAN_AND_MEAN", "-D_WIN32_WINNT=0x0601",
745
765
  ]);
766
+ writeFileSync(keyFile, key);
746
767
  return lib;
747
768
  }
748
769
  if (process.platform === "darwin") {
@@ -752,6 +773,7 @@ function buildShim(cacheDir) {
752
773
  run(["g++", "-c", src, "-o", obj, "-std=c++17", "-O2", inc, ...cflags]);
753
774
  }
754
775
  run(["ar", "rcs", lib, obj]);
776
+ writeFileSync(keyFile, key);
755
777
  return lib;
756
778
  }
757
779
 
package/bin/lib.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  /**
2
3
  * Pure helpers shared by the CLI and its tests.
3
4
  *
@@ -223,6 +224,18 @@ export function ffiManifest(shimLib, { platform = process.platform, macSdkPath =
223
224
  returns: "i32",
224
225
  },
225
226
  { name: "wvSetFullscreen", symbol: "wv_set_fullscreen", params: ["i32", "i32"], returns: "i32" },
227
+ STR("wvSetMenu", "wv_set_menu"),
228
+ // Retained like wvOnInvoke: registered once, valid until the app exits.
229
+ // The clicked item's id rides in as a `string` param (format 3).
230
+ {
231
+ name: "wvOnMenu", symbol: "wv_on_menu",
232
+ params: [
233
+ "i32",
234
+ { callback: { id: "menu", params: ["string", { context: "menu" }], returns: "i32", lifetime: "retained" } },
235
+ { context: "menu" },
236
+ ],
237
+ returns: "i32",
238
+ },
226
239
  ];
227
240
 
228
241
  if (platform === "win32") {
@@ -372,3 +385,24 @@ export function installCommand(pm) {
372
385
  if (pm === "npm") return ["npm", "install", "--no-fund", "--no-audit"];
373
386
  return [pm, "install"];
374
387
  }
388
+
389
+ /**
390
+ * The cache key for the compiled webview shim.
391
+ *
392
+ * Keyed on the shim's CONTENT, not its mtime. The mtime test this replaced was
393
+ * wrong in the one case that matters — upgrading janela: pnpm's store is
394
+ * content-addressed and hard-links files into node_modules carrying the
395
+ * STORE's mtime, which is older than an object compiled minutes earlier from
396
+ * the previous version. The cache then looked fresh, the stale archive was
397
+ * reused, and the link failed naming symbols the new shim exports and the old
398
+ * object does not.
399
+ *
400
+ * `version` is in the key so a shim that happens to be byte-identical across
401
+ * releases still recompiles when anything else about the build moved.
402
+ */
403
+ export function shimCacheKey(source, version, platform, includeFlag) {
404
+ return createHash("sha256")
405
+ .update(source)
406
+ .update(`\u0000${version}\u0000${platform}\u0000${includeFlag}`)
407
+ .digest("hex");
408
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "janela",
3
- "version": "0.16.1",
3
+ "version": "0.17.1",
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
@@ -489,6 +498,153 @@ static void install_main_menu() {
489
498
  objc::msg_send<void>(app, objc::selector("setWindowsMenu:"), window);
490
499
  }
491
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
+
492
648
  bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
493
649
  std::string &error) {
494
650
  (void)error;
@@ -745,6 +901,14 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
745
901
  // the editing keys are handled inside WebView2 and WebKitGTK. A menu here
746
902
  // would be a feature, not a fix.
747
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; }
748
912
  #endif
749
913
 
750
914
  // Runs on the UI thread with no TS frame beneath it — see the note on the job
@@ -868,8 +1032,9 @@ int32_t wv_create(int32_t debug) {
868
1032
  webview_t w = webview_create(debug, nullptr);
869
1033
  if (!w) return -1;
870
1034
  // After webview_create, which is what brings NSApplication into being.
871
- // A no-op off macOS.
1035
+ // Both are no-ops off macOS.
872
1036
  install_main_menu();
1037
+ g_apps[i].std_menu_count = standard_menu_count();
873
1038
  // Field-wise reset: App holds a thread and atomics, so it is not
874
1039
  // copy-assignable from a temporary.
875
1040
  g_apps[i].binds.clear();
@@ -877,6 +1042,9 @@ int32_t wv_create(int32_t debug) {
877
1042
  g_apps[i].on_invoke_ctx = nullptr;
878
1043
  g_apps[i].on_timer = nullptr;
879
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();
880
1048
  g_apps[i].req.clear();
881
1049
  g_apps[i].cur_id.clear();
882
1050
  g_apps[i].reply.clear();
@@ -1179,6 +1347,27 @@ int32_t wv_dialog(int32_t h, int32_t kind, int32_t flags, const uint8_t *tp,
1179
1347
 
1180
1348
  // ---- window control ---------------------------------------------------------
1181
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
+
1182
1371
  int32_t wv_set_fullscreen(int32_t h, int32_t on) {
1183
1372
  App *a = app_at(h);
1184
1373
  if (!a) return -1;