janela 0.18.1 → 0.19.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/shim/wvshim.cc CHANGED
@@ -22,10 +22,12 @@
22
22
 
23
23
  #include <algorithm>
24
24
  #include <atomic>
25
+ #include <cctype>
25
26
  #include <chrono>
26
27
  #include <condition_variable>
27
28
  #include <cstdint>
28
29
  #include <cstdio>
30
+ #include <cstdlib>
29
31
  #include <cstring>
30
32
  #include <filesystem>
31
33
  #include <fstream>
@@ -40,8 +42,25 @@
40
42
  // Win32; these add only what it does not use itself.
41
43
  #if defined(_WIN32)
42
44
  #include <commdlg.h>
45
+ // ShellAboutW: the standard Windows About box, which is what `predefined.about`
46
+ // should show here for the same reason macOS shows NSApplication's.
47
+ #include <shellapi.h>
43
48
  #elif !defined(__APPLE__)
44
49
  #include <gtk/gtk.h>
50
+ // The editing actions (Copy, Paste, Undo) are WebKit's, not GTK's, and this is
51
+ // the only header that declares them. It costs nothing to include: the build
52
+ // already compiles and links against WebKitGTK for the backend itself.
53
+ //
54
+ // Which header depends on the GTK version, because webkit2gtk-4.1 IS the GTK 3
55
+ // build — the GTK 4 one is a different API version under a different name. The
56
+ // build pins gtk+-3.0 today; this split mirrors the vendored backend's own so
57
+ // that a GTK 4 attempt fails in the renderer, where the reason is written
58
+ // down, rather than on an include line.
59
+ #if GTK_MAJOR_VERSION >= 4
60
+ #include <webkit/webkit.h>
61
+ #else
62
+ #include <webkit2/webkit2.h>
63
+ #endif
45
64
  #endif
46
65
 
