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
@@ -17,6 +17,7 @@
17
17
  #include <fstream>
18
18
  #include <functional>
19
19
  #include <map>
20
+ #include <unordered_map>
20
21
  #include <memory>
21
22
  #include <mutex>
22
23
  #include <optional>
@@ -86,6 +87,28 @@ struct ViewHost {
86
87
 
87
88
  std::atomic<bool> ready{false};
88
89
  std::atomic<bool> closing{false};
90
+
91
+ // Download policy: 0=auto, 1=ask, 2=block. Default block (current behavior).
92
+ std::atomic<int32_t> download_policy{2};
93
+ std::string download_dir; // optional; falls back to backend default temp dir.
94
+
95
+ // Pending page-initiated dialogs (alert/confirm/prompt/beforeunload).
96
+ // ScriptDialogOpening hands us a `Deferral` we Complete() on host response.
97
+ struct PendingDialog {
98
+ ComPtr<ICoreWebView2ScriptDialogOpeningEventArgs> args;
99
+ ComPtr<ICoreWebView2Deferral> deferral;
100
+ };
101
+ std::unordered_map<uint32_t, PendingDialog> pending_dialogs;
102
+ uint32_t next_dialog_request_id = 1;
103
+
104
+ // OOPIF input dispatch — populated by Target.attachedToTarget events after
105
+ // lazy Target.setAutoAttach. frameId → sessionId for flatten:true routing.
106
+ std::atomic<bool> oopif_autoattach_armed{false};
107
+ std::mutex oopif_sessions_mutex;
108
+ std::unordered_map<std::string, std::string> oopif_sessions;
109
+ EventRegistrationToken target_attached_token{};
110
+ EventRegistrationToken target_detached_token{};
111
+ bool oopif_event_tokens_registered = false;
89
112
  };
90
113
 
