@livx.cc/appwrap 0.46.1 → 0.46.2

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.
@@ -1,347 +1,37 @@
1
- // SPIKE: window-ops backend for the browser-bridge shim (bridge-shim/shim.ts).
1
+ // Control-channel ADAPTER for the browser-bridge shim (bridge-shim/shim.ts).
2
+ //
3
+ // Phase 1 extraction: the WKWebView primitive (per-profile persistent+isolated stores, manual
4
+ // window creation, CSP-immune eval, snapshot, the bridgeShim handler, SSO popups) now lives in the
5
+ // `webview-control` crate. This file is the thin transport adapter that OWNS the bod-browser wire
6
+ // format + the TCP control channel:
7
+ // - dials the shim's TCP control port (line-JSON) and serves window ops,
8
+ // - frames the crate's RAW page payloads into {type:"pageMsg", payload} lines,
9
+ // - frames RAW eval/snapshot results into {id,result}/{id,error} responses,
10
+ // - keeps the legacy tauri-window path (no profileId) for back-compat.
2
11
  // Env-gated: fully inert unless APPWRAP_BRIDGE_JS points at the built content bundle.
3
- // The shell dials the shim's TCP control port (line-JSON) and serves window ops:
4
- // createWindow { label, url } -> new WebviewWindow with the content bundle injected
5
- // closeWindow { label }
6
- // navigate { label, url }
7
- // screenshot { label } -> WKWebView takeSnapshot -> PNG base64
8
- // listWindows
9
12
  use base64::Engine;
10
13
  use serde_json::{json, Value};
11
14
  use std::io::{Read, Write};
12
15
  use std::net::TcpStream;
13
- use std::sync::atomic::{AtomicBool, Ordering};
14
16
  use std::sync::mpsc::{channel, Sender};
15
- use std::sync::Mutex;
16
17
  use std::time::Duration;
17
18
  use tauri::{AppHandle, Manager};
18
19
 
19
20
  use objc2::rc::Retained;