47
66
  namespace {
@@ -89,9 +108,10 @@ struct App {
89
108
  // `id` is Objective-C, so naming it here breaks the Linux and Windows
90
109
  // builds. The Apple code casts on the way in and out.
91
110
  std::vector<void *> menu_items;
92
- // How many submenus the standard bar installed, so setMenu appends after
93
- // them and can remove only its own on a later call.
94
- size_t std_menu_count = 0;
111
+ // Whether the host ever called setMenu. If it never does, wv_run installs
112
+ // the whole standard bar, so an app that says nothing about menus still gets
113
+ // Cmd+Q and Cmd+V.
114
+ bool menu_set = false;
95
115
 
96
116
  // Staging for the in-flight request.
97
117
  std::string req; // JSON args array from JS
@@ -373,6 +393,54 @@ std::string json_array(const std::vector<std::string> &items) {
373
393
  bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
374
394
  std::string &error);
375
395
 
396
+ // ---- menus: the part that is the same everywhere ---------------------------
397
+ //
398
+ // Three renderers sit below this: AppKit, Win32 and GTK. They agree on the
399
+ // wire format (see the note above apply_custom_menu) and on who owns a click.
400
+ //
401
+ // Ownership is a real question because the callback is per-app while the menu
402
+ // is not: on macOS the menu bar is process-global, on Windows and Linux it
403
+ // belongs to a window. The rule that works for both is the same one — the
404
+ // last app to call setMenu owns the clicks — so it lives here rather than
405
+ // three times below.
406
+ static int32_t g_menu_owner = -1;
407
+
408
+ static void menu_clicked(long tag) {
409
+ App *a = g_menu_owner >= 0 ? app_at(g_menu_owner) : nullptr;
410
+ if (!a || !a->on_menu) return;
411
+ a->on_menu(static_cast<int32_t>(tag), a->on_menu_ctx);
412
+ }
413
+
414
+ static void claim_menu_owner(App *a) {
415
+ g_menu_owner = -1;
416
+ for (int32_t i = 0; i < 8; i++) {
417
+ if (app_at(i) == a) { g_menu_owner = i; break; }
418
+ }
419
+ }
420
+
421
+ // The app a handle-free call acts on.
422
+ //
423
+ // wv_perform_action takes no handle because on macOS it needs none: the
424
+ // responder chain and the key window are process state. Win32 and GTK do need
425
+ // a window, and `predefined.copy()` is imported as a bare function with no app
426
+ // in scope, so the shim resolves it here. janela opens one window, which is
427
+ // what makes "the app" unambiguous rather than a guess; the menu owner is
428
+ // preferred so a two-window future picks the one the user is driving.
429
+ // Fullscreen is both a window API (app.setFullscreen) and a menu action
430
+ // (predefined.fullscreen), and the action has to toggle, so it needs to read
431
+ // the state as well as set it. Declared here, defined by each renderer.
432
+ static int32_t platform_set_fullscreen(void *win, bool on);
433
+ static bool platform_is_fullscreen(void *win);
434
+
435
+ static App *current_app() {
436
+ App *a = g_menu_owner >= 0 ? app_at(g_menu_owner) : nullptr;
437
+ if (a) return a;
438
+ for (int32_t i = 0; i < 8; i++) {
439
+ if (g_apps[i].used) return &g_apps[i];
440
+ }
441
+ return nullptr;
442
+ }
443
+
376
444
  #if defined(__APPLE__)
377
445
 
378
446
  // A standard macOS main menu.
@@ -392,34 +460,21 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
392
460
  // chain, so there is nothing to call back into TypeScript for and no new FFI
393
461
  // surface. WKWebView answers the editing ones itself. Custom menus — a
394
462
  // declarative table passed down from the host — are a separate feature.
395
- static void install_main_menu() {
463
+ static NSUInteger cocoa_mods(unsigned long symbolic);
464
+ static void add_app_submenu(id menubar, const std::string &name);
465
+ static void add_standard_editing_menus(id menubar);
466
+
467
+ // The application submenu, which AppKit requires be FIRST: it turns the main
468
+ // menu's first item into the app menu whatever that item is titled. A host
469
+ // menu starting with "File" would otherwise be rendered as the app menu and
470
+ // labelled with the bundle name, so this always goes in front of whatever the
471
+ // host asked for.
472
+ static id build_menubar_with_app_menu() {
396
473
  using namespace webview::detail;
397
- objc::autoreleasepool arp;
398
-
399
474
  id app = objc::msg_send<id>(objc::get_class("NSApplication"),
400
475
  objc::selector("sharedApplication"));
401
- if (!app) return;
402
- // Idempotent: an embedder that already installed a menu keeps it.
403
- if (objc::msg_send<id>(app, objc::selector("mainMenu"))) return;
476
+ if (!app) return nullptr;
404
477
 
405
- // NSEventModifierFlags. The default for a key equivalent is Command alone,
406
- // so only the combinations need naming.
407
- const NSUInteger kShift = 1UL << 17;
408
- const NSUInteger kControl = 1UL << 18;
409
- const NSUInteger kOption = 1UL << 19;
410
- const NSUInteger kCommand = 1UL << 20;
411
-
412
- auto str = [](const std::string &v) {
413
- return cocoa::NSString_stringWithUTF8String(v);
414
- };
415
- auto alloc_init = [](const char *cls) {
416
- return objc::msg_send<id>(
417
- objc::msg_send<id>(objc::get_class(cls), objc::selector("alloc")),
418
- objc::selector("init"));
419
- };
420
-
421
- // AppKit labels the first submenu from the bundle, not from this title, but
422
- // "About <name>" and "Quit <name>" are ours to spell.
423
478
  id process = objc::msg_send<id>(objc::get_class("NSProcessInfo"),
424
479
  objc::selector("processInfo"));
425
480
  const char *raw =
@@ -427,96 +482,128 @@ static void install_main_menu() {
427
482
  objc::msg_send<id>(process, objc::selector("processName")),
428
483
  objc::selector("UTF8String"))
429
484
  : nullptr;
430
- std::string name = raw ? raw : "App";
431
-
432
- id menubar = alloc_init("NSMenu");
433
-
434
- auto submenu = [&](const std::string &title) {
435
- id holder = alloc_init("NSMenuItem");
436
- id menu = objc::msg_send<id>(
437
- objc::msg_send<id>(objc::get_class("NSMenu"), objc::selector("alloc")),
438
- objc::selector("initWithTitle:"), str(title));
439
- objc::msg_send<void>(holder, objc::selector("setSubmenu:"), menu);
440
- objc::msg_send<void>(menubar, objc::selector("addItem:"), holder);
441
- return menu;
442
- };
485
+ id menubar = objc::msg_send<id>(
486
+ objc::msg_send<id>(objc::get_class("NSMenu"), objc::selector("alloc")),
487
+ objc::selector("init"));
488
+ add_app_submenu(menubar, raw ? raw : "App");
489
+ return menubar;
490
+ }
443
491
 
444
- auto item = [&](id menu, const std::string &title, const char *sel,
445
- const std::string &key, NSUInteger mods) {
446
- id it = objc::msg_send<id>(
447
- objc::msg_send<id>(objc::get_class("NSMenuItem"),
448
- objc::selector("alloc")),
449
- objc::selector("initWithTitle:action:keyEquivalent:"), str(title),
450
- sel ? objc::selector(sel) : nullptr, str(key));
451
- if (mods) {
452
- objc::msg_send<void>(
453
- it, objc::selector("setKeyEquivalentModifierMask:"), mods);
454
- }
455
- objc::msg_send<void>(menu, objc::selector("addItem:"), it);
456
- };
457
- auto separator = [&](id menu) {
458
- objc::msg_send<void>(menu, objc::selector("addItem:"),
459
- objc::msg_send<id>(objc::get_class("NSMenuItem"),
460
- objc::selector("separatorItem")));
461
- };
492
+ // The whole standard bar. Installed only when the host never calls setMenu —
493
+ // a scratch app still gets Cmd+Q and Cmd+V, while an app that sets its own
494
+ // menu owns the bar beyond the app submenu.
495
+ static void install_main_menu() {
496
+ using namespace webview::detail;
497
+ objc::autoreleasepool arp;
462
498
 
463
- id app_menu = submenu(name);
464
- item(app_menu, "About " + name, "orderFrontStandardAboutPanel:", "", 0);
465
- separator(app_menu);
466
- item(app_menu, "Hide " + name, "hide:", "h", 0);
467
- item(app_menu, "Hide Others", "hideOtherApplications:", "h",
468
- kOption | kCommand);
469
- item(app_menu, "Show All", "unhideAllApplications:", "", 0);
470
- separator(app_menu);
471
- // performClose:, not terminate: — measured, not assumed. Both quit and
472
- // both exit 0, but terminate: exits the process itself, so the host never
473
- // returns from wv_run and anything after app.run() is skipped;
474
- // performClose: goes through the same window-close path the red button
475
- // uses, the run loop unwinds, and the host prints its own "run returned 0".
476
- // One shutdown path instead of two. muda picks terminate: because Tauri's
477
- // app logic is native and multi-window; janela is single-window, so closing
478
- // the window IS quitting. If janela ever grows multiple windows this has to
479
- // become a real Quit that closes all of them.
480
- //
481
- // (An earlier note here claimed performClose: did not fire at all. That was
482
- // a bad test: posted key events go to the FRONTMOST app, and the app was not
483
- // active. With it activated first, both actions work.)
484
- item(app_menu, "Quit " + name, "performClose:", "q", 0);
485
-
486
- id edit = submenu("Edit");
487
- item(edit, "Undo", "undo:", "z", 0);
488
- item(edit, "Redo", "redo:", "z", kShift | kCommand);
489
- separator(edit);
490
- item(edit, "Cut", "cut:", "x", 0);
491
- item(edit, "Copy", "copy:", "c", 0);
492
- item(edit, "Paste", "paste:", "v", 0);
493
- item(edit, "Select All", "selectAll:", "a", 0);
494
-
495
- id view = submenu("View");
496
- item(view, "Enter Full Screen", "toggleFullScreen:", "f",
497
- kControl | kCommand);
498
-
499
- id window = submenu("Window");
500
- item(window, "Minimize", "performMiniaturize:", "m", 0);
501
- item(window, "Close", "performClose:", "w", 0);
499
+ id app = objc::msg_send<id>(objc::get_class("NSApplication"),
500
+ objc::selector("sharedApplication"));
501
+ if (!app) return;
502
+ if (objc::msg_send<id>(app, objc::selector("mainMenu"))) return;
502
503
 
504
+ id menubar = build_menubar_with_app_menu();
505
+ if (!menubar) return;
506
+ add_standard_editing_menus(menubar);
503
507
  objc::msg_send<void>(app, objc::selector("setMainMenu:"), menubar);
504
- // Lets AppKit add the standard Window-menu bookkeeping (window list,
505
- // Bring All to Front).
506
- objc::msg_send<void>(app, objc::selector("setWindowsMenu:"), window);
507
508
  }
508
509
 
509
- // How many submenus the standard bar has; custom ones are appended after these
510
- // and only those are removed on a later setMenu.
511
- static size_t standard_menu_count() {
510
+ // Shared by both paths: a submenu of items wired to standard selectors.
511
+ static void menu_add(id menu, const std::string &title, const char *sel,
512
+ const std::string &key, unsigned long symbolic) {
512
513
  using namespace webview::detail;
513
- objc::autoreleasepool arp;
514
- id app = objc::msg_send<id>(objc::get_class("NSApplication"),
515
- objc::selector("sharedApplication"));
516
- id menubar = app ? objc::msg_send<id>(app, objc::selector("mainMenu")) : nullptr;
517
- if (!menubar) return 0;
518
- return static_cast<size_t>(
519
- objc::msg_send<NSUInteger>(menubar, objc::selector("numberOfItems")));
514
+ id it = objc::msg_send<id>(
515
+ objc::msg_send<id>(objc::get_class("NSMenuItem"), objc::selector("alloc")),
516
+ objc::selector("initWithTitle:action:keyEquivalent:"),
517
+ cocoa::NSString_stringWithUTF8String(title),
518
+ sel ? objc::selector(sel) : nullptr,
519
+ cocoa::NSString_stringWithUTF8String(key));
520
+ objc::msg_send<void>(it, objc::selector("setKeyEquivalentModifierMask:"),
521
+ cocoa_mods(symbolic));
522
+ objc::msg_send<void>(menu, objc::selector("addItem:"), it);
523
+ }
524
+
525
+ static void menu_add_separator(id menu) {
526
+ using namespace webview::detail;
527
+ objc::msg_send<void>(menu, objc::selector("addItem:"),
528
+ objc::msg_send<id>(objc::get_class("NSMenuItem"),
529
+ objc::selector("separatorItem")));
530
+ }
531
+
532
+ static id menu_add_submenu(id menubar, const std::string &title) {
533
+ using namespace webview::detail;
534
+ id holder = objc::msg_send<id>(
535
+ objc::msg_send<id>(objc::get_class("NSMenuItem"), objc::selector("alloc")),
536
+ objc::selector("init"));
537
+ id menu = objc::msg_send<id>(
538
+ objc::msg_send<id>(objc::get_class("NSMenu"), objc::selector("alloc")),
539
+ objc::selector("initWithTitle:"),
540
+ cocoa::NSString_stringWithUTF8String(title));
541
+ objc::msg_send<void>(holder, objc::selector("setTitle:"),
542
+ cocoa::NSString_stringWithUTF8String(title));
543
+ objc::msg_send<void>(holder, objc::selector("setSubmenu:"), menu);
544
+ objc::msg_send<void>(menubar, objc::selector("addItem:"), holder);
545
+ return menu;
546
+ }
547
+
548
+ static void add_app_submenu(id menubar, const std::string &name) {
549
+ id m = menu_add_submenu(menubar, name);
550
+ menu_add(m, "About " + name, "orderFrontStandardAboutPanel:", "", 0);
551
+ menu_add_separator(m);
552
+ menu_add(m, "Hide " + name, "hide:", "h", 1);
553
+ menu_add(m, "Hide Others", "hideOtherApplications:", "h", 1 | 4);
554
+ menu_add(m, "Show All", "unhideAllApplications:", "", 0);
555
+ menu_add_separator(m);
556
+ // performClose:, not terminate: — measured: terminate: exits the process
557
+ // itself so the host never returns from wv_run, while performClose: unwinds
558
+ // through the window-close path the red button uses.
559
+ menu_add(m, "Quit " + name, "performClose:", "q", 1);
560
+ }
561
+
562
+ // True if the bar already has a submenu with this title — the host declaring
563
+ // its own "Edit" must not get two.
564
+ static bool menubar_has(id menubar, const std::string &title) {
565
+ using namespace webview::detail;
566
+ NSUInteger n = objc::msg_send<NSUInteger>(menubar,
567
+ objc::selector("numberOfItems"));
568
+ for (NSUInteger i = 0; i < n; i++) {
569
+ id item = objc::msg_send<id>(menubar, objc::selector("itemAtIndex:"), i);
570
+ const char *got = cocoa::NSString_get_UTF8String(
571
+ objc::msg_send<id>(item, objc::selector("title")));
572
+ if (got && title == got) return true;
573
+ }
574
+ return false;
575
+ }
576
+
577
+ // Each of the three is filled in only if the host has not declared one under
578
+ // the same title. Guarded SEPARATELY, and not with an early return: a host
579
+ // that declares its own Edit would otherwise silently lose View and Window
580
+ // too — measured, before this was three checks instead of one.
581
+ static void add_standard_editing_menus(id menubar) {
582
+ using namespace webview::detail;
583
+ if (!menubar_has(menubar, "Edit")) {
584
+ id edit = menu_add_submenu(menubar, "Edit");
585
+ menu_add(edit, "Undo", "undo:", "z", 1);
586
+ menu_add(edit, "Redo", "redo:", "z", 1 | 2);
587
+ menu_add_separator(edit);
588
+ menu_add(edit, "Cut", "cut:", "x", 1);
589
+ menu_add(edit, "Copy", "copy:", "c", 1);
590
+ menu_add(edit, "Paste", "paste:", "v", 1);
591
+ menu_add(edit, "Select All", "selectAll:", "a", 1);
592
+ }
593
+
594
+ if (!menubar_has(menubar, "View")) {
595
+ id view = menu_add_submenu(menubar, "View");
596
+ menu_add(view, "Enter Full Screen", "toggleFullScreen:", "f", 1 | 8);
597
+ }
598
+
599
+ if (!menubar_has(menubar, "Window")) {
600
+ id window = menu_add_submenu(menubar, "Window");
601
+ menu_add(window, "Minimize", "performMiniaturize:", "m", 1);
602
+ menu_add(window, "Close", "performClose:", "w", 1);
603
+ id app = objc::msg_send<id>(objc::get_class("NSApplication"),
604
+ objc::selector("sharedApplication"));
605
+ objc::msg_send<void>(app, objc::selector("setWindowsMenu:"), window);
606
+ }
520
607
  }
521
608
 
522
609
  // ---- custom menus ------------------------------------------------------
@@ -527,20 +614,12 @@ static size_t standard_menu_count() {
527
614
  //
528
615
  // S<US>Label open a submenu
529
616
  // E close it
530
- // I<US>tag<US>Label<US>key<US>mods an item
617
+ // I<US>tag<US>Label<US>key<US>mods<US>enabled<US>checked<US>checkable
531
618
  // - a separator
532
619
  //
533
- // A click sends the item's tag back, which indexes App::menu_ids, and the id
534
- // string goes up to TypeScript on the retained on_menu callback — the same
535
- // shape as on_invoke. The menu bar is process-global on macOS, so exactly one
536
- // app owns it at a time.
537
- static int32_t g_menu_owner = -1;
538
-
539
- static void menu_clicked(long tag) {
540
- App *a = g_menu_owner >= 0 ? app_at(g_menu_owner) : nullptr;
541
- if (!a || !a->on_menu) return;
542
- a->on_menu(static_cast<int32_t>(tag), a->on_menu_ctx);
543
- }
620
+ // A click sends the item's tag back, which indexes the host's handler
621
+ // registry, and the tag goes up to TypeScript on the retained on_menu
622
+ // callback — the same shape as on_invoke.
544
623
 
545
624
  // One shared target for every custom item; the tag says which one fired.
546
625
  static id menu_target() {
@@ -568,6 +647,103 @@ static id menu_target() {
568
647
  return instance;
569
648
  }
570
649
 
650
+ // The platform's own menu items, by the host's action name.
651
+ //
652
+ // These carry a SELECTOR rather than a callback: Paste has to travel up the
653
+ // responder chain for WKWebView to handle it, so the behaviour is AppKit's and
654
+ // the click never reaches TypeScript. An unknown action renders nothing rather
655
+ // than a dead item.
656
+ struct Predefined {
657
+ const char *action;
658
+ const char *title;
659
+ const char *selector;
660
+ const char *key;
661
+ unsigned long mods; // symbolic, as the host sends them
662
+ };
663
+
664
+ static const Predefined *predefined_for(const std::string &action) {
665
+ static const Predefined table[] = {
666
+ {"about", "About", "orderFrontStandardAboutPanel:", "", 0},
667
+ {"services", "Services", nullptr, "", 0},
668
+ {"hide", "Hide", "hide:", "h", 1},
669
+ {"hideOthers", "Hide Others", "hideOtherApplications:", "h", 1 | 4},
670
+ {"showAll", "Show All", "unhideAllApplications:", "", 0},
671
+ // performClose:, not terminate: — see the note on the standard menu.
672
+ {"quit", "Quit", "performClose:", "q", 1},
673
+ {"undo", "Undo", "undo:", "z", 1},
674
+ {"redo", "Redo", "redo:", "z", 1 | 2},
675
+ {"cut", "Cut", "cut:", "x", 1},
676
+ {"copy", "Copy", "copy:", "c", 1},
677
+ {"paste", "Paste", "paste:", "v", 1},
678
+ {"selectAll", "Select All", "selectAll:", "a", 1},
679
+ {"minimize", "Minimize", "performMiniaturize:", "m", 1},
680
+ {"zoom", "Zoom", "performZoom:", "", 0},
681
+ {"fullscreen", "Enter Full Screen", "toggleFullScreen:", "f", 1 | 8},
682
+ {"closeWindow", "Close Window", "performClose:", "w", 1},
683
+ };
684
+ for (const Predefined &p : table) {
685
+ if (action == p.action) return &p;
686
+ }
687
+ return nullptr;
688
+ }
689
+
690
+ // Perform a platform action right now, from wherever the host calls it.
691
+ //
692
+ // The interesting case is copy/paste/undo: those are not "do this" but a
693
+ // SELECTOR sent up the responder chain, and by the time a TypeScript handler
694
+ // runs the menu click has already been delivered to us. sendAction:to:nil
695
+ // walks the chain at this moment instead, which is what lets it still reach
696
+ // the WKWebView's editing context.
697
+ static int32_t perform_action(const std::string &action) {
698
+ using namespace webview::detail;
699
+ objc::autoreleasepool arp;
700
+ const Predefined *pd = predefined_for(action);
701
+ if (!pd || !pd->selector) return -1;
702
+ id app = objc::msg_send<id>(objc::get_class("NSApplication"),
703
+ objc::selector("sharedApplication"));
704
+ if (!app) return -1;
705
+
706
+ // to:nil walks the responder chain from the key window's first responder,
707
+ // which is how this reaches the WKWebView's editing context.
708
+ if (objc::msg_send<bool>(app, objc::selector("sendAction:to:from:"),
709
+ objc::selector(pd->selector), nullptr, app)) {
710
+ return 0;
711
+ }
712
+
713
+ // Falls back to addressing the first responder directly. Observed once, on
714
+ // the very first launch: the window had not become key yet, so the chain
715
+ // walk above found nobody. Three runs in a row succeed on the first path
716
+ // once the app is settled, so this is a belt for the startup edge.
717
+ id key_window = objc::msg_send<id>(app, objc::selector("keyWindow"));
718
+ id responder = key_window ? objc::msg_send<id>(
719
+ key_window, objc::selector("firstResponder"))
720
+ : nullptr;
721
+ if (responder &&
722
+ objc::msg_send<bool>(app, objc::selector("sendAction:to:from:"),
723
+ objc::selector(pd->selector), responder, app)) {
724
+ return 0;
725
+ }
726
+ return -1;
727
+ }
728
+
729
+
730
+ // The host sends modifiers symbolically; this is the AppKit half of the map.
731
+ // MOD_PRIMARY is Command here — on Windows and Linux the same bit becomes
732
+ // Control, which is the whole point of sending intent rather than constants.
733
+ static NSUInteger cocoa_mods(unsigned long symbolic) {
734
+ const NSUInteger kShift = 1UL << 17;
735
+ const NSUInteger kControl = 1UL << 18;
736
+ const NSUInteger kOption = 1UL << 19;
737
+ const NSUInteger kCommand = 1UL << 20;
738
+ NSUInteger out = 0;
739
+ if (symbolic & 1) out |= kCommand; // primary
740
+ if (symbolic & 2) out |= kShift;
741
+ if (symbolic & 4) out |= kOption;
742
+ if (symbolic & 8) out |= kControl;
743
+ if (symbolic & 16) out |= kCommand;
744
+ return out;
745
+ }
746
+
571
747
  // Retains the item under its tag so a later setEnabled/setChecked/setLabel can
572
748
  // find it. The vector is sized to fit rather than pushed, because the host's
573
749
  // tags are registry indices and need not arrive in order.
@@ -592,17 +768,18 @@ static int32_t apply_custom_menu(App *a, const std::string &spec) {
592
768
 
593
769
  id app = objc::msg_send<id>(objc::get_class("NSApplication"),
594
770
  objc::selector("sharedApplication"));
595
- id menubar = objc::msg_send<id>(app, objc::selector("mainMenu"));
771
+ if (!app) return -1;
772
+
773
+ // A fresh bar every time: the host owns what it declared, so setMenu
774
+ // replaces rather than appends and the menu can shrink as well as grow.
775
+ //
776
+ // The application submenu is prepended regardless, because AppKit turns the
777
+ // main menu's FIRST item into the app menu whatever it is titled — a host
778
+ // menu starting with "File" would otherwise be rendered as the app menu and
779
+ // labelled with the bundle name. It is also the floor under Cmd+Q.
780
+ id menubar = build_menubar_with_app_menu();
596
781
  if (!menubar) return -1;
597
782
 
598
- // Drop whatever a previous call added, so setMenu is idempotent and can
599
- // shrink the bar as well as grow it.
600
- for (size_t n = objc::msg_send<NSUInteger>(menubar,
601
- objc::selector("numberOfItems"));
602
- n > a->std_menu_count; n--) {
603
- objc::msg_send<void>(menubar, objc::selector("removeItemAtIndex:"),
604
- static_cast<NSInteger>(n - 1));
605
- }
606
783
  for (void *old_item : a->menu_items) {
607
784
  if (old_item) objc::release(static_cast<id>(old_item));
608
785
  }
@@ -661,9 +838,8 @@ static int32_t apply_custom_menu(App *a, const std::string &spec) {
661
838
  // Always set the mask, including 0: AppKit's default for a key
662
839
  // equivalent is Command, so leaving it alone would turn an accelerator
663
840
  // with no modifiers into a Command shortcut.
664
- NSUInteger mods = static_cast<NSUInteger>(strtoul(f[4].c_str(), nullptr, 10));
665
841
  objc::msg_send<void>(it, objc::selector("setKeyEquivalentModifierMask:"),
666
- mods);
842
+ cocoa_mods(strtoul(f[4].c_str(), nullptr, 10)));
667
843
  objc::msg_send<void>(it, objc::selector("setEnabled:"), f[5] == "1");
668
844
  // f[7] says the item is checkable. AppKit lets any item carry a state,
669
845
  // so it is unused here — it is on the wire for the GTK renderer, which
@@ -675,10 +851,50 @@ static int32_t apply_custom_menu(App *a, const std::string &spec) {
675
851
  }
676
852
  }
677
853
 
678
- g_menu_owner = -1;
679
- for (int32_t i = 0; i < 8; i++) {
680
- if (app_at(i) == a) { g_menu_owner = i; break; }
681
- }
854
+ // The floor goes back on AFTER the host's menus, which also puts them in
855
+ // macOS's conventional order: App, File, …, Edit, View, Window. Without this
856
+ // any setMenu at all would cost the app ⌘C and ⌘V — the exact bug the
857
+ // standard bar exists to prevent, reintroduced by the feature that was
858
+ // supposed to build on it. A host that declares its own Edit, View or Window
859
+ // keeps it; only the missing ones are filled in.
860
+ add_standard_editing_menus(menubar);
861
+
862
+ objc::msg_send<void>(app, objc::selector("setMainMenu:"), menubar);
863
+ a->menu_set = true;
864
+ claim_menu_owner(a);
865
+ return 0;
866
+ }
867
+
868
+ // The live-item setters. Each takes the host's tag, which is a registry index
869
+ // in TypeScript, and -1 for an unknown one rather than silence — a stale
870
+ // handle should be visible, not a no-op.
871
+ static int32_t menu_set_enabled(App *a, int32_t tag, int32_t on) {
872
+ using namespace webview::detail;
873
+ objc::autoreleasepool arp;
874
+ id it = item_at(a, tag);
875
+ if (!it) return -1;
876
+ objc::msg_send<void>(it, objc::selector("setEnabled:"), on != 0);
877
+ return 0;
878
+ }
879
+
880
+ static int32_t menu_set_checked(App *a, int32_t tag, int32_t on) {
881
+ using namespace webview::detail;
882
+ objc::autoreleasepool arp;
883
+ id it = item_at(a, tag);
884
+ if (!it) return -1;
885
+ // NSControlStateValueOn / Off.
886
+ objc::msg_send<void>(it, objc::selector("setState:"),
887
+ static_cast<NSInteger>(on != 0 ? 1 : 0));
888
+ return 0;
889
+ }
890
+
891
+ static int32_t menu_set_label(App *a, int32_t tag, const std::string &label) {
892
+ using namespace webview::detail;
893
+ objc::autoreleasepool arp;
894
+ id it = item_at(a, tag);
895
+ if (!it) return -1;
896
+ objc::msg_send<void>(it, objc::selector("setTitle:"),
897
+ cocoa::NSString_stringWithUTF8String(label));
682
898
  return 0;
683
899
  }
684
900
 
@@ -763,6 +979,41 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
763
979
  return !out.empty();
764
980
  }
765
981
 
982
+
983
+ // Releases the retained items. The menu bar itself belongs to NSApplication,
984
+ // which replaces it wholesale on the next setMenu.
985
+ static void menu_teardown(App *a) {
986
+ using namespace webview::detail;
987
+ for (void *it : a->menu_items) {
988
+ if (it) objc::release(static_cast<id>(it));
989
+ }
990
+ a->menu_items.clear();
991
+ }
992
+
993
+ // AppKit parents the webview eagerly, so nothing here has to wait for run.
994
+ static void menu_realize(App *) {}
995
+
996
+ static bool platform_is_fullscreen(void *win) {
997
+ using namespace webview::detail;
998
+ objc::autoreleasepool arp;
999
+ // NSWindowStyleMaskFullScreen.
1000
+ const NSUInteger full = 1UL << 14;
1001
+ NSUInteger mask = objc::msg_send<NSUInteger>(static_cast<id>(win),
1002
+ objc::selector("styleMask"));
1003
+ return (mask & full) != 0;
1004
+ }
1005
+
1006
+ static int32_t platform_set_fullscreen(void *win, bool on) {
1007
+ using namespace webview::detail;
1008
+ objc::autoreleasepool arp;
1009
+ // toggleFullScreen: only toggles, so read the current state first and leave
1010
+ // it alone when it already matches.
1011
+ if (platform_is_fullscreen(win) != on) {
1012
+ objc::msg_send<void>(static_cast<id>(win),
1013
+ objc::selector("toggleFullScreen:"), nullptr);
1014
+ }
1015
+ return 0;
1016
+ }
766
1017
  #elif defined(_WIN32)
767
1018
 
768
1019
  std::string from_wide(const wchar_t *w, int wlen) {
@@ -932,20 +1183,889 @@ bool run_file_dialog(const DialogRequest &req, std::vector<std::string> &out,
932
1183
 
933
1184
  #endif
934
1185
 
935
- #if !defined(__APPLE__)
936
- // Windows and Linux need no menu for these shortcuts: Alt+F4 is a
937
- // window-manager message the win32 backend already answers as WM_CLOSE, and
938
- // the editing keys are handled inside WebView2 and WebKitGTK. A menu here
939
- // would be a feature, not a fix.
1186
+ #if defined(__APPLE__)
1187
+ // The AppKit renderer is further up, beside the standard menu bar it shares
1188
+ // its helpers with. This chain covers the other two.
1189
+ #elif defined(_WIN32)
1190
+
1191
+ // ---- Win32 menus -----------------------------------------------------------
1192
+ //
1193
+ // There is no standard bar to install here. Alt+F4 is a window-manager
1194
+ // message the win32 backend already answers as WM_CLOSE, and Ctrl+C/V/Z/A are
1195
+ // handled inside WebView2 — so unlike macOS, an app that says nothing about
1196
+ // menus loses nothing by having none. A menu on Windows is a feature the app
1197
+ // asked for, not a floor under the keyboard.
940
1198
  static void install_main_menu() {}
941
- static size_t standard_menu_count() { return 0; }
942
1199
 
943
- // Custom menus are macOS-only for now. Two things have to be solved first:
944
- // webview.h's win32 loop never calls TranslateAcceleratorW, without which
945
- // accelerators do not fire, and the GTK backend keeps both GTK 3 and GTK 4
946
- // alive where GTK 4 removed GtkMenuBar. Reporting -1 lets the host say
947
- // "unsupported here" rather than pretend it worked.
1200
+ // The window that owns the menu bar, and the wndproc that was there before we
1201
+ // subclassed it. Process-wide because janela opens one window; the menu bar
1202
+ // itself is per-window, so a second one would need these per App.
1203
+ static HWND g_menu_hwnd = nullptr;
1204
+ static WNDPROC g_prev_wndproc = nullptr;
1205
+ static HACCEL g_accel = nullptr;
1206
+ static HHOOK g_accel_hook = nullptr;
1207
+ static HMENU g_menu_bar = nullptr;
1208
+ static bool g_win_fullscreen = false;
1209
+
1210
+ // WM_COMMAND's id is 16 bits, and Windows reserves the low range for its own
1211
+ // controls (IDOK is 1), so item ids start well clear of it.
1212
+ static const UINT kIdItemBase = 0x4000;
1213
+
1214
+ static std::wstring widen(const std::string &s) {
1215
+ if (s.empty()) return std::wstring();
1216
+ int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()),
1217
+ nullptr, 0);
1218
+ if (n <= 0) return std::wstring();
1219
+ std::wstring out(static_cast<size_t>(n), L'\0');
1220
+ MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()),
1221
+ &out[0], n);
1222
+ return out;
1223
+ }
1224
+
1225
+ // A literal ampersand in a menu label is a mnemonic marker, so a label like
1226
+ // "Cut && Paste" has to be escaped or Windows eats the character and
1227
+ // underlines the P.
1228
+ static std::string escape_mnemonics(const std::string &s) {
1229
+ std::string out;
1230
+ for (char c : s) {
1231
+ out.push_back(c);
1232
+ if (c == '&') out.push_back('&');
1233
+ }
1234
+ return out;
1235
+ }
1236
+
1237
+ // The host sends a lowercased key name. A single character goes through the
1238
+ // keyboard layout; everything else is a named key, which is the only way F5 or
1239
+ // Delete can be an accelerator at all. `shown` is what the menu prints, which
1240
+ // is not always the name that was sent.
1241
+ static bool vk_for_key(const std::string &key, WORD &vk, std::string &shown) {
1242
+ if (key.empty()) return false;
1243
+ if (key.size() == 1) {
1244
+ SHORT s = VkKeyScanW(static_cast<wchar_t>(
1245
+ static_cast<unsigned char>(std::toupper(
1246
+ static_cast<unsigned char>(key[0])))));
1247
+ if (s == -1) return false;
1248
+ vk = static_cast<WORD>(s & 0xFF);
1249
+ shown = std::string(1, static_cast<char>(std::toupper(
1250
+ static_cast<unsigned char>(key[0]))));
1251
+ return true;
1252
+ }
1253
+ if (key[0] == 'f' && key.size() <= 3) {
1254
+ int n = atoi(key.c_str() + 1);
1255
+ if (n >= 1 && n <= 24) {
1256
+ vk = static_cast<WORD>(VK_F1 + n - 1);
1257
+ shown = "F" + std::to_string(n);
1258
+ return true;
1259
+ }
1260
+ }
1261
+ struct Named { const char *name; WORD vk; const char *shown; };
1262
+ static const Named table[] = {
1263
+ {"enter", VK_RETURN, "Enter"}, {"return", VK_RETURN, "Enter"},
1264
+ {"escape", VK_ESCAPE, "Esc"}, {"esc", VK_ESCAPE, "Esc"},
1265
+ {"space", VK_SPACE, "Space"}, {"tab", VK_TAB, "Tab"},
1266
+ {"backspace", VK_BACK, "Backspace"},
1267
+ {"delete", VK_DELETE, "Del"}, {"del", VK_DELETE, "Del"},
1268
+ {"insert", VK_INSERT, "Ins"}, {"up", VK_UP, "Up"},
1269
+ {"down", VK_DOWN, "Down"}, {"left", VK_LEFT, "Left"},
1270
+ {"right", VK_RIGHT, "Right"}, {"home", VK_HOME, "Home"},
1271
+ {"end", VK_END, "End"}, {"pageup", VK_PRIOR, "PgUp"},
1272
+ {"pagedown", VK_NEXT, "PgDn"},
1273
+ };
1274
+ for (const Named &n : table) {
1275
+ if (key == n.name) {
1276
+ vk = n.vk;
1277
+ shown = n.shown;
1278
+ return true;
1279
+ }
1280
+ }
1281
+ return false;
1282
+ }
1283
+
1284
+ // What the menu prints to the right of the label. MOD_PRIMARY is Control here
1285
+ // — the same symbolic bit AppKit renders as Command, which is the whole point
1286
+ // of the host sending intent rather than platform constants.
1287
+ static std::string accel_text(const std::string &shown, unsigned long mods) {
1288
+ std::string out;
1289
+ if (mods & (1 | 8)) out += "Ctrl+";
1290
+ if (mods & 4) out += "Alt+";
1291
+ if (mods & 2) out += "Shift+";
1292
+ return out.empty() && shown.empty() ? std::string() : out + shown;
1293
+ }
1294
+
1295
+ // ---- the editing commands, through the DevTools protocol -------------------
1296
+ //
1297
+ // WebView2 runs the page out of process and exposes no copy/paste entry point,
1298
+ // and a webview blocks document.execCommand("paste"). What it DOES expose is
1299
+ // the DevTools protocol, and CDP's Input.dispatchKeyEvent takes a `commands`
1300
+ // array: Blink's own editor commands, the same ones a real Ctrl+V runs,
1301
+ // executed in the browser process where the clipboard actually lives.
1302
+ //
1303
+ // Measured against Chromium (which is what WebView2 is), with a sentinel in
1304
+ // the system clipboard before and after: copy writes it, paste reads it, cut
1305
+ // does both. Blink refuses the clipboard half when the page is not focused,
1306
+ // which is why this dispatches at the webview rather than asking the page.
1307
+ //
1308
+ // undo, redo and selectAll would also work through plain ExecuteScript —
1309
+ // document.execCommand allows those without a user gesture, measured — but
1310
+ // routing all six the same way leaves one mechanism to be wrong instead of two.
1311
+ struct CdpDone final
1312
+ : public ICoreWebView2CallDevToolsProtocolMethodCompletedHandler {
1313
+ // The reply is of no interest; the call is fire-and-forget. A static
1314
+ // instance with inert refcounting is why there is no lifetime to manage.
1315
+ // The two IIDs come from different places, and each is the only one that
1316
+ // links. IID_IUnknown lives in libuuid — pulling a whole library into every
1317
+ // Windows build for one GUID is a worse trade than __uuidof, which mingw
1318
+ // declares and the compiler folds in place. WebView2's own IIDs go the other
1319
+ // way: the SDK header defines them `selectany`, but attaches no
1320
+ // __declspec(uuid), so __uuidof on one is an undefined symbol. Both mistakes
1321
+ // are LINK errors, which -fsyntax-only does not catch — see the win32 link
1322
+ // check in the cross-compile notes.
1323
+ HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv) override {
1324
+ if (!ppv) return E_POINTER;
1325
+ if (riid == __uuidof(IUnknown) ||
1326
+ riid == IID_ICoreWebView2CallDevToolsProtocolMethodCompletedHandler) {
1327
+ *ppv = static_cast<ICoreWebView2CallDevToolsProtocolMethodCompletedHandler *>(this);
1328
+ return S_OK;
1329
+ }
1330
+ *ppv = nullptr;
1331
+ return E_NOINTERFACE;
1332
+ }
1333
+ ULONG STDMETHODCALLTYPE AddRef() override { return 2; }
1334
+ ULONG STDMETHODCALLTYPE Release() override { return 1; }
1335
+ HRESULT STDMETHODCALLTYPE Invoke(HRESULT, LPCWSTR) override { return S_OK; }
1336
+ };
1337
+
1338
+ static ICoreWebView2 *core_webview(App *a) {
1339
+ if (!a || !a->w) return nullptr;
1340
+ void *h = webview_get_native_handle(
1341
+ a->w, WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER);
1342
+ if (!h) return nullptr;
1343
+ auto *controller = static_cast<ICoreWebView2Controller *>(h);
1344
+ ICoreWebView2 *core = nullptr;
1345
+ if (FAILED(controller->get_CoreWebView2(&core))) return nullptr;
1346
+ return core; // borrowed: the controller owns it for the app's lifetime
1347
+ }
1348
+
1349
+ // The key is the one the command's real shortcut uses, because `commands` is
1350
+ // "the editing commands associated with THIS key event" — Blink runs them in
1351
+ // place of the key's default handling, so a mismatched key would be a lie in
1352
+ // anything that observes the event.
1353
+ struct EditCommand {
1354
+ const char *action;
1355
+ const char *command; // Blink's name, which is not always ours
1356
+ int vk;
1357
+ const char *key;
1358
+ const char *code;
1359
+ bool shift;
1360
+ };
1361
+
1362
+ static int32_t cdp_edit(App *a, const EditCommand &e) {
1363
+ ICoreWebView2 *core = core_webview(a);
1364
+ if (!core) return -1;
1365
+ char json[320];
1366
+ // modifiers is a bitmask: 2 is Ctrl, 8 is Shift.
1367
+ snprintf(json, sizeof(json),
1368
+ "{\"type\":\"rawKeyDown\",\"modifiers\":%d,"
1369
+ "\"windowsVirtualKeyCode\":%d,\"nativeVirtualKeyCode\":%d,"
1370
+ "\"key\":\"%s\",\"code\":\"%s\",\"commands\":[\"%s\"]}",
1371
+ e.shift ? 2 | 8 : 2, e.vk, e.vk, e.key, e.code, e.command);
1372
+ static CdpDone done;
1373
+ return SUCCEEDED(core->CallDevToolsProtocolMethod(
1374
+ L"Input.dispatchKeyEvent", widen(json).c_str(), &done))
1375
+ ? 0
1376
+ : -1;
1377
+ }
1378
+
1379
+ // The process name, which is what macOS's application menu shows and so what
1380
+ // About should say here too. Windows has no NSProcessInfo; the executable's
1381
+ // basename without its extension is the same thing by another route.
1382
+ static std::wstring process_name() {
1383
+ wchar_t path[MAX_PATH];
1384
+ DWORD n = GetModuleFileNameW(nullptr, path, MAX_PATH);
1385
+ if (n == 0 || n >= MAX_PATH) return L"App";
1386
+ std::wstring s(path, n);
1387
+ size_t slash = s.find_last_of(L"\\/");
1388
+ if (slash != std::wstring::npos) s = s.substr(slash + 1);
1389
+ size_t dot = s.find_last_of(L'.');
1390
+ if (dot != std::wstring::npos && dot > 0) s = s.substr(0, dot);
1391
+ return s.empty() ? L"App" : s;
1392
+ }
1393
+
1394
+ // ShellAboutW spins its own message loop, so it must not run with a TypeScript
1395
+ // frame beneath it — the menu handler that asked for it is still on the stack.
1396
+ // Posting it to a later UI turn is the same rule the file dialogs follow.
1397
+ static void about_on_ui_thread(webview_t w, void *) {
1398
+ const std::wstring name = process_name();
1399
+ ShellAboutW(static_cast<HWND>(webview_get_window(w)), name.c_str(), L"",
1400
+ nullptr);
1401
+ }
1402
+
1403
+ static int32_t perform_action(const std::string &action) {
1404
+ App *a = current_app();
1405
+ HWND hwnd = a && a->w ? static_cast<HWND>(webview_get_window(a->w))
1406
+ : g_menu_hwnd;
1407
+ if (!hwnd) return -1;
1408
+ // performClose:'s counterpart: WM_CLOSE unwinds through the same path the
1409
+ // title bar's X uses, so the host returns from wv_run instead of the process
1410
+ // vanishing under it.
1411
+ if (action == "quit" || action == "closeWindow") {
1412
+ PostMessageW(hwnd, WM_CLOSE, 0, 0);
1413
+ return 0;
1414
+ }
1415
+ if (action == "minimize") {
1416
+ ShowWindow(hwnd, SW_MINIMIZE);
1417
+ return 0;
1418
+ }
1419
+ if (action == "zoom") {
1420
+ ShowWindow(hwnd, IsZoomed(hwnd) ? SW_RESTORE : SW_MAXIMIZE);
1421
+ return 0;
1422
+ }
1423
+ if (action == "fullscreen") {
1424
+ platform_set_fullscreen(hwnd, !g_win_fullscreen);
1425
+ return 0;
1426
+ }
1427
+ if (action == "about") {
1428
+ return webview_dispatch(a->w, about_on_ui_thread, nullptr) == WEBVIEW_ERROR_OK
1429
+ ? 0
1430
+ : -1;
1431
+ }
1432
+
1433
+ static const EditCommand edits[] = {
1434
+ {"undo", "undo", 'Z', "z", "KeyZ", false},
1435
+ // Windows redo is Ctrl+Y where macOS is Shift+Cmd+Z. Blink's command
1436
+ // name is the same either way; only the key event differs.
1437
+ {"redo", "redo", 'Y', "y", "KeyY", false},
1438
+ {"cut", "cut", 'X', "x", "KeyX", false},
1439
+ {"copy", "copy", 'C', "c", "KeyC", false},
1440
+ {"paste", "paste", 'V', "v", "KeyV", false},
1441
+ {"selectAll", "selectAll", 'A', "a", "KeyA", false},
1442
+ };
1443
+ for (const EditCommand &e : edits) {
1444
+ if (action == e.action) return cdp_edit(a, e);
1445
+ }
1446
+
1447
+ // hide, hideOthers and showAll are what is left, and they are not a missing
1448
+ // API but a missing CONCEPT. macOS hide is application state — windows
1449
+ // vanish, the app stays in the Dock, one click brings it back. The nearest
1450
+ // Windows call, ShowWindow(SW_HIDE), takes the window out of the taskbar and
1451
+ // Alt+Tab with no way back short of a tray icon; hideOthers and showAll
1452
+ // reach into other applications. False beats a dead menu entry.
1453
+ return -1;
1454
+ }
1455
+
1456
+ // A menu click and an accelerator both arrive as WM_COMMAND on the window that
1457
+ // owns the menu; the high word says which (0 for a menu, 1 for an accelerator)
1458
+ // and both are ours. Everything else goes back to webview.h's own wndproc —
1459
+ // this subclass adds a case, it does not replace the window's behaviour.
1460
+ static LRESULT CALLBACK menu_wndproc(HWND hwnd, UINT msg, WPARAM wp,
1461
+ LPARAM lp) {
1462
+ if (msg == WM_COMMAND && lp == 0 && (HIWORD(wp) == 0 || HIWORD(wp) == 1)) {
1463
+ UINT id = LOWORD(wp);
1464
+ if (id >= kIdItemBase) {
1465
+ menu_clicked(static_cast<long>(id - kIdItemBase));
1466
+ return 0;
1467
+ }
1468
+ }
1469
+ return g_prev_wndproc
1470
+ ? CallWindowProcW(g_prev_wndproc, hwnd, msg, wp, lp)
1471
+ : DefWindowProcW(hwnd, msg, wp, lp);
1472
+ }
1473
+
1474
+ // Accelerators need TranslateAcceleratorW, and webview.h's loop never calls it
1475
+ // (win32_edge.hh, run_impl: GetMessageW / TranslateMessage / DispatchMessageW
1476
+ // and nothing else). We do not own that loop — but a WH_GETMESSAGE hook runs
1477
+ // INSIDE its GetMessageW, which is the same point in the cycle. Rewriting a
1478
+ // consumed message to WM_NULL is how the key stops here instead of also
1479
+ // reaching the page, which is what a real accelerator does.
1480
+ static LRESULT CALLBACK accel_hook(int code, WPARAM wp, LPARAM lp) {
1481
+ if (code == HC_ACTION && wp == PM_REMOVE && g_accel && g_menu_hwnd) {
1482
+ MSG *msg = reinterpret_cast<MSG *>(lp);
1483
+ if (msg && msg->message >= WM_KEYFIRST && msg->message <= WM_KEYLAST &&
1484
+ (msg->hwnd == g_menu_hwnd || IsChild(g_menu_hwnd, msg->hwnd))) {
1485
+ if (TranslateAcceleratorW(g_menu_hwnd, g_accel, msg)) {
1486
+ msg->message = WM_NULL;
1487
+ msg->wParam = 0;
1488
+ msg->lParam = 0;
1489
+ }
1490
+ }
1491
+ }
1492
+ return CallNextHookEx(nullptr, code, wp, lp);
1493
+ }
1494
+
1495
+ static int32_t apply_custom_menu(App *a, const std::string &spec) {
1496
+ HWND hwnd = a->w ? static_cast<HWND>(webview_get_window(a->w)) : nullptr;
1497
+ if (!hwnd) return -1;
1498
+
1499
+ // A fresh bar every time: the host owns what it declared, so setMenu
1500
+ // replaces rather than appends and the menu can shrink as well as grow.
1501
+ HMENU bar = CreateMenu();
1502
+ if (!bar) return -1;
1503
+
1504
+ a->menu_items.clear();
1505
+ std::vector<ACCEL> accels;
1506
+
1507
+ // The bar itself plus one popup per open submenu. Popups are appended to
1508
+ // their parent as they are created, so destroying the bar destroys them all.
1509
+ std::vector<HMENU> stack;
1510
+ stack.push_back(bar);
1511
+
1512
+ for (const std::string &line : split_on(spec, '\n')) {
1513
+ if (line.empty()) continue;
1514
+ std::vector<std::string> f = split_on(line, '\x1f');
1515
+ const std::string &kind = f[0];
1516
+
1517
+ if (kind == "S" && f.size() >= 2) {
1518
+ HMENU popup = CreatePopupMenu();
1519
+ if (!popup) continue;
1520
+ AppendMenuW(stack.back(), MF_POPUP,
1521
+ reinterpret_cast<UINT_PTR>(popup),
1522
+ widen(escape_mnemonics(f[1])).c_str());
1523
+ stack.push_back(popup);
1524
+ } else if (kind == "E") {
1525
+ if (stack.size() > 1) stack.pop_back();
1526
+ } else if (kind == "-") {
1527
+ if (stack.size() > 1) {
1528
+ AppendMenuW(stack.back(), MF_SEPARATOR, 0, nullptr);
1529
+ }
1530
+ } else if (kind == "I" && f.size() >= 8 && stack.size() > 1) {
1531
+ // The host owns the tag: it indexes the handler registry in TypeScript,
1532
+ // so the numbering comes from there rather than from insertion order.
1533
+ long tag = strtol(f[1].c_str(), nullptr, 10);
1534
+ UINT id = kIdItemBase + static_cast<UINT>(tag);
1535
+ WORD vk = 0;
1536
+ std::string shown;
1537
+ std::string label = escape_mnemonics(f[2]);
1538
+ unsigned long mods = strtoul(f[4].c_str(), nullptr, 10);
1539
+ if (!f[3].empty() && vk_for_key(f[3], vk, shown)) {
1540
+ label += "\t" + accel_text(shown, mods);
1541
+ ACCEL ac{};
1542
+ ac.fVirt = FVIRTKEY;
1543
+ if (mods & (1 | 8)) ac.fVirt |= FCONTROL;
1544
+ if (mods & 2) ac.fVirt |= FSHIFT;
1545
+ if (mods & 4) ac.fVirt |= FALT;
1546
+ ac.key = vk;
1547
+ ac.cmd = static_cast<WORD>(id);
1548
+ accels.push_back(ac);
1549
+ }
1550
+ UINT flags = MF_STRING;
1551
+ if (f[5] != "1") flags |= MF_GRAYED;
1552
+ if (f[6] == "1") flags |= MF_CHECKED;
1553
+ AppendMenuW(stack.back(), flags, id, widen(label).c_str());
1554
+ // The owning popup, not the bar: EnableMenuItem searches submenus by
1555
+ // command but SetMenuItemInfoW does not, and setLabel needs the latter.
1556
+ if (tag >= 0) {
1557
+ size_t at = static_cast<size_t>(tag);
1558
+ if (a->menu_items.size() <= at) a->menu_items.resize(at + 1, nullptr);
1559
+ a->menu_items[at] = stack.back();
1560
+ }
1561
+ }
1562
+ }
1563
+
1564
+ if (!SetMenu(hwnd, bar)) {
1565
+ DestroyMenu(bar);
1566
+ return -1;
1567
+ }
1568
+ // Destroying the previous bar only after the new one is installed: it is
1569
+ // still the window's menu until SetMenu returns.
1570
+ if (g_menu_bar && g_menu_bar != bar) DestroyMenu(g_menu_bar);
1571
+ g_menu_bar = bar;
1572
+ DrawMenuBar(hwnd);
1573
+ // A menu bar takes a row out of the client area, and webview.h sizes its
1574
+ // widget only from WM_SIZE — which SetMenu does not send. Without this the
1575
+ // webview keeps its old height and the bottom of the page is clipped off
1576
+ // the window. SWP_FRAMECHANGED forces the WM_NCCALCSIZE/WM_SIZE pair.
1577
+ SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
1578
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER |
1579
+ SWP_FRAMECHANGED);
1580
+
1581
+ if (g_accel) {
1582
+ DestroyAcceleratorTable(g_accel);
1583
+ g_accel = nullptr;
1584
+ }
1585
+ if (!accels.empty()) {
1586
+ g_accel = CreateAcceleratorTableW(accels.data(),
1587
+ static_cast<int>(accels.size()));
1588
+ }
1589
+
1590
+ if (g_menu_hwnd != hwnd) {
1591
+ g_prev_wndproc = reinterpret_cast<WNDPROC>(SetWindowLongPtrW(
1592
+ hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(menu_wndproc)));
1593
+ g_menu_hwnd = hwnd;
1594
+ }
1595
+ if (!g_accel_hook) {
1596
+ g_accel_hook = SetWindowsHookExW(WH_GETMESSAGE, accel_hook, nullptr,
1597
+ GetCurrentThreadId());
1598
+ }
1599
+
1600
+ a->menu_set = true;
1601
+ claim_menu_owner(a);
1602
+ return 0;
1603
+ }
1604
+
1605
+ static HMENU owner_menu(App *a, int32_t tag) {
1606
+ if (tag < 0 || static_cast<size_t>(tag) >= a->menu_items.size()) return nullptr;
1607
+ return static_cast<HMENU>(a->menu_items[static_cast<size_t>(tag)]);
1608
+ }
1609
+
1610
+ static int32_t menu_set_enabled(App *a, int32_t tag, int32_t on) {
1611
+ HMENU m = owner_menu(a, tag);
1612
+ if (!m || !g_menu_hwnd) return -1;
1613
+ if (EnableMenuItem(m, kIdItemBase + static_cast<UINT>(tag),
1614
+ MF_BYCOMMAND | (on ? MF_ENABLED : MF_GRAYED)) == -1) {
1615
+ return -1;
1616
+ }
1617
+ DrawMenuBar(g_menu_hwnd);
1618
+ return 0;
1619
+ }
1620
+
1621
+ static int32_t menu_set_checked(App *a, int32_t tag, int32_t on) {
1622
+ HMENU m = owner_menu(a, tag);
1623
+ if (!m) return -1;
1624
+ return CheckMenuItem(m, kIdItemBase + static_cast<UINT>(tag),
1625
+ MF_BYCOMMAND | (on ? MF_CHECKED : MF_UNCHECKED)) ==
1626
+ static_cast<DWORD>(-1)
1627
+ ? -1
1628
+ : 0;
1629
+ }
1630
+
1631
+ static int32_t menu_set_label(App *a, int32_t tag, const std::string &label) {
1632
+ HMENU m = owner_menu(a, tag);
1633
+ if (!m || !g_menu_hwnd) return -1;
1634
+ // The accelerator text lives after a tab in the same string, so replacing
1635
+ // the label has to keep whatever was there or the shortcut stops being
1636
+ // printed while it goes on working.
1637
+ wchar_t existing[512];
1638
+ std::wstring text = widen(escape_mnemonics(label));
1639
+ int n = GetMenuStringW(m, kIdItemBase + static_cast<UINT>(tag), existing,
1640
+ 512, MF_BYCOMMAND);
1641
+ if (n > 0) {
1642
+ std::wstring was(existing, static_cast<size_t>(n));
1643
+ size_t tab = was.find(L'\t');
1644
+ if (tab != std::wstring::npos) text += was.substr(tab);
1645
+ }
1646
+ MENUITEMINFOW mii{};
1647
+ mii.cbSize = sizeof(mii);
1648
+ mii.fMask = MIIM_STRING;
1649
+ mii.dwTypeData = const_cast<wchar_t *>(text.c_str());
1650
+ if (!SetMenuItemInfoW(m, kIdItemBase + static_cast<UINT>(tag), FALSE, &mii)) {
1651
+ return -1;
1652
+ }
1653
+ DrawMenuBar(g_menu_hwnd);
1654
+ return 0;
1655
+ }
1656
+
1657
+ static void menu_teardown(App *) {
1658
+ if (g_accel_hook) {
1659
+ UnhookWindowsHookEx(g_accel_hook);
1660
+ g_accel_hook = nullptr;
1661
+ }
1662
+ if (g_accel) {
1663
+ DestroyAcceleratorTable(g_accel);
1664
+ g_accel = nullptr;
1665
+ }
1666
+ // The menu bar is destroyed with the window; g_menu_bar is only cleared so
1667
+ // a later setMenu does not free a handle Windows already reclaimed.
1668
+ g_menu_bar = nullptr;
1669
+ g_menu_hwnd = nullptr;
1670
+ g_prev_wndproc = nullptr;
1671
+ }
1672
+
1673
+ static void menu_realize(App *) {}
1674
+
1675
+
1676
+ static bool platform_is_fullscreen(void *) { return g_win_fullscreen; }
1677
+
1678
+ static int32_t platform_set_fullscreen(void *win, bool on) {
1679
+ HWND hwnd = static_cast<HWND>(win);
1680
+ static WINDOWPLACEMENT saved = {sizeof(saved), 0, 0, {0, 0}, {0, 0}, {0, 0, 0, 0}};
1681
+ LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_STYLE);
1682
+ if (on) {
1683
+ MONITORINFO mi;
1684
+ mi.cbSize = sizeof(mi);
1685
+ if (!GetWindowPlacement(hwnd, &saved) ||
1686
+ !GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY), &mi)) {
1687
+ return -1;
1688
+ }
1689
+ SetWindowLongPtrW(hwnd, GWL_STYLE, style & ~WS_OVERLAPPEDWINDOW);
1690
+ SetWindowPos(hwnd, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top,
1691
+ mi.rcMonitor.right - mi.rcMonitor.left,
1692
+ mi.rcMonitor.bottom - mi.rcMonitor.top,
1693
+ SWP_NOOWNERZORDER | SWP_FRAMECHANGED);
1694
+ } else {
1695
+ SetWindowLongPtrW(hwnd, GWL_STYLE, style | WS_OVERLAPPEDWINDOW);
1696
+ SetWindowPlacement(hwnd, &saved);
1697
+ SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
1698
+ SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER |
1699
+ SWP_FRAMECHANGED);
1700
+ }
1701
+ g_win_fullscreen = on;
1702
+ return 0;
1703
+ }
1704
+
1705
+ #else // GTK
1706
+
1707
+ // ---- GTK menus -------------------------------------------------------------
1708
+ //
1709
+ // As on Windows there is no standard bar to install: WebKitGTK handles the
1710
+ // editing keys itself, and closing the window is the window manager's job. A
1711
+ // menu here is a feature the app asked for.
1712
+ static void install_main_menu() {}
1713
+
1714
+ #if GTK_MAJOR_VERSION >= 4
1715
+ // GTK 4 removed GtkMenuBar outright — menus there are GMenuModel, a different
1716
+ // model with a different lifetime. The build pins gtk+-3.0 (bin/janela.mjs),
1717
+ // so this is a compile-out rather than a guess at code nobody has run.
948
1718
  static int32_t apply_custom_menu(App *, const std::string &) { return -1; }
