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
@@ -3,6 +3,7 @@
3
3
  #import "bunite_mac_internal.h"
4
4
 
5
5
  #include "webview_storage.h"
6
+ #include <objc/runtime.h>
6
7
 
7
8
  @implementation BunitePendingPermission
8
9
  @end
@@ -27,6 +28,125 @@
27
28
  using bunite_mac::g_runtime;
28
29
  using bunite_mac::utf8ToNSString;
29
30
 
31
+ // WKDownload delegate — decideDestination gates host policy + emits
32
+ // started/blocked, KVO on download.progress emits progress, didFinish/didFail
33
+ // emit terminal events.
34
+ @interface BuniteDownloadDelegate : NSObject <WKDownloadDelegate>
35
+ @property (nonatomic) uint32_t viewId;
36
+ @property (nonatomic) uint64_t downloadId;
37
+ @property (nonatomic, strong) NSString* destinationPath;
38
+ @property (nonatomic, weak) NSProgress* observedProgress;
39
+ @property (nonatomic) int64_t lastReportedBytes;
40
+ @property (atomic) BOOL terminalReached;
41
+ @end
42
+
43
+ static char kBuniteProgressCtx;
44
+
45
+ @implementation BuniteDownloadDelegate
46
+
47
+ - (void)download:(WKDownload*)download
48
+ decideDestinationUsingResponse:(NSURLResponse*)response
49
+ suggestedFilename:(NSString*)suggested
50
+ completionHandler:(void (^)(NSURL*))completionHandler
51
+ {
52
+ auto* st = bunite_mac::findView(self.viewId);
53
+ if (!st) { completionHandler(nil); return; }
54
+ const int32_t policy = st->download_policy.load();
55
+ NSString* url = download.originalRequest.URL.absoluteString ?: @"";
56
+ std::string url_s = url.UTF8String ?: "";
57
+ if (policy != 0) {
58
+ const char* reason = (policy == 1) ? "ask-not-implemented" : "host-policy";
59
+ std::string payload = "{\"kind\":\"blocked\",\"id\":\"mac-" + std::to_string(self.downloadId) +
60
+ "\",\"url\":\"" + bunite_mac::escapeJsonString(url_s) +
61
+ "\",\"reason\":\"" + reason + "\"}";
62
+ bunite_mac::emitWebviewEvent(self.viewId, "download-event", payload);
63
+ completionHandler(nil);
64
+ return;
65
+ }
66
+ NSString* dirStr = st->download_dir.empty() ? nil : [NSString stringWithUTF8String:st->download_dir.c_str()];
67
+ if (!dirStr || dirStr.length == 0) {
68
+ NSArray<NSURL*>* paths = [NSFileManager.defaultManager URLsForDirectory:NSDownloadsDirectory inDomains:NSUserDomainMask];
69
+ dirStr = paths.firstObject.path ?: NSTemporaryDirectory();
70
+ }
71
+ NSString* fname = suggested.length > 0 ? suggested : @"download";
72
+ NSString* path = [dirStr stringByAppendingPathComponent:fname];
73
+ self.destinationPath = path;
74
+ std::string mime = (response.MIMEType ?: @"").UTF8String ?: "";
75
+ long long total = response.expectedContentLength > 0 ? (long long)response.expectedContentLength : 0;
76
+ std::string started = "{\"kind\":\"started\",\"id\":\"mac-" + std::to_string(self.downloadId) +
77
+ "\",\"url\":\"" + bunite_mac::escapeJsonString(url_s) +
78
+ "\",\"suggestedFilename\":\"" + bunite_mac::escapeJsonString(fname.UTF8String ?: "") +
79
+ "\",\"mimeType\":\"" + bunite_mac::escapeJsonString(mime) + "\"";
80
+ if (total > 0) started += ",\"sizeBytes\":" + std::to_string(total);
81
+ started += "}";
82
+ bunite_mac::emitWebviewEvent(self.viewId, "download-event", started);
83
+ NSProgress* prog = download.progress;
84
+ if (prog) {
85
+ self.observedProgress = prog;
86
+ self.lastReportedBytes = 0;
87
+ [prog addObserver:self
88
+ forKeyPath:@"completedUnitCount"
89
+ options:NSKeyValueObservingOptionNew
90
+ context:&kBuniteProgressCtx];
91
+ }
92
+ completionHandler([NSURL fileURLWithPath:path]);
93
+ }
94
+
95
+ - (void)observeValueForKeyPath:(NSString*)keyPath
96
+ ofObject:(id)object
97
+ change:(NSDictionary*)change
98
+ context:(void*)context
99
+ {
100
+ if (context != &kBuniteProgressCtx) {
101
+ [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
102
+ return;
103
+ }
104
+ if (self.terminalReached) return;
105
+ NSProgress* prog = (NSProgress*)object;
106
+ int64_t received = prog.completedUnitCount;
107
+ int64_t total = prog.totalUnitCount;
108
+ if (received <= self.lastReportedBytes) return;
109
+ self.lastReportedBytes = received;
110
+ std::string payload = "{\"kind\":\"progress\",\"id\":\"mac-" + std::to_string(self.downloadId) +
111
+ "\",\"receivedBytes\":" + std::to_string(received);
112
+ if (total > 0) payload += ",\"totalBytes\":" + std::to_string(total);
113
+ payload += "}";
114
+ bunite_mac::emitWebviewEvent(self.viewId, "download-event", payload);
115
+ }
116
+
117
+ - (void)_detachProgressObserver {
118
+ NSProgress* prog = self.observedProgress;
119
+ if (!prog) return;
120
+ @try {
121
+ [prog removeObserver:self forKeyPath:@"completedUnitCount" context:&kBuniteProgressCtx];
122
+ } @catch (NSException* ex) {} // tolerate over-remove on edge lifecycles
123
+ self.observedProgress = nil;
124
+ }
125
+
126
+ - (void)downloadDidFinish:(WKDownload*)download {
127
+ self.terminalReached = YES;
128
+ [self _detachProgressObserver];
129
+ std::string dest = self.destinationPath ? std::string(self.destinationPath.UTF8String ?: "") : "";
130
+ std::string payload = "{\"kind\":\"completed\",\"id\":\"mac-" + std::to_string(self.downloadId) +
131
+ "\",\"localPath\":\"" + bunite_mac::escapeJsonString(dest) + "\"}";
132
+ bunite_mac::emitWebviewEvent(self.viewId, "download-event", payload);
133
+ }
134
+
135
+ - (void)download:(WKDownload*)download didFailWithError:(NSError*)error resumeData:(NSData*)resumeData {
136
+ self.terminalReached = YES;
137
+ [self _detachProgressObserver];
138
+ std::string reason = error ? std::string(error.localizedDescription.UTF8String ?: "unknown") : "unknown";
139
+ std::string payload = "{\"kind\":\"failed\",\"id\":\"mac-" + std::to_string(self.downloadId) +
140
+ "\",\"reason\":\"" + bunite_mac::escapeJsonString(reason) + "\"}";
141
+ bunite_mac::emitWebviewEvent(self.viewId, "download-event", payload);
142
+ }
143
+
144
+ - (void)dealloc {
145
+ [self _detachProgressObserver];
146
+ }
147
+
148
+ @end
149
+
30
150
  @interface BuniteNavigationDelegate : NSObject <WKNavigationDelegate>
31
151
  @end
32
152
 
@@ -92,6 +212,24 @@ decidePolicyForNavigationAction:(WKNavigationAction*)action
92
212
  bunite_mac::emitWebviewEvent(view_id, "load-fail", payload);
93
213
  }
