bunite-core 0.12.1 → 0.16.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 (36) hide show
  1. package/package.json +4 -4
  2. package/src/host/core/App.ts +19 -2
  3. package/src/host/core/BrowserView.ts +515 -38
  4. package/src/host/core/SurfaceBrowserIPC.ts +53 -3
  5. package/src/host/core/SurfaceManager.ts +603 -30
  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 +25 -1
  9. package/src/host/log.ts +6 -1
  10. package/src/host/native.ts +263 -1
  11. package/src/host/preloadBundle.ts +7 -2
  12. package/src/native/linux/bunite_linux_ffi.cpp +427 -6
  13. package/src/native/linux/bunite_linux_internal.h +18 -0
  14. package/src/native/linux/bunite_linux_runtime.cpp +6 -1
  15. package/src/native/linux/bunite_linux_utils.cpp +2 -2
  16. package/src/native/linux/bunite_linux_view.cpp +296 -5
  17. package/src/native/mac/bunite_mac_ffi.mm +630 -8
  18. package/src/native/mac/bunite_mac_internal.h +19 -0
  19. package/src/native/mac/bunite_mac_utils.mm +2 -2
  20. package/src/native/mac/bunite_mac_view.mm +371 -9
  21. package/src/native/shared/ffi_exports.h +200 -2
  22. package/src/native/win/native_host_cef.cpp +186 -11
  23. package/src/native/win/native_host_ffi.cpp +1194 -1
  24. package/src/native/win/native_host_internal.h +35 -0
  25. package/src/native/win/native_host_utils.cpp +2 -1
  26. package/src/native/win/process_helper_win.cpp +54 -27
  27. package/src/native/win-webview2/bunite_webview2_ffi.cpp +1023 -12
  28. package/src/native/win-webview2/webview2_internal.h +25 -0
  29. package/src/native/win-webview2/webview2_runtime.cpp +403 -34
  30. package/src/native/win-webview2/webview2_utils.cpp +30 -12
  31. package/src/preload/runtime.built.js +1 -1
  32. package/src/preload/runtime.ts +97 -0
  33. package/src/rpc/framework.ts +340 -8
  34. package/src/rpc/index.ts +32 -0
  35. package/src/webview/native.ts +253 -51
  36. package/src/webview/polyfill.ts +283 -22