1719
+ static int32_t menu_set_enabled(App *, int32_t, int32_t) { return -1; }
1720
+ static int32_t menu_set_checked(App *, int32_t, int32_t) { return -1; }
1721
+ static int32_t menu_set_label(App *, int32_t, const std::string &) { return -1; }
1722
+ static int32_t perform_action(const std::string &) { return -1; }
1723
+ static void menu_teardown(App *) {}
1724
+ static void menu_realize(App *) {}
1725
+ #else
1726
+
1727
+ static GtkAccelGroup *g_accel_group = nullptr;
1728
+ // Set when the webview was not parented yet at setMenu time — see attach_menubar.
1729
+ static std::string g_pending_spec;
1730
+ static bool g_menu_pending = false;
1731
+
1732
+ static WebKitWebView *webkit_of(App *a) {
1733
+ if (!a || !a->w) return nullptr;
1734
+ void *h = webview_get_native_handle(
1735
+ a->w, WEBVIEW_NATIVE_HANDLE_KIND_BROWSER_CONTROLLER);
1736
+ return h ? WEBKIT_WEB_VIEW(h) : nullptr;
1737
+ }
1738
+
1739
+ static int32_t perform_action(const std::string &action) {
1740
+ App *a = current_app();
1741
+ if (!a || !a->w) return -1;
1742
+ GtkWindow *window = GTK_WINDOW(webview_get_window(a->w));
1743
+ if (!window) return -1;
1744
+
1745
+ // WebKitGTK does expose the editing commands, unlike WebView2 — which is
1746
+ // why Copy and Paste are real menu items on Linux and refusals on Windows.
1747
+ // The names are the literal strings behind the WEBKIT_EDITING_COMMAND_*
1748
+ // macros, spelled out so a webkit2gtk without the newer macro still builds.
1749
+ static const struct { const char *action; const char *command; } edits[] = {
1750
+ {"cut", "Cut"}, {"copy", "Copy"}, {"paste", "Paste"},
1751
+ {"undo", "Undo"}, {"redo", "Redo"}, {"selectAll", "SelectAll"},
1752
+ };
1753
+ for (const auto &e : edits) {
1754
+ if (action != e.action) continue;
1755
+ WebKitWebView *wv = webkit_of(a);
1756
+ if (!wv) return -1;
1757
+ webkit_web_view_execute_editing_command(wv, e.command);
1758
+ return 0;
1759
+ }
1760
+
1761
+ // gtk_window_close, not gtk_main_quit: it unwinds through the same path the
1762
+ // title bar's close button uses, so the host returns from wv_run.
1763
+ if (action == "quit" || action == "closeWindow") {
1764
+ gtk_window_close(window);
1765
+ return 0;
1766
+ }
1767
+ if (action == "minimize") {
1768
+ gtk_window_iconify(window);
1769
+ return 0;
1770
+ }
1771
+ if (action == "zoom") {
1772
+ if (gtk_window_is_maximized(window)) {
1773
+ gtk_window_unmaximize(window);
1774
+ } else {
1775
+ gtk_window_maximize(window);
1776
+ }
1777
+ return 0;
1778
+ }
1779
+ if (action == "fullscreen") {
1780
+ platform_set_fullscreen(window, !platform_is_fullscreen(window));
1781
+ return 0;
1782
+ }
1783
+ if (action == "about") {
1784
+ // GTK's own About dialog, which is what a Linux user expects here for the
1785
+ // same reason macOS shows NSApplication's. Unlike Windows' ShellAboutW it
1786
+ // spins no nested loop — it presents and returns — so it is safe to call
1787
+ // with the menu handler's TypeScript frame still beneath us.
1788
+ //
1789
+ // The program name is what macOS's application menu shows, by the nearest
1790
+ // route Linux has: GTK sets it from argv[0] during init.
1791
+ const char *name = g_get_prgname();
1792
+ gtk_show_about_dialog(window, "program-name", name ? name : "App", NULL);
1793
+ return 0;
1794
+ }
1795
+
1796
+ // hide, hideOthers and showAll are what is left, and they are not a missing
1797
+ // API but a missing CONCEPT. macOS hide is application state — windows
1798
+ // vanish, the app stays in the Dock, one click brings it back. Hiding a GTK
1799
+ // window instead takes it out of the taskbar with no way back short of a
1800
+ // tray icon, and there is no desktop-wide "hide others" to call: that is the
1801
+ // window manager's business, and on Wayland not even that. False beats a
1802
+ // dead menu entry.
1803
+ return -1;
1804
+ }
1805
+
1806
+ static void on_item_activate(GtkMenuItem *, gpointer data) {
1807
+ menu_clicked(static_cast<long>(reinterpret_cast<intptr_t>(data)));
1808
+ }
1809
+
1810
+ // Turns the host's symbolic modifiers and key name into something
1811
+ // gtk_accelerator_parse understands. <Primary> is GTK's own name for "Control
1812
+ // here, Command on a Mac", which is exactly MOD_PRIMARY.
1813
+ static bool gtk_accel_for(const std::string &key, unsigned long mods,
1814
+ guint &out_key, GdkModifierType &out_mods) {
1815
+ if (key.empty()) return false;
1816
+ std::string spec;
1817
+ if (mods & 1) spec += "<Primary>";
1818
+ if (mods & 8) spec += "<Control>";
1819
+ if (mods & 4) spec += "<Alt>";
1820
+ if (mods & 2) spec += "<Shift>";
1821
+ if (mods & 16) spec += "<Super>"; // Cmd, explicitly; Super is the nearest thing
1822
+
1823
+ // GDK key names are case-sensitive ("F5", "Return"), and the host lowercases
1824
+ // everything. Single characters are already right; named keys need their own
1825
+ // spelling, and "Page_Up" is not a capitalisation of "pageup".
1826
+ static const struct { const char *name; const char *gdk; } named[] = {
1827
+ {"enter", "Return"}, {"return", "Return"},
1828
+ {"escape", "Escape"}, {"esc", "Escape"},
1829
+ {"space", "space"}, {"tab", "Tab"},
1830
+ {"backspace", "BackSpace"},
1831
+ {"delete", "Delete"}, {"del", "Delete"},
1832
+ {"insert", "Insert"}, {"up", "Up"},
1833
+ {"down", "Down"}, {"left", "Left"},
1834
+ {"right", "Right"}, {"home", "Home"},
1835
+ {"end", "End"}, {"pageup", "Page_Up"},
1836
+ {"pagedown", "Page_Down"},
1837
+ };
1838
+ std::string name = key;
1839
+ if (key.size() > 1) {
1840
+ bool found = false;
1841
+ for (const auto &n : named) {
1842
+ if (key == n.name) { name = n.gdk; found = true; break; }
1843
+ }
1844
+ if (!found) {
1845
+ // F1-F24 and anything else that is just the lowercase of its GDK name.
1846
+ name[0] = static_cast<char>(std::toupper(
1847
+ static_cast<unsigned char>(name[0])));
1848
+ }
1849
+ }
1850
+ spec += name;
1851
+ out_key = 0;
1852
+ out_mods = static_cast<GdkModifierType>(0);
1853
+ gtk_accelerator_parse(spec.c_str(), &out_key, &out_mods);
1854
+ return out_key != 0;
1855
+ }
1856
+
1857
+ // Puts the bar above the webview.
1858
+ //
1859
+ // The GTK backend does gtk_container_add(window, webview) — a GtkWindow is a
1860
+ // GtkBin and holds exactly one child — so a menu bar means re-parenting the
1861
+ // webview into a box first. That is done once and remembered on the box, so a
1862
+ // later setMenu swaps the bar rather than nesting another box.
1863
+ //
1864
+ // It can also arrive too early: the backend parents the webview lazily, in
1865
+ // window_show(), which runs on the first webview_set_size. Adding our box to
1866
+ // an empty window then would leave window_show() adding the webview to a
1867
+ // GtkBin that is already full — a GTK warning and a blank window. So an
1868
+ // unparented window means "not yet", and wv_run retries once setup is done.
1869
+ static bool attach_menubar(App *a, GtkWidget *bar) {
1870
+ GtkWidget *window = GTK_WIDGET(webview_get_window(a->w));
1871
+ if (!window) return false;
1872
+ GtkWidget *child = gtk_bin_get_child(GTK_BIN(window));
1873
+ if (!child) return false;
1874
+
1875
+ GtkWidget *box = nullptr;
1876
+ if (GTK_IS_BOX(child) &&
1877
+ g_object_get_data(G_OBJECT(child), "janela-menu-box")) {
1878
+ box = child;
1879
+ GtkWidget *old = GTK_WIDGET(
1880
+ g_object_get_data(G_OBJECT(box), "janela-menubar"));
1881
+ if (old) gtk_container_remove(GTK_CONTAINER(box), old);
1882
+ } else {
1883
+ g_object_ref(child);
1884
+ gtk_container_remove(GTK_CONTAINER(window), child);
1885
+ box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
1886
+ g_object_set_data(G_OBJECT(box), "janela-menu-box", box);
1887
+ gtk_box_pack_end(GTK_BOX(box), child, TRUE, TRUE, 0);
1888
+ g_object_unref(child);
1889
+ gtk_container_add(GTK_CONTAINER(window), box);
1890
+ gtk_widget_show(box);
1891
+ }
1892
+ gtk_box_pack_start(GTK_BOX(box), bar, FALSE, FALSE, 0);
1893
+ gtk_box_reorder_child(GTK_BOX(box), bar, 0);
1894
+ g_object_set_data(G_OBJECT(box), "janela-menubar", bar);
1895
+ gtk_widget_show_all(bar);
1896
+
1897
+ if (!g_accel_group) return true;
1898
+ GtkWindow *w = GTK_WINDOW(window);
1899
+ if (!g_object_get_data(G_OBJECT(window), "janela-accels")) {
1900
+ gtk_window_add_accel_group(w, g_accel_group);
1901
+ g_object_set_data(G_OBJECT(window), "janela-accels", g_accel_group);
1902
+ }
1903
+ return true;
1904
+ }
1905
+
1906
+ static int32_t apply_custom_menu(App *a, const std::string &spec) {
1907
+ if (!a->w) return -1;
1908
+
1909
+ // One accel group for the window's lifetime: gtk_window_add_accel_group is
1910
+ // not idempotent, so rebuilding the menu re-uses the group and only the
1911
+ // items inside it change.
1912
+ if (!g_accel_group) g_accel_group = gtk_accel_group_new();
1913
+
1914
+ GtkWidget *bar = gtk_menu_bar_new();
1915
+ a->menu_items.clear();
1916
+
1917
+ std::vector<GtkWidget *> stack; // containers: the bar, then each open menu
1918
+ stack.push_back(bar);
1919
+
1920
+ for (const std::string &line : split_on(spec, '\n')) {
1921
+ if (line.empty()) continue;
1922
+ std::vector<std::string> f = split_on(line, '\x1f');
1923
+ const std::string &kind = f[0];
1924
+
1925
+ if (kind == "S" && f.size() >= 2) {
1926
+ GtkWidget *holder = gtk_menu_item_new_with_label(f[1].c_str());
1927
+ GtkWidget *menu = gtk_menu_new();
1928
+ gtk_menu_item_set_submenu(GTK_MENU_ITEM(holder), menu);
1929
+ gtk_menu_shell_append(GTK_MENU_SHELL(stack.back()), holder);
1930
+ stack.push_back(menu);
1931
+ } else if (kind == "E") {
1932
+ if (stack.size() > 1) stack.pop_back();
1933
+ } else if (kind == "-") {
1934
+ if (stack.size() > 1) {
1935
+ gtk_menu_shell_append(GTK_MENU_SHELL(stack.back()),
1936
+ gtk_separator_menu_item_new());
1937
+ }
1938
+ } else if (kind == "I" && f.size() >= 8 && stack.size() > 1) {
1939
+ long tag = strtol(f[1].c_str(), nullptr, 10);
1940
+ // f[7] is why the wire carries "checkable" separately from "checked":
1941
+ // GTK decides this at construction — a tick needs GtkCheckMenuItem, a
1942
+ // different widget — where AppKit lets any item carry a state.
1943
+ const bool checkable = f[7] == "1";
1944
+ GtkWidget *it = checkable
1945
+ ? gtk_check_menu_item_new_with_label(f[2].c_str())
1946
+ : gtk_menu_item_new_with_label(f[2].c_str());
1947
+ if (checkable) {
1948
+ gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(it), f[6] == "1");
1949
+ }
1950
+ gtk_widget_set_sensitive(it, f[5] == "1");
1951
+ g_signal_connect(it, "activate", G_CALLBACK(on_item_activate),
1952
+ reinterpret_cast<gpointer>(
1953
+ static_cast<intptr_t>(tag)));
1954
+ guint akey = 0;
1955
+ GdkModifierType amods = static_cast<GdkModifierType>(0);
1956
+ if (gtk_accel_for(f[3], strtoul(f[4].c_str(), nullptr, 10), akey, amods)) {
1957
+ gtk_widget_add_accelerator(it, "activate", g_accel_group, akey, amods,
1958
+ GTK_ACCEL_VISIBLE);
1959
+ }
1960
+ gtk_menu_shell_append(GTK_MENU_SHELL(stack.back()), it);
1961
+ if (tag >= 0) {
1962
+ size_t at = static_cast<size_t>(tag);
1963
+ if (a->menu_items.size() <= at) a->menu_items.resize(at + 1, nullptr);
1964
+ a->menu_items[at] = g_object_ref_sink(it);
1965
+ }
1966
+ }
1967
+ }
1968
+
1969
+ a->menu_set = true;
1970
+ claim_menu_owner(a);
1971
+
1972
+ if (!attach_menubar(a, bar)) {
1973
+ // Too early. Hold the spec and rebuild at run, when the webview is
1974
+ // certain to be parented; the widgets built here are dropped rather than
1975
+ // kept, because their accelerators would otherwise fire twice.
1976
+ g_object_ref_sink(bar);
1977
+ g_object_unref(bar);
1978
+ for (void *w : a->menu_items) {
1979
+ if (w) g_object_unref(G_OBJECT(w));
1980
+ }
1981
+ a->menu_items.clear();
1982
+ g_pending_spec = spec;
1983
+ g_menu_pending = true;
1984
+ }
1985
+ return 0;
1986
+ }
1987
+
1988
+ // The retry the note on attach_menubar describes. Called from wv_run, after
1989
+ // setup has had its chance to size the window.
1990
+ static void menu_realize(App *a) {
1991
+ if (!g_menu_pending) return;
1992
+ g_menu_pending = false;
1993
+ const std::string spec = g_pending_spec;
1994
+ g_pending_spec.clear();
1995
+ apply_custom_menu(a, spec);
1996
+ }
1997
+
1998
+ static GtkWidget *item_at(App *a, int32_t tag) {
1999
+ if (tag < 0 || static_cast<size_t>(tag) >= a->menu_items.size()) return nullptr;
2000
+ return GTK_WIDGET(a->menu_items[static_cast<size_t>(tag)]);
2001
+ }
2002
+
2003
+ static int32_t menu_set_enabled(App *a, int32_t tag, int32_t on) {
2004
+ GtkWidget *it = item_at(a, tag);
2005
+ if (!it) return -1;
2006
+ gtk_widget_set_sensitive(it, on != 0);
2007
+ return 0;
2008
+ }
2009
+
2010
+ static int32_t menu_set_checked(App *a, int32_t tag, int32_t on) {
2011
+ GtkWidget *it = item_at(a, tag);
2012
+ if (!it || !GTK_IS_CHECK_MENU_ITEM(it)) return -1;
2013
+ // set_active emits "activate", which would run the item's own click handler
2014
+ // as a side effect of the host setting the tick. Blocking the handler for
2015
+ // the duration is the difference between reflecting state and inventing a
2016
+ // click.
2017
+ g_signal_handlers_block_matched(G_OBJECT(it), G_SIGNAL_MATCH_FUNC, 0, 0,
2018
+ nullptr,
2019
+ reinterpret_cast<gpointer>(on_item_activate),
2020
+ nullptr);
2021
+ gtk_check_menu_item_set_active(GTK_CHECK_MENU_ITEM(it), on != 0);
2022
+ g_signal_handlers_unblock_matched(
2023
+ G_OBJECT(it), G_SIGNAL_MATCH_FUNC, 0, 0, nullptr,
2024
+ reinterpret_cast<gpointer>(on_item_activate), nullptr);
2025
+ return 0;
2026
+ }
2027
+
2028
+ static int32_t menu_set_label(App *a, int32_t tag, const std::string &label) {
2029
+ GtkWidget *it = item_at(a, tag);
2030
+ if (!it) return -1;
2031
+ gtk_menu_item_set_label(GTK_MENU_ITEM(it), label.c_str());
2032
+ return 0;
2033
+ }
2034
+
2035
+ static void menu_teardown(App *a) {
2036
+ for (void *w : a->menu_items) {
2037
+ if (w) g_object_unref(G_OBJECT(w));
2038
+ }
2039
+ a->menu_items.clear();
2040
+ g_menu_pending = false;
2041
+ g_pending_spec.clear();
2042
+ }
2043
+
2044
+ #endif // GTK_MAJOR_VERSION >= 4
2045
+
2046
+ // Asks the window manager rather than remembering what we last asked for: a
2047
+ // GTK window can leave fullscreen without us (the WM's own keybinding), and a
2048
+ // toggle that trusts a stale flag then does the wrong thing.
2049
+ static bool platform_is_fullscreen(void *win) {
2050
+ #if GTK_MAJOR_VERSION >= 4
2051
+ return gtk_window_is_fullscreen(GTK_WINDOW(win));
2052
+ #else
2053
+ GdkWindow *gdk = gtk_widget_get_window(GTK_WIDGET(win));
2054
+ if (!gdk) return false;
2055
+ return (gdk_window_get_state(gdk) & GDK_WINDOW_STATE_FULLSCREEN) != 0;
2056
+ #endif
2057
+ }
2058
+
2059
+ static int32_t platform_set_fullscreen(void *win, bool on) {
2060
+ GtkWindow *window = GTK_WINDOW(win);
2061
+ if (on) {
2062
+ gtk_window_fullscreen(window);
2063
+ } else {
2064
+ gtk_window_unfullscreen(window);
2065
+ }
2066
+ return 0;
2067
+ }
2068
+
949
2069
  #endif