94
214
 
215
+ - (void)webView:(WKWebView*)wv navigationAction:(WKNavigationAction*)action didBecomeDownload:(WKDownload*)download {
216
+ static std::atomic<uint64_t> g_seq{1};
217
+ BuniteDownloadDelegate* d = [BuniteDownloadDelegate new];
218
+ d.viewId = bunite_mac::viewIdForWebView(wv);
219
+ d.downloadId = g_seq.fetch_add(1);
220
+ download.delegate = d;
221
+ objc_setAssociatedObject(download, "bunite.dl.delegate", d, OBJC_ASSOCIATION_RETAIN);
222
+ }
223
+
224
+ - (void)webView:(WKWebView*)wv navigationResponse:(WKNavigationResponse*)response didBecomeDownload:(WKDownload*)download {
225
+ static std::atomic<uint64_t> g_seq{0x80000000ULL};
226
+ BuniteDownloadDelegate* d = [BuniteDownloadDelegate new];
227
+ d.viewId = bunite_mac::viewIdForWebView(wv);
228
+ d.downloadId = g_seq.fetch_add(1);
229
+ download.delegate = d;
230
+ objc_setAssociatedObject(download, "bunite.dl.delegate", d, OBJC_ASSOCIATION_RETAIN);
231
+ }
232
+
95
233
  @end
