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
@@ -15,8 +15,7 @@
15
15
  extern "C" {
16
16
  #endif
17
17
 
18
- /** ABI version. Bump on any breaking change to symbol set / signatures.
19
- * v5 (2026-05): adds `bunite_view_evaluate` + `evaluate-result` webview event. */
18
+ /** ABI version. Bump on any breaking change to symbol set / signatures. */
20
19
  BUNITE_EXPORT int32_t bunite_abi_version(void);
21
20
  BUNITE_EXPORT void bunite_set_log_level(int32_t level);
22
21
  BUNITE_EXPORT bool bunite_init(
@@ -124,6 +123,205 @@ BUNITE_EXPORT void bunite_view_set_anchor(uint32_t view_id, int mode, double ins
124
123
  BUNITE_EXPORT void bunite_view_go_back(uint32_t view_id);
125
124
  BUNITE_EXPORT void bunite_view_reload(uint32_t view_id);
126
125
  BUNITE_EXPORT void bunite_view_remove(uint32_t view_id);
126
+
127
+ /* Input dispatch (ABI v6). Coordinates are CSS px, viewport-relative (TS
128
+ * normalizes devicePixelRatio + container offset). Modifier bitmask is
129
+ * Alt=1, Ctrl=2, Meta=4, Shift=8. Backends translate to their native form.
130
+ * No result envelope — `nativeInputTrusted` capability indicates DOM trust. */
131
+ BUNITE_EXPORT void bunite_view_click(
132
+ uint32_t view_id,
133
+ double x,
134
+ double y,
135
+ int32_t button, /* 0=left, 1=middle, 2=right */
136
+ int32_t click_count, /* >=1 */
137
+ uint32_t modifiers
138
+ );
139
+ /** Type UTF-8 text. Each codepoint becomes a CHAR / insertText event — no IME composition. */
140
+ BUNITE_EXPORT void bunite_view_type(uint32_t view_id, const char* text);
141
+ /** Press a key: down + (optional) char + up.
142
+ * `windows_vk_code` is Win32 VK_* for CEF / WebView2 CDP path.
143
+ * `mac_key_code` is the Quartz Event Services hardware key code (kVK_*) — separate
144
+ * from Win VK because DOM `KeyboardEvent.code` is derived from this on WebKit.
145
+ * `key` / `code` are DOM `KeyboardEvent.key` / `.code` strings, passed to CDP so
146
+ * the page sees the correct values. `character` is UTF-8 for the CHAR event;
147
+ * empty = skip char. 0 vk codes = skip the virtual-key down/up.
148
+ * `action`: 0=down only, 1=up only, 2=both (default). For Playwright-style
149
+ * modifier-held wrap (keydown → click → keyup). CHAR follows Playwright rule:
150
+ * emitted with down only when character is non-empty.
151
+ * `extended`: Win scancode 0xE0 prefix — Numpad-Enter AND nav-cluster
152
+ * (Arrow/Home/End/Insert/Delete/PageUp/PageDown/Meta/ContextMenu). Drives CEF
153
+ * `native_key_code`. Other backends ignore.
154
+ * `location`: DOM `KeyboardEvent.location` (0=standard, 1=left mod, 2=right
155
+ * mod, 3=numpad). WV2 CDP forwards as `location`. Most extended keys are
156
+ * location 0 — only NumpadEnter is location 3 here. */
157
+ BUNITE_EXPORT void bunite_view_press(
158
+ uint32_t view_id,
159
+ int32_t windows_vk_code,
160
+ int32_t mac_key_code,
161
+ const char* key,
162
+ const char* code,
163
+ const char* character,
164
+ uint32_t modifiers,
165
+ int32_t action,
166
+ bool extended,
167
+ int32_t location
168
+ );
169
+ /** Scroll at (x, y). dx/dy in CSS px; positive = right/down. */
170
+ BUNITE_EXPORT void bunite_view_scroll(
171
+ uint32_t view_id,
172
+ double dx,
173
+ double dy,
174
+ double x,
175
+ double y,
176
+ uint32_t modifiers
177
+ );
178
+
179
+ /** Raw mouse primitive. `action` 0=move, 1=down, 2=up. `button` 0=left, 1=middle,
180
+ * 2=right (ignored for move). Coordinates are CSS px viewport-relative. Drag is
181
+ * composed = down → move(s) → up. Backends without input support (linux GTK)
182
+ * treat as no-op. `modifiers` per-call atomic — no sticky state. */
183
+ BUNITE_EXPORT void bunite_view_mouse(
184
+ uint32_t view_id,
185
+ int32_t action,
186
+ double x,
187
+ double y,
188
+ int32_t button,
189
+ uint32_t modifiers
190
+ );
191
+
192
+ /** Respond to a page-initiated modal dialog previously announced via the
193
+ * webview event channel as `dialog` (`{requestId, kind, message, defaultPrompt?}`).
194
+ * `accept` decides confirm/beforeunload outcome and gates whether `text` is
195
+ * applied to `prompt`. Backends release the held page execution. */
196
+ BUNITE_EXPORT void bunite_view_respond_dialog(
197
+ uint32_t view_id,
198
+ uint32_t request_id,
199
+ bool accept,
200
+ const char* text
201
+ );
202
+
203
+ /** Per-view automation capability bitset. Two categories: method gate
204
+ * (bit=false → method must not be called) and property advertise (bit=false
205
+ * → method runs, property degrades). See `.agents/architecture.md`. */
206
+ enum BuniteCapBit {
207
+ /* method gate */
208
+ BUNITE_CAP_EVALUATE = 1u << 0,
209
+ BUNITE_CAP_SURFACE_EVENTS = 1u << 2,
210
+ BUNITE_CAP_CLICK = 1u << 4,
211
+ BUNITE_CAP_TYPE = 1u << 5,
212
+ BUNITE_CAP_PRESS = 1u << 6,
213
+ BUNITE_CAP_SCROLL = 1u << 7,
214
+ BUNITE_CAP_SCREENSHOT = 1u << 8,
215
+ BUNITE_CAP_MOUSE = 1u << 11,
216
+ BUNITE_CAP_DIALOGS = 1u << 12,
217
+ BUNITE_CAP_CONSOLE = 1u << 13,
218
+ BUNITE_CAP_AX = 1u << 15,
219
+ BUNITE_CAP_BOUNDING_RECT = 1u << 16,
220
+ BUNITE_CAP_FRAMES = 1u << 17,
221
+ BUNITE_CAP_DOWNLOADS = 1u << 18,
222
+ BUNITE_CAP_POPUPS = 1u << 19,
223
+ BUNITE_CAP_RESOLVE_AND_CLICK = 1u << 20,
224
+
225
+ /* property advertise */
226
+ BUNITE_CAP_CROSS_ORIGIN_EVAL = 1u << 1,
227
+ BUNITE_CAP_NATIVE_INPUT_TRUSTED = 1u << 3, /* click/type/press/mouse isTrusted */
228
+ BUNITE_CAP_FORMAT_PNG = 1u << 9,
229
+ BUNITE_CAP_FORMAT_JPEG = 1u << 10,
230
+ };
231
+ BUNITE_EXPORT uint32_t bunite_view_capabilities(uint32_t view_id);
232
+
233
+ /** Capture the visible viewport as a PNG/JPEG image. Async — result reported
234
+ * via the webview event handler as `screenshot-result` with payload
235
+ * { requestId, ok: true, format, mime, dataBase64 }
236
+ * { requestId, ok: false, code, message }
237
+ * Error codes: `not_supported`, `runtime_error`, `timeout`, `black_frame` (CEF
238
+ * compositor surface unreachable). `quality` is 0–100 for JPEG only — ignored
239
+ * for PNG, and ignored entirely on WebView2 (`CapturePreview` has no quality
240
+ * parameter; output uses Edge's default ~80). `dataBase64` is base64-encoded
241
+ * image bytes; TS layer decodes. */
242
+ BUNITE_EXPORT void bunite_view_screenshot(
243
+ uint32_t view_id,
244
+ uint32_t request_id,
245
+ const char* format,
246
+ int32_t quality
247
+ );
248
+
249
+ /** Snapshot the accessibility tree (CDP `Accessibility.getFullAXTree`). Async —
250
+ * result reported via webview event handler as `accessibility-result` payload
251
+ * { requestId, ok: true, tree: {nodes: [<CDP AXNode flat list>]} }
252
+ * { requestId, ok: false, code, message }
253
+ * TS builds the nested tree from `childIds`. mac/linux always emit
254
+ * `not_supported` (no public ax tree API). `interesting_only` is reserved and
255
+ * currently unused on the native side (filter is TS-side). */
256
+ BUNITE_EXPORT void bunite_view_accessibility_snapshot(
257
+ uint32_t view_id,
258
+ uint32_t request_id,
259
+ int32_t interesting_only
260
+ );
261
+
262
+ /** Enumerate frames in the view. Async — result reported via webview event
263
+ * `list-frames-result` payload
264
+ * { requestId, ok: true, frames: [{frameId, parentFrameId, origin, url, name?}] }
265
+ * { requestId, ok: false, code, message }
266
+ * Codes: `not_supported`, `runtime_error`. mac/linux emit `not_supported`. */
267
+ BUNITE_EXPORT void bunite_view_list_frames(uint32_t view_id, uint32_t request_id);
268
+
269
+ /** Evaluate `script` in the target frame's isolated world (CDP
270
+ * `Page.createIsolatedWorld` + `Runtime.evaluate`). Page main-world JS
271
+ * variables are NOT visible; DOM access works. Result reused via the
272
+ * existing `evaluate-result` event. `frame_id` empty/null delegates to
273
+ * `bunite_view_evaluate` (main frame, main world). */
274
+ BUNITE_EXPORT void bunite_view_evaluate_in_frame(
275
+ uint32_t view_id,
276
+ uint32_t request_id,
277
+ const char* script,
278
+ const char* frame_id
279
+ );
280
+
281
+ /** Atomic selector resolve + native click. Async via `resolve-and-click-result`:
282
+ * { requestId, ok: true, rect, isTrustedEvent }
283
+ * { requestId, ok: false, code, message }
284
+ * Codes: not_found / not_visible / runtime_error / cross_origin / not_supported.
285
+ * `frame_id` non-empty selects a same-origin iframe (rect viewport-normalized);
286
+ * cross-origin → `cross_origin`, mac/linux → `not_supported`. scrollIntoView is
287
+ * automatic. `isTrustedEvent` is empirical per backend; CEF/WV2 CDP path and
288
+ * mac NSEvent direct dispatch all produce trusted events (all `true`). */
289
+ BUNITE_EXPORT void bunite_view_resolve_and_click(
290
+ uint32_t view_id,
291
+ uint32_t request_id,
292
+ const char* selector,
293
+ const char* frame_id,
294
+ int32_t button,
295
+ int32_t click_count,
296
+ uint32_t modifiers
297
+ );
298
+
299
+ /** Set per-view download policy. `policy`: 0=auto (allow + emit lifecycle),
300
+ * 1=ask (not implemented for v10, treated as block), 2=block (default).
301
+ * `download_dir` (utf-8) optionally overrides backend default save dir.
302
+ * Lifecycle events emit as `download-event` payloads
303
+ * { kind: "started"|"progress"|"completed"|"failed"|"blocked", id, ...fields }
304
+ * See `DownloadEvent` (TS) for the per-kind field set. */
305
+ BUNITE_EXPORT void bunite_view_set_download_policy(
306
+ uint32_t view_id,
307
+ int32_t policy,
308
+ const char* download_dir
309
+ );
310
+
311
+ /** Adopt a popup-minted view. Native must have previously emitted a
312
+ * `popup-requested` event (carrying `newSurfaceId`); host calls this to attach
313
+ * the pre-minted view to the target window + bounds. `host_window_id` is the
314
+ * destination `WindowHost.id`. */
315
+ BUNITE_EXPORT void bunite_view_popup_accept(
316
+ uint32_t new_view_id,
317
+ uint32_t host_window_id,
318
+ double x, double y, double w, double h
319
+ );
320
+
321
+ /** Discard a popup-minted view that wasn't adopted (or that host wants to
322
+ * reject). Native destroys the controller/browser. Idempotent. */
323
+ BUNITE_EXPORT void bunite_view_popup_dismiss(uint32_t new_view_id);
324
+
127
325
  BUNITE_EXPORT void bunite_view_open_devtools(uint32_t view_id);
128
326
  BUNITE_EXPORT void bunite_view_close_devtools(uint32_t view_id);
129
327
  BUNITE_EXPORT void bunite_view_toggle_devtools(uint32_t view_id);
@@ -85,8 +85,12 @@ class BuniteCefClient
85
85
  public CefRequestHandler,
86
86
  public CefResourceRequestHandler,
87
87
  public CefPermissionHandler,
88
- public CefDisplayHandler {
88
+ public CefDisplayHandler,
89
+ public CefJSDialogHandler,
90
+ public CefDownloadHandler {
89
91
  public:
92
+ // BuniteCefClient is constructed 1:1 with a `ViewHost*`; `last_title_` is
93
+ // therefore per-view. OnTitleChange runs on the CEF UI thread (single).
90
94
  explicit BuniteCefClient(ViewHost* view)
91
95
  : view_(view) {}
92
96
 
@@ -95,6 +99,113 @@ public:
95
99
  CefRefPtr<CefRequestHandler> GetRequestHandler() override { return this; }
96
100
  CefRefPtr<CefPermissionHandler> GetPermissionHandler() override { return this; }
97
101
  CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
102
+ CefRefPtr<CefJSDialogHandler> GetJSDialogHandler() override { return this; }
103
+ CefRefPtr<CefDownloadHandler> GetDownloadHandler() override { return this; }
104
+
105
+ bool OnBeforeDownload(CefRefPtr<CefBrowser>, CefRefPtr<CefDownloadItem> item,
106
+ const CefString& suggested_name,
107
+ CefRefPtr<CefBeforeDownloadCallback> callback) override {
108
+ CEF_REQUIRE_UI_THREAD();
109
+ int32_t policy = view_->download_policy.load();
110
+ std::string id = "cef-" + std::to_string(item->GetId());
111
+ std::string url = item->GetURL().ToString();
112
+ std::string mime = item->GetMimeType().ToString();
113
+ int64_t total = item->GetTotalBytes();
114
+ std::string suggested = suggested_name.ToString();
115
+ // Only policy=0 (auto) allows. `ask` (1) falls back to block — distinguished
116
+ // via blocked.reason so callers can detect the unsupported policy path.
117
+ if (policy != 0) {
118
+ const char* reason = (policy == 1) ? "ask-not-implemented" : "host-policy";
119
+ std::string payload = "{\"kind\":\"blocked\",\"id\":\"" + id +
120
+ "\",\"url\":\"" + bunite_win::escapeJsonString(url) +
121
+ "\",\"reason\":\"" + reason + "\"}";
122
+ bunite_win::emitWebviewEvent(view_->id, "download-event", payload);
123
+ return true; // not calling callback->Continue → cancels.
124
+ }
125
+ // auto: build target path. If host set download_dir, use it; else CEF defaults to user Downloads.
126
+ std::string target = view_->download_dir;
127
+ if (!target.empty()) {
128
+ if (target.back() != '\\' && target.back() != '/') target.push_back('\\');
129
+ target += suggested;
130
+ }
131
+ callback->Continue(target, false); // false = no Save-As dialog.
132
+ std::string payload = "{\"kind\":\"started\",\"id\":\"" + id +
133
+ "\",\"url\":\"" + bunite_win::escapeJsonString(url) +
134
+ "\",\"suggestedFilename\":\"" + bunite_win::escapeJsonString(suggested) +
135
+ "\",\"mimeType\":\"" + bunite_win::escapeJsonString(mime) + "\"";
136
+ if (total > 0) payload += ",\"sizeBytes\":" + std::to_string(total);
137
+ payload += "}";
138
+ bunite_win::emitWebviewEvent(view_->id, "download-event", payload);
139
+ return true;
140
+ }
141
+
142
+ void OnDownloadUpdated(CefRefPtr<CefBrowser>, CefRefPtr<CefDownloadItem> item,
143
+ CefRefPtr<CefDownloadItemCallback> /*callback*/) override {
144
+ CEF_REQUIRE_UI_THREAD();
145
+ std::string id = "cef-" + std::to_string(item->GetId());
146
+ if (item->IsComplete()) {
147
+ std::string path = item->GetFullPath().ToString();
148
+ std::string payload = "{\"kind\":\"completed\",\"id\":\"" + id +
149
+ "\",\"localPath\":\"" + bunite_win::escapeJsonString(path) + "\"}";
150
+ bunite_win::emitWebviewEvent(view_->id, "download-event", payload);
151
+ } else if (item->IsCanceled()) {
152
+ std::string payload = "{\"kind\":\"failed\",\"id\":\"" + id +
153
+ "\",\"reason\":\"canceled\"}";
154
+ bunite_win::emitWebviewEvent(view_->id, "download-event", payload);
155
+ } else if (item->IsInProgress()) {
156
+ int64_t rec = item->GetReceivedBytes();
157
+ int64_t tot = item->GetTotalBytes();
158
+ std::string payload = "{\"kind\":\"progress\",\"id\":\"" + id +
159
+ "\",\"receivedBytes\":" + std::to_string(rec);
160
+ if (tot > 0) payload += ",\"totalBytes\":" + std::to_string(tot);
161
+ payload += "}";
162
+ bunite_win::emitWebviewEvent(view_->id, "download-event", payload);
163
+ }
164
+ }
165
+
166
+ bool OnJSDialog(CefRefPtr<CefBrowser>, const CefString& /*origin_url*/,
167
+ JSDialogType dialog_type, const CefString& message_text,
168
+ const CefString& default_prompt_text,
169
+ CefRefPtr<CefJSDialogCallback> callback,
170
+ bool& suppress_message) override {
171
+ CEF_REQUIRE_UI_THREAD();
172
+ const char* kind = (dialog_type == JSDIALOGTYPE_ALERT) ? "alert"
173
+ : (dialog_type == JSDIALOGTYPE_CONFIRM) ? "confirm" : "prompt";
174
+ const uint32_t rid = view_->next_dialog_request_id++;
175
+ view_->pending_dialogs[rid] = callback;
176
+ BUNITE_INFO("cef/dialog: OnJSDialog view=%u kind=%s rid=%u", view_->id, kind, rid);
177
+ // CEF asserts !suppress_message when return=true (custom dialog path).
178
+ suppress_message = false;
179
+ std::string payload = "{\"requestId\":" + std::to_string(rid) +
180
+ ",\"kind\":\"" + kind +
181
+ "\",\"message\":\"" + bunite_win::escapeJsonString(message_text.ToString()) + "\"";
182
+ if (dialog_type == JSDIALOGTYPE_PROMPT) {
183
+ payload += ",\"defaultPrompt\":\"" + bunite_win::escapeJsonString(default_prompt_text.ToString()) + "\"";
184
+ }
185
+ payload += "}";
186
+ bunite_win::emitWebviewEvent(view_->id, "dialog", payload);
187
+ return true; // handled — CEF will not show its own UI.
188
+ }
189
+
190
+ bool OnBeforeUnloadDialog(CefRefPtr<CefBrowser>, const CefString& message_text,
191
+ bool /*is_reload*/,
192
+ CefRefPtr<CefJSDialogCallback> callback) override {
193
+ CEF_REQUIRE_UI_THREAD();
194
+ const uint32_t rid = view_->next_dialog_request_id++;
195
+ view_->pending_dialogs[rid] = callback;
196
+ std::string payload = "{\"requestId\":" + std::to_string(rid) +
197
+ ",\"kind\":\"beforeunload\",\"message\":\"" +
198
+ bunite_win::escapeJsonString(message_text.ToString()) + "\"}";
199
+ bunite_win::emitWebviewEvent(view_->id, "dialog", payload);
200
+ return true;
201
+ }
202
+
203
+ void OnResetDialogState(CefRefPtr<CefBrowser>) override {
204
+ CEF_REQUIRE_UI_THREAD();
205
+ // Tab navigation / reload clears any stuck dialogs. Drop the callbacks —
206
+ // CEF won't deliver them anymore, and the page is already moving on.
207
+ view_->pending_dialogs.clear();
208
+ }
98
209
 
99
210
  bool OnProcessMessageReceived(
100
211
  CefRefPtr<CefBrowser> /*browser*/,
@@ -122,10 +233,25 @@ public:
122
233
 
123
234
  void OnTitleChange(CefRefPtr<CefBrowser>, const CefString& title) override {
124
235
  CEF_REQUIRE_UI_THREAD();
125
- std::string payload = "{\"title\":\"" + bunite_win::escapeJsonString(title.ToString()) + "\"}";
236
+ std::string s = title.ToString();
237
+ // Filter the transients CEF emits during initial / mid-load (blank doc
238
+ // placeholder, momentary URL-as-title) and dedup on the last emitted value.
239
+ if (s.empty()) return;
240
+ if (s == last_title_) return;
241
+ last_title_ = s;
242
+ std::string payload = "{\"title\":\"" + bunite_win::escapeJsonString(s) + "\"}";
126
243
  bunite_win::emitWebviewEvent(view_->id, "title-changed", payload);
127
244
  }
128
245
 
246
+ void OnAddressChange(CefRefPtr<CefBrowser>, CefRefPtr<CefFrame> frame,
247
+ const CefString& url) override {
248
+ CEF_REQUIRE_UI_THREAD();
249
+ if (!frame->IsMain()) return;
250
+ // URL commit point — parity with WV2 SourceChanged / mac didCommitNavigation.
251
+ // Distinct from load-finish (which is OnLoadEnd).
252
+ bunite_win::emitWebviewEvent(view_->id, "did-navigate", url.ToString());
253
+ }
254
+
129
255
  void OnBeforeDevToolsPopup(
130
256
  CefRefPtr<CefBrowser>,
131
257
  CefWindowInfo&,
@@ -153,6 +279,7 @@ public:
153
279
  void OnAfterCreated(CefRefPtr<CefBrowser> browser) override {
154
280
  CEF_REQUIRE_UI_THREAD();
155
281
  view_->browser = browser;
282
+ bunite_win::registerCdpObserverForView(view_);
156
283
  {
157
284
  std::lock_guard<std::mutex> lock(g_runtime.object_mutex);
158
285
  g_runtime.browser_to_view_id[browser->GetIdentifier()] = view_->id;
@@ -192,6 +319,19 @@ public:
192
319
  }
193
320
 
194
321
  bunite_win::emitWebviewEvent(view_->id, "view-ready");
322
+
323
+ if (view_->is_popup_pending) {
324
+ if (view_->popup_dismiss_requested) {
325
+ view_->closing.store(true);
326
+ browser->GetHost()->CloseBrowser(true);
327
+ return;
328
+ }
329
+ if (view_->pending_popup_accept) {
330
+ auto p = *view_->pending_popup_accept;
331
+ view_->pending_popup_accept.reset();
332
+ bunite_win::applyPopupAccept(view_, p.host_window_id, p.x, p.y, p.w, p.h);
333
+ }
334
+ }
195
335
  }
196
336
 
197
337
  bool DoClose(CefRefPtr<CefBrowser>) override {
@@ -255,19 +395,39 @@ public:
255
395
  CefLifeSpanHandler::WindowOpenDisposition,
256
396
  bool,
257
397
  const CefPopupFeatures&,
258
- CefWindowInfo&,
259
- CefRefPtr<CefClient>&,
398
+ CefWindowInfo& window_info,
399
+ CefRefPtr<CefClient>& client,
260
400
  CefBrowserSettings&,
261
401
  CefRefPtr<CefDictionaryValue>&,
262
402
  bool*
263
403
  ) override {
264
404
  CEF_REQUIRE_UI_THREAD();
265
- bunite_win::emitWebviewEvent(
266
- view_->id,
267
- "new-window-open",
268
- "{\"url\":\"" + bunite_win::escapeJsonString(target_url.ToString()) + "\"}"
269
- );
270
- return true;
405
+ // Popup IDs live in the upper u32 half; TS allocator stays below.
406
+ static std::atomic<uint32_t> g_popup_seq{0x80000000u};
407
+ uint32_t new_view_id = g_popup_seq.fetch_add(1);
408
+ auto* popup = new ViewHost();
409
+ popup->id = new_view_id;
410
+ popup->window = nullptr;
411
+ popup->is_popup_pending = true;
412
+ {
413
+ std::lock_guard<std::mutex> lock(g_runtime.object_mutex);
414
+ g_runtime.views_by_id[new_view_id] = popup;
415
+ }
416
+ // Initial parent = runtime message window; host adopts and reparents later.
417
+ window_info.SetAsChild(g_runtime.message_window, CefRect{0, 0, 0, 0});
418
+ window_info.style = WS_CHILD;
419
+ client = new BuniteCefClient(popup);
420
+ std::string payload = "{\"newSurfaceId\":" + std::to_string(new_view_id) +
421
+ ",\"url\":\"" + bunite_win::escapeJsonString(target_url.ToString()) +
422
+ "\",\"disposition\":\"popup\"}";
423
+ bunite_win::emitWebviewEvent(view_->id, "popup-requested", payload);
424
+ return false; // allow CEF to create the popup browser.
425
+ }
426
+
427
+ void OnLoadStart(CefRefPtr<CefBrowser>, CefRefPtr<CefFrame> frame, TransitionType) override {
428
+ CEF_REQUIRE_UI_THREAD();
429
+ if (!frame->IsMain()) return;
430
+ bunite_win::emitWebviewEvent(view_->id, "load-start", frame->GetURL().ToString());
271
431
  }
272
432
 
273
433
  void OnLoadEnd(CefRefPtr<CefBrowser>, CefRefPtr<CefFrame> frame, int) override {
@@ -277,10 +437,24 @@ public:
277
437
  }
278
438
 
279
439
  const std::string url = frame->GetURL().ToString();
280
- bunite_win::emitWebviewEvent(view_->id, "did-navigate", url);
440
+ bunite_win::emitWebviewEvent(view_->id, "load-finish", url);
281
441
  bunite_win::emitWebviewEvent(view_->id, "dom-ready", url);
282
442
  }
283
443
 
444
+ void OnLoadError(CefRefPtr<CefBrowser>, CefRefPtr<CefFrame> frame,
445
+ ErrorCode errorCode, const CefString& errorText, const CefString& failedUrl) override {
446
+ CEF_REQUIRE_UI_THREAD();
447
+ if (!frame->IsMain()) return;
448
+ // ERR_ABORTED fires on user-initiated navigation cancellation — not a
449
+ // failure from the consumer's perspective. Filter to align with WV2.
450
+ if (errorCode == ERR_ABORTED) return;
451
+ std::string reason = errorText.ToString();
452
+ if (reason.empty()) reason = "ERR_" + std::to_string(static_cast<int>(errorCode));
453
+ std::string payload = "{\"url\":\"" + bunite_win::escapeJsonString(failedUrl.ToString()) +
454
+ "\",\"reason\":\"" + bunite_win::escapeJsonString(reason) + "\"}";
455
+ bunite_win::emitWebviewEvent(view_->id, "load-fail", payload);
456
+ }
457
+
284
458
  bool OnShowPermissionPrompt(
285
459
  CefRefPtr<CefBrowser>,
286
460
  uint64_t,
@@ -341,6 +515,7 @@ public:
341
515
 
342
516
  private:
343
517
  ViewHost* view_;
518
+ std::string last_title_;
344
519
 
345
520
  IMPLEMENT_REFCOUNTING(BuniteCefClient);
346
521
  };