bunite-core 0.14.0 → 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.
@@ -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 {
@@ -116,6 +129,7 @@ struct RuntimeState {
116
129
  std::atomic<bool> initialized{false};
117
130
  std::atomic<bool> shutting_down{false};
118
131
  HWND message_window = nullptr;
132
+ HWND popup_parent = nullptr; // hidden top-level parking parent for popup-minted controllers.
119
133
 
120
134
  std::mutex task_mutex;
121
135
  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
@@ -634,15 +643,42 @@ static void wireView(ViewHost* view, std::function<void()> on_attached) {
634
643
  // - main frame only (matching CEF's OnContextCreated main-frame gate)
635
644
  // - origin allowlist when `preload_origins` is non-empty
636
645
  // Empty allowlist = inject on every main frame (CEF parity default).
637
- 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) {
638
664
  std::string allowlist = "[";
639
665
  for (size_t i = 0; i < v->preload_origins.size(); ++i) {
640
666
  if (i) allowlist += ",";
641
667
  allowlist += "\"" + escapeJsonString(v->preload_origins[i]) + "\"";
642
668
  }
643
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
+ : "";
644
679
  std::string body =
645
- "(function(){if(window.self!==window.top)return;"
680
+ "(function(){"
681
+ "if(window.self!==window.top)return;"
646
682
  "var __a=" + allowlist +
647
683
  ",__o=location.origin;"
648
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;};"
@@ -652,26 +688,32 @@ static void wireView(ViewHost* view, std::function<void()> on_attached) {
652
688
  "while(i<p.length&&p[i]===\"*\")i++;return i===p.length;};"
653
689
  "var __ok=false;for(var i=0;i<__a.length;i++){if(__m(__a[i],__o)){__ok=true;break;}}"
654
690
  "if(!__ok)return;}"
655
- + v->preload_script +
691
+ + markerStart
692
+ + v->preload_script
693
+ + markerEnd +
656
694
  "})();";
657
695
  std::wstring wpreload = utf8ToWide(body);
658
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()));
659
699
  v->webview->AddScriptToExecuteOnDocumentCreated(
660
700
  wpreload.c_str(),
661
701
  Callback<ICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler>(
662
- [lt, view_id](HRESULT, LPCWSTR id) -> HRESULT {
702
+ [lt, view_id, doInitialNav](HRESULT hr, LPCWSTR id) -> HRESULT {
663
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
+ }
664
707
  ViewHost* vv = getView(view_id);
665
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();
666
713
  return S_OK;
667
714
  }).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());
715
+ } else {
716
+ doInitialNav();
675
717
  }
676
718
 
677
719
  emitWebviewEvent(v->id, "view-ready");
@@ -755,6 +797,19 @@ void destroyView(uint32_t id) {
755
797
  vs.erase(std::remove(vs.begin(), vs.end(), v), vs.end());
756
798
  }
757
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
+
758
813
  // Defer Close() → container destroy → delete across three pump ticks. Edge
759
814
  // gets at least one tick after Close() to settle before its parent HWND
760
815
  // vanishes; see shutdownRuntime's staged drains for the same reason.
@@ -819,6 +874,9 @@ static void attachControllerCallbacks(ViewHost* view) {
819
874
  args->get_Uri(&uri_raw);
820
875
  std::string url = wideToUtf8(uri_raw);
821
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 ? "..." : "");
822
880
  emitWebviewEvent(v->id, "will-navigate", url);
823
881
  if (!shouldAllowNavigation(v, url)) {
824
882
  args->put_Cancel(TRUE);
@@ -859,6 +917,9 @@ static void attachControllerCallbacks(ViewHost* view) {
859
917
  args->get_IsSuccess(&ok);
860
918
  UINT64 nav_id = 0;
861
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));
862
923
  std::string url;
863
924
  auto it = g_nav_uris.find(navKey(view_id, nav_id));
864
925
  if (it != g_nav_uris.end()) {
@@ -884,9 +945,15 @@ static void attachControllerCallbacks(ViewHost* view) {
884
945
  }).Get(),
885
946
  &tok);
886
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
+
887
954
  // ScriptDialogOpening — alert / confirm / prompt / beforeunload. Defer the
888
955
  // event so host can decide via `respondToDialog`.