96
234
 
97
235
  @interface BuniteTitleObserver : NSObject
@@ -132,14 +270,44 @@ createWebViewWithConfiguration:(WKWebViewConfiguration*)config
132
270
  forNavigationAction:(WKNavigationAction*)action
133
271
  windowFeatures:(WKWindowFeatures*)features
134
272
  {
135
- (void)config; (void)features;
136
- uint32_t view_id = bunite_mac::viewIdForWebView(wv);
273
+ (void)features;
274
+ uint32_t opener_view_id = bunite_mac::viewIdForWebView(wv);
137
275
  NSString* url = action.request.URL.absoluteString ?: @"";
138
- // Match win OnBeforePopup/OnOpenURLFromTab: emit, cancel; JS decides via load_url.
139
- std::string payload = "{\"url\":\"" +
140
- bunite_mac::escapeJsonString(url.UTF8String ?: "") + "\"}";
141
- bunite_mac::emitWebviewEvent(view_id, "new-window-open", payload);
142
- return nil;
276
+ if (bunite_mac::g_runtime.popup_blocking) {
277
+ std::string payload = "{\"url\":\"" + bunite_mac::escapeJsonString(url.UTF8String ?: "") + "\"}";
278
+ bunite_mac::emitWebviewEvent(opener_view_id, "new-window-open", payload);
279
+ return nil;
280
+ }
281
+ static std::atomic<uint32_t> g_popup_seq{0x80000000u};
282
+ const uint32_t new_view_id = g_popup_seq.fetch_add(1);
283
+ WKWebView* popup = [[WKWebView alloc] initWithFrame:NSMakeRect(0, 0, 1, 1) configuration:config];
284
+ popup.navigationDelegate = wv.navigationDelegate;
285
+ popup.UIDelegate = wv.UIDelegate;
286
+ if (!bunite_mac::g_runtime.parked_popups) {
287
+ bunite_mac::g_runtime.parked_popups = [NSMutableDictionary dictionary];
288
+ }
289
+ if (!bunite_mac::g_runtime.popup_parent) {
290
+ bunite_mac::g_runtime.popup_parent =
291
+ [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 1, 1)
292
+ styleMask:NSWindowStyleMaskBorderless
293
+ backing:NSBackingStoreBuffered
294
+ defer:YES];
295
+ }
296
+ bunite_mac::g_runtime.parked_popups[@(new_view_id)] = popup;
297
+ [bunite_mac::g_runtime.popup_parent.contentView addSubview:popup];
298
+ bunite_mac::registerWebViewId(popup, new_view_id);
299
+ {
300
+ std::lock_guard<std::mutex> lock(bunite_mac::g_runtime.object_mutex);
301
+ auto& st = bunite_mac::g_runtime.views[new_view_id];
302
+ st.webview = popup;
303
+ st.container = nil; // bound on adoption
304
+ st.window_id = 0;
305
+ }
306
+ std::string payload = "{\"newSurfaceId\":" + std::to_string(new_view_id) +
307
+ ",\"url\":\"" + bunite_mac::escapeJsonString(url.UTF8String ?: "") +
308
+ "\",\"disposition\":\"popup\"}";
309
+ bunite_mac::emitWebviewEvent(opener_view_id, "popup-requested", payload);
310
+ return popup;
143
311
  }
144
312
 
145
313
  // --- Dialog handlers (alert / confirm / prompt). beforeunload is not
@@ -279,6 +447,14 @@ uint32_t viewIdForWebView(WKWebView* wv) {
279
447
  return id ? id.unsignedIntValue : 0;
280
448
  }
281
449
 
