bunite-core 0.12.0 → 0.14.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.
Files changed (34) hide show
  1. package/package.json +4 -4
  2. package/src/host/core/App.ts +17 -1
  3. package/src/host/core/BrowserView.ts +197 -28
  4. package/src/host/core/SurfaceBrowserIPC.ts +44 -3
  5. package/src/host/core/SurfaceManager.ts +260 -28
  6. package/src/host/core/SurfaceRegistry.ts +9 -1
  7. package/src/host/core/inputDispatch.ts +147 -0
  8. package/src/host/events/webviewEvents.ts +8 -1
  9. package/src/host/native.ts +124 -1
  10. package/src/native/linux/bunite_linux_ffi.cpp +223 -6
  11. package/src/native/linux/bunite_linux_internal.h +6 -0
  12. package/src/native/linux/bunite_linux_runtime.cpp +1 -1
  13. package/src/native/linux/bunite_linux_utils.cpp +2 -2
  14. package/src/native/linux/bunite_linux_view.cpp +85 -0
  15. package/src/native/mac/bunite_mac_ffi.mm +356 -8
  16. package/src/native/mac/bunite_mac_internal.h +6 -0
  17. package/src/native/mac/bunite_mac_utils.mm +2 -2
  18. package/src/native/mac/bunite_mac_view.mm +144 -2
  19. package/src/native/shared/ffi_exports.h +135 -0
  20. package/src/native/win/native_host_cef.cpp +86 -3
  21. package/src/native/win/native_host_ffi.cpp +378 -1
  22. package/src/native/win/native_host_internal.h +13 -0
  23. package/src/native/win/native_host_utils.cpp +2 -1
  24. package/src/native/win/process_helper_win.cpp +54 -27
  25. package/src/native/win-webview2/bunite_webview2_ffi.cpp +303 -9
  26. package/src/native/win-webview2/webview2_internal.h +11 -0
  27. package/src/native/win-webview2/webview2_runtime.cpp +128 -12
  28. package/src/native/win-webview2/webview2_utils.cpp +30 -12
  29. package/src/preload/runtime.built.js +1 -1
  30. package/src/preload/runtime.ts +97 -0
  31. package/src/rpc/framework.ts +173 -4
  32. package/src/rpc/index.ts +21 -0
  33. package/src/webview/native.ts +126 -25
  34. package/src/webview/polyfill.ts +196 -12
@@ -20,7 +20,7 @@ using bunite_mac::runOnUiThreadSync;
20
20
 
21
21
  namespace {
22
22
 
23
- constexpr int32_t kBuniteAbiVersion = 5;
23
+ constexpr int32_t kBuniteAbiVersion = 9;
24
24
 
25
25
  // warn-once — avoid log spam from tight JS call loops.
26
26
  #define BUNITE_MAC_TODO(name) \
@@ -273,13 +273,83 @@ extern "C" BUNITE_EXPORT void bunite_view_execute_javascript(uint32_t view_id, c
273
273
  });
274
274
  }
275
275
 
