bunite-core 0.14.0 → 0.17.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 (33) hide show
  1. package/package.json +4 -4
  2. package/src/host/core/App.ts +6 -3
  3. package/src/host/core/BrowserView.ts +345 -24
  4. package/src/host/core/BrowserWindow.ts +52 -6
  5. package/src/host/core/SurfaceBrowserIPC.ts +10 -1
  6. package/src/host/core/SurfaceManager.ts +357 -16
  7. package/src/host/core/windowCap.ts +69 -0
  8. package/src/host/events/webviewEvents.ts +18 -1
  9. package/src/host/log.ts +6 -1
  10. package/src/host/native.ts +145 -1
  11. package/src/host/preloadBundle.ts +7 -2
  12. package/src/native/linux/bunite_linux_ffi.cpp +225 -1
  13. package/src/native/linux/bunite_linux_internal.h +12 -0
  14. package/src/native/linux/bunite_linux_runtime.cpp +6 -1
  15. package/src/native/linux/bunite_linux_view.cpp +211 -5
  16. package/src/native/mac/bunite_mac_ffi.mm +293 -4
  17. package/src/native/mac/bunite_mac_internal.h +13 -0
  18. package/src/native/mac/bunite_mac_view.mm +227 -7
  19. package/src/native/shared/ffi_exports.h +97 -30
  20. package/src/native/win/native_host_cef.cpp +107 -13
  21. package/src/native/win/native_host_ffi.cpp +831 -2
  22. package/src/native/win/native_host_internal.h +22 -0
  23. package/src/native/win/native_host_runtime.cpp +34 -0
  24. package/src/native/win-webview2/bunite_webview2_ffi.cpp +827 -5
  25. package/src/native/win-webview2/webview2_internal.h +19 -0
  26. package/src/native/win-webview2/webview2_runtime.cpp +383 -31
  27. package/src/preload/runtime.built.js +1 -1
  28. package/src/preload/runtime.ts +39 -0
  29. package/src/rpc/framework.ts +194 -12
  30. package/src/rpc/index.ts +12 -0
  31. package/src/rpc/peer.ts +1 -1
  32. package/src/webview/native.ts +142 -32
  33. package/src/webview/polyfill.ts +91 -14
