bunite-core 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +4 -4
  2. package/src/host/core/App.ts +17 -1
  3. package/src/host/core/BrowserView.ts +197 -28
  4. package/src/host/core/SurfaceBrowserIPC.ts +44 -3
  5. package/src/host/core/SurfaceManager.ts +260 -28
  6. package/src/host/core/SurfaceRegistry.ts +9 -1
  7. package/src/host/core/inputDispatch.ts +147 -0
  8. package/src/host/events/webviewEvents.ts +8 -1
  9. package/src/host/native.ts +124 -1
  10. package/src/native/linux/bunite_linux_ffi.cpp +223 -6
  11. package/src/native/linux/bunite_linux_internal.h +6 -0
  12. package/src/native/linux/bunite_linux_runtime.cpp +1 -1
  13. package/src/native/linux/bunite_linux_utils.cpp +2 -2
  14. package/src/native/linux/bunite_linux_view.cpp +85 -0
  15. package/src/native/mac/bunite_mac_ffi.mm +356 -8
  16. package/src/native/mac/bunite_mac_internal.h +6 -0
  17. package/src/native/mac/bunite_mac_utils.mm +2 -2
  18. package/src/native/mac/bunite_mac_view.mm +144 -2
  19. package/src/native/shared/ffi_exports.h +135 -0
  20. package/src/native/win/native_host_cef.cpp +86 -3
  21. package/src/native/win/native_host_ffi.cpp +378 -1
  22. package/src/native/win/native_host_internal.h +13 -0
  23. package/src/native/win/native_host_utils.cpp +2 -1
  24. package/src/native/win/process_helper_win.cpp +54 -27
  25. package/src/native/win-webview2/bunite_webview2_ffi.cpp +303 -9
  26. package/src/native/win-webview2/webview2_internal.h +11 -0
  27. package/src/native/win-webview2/webview2_runtime.cpp +128 -12
  28. package/src/native/win-webview2/webview2_utils.cpp +30 -12
  29. package/src/preload/runtime.built.js +1 -1
  30. package/src/preload/runtime.ts +97 -0
  31. package/src/rpc/framework.ts +173 -4
  32. package/src/rpc/index.ts +21 -0
  33. package/src/webview/native.ts +126 -25
  34. package/src/webview/polyfill.ts +196 -12
@@ -1,6 +1,7 @@
1
1
  #include "webview2_internal.h"
2
2
 
3
3
  #include <cstring>
4
+ #include <wincrypt.h> // CryptBinaryToStringA — base64 encoding for screenshot payload.
4
5
 
5
6
  using namespace bunite_webview2;
6
7
 
@@ -11,7 +12,7 @@ void setViewInputPassthrough(ViewHost* v, bool passthrough);
11
12
 
