@livx.cc/appwrap 0.46.1 → 0.46.3

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.
@@ -0,0 +1,540 @@
1
+ // WKWebView adapter for the webview-control primitive (macOS). Lifted from bridge_mac.rs
2
+ // (Phase 1 extraction) with the transport/protocol stripped OUT: this module emits RAW page
3
+ // payloads via an `on_message` callback and RAW eval/snapshot results via per-call `on_done`
4
+ // callbacks. It knows nothing of the bod-browser wire format, the TCP Sender, or the
5
+ // {type:"pageMsg"} / {id,result} framing — those live in the control-channel adapter (bridge_mac.rs).
6
+ use std::cell::RefCell;
7
+ use std::sync::atomic::{AtomicBool, Ordering};
8
+ use std::sync::Mutex;
9
+
10
+ use objc2::rc::Retained;
11
+ use objc2::runtime::AnyObject;
12
+ use objc2::runtime::ProtocolObject;
13
+ use objc2::{define_class, msg_send, AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly};
14
+ use objc2_app_kit::{
15
+ NSAutoresizingMaskOptions, NSBackingStoreType, NSBitmapImageFileType, NSBitmapImageRep, NSImage,
16
+ NSView, NSWindow, NSWindowDelegate, NSWindowStyleMask,
17
+ };
18
+ use objc2_foundation::{
19
+ NSDictionary, NSError, NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize,
20
+ NSString, NSURL, NSURLRequest, NSUUID,
21
+ };
22
+ use objc2_web_kit::{
23
+ WKContentWorld, WKScriptMessage, WKScriptMessageHandler, WKUserContentController, WKUserScript,
24
+ WKUserScriptInjectionTime, WKWebView, WKWebViewConfiguration, WKWebsiteDataStore,
25
+ };
26
+
27
+ use crate::popup;
28
+ use crate::profiles;
29
+ use crate::Mount;
30
+
31
+ /// True when webviews should be Safari-inspectable (Develop ▸ <app> ▸ Web Inspector). Debug builds
32
+ /// are ALWAYS inspectable; a release build is inspectable ONLY when `APPWRAP_DEVTOOLS` is set to
33
+ /// `1`/`true` — so production apps aren't inspectable by default. Self-contained in this crate (no
34
+ /// shell-config dependency) so every webview creation site can gate uniformly.
35
+ pub fn devtools_enabled() -> bool {
36
+ cfg!(debug_assertions)
37
+ || matches!(std::env::var("APPWRAP_DEVTOOLS").ok().as_deref(), Some("1") | Some("true"))
38
+ }
39
+
40
+ /// Flip a WKWebView's `inspectable` property ON when [`devtools_enabled`], making it attachable from
41
+ /// Safari → Develop. The property lands in macOS 13.3+, so it's guarded by `respondsToSelector:` and
42
+ /// no-ops on older systems. Set right after webview creation, on the main thread. `wk` must be a live
43
+ /// `*mut WKWebView`.
44
+ pub unsafe fn set_inspectable(wk: *mut WKWebView) {
45
+ if wk.is_null() || !devtools_enabled() {
46
+ return;
47
+ }
48
+ let wk = &*wk;
49
+ let sel = objc2::sel!(setInspectable:);
50
+ let responds: bool = msg_send![wk, respondsToSelector: sel];
51
+ if responds {
52
+ let _: () = msg_send![wk, setInspectable: true];
53
+ }
54
+ }
55
+
56
+ /// Autoresizing mask that pins a mounted webview to fill its superview on resize — the same
57
+ /// width+height-sizable mask popup windows use, applied on every (re)mount.
58
+ const FILL_MASK: NSAutoresizingMaskOptions =
59
+ NSAutoresizingMaskOptions(NSAutoresizingMaskOptions::ViewWidthSizable.0 | NSAutoresizingMaskOptions::ViewHeightSizable.0);
60
+
61
+ /// Raw page->host message sink. Receives the UNWRAPPED WKScriptMessage body string (the page's
62
+ /// `webkit.messageHandlers.bridgeShim.postMessage(...)` payload). The adapter wraps it into its
63
+ /// transport envelope — the primitive stays framing-agnostic. `Send` (it crosses onto the main
64
+ /// thread with the create op) but not `Sync` — the handler is MainThreadOnly and only ever invoked
65
+ /// on the main thread, mirroring the original bare `Sender`.
66
+ pub type MessageSink = Box<dyn Fn(String) + Send>;
67
+
68
+ /// Result of creating a profile window — the store facts the harness asserts. The adapter builds
69
+ /// the wire JSON from this; the primitive returns plain data.
70
+ pub struct CreateResult {
71
+ pub persistent: bool,
72
+ pub store_uuid: String,
73
+ }
74
+
75
+ /// One profile window's public identity for `list_webviews`.
76
+ pub struct WebviewInfo {
77
+ pub label: String,
78
+ pub profile_id: String,
79
+ }
80
+
81
+ /// Spec for creating a webview via the embedded facade (Phase 2). Mirrors the control-channel
82
+ /// `createWindow` op fields plus the `mount` target (the adapter always builds `Mount::TopLevel`).
83
+ pub struct CreateSpec {
84
+ pub label: String,
85
+ pub url: String,
86
+ pub profile_id: String,
87
+ pub init_js: String,
88
+ pub mount: Mount,
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Page -> host transport: a native WKScriptMessageHandler ("bridgeShim").
93
+ // WebKit blocks ws://127.0.0.1 (and http beacons) from https pages as mixed content — NO loopback
94
+ // exemption like Chrome — so the content layer posts results via
95
+ // webkit.messageHandlers.bridgeShim.postMessage(json) instead. We hand the RAW payload to the
96
+ // on_message sink; the adapter does the framing.
97
+ // ---------------------------------------------------------------------------
98
+ pub struct MsgHandlerIvars {
99
+ on_message: MessageSink,
100
+ }
101
+
102
+ define_class!(
103
+ #[unsafe(super(NSObject))]
104
+ #[thread_kind = MainThreadOnly]
105
+ #[name = "AppwrapBridgeMsgHandler"]
106
+ #[ivars = MsgHandlerIvars]
107
+ struct MsgHandler;
108
+
109
+ unsafe impl NSObjectProtocol for MsgHandler {}
110
+
111
+ unsafe impl WKScriptMessageHandler for MsgHandler {
112
+ #[unsafe(method(userContentController:didReceiveScriptMessage:))]
113
+ fn did_receive(&self, _ucc: &WKUserContentController, message: &WKScriptMessage) {
114
+ let body = unsafe { message.body() };
115
+ if let Ok(s) = body.downcast::<NSString>() {
116
+ (self.ivars().on_message)(s.to_string());
117
+ }
118
+ }
119
+ }
120
+ );
121
+
122
+ /// Install the bridgeShim WKScriptMessageHandler on an arbitrary WKWebView (used by the adapter for
123
+ /// the legacy tauri-window path — the profile-window path installs it on the config's UCC before
124
+ /// creation). The UCC retains the handler, so no external retention is needed here. Main-thread only.
125
+ pub unsafe fn install_message_handler(wk: *mut WKWebView, mtm: MainThreadMarker, on_message: MessageSink) {
126
+ let wk = &*wk;
127
+ let handler = MsgHandler::alloc(mtm).set_ivars(MsgHandlerIvars { on_message });
128
+ let handler: Retained<MsgHandler> = msg_send![super(handler), init];
129
+ let ucc = wk.configuration().userContentController();
130
+ ucc.addScriptMessageHandler_name(
131
+ ProtocolObject::from_ref(&*handler),
132
+ &NSString::from_str("bridgeShim"),
133
+ );
134
+ }
135
+
136
+ /// A manually-created profile window. Strong refs kept alive until close (or windowWillClose).
137
+ /// Only touched on the main thread; the unsafe Send exists solely so the registry can be a static
138
+ /// Mutex (same pattern as popup_mac).
139
+ struct ProfileWindow {
140
+ label: String,
141
+ profile_id: String,
142
+ window: Retained<NSWindow>,
143
+ webview: Retained<WKWebView>,
144
+ /// Set true by the window-close delegate on a USER (red-button) close.
145
+ closed: AtomicBool,
146
+ _handler: Retained<MsgHandler>,
147
+ /// Per-window SSO-popup delegate (weak uiDelegate → held here). Opaque; lifetime only.
148
+ _popup_delegate: Retained<AnyObject>,
149
+ /// NSWindowDelegate that flags `closed` on a user close (weak setDelegate → held here).
150
+ _win_delegate: Retained<ProfileWinDelegate>,
151
+ }
152
+ unsafe impl Send for ProfileWindow {}
153
+ static PROFILE_WINDOWS: Mutex<Vec<ProfileWindow>> = Mutex::new(Vec::new());
154
+
155
+ // NSWindowDelegate for profile windows — flags the matching registry entry `closed` when the user
156
+ // closes the window (red button), so it drops out of list_webviews. Matches by NSWindow identity
157
+ // (the notification's object). Main-thread only.
158
+ define_class!(
159
+ #[unsafe(super(NSObject))]
160
+ #[thread_kind = MainThreadOnly]
161
+ #[name = "AppwrapProfileWinDelegate"]
162
+ struct ProfileWinDelegate;
163
+
164
+ unsafe impl NSObjectProtocol for ProfileWinDelegate {}
165
+
166
+ unsafe impl NSWindowDelegate for ProfileWinDelegate {
167
+ #[unsafe(method(windowWillClose:))]
168
+ fn window_will_close(&self, notification: &NSNotification) {
169
+ let Some(obj) = notification.object() else { return };
170
+ let key = Retained::as_ptr(&obj) as usize;
171
+ let v = PROFILE_WINDOWS.lock().unwrap();
172
+ if let Some(p) = v.iter().find(|p| Retained::as_ptr(&p.window) as usize == key) {
173
+ eprintln!("[bridge-shim] profile window {} user-closed", p.label);
174
+ // Detach from the superview so a ChildOf-mounted webview isn't left as a zombie in
175
+ // the host pane once `reap_closed` drops the registry `Retained` (see `close`).
176
+ p.webview.removeFromSuperview();
177
+ p.closed.store(true, Ordering::Relaxed);
178
+ }
179
+ }
180
+ }
181
+ );
182
+
183
+ /// Retained clone of a profile window's WKWebView (op routing), or None if the label is unknown /
184
+ /// already closed. The adapter uses this to decide whether an op targets a profile window (else it
185
+ /// falls back to its legacy tauri path).
186
+ pub fn profile_webview(label: &str) -> Option<Retained<WKWebView>> {
187
+ PROFILE_WINDOWS
188
+ .lock()
189
+ .unwrap()
190
+ .iter()
191
+ .find(|p| p.label == label && !p.closed.load(Ordering::Relaxed))
192
+ .map(|p| p.webview.clone())
193
+ }
194
+
195
+ /// Public identities of the live (non-closed) profile windows.
196
+ pub fn list_webviews() -> Vec<WebviewInfo> {
197
+ PROFILE_WINDOWS
198
+ .lock()
199
+ .unwrap()
200
+ .iter()
201
+ .filter(|p| !p.closed.load(Ordering::Relaxed))
202
+ .map(|p| WebviewInfo { label: p.label.clone(), profile_id: p.profile_id.clone() })
203
+ .collect()
204
+ }
205
+
206
+ /// Drop entries flagged `closed` (user-closed windows). Called at safe points only — never from
207
+ /// within a delegate callback, so the delegate isn't released mid-call.
208
+ fn reap_closed() {
209
+ let mut v = PROFILE_WINDOWS.lock().unwrap();
210
+ v.retain(|p| !p.closed.load(Ordering::Relaxed));
211
+ }
212
+
213
+ /// Close + drop a profile window (releases its NSWindow + WKWebView). Main-thread only. Returns
214
+ /// false if no such profile window (caller falls back to tauri). The removed entry is held on the
215
+ /// stack across window.close() so its window delegate stays alive while AppKit fires windowWillClose.
216
+ pub fn close(label: &str) -> bool {
217
+ let entry = {
218
+ let mut v = PROFILE_WINDOWS.lock().unwrap();
219
+ v.iter().position(|p| p.label == label).map(|i| v.remove(i))
220
+ };
221
+ match entry {
222
+ Some(p) => {
223
+ // Detach the WKWebView from its superview BEFORE dropping the registry `Retained`. In
224
+ // `Mount::ChildOf` (embedded) mode the webview is a subview of the HOST pane (retained
225
+ // by the host's subview array), NOT the home window's contentView — so `window.close()`
226
+ // alone leaves it alive and VISIBLE in the host pane (a zombie). Harmless for TopLevel
227
+ // (the webview IS the home window's contentView, released with the window anyway).
228
+ p.webview.removeFromSuperview();
229
+ p.window.close();
230
+ true
231
+ }
232
+ None => false,
233
+ }
234
+ }
235
+
236
+ /// Drive NSWindow performClose: (the red-button path) WITHOUT touching the registry, so the
237
+ /// windowWillClose delegate is the sole remover — proves user-close hygiene headlessly. Returns
238
+ /// false if no such profile window. Main-thread only.
239
+ pub fn user_close(label: &str) -> bool {
240
+ let win = PROFILE_WINDOWS
241
+ .lock()
242
+ .unwrap()
243
+ .iter()
244
+ .find(|p| p.label == label)
245
+ .map(|p| p.window.clone());
246
+ match win {
247
+ Some(w) => {
248
+ w.performClose(None);
249
+ true
250
+ }
251
+ None => false,
252
+ }
253
+ }
254
+
255
+ /// Create a top-level profile window (the standalone/MCP shape — `Mount::TopLevel`). Thin wrapper
256
+ /// over [`create_webview`]; kept as-is so the control-channel adapter (bridge_mac.rs) is unchanged
257
+ /// and its behavior byte-identical. Main-thread only.
258
+ pub fn create_profile_window(
259
+ mtm: MainThreadMarker,
260
+ label: &str,
261
+ url: &str,
262
+ profile_id: &str,
263
+ init_js: &str,
264
+ on_message: MessageSink,
265
+ ) -> Result<CreateResult, String> {
266
+ create_webview(
267
+ mtm,
268
+ CreateSpec {
269
+ label: label.to_string(),
270
+ url: url.to_string(),
271
+ profile_id: profile_id.to_string(),
272
+ init_js: init_js.to_string(),
273
+ mount: Mount::TopLevel,
274
+ },
275
+ on_message,
276
+ )
277
+ }
278
+
279
+ /// Create a manual NSWindow + WKWebView bound to `profile_id`'s persistent data store, inject the
280
+ /// bridge content bundle + bridgeShim handler, mount it per `spec.mount`, and register it. Runs on
281
+ /// the main thread (the adapter/host marshals it there). Returns the store facts.
282
+ ///
283
+ /// The webview ALWAYS owns a hidden home `NSWindow` (the `TopLevel` target), created here with
284
+ /// `setReleasedWhenClosed(false)` so a host-window close never frees the view. For `TopLevel` the
285
+ /// window is shown and holds the webview as its content view (identical to the pre-Phase-2 path);
286
+ /// for `ChildOf(handle)` the window stays unshown and the webview is added as a subview of the
287
+ /// host-owned pane. `set_mount` later moves the same live `WKWebView` between the two.
288
+ pub fn create_webview(
289
+ mtm: MainThreadMarker,
290
+ spec: CreateSpec,
291
+ on_message: MessageSink,
292
+ ) -> Result<CreateResult, String> {
293
+ let CreateSpec { label, url, profile_id, init_js, mount } = spec;
294
+ let (label, url, profile_id, init_js) = (label.as_str(), url.as_str(), profile_id.as_str(), init_js.as_str());
295
+ let uuid_str = profiles::uuid_for(profile_id);
296
+ let nsuuid = NSUUID::initWithUUIDString(NSUUID::alloc(), &NSString::from_str(&uuid_str))
297
+ .ok_or_else(|| format!("bad uuid for profile {profile_id}: {uuid_str}"))?;
298
+ let store = unsafe { WKWebsiteDataStore::dataStoreForIdentifier(&nsuuid, mtm) };
299
+ let persistent = unsafe { store.isPersistent() };
300
+
301
+ let cfg = unsafe { WKWebViewConfiguration::new(mtm) };
302
+ unsafe { cfg.setWebsiteDataStore(&store) };
303
+
304
+ // bridgeShim message handler + content bundle (documentStart) on this cfg's UCC.
305
+ let ucc = unsafe { cfg.userContentController() };
306
+ let handler = MsgHandler::alloc(mtm).set_ivars(MsgHandlerIvars { on_message });
307
+ let handler: Retained<MsgHandler> = unsafe { msg_send![super(handler), init] };
308
+ unsafe {
309
+ ucc.addScriptMessageHandler_name(
310
+ ProtocolObject::from_ref(&*handler),
311
+ &NSString::from_str("bridgeShim"),
312
+ );
313
+ let script = WKUserScript::initWithSource_injectionTime_forMainFrameOnly(
314
+ WKUserScript::alloc(mtm),
315
+ &NSString::from_str(init_js),
316
+ WKUserScriptInjectionTime::AtDocumentStart,
317
+ true,
318
+ );
319
+ ucc.addUserScript(&script);
320
+ }
321
+
322
+ let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(1100.0, 800.0));
323
+ let style = NSWindowStyleMask::Titled
324
+ | NSWindowStyleMask::Closable
325
+ | NSWindowStyleMask::Miniaturizable
326
+ | NSWindowStyleMask::Resizable;
327
+ let window = unsafe {
328
+ NSWindow::initWithContentRect_styleMask_backing_defer(
329
+ mtm.alloc::<NSWindow>(),
330
+ rect,
331
+ style,
332
+ NSBackingStoreType::Buffered,
333
+ false,
334
+ )
335
+ };
336
+ unsafe { window.setReleasedWhenClosed(false) };
337
+
338
+ let webview = unsafe {
339
+ WKWebView::initWithFrame_configuration(mtm.alloc::<WKWebView>(), window.frame(), &cfg)
340
+ };
341
+ unsafe {
342
+ webview.setCustomUserAgent(Some(&NSString::from_str(popup::SAFARI_UA)));
343
+ window.setTitle(&NSString::from_str(label));
344
+ // Safari Web Inspector (debug always; release only under APPWRAP_DEVTOOLS) for every
345
+ // bridge/profile/sub-window webview created through this primitive.
346
+ set_inspectable(Retained::as_ptr(&webview) as *mut WKWebView);
347
+ }
348
+
349
+ // Per-window SSO-popup delegate + window-close delegate. Both are weak refs on AppKit/WebKit →
350
+ // retained in the registry entry below.
351
+ let popup_delegate: Retained<AnyObject> =
352
+ popup::install_for_profile(Retained::as_ptr(&webview) as *mut std::ffi::c_void, mtm);
353
+ let win_delegate = ProfileWinDelegate::alloc(mtm).set_ivars(());
354
+ let win_delegate: Retained<ProfileWinDelegate> = unsafe { msg_send![super(win_delegate), init] };
355
+ window.setDelegate(Some(ProtocolObject::from_ref(&*win_delegate)));
356
+ window.center();
357
+
358
+ // Initial mount: TopLevel shows the home window with the webview as content view (the
359
+ // pre-Phase-2 behavior, byte-identical); ChildOf leaves the home window unshown and adds the
360
+ // webview into the host pane. Same reparent primitive as set_mount, applied at birth.
361
+ unsafe { mount_view(mtm, &webview, &window, mount) };
362
+
363
+ let nsurl = NSURL::URLWithString(&NSString::from_str(url))
364
+ .ok_or_else(|| format!("bad url: {url}"))?;
365
+ let req = NSURLRequest::requestWithURL(&nsurl);
366
+ let _ = unsafe { webview.loadRequest(&req) };
367
+
368
+ reap_closed();
369
+ PROFILE_WINDOWS.lock().unwrap().push(ProfileWindow {
370
+ label: label.to_string(),
371
+ profile_id: profile_id.to_string(),
372
+ window,
373
+ webview,
374
+ closed: AtomicBool::new(false),
375
+ _handler: handler,
376
+ _popup_delegate: popup_delegate,
377
+ _win_delegate: win_delegate,
378
+ });
379
+ eprintln!(
380
+ "[bridge-shim] profile window {label} profile={profile_id} store={uuid_str} persistent={persistent}"
381
+ );
382
+ Ok(CreateResult { persistent, store_uuid: uuid_str })
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Phase 2: NSView-reparent (tear-off / dock). The SAME live WKWebView object is moved between its
387
+ // home NSWindow (TopLevel) and a host-owned pane NSView (ChildOf) — page + WKWebsiteDataStore
388
+ // session + WKScriptMessageHandler bridge + SSO uiDelegate all survive because it is never
389
+ // re-created. Productionized from the Phase-0 spike. Main-thread only (WKWebView/NSView are not
390
+ // thread-safe); zero-frame subviews render blank, so we always set frame = target bounds + a
391
+ // fill autoresizing mask.
392
+ // ---------------------------------------------------------------------------
393
+
394
+ /// Reparent `webview` to `mount`, using `home_window` as the TopLevel target. Caller holds the
395
+ /// main thread. `ChildOf` dereferences the host `NSView*` per the [`crate::ViewHandle`] contract.
396
+ unsafe fn mount_view(
397
+ _mtm: MainThreadMarker,
398
+ webview: &WKWebView,
399
+ home_window: &NSWindow,
400
+ mount: Mount,
401
+ ) {
402
+ webview.removeFromSuperview();
403
+ match mount {
404
+ Mount::TopLevel => {
405
+ let size = home_window.frame().size;
406
+ webview.setFrame(NSRect::new(NSPoint::new(0.0, 0.0), size));
407
+ webview.setAutoresizingMask(FILL_MASK);
408
+ home_window.setContentView(Some(webview));
409
+ home_window.makeKeyAndOrderFront(None);
410
+ }
411
+ Mount::ChildOf(handle) => {
412
+ // The host owns this NSView; we only add a subview link (contract: valid, main-thread,
413
+ // host keeps it alive while mounted). Order the home window out so it isn't a stray.
414
+ let host = &*(handle.as_ptr() as *mut NSView);
415
+ let bounds = host.bounds();
416
+ webview.setFrame(bounds);
417
+ webview.setAutoresizingMask(FILL_MASK);
418
+ host.addSubview(webview);
419
+ home_window.orderOut(None);
420
+ }
421
+ }
422
+ }
423
+
424
+ /// Move a live profile webview between top-level and child-of-a-host-pane (tear-off / dock).
425
+ /// Returns false if `label` is unknown or already closed. Main-thread only.
426
+ pub fn set_mount(mtm: MainThreadMarker, label: &str, mount: Mount) -> bool {
427
+ let parts = PROFILE_WINDOWS
428
+ .lock()
429
+ .unwrap()
430
+ .iter()
431
+ .find(|p| p.label == label && !p.closed.load(Ordering::Relaxed))
432
+ .map(|p| (p.webview.clone(), p.window.clone()));
433
+ match parts {
434
+ Some((webview, window)) => {
435
+ unsafe { mount_view(mtm, &webview, &window, mount) };
436
+ true
437
+ }
438
+ None => false,
439
+ }
440
+ }
441
+
442
+ /// Navigate any WKWebView (profile or tauri) to `url`. Main-thread only.
443
+ pub unsafe fn navigate(wk: *mut WKWebView, url: &str) -> Result<(), String> {
444
+ let wk = &*wk;
445
+ match NSURL::URLWithString(&NSString::from_str(url)) {
446
+ Some(nsurl) => {
447
+ let req = NSURLRequest::requestWithURL(&nsurl);
448
+ let _ = wk.loadRequest(&req);
449
+ Ok(())
450
+ }
451
+ None => Err(format!("bad url: {url}")),
452
+ }
453
+ }
454
+
455
+ /// Navigate back in the WKWebView's session history (no-op if none). Main-thread only.
456
+ pub unsafe fn go_back(wk: *mut WKWebView) {
457
+ let _ = (&*wk).goBack();
458
+ }
459
+
460
+ /// Navigate forward in the WKWebView's session history (no-op if none). Main-thread only.
461
+ pub unsafe fn go_forward(wk: *mut WKWebView) {
462
+ let _ = (&*wk).goForward();
463
+ }
464
+
465
+ /// Reload the current page. Main-thread only.
466
+ pub unsafe fn reload(wk: *mut WKWebView) {
467
+ let _ = (&*wk).reload();
468
+ }
469
+
470
+ /// Fire-and-forget JS injection. Main-thread only.
471
+ pub unsafe fn inject_script(wk: *mut WKWebView, js: &str) {
472
+ let wk = &*wk;
473
+ wk.evaluateJavaScript_completionHandler(&NSString::from_str(js), None);
474
+ }
475
+
476
+ /// callAsyncJavaScript on a raw WKWebView ptr — CSP-immune eval with an awaited return value.
477
+ /// Delivers the RAW stringified JS return value ("null" or the NSString) via `on_done`; the adapter
478
+ /// wraps it into the wire envelope. Main-thread only (the completion block also lands there).
479
+ pub unsafe fn eval_async(
480
+ wk: *mut WKWebView,
481
+ js: String,
482
+ on_done: impl FnOnce(Result<String, String>) + 'static,
483
+ ) {
484
+ let wk = &*wk;
485
+ let mtm = MainThreadMarker::new().expect("eval_async runs on main");
486
+ let on_done = RefCell::new(Some(on_done));
487
+ let block = block2::RcBlock::new(move |result: *mut AnyObject, error: *mut NSError| {
488
+ let Some(cb) = on_done.borrow_mut().take() else { return };
489
+ if !error.is_null() {
490
+ cb(Err((*error).localizedDescription().to_string()));
491
+ return;
492
+ }
493
+ let out = if result.is_null() {
494
+ "null".to_string()
495
+ } else if let Ok(ns) = (&*result).downcast_ref::<NSString>().ok_or(()) {
496
+ ns.to_string()
497
+ } else {
498
+ "null".to_string()
499
+ };
500
+ cb(Ok(out));
501
+ });
502
+ wk.callAsyncJavaScript_arguments_inFrame_inContentWorld_completionHandler(
503
+ &NSString::from_str(&js),
504
+ None,
505
+ None,
506
+ &WKContentWorld::pageWorld(mtm),
507
+ Some(&block),
508
+ );
509
+ }
510
+
511
+ /// WKWebView takeSnapshot -> NSImage -> PNG bytes, delivered via `on_done`. The adapter base64s +
512
+ /// frames it. Main-thread only (the completion block also lands there).
513
+ pub unsafe fn snapshot(wk: *mut WKWebView, on_done: impl FnOnce(Result<Vec<u8>, String>) + 'static) {
514
+ let wk = &*wk;
515
+ let on_done = RefCell::new(Some(on_done));
516
+ let block = block2::RcBlock::new(move |image: *mut NSImage, error: *mut NSError| {
517
+ let Some(cb) = on_done.borrow_mut().take() else { return };
518
+ if image.is_null() {
519
+ let msg = if error.is_null() { "snapshot failed".to_string() } else { (*error).localizedDescription().to_string() };
520
+ cb(Err(msg));
521
+ return;
522
+ }
523
+ let img = &*image;
524
+ let Some(tiff) = img.TIFFRepresentation() else {
525
+ cb(Err("no TIFF representation".into()));
526
+ return;
527
+ };
528
+ let Some(rep) = NSBitmapImageRep::initWithData(NSBitmapImageRep::alloc(), &tiff) else {
529
+ cb(Err("bitmap rep failed".into()));
530
+ return;
531
+ };
532
+ let props = NSDictionary::new();
533
+ let Some(png) = rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &props) else {
534
+ cb(Err("png encode failed".into()));
535
+ return;
536
+ };
537
+ cb(Ok(png.to_vec()));
538
+ });
539
+ wk.takeSnapshotWithConfiguration_completionHandler(None, &block);
540
+ }
@@ -70,6 +70,7 @@ dependencies = [
70
70
  "tauri-plugin-deep-link",
71
71
  "tauri-plugin-notification",
72
72
  "tauri-plugin-window-state",
73
+ "webview-control",
73
74
  ]