450
+ void registerWebViewId(WKWebView* wv, uint32_t view_id) {
451
+ [webviewIdTable() setObject:@(view_id) forKey:wv];
452
+ }
453
+
454
+ void unregisterWebViewId(WKWebView* wv) {
455
+ [webviewIdTable() removeObjectForKey:wv];
456
+ }
457
+
282
458
  bool createView(uint32_t view_id, uint32_t window_id,
283
459
  NSString* url, NSString* html, NSString* preload, NSString* appres_root,
284
460
  NSString* navigation_rules_json, NSString* preload_origins_json,
@@ -364,6 +540,50 @@ bool createView(uint32_t view_id, uint32_t window_id,
364
540
  return true;
365
541
  }
366
542
 
543
+ bool acceptParkedPopup(uint32_t new_view_id, uint32_t host_window_id, double x, double y, double w, double h) {
544
+ WKWebView* popup = nil;
545
+ if (g_runtime.parked_popups) {
546
+ popup = g_runtime.parked_popups[@(new_view_id)];
547
+ if (popup) [g_runtime.parked_popups removeObjectForKey:@(new_view_id)];
548
+ }
549
+ if (!popup) return false;
550
+ WindowState* host = findWindow(host_window_id);
551
+ if (!host || !host->window) return false;
552
+ [popup removeFromSuperview];
553
+ BunitePassthroughContainer* container = [[BunitePassthroughContainer alloc] init];
554
+ container.frame = NSMakeRect(x, y, w, h);
555
+ popup.frame = NSMakeRect(0, 0, w, h);
556
+ popup.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
557
+ [container addSubview:popup];
558
+ [host->window.contentView addSubview:container];
559
+ {
560
+ std::lock_guard<std::mutex> lock(g_runtime.object_mutex);
561
+ auto& st = g_runtime.views[new_view_id];
562
+ st.container = container;
563
+ st.webview = popup;
564
+ st.window_id = host_window_id;
565
+ }
566
+ // Re-emit view-ready so TS BrowserView.adopt resolves — the initial
567
+ // did-navigate fired before the adopter registered.
568
+ emitWebviewEvent(new_view_id, "view-ready", "");
569
+ return true;
570
+ }
571
+
572
+ void dismissParkedPopup(uint32_t new_view_id) {
573
+ WKWebView* popup = nil;
574
+ if (g_runtime.parked_popups) {
575
+ popup = g_runtime.parked_popups[@(new_view_id)];
576
+ if (popup) [g_runtime.parked_popups removeObjectForKey:@(new_view_id)];
577
+ }
578
+ if (!popup) return;
579
+ [popup removeFromSuperview];
580
+ unregisterWebViewId(popup);
581
+ {
582
+ std::lock_guard<std::mutex> lock(g_runtime.object_mutex);
583
+ g_runtime.views.erase(new_view_id);
584
+ }
585
+ }
586
+
367
587
  void removeView(uint32_t view_id) {
368
588
  __strong WKWebView* wv = nil;
369
589
  __strong NSView* container = nil;
@@ -15,29 +15,7 @@
15
15
  extern "C" {
16
16
  #endif
17
17
 
18
- /** ABI version. Bump on any breaking change to symbol set / signatures.
19
- * v9 (2026-05): adds `bunite_view_mouse` (move/down/up primitives for drag &
20
- * hover) + `bunite_view_respond_dialog`. Webview event names
21
- * expand to include `dialog` (alert/confirm/prompt/beforeunload)
22
- * and `console-message` (the latter is RPC-pushed by preload,
23
- * not emitted by native — listed here for the host-side event
24
- * channel completeness). Capability bits add `MOUSE` (1<<11),
25
- * `DIALOGS` (1<<12), `CONSOLE` (1<<13). `NATIVE_INPUT_TRUSTED`
26
- * meaning stretches to include `mouse` (click/type/press/mouse
27
- * all produce isTrusted=true on supporting backends; scroll and
28
- * screenshot remain outside that guarantee).
29
- * v8 (2026-05): `bunite_view_press` gains `action` (down/up/both), `extended`
30
- * (Win 0xE0 scancode prefix), `location` (DOM KeyboardEvent
31
- * location, 0/1/2/3) params. Webview event names expand to
32
- * include `load-start` / `load-finish` / `load-fail`. Capability
33
- * bit 2 renamed `TITLE_CHANGED` → `SURFACE_EVENTS` — value
34
- * unchanged but semantic is now "unified surfaceEvents stream
35
- * supported". A surface fires `load-finish` OR `load-fail` per
36
- * navigation, never both.
37
- * v7 (2026-05): adds `bunite_view_screenshot` + `bunite_view_capabilities`
38
- * + capability bitset (`BuniteCapBit`).
39
- * v6 (2026-05): adds input dispatch — `bunite_view_click/type/press/scroll`.
40
- * v5 (2026-05): adds `bunite_view_evaluate` + `evaluate-result` webview event. */
18
+ /** ABI version. Bump on any breaking change to symbol set / signatures. */
41
19
  BUNITE_EXPORT int32_t bunite_abi_version(void);
42
20
  BUNITE_EXPORT void bunite_set_log_level(int32_t level);
43
21
  BUNITE_EXPORT bool bunite_init(
@@ -92,6 +70,10 @@ BUNITE_EXPORT void bunite_window_set_frame(
92
70
  double width,
93
71
  double height
94
72
  );
73
+ /** Start an OS-driven window move drag (frameless titlebar). Call from a
74
+ * page mousedown; the OS handles tracking through mouse-up. No-op if the
75
+ * window is unknown. */
76
+ BUNITE_EXPORT void bunite_window_begin_move_drag(uint32_t window_id);
95
77
 
96
78
  BUNITE_EXPORT bool bunite_view_create(
97
79
  uint32_t view_id,
@@ -222,24 +204,33 @@ BUNITE_EXPORT void bunite_view_respond_dialog(
222
204
  const char* text
223
205
  );
224
206
 
225
- /** Per-view automation capability bitset. Each backend returns the bits it
226
- * actually supports TS layer decodes to `SurfaceCapabilities` object.
227
- * Bits are locked at ABI v6; append-only. */
207
+ /** Per-view automation capability bitset. Two categories: method gate
208
+ * (bit=false method must not be called) and property advertise (bit=false
209
+ * method runs, property degrades). See `.agents/architecture.md`. */
228
210
  enum BuniteCapBit {
211
+ /* method gate */
229
212
  BUNITE_CAP_EVALUATE = 1u << 0,
230
- BUNITE_CAP_CROSS_ORIGIN_EVAL = 1u << 1,
231
213
  BUNITE_CAP_SURFACE_EVENTS = 1u << 2,
232
- BUNITE_CAP_NATIVE_INPUT_TRUSTED = 1u << 3, /* click/type/press/mouse all isTrusted=true */
233
214
  BUNITE_CAP_CLICK = 1u << 4,
234
215
  BUNITE_CAP_TYPE = 1u << 5,
235
216
  BUNITE_CAP_PRESS = 1u << 6,
236
217
  BUNITE_CAP_SCROLL = 1u << 7,
237
218
  BUNITE_CAP_SCREENSHOT = 1u << 8,
238
- BUNITE_CAP_FORMAT_PNG = 1u << 9,
239
- BUNITE_CAP_FORMAT_JPEG = 1u << 10,
240
219
  BUNITE_CAP_MOUSE = 1u << 11,
241
220
  BUNITE_CAP_DIALOGS = 1u << 12,
242
221
  BUNITE_CAP_CONSOLE = 1u << 13,
222
+ BUNITE_CAP_AX = 1u << 15,
223
+ BUNITE_CAP_BOUNDING_RECT = 1u << 16,
224
+ BUNITE_CAP_FRAMES = 1u << 17,
225
+ BUNITE_CAP_DOWNLOADS = 1u << 18,
226
+ BUNITE_CAP_POPUPS = 1u << 19,
227
+ BUNITE_CAP_RESOLVE_AND_CLICK = 1u << 20,
228
+
229
+ /* property advertise */
230
+ BUNITE_CAP_CROSS_ORIGIN_EVAL = 1u << 1,
231
+ BUNITE_CAP_NATIVE_INPUT_TRUSTED = 1u << 3, /* click/type/press/mouse isTrusted */
232
+ BUNITE_CAP_FORMAT_PNG = 1u << 9,
233
+ BUNITE_CAP_FORMAT_JPEG = 1u << 10,
243
234
  };
244
235
  BUNITE_EXPORT uint32_t bunite_view_capabilities(uint32_t view_id);
245
236
 
@@ -259,6 +250,82 @@ BUNITE_EXPORT void bunite_view_screenshot(
259
250
  int32_t quality
260
251
  );
261
252
 
253
+ /** Snapshot the accessibility tree (CDP `Accessibility.getFullAXTree`). Async —
254
+ * result reported via webview event handler as `accessibility-result` payload
255
+ * { requestId, ok: true, tree: {nodes: [<CDP AXNode flat list>]} }
256
+ * { requestId, ok: false, code, message }
257
+ * TS builds the nested tree from `childIds`. mac/linux always emit
258
+ * `not_supported` (no public ax tree API). `interesting_only` is reserved and
259
+ * currently unused on the native side (filter is TS-side). */
260
+ BUNITE_EXPORT void bunite_view_accessibility_snapshot(
261
+ uint32_t view_id,
262
+ uint32_t request_id,
263
+ int32_t interesting_only
264
+ );
265
+
266
+ /** Enumerate frames in the view. Async — result reported via webview event
267
+ * `list-frames-result` payload
268
+ * { requestId, ok: true, frames: [{frameId, parentFrameId, origin, url, name?}] }
269
+ * { requestId, ok: false, code, message }
270
+ * Codes: `not_supported`, `runtime_error`. mac/linux emit `not_supported`. */
271
+ BUNITE_EXPORT void bunite_view_list_frames(uint32_t view_id, uint32_t request_id);
272
+
273
+ /** Evaluate `script` in the target frame's isolated world (CDP
274
+ * `Page.createIsolatedWorld` + `Runtime.evaluate`). Page main-world JS
275
+ * variables are NOT visible; DOM access works. Result reused via the
276
+ * existing `evaluate-result` event. `frame_id` empty/null delegates to
277
+ * `bunite_view_evaluate` (main frame, main world). */
278
+ BUNITE_EXPORT void bunite_view_evaluate_in_frame(
279
+ uint32_t view_id,
280
+ uint32_t request_id,
281
+ const char* script,
282
+ const char* frame_id
283
+ );
284
+
285
+ /** Atomic selector resolve + native click. Async via `resolve-and-click-result`:
286
+ * { requestId, ok: true, rect, isTrustedEvent }
287
+ * { requestId, ok: false, code, message }
288
+ * Codes: not_found / not_visible / runtime_error / cross_origin / not_supported.
289
+ * `frame_id` non-empty selects a same-origin iframe (rect viewport-normalized);
290
+ * cross-origin → `cross_origin`, mac/linux → `not_supported`. scrollIntoView is
291
+ * automatic. `isTrustedEvent` is empirical per backend; CEF/WV2 CDP path and
292
+ * mac NSEvent direct dispatch all produce trusted events (all `true`). */
293
+ BUNITE_EXPORT void bunite_view_resolve_and_click(
294
+ uint32_t view_id,
295
+ uint32_t request_id,
296
+ const char* selector,
297
+ const char* frame_id,
298
+ int32_t button,
299
+ int32_t click_count,
300
+ uint32_t modifiers
301
+ );
302
+
303
+ /** Set per-view download policy. `policy`: 0=auto (allow + emit lifecycle),
304
+ * 1=ask (not implemented for v10, treated as block), 2=block (default).
305
+ * `download_dir` (utf-8) optionally overrides backend default save dir.
306
+ * Lifecycle events emit as `download-event` payloads
307
+ * { kind: "started"|"progress"|"completed"|"failed"|"blocked", id, ...fields }
308
+ * See `DownloadEvent` (TS) for the per-kind field set. */
309
+ BUNITE_EXPORT void bunite_view_set_download_policy(
310
+ uint32_t view_id,
311
+ int32_t policy,
312
+ const char* download_dir
313
+ );
314
+
315
+ /** Adopt a popup-minted view. Native must have previously emitted a
316
+ * `popup-requested` event (carrying `newSurfaceId`); host calls this to attach
317
+ * the pre-minted view to the target window + bounds. `host_window_id` is the
318
+ * destination `WindowHost.id`. */
319
+ BUNITE_EXPORT void bunite_view_popup_accept(
320
+ uint32_t new_view_id,
321
+ uint32_t host_window_id,
322
+ double x, double y, double w, double h
323
+ );
324
+
325
+ /** Discard a popup-minted view that wasn't adopted (or that host wants to
326
+ * reject). Native destroys the controller/browser. Idempotent. */
327
+ BUNITE_EXPORT void bunite_view_popup_dismiss(uint32_t new_view_id);
328
+
262
329
  BUNITE_EXPORT void bunite_view_open_devtools(uint32_t view_id);
263
330
  BUNITE_EXPORT void bunite_view_close_devtools(uint32_t view_id);
264
331
  BUNITE_EXPORT void bunite_view_toggle_devtools(uint32_t view_id);
@@ -86,7 +86,8 @@ class BuniteCefClient
86
86
  public CefResourceRequestHandler,
87
87
  public CefPermissionHandler,
88
88
  public CefDisplayHandler,
89
- public CefJSDialogHandler {
89
+ public CefJSDialogHandler,
90
+ public CefDownloadHandler {
90
91
  public:
91
92
  // BuniteCefClient is constructed 1:1 with a `ViewHost*`; `last_title_` is
92
93
  // therefore per-view. OnTitleChange runs on the CEF UI thread (single).
@@ -99,6 +100,68 @@ public:
99
100
  CefRefPtr<CefPermissionHandler> GetPermissionHandler() override { return this; }
100
101
  CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
101
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
+ }
102
165
 
103
166
  bool OnJSDialog(CefRefPtr<CefBrowser>, const CefString& /*origin_url*/,
104
167
  JSDialogType dialog_type, const CefString& message_text,
@@ -110,7 +173,9 @@ public:
110
173
  : (dialog_type == JSDIALOGTYPE_CONFIRM) ? "confirm" : "prompt";
111
174
  const uint32_t rid = view_->next_dialog_request_id++;
112
175
  view_->pending_dialogs[rid] = callback;
113
- suppress_message = true; // we control the dialog UX; host sends the answer.
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;
114
179
  std::string payload = "{\"requestId\":" + std::to_string(rid) +
115
180
  ",\"kind\":\"" + kind +
116
181
  "\",\"message\":\"" + bunite_win::escapeJsonString(message_text.ToString()) + "\"";
@@ -254,6 +319,19 @@ public:
254
319
  }
255
320
 
256
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
+ }
257
335
  }
258
336
 
259
337
  bool DoClose(CefRefPtr<CefBrowser>) override {
@@ -317,19 +395,33 @@ public:
317
395
  CefLifeSpanHandler::WindowOpenDisposition,
318
396
  bool,
319
397
  const CefPopupFeatures&,
320
- CefWindowInfo&,
321
- CefRefPtr<CefClient>&,
398
+ CefWindowInfo& window_info,
399
+ CefRefPtr<CefClient>& client,
322
400
  CefBrowserSettings&,
323
401
  CefRefPtr<CefDictionaryValue>&,
324
402
  bool*
325
403
  ) override {
326
404
  CEF_REQUIRE_UI_THREAD();
327
- bunite_win::emitWebviewEvent(
328
- view_->id,
329
- "new-window-open",
330
- "{\"url\":\"" + bunite_win::escapeJsonString(target_url.ToString()) + "\"}"
331
- );
332
- 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.
333
425
  }
334
426
 
335
427
  void OnLoadStart(CefRefPtr<CefBrowser>, CefRefPtr<CefFrame> frame, TransitionType) override {
@@ -638,11 +730,13 @@ ViewHost* getViewHostById(uint32_t view_id) {
638
730
  }
639
731
 
640
732
  DWORD makeWindowStyle(const std::wstring& title_bar_style) {
641
- DWORD style = WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN;
642
733
  if (title_bar_style == L"hidden" || title_bar_style == L"hiddenInset") {
643
- style &= ~WS_CAPTION;
734
+ // WS_POPUP, not `WS_OVERLAPPEDWINDOW & ~WS_CAPTION`: the latter keeps
735
+ // WS_SYSMENU, so Windows re-adds WS_CAPTION at create. WS_THICKFRAME keeps
736
+ // snap + resize; the frame edge is reclaimed in WM_NCCALCSIZE.
737
+ return WS_POPUP | WS_THICKFRAME | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_CLIPCHILDREN;
644
738
  }
645
- return style;
739
+ return WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN;
646
740
  }
647
741
 
648
742
  // ---------------------------------------------------------------------------