20
- use objc2::runtime::ProtocolObject;
21
- use objc2::{define_class, msg_send, AnyThread, DefinedClass, MainThreadMarker, MainThreadOnly};
22
- use objc2_app_kit::{
23
- NSBackingStoreType, NSBitmapImageFileType, NSBitmapImageRep, NSImage, NSWindow,
24
- NSWindowDelegate, NSWindowStyleMask,
25
- };
26
- use objc2_foundation::{
27
- NSDictionary, NSError, NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSSize,
28
- NSString, NSURL, NSURLRequest, NSUUID,
29
- };
30
- use objc2::runtime::AnyObject;
31
- use objc2_web_kit::{
32
- WKContentWorld, WKScriptMessage, WKScriptMessageHandler, WKUserContentController,
33
- WKUserScript, WKUserScriptInjectionTime, WKWebView, WKWebViewConfiguration, WKWebsiteDataStore,
34
- };
35
-
36
- // ---------------------------------------------------------------------------
37
- // Page -> shim transport: a native WKScriptMessageHandler ("bridgeShim").
38
- // WebKit blocks ws://127.0.0.1 (and http beacons) from https pages as mixed
39
- // content — NO loopback exemption like Chrome — so the content layer posts
40
- // results via webkit.messageHandlers.bridgeShim.postMessage(json) instead,
41
- // and we forward them to the shim as {type:"pageMsg", payload} lines.
42
- // Commands travel shim -> evalJs(window.__bridgeDispatch(...)).
43
- // ---------------------------------------------------------------------------
44
-
45
- pub struct MsgHandlerIvars {
46
- tx: Sender<String>,
47
- }
48
-
49
- define_class!(
50
- #[unsafe(super(NSObject))]
51
- #[thread_kind = MainThreadOnly]
52
- #[name = "AppwrapBridgeMsgHandler"]
53
- #[ivars = MsgHandlerIvars]
54
- struct MsgHandler;
55
-
56
- unsafe impl NSObjectProtocol for MsgHandler {}
57
-
58
- unsafe impl WKScriptMessageHandler for MsgHandler {
59
- #[unsafe(method(userContentController:didReceiveScriptMessage:))]
60
- fn did_receive(&self, _ucc: &WKUserContentController, message: &WKScriptMessage) {
61
- let body = unsafe { message.body() };
62
- if let Ok(s) = body.downcast::<NSString>() {
63
- let _ = self.ivars().tx.send(
64
- json!({ "type": "pageMsg", "payload": s.to_string() }).to_string(),
65
- );
66
- }
67
- }
68
- }
69
- );
70
-
71
- // ---------------------------------------------------------------------------
72
- // Per-profile persistent + isolated WKWebsiteDataStore (THE CRUX).
73
- //
74
- // A "profile" is a named, persistent cookie/localStorage/IndexedDB jar. On macOS
75
- // 14+ WebKit gives us persistent, isolated stores via
76
- // `WKWebsiteDataStore(forIdentifier:)` keyed by a stable NSUUID. We persist a
77
- // profileId->UUID map on disk so the SAME store is reattached across app quits
78
- // (persistence) while different profiles get different stores (isolation).
79
- //
80
- // Such a store can only be selected on a WKWebViewConfiguration BEFORE the
81
- // webview is created, and tauri::WebviewWindowBuilder exposes no hook for it — so
82
- // profile windows are created MANUALLY (NSWindow + WKWebView), mirroring
83
- // popup_mac.rs. They live in their own registry (below); the bridge ops route to
84
- // it first and fall back to tauri windows (the main shell window) otherwise.
85
- // ---------------------------------------------------------------------------
86
- mod profiles {
87
- use objc2_foundation::NSUUID;
88
- use std::collections::HashMap;
89
- use std::path::PathBuf;
90
-
91
- fn map_path() -> PathBuf {
92
- let base = std::env::var("APPWRAP_PROFILE_DIR")
93
- .map(PathBuf::from)
94
- .unwrap_or_else(|_| {
95
- PathBuf::from(std::env::var("HOME").unwrap_or_default())
96
- .join("Library/Application Support/appwrap-desktop")
97
- });
98
- base.join("browser-profiles.json")
99
- }
100
-
101
- fn load() -> HashMap<String, String> {
102
- std::fs::read_to_string(map_path())
103
- .ok()
104
- .and_then(|s| serde_json::from_str(&s).ok())
105
- .unwrap_or_default()
106
- }
107
-
108
- /// Stable UUID string for a profile id — generated once and persisted so the
109
- /// same on-disk data store is reattached on every subsequent launch.
110
- pub fn uuid_for(profile_id: &str) -> String {
111
- let mut map = load();
112
- if let Some(u) = map.get(profile_id) {
113
- return u.clone();
114
- }
115
- let uuid = NSUUID::UUID().UUIDString().to_string();
116
- map.insert(profile_id.to_string(), uuid.clone());
117
- let p = map_path();
118
- if let Some(dir) = p.parent() {
119
- let _ = std::fs::create_dir_all(dir);
120
- }
121
- let _ = std::fs::write(&p, serde_json::to_string_pretty(&map).unwrap_or_default());
122
- uuid
123
- }
124
- }
125
-
126
- /// A manually-created profile window. Strong refs kept alive until closeWindow
127
- /// (or windowWillClose). Only touched on the main thread; the unsafe Send exists
128
- /// solely so the registry can be a static Mutex (same pattern as popup_mac).
129
- struct ProfileWindow {
130
- label: String,
131
- profile_id: String,
132
- window: Retained<NSWindow>,
133
- webview: Retained<WKWebView>,
134
- /// Set true by the window-close delegate on a USER (red-button) close. Entries
135
- /// stay in the registry but are filtered out of lookups/listWindows, then reaped
136
- /// at a safe point (reap_closed, not inside the delegate callback — which would
137
- /// drop the delegate mid-call → UAF).
138
- closed: AtomicBool,
139
- _handler: Retained<MsgHandler>,
140
- /// Per-window SSO-popup delegate (weak uiDelegate → held here). Opaque; lifetime only.
141
- _popup_delegate: Retained<AnyObject>,
142
- /// NSWindowDelegate that flags `closed` on a user close (weak setDelegate → held here).
143
- _win_delegate: Retained<ProfileWinDelegate>,
144
- }
145
- unsafe impl Send for ProfileWindow {}
146
- static PROFILE_WINDOWS: Mutex<Vec<ProfileWindow>> = Mutex::new(Vec::new());
147
-
148
- // NSWindowDelegate for profile windows — flags the matching registry entry `closed`
149
- // when the user closes the window (red button), so it drops out of listWindows/getTabs.
150
- // Matches by NSWindow identity (the notification's object). Main-thread only.
151
- define_class!(
152
- #[unsafe(super(NSObject))]
153
- #[thread_kind = MainThreadOnly]
154
- #[name = "AppwrapProfileWinDelegate"]
155
- struct ProfileWinDelegate;
156
-
157
- unsafe impl NSObjectProtocol for ProfileWinDelegate {}
158
-
159
- unsafe impl NSWindowDelegate for ProfileWinDelegate {
160
- #[unsafe(method(windowWillClose:))]
161
- fn window_will_close(&self, notification: &NSNotification) {
162
- let Some(obj) = notification.object() else { return };
163
- let key = Retained::as_ptr(&obj) as usize;
164
- let v = PROFILE_WINDOWS.lock().unwrap();
165
- if let Some(p) = v.iter().find(|p| Retained::as_ptr(&p.window) as usize == key) {
166
- eprintln!("[bridge-shim] profile window {} user-closed", p.label);
167
- p.closed.store(true, Ordering::Relaxed);
168
- }
169
- }
170
- }
171
- );
172
-
173
- /// Retained clone of a profile window's WKWebView (op routing), or None if the
174
- /// label is a tauri window / unknown / already closed.
175
- fn profile_webview(label: &str) -> Option<Retained<WKWebView>> {
176
- PROFILE_WINDOWS
177
- .lock()
178
- .unwrap()
179
- .iter()
180
- .find(|p| p.label == label && !p.closed.load(Ordering::Relaxed))
181
- .map(|p| p.webview.clone())
182
- }
183
-
184
- fn profile_labels() -> Vec<(String, String)> {
185
- PROFILE_WINDOWS
186
- .lock()
187
- .unwrap()
188
- .iter()
189
- .filter(|p| !p.closed.load(Ordering::Relaxed))
190
- .map(|p| (p.label.clone(), p.profile_id.clone()))
191
- .collect()
192
- }
193
-
194
- /// Drop entries flagged `closed` (user-closed windows). Called at safe points only
195
- /// (createWindow/listWindows/closeWindow entry) — never from within a delegate callback,
196
- /// so the delegate isn't released mid-call.
197
- fn reap_closed() {
198
- let mut v = PROFILE_WINDOWS.lock().unwrap();
199
- v.retain(|p| !p.closed.load(Ordering::Relaxed));
200
- }
201
-
202
- /// Close + drop a profile window (releases its NSWindow + WKWebView). Main-thread
203
- /// only. Returns false if no such profile window (caller falls back to tauri).
204
- /// The removed entry is held on the stack across window.close() so its window
205
- /// delegate stays alive while AppKit fires windowWillClose.
206
- fn profile_close(label: &str) -> bool {
207
- let entry = {
208
- let mut v = PROFILE_WINDOWS.lock().unwrap();
209
- v.iter().position(|p| p.label == label).map(|i| v.remove(i))
210
- };
211
- match entry {
212
- Some(p) => {
213
- p.window.close();
214
- true
215
- }
216
- None => false,
217
- }
218
- }
219
-
220
- /// Create a manual NSWindow + WKWebView bound to `profile_id`'s persistent data
221
- /// store, inject the bridge content bundle + bridgeShim handler, and register it.
222
- /// Runs on the main thread (called inside run_on_main_thread). Returns the
223
- /// isPersistent flag of the resolved store so the harness can assert it.
224
- fn create_profile_window(
225
- mtm: MainThreadMarker,
226
- label: &str,
227
- url: &str,
228
- profile_id: &str,
229
- init_js: &str,
230
- tx: Sender<String>,
231
- ) -> Result<Value, String> {
232
- let uuid_str = profiles::uuid_for(profile_id);
233
- let nsuuid = NSUUID::initWithUUIDString(NSUUID::alloc(), &NSString::from_str(&uuid_str))
234
- .ok_or_else(|| format!("bad uuid for profile {profile_id}: {uuid_str}"))?;
235
- let store = unsafe { WKWebsiteDataStore::dataStoreForIdentifier(&nsuuid, mtm) };
236
- let persistent = unsafe { store.isPersistent() };
237
-
238
- let cfg = unsafe { WKWebViewConfiguration::new(mtm) };
239
- unsafe { cfg.setWebsiteDataStore(&store) };
240
-
241
- // bridgeShim message handler + content bundle (documentStart) on this cfg's UCC.
242
- let ucc = unsafe { cfg.userContentController() };
243
- let handler = MsgHandler::alloc(mtm).set_ivars(MsgHandlerIvars { tx });
244
- let handler: Retained<MsgHandler> = unsafe { objc2::msg_send![super(handler), init] };
245
- unsafe {
246
- ucc.addScriptMessageHandler_name(
247
- ProtocolObject::from_ref(&*handler),
248
- &NSString::from_str("bridgeShim"),
249
- );
250
- let script = WKUserScript::initWithSource_injectionTime_forMainFrameOnly(
251
- WKUserScript::alloc(mtm),
252
- &NSString::from_str(init_js),
253
- WKUserScriptInjectionTime::AtDocumentStart,
254
- true,
255
- );
256
- ucc.addUserScript(&script);
257
- }
258
-
259
- let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(1100.0, 800.0));
260
- let style = NSWindowStyleMask::Titled
261
- | NSWindowStyleMask::Closable
262
- | NSWindowStyleMask::Miniaturizable
263
- | NSWindowStyleMask::Resizable;
264
- let window = unsafe {
265
- NSWindow::initWithContentRect_styleMask_backing_defer(
266
- mtm.alloc::<NSWindow>(),
267
- rect,
268
- style,
269
- NSBackingStoreType::Buffered,
270
- false,
271
- )
272
- };
273
- unsafe { window.setReleasedWhenClosed(false) };
274
-
275
- let webview = unsafe {
276
- WKWebView::initWithFrame_configuration(mtm.alloc::<WKWebView>(), window.frame(), &cfg)
277
- };
278
- unsafe {
279
- webview.setCustomUserAgent(Some(&NSString::from_str(crate::popup_mac::SAFARI_UA)));
280
- window.setContentView(Some(&webview));
281
- window.setTitle(&NSString::from_str(label));
282
- }
283
-
284
- // Per-window SSO-popup delegate (window.open → real child NSWindow inheriting this profile's
285
- // data store) + window-close delegate (drop from registry on user close). Both are weak refs on
286
- // AppKit/WebKit → retained in the registry entry below.
287
- let popup_delegate: Retained<AnyObject> = crate::popup_mac::install_for_profile(
288
- Retained::as_ptr(&webview) as *mut std::ffi::c_void,
289
- mtm,
290
- );
291
- let win_delegate = ProfileWinDelegate::alloc(mtm).set_ivars(());
292
- let win_delegate: Retained<ProfileWinDelegate> = unsafe { msg_send![super(win_delegate), init] };
293
- window.setDelegate(Some(ProtocolObject::from_ref(&*win_delegate)));
294
- window.center();
295
- window.makeKeyAndOrderFront(None);
296
-
297
- let nsurl = NSURL::URLWithString(&NSString::from_str(url))
298
- .ok_or_else(|| format!("bad url: {url}"))?;
299
- let req = NSURLRequest::requestWithURL(&nsurl);
300
- let _ = unsafe { webview.loadRequest(&req) };
301
-
302
- reap_closed();
303
- PROFILE_WINDOWS.lock().unwrap().push(ProfileWindow {
304
- label: label.to_string(),
305
- profile_id: profile_id.to_string(),
306
- window,
307
- webview,
308
- closed: AtomicBool::new(false),
309
- _handler: handler,
310
- _popup_delegate: popup_delegate,
311
- _win_delegate: win_delegate,
312
- });
313
- eprintln!(
314
- "[bridge-shim] profile window {label} profile={profile_id} store={uuid_str} persistent={persistent}"
315
- );
316
- Ok(json!({ "ok": true, "label": label, "profileId": profile_id, "storeUuid": uuid_str, "persistent": persistent }))
317
- }
318
-
319
- /// callAsyncJavaScript on a raw WKWebView ptr — CSP-immune eval with an awaited
320
- /// return value. Shared by the tauri and profile-window paths. Main-thread only.
321
- unsafe fn eval_async_on(wk: *mut WKWebView, js: String, tx: Sender<String>, id: String) {
322
- let wk = &*wk;
323
- let mtm = MainThreadMarker::new().expect("eval_async_on runs on main");
324
- let block = block2::RcBlock::new(move |result: *mut AnyObject, error: *mut NSError| {
325
- if !error.is_null() {
326
- respond(&tx, &id, Err((*error).localizedDescription().to_string()));
327
- return;
328
- }
329
- let out = if result.is_null() {
330
- "null".to_string()
331
- } else if let Ok(ns) = (&*result).downcast_ref::<NSString>().ok_or(()) {
332
- ns.to_string()
333
- } else {
334
- "null".to_string()
335
- };
336
- respond(&tx, &id, Ok(json!({ "json": out })));
337
- });
338
- wk.callAsyncJavaScript_arguments_inFrame_inContentWorld_completionHandler(
339
- &NSString::from_str(&js),
340
- None,
341
- None,
342
- &WKContentWorld::pageWorld(mtm),
343
- Some(&block),
344
- );
21
+ use objc2::MainThreadMarker;
22
+ use objc2_web_kit::WKWebView;
23
+
24
+ // The transport-free primitive. This adapter is the ONLY place that knows the bod-browser wire
25
+ // format; the crate stays automation-agnostic.
26
+ use webview_control as wv;
27
+
28
+ /// Build the page->shim message sink for one webview: wrap the crate's RAW payload into the
29
+ /// {type:"pageMsg", payload} envelope the shim expects. This is the seam — the framing lives here,
30
+ /// never in the crate.
31
+ fn page_sink(tx: Sender<String>) -> wv::MessageSink {
32
+ Box::new(move |raw: String| {
33
+ let _ = tx.send(json!({ "type": "pageMsg", "payload": raw }).to_string());
34
+ })
345
35
  }
346
36
 
347
37
  fn page_port() -> u16 {
@@ -427,15 +117,16 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
427
117
  ),
428
118
  Err(e) => { respond(tx, &id, Err(format!("read bundle {js_path}: {e}"))); return; }
429
119
  };