91
114
  struct WindowHost {
@@ -106,6 +129,7 @@ struct RuntimeState {
106
129
  std::atomic<bool> initialized{false};
107
130
  std::atomic<bool> shutting_down{false};
108
131
  HWND message_window = nullptr;
132
+ HWND popup_parent = nullptr; // hidden top-level parking parent for popup-minted controllers.
109
133
 
110
134
  std::mutex task_mutex;
111
135
  std::deque<std::function<void()>> queued_tasks;
@@ -205,6 +229,7 @@ std::wstring exeDir();
205
229
  uint32_t permissionKindToBuniteBit(COREWEBVIEW2_PERMISSION_KIND kind);
206
230
  COREWEBVIEW2_PERMISSION_STATE buniteStateToWebView2(uint32_t state);
207
231
 
232
+ bool shouldAlwaysAllowNavigationUrl(const std::string& url);
208
233
  bool shouldAllowNavigation(const ViewHost* view, const std::string& url);
209
234
 
210
235
  } // namespace bunite_webview2
@@ -1,6 +1,7 @@
1
1
  #include "webview2_internal.h"
2
2
 
3
3
  #include <algorithm>
4
+ #include <unordered_map>
4
5
 
5
6
  using Microsoft::WRL::Callback;
6
7
  using Microsoft::WRL::ComPtr;
@@ -10,6 +11,14 @@ namespace bunite_webview2 {
10
11
 
11
12
  RuntimeState g_runtime;
12
13
 
14
+ // Pending nav URI by (view_id, nav_id) — NavigationCompleted's get_Source()
15
+ // returns the previous committed URL on provisional failure, so we stash the
16
+ // URI at NavigationStarting and look it up on completion.
17
+ static std::unordered_map<uint64_t, std::string> g_nav_uris;
18
+ static uint64_t navKey(uint32_t view_id, uint64_t nav_id) {
19
+ return (static_cast<uint64_t>(view_id) << 56) ^ nav_id;
20
+ }
21
+
13
22
  static HINSTANCE g_module = nullptr;
14
23
  static bool g_co_initialized = false;
15
24
 
@@ -121,7 +130,28 @@ bool registerWindowClasses() {
121
130
  0, 0, 0, 0, 0,
122
131
  HWND_MESSAGE, nullptr,
123
132
  getCurrentModuleHandle(), nullptr);
124
- return g_runtime.message_window != nullptr;
133
+ if (!g_runtime.message_window) return false;
134
+
135
+ // Popup parking parent — a hidden top-level window. Children of HWND_MESSAGE
136
+ // can't render (WebView2 child controllers won't initialize), so popup-minted
137
+ // controllers live here until accept reparents to the user-visible host.
138
+ g_runtime.popup_parent = CreateWindowExW(
139
+ WS_EX_TOOLWINDOW, mc.lpszClassName, L"BunitePopupPark",
140
+ WS_POPUP, 0, 0, 0, 0,
141
+ nullptr, nullptr, getCurrentModuleHandle(), nullptr);
142
+ if (!g_runtime.popup_parent) return false;
143
+
144
+ // `bun run` passes STARTF_USESHOWWINDOW + SW_HIDE; Win documented behavior
145
+ // is for the first ShowWindow call to use STARTUPINFO.wShowWindow instead
146
+ // of the requested nCmdShow. Consume it here on the message window so the
147
+ // first user-visible window's ShowWindow honors its argument.
148
+ STARTUPINFOW si{};
149
+ si.cb = sizeof(si);
150
+ GetStartupInfoW(&si);
151
+ if ((si.dwFlags & STARTF_USESHOWWINDOW) && si.wShowWindow == SW_HIDE) {
152
+ ShowWindow(g_runtime.message_window, SW_HIDE);
153
+ }
154
+ return true;
125
155
  }
126
156
 
127
157
  // ---- environment bootstrap -----------------------------------------------
@@ -282,6 +312,19 @@ LRESULT CALLBACK messageProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
282
312
 
283
313
  // ---- init / shutdown ----------------------------------------------------
284
314
 
315
+ // KILL_ON_JOB_CLOSE — Edge helpers die with bun.exe instead of holding the
316
+ // UDF SingletonLock. Handle leaked intentionally (kernel closes on exit).
317
+ static void reapChildrenOnExit() {
318
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
319
+ if (!job) return;
320
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{};
321
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
322
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info)) ||
323
+ !AssignProcessToJobObject(job, GetCurrentProcess())) {
324
+ CloseHandle(job); // already in a non-nestable job, etc — give up.
325
+ }
326
+ }
327
+
285
328
  bool initRuntime(const char* engine_dir, bool /*hide_console*/,
286
329
  bool popup_blocking, const char* engine_config_json) {
287
330
  buniteApplyEnvLogLevel();
@@ -289,6 +332,8 @@ bool initRuntime(const char* engine_dir, bool /*hide_console*/,
289
332
  (engine_dir && *engine_dir) ? engine_dir : "(null)");
290
333
  if (g_runtime.initialized.load()) return true;
291
334
 
335
+ reapChildrenOnExit();
336
+
292
337
  HRESULT co = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
293
338
  if (SUCCEEDED(co)) g_co_initialized = true;
294
339
  else if (co != RPC_E_CHANGED_MODE) {
@@ -598,15 +643,42 @@ static void wireView(ViewHost* view, std::function<void()> on_attached) {
598
643
  // - main frame only (matching CEF's OnContextCreated main-frame gate)
599
644
  // - origin allowlist when `preload_origins` is non-empty
600
645
  // Empty allowlist = inject on every main frame (CEF parity default).
601
- if (!v->preload_script.empty() && v->webview) {
646
+ //
647
+ // AddScriptToExecuteOnDocumentCreated is async — registration must
648
+ // complete BEFORE Navigate() or the first document load runs without
649
+ // the preload. Defer Navigate into the completion handler.
650
+ const bool inject = !v->preload_script.empty() && v->webview;
651
+ auto doInitialNav = [v]() {
652
+ if (!v->url.empty()) {
653
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu navigate-call url=%.200s%s",
654
+ v->id, static_cast<unsigned long long>(GetTickCount64()),
655
+ v->url.c_str(), v->url.size() > 200 ? "..." : "");
656
+ v->webview->Navigate(utf8ToWide(v->url).c_str());
657
+ } else if (!v->html.empty()) {
658
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu navigate-tostring-call",
659
+ v->id, static_cast<unsigned long long>(GetTickCount64()));
660
+ v->webview->NavigateToString(utf8ToWide(v->html).c_str());
661
+ }
662
+ };
663
+ if (inject) {
602
664
  std::string allowlist = "[";
603
665
  for (size_t i = 0; i < v->preload_origins.size(); ++i) {
604
666
  if (i) allowlist += ",";
605
667
  allowlist += "\"" + escapeJsonString(v->preload_origins[i]) + "\"";
606
668
  }
607
669
  allowlist += "]";
670
+ // Diagnostic IIFE-start/end markers, gated by log level. Leading
671
+ // `;` defends against preload_script with no trailing semicolon.
672
+ const bool emitMarkers = buniteShouldLog(BuniteLogLevel::Info);
673
+ const char* markerStart = emitMarkers
674
+ ? ";try{(self.chrome&&chrome.webview&&chrome.webview.postMessage)&&chrome.webview.postMessage('__bunite_preload_iife_start__');}catch(_){}"
675
+ : "";
676
+ const char* markerEnd = emitMarkers
677
+ ? ";try{(self.chrome&&chrome.webview&&chrome.webview.postMessage)&&chrome.webview.postMessage('__bunite_preload_iife_end__');}catch(_){}"
678
+ : "";
608
679
  std::string body =
609
- "(function(){if(window.self!==window.top)return;"
680
+ "(function(){"
681
+ "if(window.self!==window.top)return;"
610
682
  "var __a=" + allowlist +
611
683
  ",__o=location.origin;"
612
684
  "if(__a.length){var __m=function(p,v){var i=0,j=0,s=-1,k=0,L=function(c){return c.charCodeAt(0)|32;};"
@@ -616,26 +688,32 @@ static void wireView(ViewHost* view, std::function<void()> on_attached) {
616
688
  "while(i<p.length&&p[i]===\"*\")i++;return i===p.length;};"
617
689
  "var __ok=false;for(var i=0;i<__a.length;i++){if(__m(__a[i],__o)){__ok=true;break;}}"
618
690
  "if(!__ok)return;}"
619
- + v->preload_script +
691
+ + markerStart
692
+ + v->preload_script
693
+ + markerEnd +
620
694
  "})();";
621
695
  std::wstring wpreload = utf8ToWide(body);
622
696
  auto lt = lifetime;
697
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu addscript-request",
698
+ view_id, static_cast<unsigned long long>(GetTickCount64()));
623
699
  v->webview->AddScriptToExecuteOnDocumentCreated(
624
700
  wpreload.c_str(),
625
701
  Callback<ICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler>(
626
- [lt, view_id](HRESULT, LPCWSTR id) -> HRESULT {
702
+ [lt, view_id, doInitialNav](HRESULT hr, LPCWSTR id) -> HRESULT {
627
703
  if (!lt || !lt->alive.load()) return S_OK;
704
+ if (FAILED(hr)) {
705
+ BUNITE_ERROR("AddScriptToExecuteOnDocumentCreated failed hr=0x%08x", static_cast<unsigned>(hr));
706
+ }
628
707
  ViewHost* vv = getView(view_id);
629
708
  if (vv && id) vv->add_script_id = id;
709
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu addscript-completion hr=0x%08x",
710
+ view_id, static_cast<unsigned long long>(GetTickCount64()),
711
+ static_cast<unsigned>(hr));
712
+ doInitialNav();
630
713
  return S_OK;
631
714
  }).Get());
632
- }
633
-
634
- // Initial navigation.
635
- if (!v->url.empty()) {
636
- v->webview->Navigate(utf8ToWide(v->url).c_str());
637
- } else if (!v->html.empty()) {
638
- v->webview->NavigateToString(utf8ToWide(v->html).c_str());
715
+ } else {
716
+ doInitialNav();
639
717
  }
640
718
 
641
719
  emitWebviewEvent(v->id, "view-ready");
@@ -719,6 +797,19 @@ void destroyView(uint32_t id) {
719
797
  vs.erase(std::remove(vs.begin(), vs.end(), v), vs.end());
720
798
  }
721
799
 
800
+ // Remove DevTools event-receiver tokens before tearing down the webview to
801
+ // avoid AVs on event delivery after controller release.
802
+ if (v->oopif_event_tokens_registered && v->webview) {
803
+ ComPtr<ICoreWebView2DevToolsProtocolEventReceiver> attached_r, detached_r;
804
+ if (SUCCEEDED(v->webview->GetDevToolsProtocolEventReceiver(L"Target.attachedToTarget", &attached_r))) {
805
+ attached_r->remove_DevToolsProtocolEventReceived(v->target_attached_token);
806
+ }
807
+ if (SUCCEEDED(v->webview->GetDevToolsProtocolEventReceiver(L"Target.detachedFromTarget", &detached_r))) {
808
+ detached_r->remove_DevToolsProtocolEventReceived(v->target_detached_token);
809
+ }
810
+ v->oopif_event_tokens_registered = false;
811
+ }
812
+
722
813
  // Defer Close() → container destroy → delete across three pump ticks. Edge
723
814
  // gets at least one tick after Close() to settle before its parent HWND
724
815
  // vanishes; see shutdownRuntime's staged drains for the same reason.
@@ -766,9 +857,13 @@ static void attachControllerCallbacks(ViewHost* view) {
766
857
  if (!view->webview) return;
767
858
  auto lifetime = g_runtime.lifetime;
768
859
  uint32_t view_id = view->id;
860
+ // Token reuse OK — controller->Close() releases all add_* handlers.
769
861
  EventRegistrationToken tok;
770
862
 
771
- // NavigationStarting — enforce navigation rules, emit "will-navigate" event.
863
+ // NavigationStarting — emit "will-navigate" (parity with CEF/mac/linux,
864
+ // which fire regardless of allow), then cancel if nav rules say block.
865
+ // Also emit "load-start" for the surfaceEvents stream + stash URI for
866
+ // NavigationCompleted lookup (failure case: get_Source() returns prior URL).
772
867
  view->webview->add_NavigationStarting(
773
868
  Callback<ICoreWebView2NavigationStartingEventHandler>(
774
869
  [lifetime, view_id](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT {
@@ -779,34 +874,149 @@ static void attachControllerCallbacks(ViewHost* view) {
779
874
  args->get_Uri(&uri_raw);
780
875
  std::string url = wideToUtf8(uri_raw);
781
876
  if (uri_raw) CoTaskMemFree(uri_raw);
877
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu nav-starting url=%.200s%s",
878
+ view_id, static_cast<unsigned long long>(GetTickCount64()),
879
+ url.c_str(), url.size() > 200 ? "..." : "");
880
+ emitWebviewEvent(v->id, "will-navigate", url);
782
881
  if (!shouldAllowNavigation(v, url)) {
783
882
  args->put_Cancel(TRUE);
784
883
  return S_OK;
785
884
  }
786
- emitWebviewEvent(v->id, "will-navigate", url);
885
+ UINT64 nav_id = 0;
886
+ args->get_NavigationId(&nav_id);
887
+ g_nav_uris[navKey(view_id, nav_id)] = url;
888
+ emitWebviewEvent(v->id, "load-start", url);
787
889
  return S_OK;
788
890
  }).Get(),
789
891
  &tok);
790
892
 
791
- // NavigationCompletedsurfaced as did-navigate + dom-ready (CEF parity).
792
- view->webview->add_NavigationCompleted(
793
- Callback<ICoreWebView2NavigationCompletedEventHandler>(
794
- [lifetime, view_id](ICoreWebView2* wv, ICoreWebView2NavigationCompletedEventArgs* args) -> HRESULT {
893
+ // SourceChangedURL commit point; map to did-navigate (surfaceEvents
894
+ // `navigate` arm). Distinct from NavigationCompleted which fires later.
895
+ view->webview->add_SourceChanged(
896
+ Callback<ICoreWebView2SourceChangedEventHandler>(
897
+ [lifetime, view_id](ICoreWebView2* wv, ICoreWebView2SourceChangedEventArgs*) -> HRESULT {
795
898
  if (!lifetime || !lifetime->alive.load()) return S_OK;
796
- BOOL ok = FALSE;
797
- args->get_IsSuccess(&ok);
798
- if (!ok) return S_OK;
799
899
  LPWSTR src_raw = nullptr;
800
900
  if (wv) wv->get_Source(&src_raw);
801
901
  std::string url = wideToUtf8(src_raw);
802
902
  if (src_raw) CoTaskMemFree(src_raw);
803
903
  emitWebviewEvent(view_id, "did-navigate", url);
804
- emitWebviewEvent(view_id, "dom-ready", url);
805
904
  return S_OK;
806
905
  }).Get(),
807
906
  &tok);
808
907
 
809
- // DocumentTitleChangedsurface for automation `titleChanged` stream.
908
+ // NavigationCompletedload lifecycle terminator. Success → load-finish
909
+ // + dom-ready; failure → load-fail with WebErrorStatus as reason. Use the
910
+ // URI we stashed at NavigationStarting — get_Source() returns the prior
911
+ // committed URL on provisional-navigation failure.
912
+ view->webview->add_NavigationCompleted(
913
+ Callback<ICoreWebView2NavigationCompletedEventHandler>(
914
+ [lifetime, view_id](ICoreWebView2* wv, ICoreWebView2NavigationCompletedEventArgs* args) -> HRESULT {
915
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
916
+ BOOL ok = FALSE;
917
+ args->get_IsSuccess(&ok);
918
+ UINT64 nav_id = 0;
919
+ args->get_NavigationId(&nav_id);
920
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu nav-completed ok=%d nav_id=%llu",
921
+ view_id, static_cast<unsigned long long>(GetTickCount64()),
922
+ ok ? 1 : 0, static_cast<unsigned long long>(nav_id));
923
+ std::string url;
924
+ auto it = g_nav_uris.find(navKey(view_id, nav_id));
925
+ if (it != g_nav_uris.end()) {
926
+ url = std::move(it->second);
927
+ g_nav_uris.erase(it);
928
+ } else {
929
+ LPWSTR src_raw = nullptr;
930
+ if (wv) wv->get_Source(&src_raw);
931
+ url = wideToUtf8(src_raw);
932
+ if (src_raw) CoTaskMemFree(src_raw);
933
+ }
934
+ if (ok) {
935
+ emitWebviewEvent(view_id, "load-finish", url);
936
+ emitWebviewEvent(view_id, "dom-ready", url);
937
+ } else {
938
+ COREWEBVIEW2_WEB_ERROR_STATUS status = COREWEBVIEW2_WEB_ERROR_STATUS_UNKNOWN;
939
+ args->get_WebErrorStatus(&status);
940
+ std::string payload = "{\"url\":\"" + escapeJsonString(url) +
941
+ "\",\"reason\":\"WebErrorStatus_" + std::to_string(static_cast<int>(status)) + "\"}";
942
+ emitWebviewEvent(view_id, "load-fail", payload);
943
+ }
944
+ return S_OK;
945
+ }).Get(),
946
+ &tok);
947
+
948
+ // Disable default WV2 dialog UI so ScriptDialogOpening drives all dialogs.
949
+ ComPtr<ICoreWebView2Settings> settings;
950
+ if (SUCCEEDED(view->webview->get_Settings(&settings)) && settings) {
951
+ settings->put_AreDefaultScriptDialogsEnabled(FALSE);
952
+ }
953
+
954
+ // ScriptDialogOpening — alert / confirm / prompt / beforeunload. Defer the
955
+ // event so host can decide via `respondToDialog`.
956
+ HRESULT dlg_hr = view->webview->add_ScriptDialogOpening(
957
+ Callback<ICoreWebView2ScriptDialogOpeningEventHandler>(
958
+ [lifetime, view_id](ICoreWebView2*, ICoreWebView2ScriptDialogOpeningEventArgs* args) -> HRESULT {
959
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
960
+ ViewHost* v = getView(view_id);
961
+ if (!v) return S_OK;
962
+ ComPtr<ICoreWebView2Deferral> deferral;
963
+ args->GetDeferral(&deferral);
964
+ COREWEBVIEW2_SCRIPT_DIALOG_KIND kind = COREWEBVIEW2_SCRIPT_DIALOG_KIND_ALERT;
965
+ args->get_Kind(&kind);
966
+ LPWSTR msg_raw = nullptr;
967
+ args->get_Message(&msg_raw);
968
+ std::string message = wideToUtf8(msg_raw);
969
+ if (msg_raw) CoTaskMemFree(msg_raw);
970
+ LPWSTR def_raw = nullptr;
971
+ args->get_DefaultText(&def_raw);
972
+ std::string default_prompt = wideToUtf8(def_raw);
973
+ if (def_raw) CoTaskMemFree(def_raw);
974
+ const char* kind_str = (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_CONFIRM) ? "confirm"
975
+ : (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_PROMPT) ? "prompt"
976
+ : (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_BEFOREUNLOAD) ? "beforeunload"
977
+ : "alert";
978
+ const uint32_t rid = v->next_dialog_request_id++;
979
+ v->pending_dialogs[rid] = ViewHost::PendingDialog{ args, std::move(deferral) };
980
+ BUNITE_INFO("webview2/dialog: ScriptDialogOpening view=%u kind=%s rid=%u",
981
+ view_id, kind_str, rid);
982
+ std::string payload = "{\"requestId\":" + std::to_string(rid) +
983
+ ",\"kind\":\"" + kind_str +
984
+ "\",\"message\":\"" + escapeJsonString(message) + "\"";
985
+ if (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_PROMPT) {
986
+ payload += ",\"defaultPrompt\":\"" + escapeJsonString(default_prompt) + "\"";
987
+ }
988
+ payload += "}";
989
+ emitWebviewEvent(view_id, "dialog", payload);
990
+ return S_OK;
991
+ }).Get(),
992
+ &tok);
993
+ BUNITE_INFO("webview2/dialog: add_ScriptDialogOpening view=%u hr=0x%08x token=%lld",
994
+ view_id, static_cast<unsigned>(dlg_hr), static_cast<long long>(tok.value));
995
+
996
+ // Capture preload IIFE markers. Production IPC is on the encrypted WS, so
997
+ // skip registering entirely when markers aren't emitted.
998
+ if (buniteShouldLog(BuniteLogLevel::Info)) {
999
+ view->webview->add_WebMessageReceived(
1000
+ Callback<ICoreWebView2WebMessageReceivedEventHandler>(
1001
+ [lifetime, view_id](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {
1002
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
1003
+ LPWSTR raw = nullptr;
1004
+ if (FAILED(args->TryGetWebMessageAsString(&raw)) || !raw) return S_OK;
1005
+ std::string s = wideToUtf8(raw);
1006
+ CoTaskMemFree(raw);
1007
+ if (s == "__bunite_preload_iife_start__") {
1008
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu preload-iife-start",
1009
+ view_id, static_cast<unsigned long long>(GetTickCount64()));
1010
+ } else if (s == "__bunite_preload_iife_end__") {
1011
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu preload-iife-end",
1012
+ view_id, static_cast<unsigned long long>(GetTickCount64()));
1013
+ }
1014
+ return S_OK;
1015
+ }).Get(),
1016
+ &tok);
1017
+ }
1018
+
1019
+ // DocumentTitleChanged — surface for automation surfaceEvents title-change arm.
810
1020
  view->webview->add_DocumentTitleChanged(
811
1021
  Callback<ICoreWebView2DocumentTitleChangedEventHandler>(
812
1022
  [lifetime, view_id](ICoreWebView2* wv, IUnknown*) -> HRESULT {
@@ -853,19 +1063,87 @@ static void attachControllerCallbacks(ViewHost* view) {
853
1063
  }).Get(),
854
1064
  &tok);
855
1065
 
856
- // NewWindowRequested — block by default (matches plan), bubble event so the
857
- // host can decide to open externally.
1066
+ // NewWindowRequested — eager-mint a popup ViewHost with the requested
1067
+ // CoreWebView2 (preserves window.opener) and emit `popup-requested`. Host
1068
+ // adopts via `bunite_view_popup_accept` or rejects via `bunite_view_popup_dismiss`;
1069
+ // SurfaceManager arms a 5s timer for the auto-dismiss safety net.
858
1070
  view->webview->add_NewWindowRequested(
859
1071
  Callback<ICoreWebView2NewWindowRequestedEventHandler>(
860
- [lifetime, view_id](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
1072
+ [lifetime, view_id](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args_raw) -> HRESULT {
861
1073
  if (!lifetime || !lifetime->alive.load()) return S_OK;
862
- args->put_Handled(TRUE);
1074
+ ComPtr<ICoreWebView2NewWindowRequestedEventArgs> args(args_raw);
863
1075
  LPWSTR uri_raw = nullptr;
864
1076
  args->get_Uri(&uri_raw);
865
1077
  std::string url = wideToUtf8(uri_raw);
866
1078
  if (uri_raw) CoTaskMemFree(uri_raw);
867
- std::string payload = "{\"url\":\"" + escapeJsonString(url) + "\"}";
868
- emitWebviewEvent(view_id, "new-window-open", payload);
1079
+ ComPtr<ICoreWebView2Deferral> deferral;
1080
+ args->GetDeferral(&deferral);
1081
+ args->put_Handled(TRUE);
1082
+ // Popup IDs live in the upper u32 half; TS allocator stays below.
1083
+ static std::atomic<uint32_t> g_popup_seq{0x80000000u};
1084
+ uint32_t new_view_id = g_popup_seq.fetch_add(1);
1085
+ auto* popup_raw = new ViewHost();
1086
+ popup_raw->id = new_view_id;
1087
+ popup_raw->window = nullptr;
1088
+ popup_raw->bounds = RECT{0, 0, 0, 0};
1089
+ popup_raw->auto_resize = false;
1090
+ popup_raw->container_hwnd = CreateWindowExW(
1091
+ 0, kViewContainerClass, L"", WS_CHILD | WS_CLIPCHILDREN,
1092
+ 0, 0, 0, 0, g_runtime.popup_parent,
1093
+ nullptr, getCurrentModuleHandle(), nullptr);
1094
+ {
1095
+ std::lock_guard<std::mutex> g(g_runtime.object_mutex);
1096
+ g_runtime.views_by_id[new_view_id] = popup_raw;
1097
+ }
1098
+ auto cleanupPopup = [new_view_id]() {
1099
+ auto* p = getView(new_view_id);
1100
+ if (!p) return;
1101
+ if (p->container_hwnd) DestroyWindow(p->container_hwnd);
1102
+ {
1103
+ std::lock_guard<std::mutex> g(g_runtime.object_mutex);
1104
+ g_runtime.views_by_id.erase(new_view_id);
1105
+ }
1106
+ delete p;
1107
+ };
1108
+ HRESULT sync_hr = g_runtime.env->CreateCoreWebView2Controller(
1109
+ popup_raw->container_hwnd,
1110
+ Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
1111
+ [lifetime, view_id, new_view_id, url, args, deferral, cleanupPopup](HRESULT hr, ICoreWebView2Controller* controller) -> HRESULT {
1112
+ if (!lifetime || !lifetime->alive.load()) {
1113
+ if (deferral) deferral->Complete();
1114
+ if (controller) controller->Close(); // close the orphan controller too.
1115
+ cleanupPopup();
1116
+ return S_OK;
1117
+ }
1118
+ auto* popup = getView(new_view_id);
1119
+ if (!popup || FAILED(hr) || !controller) {
1120
+ if (deferral) deferral->Complete();
1121
+ if (controller) controller->Close(); // popup was dismissed during creation — close the orphan.
1122
+ cleanupPopup();
1123
+ return S_OK;
1124
+ }
1125
+ popup->controller = controller;
1126
+ controller->get_CoreWebView2(&popup->webview);
1127
+ if (popup->webview) {
1128
+ args->put_NewWindow(popup->webview.Get());
1129
+ }
1130
+ controller->put_IsVisible(FALSE);
1131
+ RECT zero{0,0,0,0};
1132
+ controller->put_Bounds(zero);
1133
+ attachControllerCallbacks(popup);
1134
+ attachAppResFilter(popup);
1135
+ popup->ready.store(true);
1136
+ if (deferral) deferral->Complete();
1137
+ std::string payload = "{\"newSurfaceId\":" + std::to_string(new_view_id) +
1138
+ ",\"url\":\"" + escapeJsonString(url) +
1139
+ "\",\"disposition\":\"popup\"}";
1140
+ emitWebviewEvent(view_id, "popup-requested", payload);
1141
+ return S_OK;
1142
+ }).Get());
1143
+ if (FAILED(sync_hr)) {
1144
+ if (deferral) deferral->Complete();
1145
+ cleanupPopup();
1146
+ }
869
1147
  return S_OK;
870
1148
  }).Get(),
871
1149
  &tok);
@@ -881,13 +1159,15 @@ static void attachControllerCallbacks(ViewHost* view) {
881
1159
  }).Get(),
882
1160
  &tok);
883
1161
 
884
- // DownloadStarting (ICoreWebView2_4) — suppress by default.
1162
+ // DownloadStarting (ICoreWebView2_4) — policy-driven: block | auto | ask.
1163
+ // Default block preserves the original behavior.
885
1164
  ComPtr<ICoreWebView2_4> wv4;
886
1165
  view->webview->QueryInterface(IID_PPV_ARGS(&wv4));
887
1166
  if (wv4) {
1167
+ static std::atomic<uint32_t> g_download_seq{1};
888
1168
  wv4->add_DownloadStarting(
889
1169
  Callback<ICoreWebView2DownloadStartingEventHandler>(
890
- [lifetime, view_id](ICoreWebView2*, ICoreWebView2DownloadStartingEventArgs* args) -> HRESULT {
1170
+ [lifetime, view_id, view](ICoreWebView2*, ICoreWebView2DownloadStartingEventArgs* args) -> HRESULT {
891
1171
  if (!lifetime || !lifetime->alive.load()) return S_OK;
892
1172
  ComPtr<ICoreWebView2DownloadOperation> op;
893
1173
  args->get_DownloadOperation(&op);
@@ -895,9 +1175,98 @@ static void attachControllerCallbacks(ViewHost* view) {
895
1175
  if (op) op->get_Uri(&uri_raw);
896
1176
  std::string url = wideToUtf8(uri_raw);
897
1177
  if (uri_raw) CoTaskMemFree(uri_raw);
898
- args->put_Cancel(TRUE);
899
- std::string payload = "{\"url\":\"" + escapeJsonString(url) + "\"}";
900
- emitWebviewEvent(view_id, "download-blocked", payload);
1178
+ int32_t policy = view->download_policy.load();
1179
+ const std::string id = "wv2-" + std::to_string(g_download_seq.fetch_add(1));
1180
+ // Only policy=0 (auto) allows. `ask` (1) falls back to block
1181
+ // until implemented — distinguish via blocked.reason.
1182
+ if (policy != 0) {
1183
+ args->put_Cancel(TRUE);
1184
+ const char* reason = (policy == 1) ? "ask-not-implemented" : "host-policy";
1185
+ std::string payload = "{\"kind\":\"blocked\",\"id\":\"" + id +
1186
+ "\",\"url\":\"" + escapeJsonString(url) +
1187
+ "\",\"reason\":\"" + reason + "\"}";
1188
+ emitWebviewEvent(view_id, "download-event", payload);
1189
+ return S_OK;
1190
+ }
1191
+ // auto: don't cancel; report started + progress + completed.
1192
+ LPWSTR sugg_raw = nullptr;
1193
+ if (op) op->get_ContentDisposition(&sugg_raw); // fallback
1194
+ LPWSTR result_path_raw = nullptr;
1195
+ args->get_ResultFilePath(&result_path_raw);
1196
+ std::string suggested = result_path_raw ? wideToUtf8(result_path_raw) : "";
1197
+ // strip dir, keep filename only.
1198
+ auto slash = suggested.find_last_of("/\\");
1199
+ if (slash != std::string::npos) suggested = suggested.substr(slash + 1);
1200
+ if (sugg_raw) CoTaskMemFree(sugg_raw);
1201
+ // Optional host downloadDir override.
1202
+ std::string overrideDir = view->download_dir;
1203
+ if (!overrideDir.empty() && result_path_raw) {
1204
+ std::string base = suggested.empty() ? "download" : suggested;
1205
+ std::string custom = overrideDir;
1206
+ if (!custom.empty() && custom.back() != '\\' && custom.back() != '/') custom.push_back('\\');
1207
+ custom += base;
1208
+ args->put_ResultFilePath(utf8ToWide(custom).c_str());
1209
+ }
1210
+ if (result_path_raw) CoTaskMemFree(result_path_raw);
1211
+ int64_t total = 0;
1212
+ if (op) op->get_TotalBytesToReceive(&total);
1213
+ std::string startPayload = "{\"kind\":\"started\",\"id\":\"" + id +
1214
+ "\",\"url\":\"" + escapeJsonString(url) +
1215
+ "\",\"suggestedFilename\":\"" + escapeJsonString(suggested) + "\"";
1216
+ if (total > 0) startPayload += ",\"sizeBytes\":" + std::to_string(total);
1217
+ startPayload += "}";
1218
+ emitWebviewEvent(view_id, "download-event", startPayload);
1219
+ if (op) {
1220
+ // Tokens kept in a shared struct so the StateChanged terminal
1221
+ // branch can self-unregister both handlers — breaks the
1222
+ // op→handler→op ComPtr ref cycle that otherwise leaks per download.
1223
+ struct Tokens { EventRegistrationToken b{}; EventRegistrationToken s{}; };
1224
+ auto toks = std::make_shared<Tokens>();
1225
+ op->add_BytesReceivedChanged(
1226
+ Callback<ICoreWebView2BytesReceivedChangedEventHandler>(
1227
+ [lifetime, view_id, id, op](ICoreWebView2DownloadOperation*, IUnknown*) -> HRESULT {
1228
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
1229
+ INT64 rec = 0; op->get_BytesReceived(&rec);
1230
+ INT64 tot = 0; op->get_TotalBytesToReceive(&tot);
1231
+ std::string payload = "{\"kind\":\"progress\",\"id\":\"" + id +
1232
+ "\",\"receivedBytes\":" + std::to_string(rec);
1233
+ if (tot > 0) payload += ",\"totalBytes\":" + std::to_string(tot);
1234
+ payload += "}";
1235
+ emitWebviewEvent(view_id, "download-event", payload);
1236
+ return S_OK;
1237
+ }).Get(),
1238
+ &toks->b);
1239
+ op->add_StateChanged(
1240
+ Callback<ICoreWebView2StateChangedEventHandler>(
1241
+ [lifetime, view_id, id, op, toks](ICoreWebView2DownloadOperation*, IUnknown*) -> HRESULT {
1242
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
1243
+ COREWEBVIEW2_DOWNLOAD_STATE state;
1244
+ op->get_State(&state);
1245
+ bool terminal = false;
1246
+ if (state == COREWEBVIEW2_DOWNLOAD_STATE_COMPLETED) {
1247
+ terminal = true;
1248
+ LPWSTR path_raw = nullptr; op->get_ResultFilePath(&path_raw);
1249
+ std::string path = wideToUtf8(path_raw);
1250
+ if (path_raw) CoTaskMemFree(path_raw);
1251
+ std::string payload = "{\"kind\":\"completed\",\"id\":\"" + id +
1252
+ "\",\"localPath\":\"" + escapeJsonString(path) + "\"}";
1253
+ emitWebviewEvent(view_id, "download-event", payload);
1254
+ } else if (state == COREWEBVIEW2_DOWNLOAD_STATE_INTERRUPTED) {
1255
+ terminal = true;
1256
+ COREWEBVIEW2_DOWNLOAD_INTERRUPT_REASON reason;
1257
+ op->get_InterruptReason(&reason);
1258
+ std::string payload = "{\"kind\":\"failed\",\"id\":\"" + id +
1259
+ "\",\"reason\":\"interrupted-" + std::to_string(reason) + "\"}";
1260
+ emitWebviewEvent(view_id, "download-event", payload);
1261
+ }
1262
+ if (terminal) {
1263
+ op->remove_BytesReceivedChanged(toks->b);
1264
+ op->remove_StateChanged(toks->s);
1265
+ }
1266
+ return S_OK;
1267
+ }).Get(),
1268
+ &toks->s);
1269
+ }
901
1270
  return S_OK;
902
1271
  }).Get(),
903
1272
  &tok);