276
- extern "C" BUNITE_EXPORT void bunite_view_evaluate(uint32_t view_id, uint32_t request_id, const char* /*script*/) {
277
- // Stage A: macOS evaluate not yet implemented. Report not_supported so the
278
- // JS side's whenReady() resolves with a structured envelope.
279
- std::string payload = "{\"requestId\":" + std::to_string(request_id) +
280
- ",\"ok\":false,\"code\":\"not_supported\","
281
- "\"message\":\"macOS evaluate not implemented (Stage A: Windows only)\"}";
282
- bunite_mac::emitWebviewEvent(view_id, "evaluate-result", payload);
276
+ extern "C" BUNITE_EXPORT void bunite_view_evaluate(uint32_t view_id, uint32_t request_id, const char* script) {
277
+ // Wrapper matches WebView2/CEF: try/catch returns JSON envelope string.
278
+ // WKWebView's `evaluateJavaScript:` delivers the string directly (no outer
279
+ // re-stringify), so the inner envelope is the completion result.
280
+ if (!script) {
281
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
282
+ ",\"ok\":false,\"code\":\"runtime_error\","
283
+ "\"message\":\"null script\"}";
284
+ bunite_mac::emitWebviewEvent(view_id, "evaluate-result", payload);
285
+ return;
286
+ }
287
+ std::string wrapped =
288
+ "(function(){try{return JSON.stringify({__bunite_ok:true,value:("
289
+ + std::string(script) +
290
+ ")})}catch(e){var c=(e&&e.name===\"SecurityError\")?\"cross_origin\":\"runtime_error\";"
291
+ "return JSON.stringify({__bunite_ok:false,code:c,"
292
+ "message:(e&&e.message)?e.message:String(e),"
293
+ "name:(e&&e.name)||\"\"})}})()";
294
+ NSString* nsScript = [NSString stringWithUTF8String:wrapped.c_str()];
295
+ runOnUiThreadSync([=]() {
296
+ auto* v = bunite_mac::findView(view_id);
297
+ if (!v || !v->webview) {
298
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
299
+ ",\"ok\":false,\"code\":\"not_supported\","
300
+ "\"message\":\"view not ready\"}";
301
+ bunite_mac::emitWebviewEvent(view_id, "evaluate-result", payload);
302
+ return;
303
+ }
304
+ [v->webview evaluateJavaScript:nsScript completionHandler:^(id result, NSError* error) {
305
+ std::string payload = "{\"requestId\":" + std::to_string(request_id);
306
+ if (error) {
307
+ std::string msg = error.localizedDescription.UTF8String ?: "evaluate failed";
308
+ payload += ",\"ok\":false,\"code\":\"runtime_error\","
309
+ "\"message\":\"" + bunite_mac::escapeJsonString(msg) + "\"}";
310
+ } else if (![result isKindOfClass:[NSString class]]) {
311
+ payload += ",\"ok\":false,\"code\":\"runtime_error\","
312
+ "\"message\":\"wrapper returned non-string\"}";
313
+ } else {
314
+ std::string inner = ((NSString*)result).UTF8String ?: "";
315
+ if (inner.find("\"__bunite_ok\":true") != std::string::npos) {
316
+ static const std::string prefix = "{\"__bunite_ok\":true,\"value\":";
317
+ std::string value_json = "null";
318
+ if (inner.compare(0, prefix.size(), prefix) == 0 &&
319
+ inner.size() > prefix.size() + 1) {
320
+ value_json = inner.substr(prefix.size(), inner.size() - prefix.size() - 1);
321
+ }
322
+ payload += ",\"ok\":true,\"value\":\"" + bunite_mac::escapeJsonString(value_json) + "\"}";
323
+ } else {
324
+ // Anchor extraction at the fixed envelope prefix — user-controlled
325
+ // e.message could otherwise inject a fake "code" via substring match.
326
+ static const std::string codePrefix = "{\"__bunite_ok\":false,\"code\":\"";
327
+ std::string code = "runtime_error";
328
+ std::string msg = "script threw";
329
+ if (inner.compare(0, codePrefix.size(), codePrefix) == 0) {
330
+ size_t start = codePrefix.size();
331
+ size_t end = start;
332
+ while (end < inner.size() && inner[end] != '"') ++end;
333
+ if (end > start) code = inner.substr(start, end - start);
334
+ static const std::string msgKey = "\",\"message\":\"";
335
+ if (end + msgKey.size() <= inner.size() &&
336
+ inner.compare(end, msgKey.size(), msgKey) == 0) {
337
+ size_t mstart = end + msgKey.size();
338
+ size_t mend = mstart;
339
+ while (mend < inner.size()) {
340
+ if (inner[mend] == '"' && (mend == mstart || inner[mend - 1] != '\\')) break;
341
+ ++mend;
342
+ }
343
+ if (mend > mstart) msg = inner.substr(mstart, mend - mstart);
344
+ }
345
+ }
346
+ payload += ",\"ok\":false,\"code\":\"" + bunite_mac::escapeJsonString(code) + "\","
347
+ "\"message\":\"" + bunite_mac::escapeJsonString(msg) + "\"}";
348
+ }
349
+ }
350
+ bunite_mac::emitWebviewEvent(view_id, "evaluate-result", payload);
351
+ }];
352
+ });
283
353
  }
284
354
 
285
355
  extern "C" BUNITE_EXPORT void bunite_view_load_url(uint32_t view_id, const char* url) {
@@ -443,6 +513,284 @@ extern "C" BUNITE_EXPORT void bunite_view_remove(uint32_t view_id) {
443
513
  runOnUiThreadSync([=]() { bunite_mac::removeView(view_id); });
444
514
  }
445
515
 
516
+ // Input dispatch — synthesized NSEvent + window sendEvent: (full responder chain).
517
+ // `isTrusted` is false (synthetic), so `nativeInputTrusted` capability stays false.
518
+ namespace {
519
+
520
+ NSEventModifierFlags macModifiers(uint32_t bits) {
521
+ NSEventModifierFlags m = 0;
522
+ if (bits & 8) m |= NSEventModifierFlagShift;
523
+ if (bits & 2) m |= NSEventModifierFlagControl;
524
+ if (bits & 1) m |= NSEventModifierFlagOption;
525
+ if (bits & 4) m |= NSEventModifierFlagCommand;
526
+ return m;
527
+ }
528
+
529
+ NSEventType macMouseDownType(int32_t button) {
530
+ switch (button) {
531
+ case 1: return NSEventTypeOtherMouseDown;
532
+ case 2: return NSEventTypeRightMouseDown;
533
+ default: return NSEventTypeLeftMouseDown;
534
+ }
535
+ }
536
+ NSEventType macMouseUpType(int32_t button) {
537
+ switch (button) {
538
+ case 1: return NSEventTypeOtherMouseUp;
539
+ case 2: return NSEventTypeRightMouseUp;
540
+ default: return NSEventTypeLeftMouseUp;
541
+ }
542
+ }
543
+
544
+ // FFI x/y is CSS px in top-left view space; convert to AppKit window coords.
545
+ // WKWebView is non-flipped, so y flips against bounds.height.
546
+ NSPoint viewPointToWindow(NSView* view, double x, double y) {
547
+ NSRect bounds = view.bounds;
548
+ NSPoint local = view.isFlipped ? NSMakePoint(x, y) : NSMakePoint(x, bounds.size.height - y);
549
+ return [view convertPoint:local toView:nil];
550
+ }
551
+
552
+ } // namespace
553
+
554
+ extern "C" BUNITE_EXPORT void bunite_view_click(uint32_t view_id, double x, double y,
555
+ int32_t button, int32_t click_count, uint32_t modifiers) {
556
+ if (click_count < 1) click_count = 1;
557
+ runOnUiThreadSync([=]() {
558
+ auto* v = bunite_mac::findView(view_id);
559
+ if (!v || !v->webview || !v->webview.window) return;
560
+ NSWindow* win = v->webview.window;
561
+ NSPoint loc = viewPointToWindow(v->webview, x, y);
562
+ // Strip the Control bit from mouse-event modifierFlags: AppKit converts
563
+ // Ctrl+leftMouseDown into a secondary click (context-menu modal),
564
+ // deadlocking the cooperative pump. Page-side `ctrlKey` will read false
565
+ // for ctrl-click on mac as a result — callers needing ctrl-click should
566
+ // wrap with explicit `press("Control")` keydown/keyup.
567
+ NSEventModifierFlags flags = macModifiers(modifiers) & ~NSEventModifierFlagControl;
568
+ // dblclick parity: ascending clickCount per pair. Dispatch directly to
569
+ // the WKWebView — `[win sendEvent:]` enters AppKit's modal mouse-tracking
570
+ // loop and deadlocks the cooperative pump.
571
+ for (int i = 1; i <= click_count; ++i) {
572
+ NSEvent* down = [NSEvent mouseEventWithType:macMouseDownType(button)
573
+ location:loc modifierFlags:flags
574
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
575
+ windowNumber:win.windowNumber context:nil
576
+ eventNumber:0 clickCount:i pressure:1.0];
577
+ NSEvent* up = [NSEvent mouseEventWithType:macMouseUpType(button)
578
+ location:loc modifierFlags:flags
579
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
580
+ windowNumber:win.windowNumber context:nil
581
+ eventNumber:0 clickCount:i pressure:0.0];
582
+ // Dispatch directly to WKWebView — `[NSApp sendEvent:]` would translate
583
+ // Ctrl+leftMouseDown into rightMouseDown (AppKit's emulated secondary
584
+ // click), entering the context-menu modal run-loop mode and stalling
585
+ // the cooperative pump indefinitely.
586
+ if (down) {
587
+ if (button == 0) [v->webview mouseDown:down];
588
+ else if (button == 2) [v->webview rightMouseDown:down];
589
+ else [v->webview otherMouseDown:down];
590
+ }
591
+ if (up) {
592
+ if (button == 0) [v->webview mouseUp:up];
593
+ else if (button == 2) [v->webview rightMouseUp:up];
594
+ else [v->webview otherMouseUp:up];
595
+ }
596
+ }
597
+ });
598
+ }
599
+
600
+ extern "C" BUNITE_EXPORT void bunite_view_type(uint32_t view_id, const char* text) {
601
+ std::string s = text ? text : "";
602
+ runOnUiThreadSync([=]() {
603
+ auto* v = bunite_mac::findView(view_id);
604
+ if (!v || !v->webview) return;
605
+ NSString* ns = [NSString stringWithUTF8String:s.c_str()];
606
+ if (!ns.length) return;
607
+ // WKWebView conforms to NSTextInputClient — insertText: routes through
608
+ // its IME chain so DOM input events fire on focused editable elements.
609
+ if ([v->webview respondsToSelector:@selector(insertText:)]) {
610
+ [(id)v->webview insertText:ns];
611
+ }
612
+ });
613
+ }
614
+
615
+ extern "C" BUNITE_EXPORT void bunite_view_press(uint32_t view_id, int32_t /*windows_vk_code*/,
616
+ int32_t mac_key_code, const char* /*key*/, const char* /*code*/,
617
+ const char* character, uint32_t modifiers,
618
+ int32_t action, bool /*extended*/, int32_t /*location*/) {
619
+ std::string char_str = character ? character : "";
620
+ runOnUiThreadSync([=]() {
621
+ auto* v = bunite_mac::findView(view_id);
622
+ if (!v || !v->webview || !v->webview.window) return;
623
+ NSString* chars = char_str.empty() ? @"" : [NSString stringWithUTF8String:char_str.c_str()];
624
+ NSEventModifierFlags flags = macModifiers(modifiers);
625
+ NSWindow* win = v->webview.window;
626
+ if (action != 1) {
627
+ NSEvent* down = [NSEvent keyEventWithType:NSEventTypeKeyDown
628
+ location:NSZeroPoint modifierFlags:flags
629
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
630
+ windowNumber:win.windowNumber context:nil
631
+ characters:chars charactersIgnoringModifiers:chars
632
+ isARepeat:NO keyCode:(unsigned short)mac_key_code];
633
+ if (down) [v->webview keyDown:down];
634
+ }
635
+ if (action != 0) {
636
+ NSEvent* up = [NSEvent keyEventWithType:NSEventTypeKeyUp
637
+ location:NSZeroPoint modifierFlags:flags
638
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
639
+ windowNumber:win.windowNumber context:nil
640
+ characters:chars charactersIgnoringModifiers:chars
641
+ isARepeat:NO keyCode:(unsigned short)mac_key_code];
642
+ if (up) [v->webview keyUp:up];
643
+ }
644
+ });
645
+ }
646
+
647
+ extern "C" BUNITE_EXPORT void bunite_view_scroll(uint32_t view_id, double dx, double dy,
648
+ double x, double y, uint32_t modifiers) {
649
+ runOnUiThreadSync([=]() {
650
+ auto* v = bunite_mac::findView(view_id);
651
+ if (!v || !v->webview || !v->webview.window) return;
652
+ CGEventRef cg = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitPixel, 2,
653
+ static_cast<int32_t>(-dy),
654
+ static_cast<int32_t>(-dx));
655
+ if (!cg) return;
656
+ // CGEvent location is screen coords; CSS px → window → screen.
657
+ NSPoint inWin = viewPointToWindow(v->webview, x, y);
658
+ NSPoint onScreen = [v->webview.window convertPointToScreen:inWin];
659
+ CGEventSetLocation(cg, CGPointMake(onScreen.x, onScreen.y));
660
+ CGEventSetFlags(cg, (CGEventFlags)macModifiers(modifiers));
661
+ NSEvent* ev = [NSEvent eventWithCGEvent:cg];
662
+ CFRelease(cg);
663
+ if (ev) [v->webview scrollWheel:ev];
664
+ });
665
+ }
666
+
667
+ extern "C" BUNITE_EXPORT void bunite_view_respond_dialog(uint32_t view_id, uint32_t request_id,
668
+ bool accept, const char* text) {
669
+ std::string text_str = text ? text : "";
670
+ runOnUiThreadSync([=]() {
671
+ auto* v = bunite_mac::findView(view_id);
672
+ if (!v) return;
673
+ auto it = v->pending_dialogs.find(request_id);
674
+ if (it == v->pending_dialogs.end()) return;
675
+ auto cb = it->second;
676
+ v->pending_dialogs.erase(it);
677
+ if (cb) cb(accept, text_str);
678
+ });
679
+ }
680
+
681
+ extern "C" BUNITE_EXPORT void bunite_view_mouse(uint32_t view_id, int32_t action,
682
+ double x, double y, int32_t button,
683
+ uint32_t modifiers) {
684
+ runOnUiThreadSync([=]() {
685
+ auto* v = bunite_mac::findView(view_id);
686
+ if (!v || !v->webview || !v->webview.window) return;
687
+ NSWindow* win = v->webview.window;
688
+ NSPoint loc = viewPointToWindow(v->webview, x, y);
689
+ // Strip Control bit on mouse events for the same reason as click —
690
+ // AppKit Ctrl+leftMouseDown maps to secondary-click which enters context
691
+ // menu modal mode and stalls the cooperative pump.
692
+ NSEventModifierFlags flags = macModifiers(modifiers) & ~NSEventModifierFlagControl;
693
+ NSEventType type;
694
+ if (action == 0) {
695
+ type = NSEventTypeMouseMoved;
696
+ } else if (action == 1) {
697
+ type = macMouseDownType(button);
698
+ } else {
699
+ type = macMouseUpType(button);
700
+ }
701
+ NSEvent* ev = [NSEvent mouseEventWithType:type
702
+ location:loc modifierFlags:flags
703
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
704
+ windowNumber:win.windowNumber context:nil
705
+ eventNumber:0 clickCount:(action == 0 ? 0 : 1)
706
+ pressure:(action == 1 ? 1.0 : 0.0)];
707
+ if (!ev) return;
708
+ // Direct WKWebView dispatch — same modal-tracking concerns as click.
709
+ if (action == 0) {
710
+ [v->webview mouseMoved:ev];
711
+ } else if (action == 1) {
712
+ if (button == 0) [v->webview mouseDown:ev];
713
+ else if (button == 1) [v->webview otherMouseDown:ev];
714
+ else [v->webview rightMouseDown:ev];
715
+ } else {
716
+ if (button == 0) [v->webview mouseUp:ev];
717
+ else if (button == 1) [v->webview otherMouseUp:ev];
718
+ else [v->webview rightMouseUp:ev];
719
+ }
720
+ });
721
+ }
722
+
723
+ // Screenshot — WKWebView.takeSnapshotWithConfiguration: + NSBitmapImageRep PNG/JPEG.
724
+ namespace {
725
+
726
+ void emitMacScreenshotError(uint32_t view_id, uint32_t request_id, const char* code, NSString* msg) {
727
+ std::string m = msg ? (msg.UTF8String ?: "") : "";
728
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
729
+ ",\"ok\":false,\"code\":\"" + code + "\","
730
+ "\"message\":\"" + bunite_mac::escapeJsonString(m) + "\"}";
731
+ bunite_mac::emitWebviewEvent(view_id, "screenshot-result", payload);
732
+ }
733
+
734
+ } // namespace
735
+
736
+ extern "C" BUNITE_EXPORT uint32_t bunite_view_capabilities(uint32_t view_id) {
737
+ // WKWebView — synthetic NSEvent dispatched directly to the view. Empirically
738
+ // WebKit marks these events `isTrusted=true` on the page (the synthesis bit
739
+ // matters for AppKit responder routing, not for the DOM trust flag).
740
+ auto* v = bunite_mac::findView(view_id);
741
+ if (!v) return 0;
742
+ return BUNITE_CAP_EVALUATE | BUNITE_CAP_SURFACE_EVENTS |
743
+ BUNITE_CAP_NATIVE_INPUT_TRUSTED |
744
+ BUNITE_CAP_CLICK | BUNITE_CAP_TYPE | BUNITE_CAP_PRESS | BUNITE_CAP_SCROLL |
745
+ BUNITE_CAP_MOUSE | BUNITE_CAP_DIALOGS | BUNITE_CAP_CONSOLE |
746
+ BUNITE_CAP_SCREENSHOT | BUNITE_CAP_FORMAT_PNG | BUNITE_CAP_FORMAT_JPEG;
747
+ }
748
+
749
+ extern "C" BUNITE_EXPORT void bunite_view_screenshot(uint32_t view_id, uint32_t request_id,
750
+ const char* format, int32_t quality) {
751
+ std::string fmt = format ? format : "png";
752
+ runOnUiThreadSync([=]() {
753
+ auto* v = bunite_mac::findView(view_id);
754
+ if (!v || !v->webview) {
755
+ emitMacScreenshotError(view_id, request_id, "not_supported", @"view not ready");
756
+ return;
757
+ }
758
+ WKSnapshotConfiguration* cfg = [[WKSnapshotConfiguration alloc] init];
759
+ cfg.afterScreenUpdates = YES;
760
+ const bool jpeg = (fmt == "jpeg" || fmt == "jpg");
761
+ NSString* outFmt = jpeg ? @"jpeg" : @"png";
762
+ NSString* mime = jpeg ? @"image/jpeg" : @"image/png";
763
+ [v->webview takeSnapshotWithConfiguration:cfg
764
+ completionHandler:^(NSImage* img, NSError* err) {
765
+ if (err || !img) {
766
+ emitMacScreenshotError(view_id, request_id, "runtime_error", err ? err.localizedDescription : @"takeSnapshot returned nil");
767
+ return;
768
+ }
769
+ CGImageRef cgImg = [img CGImageForProposedRect:nullptr context:nil hints:nil];
770
+ if (!cgImg) {
771
+ emitMacScreenshotError(view_id, request_id, "runtime_error", @"CGImageForProposedRect failed");
772
+ return;
773
+ }
774
+ NSBitmapImageRep* rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImg];
775
+ NSDictionary* props = jpeg
776
+ ? @{ NSImageCompressionFactor: @((quality < 0 ? 0.9 : std::min(quality, 100) / 100.0)) }
777
+ : @{};
778
+ NSData* data = [rep representationUsingType:(jpeg ? NSBitmapImageFileTypeJPEG : NSBitmapImageFileTypePNG)
779
+ properties:props];
780
+ if (!data || data.length == 0) {
781
+ emitMacScreenshotError(view_id, request_id, "runtime_error", @"encode failed");
782
+ return;
783
+ }
784
+ NSString* b64 = [data base64EncodedStringWithOptions:0];
785
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
786
+ ",\"ok\":true,\"format\":\"" + outFmt.UTF8String +
787
+ "\",\"mime\":\"" + mime.UTF8String +
788
+ "\",\"dataBase64\":\"" + (b64.UTF8String ?: "") + "\"}";
789
+ bunite_mac::emitWebviewEvent(view_id, "screenshot-result", payload);
790
+ }];
791
+ });
792
+ }
793
+
446
794
  extern "C" BUNITE_EXPORT void bunite_view_open_devtools(uint32_t view_id) {
447
795
  runOnUiThreadSync([=]() {
448
796
  if (@available(macOS 13.3, *)) {
@@ -61,6 +61,12 @@ struct ViewState {
61
61
  // HTML stashed by load_html; appres handler serves at internal/index.html.
62
62
  std::string stored_html;
63
63
  std::vector<std::string> navigation_rules;
64
+
65
+ // Page-initiated dialogs awaiting host response (alert/confirm/prompt).
66
+ // WKUIDelegate completion handlers are held in `__strong` blocks until
67
+ // respondToDialog invokes them; the page execution is paused meanwhile.
68
+ std::unordered_map<uint32_t, void(^)(bool /*accept*/, const std::string& /*text*/)> pending_dialogs;
69
+ uint32_t next_dialog_request_id = 1;
64
70
  };
65
71
 
66
72
  // ---------------------------------------------------------------------------
@@ -100,8 +100,8 @@ std::vector<std::string> parseNavigationRulesJson(NSString* json) {
100
100
  }
101
101
 
102
102
  bool shouldAlwaysAllowNavigationUrl(const std::string& url) {
103
- return url == "about:blank" ||
104
- url.rfind("appres://app.internal/internal/", 0) == 0;
103
+ // Exact-match prefix would let `../../evil` style paths bypass scrutiny.
104
+ return url == "about:blank" || url == "appres://app.internal/internal/index.html";
105
105
  }
106
106
 
107
107
  bool shouldAllowNavigation(const ViewState* view, const std::string& url) {
@@ -49,14 +49,77 @@ decidePolicyForNavigationAction:(WKNavigationAction*)action
49
49
  decisionHandler(allow ? WKNavigationActionPolicyAllow : WKNavigationActionPolicyCancel);
50
50
  }
51
51
 
52
- - (void)webView:(WKWebView*)wv didFinishNavigation:(WKNavigation*)nav {
52
+ - (void)webView:(WKWebView*)wv didStartProvisionalNavigation:(WKNavigation*)nav {
53
53
  (void)nav;
54
54
  uint32_t view_id = bunite_mac::viewIdForWebView(wv);
55
55
  NSString* url = wv.URL.absoluteString ?: @"";
56
+ bunite_mac::emitWebviewEvent(view_id, "load-start", url.UTF8String);
57
+ }
58
+
59
+ - (void)webView:(WKWebView*)wv didCommitNavigation:(WKNavigation*)nav {
60
+ (void)nav;
61
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
62
+ NSString* url = wv.URL.absoluteString ?: @"";
63
+ // URL commit point — surfaceEvents `navigate` arm. WKWebView fires this
64
+ // when the document begins loading after server response (post-redirect).
56
65
  bunite_mac::emitWebviewEvent(view_id, "did-navigate", url.UTF8String);
66
+ }
67
+
68
+ - (void)webView:(WKWebView*)wv didFinishNavigation:(WKNavigation*)nav {
69
+ (void)nav;
70
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
71
+ NSString* url = wv.URL.absoluteString ?: @"";
72
+ bunite_mac::emitWebviewEvent(view_id, "load-finish", url.UTF8String);
57
73
  bunite_mac::emitWebviewEvent(view_id, "dom-ready", url.UTF8String);
58
74
  }
59
75
 
76
+ - (void)webView:(WKWebView*)wv didFailNavigation:(WKNavigation*)nav withError:(NSError*)error {
77
+ (void)nav;
78
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
79
+ NSString* url = wv.URL.absoluteString ?: @"";
80
+ std::string payload = "{\"url\":\"" + bunite_mac::escapeJsonString(url.UTF8String ?: "") +
81
+ "\",\"reason\":\"" + bunite_mac::escapeJsonString(error.localizedDescription.UTF8String ?: "") + "\"}";
82
+ bunite_mac::emitWebviewEvent(view_id, "load-fail", payload);
83
+ }
84
+
85
+ - (void)webView:(WKWebView*)wv didFailProvisionalNavigation:(WKNavigation*)nav withError:(NSError*)error {
86
+ (void)nav;
87
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
88
+ NSString* failingUrl = ((NSURL*)error.userInfo[NSURLErrorFailingURLErrorKey]).absoluteString
89
+ ?: (wv.URL.absoluteString ?: @"");
90
+ std::string payload = "{\"url\":\"" + bunite_mac::escapeJsonString(failingUrl.UTF8String ?: "") +
91
+ "\",\"reason\":\"" + bunite_mac::escapeJsonString(error.localizedDescription.UTF8String ?: "") + "\"}";
92
+ bunite_mac::emitWebviewEvent(view_id, "load-fail", payload);
93
+ }
94
+
95
+ @end
96
+
97
+ @interface BuniteTitleObserver : NSObject
98
+ + (instancetype)shared;
99
+ @end
100
+
101
+ @implementation BuniteTitleObserver
102
+ + (instancetype)shared {
103
+ static BuniteTitleObserver* o = nil;
104
+ static dispatch_once_t once;
105
+ dispatch_once(&once, ^{ o = [[BuniteTitleObserver alloc] init]; });
106
+ return o;
107
+ }
108
+ - (void)observeValueForKeyPath:(NSString*)keyPath
109
+ ofObject:(id)object
110
+ change:(NSDictionary<NSKeyValueChangeKey, id>*)change
111
+ context:(void*)context
112
+ {
113
+ (void)change; (void)context;
114
+ if (![keyPath isEqualToString:@"title"]) return;
115
+ WKWebView* wv = (WKWebView*)object;
116
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
117
+ if (!view_id) return;
118
+ NSString* title = wv.title ?: @"";
119
+ std::string payload = "{\"title\":\"" +
120
+ bunite_mac::escapeJsonString(title.UTF8String ?: "") + "\"}";
121
+ bunite_mac::emitWebviewEvent(view_id, "title-changed", payload);
122
+ }
60
123
  @end
61
124
 
62
125
  @interface BuniteUIDelegate : NSObject <WKUIDelegate>
@@ -79,6 +142,80 @@ createWebViewWithConfiguration:(WKWebViewConfiguration*)config
79
142
  return nil;
80
143
  }
81
144
 
145
+ // --- Dialog handlers (alert / confirm / prompt). beforeunload is not
146
+ // surfaced by WKUIDelegate on macOS — the cancel/proceed decision is made
147
+ // through the navigation-policy delegate's `decidePolicyForNavigationAction:`
148
+ // which we already drive via the `will-navigate` allow-list. Hence no
149
+ // beforeunload arm on this backend.
150
+
151
+ - (void)webView:(WKWebView*)wv
152
+ runJavaScriptAlertPanelWithMessage:(NSString*)message
153
+ initiatedByFrame:(WKFrameInfo*)frame
154
+ completionHandler:(void (^)(void))completionHandler
155
+ {
156
+ (void)frame;
157
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
158
+ auto* v = bunite_mac::findView(view_id);
159
+ if (!v) { completionHandler(); return; }
160
+ uint32_t rid = v->next_dialog_request_id++;
161
+ v->pending_dialogs[rid] = ^(bool /*accept*/, const std::string& /*text*/) {
162
+ completionHandler();
163
+ };
164
+ std::string payload = "{\"requestId\":" + std::to_string(rid) +
165
+ ",\"kind\":\"alert\",\"message\":\"" +
166
+ bunite_mac::escapeJsonString(message.UTF8String ?: "") + "\"}";
167
+ // Post emit to next runloop turn so a heavy host-side listener cannot stall
168
+ // the cooperative pump while the page is awaiting the completion handler.
169
+ dispatch_async(dispatch_get_main_queue(), ^{
170
+ bunite_mac::emitWebviewEvent(view_id, "dialog", payload);
171
+ });
172
+ }
173
+
174
+ - (void)webView:(WKWebView*)wv
175
+ runJavaScriptConfirmPanelWithMessage:(NSString*)message
176
+ initiatedByFrame:(WKFrameInfo*)frame
177
+ completionHandler:(void (^)(BOOL))completionHandler
178
+ {
179
+ (void)frame;
180
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
181
+ auto* v = bunite_mac::findView(view_id);
182
+ if (!v) { completionHandler(NO); return; }
183
+ uint32_t rid = v->next_dialog_request_id++;
184
+ v->pending_dialogs[rid] = ^(bool accept, const std::string& /*text*/) {
185
+ completionHandler(accept ? YES : NO);
186
+ };
187
+ std::string payload = "{\"requestId\":" + std::to_string(rid) +
188
+ ",\"kind\":\"confirm\",\"message\":\"" +
189
+ bunite_mac::escapeJsonString(message.UTF8String ?: "") + "\"}";
190
+ dispatch_async(dispatch_get_main_queue(), ^{
191
+ bunite_mac::emitWebviewEvent(view_id, "dialog", payload);
192
+ });
193
+ }
194
+
195
+ - (void)webView:(WKWebView*)wv
196
+ runJavaScriptTextInputPanelWithPrompt:(NSString*)prompt
197
+ defaultText:(NSString*)defaultText
198
+ initiatedByFrame:(WKFrameInfo*)frame
199
+ completionHandler:(void (^)(NSString*))completionHandler
200
+ {
201
+ (void)frame;
202
+ uint32_t view_id = bunite_mac::viewIdForWebView(wv);
203
+ auto* v = bunite_mac::findView(view_id);
204
+ if (!v) { completionHandler(nil); return; }
205
+ uint32_t rid = v->next_dialog_request_id++;
206
+ v->pending_dialogs[rid] = ^(bool accept, const std::string& text) {
207
+ completionHandler(accept ? [NSString stringWithUTF8String:text.c_str()] : nil);
208
+ };
209
+ std::string payload = "{\"requestId\":" + std::to_string(rid) +
210
+ ",\"kind\":\"prompt\",\"message\":\"" +
211
+ bunite_mac::escapeJsonString(prompt.UTF8String ?: "") +
212
+ "\",\"defaultPrompt\":\"" +
213
+ bunite_mac::escapeJsonString(defaultText.UTF8String ?: "") + "\"}";
214
+ dispatch_async(dispatch_get_main_queue(), ^{
215
+ bunite_mac::emitWebviewEvent(view_id, "dialog", payload);
216
+ });
217
+ }
218
+
82
219
  - (void)webView:(WKWebView*)wv
83
220
  requestMediaCapturePermissionForOrigin:(WKSecurityOrigin*)origin
84
221
  initiatedByFrame:(WKFrameInfo*)frame
@@ -196,6 +333,7 @@ bool createView(uint32_t view_id, uint32_t window_id,
196
333
  static __strong BuniteUIDelegate* uiDelegate = [[BuniteUIDelegate alloc] init];
197
334
  wv.navigationDelegate = navDelegate;
198
335
  wv.UIDelegate = uiDelegate;
336
+ [wv addObserver:[BuniteTitleObserver shared] forKeyPath:@"title" options:NSKeyValueObservingOptionNew context:NULL];
199
337
 
200
338
  [container addSubview:wv];
201
339
  [window_state->window.contentView addSubview:container];
@@ -272,7 +410,11 @@ void removeView(uint32_t view_id) {
272
410
  for (BunitePendingPermission* p in to_deny) p.handler(WKPermissionDecisionDeny);
273
411
  }
274
412
 
275
- if (wv) [webviewIdTable() removeObjectForKey:wv];
413
+ if (wv) {
414
+ @try { [wv removeObserver:[BuniteTitleObserver shared] forKeyPath:@"title"]; }
415
+ @catch (NSException*) { /* never registered (race) */ }
416
+ [webviewIdTable() removeObjectForKey:wv];
417
+ }
276
418
  if (container) [container removeFromSuperview];
277
419
  }
278
420