@@ -88,6 +88,10 @@ struct ViewHost {
88
88
  std::atomic<bool> ready{false};
89
89
  std::atomic<bool> closing{false};
90
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
+
91
95
  // Pending page-initiated dialogs (alert/confirm/prompt/beforeunload).
92
96
  // ScriptDialogOpening hands us a `Deferral` we Complete() on host response.
93
97
  struct PendingDialog {
@@ -96,6 +100,15 @@ struct ViewHost {
96
100
  };
97
101
  std::unordered_map<uint32_t, PendingDialog> pending_dialogs;
98
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;
99
112
  };
100
113
 
101
114
  struct WindowHost {
@@ -110,12 +123,18 @@ struct WindowHost {
110
123
  std::atomic<bool> close_pending{false};
111
124
  std::atomic<bool> closing{false};
112
125
  std::vector<ViewHost*> views;
126
+ // Capture-based move-drag (no WM_NCLBUTTONDOWN modal loop — see
127
+ // bunite_window_begin_move_drag + windowProc).
128
+ bool drag_active = false;
129
+ POINT drag_anchor_cursor{}; // screen cursor at drag start
130
+ POINT drag_anchor_origin{}; // window top-left at drag start
113
131
  };
114
132
 
115
133
  struct RuntimeState {
116
134
  std::atomic<bool> initialized{false};
117
135
  std::atomic<bool> shutting_down{false};
118
136
  HWND message_window = nullptr;
137
+ HWND popup_parent = nullptr; // hidden top-level parking parent for popup-minted controllers.
119
138
 
120
139
  std::mutex task_mutex;
121
140
  std::deque<std::function<void()>> queued_tasks;
@@ -132,6 +132,15 @@ bool registerWindowClasses() {
132
132
  getCurrentModuleHandle(), nullptr);
133
133
  if (!g_runtime.message_window) return false;
134
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
+
135
144
  // `bun run` passes STARTF_USESHOWWINDOW + SW_HIDE; Win documented behavior
136
145
  // is for the first ShowWindow call to use STARTUPINFO.wShowWindow instead
137
146
  // of the requested nCmdShow. Consume it here on the message window so the
@@ -255,13 +264,103 @@ static void layoutViewsForWindow(WindowHost* w) {
255
264
  for (ViewHost* v : w->views) applyViewLayout(v);
256
265
  }
257
266
 
267
+ // Set drag_active=false before ReleaseCapture so the WM_CAPTURECHANGED it
268
+ // posts is a no-op (re-entrancy guard).
269
+ static void endMoveDrag(WindowHost* w, HWND hwnd) {
270
+ w->drag_active = false;
271
+ if (GetCapture() == hwnd) ReleaseCapture();
272
+ }
273
+
258
274
  LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
259
275
  switch (msg) {
276
+ case WM_NCCALCSIZE: {
277
+ // Frameless windows keep WS_THICKFRAME (Win11 snap + native resize), so
278
+ // DefWindowProc reserves a sizing frame on every edge. The HTML titlebar
279
+ // fills the client rect, leaving the DWM frame exposed as thin bands.
280
+ if (wp != TRUE) break;
281
+ WindowHost* w = findWindowByHwnd(hwnd);
282
+ if (!w || (w->title_bar_style != L"hidden" && w->title_bar_style != L"hiddenInset"))
283
+ break;
284
+ auto* p = reinterpret_cast<NCCALCSIZE_PARAMS*>(lp);
285
+ // Maximized: the window rect is clamped to the work area (WM_GETMINMAXINFO),
286
+ // so there is no off-screen frame — reclaim every edge to fill it with no
287
+ // frame slivers. Restored: reclaim only the top edge so the titlebar reaches
288
+ // y=0 (kills the top light band); keep L/R/B borders grabbable for native
289
+ // resize. The top is the HTML titlebar (move region), not a resize edge.
290
+ if (IsZoomed(hwnd)) return 0;
291
+ LONG top = p->rgrc[0].top;
292
+ LRESULT r = DefWindowProcW(hwnd, msg, wp, lp);
293
+ p->rgrc[0].top = top;
294
+ return r;
295
+ }
296
+ case WM_MOUSEMOVE: {
297
+ WindowHost* w = findWindowByHwnd(hwnd);
298
+ if (!w || !w->drag_active) break;
299
+ if (!(wp & MK_LBUTTON)) { endMoveDrag(w, hwnd); return 0; }
300
+ POINT cur;
301
+ if (GetCursorPos(&cur)) {
302
+ SetWindowPos(hwnd, nullptr,
303
+ w->drag_anchor_origin.x + (cur.x - w->drag_anchor_cursor.x),
304
+ w->drag_anchor_origin.y + (cur.y - w->drag_anchor_cursor.y),
305
+ 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
306
+ }
307
+ return 0; // don't deref w past SetWindowPos (may re-enter windowProc)
308
+ }
309
+ case WM_LBUTTONUP: {
310
+ WindowHost* w = findWindowByHwnd(hwnd);
311
+ if (w && w->drag_active) { endMoveDrag(w, hwnd); return 0; }
312
+ break;
313
+ }
314
+ case WM_CAPTURECHANGED:
315
+ case WM_CANCELMODE: {
316
+ WindowHost* w = findWindowByHwnd(hwnd);
317
+ if (w && w->drag_active) endMoveDrag(w, hwnd);
318
+ break;
319
+ }
260
320
  case WM_SIZE: {
261
321
  WindowHost* w = findWindowByHwnd(hwnd);
262
- if (w) layoutViewsForWindow(w);
322
+ if (!w) break;
323
+ layoutViewsForWindow(w);
324
+ RECT r{};
325
+ GetWindowRect(hwnd, &r);
326
+ std::string payload = "{\"x\":" + std::to_string(r.left) + ",\"y\":" + std::to_string(r.top) +
327
+ ",\"width\":" + std::to_string(r.right - r.left) + ",\"height\":" + std::to_string(r.bottom - r.top) +
328
+ ",\"maximized\":" + (wp == SIZE_MAXIMIZED ? "true" : "false") +
329
+ ",\"minimized\":" + (wp == SIZE_MINIMIZED ? "true" : "false") + "}";
330
+ emitWindowEvent(w->id, "resize", payload);
263
331
  return 0;
264
332
  }
333
+ case WM_MOVE: {
334
+ WindowHost* w = findWindowByHwnd(hwnd);
335
+ if (w) {
336
+ RECT r{};
337
+ GetWindowRect(hwnd, &r);
338
+ emitWindowEvent(w->id, "move",
339
+ "{\"x\":" + std::to_string(r.left) + ",\"y\":" + std::to_string(r.top) +
340
+ ",\"maximized\":" + (IsZoomed(hwnd) ? "true" : "false") +
341
+ ",\"minimized\":" + (IsIconic(hwnd) ? "true" : "false") + "}");
342
+ }
343
+ break;
344
+ }
345
+ case WM_GETMINMAXINFO: {
346
+ // Frameless (WS_POPUP) windows maximize over the taskbar unless clamped
347
+ // to the monitor work area.
348
+ WindowHost* w = findWindowByHwnd(hwnd);
349
+ if (w && (w->title_bar_style == L"hidden" || w->title_bar_style == L"hiddenInset")) {
350
+ HMONITOR mon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST);
351
+ MONITORINFO mi{ sizeof(mi) };
352
+ if (GetMonitorInfoW(mon, &mi)) {
353
+ auto* mmi = reinterpret_cast<MINMAXINFO*>(lp);
354
+ mmi->ptMaxPosition.x = mi.rcWork.left - mi.rcMonitor.left;
355
+ mmi->ptMaxPosition.y = mi.rcWork.top - mi.rcMonitor.top;
356
+ mmi->ptMaxSize.x = mi.rcWork.right - mi.rcWork.left;
357
+ mmi->ptMaxSize.y = mi.rcWork.bottom - mi.rcWork.top;
358
+ mmi->ptMaxTrackSize = mmi->ptMaxSize;
359
+ return 0;
360
+ }
361
+ }
362
+ break;
363
+ }
265
364
  case WM_CLOSE: {
266
365
  WindowHost* w = findWindowByHwnd(hwnd);
267
366
  if (w && !w->close_pending.load()) {
@@ -272,19 +371,17 @@ LRESULT CALLBACK windowProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
272
371
  DestroyWindow(hwnd);
273
372
  return 0;
274
373
  }
275
- case WM_SETFOCUS:
276
374
  case WM_ACTIVATE: {
277
375
  WindowHost* w = findWindowByHwnd(hwnd);
278
- if (w) emitWindowEvent(w->id, "focus");
376
+ // Top-level activation drives focus (not WM_SETFOCUS, which can be child focus).
377
+ if (w) emitWindowEvent(w->id, LOWORD(wp) == WA_INACTIVE ? "blur" : "focus");
279
378
  break;
280
379
  }
281
- case WM_KILLFOCUS: {
380
+ case WM_DESTROY: {
282
381
  WindowHost* w = findWindowByHwnd(hwnd);
283
- if (w) emitWindowEvent(w->id, "blur");
284
- break;
285
- }
286
- case WM_DESTROY:
382
+ if (w && w->drag_active) endMoveDrag(w, hwnd);
287
383
  return 0;
384
+ }
288
385
  }
289
386
  return DefWindowProcW(hwnd, msg, wp, lp);
290
387
  }
@@ -506,6 +603,14 @@ bool createWindow(uint32_t window_id, double x, double y, double w, double h,
506
603
  g_runtime.windows_by_id[window_id] = host;
507
604
  }
508
605
 
606
+ // CreateWindowExW dispatched WM_NCCALCSIZE before the window was registered,
607
+ // so the frameless top-frame reclaim (windowProc) couldn't run yet. Force a
608
+ // frame recalc now that findWindowByHwnd can resolve it.
609
+ if (host->title_bar_style == L"hidden" || host->title_bar_style == L"hiddenInset") {
610
+ SetWindowPos(hwnd, nullptr, 0, 0, 0, 0,
611
+ SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
612
+ }
613
+
509
614
  if (maximized) ShowWindow(hwnd, SW_MAXIMIZE);
510
615
  else if (minimized) ShowWindow(hwnd, SW_MINIMIZE);
511
616
  else if (!hidden) ShowWindow(hwnd, SW_SHOW);
@@ -634,15 +739,42 @@ static void wireView(ViewHost* view, std::function<void()> on_attached) {
634
739
  // - main frame only (matching CEF's OnContextCreated main-frame gate)
635
740
  // - origin allowlist when `preload_origins` is non-empty
636
741
  // Empty allowlist = inject on every main frame (CEF parity default).
637
- if (!v->preload_script.empty() && v->webview) {
742
+ //
743
+ // AddScriptToExecuteOnDocumentCreated is async — registration must
744
+ // complete BEFORE Navigate() or the first document load runs without
745
+ // the preload. Defer Navigate into the completion handler.
746
+ const bool inject = !v->preload_script.empty() && v->webview;
747
+ auto doInitialNav = [v]() {
748
+ if (!v->url.empty()) {
749
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu navigate-call url=%.200s%s",
750
+ v->id, static_cast<unsigned long long>(GetTickCount64()),
751
+ v->url.c_str(), v->url.size() > 200 ? "..." : "");
752
+ v->webview->Navigate(utf8ToWide(v->url).c_str());
753
+ } else if (!v->html.empty()) {
754
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu navigate-tostring-call",
755
+ v->id, static_cast<unsigned long long>(GetTickCount64()));
756
+ v->webview->NavigateToString(utf8ToWide(v->html).c_str());
757
+ }
758
+ };
759
+ if (inject) {
638
760
  std::string allowlist = "[";
639
761
  for (size_t i = 0; i < v->preload_origins.size(); ++i) {
640
762
  if (i) allowlist += ",";
641
763
  allowlist += "\"" + escapeJsonString(v->preload_origins[i]) + "\"";
642
764
  }
643
765
  allowlist += "]";
766
+ // Diagnostic IIFE-start/end markers, gated by log level. Leading
767
+ // `;` defends against preload_script with no trailing semicolon.
768
+ const bool emitMarkers = buniteShouldLog(BuniteLogLevel::Info);
769
+ const char* markerStart = emitMarkers
770
+ ? ";try{(self.chrome&&chrome.webview&&chrome.webview.postMessage)&&chrome.webview.postMessage('__bunite_preload_iife_start__');}catch(_){}"
771
+ : "";
772
+ const char* markerEnd = emitMarkers
773
+ ? ";try{(self.chrome&&chrome.webview&&chrome.webview.postMessage)&&chrome.webview.postMessage('__bunite_preload_iife_end__');}catch(_){}"
774
+ : "";
644
775
  std::string body =
645
- "(function(){if(window.self!==window.top)return;"
776
+ "(function(){"
777
+ "if(window.self!==window.top)return;"
646
778
  "var __a=" + allowlist +
647
779
  ",__o=location.origin;"
648
780
  "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;};"
@@ -652,26 +784,32 @@ static void wireView(ViewHost* view, std::function<void()> on_attached) {
652
784
  "while(i<p.length&&p[i]===\"*\")i++;return i===p.length;};"
653
785
  "var __ok=false;for(var i=0;i<__a.length;i++){if(__m(__a[i],__o)){__ok=true;break;}}"
654
786
  "if(!__ok)return;}"
655
- + v->preload_script +
787
+ + markerStart
788
+ + v->preload_script
789
+ + markerEnd +
656
790
  "})();";
657
791
  std::wstring wpreload = utf8ToWide(body);
658
792
  auto lt = lifetime;
793
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu addscript-request",
794
+ view_id, static_cast<unsigned long long>(GetTickCount64()));
659
795
  v->webview->AddScriptToExecuteOnDocumentCreated(
660
796
  wpreload.c_str(),
661
797
  Callback<ICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler>(
662
- [lt, view_id](HRESULT, LPCWSTR id) -> HRESULT {
798
+ [lt, view_id, doInitialNav](HRESULT hr, LPCWSTR id) -> HRESULT {
663
799
  if (!lt || !lt->alive.load()) return S_OK;
800
+ if (FAILED(hr)) {
801
+ BUNITE_ERROR("AddScriptToExecuteOnDocumentCreated failed hr=0x%08x", static_cast<unsigned>(hr));
802
+ }
664
803
  ViewHost* vv = getView(view_id);
665
804
  if (vv && id) vv->add_script_id = id;
805
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu addscript-completion hr=0x%08x",
806
+ view_id, static_cast<unsigned long long>(GetTickCount64()),
807
+ static_cast<unsigned>(hr));
808
+ doInitialNav();
666
809
  return S_OK;
667
810
  }).Get());
668
- }
669
-
670
- // Initial navigation.
671
- if (!v->url.empty()) {
672
- v->webview->Navigate(utf8ToWide(v->url).c_str());
673
- } else if (!v->html.empty()) {
674
- v->webview->NavigateToString(utf8ToWide(v->html).c_str());
811
+ } else {
812
+ doInitialNav();
675
813
  }
676
814
 
677
815
  emitWebviewEvent(v->id, "view-ready");
@@ -755,6 +893,19 @@ void destroyView(uint32_t id) {
755
893
  vs.erase(std::remove(vs.begin(), vs.end(), v), vs.end());
756
894
  }
757
895
 
896
+ // Remove DevTools event-receiver tokens before tearing down the webview to
897
+ // avoid AVs on event delivery after controller release.
898
+ if (v->oopif_event_tokens_registered && v->webview) {
899
+ ComPtr<ICoreWebView2DevToolsProtocolEventReceiver> attached_r, detached_r;
900
+ if (SUCCEEDED(v->webview->GetDevToolsProtocolEventReceiver(L"Target.attachedToTarget", &attached_r))) {
901
+ attached_r->remove_DevToolsProtocolEventReceived(v->target_attached_token);
902
+ }
903
+ if (SUCCEEDED(v->webview->GetDevToolsProtocolEventReceiver(L"Target.detachedFromTarget", &detached_r))) {
904
+ detached_r->remove_DevToolsProtocolEventReceived(v->target_detached_token);
905
+ }
906
+ v->oopif_event_tokens_registered = false;
907
+ }
908
+
758
909
  // Defer Close() → container destroy → delete across three pump ticks. Edge
759
910
  // gets at least one tick after Close() to settle before its parent HWND
760
911
  // vanishes; see shutdownRuntime's staged drains for the same reason.
@@ -819,6 +970,9 @@ static void attachControllerCallbacks(ViewHost* view) {
819
970
  args->get_Uri(&uri_raw);
820
971
  std::string url = wideToUtf8(uri_raw);
821
972
  if (uri_raw) CoTaskMemFree(uri_raw);
973
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu nav-starting url=%.200s%s",
974
+ view_id, static_cast<unsigned long long>(GetTickCount64()),
975
+ url.c_str(), url.size() > 200 ? "..." : "");
822
976
  emitWebviewEvent(v->id, "will-navigate", url);
823
977
  if (!shouldAllowNavigation(v, url)) {
824
978
  args->put_Cancel(TRUE);
@@ -859,6 +1013,9 @@ static void attachControllerCallbacks(ViewHost* view) {
859
1013
  args->get_IsSuccess(&ok);
860
1014
  UINT64 nav_id = 0;
861
1015
  args->get_NavigationId(&nav_id);
1016
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu nav-completed ok=%d nav_id=%llu",
1017
+ view_id, static_cast<unsigned long long>(GetTickCount64()),
1018
+ ok ? 1 : 0, static_cast<unsigned long long>(nav_id));
862
1019
  std::string url;
863
1020
  auto it = g_nav_uris.find(navKey(view_id, nav_id));
864
1021
  if (it != g_nav_uris.end()) {
@@ -884,9 +1041,18 @@ static void attachControllerCallbacks(ViewHost* view) {
884
1041
  }).Get(),
885
1042
  &tok);
886
1043
 
1044
+ // Disable default WV2 dialog UI so ScriptDialogOpening drives all dialogs.
1045
+ // NB: NonClientRegionSupport (app-region:drag) is intentionally NOT enabled —
1046
+ // its native window-move runs a modal loop on the shared Bun/UI thread and
1047
+ // freezes JS for the whole drag (spike-confirmed). WV2 drag stays capture-based.
1048
+ ComPtr<ICoreWebView2Settings> settings;
1049
+ if (SUCCEEDED(view->webview->get_Settings(&settings)) && settings) {
1050
+ settings->put_AreDefaultScriptDialogsEnabled(FALSE);
1051
+ }
1052
+
887
1053
  // ScriptDialogOpening — alert / confirm / prompt / beforeunload. Defer the
888
1054
  // event so host can decide via `respondToDialog`.
889
- view->webview->add_ScriptDialogOpening(
1055
+ HRESULT dlg_hr = view->webview->add_ScriptDialogOpening(
890
1056
  Callback<ICoreWebView2ScriptDialogOpeningEventHandler>(
891
1057
  [lifetime, view_id](ICoreWebView2*, ICoreWebView2ScriptDialogOpeningEventArgs* args) -> HRESULT {
892
1058
  if (!lifetime || !lifetime->alive.load()) return S_OK;
@@ -910,6 +1076,8 @@ static void attachControllerCallbacks(ViewHost* view) {
910
1076
  : "alert";
911
1077
  const uint32_t rid = v->next_dialog_request_id++;
912
1078
  v->pending_dialogs[rid] = ViewHost::PendingDialog{ args, std::move(deferral) };
1079
+ BUNITE_INFO("webview2/dialog: ScriptDialogOpening view=%u kind=%s rid=%u",
1080
+ view_id, kind_str, rid);
913
1081
  std::string payload = "{\"requestId\":" + std::to_string(rid) +
914
1082
  ",\"kind\":\"" + kind_str +
915
1083
  "\",\"message\":\"" + escapeJsonString(message) + "\"";
@@ -921,6 +1089,31 @@ static void attachControllerCallbacks(ViewHost* view) {
921
1089
  return S_OK;
922
1090
  }).Get(),
923
1091
  &tok);
1092
+ BUNITE_INFO("webview2/dialog: add_ScriptDialogOpening view=%u hr=0x%08x token=%lld",
1093
+ view_id, static_cast<unsigned>(dlg_hr), static_cast<long long>(tok.value));
1094
+
1095
+ // Capture preload IIFE markers. Production IPC is on the encrypted WS, so
1096
+ // skip registering entirely when markers aren't emitted.
1097
+ if (buniteShouldLog(BuniteLogLevel::Info)) {
1098
+ view->webview->add_WebMessageReceived(
1099
+ Callback<ICoreWebView2WebMessageReceivedEventHandler>(
1100
+ [lifetime, view_id](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT {
1101
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
1102
+ LPWSTR raw = nullptr;
1103
+ if (FAILED(args->TryGetWebMessageAsString(&raw)) || !raw) return S_OK;
1104
+ std::string s = wideToUtf8(raw);
1105
+ CoTaskMemFree(raw);
1106
+ if (s == "__bunite_preload_iife_start__") {
1107
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu preload-iife-start",
1108
+ view_id, static_cast<unsigned long long>(GetTickCount64()));
1109
+ } else if (s == "__bunite_preload_iife_end__") {
1110
+ BUNITE_INFO("webview2/preload-race: view=%u t=%llu preload-iife-end",
1111
+ view_id, static_cast<unsigned long long>(GetTickCount64()));
1112
+ }
1113
+ return S_OK;
1114
+ }).Get(),
1115
+ &tok);
1116
+ }
924
1117
 
925
1118
  // DocumentTitleChanged — surface for automation surfaceEvents title-change arm.
926
1119
  view->webview->add_DocumentTitleChanged(
@@ -969,19 +1162,87 @@ static void attachControllerCallbacks(ViewHost* view) {
969
1162
  }).Get(),
970
1163
  &tok);
971
1164
 
972
- // NewWindowRequested — block by default (matches plan), bubble event so the
973
- // host can decide to open externally.
1165
+ // NewWindowRequested — eager-mint a popup ViewHost with the requested
1166
+ // CoreWebView2 (preserves window.opener) and emit `popup-requested`. Host
1167
+ // adopts via `bunite_view_popup_accept` or rejects via `bunite_view_popup_dismiss`;
1168
+ // SurfaceManager arms a 5s timer for the auto-dismiss safety net.
974
1169
  view->webview->add_NewWindowRequested(
975
1170
  Callback<ICoreWebView2NewWindowRequestedEventHandler>(
976
- [lifetime, view_id](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
1171
+ [lifetime, view_id](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args_raw) -> HRESULT {
977
1172
  if (!lifetime || !lifetime->alive.load()) return S_OK;
978
- args->put_Handled(TRUE);
1173
+ ComPtr<ICoreWebView2NewWindowRequestedEventArgs> args(args_raw);
979
1174
  LPWSTR uri_raw = nullptr;
980
1175
  args->get_Uri(&uri_raw);
981
1176
  std::string url = wideToUtf8(uri_raw);
982
1177
  if (uri_raw) CoTaskMemFree(uri_raw);
983
- std::string payload = "{\"url\":\"" + escapeJsonString(url) + "\"}";
984
- emitWebviewEvent(view_id, "new-window-open", payload);
1178
+ ComPtr<ICoreWebView2Deferral> deferral;
1179
+ args->GetDeferral(&deferral);
1180
+ args->put_Handled(TRUE);
1181
+ // Popup IDs live in the upper u32 half; TS allocator stays below.
1182
+ static std::atomic<uint32_t> g_popup_seq{0x80000000u};
1183
+ uint32_t new_view_id = g_popup_seq.fetch_add(1);
1184
+ auto* popup_raw = new ViewHost();
1185
+ popup_raw->id = new_view_id;
1186
+ popup_raw->window = nullptr;
1187
+ popup_raw->bounds = RECT{0, 0, 0, 0};
1188
+ popup_raw->auto_resize = false;
1189
+ popup_raw->container_hwnd = CreateWindowExW(
1190
+ 0, kViewContainerClass, L"", WS_CHILD | WS_CLIPCHILDREN,
1191
+ 0, 0, 0, 0, g_runtime.popup_parent,
1192
+ nullptr, getCurrentModuleHandle(), nullptr);
1193
+ {
1194
+ std::lock_guard<std::mutex> g(g_runtime.object_mutex);
1195
+ g_runtime.views_by_id[new_view_id] = popup_raw;
1196
+ }
1197
+ auto cleanupPopup = [new_view_id]() {
1198
+ auto* p = getView(new_view_id);
1199
+ if (!p) return;
1200
+ if (p->container_hwnd) DestroyWindow(p->container_hwnd);
1201
+ {
1202
+ std::lock_guard<std::mutex> g(g_runtime.object_mutex);
1203
+ g_runtime.views_by_id.erase(new_view_id);
1204
+ }
1205
+ delete p;
1206
+ };
1207
+ HRESULT sync_hr = g_runtime.env->CreateCoreWebView2Controller(
1208
+ popup_raw->container_hwnd,
1209
+ Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>(
1210
+ [lifetime, view_id, new_view_id, url, args, deferral, cleanupPopup](HRESULT hr, ICoreWebView2Controller* controller) -> HRESULT {
1211
+ if (!lifetime || !lifetime->alive.load()) {
1212
+ if (deferral) deferral->Complete();
1213
+ if (controller) controller->Close(); // close the orphan controller too.
1214
+ cleanupPopup();
1215
+ return S_OK;
1216
+ }
1217
+ auto* popup = getView(new_view_id);
1218
+ if (!popup || FAILED(hr) || !controller) {
1219
+ if (deferral) deferral->Complete();
1220
+ if (controller) controller->Close(); // popup was dismissed during creation — close the orphan.
1221
+ cleanupPopup();
1222
+ return S_OK;
1223
+ }
1224
+ popup->controller = controller;
1225
+ controller->get_CoreWebView2(&popup->webview);
1226
+ if (popup->webview) {
1227
+ args->put_NewWindow(popup->webview.Get());
1228
+ }
1229
+ controller->put_IsVisible(FALSE);
1230
+ RECT zero{0,0,0,0};
1231
+ controller->put_Bounds(zero);
1232
+ attachControllerCallbacks(popup);
1233
+ attachAppResFilter(popup);
1234
+ popup->ready.store(true);
1235
+ if (deferral) deferral->Complete();
1236
+ std::string payload = "{\"newSurfaceId\":" + std::to_string(new_view_id) +
1237
+ ",\"url\":\"" + escapeJsonString(url) +
1238
+ "\",\"disposition\":\"popup\"}";
1239
+ emitWebviewEvent(view_id, "popup-requested", payload);
1240
+ return S_OK;
1241
+ }).Get());
1242
+ if (FAILED(sync_hr)) {
1243
+ if (deferral) deferral->Complete();
1244
+ cleanupPopup();
1245
+ }
985
1246
  return S_OK;
986
1247
  }).Get(),
987
1248
  &tok);
@@ -997,13 +1258,15 @@ static void attachControllerCallbacks(ViewHost* view) {
997
1258
  }).Get(),
998
1259
  &tok);
999
1260
 
1000
- // DownloadStarting (ICoreWebView2_4) — suppress by default.
1261
+ // DownloadStarting (ICoreWebView2_4) — policy-driven: block | auto | ask.
1262
+ // Default block preserves the original behavior.
1001
1263
  ComPtr<ICoreWebView2_4> wv4;
1002
1264
  view->webview->QueryInterface(IID_PPV_ARGS(&wv4));
1003
1265
  if (wv4) {
1266
+ static std::atomic<uint32_t> g_download_seq{1};
1004
1267
  wv4->add_DownloadStarting(
1005
1268
  Callback<ICoreWebView2DownloadStartingEventHandler>(
1006
- [lifetime, view_id](ICoreWebView2*, ICoreWebView2DownloadStartingEventArgs* args) -> HRESULT {
1269
+ [lifetime, view_id, view](ICoreWebView2*, ICoreWebView2DownloadStartingEventArgs* args) -> HRESULT {
1007
1270
  if (!lifetime || !lifetime->alive.load()) return S_OK;
1008
1271
  ComPtr<ICoreWebView2DownloadOperation> op;
1009
1272
  args->get_DownloadOperation(&op);
@@ -1011,9 +1274,98 @@ static void attachControllerCallbacks(ViewHost* view) {
1011
1274
  if (op) op->get_Uri(&uri_raw);
1012
1275
  std::string url = wideToUtf8(uri_raw);
1013
1276
  if (uri_raw) CoTaskMemFree(uri_raw);
1014
- args->put_Cancel(TRUE);
1015
- std::string payload = "{\"url\":\"" + escapeJsonString(url) + "\"}";
1016
- emitWebviewEvent(view_id, "download-blocked", payload);
1277
+ int32_t policy = view->download_policy.load();
1278
+ const std::string id = "wv2-" + std::to_string(g_download_seq.fetch_add(1));
1279
+ // Only policy=0 (auto) allows. `ask` (1) falls back to block
1280
+ // until implemented — distinguish via blocked.reason.
1281
+ if (policy != 0) {
1282
+ args->put_Cancel(TRUE);
1283
+ const char* reason = (policy == 1) ? "ask-not-implemented" : "host-policy";
1284
+ std::string payload = "{\"kind\":\"blocked\",\"id\":\"" + id +
1285
+ "\",\"url\":\"" + escapeJsonString(url) +
1286
+ "\",\"reason\":\"" + reason + "\"}";
1287
+ emitWebviewEvent(view_id, "download-event", payload);
1288
+ return S_OK;
1289
+ }
1290
+ // auto: don't cancel; report started + progress + completed.
1291
+ LPWSTR sugg_raw = nullptr;
1292
+ if (op) op->get_ContentDisposition(&sugg_raw); // fallback
1293
+ LPWSTR result_path_raw = nullptr;
1294
+ args->get_ResultFilePath(&result_path_raw);
1295
+ std::string suggested = result_path_raw ? wideToUtf8(result_path_raw) : "";
1296
+ // strip dir, keep filename only.
1297
+ auto slash = suggested.find_last_of("/\\");
1298
+ if (slash != std::string::npos) suggested = suggested.substr(slash + 1);
1299
+ if (sugg_raw) CoTaskMemFree(sugg_raw);
1300
+ // Optional host downloadDir override.
1301
+ std::string overrideDir = view->download_dir;
1302
+ if (!overrideDir.empty() && result_path_raw) {
1303
+ std::string base = suggested.empty() ? "download" : suggested;
1304
+ std::string custom = overrideDir;
1305
+ if (!custom.empty() && custom.back() != '\\' && custom.back() != '/') custom.push_back('\\');
1306
+ custom += base;
1307
+ args->put_ResultFilePath(utf8ToWide(custom).c_str());
1308
+ }
1309
+ if (result_path_raw) CoTaskMemFree(result_path_raw);
1310
+ int64_t total = 0;
1311
+ if (op) op->get_TotalBytesToReceive(&total);
1312
+ std::string startPayload = "{\"kind\":\"started\",\"id\":\"" + id +
1313
+ "\",\"url\":\"" + escapeJsonString(url) +
1314
+ "\",\"suggestedFilename\":\"" + escapeJsonString(suggested) + "\"";
1315
+ if (total > 0) startPayload += ",\"sizeBytes\":" + std::to_string(total);
1316
+ startPayload += "}";
1317
+ emitWebviewEvent(view_id, "download-event", startPayload);
1318
+ if (op) {
1319
+ // Tokens kept in a shared struct so the StateChanged terminal
1320
+ // branch can self-unregister both handlers — breaks the
1321
+ // op→handler→op ComPtr ref cycle that otherwise leaks per download.
1322
+ struct Tokens { EventRegistrationToken b{}; EventRegistrationToken s{}; };
1323
+ auto toks = std::make_shared<Tokens>();
1324
+ op->add_BytesReceivedChanged(
1325
+ Callback<ICoreWebView2BytesReceivedChangedEventHandler>(
1326
+ [lifetime, view_id, id, op](ICoreWebView2DownloadOperation*, IUnknown*) -> HRESULT {
1327
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
1328
+ INT64 rec = 0; op->get_BytesReceived(&rec);
1329
+ INT64 tot = 0; op->get_TotalBytesToReceive(&tot);
1330
+ std::string payload = "{\"kind\":\"progress\",\"id\":\"" + id +
1331
+ "\",\"receivedBytes\":" + std::to_string(rec);
1332
+ if (tot > 0) payload += ",\"totalBytes\":" + std::to_string(tot);
1333
+ payload += "}";
1334
+ emitWebviewEvent(view_id, "download-event", payload);
1335
+ return S_OK;
1336
+ }).Get(),
1337
+ &toks->b);
1338
+ op->add_StateChanged(
1339
+ Callback<ICoreWebView2StateChangedEventHandler>(
1340
+ [lifetime, view_id, id, op, toks](ICoreWebView2DownloadOperation*, IUnknown*) -> HRESULT {
1341
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
1342
+ COREWEBVIEW2_DOWNLOAD_STATE state;
1343
+ op->get_State(&state);
1344
+ bool terminal = false;
1345
+ if (state == COREWEBVIEW2_DOWNLOAD_STATE_COMPLETED) {
1346
+ terminal = true;
1347
+ LPWSTR path_raw = nullptr; op->get_ResultFilePath(&path_raw);
1348
+ std::string path = wideToUtf8(path_raw);
1349
+ if (path_raw) CoTaskMemFree(path_raw);
1350
+ std::string payload = "{\"kind\":\"completed\",\"id\":\"" + id +
1351
+ "\",\"localPath\":\"" + escapeJsonString(path) + "\"}";
1352
+ emitWebviewEvent(view_id, "download-event", payload);
1353
+ } else if (state == COREWEBVIEW2_DOWNLOAD_STATE_INTERRUPTED) {
1354
+ terminal = true;
1355
+ COREWEBVIEW2_DOWNLOAD_INTERRUPT_REASON reason;
1356
+ op->get_InterruptReason(&reason);
1357
+ std::string payload = "{\"kind\":\"failed\",\"id\":\"" + id +
1358
+ "\",\"reason\":\"interrupted-" + std::to_string(reason) + "\"}";
1359
+ emitWebviewEvent(view_id, "download-event", payload);
1360
+ }
1361
+ if (terminal) {
1362
+ op->remove_BytesReceivedChanged(toks->b);
1363
+ op->remove_StateChanged(toks->s);
1364
+ }
1365
+ return S_OK;
1366
+ }).Get(),
1367
+ &toks->s);
1368
+ }
1017
1369
  return S_OK;
1018
1370
  }).Get(),
1019
1371
  &tok);