@@ -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 = 11;
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,558 @@ 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 directly to the WKWebView (not via
517
+ // `[NSApp sendEvent:]` — see ctrl-strip note in `bunite_view_click`). WebKit
518
+ // marks these events `isTrusted=true` on the page; capabilities flag matches.
519
+ namespace {
520
+
521
+ NSEventModifierFlags macModifiers(uint32_t bits) {
522
+ NSEventModifierFlags m = 0;
523
+ if (bits & 8) m |= NSEventModifierFlagShift;
524
+ if (bits & 2) m |= NSEventModifierFlagControl;
525
+ if (bits & 1) m |= NSEventModifierFlagOption;
526
+ if (bits & 4) m |= NSEventModifierFlagCommand;
527
+ return m;
528
+ }
529
+
530
+ NSEventType macMouseDownType(int32_t button) {
531
+ switch (button) {
532
+ case 1: return NSEventTypeOtherMouseDown;
533
+ case 2: return NSEventTypeRightMouseDown;
534
+ default: return NSEventTypeLeftMouseDown;
535
+ }
536
+ }
537
+ NSEventType macMouseUpType(int32_t button) {
538
+ switch (button) {
539
+ case 1: return NSEventTypeOtherMouseUp;
540
+ case 2: return NSEventTypeRightMouseUp;
541
+ default: return NSEventTypeLeftMouseUp;
542
+ }
543
+ }
544
+
545
+ // FFI x/y is CSS px in top-left view space; convert to AppKit window coords.
546
+ // WKWebView is non-flipped, so y flips against bounds.height.
547
+ NSPoint viewPointToWindow(NSView* view, double x, double y) {
548
+ NSRect bounds = view.bounds;
549
+ NSPoint local = view.isFlipped ? NSMakePoint(x, y) : NSMakePoint(x, bounds.size.height - y);
550
+ return [view convertPoint:local toView:nil];
551
+ }
552
+
553
+ } // namespace
554
+
555
+ extern "C" BUNITE_EXPORT void bunite_view_click(uint32_t view_id, double x, double y,
556
+ int32_t button, int32_t click_count, uint32_t modifiers) {
557
+ if (click_count < 1) click_count = 1;
558
+ runOnUiThreadSync([=]() {
559
+ auto* v = bunite_mac::findView(view_id);
560
+ if (!v || !v->webview || !v->webview.window) return;
561
+ NSWindow* win = v->webview.window;
562
+ NSPoint loc = viewPointToWindow(v->webview, x, y);
563
+ // Strip the Control bit from mouse-event modifierFlags: AppKit converts
564
+ // Ctrl+leftMouseDown into a secondary click (context-menu modal),
565
+ // deadlocking the cooperative pump. Page-side `ctrlKey` will read false
566
+ // for ctrl-click on mac as a result — callers needing ctrl-click should
567
+ // wrap with explicit `press("Control")` keydown/keyup.
568
+ NSEventModifierFlags flags = macModifiers(modifiers) & ~NSEventModifierFlagControl;
569
+ // dblclick parity: ascending clickCount per pair. Dispatch directly to
570
+ // the WKWebView — `[win sendEvent:]` enters AppKit's modal mouse-tracking
571
+ // loop and deadlocks the cooperative pump.
572
+ for (int i = 1; i <= click_count; ++i) {
573
+ NSEvent* down = [NSEvent mouseEventWithType:macMouseDownType(button)
574
+ location:loc modifierFlags:flags
575
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
576
+ windowNumber:win.windowNumber context:nil
577
+ eventNumber:0 clickCount:i pressure:1.0];
578
+ NSEvent* up = [NSEvent mouseEventWithType:macMouseUpType(button)
579
+ location:loc modifierFlags:flags
580
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
581
+ windowNumber:win.windowNumber context:nil
582
+ eventNumber:0 clickCount:i pressure:0.0];
583
+ // Dispatch directly to WKWebView — `[NSApp sendEvent:]` would translate
584
+ // Ctrl+leftMouseDown into rightMouseDown (AppKit's emulated secondary
585
+ // click), entering the context-menu modal run-loop mode and stalling
586
+ // the cooperative pump indefinitely.
587
+ if (down) {
588
+ if (button == 0) [v->webview mouseDown:down];
589
+ else if (button == 2) [v->webview rightMouseDown:down];
590
+ else [v->webview otherMouseDown:down];
591
+ }
592
+ if (up) {
593
+ if (button == 0) [v->webview mouseUp:up];
594
+ else if (button == 2) [v->webview rightMouseUp:up];
595
+ else [v->webview otherMouseUp:up];
596
+ }
597
+ }
598
+ });
599
+ }
600
+
601
+ extern "C" BUNITE_EXPORT void bunite_view_type(uint32_t view_id, const char* text) {
602
+ std::string s = text ? text : "";
603
+ runOnUiThreadSync([=]() {
604
+ auto* v = bunite_mac::findView(view_id);
605
+ if (!v || !v->webview) return;
606
+ NSString* ns = [NSString stringWithUTF8String:s.c_str()];
607
+ if (!ns.length) return;
608
+ // WKWebView conforms to NSTextInputClient — insertText: routes through
609
+ // its IME chain so DOM input events fire on focused editable elements.
610
+ if ([v->webview respondsToSelector:@selector(insertText:)]) {
611
+ [(id)v->webview insertText:ns];
612
+ }
613
+ });
614
+ }
615
+
616
+ extern "C" BUNITE_EXPORT void bunite_view_press(uint32_t view_id, int32_t /*windows_vk_code*/,
617
+ int32_t mac_key_code, const char* /*key*/, const char* /*code*/,
618
+ const char* character, uint32_t modifiers,
619
+ int32_t action, bool /*extended*/, int32_t /*location*/) {
620
+ std::string char_str = character ? character : "";
621
+ runOnUiThreadSync([=]() {
622
+ auto* v = bunite_mac::findView(view_id);
623
+ if (!v || !v->webview || !v->webview.window) return;
624
+ NSString* chars = char_str.empty() ? @"" : [NSString stringWithUTF8String:char_str.c_str()];
625
+ NSEventModifierFlags flags = macModifiers(modifiers);
626
+ NSWindow* win = v->webview.window;
627
+ if (action != 1) {
628
+ NSEvent* down = [NSEvent keyEventWithType:NSEventTypeKeyDown
629
+ location:NSZeroPoint modifierFlags:flags
630
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
631
+ windowNumber:win.windowNumber context:nil
632
+ characters:chars charactersIgnoringModifiers:chars
633
+ isARepeat:NO keyCode:(unsigned short)mac_key_code];
634
+ if (down) [v->webview keyDown:down];
635
+ }
636
+ if (action != 0) {
637
+ NSEvent* up = [NSEvent keyEventWithType:NSEventTypeKeyUp
638
+ location:NSZeroPoint modifierFlags:flags
639
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
640
+ windowNumber:win.windowNumber context:nil
641
+ characters:chars charactersIgnoringModifiers:chars
642
+ isARepeat:NO keyCode:(unsigned short)mac_key_code];
643
+ if (up) [v->webview keyUp:up];
644
+ }
645
+ });
646
+ }
647
+
648
+ extern "C" BUNITE_EXPORT void bunite_view_scroll(uint32_t view_id, double dx, double dy,
649
+ double x, double y, uint32_t modifiers) {
650
+ runOnUiThreadSync([=]() {
651
+ auto* v = bunite_mac::findView(view_id);
652
+ if (!v || !v->webview || !v->webview.window) return;
653
+ CGEventRef cg = CGEventCreateScrollWheelEvent(NULL, kCGScrollEventUnitPixel, 2,
654
+ static_cast<int32_t>(-dy),
655
+ static_cast<int32_t>(-dx));
656
+ if (!cg) return;
657
+ // CGEvent location is screen coords; CSS px → window → screen.
658
+ NSPoint inWin = viewPointToWindow(v->webview, x, y);
659
+ NSPoint onScreen = [v->webview.window convertPointToScreen:inWin];
660
+ CGEventSetLocation(cg, CGPointMake(onScreen.x, onScreen.y));
661
+ CGEventSetFlags(cg, (CGEventFlags)macModifiers(modifiers));
662
+ NSEvent* ev = [NSEvent eventWithCGEvent:cg];
663
+ CFRelease(cg);
664
+ if (ev) [v->webview scrollWheel:ev];
665
+ });
666
+ }
667
+
668
+ extern "C" BUNITE_EXPORT void bunite_view_respond_dialog(uint32_t view_id, uint32_t request_id,
669
+ bool accept, const char* text) {
670
+ std::string text_str = text ? text : "";
671
+ runOnUiThreadSync([=]() {
672
+ auto* v = bunite_mac::findView(view_id);
673
+ if (!v) return;
674
+ auto it = v->pending_dialogs.find(request_id);
675
+ if (it == v->pending_dialogs.end()) return;
676
+ auto cb = it->second;
677
+ v->pending_dialogs.erase(it);
678
+ if (cb) cb(accept, text_str);
679
+ });
680
+ }
681
+
682
+ extern "C" BUNITE_EXPORT void bunite_view_mouse(uint32_t view_id, int32_t action,
683
+ double x, double y, int32_t button,
684
+ uint32_t modifiers) {
685
+ runOnUiThreadSync([=]() {
686
+ auto* v = bunite_mac::findView(view_id);
687
+ if (!v || !v->webview || !v->webview.window) return;
688
+ NSWindow* win = v->webview.window;
689
+ NSPoint loc = viewPointToWindow(v->webview, x, y);
690
+ // Strip Control bit on mouse events for the same reason as click —
691
+ // AppKit Ctrl+leftMouseDown maps to secondary-click which enters context
692
+ // menu modal mode and stalls the cooperative pump.
693
+ NSEventModifierFlags flags = macModifiers(modifiers) & ~NSEventModifierFlagControl;
694
+ NSEventType type;
695
+ if (action == 0) {
696
+ type = NSEventTypeMouseMoved;
697
+ } else if (action == 1) {
698
+ type = macMouseDownType(button);
699
+ } else {
700
+ type = macMouseUpType(button);
701
+ }
702
+ NSEvent* ev = [NSEvent mouseEventWithType:type
703
+ location:loc modifierFlags:flags
704
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
705
+ windowNumber:win.windowNumber context:nil
706
+ eventNumber:0 clickCount:(action == 0 ? 0 : 1)
707
+ pressure:(action == 1 ? 1.0 : 0.0)];
708
+ if (!ev) return;
709
+ // Direct WKWebView dispatch — same modal-tracking concerns as click.
710
+ if (action == 0) {
711
+ [v->webview mouseMoved:ev];
712
+ } else if (action == 1) {
713
+ if (button == 0) [v->webview mouseDown:ev];
714
+ else if (button == 1) [v->webview otherMouseDown:ev];
715
+ else [v->webview rightMouseDown:ev];
716
+ } else {
717
+ if (button == 0) [v->webview mouseUp:ev];
718
+ else if (button == 1) [v->webview otherMouseUp:ev];
719
+ else [v->webview rightMouseUp:ev];
720
+ }
721
+ });
722
+ }
723
+
724
+ // Screenshot — WKWebView.takeSnapshotWithConfiguration: + NSBitmapImageRep PNG/JPEG.
725
+ namespace {
726
+
727
+ void emitMacScreenshotError(uint32_t view_id, uint32_t request_id, const char* code, NSString* msg) {
728
+ std::string m = msg ? (msg.UTF8String ?: "") : "";
729
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
730
+ ",\"ok\":false,\"code\":\"" + code + "\","
731
+ "\"message\":\"" + bunite_mac::escapeJsonString(m) + "\"}";
732
+ bunite_mac::emitWebviewEvent(view_id, "screenshot-result", payload);
733
+ }
734
+
735
+ } // namespace
736
+
737
+ extern "C" BUNITE_EXPORT uint32_t bunite_view_capabilities(uint32_t view_id) {
738
+ // WKWebView — synthetic NSEvent dispatched directly to the view. Empirically
739
+ // WebKit marks these events `isTrusted=true` on the page (the synthesis bit
740
+ // matters for AppKit responder routing, not for the DOM trust flag).
741
+ auto* v = bunite_mac::findView(view_id);
742
+ if (!v) return 0;
743
+ return BUNITE_CAP_EVALUATE | BUNITE_CAP_SURFACE_EVENTS |
744
+ BUNITE_CAP_NATIVE_INPUT_TRUSTED |
745
+ BUNITE_CAP_CLICK | BUNITE_CAP_TYPE | BUNITE_CAP_PRESS | BUNITE_CAP_SCROLL |
746
+ BUNITE_CAP_MOUSE | BUNITE_CAP_DIALOGS | BUNITE_CAP_CONSOLE |
747
+ BUNITE_CAP_SCREENSHOT | BUNITE_CAP_FORMAT_PNG | BUNITE_CAP_FORMAT_JPEG |
748
+ BUNITE_CAP_BOUNDING_RECT |
749
+ BUNITE_CAP_RESOLVE_AND_CLICK | BUNITE_CAP_DOWNLOADS | BUNITE_CAP_POPUPS |
750
+ BUNITE_CAP_AX | BUNITE_CAP_FRAMES;
751
+ }
752
+
753
+ namespace {
754
+
755
+ // JS-bridge ax tree (mirrors linux backend) — WKWebView has no public host-side
756
+ // ax API. Walks DOM + ARIA attrs, emits CDP-shaped flat list so TS-side
757
+ // convertAxTree works unchanged. `ignored` always false (interestingOnly no-op).
758
+ NSString* const kAxScript = @"(function(){"
759
+ "var nodes=[];"
760
+ "function add(el,parentId){"
761
+ "if(!el||el.nodeType!==1)return null;"
762
+ "var id=String(nodes.length+1);"
763
+ "var node={nodeId:id,parentId:parentId,"
764
+ "role:{value:el.getAttribute('role')||el.tagName.toLowerCase()},"
765
+ "name:{value:el.getAttribute('aria-label')||"
766
+ "(el.tagName==='INPUT'||el.tagName==='TEXTAREA'?'':"
767
+ "(el.firstChild&&el.firstChild.nodeType===3?el.firstChild.textContent.trim().slice(0,100):''))},"
768
+ "properties:[],childIds:[],ignored:false};"
769
+ "var d=el.getAttribute('aria-description');if(d)node.description={value:d};"
770
+ "if(el.tagName==='INPUT'||el.tagName==='TEXTAREA'){if(el.value)node.value={value:el.value};}"
771
+ "if(el.getAttribute('aria-disabled')==='true'||el.disabled)node.properties.push({name:'disabled',value:{value:true}});"
772
+ "var ck=el.getAttribute('aria-checked');"
773
+ "if(ck==='true')node.properties.push({name:'checked',value:{value:true}});"
774
+ "else if(ck==='false')node.properties.push({name:'checked',value:{value:false}});"
775
+ "else if(ck==='mixed')node.properties.push({name:'checked',value:{value:'mixed'}});"
776
+ "var pr=el.getAttribute('aria-pressed');"
777
+ "if(pr==='true')node.properties.push({name:'pressed',value:{value:true}});"
778
+ "else if(pr==='false')node.properties.push({name:'pressed',value:{value:false}});"
779
+ "else if(pr==='mixed')node.properties.push({name:'pressed',value:{value:'mixed'}});"
780
+ "if(el.getAttribute('aria-expanded')==='true')node.properties.push({name:'expanded',value:{value:true}});"
781
+ "if(el.getAttribute('aria-selected')==='true')node.properties.push({name:'selected',value:{value:true}});"
782
+ "if(el.getAttribute('aria-required')==='true')node.properties.push({name:'required',value:{value:true}});"
783
+ "if(el.getAttribute('aria-invalid')==='true')node.properties.push({name:'invalid',value:{value:true}});"
784
+ "var lv=el.getAttribute('aria-level');if(lv)node.properties.push({name:'level',value:{value:parseInt(lv,10)}});"
785
+ "if(document.activeElement===el)node.properties.push({name:'focused',value:{value:true}});"
786
+ "nodes.push(node);"
787
+ "for(var i=0;i<el.children.length;i++){var cid=add(el.children[i],id);if(cid)node.childIds.push(cid);}"
788
+ "return id;"
789
+ "}"
790
+ "add(document.documentElement,null);"
791
+ "return JSON.stringify({nodes:nodes});"
792
+ "})()";
793
+
794
+ // JS-bridge frame tree — walks window.frames into CDP `Page.getFrameTree` shape.
795
+ NSString* const kFramesScript = @"(function(){"
796
+ "var id=0;"
797
+ "function walk(win){"
798
+ "var fid=String(++id);"
799
+ "var frame={id:fid,securityOrigin:'',url:''};"
800
+ "try{frame.url=win.location.href;frame.securityOrigin=win.location.origin;"
801
+ "if(win.frameElement&&win.frameElement.name)frame.name=win.frameElement.name;}catch(e){}"
802
+ "var children=[];"
803
+ "try{for(var i=0;i<win.frames.length;i++)children.push(walk(win.frames[i]));}catch(e){}"
804
+ "return {frame:frame,childFrames:children};"
805
+ "}"
806
+ "return JSON.stringify({frameTree:walk(window)});"
807
+ "})()";
808
+
809
+ } // namespace
810
+
811
+ extern "C" BUNITE_EXPORT void bunite_view_accessibility_snapshot(uint32_t view_id, uint32_t request_id,
812
+ int32_t /*interesting_only*/) {
813
+ runOnUiThreadSync([=]() {
814
+ auto* v = bunite_mac::findView(view_id);
815
+ if (!v || !v->webview) {
816
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
817
+ ",\"ok\":false,\"code\":\"not_supported\","
818
+ "\"message\":\"view not ready\"}";
819
+ bunite_mac::emitWebviewEvent(view_id, "accessibility-result", payload);
820
+ return;
821
+ }
822
+ [v->webview evaluateJavaScript:kAxScript completionHandler:^(id result, NSError* error) {
823
+ std::string payload = "{\"requestId\":" + std::to_string(request_id);
824
+ if (error || ![result isKindOfClass:[NSString class]]) {
825
+ std::string msg = error ? std::string(error.localizedDescription.UTF8String ?: "evaluate failed")
826
+ : std::string("non-string ax result");
827
+ payload += ",\"ok\":false,\"code\":\"runtime_error\","
828
+ "\"message\":\"" + bunite_mac::escapeJsonString(msg) + "\"}";
829
+ } else {
830
+ std::string tree_json = ((NSString*)result).UTF8String ?: "{}";
831
+ payload += ",\"ok\":true,\"tree\":" + tree_json + "}";
832
+ }
833
+ bunite_mac::emitWebviewEvent(view_id, "accessibility-result", payload);
834
+ }];
835
+ });
836
+ }
837
+
838
+ extern "C" BUNITE_EXPORT void bunite_view_list_frames(uint32_t view_id, uint32_t request_id) {
839
+ runOnUiThreadSync([=]() {
840
+ auto* v = bunite_mac::findView(view_id);
841
+ if (!v || !v->webview) {
842
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
843
+ ",\"ok\":false,\"code\":\"not_supported\","
844
+ "\"message\":\"view not ready\"}";
845
+ bunite_mac::emitWebviewEvent(view_id, "list-frames-result", payload);
846
+ return;
847
+ }
848
+ [v->webview evaluateJavaScript:kFramesScript completionHandler:^(id result, NSError* error) {
849
+ std::string payload = "{\"requestId\":" + std::to_string(request_id);
850
+ if (error || ![result isKindOfClass:[NSString class]]) {
851
+ std::string msg = error ? std::string(error.localizedDescription.UTF8String ?: "evaluate failed")
852
+ : std::string("non-string frames result");
853
+ payload += ",\"ok\":false,\"code\":\"runtime_error\","
854
+ "\"message\":\"" + bunite_mac::escapeJsonString(msg) + "\"}";
855
+ } else {
856
+ std::string tree_json = ((NSString*)result).UTF8String ?: "{}";
857
+ payload += ",\"ok\":true,\"raw\":" + tree_json + "}";
858
+ }
859
+ bunite_mac::emitWebviewEvent(view_id, "list-frames-result", payload);
860
+ }];
861
+ });
862
+ }
863
+
864
+ extern "C" BUNITE_EXPORT void bunite_view_evaluate_in_frame(uint32_t view_id, uint32_t request_id,
865
+ const char* script_c, const char* frame_id_c) {
866
+ std::string script = script_c ? script_c : "";
867
+ std::string frame_id = frame_id_c ? frame_id_c : "";
868
+ if (frame_id.empty()) {
869
+ bunite_view_evaluate(view_id, request_id, script_c);
870
+ return;
871
+ }
872
+ std::string js_target = bunite_mac::escapeJsonString(frame_id);
873
+ std::string js_script = bunite_mac::escapeJsonString(script);
874
+ std::string inner =
875
+ "(function(){var target=\"" + js_target + "\";var id=0;var found=null;"
876
+ "function walk(win){var fid=String(++id);if(fid===target){found=win;return;}"
877
+ "try{for(var i=0;i<win.frames.length;i++){walk(win.frames[i]);if(found)return;}}catch(e){}}"
878
+ "walk(window);"
879
+ "if(!found)throw new Error('frame not found');"
880
+ "return found.eval(\"(\"+\"" + js_script + "\"+\")\");"
881
+ "})()";
882
+ bunite_view_evaluate(view_id, request_id, inner.c_str());
883
+ }
884
+
885
+ namespace {
886
+
887
+ void emitResolveAndClickErrorMac(uint32_t view_id, uint32_t request_id, const char* code, const std::string& msg) {
888
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
889
+ ",\"ok\":false,\"code\":\"" + code + "\","
890
+ "\"message\":\"" + bunite_mac::escapeJsonString(msg) + "\"}";
891
+ bunite_mac::emitWebviewEvent(view_id, "resolve-and-click-result", payload);
892
+ }
893
+
894
+ std::string escapeForJsStringMac(const std::string& s) {
895
+ std::string out; out.reserve(s.size() + 2);
896
+ for (char c : s) {
897
+ if (c == '"' || c == '\\') { out.push_back('\\'); out.push_back(c); }
898
+ else if (c == '\n') out += "\\n";
899
+ else if (c == '\r') out += "\\r";
900
+ else if (c == '\t') out += "\\t";
901
+ else out.push_back(c);
902
+ }
903
+ return out;
904
+ }
905
+
906
+ } // namespace
907
+
908
+ extern "C" BUNITE_EXPORT void bunite_view_resolve_and_click(
909
+ uint32_t view_id, uint32_t request_id,
910
+ const char* selector_c, const char* frame_id_c,
911
+ int32_t button, int32_t click_count, uint32_t modifiers) {
912
+ std::string selector = selector_c ? selector_c : "";
913
+ std::string frameId = frame_id_c ? frame_id_c : "";
914
+ if (!frameId.empty()) {
915
+ // No frame addressing on WKWebView — sync reject before UI hop.
916
+ emitResolveAndClickErrorMac(view_id, request_id, "not_supported",
917
+ "WKWebView has no frame addressing API");
918
+ return;
919
+ }
920
+ if (click_count < 1) click_count = 1;
921
+ std::string sel_lit = "\"" + escapeForJsStringMac(selector) + "\"";
922
+ std::string script =
923
+ "(function(){"
924
+ "var el=document.querySelector(" + sel_lit + ");"
925
+ "if(!el)return{ok:false,code:\"not_found\"};"
926
+ "el.scrollIntoView({block:\"nearest\",inline:\"nearest\",behavior:\"instant\"});"
927
+ "var r=el.getBoundingClientRect();"
928
+ "var vis=r.width>0&&r.height>0&&r.bottom>0&&r.right>0"
929
+ "&&r.top<innerHeight&&r.left<innerWidth;"
930
+ "if(!vis)return{ok:false,code:\"not_visible\"};"
931
+ "return{ok:true,x:r.x,y:r.y,w:r.width,h:r.height,"
932
+ "cx:r.x+r.width/2,cy:r.y+r.height/2};"
933
+ "})()";
934
+ NSString* nsScript = [NSString stringWithUTF8String:script.c_str()];
935
+ runOnUiThreadSync([=]() {
936
+ auto* v = bunite_mac::findView(view_id);
937
+ if (!v || !v->webview || !v->webview.window) {
938
+ emitResolveAndClickErrorMac(view_id, request_id, "runtime_error", "view not ready");
939
+ return;
940
+ }
941
+ [v->webview evaluateJavaScript:nsScript completionHandler:^(id result, NSError* error) {
942
+ if (error || ![result isKindOfClass:[NSDictionary class]]) {
943
+ std::string msg = error ? std::string(error.localizedDescription.UTF8String ?: "evaluate failed")
944
+ : std::string("evaluate returned non-object");
945
+ emitResolveAndClickErrorMac(view_id, request_id, "runtime_error", msg);
946
+ return;
947
+ }
948
+ NSDictionary* d = (NSDictionary*)result;
949
+ id okVal = d[@"ok"];
950
+ const bool ok = [okVal respondsToSelector:@selector(boolValue)] && [okVal boolValue];
951
+ if (!ok) {
952
+ id codeVal = d[@"code"];
953
+ std::string code = [codeVal isKindOfClass:[NSString class]]
954
+ ? std::string([(NSString*)codeVal UTF8String] ?: "runtime_error")
955
+ : std::string("runtime_error");
956
+ emitResolveAndClickErrorMac(view_id, request_id, code.c_str(), "");
957
+ return;
958
+ }
959
+ auto pickD = [&](NSString* k)->double {
960
+ id x = d[k];
961
+ return [x respondsToSelector:@selector(doubleValue)] ? [x doubleValue] : 0.0;
962
+ };
963
+ double x = pickD(@"x"), y = pickD(@"y"), w = pickD(@"w"), h = pickD(@"h");
964
+ double cx = pickD(@"cx"), cy = pickD(@"cy");
965
+ auto* v2 = bunite_mac::findView(view_id);
966
+ if (!v2 || !v2->webview || !v2->webview.window) {
967
+ emitResolveAndClickErrorMac(view_id, request_id, "runtime_error", "view destroyed");
968
+ return;
969
+ }
970
+ NSWindow* win = v2->webview.window;
971
+ NSPoint loc = viewPointToWindow(v2->webview, cx, cy);
972
+ NSEventModifierFlags flags = macModifiers(modifiers) & ~NSEventModifierFlagControl;
973
+ for (int i = 1; i <= click_count; ++i) {
974
+ NSEvent* down = [NSEvent mouseEventWithType:macMouseDownType(button)
975
+ location:loc modifierFlags:flags
976
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
977
+ windowNumber:win.windowNumber context:nil
978
+ eventNumber:0 clickCount:i pressure:1.0];
979
+ NSEvent* up = [NSEvent mouseEventWithType:macMouseUpType(button)
980
+ location:loc modifierFlags:flags
981
+ timestamp:[[NSProcessInfo processInfo] systemUptime]
982
+ windowNumber:win.windowNumber context:nil
983
+ eventNumber:0 clickCount:i pressure:0.0];
984
+ if (down) {
985
+ if (button == 0) [v2->webview mouseDown:down];
986
+ else if (button == 2) [v2->webview rightMouseDown:down];
987
+ else [v2->webview otherMouseDown:down];
988
+ }
989
+ if (up) {
990
+ if (button == 0) [v2->webview mouseUp:up];
991
+ else if (button == 2) [v2->webview rightMouseUp:up];
992
+ else [v2->webview otherMouseUp:up];
993
+ }
994
+ }
995
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
996
+ ",\"ok\":true,\"rect\":{\"x\":" + std::to_string(x) +
997
+ ",\"y\":" + std::to_string(y) +
998
+ ",\"width\":" + std::to_string(w) +
999
+ ",\"height\":" + std::to_string(h) + "},"
1000
+ "\"isTrustedEvent\":true}";
1001
+ bunite_mac::emitWebviewEvent(view_id, "resolve-and-click-result", payload);
1002
+ }];
1003
+ });
1004
+ }
1005
+
1006
+ extern "C" BUNITE_EXPORT void bunite_view_set_download_policy(uint32_t view_id, int32_t policy, const char* download_dir) {
1007
+ auto* st = bunite_mac::findView(view_id);
1008
+ if (!st) return;
1009
+ if (policy < 0 || policy > 2) policy = 2;
1010
+ st->download_policy.store(policy);
1011
+ st->download_dir = download_dir ? download_dir : "";
1012
+ }
1013
+
1014
+ extern "C" BUNITE_EXPORT void bunite_view_popup_accept(uint32_t new_view_id, uint32_t host_window_id,
1015
+ double x, double y, double w, double h) {
1016
+ runOnUiThreadSync([=]() { bunite_mac::acceptParkedPopup(new_view_id, host_window_id, x, y, w, h); });
1017
+ }
1018
+
1019
+ extern "C" BUNITE_EXPORT void bunite_view_popup_dismiss(uint32_t new_view_id) {
1020
+ runOnUiThreadSync([=]() { bunite_mac::dismissParkedPopup(new_view_id); });
1021
+ }
1022
+
1023
+ extern "C" BUNITE_EXPORT void bunite_view_screenshot(uint32_t view_id, uint32_t request_id,
1024
+ const char* format, int32_t quality) {
1025
+ std::string fmt = format ? format : "png";
1026
+ runOnUiThreadSync([=]() {
1027
+ auto* v = bunite_mac::findView(view_id);
1028
+ if (!v || !v->webview) {
1029
+ emitMacScreenshotError(view_id, request_id, "not_supported", @"view not ready");
1030
+ return;
1031
+ }
1032
+ WKSnapshotConfiguration* cfg = [[WKSnapshotConfiguration alloc] init];
1033
+ cfg.afterScreenUpdates = YES;
1034
+ const bool jpeg = (fmt == "jpeg" || fmt == "jpg");
1035
+ NSString* outFmt = jpeg ? @"jpeg" : @"png";
1036
+ NSString* mime = jpeg ? @"image/jpeg" : @"image/png";
1037
+ [v->webview takeSnapshotWithConfiguration:cfg
1038
+ completionHandler:^(NSImage* img, NSError* err) {
1039
+ if (err || !img) {
1040
+ emitMacScreenshotError(view_id, request_id, "runtime_error", err ? err.localizedDescription : @"takeSnapshot returned nil");
1041
+ return;
1042
+ }
1043
+ CGImageRef cgImg = [img CGImageForProposedRect:nullptr context:nil hints:nil];
1044
+ if (!cgImg) {
1045
+ emitMacScreenshotError(view_id, request_id, "runtime_error", @"CGImageForProposedRect failed");
1046
+ return;
1047
+ }
1048
+ NSBitmapImageRep* rep = [[NSBitmapImageRep alloc] initWithCGImage:cgImg];
1049
+ NSDictionary* props = jpeg
1050
+ ? @{ NSImageCompressionFactor: @((quality < 0 ? 0.9 : std::min(quality, 100) / 100.0)) }
1051
+ : @{};
1052
+ NSData* data = [rep representationUsingType:(jpeg ? NSBitmapImageFileTypeJPEG : NSBitmapImageFileTypePNG)
1053
+ properties:props];
1054
+ if (!data || data.length == 0) {
1055
+ emitMacScreenshotError(view_id, request_id, "runtime_error", @"encode failed");
1056
+ return;
1057
+ }
1058
+ NSString* b64 = [data base64EncodedStringWithOptions:0];
1059
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
1060
+ ",\"ok\":true,\"format\":\"" + outFmt.UTF8String +
1061
+ "\",\"mime\":\"" + mime.UTF8String +
1062
+ "\",\"dataBase64\":\"" + (b64.UTF8String ?: "") + "\"}";
1063
+ bunite_mac::emitWebviewEvent(view_id, "screenshot-result", payload);
1064
+ }];
1065
+ });
1066
+ }
1067
+
446
1068
  extern "C" BUNITE_EXPORT void bunite_view_open_devtools(uint32_t view_id) {
447
1069
  runOnUiThreadSync([=]() {
448
1070
  if (@available(macOS 13.3, *)) {