430
- // Profile windows take the MANUAL creation path (per-profile persistent data store).
120
+ // Profile windows take the crate's MANUAL creation path (per-profile persistent store).
431
121
  // Windows without a profileId keep the original tauri path (back-compat).
432
122
  if !profile_id.is_empty() {
433
123
  let idc = id.clone();
434
124
  let init_js2 = init_js.clone();
435
125
  let r = app.run_on_main_thread(move || {
436
126
  let mtm = MainThreadMarker::new().expect("run_on_main_thread is main");
437
- let res = create_profile_window(mtm, &label, &url, &profile_id, &init_js2, tx2.clone());
438
- respond_owned(&tx2, &idc, res);
127
+ let res = wv::create_profile_window(mtm, &label, &url, &profile_id, &init_js2, page_sink(tx2.clone()))
128
+ .map(|c| json!({ "ok": true, "label": label, "profileId": profile_id, "storeUuid": c.store_uuid, "persistent": c.persistent }));
129
+ respond(&tx2, &idc, res);
439
130
  });
440
131
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
441
132
  return;
@@ -444,45 +135,36 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
444
135
  let r = app.run_on_main_thread(move || {
445
136
  let parsed = match tauri::Url::parse(&url) {
446
137
  Ok(u) => u,
447
- Err(e) => { respond_owned(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
138
+ Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
448
139
  };
449
140
  let r = tauri::WebviewWindowBuilder::new(&app2, &label, tauri::WebviewUrl::External(parsed))
450
141
  .title(&label)
451
142
  .inner_size(1100.0, 800.0)
452
- // Safari-normalized UA at BUILD time (same constraint as the main window: the
453
- // first document snapshots navigator.userAgent before any later override).
143
+ // Safari-normalized UA at BUILD time (same constraint as the main window).
454
144
  .user_agent(crate::popup_mac::SAFARI_UA)
455
145
  .initialization_script(&init_js)
456
146
  .build();
457
- // Install the page->shim message handler on the fresh webview (main thread),
458
- // plus the SAME popup machinery the main window gets (WKUIDelegate: window.open ->
459
- // real child NSWindow — Google/Firebase SSO popups; silently dropped otherwise).
147
+ // Install the page->shim message handler on the fresh webview (main thread), plus the
148
+ // SAME popup machinery the main window gets (SSO window.open child windows).
460
149
  if let Ok(w) = &r {
461
150
  let tx4 = tx2.clone();
462
151
  let _ = w.with_webview(move |pw| unsafe {
463
152
  crate::popup_mac::install(pw.inner() as _, false);
464
- let wk = &*(pw.inner() as *mut WKWebView);
465
- let mtm = objc2::MainThreadMarker::new().expect("with_webview runs on main");
466
- let handler = MsgHandler::alloc(mtm).set_ivars(MsgHandlerIvars { tx: tx4 });
467
- let handler: Retained<MsgHandler> = objc2::msg_send![super(handler), init];
468
- let ucc = wk.configuration().userContentController();
469
- ucc.addScriptMessageHandler_name(
470
- ProtocolObject::from_ref(&*handler),
471
- &NSString::from_str("bridgeShim"),
472
- );
153
+ let mtm = MainThreadMarker::new().expect("with_webview runs on main");
154
+ wv::install_message_handler(pw.inner() as *mut WKWebView, mtm, page_sink(tx4));
473
155
  });
474
156
  }
475
- respond_owned(&tx2, &idc, r.map(|_| json!({ "ok": true, "label": label })).map_err(|e| e.to_string()));
157
+ respond(&tx2, &idc, r.map(|_| json!({ "ok": true, "label": label })).map_err(|e| e.to_string()));
476
158
  });
477
159
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
478
160
  }
479
161
  "closeWindow" => {
480
162
  let label = v["label"].as_str().unwrap_or_default().to_string();
481
- if profile_webview(&label).is_some() {
163
+ if wv::profile_webview(&label).is_some() {
482
164
  let idc = id.clone();
483
165
  let r = app.run_on_main_thread(move || {
484
- let ok = profile_close(&label);
485
- respond_owned(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
166
+ let ok = wv::close(&label);
167
+ respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
486
168
  });
487
169
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
488
170
  return;
@@ -497,24 +179,18 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
497
179
  let url = v["url"].as_str().unwrap_or_default().to_string();
498
180
  let idc = id.clone();
499
181
  let r = app.run_on_main_thread(move || {
500
- if let Some(wk) = profile_webview(&label) {
501
- match NSURL::URLWithString(&NSString::from_str(&url)) {
502
- Some(nsurl) => {
503
- let req = NSURLRequest::requestWithURL(&nsurl);
504
- let _ = unsafe { wk.loadRequest(&req) };
505
- respond_owned(&tx2, &idc, Ok(json!({ "ok": true })));
506
- }
507
- None => respond_owned(&tx2, &idc, Err(format!("bad url: {url}"))),
508
- }
182
+ if let Some(wk) = wv::profile_webview(&label) {
183
+ let res = unsafe { wv::navigate(Retained::as_ptr(&wk) as *mut WKWebView, &url) };
184
+ respond(&tx2, &idc, res.map(|_| json!({ "ok": true })));
509
185
  return;
510
186
  }
511
187
  let parsed = match tauri::Url::parse(&url) {
512
188
  Ok(u) => u,
513
- Err(e) => { respond_owned(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
189
+ Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
514
190
  };
515
191
  match app2.get_webview_window(&label) {
516
- Some(mut w) => respond_owned(&tx2, &idc, w.navigate(parsed).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
517
- None => respond_owned(&tx2, &idc, Err(format!("no window {label}"))),
192
+ Some(w) => respond(&tx2, &idc, w.navigate(parsed).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
193
+ None => respond(&tx2, &idc, Err(format!("no window {label}"))),
518
194
  }
519
195
  });
520
196
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
@@ -523,18 +199,22 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
523
199
  let label = v["label"].as_str().unwrap_or_default().to_string();
524
200
  let idc = id.clone();
525
201
  let r = app.run_on_main_thread(move || {
526
- if let Some(wk) = profile_webview(&label) {
527
- unsafe { snapshot(Retained::as_ptr(&wk) as *mut WKWebView, tx2.clone(), idc.clone()) };
202
+ if let Some(wk) = wv::profile_webview(&label) {
203
+ let tx3 = tx2.clone();
204
+ let id2 = idc.clone();
205
+ unsafe { wv::snapshot(Retained::as_ptr(&wk) as *mut WKWebView, move |r| respond_png(&tx3, &id2, r)) };
528
206
  return;
529
207
  }
530
208
  let Some(w) = app2.get_webview_window(&label) else {
531
- respond_owned(&tx2, &idc, Err(format!("no window {label}")));
209
+ respond(&tx2, &idc, Err(format!("no window {label}")));
532
210
  return;
533
211
  };
534
212
  let tx3 = tx2.clone();
535
213
  let id2 = idc.clone();
536
- let r = w.with_webview(move |pw| unsafe { snapshot(pw.inner() as *mut WKWebView, tx3, id2) });
537
- if let Err(e) = r { respond_owned(&tx2, &idc, Err(e.to_string())); }
214
+ let r = w.with_webview(move |pw| unsafe {
215
+ wv::snapshot(pw.inner() as *mut WKWebView, move |r| respond_png(&tx3, &id2, r))
216
+ });
217
+ if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
538
218
  });
539
219
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
540
220
  }
@@ -542,16 +222,14 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
542
222
  // Fire-and-forget JS injection into a window (debug aid + transport fallback probe).
543
223
  let label = v["label"].as_str().unwrap_or_default().to_string();
544
224
  let js = v["js"].as_str().unwrap_or_default().to_string();
545
- if profile_webview(&label).is_some() {
225
+ if wv::profile_webview(&label).is_some() {
546
226
  let idc = id.clone();
547
227
  let r = app.run_on_main_thread(move || {
548
- if let Some(wk) = profile_webview(&label) {
549
- unsafe {
550
- wk.evaluateJavaScript_completionHandler(&NSString::from_str(&js), None);
551
- }
552
- respond_owned(&tx2, &idc, Ok(json!({ "ok": true })));
228
+ if let Some(wk) = wv::profile_webview(&label) {
229
+ unsafe { wv::inject_script(Retained::as_ptr(&wk) as *mut WKWebView, &js) };
230
+ respond(&tx2, &idc, Ok(json!({ "ok": true })));
553
231
  } else {
554
- respond_owned(&tx2, &idc, Err(format!("no window {label}")));
232
+ respond(&tx2, &idc, Err(format!("no window {label}")));
555
233
  }
556
234
  });
557
235
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
@@ -563,63 +241,52 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
563
241
  }
564
242
  }
565
243
  "evalAsync" => {
566
- // CSP-immune eval with a real (awaited) return value — WKWebView callAsyncJavaScript.
567
- // The page's CSP can block in-page eval() (content 'eval' action); native compilation
568
- // is exempt, and promises are awaited. `js` is a FUNCTION BODY (use `return`).
244
+ // CSP-immune eval with a real (awaited) return value — callAsyncJavaScript. `js` is a
245
+ // FUNCTION BODY (use `return`). The crate delivers the RAW return string; we frame {json}.
569
246
  let label = v["label"].as_str().unwrap_or_default().to_string();
570
247
  let js = v["js"].as_str().unwrap_or_default().to_string();
571
248
  let idc = id.clone();
572
249
  let r = app.run_on_main_thread(move || {
573
- if let Some(wk) = profile_webview(&label) {
574
- unsafe { eval_async_on(Retained::as_ptr(&wk) as *mut WKWebView, js.clone(), tx2.clone(), idc.clone()) };
250
+ if let Some(wk) = wv::profile_webview(&label) {
251
+ let tx3 = tx2.clone();
252
+ let id2 = idc.clone();
253
+ unsafe { wv::eval_async(Retained::as_ptr(&wk) as *mut WKWebView, js.clone(), move |r| respond_eval(&tx3, &id2, r)) };
575
254
  return;
576
255
  }
577
256
  let Some(w) = app2.get_webview_window(&label) else {
578
- respond_owned(&tx2, &idc, Err(format!("no window {label}")));
257
+ respond(&tx2, &idc, Err(format!("no window {label}")));
579
258
  return;
580
259
  };
581
260
  let tx3 = tx2.clone();
582
261
  let id2 = idc.clone();
583
262
  let js2 = js.clone();
584
263
  let r = w.with_webview(move |pw| unsafe {
585
- eval_async_on(pw.inner() as *mut WKWebView, js2, tx3, id2);
264
+ wv::eval_async(pw.inner() as *mut WKWebView, js2, move |r| respond_eval(&tx3, &id2, r));
586
265
  });
587
- if let Err(e) = r { respond_owned(&tx2, &idc, Err(e.to_string())); }
266
+ if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
588
267
  });
589
268
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
590
269
  }
591
270
  "userClose" => {
592
- // Verification aid: drive NSWindow performClose: (the red-button path) WITHOUT touching
593
- // the registry, so the windowWillClose delegate is the sole remover — proves user-close
594
- // hygiene headlessly (accessibility clicks are TCC-gated in CI/agent contexts).
271
+ // Verification aid: drive the red-button path WITHOUT touching the registry, so the
272
+ // windowWillClose delegate is the sole remover — proves user-close hygiene headlessly.
595
273
  let label = v["label"].as_str().unwrap_or_default().to_string();
596
274
  let idc = id.clone();
597
275
  let r = app.run_on_main_thread(move || {
598
- let win = PROFILE_WINDOWS
599
- .lock()
600
- .unwrap()
601
- .iter()
602
- .find(|p| p.label == label)
603
- .map(|p| p.window.clone());
604
- match win {
605
- Some(w) => {
606
- w.performClose(None);
607
- respond_owned(&tx2, &idc, Ok(json!({ "ok": true })));
608
- }
609
- None => respond_owned(&tx2, &idc, Err(format!("no window {label}"))),
610
- }
276
+ let ok = wv::user_close(&label);
277
+ respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
611
278
  });
612
279
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
613
280
  }
614
281
  "listWindows" => {
615
- // Closed windows are filtered by profile_labels; actual reaping (which drops objc
616
- // objects) is deferred to createWindow, which runs on the main thread.
282
+ // Closed windows are filtered by the crate; actual reaping (which drops objc objects) is
283
+ // deferred to createWindow, which runs on the main thread.
617
284
  let mut labels: Vec<String> = app.webview_windows().keys().cloned().collect();
618
- let profiles: Vec<Value> = profile_labels()
285
+ let profiles: Vec<Value> = wv::list_webviews()
619
286
  .into_iter()
620
- .map(|(label, profile_id)| {
621
- labels.push(label.clone());
622
- json!({ "label": label, "profileId": profile_id })
287
+ .map(|info| {
288
+ labels.push(info.label.clone());
289
+ json!({ "label": info.label, "profileId": info.profile_id })
623
290
  })
624
291
  .collect();
625
292
  respond(tx, &id, Ok(json!({ "labels": labels, "profiles": profiles })));
@@ -628,37 +295,12 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
628
295
  }
629
296
  }
630
297
 
631
- /// respond() twin taking the Sender by ref inside move closures (owned id/tx clones).
632
- fn respond_owned(tx: &Sender<String>, id: &str, result: Result<Value, String>) {
633
- respond(tx, id, result);
298
+ /// Frame a RAW eval return string into the {json} envelope the shim reads (`r.json`).
299
+ fn respond_eval(tx: &Sender<String>, id: &str, r: Result<String, String>) {
300
+ respond(tx, id, r.map(|out| json!({ "json": out })));
634
301
  }
635
302
 
636
- /// WKWebView takeSnapshot -> NSImage -> PNG base64, delivered async via the out-queue.
637
- /// Runs on the main thread (with_webview closure); the completion block also lands there.
638
- unsafe fn snapshot(webview: *mut WKWebView, tx: Sender<String>, id: String) {
639
- let wk = &*webview;
640
- let block = block2::RcBlock::new(move |image: *mut NSImage, error: *mut NSError| {
641
- if image.is_null() {
642
- let msg = if error.is_null() { "snapshot failed".to_string() } else { (*error).localizedDescription().to_string() };
643
- respond(&tx, &id, Err(msg));
644
- return;
645
- }
646
- let img = &*image;
647
- let Some(tiff) = img.TIFFRepresentation() else {
648
- respond(&tx, &id, Err("no TIFF representation".into()));
649
- return;
650
- };
651
- let Some(rep) = NSBitmapImageRep::initWithData(NSBitmapImageRep::alloc(), &tiff) else {
652
- respond(&tx, &id, Err("bitmap rep failed".into()));
653
- return;
654
- };
655
- let props = NSDictionary::new();
656
- let Some(png) = rep.representationUsingType_properties(NSBitmapImageFileType::PNG, &props) else {
657
- respond(&tx, &id, Err("png encode failed".into()));
658
- return;
659
- };
660
- let b64 = base64::engine::general_purpose::STANDARD.encode(png.to_vec());
661
- respond(&tx, &id, Ok(json!({ "png": b64 })));
662
- });
663
- wk.takeSnapshotWithConfiguration_completionHandler(None, &block);
303
+ /// Base64-encode the crate's RAW PNG bytes and frame into the {png} envelope.
304
+ fn respond_png(tx: &Sender<String>, id: &str, r: Result<Vec<u8>, String>) {
305
+ respond(tx, id, r.map(|bytes| json!({ "png": base64::engine::general_purpose::STANDARD.encode(bytes) })));
664
306
  }