950
2070
 
951
2071
  // Runs on the UI thread with no TS frame beneath it — see the note on the job
@@ -1068,10 +2188,9 @@ int32_t wv_create(int32_t debug) {
1068
2188
  if (g_apps[i].used) continue;
1069
2189
  webview_t w = webview_create(debug, nullptr);
1070
2190
  if (!w) return -1;
1071
- // After webview_create, which is what brings NSApplication into being.
1072
- // Both are no-ops off macOS.
1073
- install_main_menu();
1074
- g_apps[i].std_menu_count = standard_menu_count();
2191
+ // The menu is not installed here: an app that calls setMenu owns the bar,
2192
+ // and one that never does gets the standard one at wv_run. Deciding at
2193
+ // create time would mean building a bar only to throw it away.
1075
2194
  // Field-wise reset: App holds a thread and atomics, so it is not
1076
2195
  // copy-assignable from a temporary.
1077
2196
  g_apps[i].binds.clear();
@@ -1082,6 +2201,7 @@ int32_t wv_create(int32_t debug) {
1082
2201
  g_apps[i].on_menu = nullptr;
1083
2202
  g_apps[i].on_menu_ctx = nullptr;
1084
2203
  g_apps[i].menu_items.clear();
2204
+ g_apps[i].menu_set = false;
1085
2205
  g_apps[i].req.clear();
1086
2206
  g_apps[i].cur_id.clear();
1087
2207
  g_apps[i].reply.clear();
@@ -1409,52 +2529,26 @@ int32_t wv_on_menu(int32_t h, int32_t (*cb)(int32_t, void *), void *ctx) {
1409
2529
  int32_t wv_menu_set_enabled(int32_t h, int32_t tag, int32_t on) {
1410
2530
  App *a = app_at(h);
1411
2531
  if (!a) return -1;
1412
- #if defined(__APPLE__)
1413
- using namespace webview::detail;
1414
- objc::autoreleasepool arp;
1415
- id it = item_at(a, tag);
1416
- if (!it) return -1;
1417
- objc::msg_send<void>(it, objc::selector("setEnabled:"), on != 0);
1418
- return 0;
1419
- #else
1420
- (void)tag; (void)on;
1421
- return -1;
1422
- #endif
2532
+ return menu_set_enabled(a, tag, on);
1423
2533
  }
1424
2534
 
1425
2535
  int32_t wv_menu_set_checked(int32_t h, int32_t tag, int32_t on) {
1426
2536
  App *a = app_at(h);
1427
2537
  if (!a) return -1;
1428
- #if defined(__APPLE__)
1429
- using namespace webview::detail;
1430
- objc::autoreleasepool arp;
1431
- id it = item_at(a, tag);
1432
- if (!it) return -1;
1433
- // NSControlStateValueOn / Off.
1434
- objc::msg_send<void>(it, objc::selector("setState:"),
1435
- static_cast<NSInteger>(on != 0 ? 1 : 0));
1436
- return 0;
1437
- #else
1438
- (void)tag; (void)on;
1439
- return -1;
1440
- #endif
2538
+ return menu_set_checked(a, tag, on);
1441
2539
  }
1442
2540
 
1443
2541
  int32_t wv_menu_set_label(int32_t h, int32_t tag, const uint8_t *p, size_t n) {
1444
2542
  App *a = app_at(h);
1445
2543
  if (!a) return -1;
1446
- #if defined(__APPLE__)
1447
- using namespace webview::detail;
1448
- objc::autoreleasepool arp;
1449
- id it = item_at(a, tag);
1450
- if (!it) return -1;
1451
- objc::msg_send<void>(it, objc::selector("setTitle:"),
1452
- cocoa::NSString_stringWithUTF8String(to_str(p, n)));
1453
- return 0;
1454
- #else
1455
- (void)tag; (void)p; (void)n;
1456
- return -1;
1457
- #endif
2544
+ return menu_set_label(a, tag, to_str(p, n));
2545
+ }
2546
+
2547
+ // Run a platform action now — copy, paste, close, quit. Takes no handle
2548
+ // because on macOS it needs none: the responder chain and the key window are
2549
+ // process state. The other two resolve the window through current_app().
2550
+ int32_t wv_perform_action(const uint8_t *p, size_t n) {
2551
+ return perform_action(to_str(p, n));
1458
2552
  }
1459
2553
 
1460
2554
  int32_t wv_set_fullscreen(int32_t h, int32_t on) {
@@ -1462,53 +2556,7 @@ int32_t wv_set_fullscreen(int32_t h, int32_t on) {
1462
2556
  if (!a) return -1;
1463
2557
  void *win = webview_get_window(a->w);
1464
2558
  if (!win) return -1;
1465
-
1466
- #if defined(__APPLE__)
1467
- using namespace webview::detail;
1468
- objc::autoreleasepool arp;
1469
- id window = static_cast<id>(win);
1470
- // NSWindowStyleMaskFullScreen. toggleFullScreen: only toggles, so read the
1471
- // current state first and leave it alone when it already matches.
1472
- const NSUInteger full = 1UL << 14;
1473
- NSUInteger mask = objc::msg_send<NSUInteger>(window, objc::selector("styleMask"));
1474
- bool is_full = (mask & full) != 0;
1475
- if (is_full != (on != 0)) {
1476
- objc::msg_send<void>(window, objc::selector("toggleFullScreen:"), nullptr);
1477
- }
1478
- return 0;
1479
- #elif defined(_WIN32)
1480
- HWND hwnd = static_cast<HWND>(win);
1481
- static WINDOWPLACEMENT saved = {sizeof(saved), 0, 0, {0, 0}, {0, 0}, {0, 0, 0, 0}};
1482
- LONG_PTR style = GetWindowLongPtrW(hwnd, GWL_STYLE);
1483
- if (on) {
1484
- MONITORINFO mi;
1485
- mi.cbSize = sizeof(mi);
1486
- if (!GetWindowPlacement(hwnd, &saved) ||
1487
- !GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY), &mi)) {
1488
- return -1;
1489
- }
1490
- SetWindowLongPtrW(hwnd, GWL_STYLE, style & ~WS_OVERLAPPEDWINDOW);
1491
- SetWindowPos(hwnd, HWND_TOP, mi.rcMonitor.left, mi.rcMonitor.top,
1492
- mi.rcMonitor.right - mi.rcMonitor.left,
1493
- mi.rcMonitor.bottom - mi.rcMonitor.top,
1494
- SWP_NOOWNERZORDER | SWP_FRAMECHANGED);
1495
- } else {
1496
- SetWindowLongPtrW(hwnd, GWL_STYLE, style | WS_OVERLAPPEDWINDOW);
1497
- SetWindowPlacement(hwnd, &saved);
1498
- SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
1499
- SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER |
1500
- SWP_FRAMECHANGED);
1501
- }
1502
- return 0;
1503
- #else
1504
- GtkWindow *window = GTK_WINDOW(win);
1505
- if (on) {
1506
- gtk_window_fullscreen(window);
1507
- } else {
1508
- gtk_window_unfullscreen(window);
1509
- }
1510
- return 0;
1511
- #endif
2559
+ return platform_set_fullscreen(win, on != 0);
1512
2560
  }
1513
2561
 
1514
2562
  // Register the retained handler for page invokes. Valid until the app exits.
@@ -1536,6 +2584,16 @@ int32_t wv_on_timer(int32_t h, void (*cb)(int32_t, void *), void *ctx) {
1536
2584
  int32_t wv_run(int32_t h) {
1537
2585
  App *a = app_at(h);
1538
2586
  if (!a) return -1;
2587
+ // The floor: an app that never set a menu still gets Cmd+Q and Cmd+V. By
2588
+ // now setup() has run, so this is the first moment it is knowable. A no-op
2589
+ // off macOS, where those keys need no menu.
2590
+ if (!a->menu_set) {
2591
+ install_main_menu();
2592
+ } else {
2593
+ // A renderer that could not attach during setup gets its second chance
2594
+ // here — see attach_menubar in the GTK section. A no-op elsewhere.
2595
+ menu_realize(a);
2596
+ }
1539
2597
  int rc = webview_run(a->w);
1540
2598
  // Nothing may call into TS once run() has returned.
1541
2599
  stop_scheduler(a);
@@ -1555,6 +2613,7 @@ int32_t wv_destroy(int32_t h) {
1555
2613
  App *a = app_at(h);
1556
2614
  if (!a) return -1;
1557
2615
  stop_scheduler(a);
2616
+ menu_teardown(a);
1558
2617
  webview_destroy(a->w);
1559
2618
  a->used = false;
1560
2619
  a->w = nullptr;