74
75
 
75
76
  [[package]]
@@ -4626,6 +4627,18 @@ dependencies = [
4626
4627
  "system-deps",
4627
4628
  ]
4628
4629
 
4630
+ [[package]]
4631
+ name = "webview-control"
4632
+ version = "0.1.0"
4633
+ dependencies = [
4634
+ "block2",
4635
+ "objc2",
4636
+ "objc2-app-kit",
4637
+ "objc2-foundation",
4638
+ "objc2-web-kit",
4639
+ "serde_json",
4640
+ ]
4641
+
4629
4642
  [[package]]
4630
4643
  name = "webview2-com"
4631
4644
  version = "0.38.2"
@@ -7,7 +7,9 @@ edition = "2021"
7
7
  tauri-build = { version = "2", features = [] }
8
8
 
9
9
  [dependencies]
10
- tauri = { version = "2", features = [] }
10
+ # `devtools` feature so `open_devtools()`/`close_devtools()` compile in RELEASE too (in debug they're
11
+ # always available); the ⌥⌘I toggle is still gated at runtime by webview_control::devtools_enabled().
12
+ tauri = { version = "2", features = ["devtools"] }
11
13
  tauri-plugin-deep-link = "2"
12
14
  tauri-plugin-window-state = "2"
13
15
  tauri-plugin-notification = "2"
@@ -20,6 +22,9 @@ libc = "0.2" # desktop.server: setsid the local-server child + SIGTERM its whol
20
22
  # macOS oauth.authorize — ASWebAuthenticationSession (system-browser auth sheet, exact parity with the
21
23
  # iOS shell path in runtime/app/shell/handlers-oauth.ts: auto-close on redirect, user-cancel signal).
22
24
  [target.'cfg(target_os = "macos")'.dependencies]
25
+ # The generalized native webview primitive (per-profile store, eval, snapshot, popup, bridgeShim).
26
+ # objc2* versions MUST stay identical to those below — the WKWebView pointer crosses the boundary.
27
+ webview-control = { path = "../crates/webview-control" }
23
28
  base64 = "0.22" # SPIKE: bridge_mac screenshot PNG encoding
24
29
  objc2 = "0.6"
25
30
  objc2-foundation = "0.3"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "../gen/schemas/desktop-schema.json",
3
3
  "identifier": "default",
4
- "windows": ["main"],
4
+ "windows": ["main", "subwin-*"],
5
5
  "permissions": ["core:default", "notification:default"]
6
6
  }