889
- view->webview->add_ScriptDialogOpening(
956
+ HRESULT dlg_hr = view->webview->add_ScriptDialogOpening(
890
957
  Callback<ICoreWebView2ScriptDialogOpeningEventHandler>(
891
958
  [lifetime, view_id](ICoreWebView2*, ICoreWebView2ScriptDialogOpeningEventArgs* args) -> HRESULT {
892
959
  if (!lifetime || !lifetime->alive.load()) return S_OK;
@@ -910,6 +977,8 @@ static void attachControllerCallbacks(ViewHost* view) {
910
977
  : "alert";
911
978
  const uint32_t rid = v->next_dialog_request_id++;
912
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);
913
982
  std::string payload = "{\"requestId\":" + std::to_string(rid) +
914
983
  ",\"kind\":\"" + kind_str +
915
984
  "\",\"message\":\"" + escapeJsonString(message) + "\"";
@@ -921,6 +990,31 @@ static void attachControllerCallbacks(ViewHost* view) {
921
990
  return S_OK;
922
991
  }).Get(),
923
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
+ }
924
1018
 
925
1019
  // DocumentTitleChanged — surface for automation surfaceEvents title-change arm.
926
1020
  view->webview->add_DocumentTitleChanged(
@@ -969,19 +1063,87 @@ static void attachControllerCallbacks(ViewHost* view) {
969
1063
  }).Get(),
970
1064
  &tok);
971
1065
 
972
- // NewWindowRequested — block by default (matches plan), bubble event so the
973
- // 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.
974
1070
  view->webview->add_NewWindowRequested(
975
1071
  Callback<ICoreWebView2NewWindowRequestedEventHandler>(
976
- [lifetime, view_id](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args) -> HRESULT {
1072
+ [lifetime, view_id](ICoreWebView2*, ICoreWebView2NewWindowRequestedEventArgs* args_raw) -> HRESULT {
977
1073
  if (!lifetime || !lifetime->alive.load()) return S_OK;
978
- args->put_Handled(TRUE);
1074
+ ComPtr<ICoreWebView2NewWindowRequestedEventArgs> args(args_raw);
979
1075
  LPWSTR uri_raw = nullptr;
980
1076
  args->get_Uri(&uri_raw);
981
1077
  std::string url = wideToUtf8(uri_raw);
982
1078
  if (uri_raw) CoTaskMemFree(uri_raw);
983
- std::string payload = "{\"url\":\"" + escapeJsonString(url) + "\"}";
984
- 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
+ }
985
1147
  return S_OK;
986
1148
  }).Get(),
987
1149
  &tok);
@@ -997,13 +1159,15 @@ static void attachControllerCallbacks(ViewHost* view) {
997
1159
  }).Get(),
998
1160
  &tok);
999
1161
 
1000
- // DownloadStarting (ICoreWebView2_4) — suppress by default.
1162
+ // DownloadStarting (ICoreWebView2_4) — policy-driven: block | auto | ask.
1163
+ // Default block preserves the original behavior.
1001
1164
  ComPtr<ICoreWebView2_4> wv4;
1002
1165
  view->webview->QueryInterface(IID_PPV_ARGS(&wv4));
1003
1166
  if (wv4) {
1167
+ static std::atomic<uint32_t> g_download_seq{1};
1004
1168
  wv4->add_DownloadStarting(
1005
1169
  Callback<ICoreWebView2DownloadStartingEventHandler>(
1006
- [lifetime, view_id](ICoreWebView2*, ICoreWebView2DownloadStartingEventArgs* args) -> HRESULT {
1170
+ [lifetime, view_id, view](ICoreWebView2*, ICoreWebView2DownloadStartingEventArgs* args) -> HRESULT {
1007
1171
  if (!lifetime || !lifetime->alive.load()) return S_OK;
1008
1172
  ComPtr<ICoreWebView2DownloadOperation> op;
1009
1173
  args->get_DownloadOperation(&op);
@@ -1011,9 +1175,98 @@ static void attachControllerCallbacks(ViewHost* view) {
1011
1175
  if (op) op->get_Uri(&uri_raw);
1012
1176
  std::string url = wideToUtf8(uri_raw);
1013
1177
  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);
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
+ }
1017
1270
  return S_OK;
1018
1271
  }).Get(),
1019
1272
  &tok);