12
13
  extern "C" {
13
14
 
14
- BUNITE_EXPORT int32_t bunite_abi_version(void) { return 5; }
15
+ BUNITE_EXPORT int32_t bunite_abi_version(void) { return 9; }
15
16
 
16
17
  BUNITE_EXPORT void bunite_set_log_level(int32_t level) {
17
18
  if (level < 0) level = 0;
@@ -164,9 +165,21 @@ BUNITE_EXPORT void bunite_view_evaluate(uint32_t view_id, uint32_t request_id, c
164
165
  emitWebviewEvent(view_id, "evaluate-result", payload);
165
166
  return;
166
167
  }
168
+ // Wrap user script in a JS try/catch envelope: WebView2 ExecuteScript
169
+ // returns "null" when the script throws (HRESULT success), so without this
170
+ // wrapper a thrown error is indistinguishable from a literal `null`. The
171
+ // wrapper surfaces throws as `{__bunite_err: <message>}` for CEF-parity.
172
+ std::string wrapped =
173
+ "(function(){try{return JSON.stringify({__bunite_ok:true,value:("
174
+ + std::string(script) +
175
+ ")})}catch(e){var c=(e&&e.name===\"SecurityError\")?\"cross_origin\":\"runtime_error\";"
176
+ "return JSON.stringify({__bunite_ok:false,code:c,"
177
+ "message:(e&&e.message)?e.message:String(e),"
178
+ "name:(e&&e.name)||\"\"})}})()";
179
+
167
180
  auto lifetime = g_runtime.lifetime;
168
181
  v->webview->ExecuteScript(
169
- utf8ToWide(script).c_str(),
182
+ utf8ToWide(wrapped).c_str(),
170
183
  Microsoft::WRL::Callback<ICoreWebView2ExecuteScriptCompletedHandler>(
171
184
  [lifetime, view_id, request_id](HRESULT hr, LPCWSTR raw) -> HRESULT {
172
185
  if (!lifetime || !lifetime->alive.load()) return S_OK;
@@ -178,13 +191,79 @@ BUNITE_EXPORT void bunite_view_evaluate(uint32_t view_id, uint32_t request_id, c
178
191
  [hr]() { char b[16]; snprintf(b, sizeof(b), "%08x", static_cast<unsigned>(hr)); return std::string(b); }() +
179
192
  "\"}";
180
193
  } else {
181
- // WebView2 returns JSON-encoded result. Embed as a JSON string so
182
- // the JS-side parses it as a string and re-JSON.parses to get the
183
- // value back (the `value` field of the envelope is a raw JSON
184
- // string per the FFI contract).
185
- std::string value = wideToUtf8(raw);
186
- payload = "{\"requestId\":" + std::to_string(request_id) +
187
- ",\"ok\":true,\"value\":\"" + escapeJsonString(value) + "\"}";
194
+ // `raw` is a JSON-encoded string. After WebView2's outer
195
+ // JSON.stringify, the wrapper's return value (itself a JSON
196
+ // string) arrives as a JSON-quoted JSON string parse the outer
197
+ // quotes via simple unescape into the inner JSON envelope.
198
+ std::string outer = wideToUtf8(raw);
199
+ // outer looks like: "\"{\\\"__bunite_ok\\\":true,...}\""
200
+ // We need the inner JSON. Walk-and-decode minimally.
201
+ std::string inner;
202
+ if (outer.size() >= 2 && outer.front() == '"' && outer.back() == '"') {
203
+ inner.reserve(outer.size());
204
+ for (size_t i = 1; i + 1 < outer.size(); ++i) {
205
+ if (outer[i] == '\\' && i + 2 < outer.size()) {
206
+ char nxt = outer[i + 1];
207
+ switch (nxt) {
208
+ case '"': inner += '"'; ++i; break;
209
+ case '\\': inner += '\\'; ++i; break;
210
+ case 'n': inner += '\n'; ++i; break;
211
+ case 'r': inner += '\r'; ++i; break;
212
+ case 't': inner += '\t'; ++i; break;
213
+ case '/': inner += '/'; ++i; break;
214
+ default: inner += outer[i]; break;
215
+ }
216
+ } else {
217
+ inner += outer[i];
218
+ }
219
+ }
220
+ }
221
+ if (inner.empty()) {
222
+ // Wrapper didn't produce a string — script failure before catch
223
+ // (e.g. parse error). Surface as runtime_error.
224
+ payload = "{\"requestId\":" + std::to_string(request_id) +
225
+ ",\"ok\":false,\"code\":\"runtime_error\","
226
+ "\"message\":\"script returned non-string from wrapper\"}";
227
+ } else if (inner.find("\"__bunite_ok\":true") != std::string::npos) {
228
+ // Re-parse to find `value:`. Strip the prefix/suffix manually —
229
+ // the wrapper always emits {"__bunite_ok":true,"value":<JSON>}.
230
+ static const std::string prefix = "{\"__bunite_ok\":true,\"value\":";
231
+ static const std::string suffix = "}";
232
+ std::string value_json = "null";
233
+ if (inner.compare(0, prefix.size(), prefix) == 0 &&
234
+ inner.size() > prefix.size() + suffix.size()) {
235
+ value_json = inner.substr(prefix.size(), inner.size() - prefix.size() - 1);
236
+ }
237
+ payload = "{\"requestId\":" + std::to_string(request_id) +
238
+ ",\"ok\":true,\"value\":\"" + escapeJsonString(value_json) + "\"}";
239
+ } else {
240
+ // __bunite_ok:false branch. Anchor extraction at fixed prefix —
241
+ // user-controlled e.message could otherwise inject a fake "code".
242
+ static const std::string codePrefix = "{\"__bunite_ok\":false,\"code\":\"";
243
+ std::string code = "runtime_error";
244
+ std::string msg = "script threw";
245
+ if (inner.compare(0, codePrefix.size(), codePrefix) == 0) {
246
+ size_t start = codePrefix.size();
247
+ size_t end = start;
248
+ while (end < inner.size() && inner[end] != '"') ++end;
249
+ if (end > start) code = inner.substr(start, end - start);
250
+ // message key follows immediately after `","` separator.
251
+ static const std::string msgKey = "\",\"message\":\"";
252
+ if (end + msgKey.size() <= inner.size() &&
253
+ inner.compare(end, msgKey.size(), msgKey) == 0) {
254
+ size_t mstart = end + msgKey.size();
255
+ size_t mend = mstart;
256
+ while (mend < inner.size()) {
257
+ if (inner[mend] == '"' && (mend == mstart || inner[mend - 1] != '\\')) break;
258
+ ++mend;
259
+ }
260
+ if (mend > mstart) msg = inner.substr(mstart, mend - mstart);
261
+ }
262
+ }
263
+ payload = "{\"requestId\":" + std::to_string(request_id) +
264
+ ",\"ok\":false,\"code\":\"" + escapeJsonString(code) + "\","
265
+ "\"message\":\"" + msg + "\"}";
266
+ }
188
267
  }
189
268
  emitWebviewEvent(view_id, "evaluate-result", payload);
190
269
  return S_OK;
@@ -299,6 +378,221 @@ BUNITE_EXPORT void bunite_view_reload(uint32_t view_id) {
299
378
 
300
379
  BUNITE_EXPORT void bunite_view_remove(uint32_t view_id) { destroyView(view_id); }
301
380
 
381
+ // Input dispatch — CDP via CallDevToolsProtocolMethod (Playwright pattern).
382
+ // MouseEvent.isTrusted is false (CDP-synthesized); capability honest.
383
+ namespace {
384
+
385
+ const char* cdpMouseButton(int32_t b) {
386
+ switch (b) { case 1: return "middle"; case 2: return "right"; default: return "left"; }
387
+ }
388
+
389
+ void cdpCall(ViewHost* v, const wchar_t* method, const std::string& json) {
390
+ if (!v || !v->webview) return;
391
+ v->webview->CallDevToolsProtocolMethod(
392
+ method, utf8ToWide(json).c_str(), nullptr);
393
+ }
394
+
395
+ } // namespace
396
+
397
+ BUNITE_EXPORT void bunite_view_click(uint32_t view_id, double x, double y,
398
+ int32_t button, int32_t click_count, uint32_t modifiers) {
399
+ ViewHost* v = getView(view_id);
400
+ if (!v) return;
401
+ if (click_count < 1) click_count = 1;
402
+ // Multi-click → repeated pairs with increasing clickCount so the page sees
403
+ // a dblclick (Playwright convention).
404
+ for (int i = 1; i <= click_count; ++i) {
405
+ std::string base = "\"x\":" + std::to_string(x) + ",\"y\":" + std::to_string(y) +
406
+ ",\"button\":\"" + cdpMouseButton(button) + "\","
407
+ "\"clickCount\":" + std::to_string(i) +
408
+ ",\"modifiers\":" + std::to_string(modifiers);
409
+ cdpCall(v, L"Input.dispatchMouseEvent", "{\"type\":\"mousePressed\"," + base + "}");
410
+ cdpCall(v, L"Input.dispatchMouseEvent", "{\"type\":\"mouseReleased\"," + base + "}");
411
+ }
412
+ }
413
+
414
+ BUNITE_EXPORT void bunite_view_type(uint32_t view_id, const char* text) {
415
+ ViewHost* v = getView(view_id);
416
+ if (!v || !text) return;
417
+ std::string json = "{\"text\":\"" + escapeJsonString(text) + "\"}";
418
+ cdpCall(v, L"Input.insertText", json);
419
+ }
420
+
421
+ BUNITE_EXPORT void bunite_view_press(uint32_t view_id, int32_t windows_vk_code,
422
+ int32_t /*mac_key_code*/, const char* key, const char* code,
423
+ const char* character, uint32_t modifiers,
424
+ int32_t action, bool /*extended*/, int32_t location) {
425
+ ViewHost* v = getView(view_id);
426
+ if (!v) return;
427
+ std::string char_str = character ? character : "";
428
+ std::string key_str = key ? key : "";
429
+ std::string code_str = code ? code : "";
430
+
431
+ auto buildPart = [&](const char* type, bool include_text) {
432
+ std::string out = "{\"type\":\"";
433
+ out += type;
434
+ out += "\",\"modifiers\":" + std::to_string(modifiers);
435
+ if (windows_vk_code != 0) out += ",\"windowsVirtualKeyCode\":" + std::to_string(windows_vk_code);
436
+ if (!key_str.empty()) out += ",\"key\":\"" + escapeJsonString(key_str) + "\"";
437
+ if (!code_str.empty()) out += ",\"code\":\"" + escapeJsonString(code_str) + "\"";
438
+ // CDP `location`: 0 standard, 1 left mod, 2 right mod, 3 numpad.
439
+ if (location > 0) out += ",\"location\":" + std::to_string(location);
440
+ if (include_text && !char_str.empty())
441
+ out += ",\"text\":\"" + escapeJsonString(char_str) + "\"";
442
+ out += "}";
443
+ return out;
444
+ };
445
+
446
+ // Playwright convention: CHAR `text` rides with keyDown for printable keys.
447
+ if (action != 1) cdpCall(v, L"Input.dispatchKeyEvent", buildPart("keyDown", /*include_text=*/true));
448
+ if (action != 0) cdpCall(v, L"Input.dispatchKeyEvent", buildPart("keyUp", /*include_text=*/false));
449
+ }
450
+
451
+ } // extern "C" — temporarily exit so C++ helpers below can return std::string.
452
+
453
+ namespace {
454
+
455
+ // Win CryptoAPI base64 — `bytes` → printable string (no line breaks).
456
+ std::string base64Encode(const BYTE* bytes, DWORD len) {
457
+ DWORD out_len = 0;
458
+ if (!CryptBinaryToStringA(bytes, len, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, nullptr, &out_len)) return {};
459
+ std::string out(out_len, '\0');
460
+ if (!CryptBinaryToStringA(bytes, len, CRYPT_STRING_BASE64 | CRYPT_STRING_NOCRLF, out.data(), &out_len)) return {};
461
+ out.resize(out_len); // CryptBinaryToString writes including trailing null on some configs; trim.
462
+ while (!out.empty() && out.back() == '\0') out.pop_back();
463
+ return out;
464
+ }
465
+
466
+ void emitScreenshotError(uint32_t view_id, uint32_t request_id, const char* code, const std::string& message) {
467
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
468
+ ",\"ok\":false,\"code\":\"" + code + "\","
469
+ "\"message\":\"" + escapeJsonString(message) + "\"}";
470
+ emitWebviewEvent(view_id, "screenshot-result", payload);
471
+ }
472
+
473
+ } // namespace
474
+
475
+ extern "C" {
476
+
477
+ BUNITE_EXPORT uint32_t bunite_view_capabilities(uint32_t view_id) {
478
+ // WebView2 — CDP input path. Empirically dispatchMouseEvent /
479
+ // dispatchKeyEvent / insertText produce events with `isTrusted=true` on
480
+ // the page (Edge runtime injects below DevTools surface; differs from
481
+ // browser-process CDP where isTrusted=false).
482
+ ViewHost* v = getView(view_id);
483
+ if (!v) return 0;
484
+ return BUNITE_CAP_EVALUATE | BUNITE_CAP_SURFACE_EVENTS |
485
+ BUNITE_CAP_NATIVE_INPUT_TRUSTED |
486
+ BUNITE_CAP_CLICK | BUNITE_CAP_TYPE | BUNITE_CAP_PRESS | BUNITE_CAP_SCROLL |
487
+ BUNITE_CAP_MOUSE | BUNITE_CAP_DIALOGS | BUNITE_CAP_CONSOLE |
488
+ BUNITE_CAP_SCREENSHOT | BUNITE_CAP_FORMAT_PNG | BUNITE_CAP_FORMAT_JPEG;
489
+ }
490
+
491
+ BUNITE_EXPORT void bunite_view_screenshot(uint32_t view_id, uint32_t request_id,
492
+ const char* format, int32_t /*quality*/) {
493
+ ViewHost* v = getView(view_id);
494
+ if (!v || !v->webview) {
495
+ emitScreenshotError(view_id, request_id, "not_supported", "view not ready");
496
+ return;
497
+ }
498
+ std::string fmt = format ? format : "png";
499
+ COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT cwv_fmt;
500
+ std::string mime;
501
+ if (fmt == "jpeg" || fmt == "jpg") {
502
+ cwv_fmt = COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_JPEG;
503
+ fmt = "jpeg"; mime = "image/jpeg";
504
+ } else {
505
+ cwv_fmt = COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG;
506
+ fmt = "png"; mime = "image/png";
507
+ }
508
+ ComPtr<IStream> stream;
509
+ HRESULT hr = CreateStreamOnHGlobal(nullptr, TRUE, &stream);
510
+ if (FAILED(hr) || !stream) {
511
+ emitScreenshotError(view_id, request_id, "runtime_error", "CreateStreamOnHGlobal failed");
512
+ return;
513
+ }
514
+ auto lifetime = g_runtime.lifetime;
515
+ v->webview->CapturePreview(
516
+ cwv_fmt, stream.Get(),
517
+ Microsoft::WRL::Callback<ICoreWebView2CapturePreviewCompletedHandler>(
518
+ [lifetime, view_id, request_id, fmt, mime, stream](HRESULT hr2) -> HRESULT {
519
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
520
+ if (FAILED(hr2)) {
521
+ emitScreenshotError(view_id, request_id, "runtime_error", "CapturePreview failed");
522
+ return S_OK;
523
+ }
524
+ HGLOBAL hg = nullptr;
525
+ if (FAILED(GetHGlobalFromStream(stream.Get(), &hg)) || !hg) {
526
+ emitScreenshotError(view_id, request_id, "runtime_error", "GetHGlobalFromStream failed");
527
+ return S_OK;
528
+ }
529
+ const SIZE_T size = GlobalSize(hg);
530
+ void* ptr = GlobalLock(hg);
531
+ std::string b64 = ptr ? base64Encode(static_cast<const BYTE*>(ptr), static_cast<DWORD>(size)) : std::string{};
532
+ if (ptr) GlobalUnlock(hg);
533
+ if (b64.empty()) {
534
+ emitScreenshotError(view_id, request_id, "runtime_error", "base64 encode failed");
535
+ return S_OK;
536
+ }
537
+ std::string payload = "{\"requestId\":" + std::to_string(request_id) +
538
+ ",\"ok\":true,\"format\":\"" + fmt +
539
+ "\",\"mime\":\"" + mime +
540
+ "\",\"dataBase64\":\"" + b64 + "\"}";
541
+ emitWebviewEvent(view_id, "screenshot-result", payload);
542
+ return S_OK;
543
+ }).Get());
544
+ }
545
+
546
+ BUNITE_EXPORT void bunite_view_scroll(uint32_t view_id, double dx, double dy,
547
+ double x, double y, uint32_t modifiers) {
548
+ ViewHost* v = getView(view_id);
549
+ if (!v) return;
550
+ std::string json = "{\"type\":\"mouseWheel\",\"x\":" + std::to_string(x) +
551
+ ",\"y\":" + std::to_string(y) +
552
+ ",\"deltaX\":" + std::to_string(dx) +
553
+ ",\"deltaY\":" + std::to_string(dy) +
554
+ ",\"modifiers\":" + std::to_string(modifiers) + "}";
555
+ cdpCall(v, L"Input.dispatchMouseEvent", json);
556
+ }
557
+
558
+ BUNITE_EXPORT void bunite_view_mouse(uint32_t view_id, int32_t action,
559
+ double x, double y, int32_t button,
560
+ uint32_t modifiers) {
561
+ ViewHost* v = getView(view_id);
562
+ if (!v) return;
563
+ // CDP mouseMoved / mousePressed / mouseReleased. WV2 produces isTrusted=true
564
+ // on the page for these (Edge runtime injects below DevTools surface).
565
+ const char* type = (action == 0) ? "mouseMoved"
566
+ : (action == 1) ? "mousePressed" : "mouseReleased";
567
+ std::string json = "{\"type\":\"" + std::string(type) +
568
+ "\",\"x\":" + std::to_string(x) +
569
+ ",\"y\":" + std::to_string(y) +
570
+ ",\"modifiers\":" + std::to_string(modifiers);
571
+ if (action != 0) {
572
+ const char* btn = (button == 2) ? "right" : (button == 1) ? "middle" : "left";
573
+ json += ",\"button\":\"" + std::string(btn) + "\",\"clickCount\":1";
574
+ }
575
+ json += "}";
576
+ cdpCall(v, L"Input.dispatchMouseEvent", json);
577
+ }
578
+
579
+ BUNITE_EXPORT void bunite_view_respond_dialog(uint32_t view_id, uint32_t request_id,
580
+ bool accept, const char* text) {
581
+ ViewHost* v = getView(view_id);
582
+ if (!v) return;
583
+ auto it = v->pending_dialogs.find(request_id);
584
+ if (it == v->pending_dialogs.end()) return;
585
+ ViewHost::PendingDialog entry = std::move(it->second);
586
+ v->pending_dialogs.erase(it);
587
+ if (entry.args && accept) {
588
+ // prompt: feed user text. WV2 ignores put_ResultText for non-prompt kinds.
589
+ if (text && *text) entry.args->put_ResultText(utf8ToWide(text).c_str());
590
+ entry.args->Accept();
591
+ }
592
+ // accept=false → no Accept() call → WV2 treats as dismiss (default behavior).
593
+ if (entry.deferral) entry.deferral->Complete();
594
+ }
595
+
302
596
  BUNITE_EXPORT void bunite_view_open_devtools(uint32_t view_id) {
303
597
  ViewHost* v = getView(view_id);
304
598
  if (v && v->webview) v->webview->OpenDevToolsWindow();
@@ -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,15 @@ struct ViewHost {
86
87
 
87
88
  std::atomic<bool> ready{false};
88
89
  std::atomic<bool> closing{false};
90
+
91
+ // Pending page-initiated dialogs (alert/confirm/prompt/beforeunload).
92
+ // ScriptDialogOpening hands us a `Deferral` we Complete() on host response.
93
+ struct PendingDialog {
94
+ ComPtr<ICoreWebView2ScriptDialogOpeningEventArgs> args;
95
+ ComPtr<ICoreWebView2Deferral> deferral;
96
+ };
97
+ std::unordered_map<uint32_t, PendingDialog> pending_dialogs;
98
+ uint32_t next_dialog_request_id = 1;
89
99
  };
90
100
 
91
101
  struct WindowHost {
@@ -205,6 +215,7 @@ std::wstring exeDir();
205
215
  uint32_t permissionKindToBuniteBit(COREWEBVIEW2_PERMISSION_KIND kind);
206
216
  COREWEBVIEW2_PERMISSION_STATE buniteStateToWebView2(uint32_t state);
207
217
 
218
+ bool shouldAlwaysAllowNavigationUrl(const std::string& url);
208
219
  bool shouldAllowNavigation(const ViewHost* view, const std::string& url);
209
220
 
210
221
  } // 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,19 @@ 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
+ // `bun run` passes STARTF_USESHOWWINDOW + SW_HIDE; Win documented behavior
136
+ // is for the first ShowWindow call to use STARTUPINFO.wShowWindow instead
137
+ // of the requested nCmdShow. Consume it here on the message window so the
138
+ // first user-visible window's ShowWindow honors its argument.
139
+ STARTUPINFOW si{};
140
+ si.cb = sizeof(si);
141
+ GetStartupInfoW(&si);
142
+ if ((si.dwFlags & STARTF_USESHOWWINDOW) && si.wShowWindow == SW_HIDE) {
143
+ ShowWindow(g_runtime.message_window, SW_HIDE);
144
+ }
145
+ return true;
125
146
  }
126
147
 
127
148
  // ---- environment bootstrap -----------------------------------------------
@@ -282,6 +303,19 @@ LRESULT CALLBACK messageProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
282
303
 
283
304
  // ---- init / shutdown ----------------------------------------------------
284
305
 
306
+ // KILL_ON_JOB_CLOSE — Edge helpers die with bun.exe instead of holding the
307
+ // UDF SingletonLock. Handle leaked intentionally (kernel closes on exit).
308
+ static void reapChildrenOnExit() {
309
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
310
+ if (!job) return;
311
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{};
312
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
313
+ if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info)) ||
314
+ !AssignProcessToJobObject(job, GetCurrentProcess())) {
315
+ CloseHandle(job); // already in a non-nestable job, etc — give up.
316
+ }
317
+ }
318
+
285
319
  bool initRuntime(const char* engine_dir, bool /*hide_console*/,
286
320
  bool popup_blocking, const char* engine_config_json) {
287
321
  buniteApplyEnvLogLevel();
@@ -289,6 +323,8 @@ bool initRuntime(const char* engine_dir, bool /*hide_console*/,
289
323
  (engine_dir && *engine_dir) ? engine_dir : "(null)");
290
324
  if (g_runtime.initialized.load()) return true;
291
325
 
326
+ reapChildrenOnExit();
327
+
292
328
  HRESULT co = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE);
293
329
  if (SUCCEEDED(co)) g_co_initialized = true;
294
330
  else if (co != RPC_E_CHANGED_MODE) {
@@ -766,9 +802,13 @@ static void attachControllerCallbacks(ViewHost* view) {
766
802
  if (!view->webview) return;
767
803
  auto lifetime = g_runtime.lifetime;
768
804
  uint32_t view_id = view->id;
805
+ // Token reuse OK — controller->Close() releases all add_* handlers.
769
806
  EventRegistrationToken tok;
770
807
 
771
- // NavigationStarting — enforce navigation rules, emit "will-navigate" event.
808
+ // NavigationStarting — emit "will-navigate" (parity with CEF/mac/linux,
809
+ // which fire regardless of allow), then cancel if nav rules say block.
810
+ // Also emit "load-start" for the surfaceEvents stream + stash URI for
811
+ // NavigationCompleted lookup (failure case: get_Source() returns prior URL).
772
812
  view->webview->add_NavigationStarting(
773
813
  Callback<ICoreWebView2NavigationStartingEventHandler>(
774
814
  [lifetime, view_id](ICoreWebView2*, ICoreWebView2NavigationStartingEventArgs* args) -> HRESULT {
@@ -779,34 +819,110 @@ static void attachControllerCallbacks(ViewHost* view) {
779
819
  args->get_Uri(&uri_raw);
780
820
  std::string url = wideToUtf8(uri_raw);
781
821
  if (uri_raw) CoTaskMemFree(uri_raw);
822
+ emitWebviewEvent(v->id, "will-navigate", url);
782
823
  if (!shouldAllowNavigation(v, url)) {
783
824
  args->put_Cancel(TRUE);
784
825
  return S_OK;
785
826
  }
786
- emitWebviewEvent(v->id, "will-navigate", url);
827
+ UINT64 nav_id = 0;
828
+ args->get_NavigationId(&nav_id);
829
+ g_nav_uris[navKey(view_id, nav_id)] = url;
830
+ emitWebviewEvent(v->id, "load-start", url);
787
831
  return S_OK;
788
832
  }).Get(),
789
833
  &tok);
790
834
 
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 {
835
+ // SourceChangedURL commit point; map to did-navigate (surfaceEvents
836
+ // `navigate` arm). Distinct from NavigationCompleted which fires later.
837
+ view->webview->add_SourceChanged(
838
+ Callback<ICoreWebView2SourceChangedEventHandler>(
839
+ [lifetime, view_id](ICoreWebView2* wv, ICoreWebView2SourceChangedEventArgs*) -> HRESULT {
795
840
  if (!lifetime || !lifetime->alive.load()) return S_OK;
796
- BOOL ok = FALSE;
797
- args->get_IsSuccess(&ok);
798
- if (!ok) return S_OK;
799
841
  LPWSTR src_raw = nullptr;
800
842
  if (wv) wv->get_Source(&src_raw);
801
843
  std::string url = wideToUtf8(src_raw);
802
844
  if (src_raw) CoTaskMemFree(src_raw);
803
845
  emitWebviewEvent(view_id, "did-navigate", url);
804
- emitWebviewEvent(view_id, "dom-ready", url);
805
846
  return S_OK;
806
847
  }).Get(),
807
848
  &tok);
808
849
 
809
- // DocumentTitleChangedsurface for automation `titleChanged` stream.
850
+ // NavigationCompletedload lifecycle terminator. Success → load-finish
851
+ // + dom-ready; failure → load-fail with WebErrorStatus as reason. Use the
852
+ // URI we stashed at NavigationStarting — get_Source() returns the prior
853
+ // committed URL on provisional-navigation failure.
854
+ view->webview->add_NavigationCompleted(
855
+ Callback<ICoreWebView2NavigationCompletedEventHandler>(
856
+ [lifetime, view_id](ICoreWebView2* wv, ICoreWebView2NavigationCompletedEventArgs* args) -> HRESULT {
857
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
858
+ BOOL ok = FALSE;
859
+ args->get_IsSuccess(&ok);
860
+ UINT64 nav_id = 0;
861
+ args->get_NavigationId(&nav_id);
862
+ std::string url;
863
+ auto it = g_nav_uris.find(navKey(view_id, nav_id));
864
+ if (it != g_nav_uris.end()) {
865
+ url = std::move(it->second);
866
+ g_nav_uris.erase(it);
867
+ } else {
868
+ LPWSTR src_raw = nullptr;
869
+ if (wv) wv->get_Source(&src_raw);
870
+ url = wideToUtf8(src_raw);
871
+ if (src_raw) CoTaskMemFree(src_raw);
872
+ }
873
+ if (ok) {
874
+ emitWebviewEvent(view_id, "load-finish", url);
875
+ emitWebviewEvent(view_id, "dom-ready", url);
876
+ } else {
877
+ COREWEBVIEW2_WEB_ERROR_STATUS status = COREWEBVIEW2_WEB_ERROR_STATUS_UNKNOWN;
878
+ args->get_WebErrorStatus(&status);
879
+ std::string payload = "{\"url\":\"" + escapeJsonString(url) +
880
+ "\",\"reason\":\"WebErrorStatus_" + std::to_string(static_cast<int>(status)) + "\"}";
881
+ emitWebviewEvent(view_id, "load-fail", payload);
882
+ }
883
+ return S_OK;
884
+ }).Get(),
885
+ &tok);
886
+
887
+ // ScriptDialogOpening — alert / confirm / prompt / beforeunload. Defer the
888
+ // event so host can decide via `respondToDialog`.
889
+ view->webview->add_ScriptDialogOpening(
890
+ Callback<ICoreWebView2ScriptDialogOpeningEventHandler>(
891
+ [lifetime, view_id](ICoreWebView2*, ICoreWebView2ScriptDialogOpeningEventArgs* args) -> HRESULT {
892
+ if (!lifetime || !lifetime->alive.load()) return S_OK;
893
+ ViewHost* v = getView(view_id);
894
+ if (!v) return S_OK;
895
+ ComPtr<ICoreWebView2Deferral> deferral;
896
+ args->GetDeferral(&deferral);
897
+ COREWEBVIEW2_SCRIPT_DIALOG_KIND kind = COREWEBVIEW2_SCRIPT_DIALOG_KIND_ALERT;
898
+ args->get_Kind(&kind);
899
+ LPWSTR msg_raw = nullptr;
900
+ args->get_Message(&msg_raw);
901
+ std::string message = wideToUtf8(msg_raw);
902
+ if (msg_raw) CoTaskMemFree(msg_raw);
903
+ LPWSTR def_raw = nullptr;
904
+ args->get_DefaultText(&def_raw);
905
+ std::string default_prompt = wideToUtf8(def_raw);
906
+ if (def_raw) CoTaskMemFree(def_raw);
907
+ const char* kind_str = (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_CONFIRM) ? "confirm"
908
+ : (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_PROMPT) ? "prompt"
909
+ : (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_BEFOREUNLOAD) ? "beforeunload"
910
+ : "alert";
911
+ const uint32_t rid = v->next_dialog_request_id++;
912
+ v->pending_dialogs[rid] = ViewHost::PendingDialog{ args, std::move(deferral) };
913
+ std::string payload = "{\"requestId\":" + std::to_string(rid) +
914
+ ",\"kind\":\"" + kind_str +
915
+ "\",\"message\":\"" + escapeJsonString(message) + "\"";
916
+ if (kind == COREWEBVIEW2_SCRIPT_DIALOG_KIND_PROMPT) {
917
+ payload += ",\"defaultPrompt\":\"" + escapeJsonString(default_prompt) + "\"";
918
+ }
919
+ payload += "}";
920
+ emitWebviewEvent(view_id, "dialog", payload);
921
+ return S_OK;
922
+ }).Get(),
923
+ &tok);
924
+
925
+ // DocumentTitleChanged — surface for automation surfaceEvents title-change arm.
810
926
  view->webview->add_DocumentTitleChanged(
811
927
  Callback<ICoreWebView2DocumentTitleChangedEventHandler>(
812
928
  [lifetime, view_id](ICoreWebView2* wv, IUnknown*) -> HRESULT {
@@ -50,14 +50,14 @@ std::string escapeJsonString(const std::string& s) {
50
50
  return out;
51
51
  }
52
52
 
53
+ // Wildcard semantics shared with CEF/mac/linux backends: `*` only (no `?`),
54
+ // locale-respecting std::tolower. Keeping the 4 impls in lockstep is the rule.
53
55
  bool globMatchCaseInsensitive(const std::string& pattern, const std::string& value) {
54
- auto lower = [](char c) -> char {
55
- return (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c;
56
- };
57
56
  size_t pi = 0, vi = 0, star = std::string::npos, match = 0;
58
57
  while (vi < value.size()) {
59
- if (pi < pattern.size() && (pattern[pi] == '?' ||
60
- lower(pattern[pi]) == lower(value[vi]))) {
58
+ if (pi < pattern.size() &&
59
+ std::tolower(static_cast<unsigned char>(pattern[pi])) ==
60
+ std::tolower(static_cast<unsigned char>(value[vi]))) {
61
61
  pi++; vi++;
62
62
  } else if (pi < pattern.size() && pattern[pi] == '*') {
63
63
  star = pi++; match = vi;
@@ -201,6 +201,10 @@ std::wstring exeDir() {
201
201
  }
202
202
 
203
203
  std::string defaultUserDataFolder() {
204
+ // Per-app via BUNITE_USER_DATA_DIR — shared path is a SingletonLock.
205
+ if (const char* env = std::getenv("BUNITE_USER_DATA_DIR"); env && *env) {
206
+ return std::string(env) + "\\WebView2";
207
+ }
204
208
  wchar_t* base = nullptr;
205
209
  if (SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &base) != S_OK || !base) {
206
210
  if (base) CoTaskMemFree(base);
@@ -232,15 +236,29 @@ COREWEBVIEW2_PERMISSION_STATE buniteStateToWebView2(uint32_t state) {
232
236
  }
233
237
  }
234
238
 
239
+ // Whitelist must be exact-match — prefix would let `../../evil` style paths
240
+ // bypass nav-rule scrutiny (the appres scheme handler still blocks file
241
+ // access, but the navigation decision itself should be honest).
242
+ bool shouldAlwaysAllowNavigationUrl(const std::string& url) {
243
+ return url == "about:blank" ||
244
+ url == "appres://app.internal/internal/index.html";
245
+ }
246
+
247
+ // Same semantics as CEF / mac / linux: `^` prefix = block, last-match-wins,
248
+ // default-allow. The shared shouldAlwaysAllowNavigationUrl whitelist guards
249
+ // about:blank + appres internal routes from being denied by user rules.
235
250
  bool shouldAllowNavigation(const ViewHost* view, const std::string& url) {
236
- if (!view || view->navigation_rules.empty()) return true;
237
- for (const auto& rule : view->navigation_rules) {
238
- if (rule.empty()) continue;
239
- bool allow = (rule[0] != '!');
240
- const std::string& pat = allow ? rule : rule.substr(1);
241
- if (globMatchCaseInsensitive(pat, url)) return allow;
251
+ if (!view || shouldAlwaysAllowNavigationUrl(url) || view->navigation_rules.empty()) {
252
+ return true;
253
+ }
254
+ bool allowed = true;
255
+ for (const auto& raw : view->navigation_rules) {
256
+ const bool block = !raw.empty() && raw.front() == '^';
257
+ const std::string pattern = block ? raw.substr(1) : raw;
258
+ if (pattern.empty()) continue;
259
+ if (globMatchCaseInsensitive(pattern, url)) allowed = !block;
242
260
  }
243
- return true;
261
+ return allowed;
244
262
  }
245
263
 
246
264
  } // namespace bunite_webview2