@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.
@@ -1,351 +1,86 @@
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
17
  use std::sync::Mutex;
16
18
  use std::time::Duration;
17
19
  use tauri::{AppHandle, Manager};
18
20
 
19
21
  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
- }
22
+ use objc2::MainThreadMarker;
23
+ use objc2_web_kit::WKWebView;
24
+
25
+ // The transport-free primitive. This adapter is the ONLY place that knows the bod-browser wire
26
+ // format; the crate stays automation-agnostic.
27
+ use webview_control as wv;
28
+
29
+ /// Build the page->shim message sink for one webview: wrap the crate's RAW payload into the
30
+ /// {type:"pageMsg", payload} envelope the shim expects. This is the seam — the framing lives here,
31
+ /// never in the crate.
32
+ fn page_sink(tx: Sender<String>) -> wv::MessageSink {
33
+ Box::new(move |raw: String| {
34
+ let _ = tx.send(json!({ "type": "pageMsg", "payload": raw }).to_string());
35
+ })
124
36
  }
125
37
 
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>,
38
+ fn page_port() -> u16 {
39
+ std::env::var("APPWRAP_BRIDGE_PAGE_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9236)
144
40
  }
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
41
 
157
- unsafe impl NSObjectProtocol for ProfileWinDelegate {}
42
+ /// The live control-channel page->shim sender, set for the duration of a connected `run()`. Lets a
43
+ /// webview created OUTSIDE the createWindow op (e.g. a browser sub-window in browser_tab.rs) route
44
+ /// its page beacons to the SAME shim, so it registers as a bod-browser tab. None when disconnected.
45
+ static PAGE_TX: Mutex<Option<Sender<String>>> = Mutex::new(None);
158
46
 
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
- }
47
+ /// Clears PAGE_TX on drop, so every `run()` return path (normal or error) leaves it None.
48
+ struct PageTxGuard;
49
+ impl Drop for PageTxGuard {
50
+ fn drop(&mut self) {
51
+ *PAGE_TX.lock().unwrap() = None;
170
52
  }
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
53
  }
193
54
 
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
55
+ /// Page->shim sink for a webview created outside `createWindow` (browser sub-windows). Unlike a
56
+ /// snapshot of the CURRENT PAGE_TX, this sink reads the LIVE control connection at send-time: a
57
+ /// sub-window opened while the shim is disconnected (PAGE_TX == None) installs this ONE sink and its
58
+ /// beacons are dropped for now, but the moment a later `run()` publishes a PAGE_TX the SAME installed
59
+ /// sink starts delivering — so the window SELF-HEALS into agent-exposure on shim (re)connect, with no
60
+ /// re-injection of the message handler. Framing stays identical to `page_sink`.
61
+ pub fn bridge_sink() -> wv::MessageSink {
62
+ Box::new(move |raw: String| {
63
+ if let Some(tx) = PAGE_TX.lock().unwrap().as_ref() {
64
+ let _ = tx.send(json!({ "type": "pageMsg", "payload": raw }).to_string());
215
65
  }
216
- None => false,
217
- }
66
+ })
218
67
  }
219
68
 
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)
69
+ /// Build the SAME framed init-JS `createWindow` injects, for a webview created elsewhere. Reads the
70
+ /// content bundle from `APPWRAP_BRIDGE_JS`; None when the feature is off (unset/empty) or the read
71
+ /// fails (logged). `label` becomes `window.__BRIDGE_LABEL` so the shim registers this webview by id.
72
+ pub fn bridge_init_js(label: &str) -> Option<String> {
73
+ let path = match std::env::var("APPWRAP_BRIDGE_JS") {
74
+ Ok(p) if !p.is_empty() => p,
75
+ _ => return None,
277
76
  };
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));
77
+ match std::fs::read_to_string(&path) {
78
+ Ok(bundle) => Some(format!(
79
+ "window.__BRIDGE_LABEL={};window.__BRIDGE_PORT={};\n{}",
80
+ serde_json::to_string(&label).unwrap(), page_port(), bundle
81
+ )),
82
+ Err(e) => { eprintln!("[bridge-shim] bridge_init_js read {path}: {e}"); None }
282
83
  }
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
- );
345
- }
346
-
347
- fn page_port() -> u16 {
348
- std::env::var("APPWRAP_BRIDGE_PAGE_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9236)
349
84
  }
350
85
  fn rust_port() -> u16 {
351
86
  std::env::var("APPWRAP_BRIDGE_RUST_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9237)
@@ -372,6 +107,14 @@ pub fn init(app: AppHandle) {
372
107
  fn run(app: &AppHandle, mut stream: TcpStream, js_path: &str) {
373
108
  let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
374
109
  let (tx, rx) = channel::<String>();
110
+ // Publish this connection's sender so sub-window webviews (created outside createWindow) can route
111
+ // their page beacons here; the guard clears it on ANY return below (disconnect/error).
112
+ *PAGE_TX.lock().unwrap() = Some(tx.clone());
113
+ let _tx_guard = PageTxGuard;
114
+ // Self-heal sub-windows opened BEFORE this connect: their dynamic sink can now deliver, but a
115
+ // document already loaded won't re-fire its `register` beacon on its own. Nudge every open
116
+ // sub-window webview to re-post `register` so it (re)appears as a bod-browser tab on this shim.
117
+ crate::browser_tab::reregister_subwindows(app);
375
118
  let mut acc = String::new();
376
119
  let mut buf = [0u8; 65536];
377
120
  loop {
@@ -427,15 +170,16 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
427
170
  ),
428
171
  Err(e) => { respond(tx, &id, Err(format!("read bundle {js_path}: {e}"))); return; }
429
172
  };
430
- // Profile windows take the MANUAL creation path (per-profile persistent data store).
173
+ // Profile windows take the crate's MANUAL creation path (per-profile persistent store).
431
174
  // Windows without a profileId keep the original tauri path (back-compat).
432
175
  if !profile_id.is_empty() {
433
176
  let idc = id.clone();
434
177
  let init_js2 = init_js.clone();
435
178
  let r = app.run_on_main_thread(move || {
436
179
  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);
180
+ let res = wv::create_profile_window(mtm, &label, &url, &profile_id, &init_js2, page_sink(tx2.clone()))
181
+ .map(|c| json!({ "ok": true, "label": label, "profileId": profile_id, "storeUuid": c.store_uuid, "persistent": c.persistent }));
182
+ respond(&tx2, &idc, res);
439
183
  });
440
184
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
441
185
  return;
@@ -444,45 +188,36 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
444
188
  let r = app.run_on_main_thread(move || {
445
189
  let parsed = match tauri::Url::parse(&url) {
446
190
  Ok(u) => u,
447
- Err(e) => { respond_owned(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
191
+ Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
448
192
  };
449
193
  let r = tauri::WebviewWindowBuilder::new(&app2, &label, tauri::WebviewUrl::External(parsed))
450
194
  .title(&label)
451
195
  .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).
196
+ // Safari-normalized UA at BUILD time (same constraint as the main window).
454
197
  .user_agent(crate::popup_mac::SAFARI_UA)
455
198
  .initialization_script(&init_js)
456
199
  .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).
200
+ // Install the page->shim message handler on the fresh webview (main thread), plus the
201
+ // SAME popup machinery the main window gets (SSO window.open child windows).
460
202
  if let Ok(w) = &r {
461
203
  let tx4 = tx2.clone();
462
204
  let _ = w.with_webview(move |pw| unsafe {
463
205
  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
- );
206
+ let mtm = MainThreadMarker::new().expect("with_webview runs on main");
207
+ wv::install_message_handler(pw.inner() as *mut WKWebView, mtm, page_sink(tx4));
473
208
  });
474
209
  }
475
- respond_owned(&tx2, &idc, r.map(|_| json!({ "ok": true, "label": label })).map_err(|e| e.to_string()));
210
+ respond(&tx2, &idc, r.map(|_| json!({ "ok": true, "label": label })).map_err(|e| e.to_string()));
476
211
  });
477
212
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
478
213
  }
479
214
  "closeWindow" => {
480
215
  let label = v["label"].as_str().unwrap_or_default().to_string();
481
- if profile_webview(&label).is_some() {
216
+ if wv::profile_webview(&label).is_some() {
482
217
  let idc = id.clone();
483
218
  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}")) });
219
+ let ok = wv::close(&label);
220
+ respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
486
221
  });
487
222
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
488
223
  return;
@@ -497,24 +232,18 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
497
232
  let url = v["url"].as_str().unwrap_or_default().to_string();
498
233
  let idc = id.clone();
499
234
  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
- }
235
+ if let Some(wk) = wv::profile_webview(&label) {
236
+ let res = unsafe { wv::navigate(Retained::as_ptr(&wk) as *mut WKWebView, &url) };
237
+ respond(&tx2, &idc, res.map(|_| json!({ "ok": true })));
509
238
  return;
510
239
  }
511
240
  let parsed = match tauri::Url::parse(&url) {
512
241
  Ok(u) => u,
513
- Err(e) => { respond_owned(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
242
+ Err(e) => { respond(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
514
243
  };
515
244
  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}"))),
245
+ Some(w) => respond(&tx2, &idc, w.navigate(parsed).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
246
+ None => respond(&tx2, &idc, Err(format!("no window {label}"))),
518
247
  }
519
248
  });
520
249
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
@@ -523,18 +252,22 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
523
252
  let label = v["label"].as_str().unwrap_or_default().to_string();
524
253
  let idc = id.clone();
525
254
  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()) };
255
+ if let Some(wk) = wv::profile_webview(&label) {
256
+ let tx3 = tx2.clone();
257
+ let id2 = idc.clone();
258
+ unsafe { wv::snapshot(Retained::as_ptr(&wk) as *mut WKWebView, move |r| respond_png(&tx3, &id2, r)) };
528
259
  return;
529
260
  }
530
261
  let Some(w) = app2.get_webview_window(&label) else {
531
- respond_owned(&tx2, &idc, Err(format!("no window {label}")));
262
+ respond(&tx2, &idc, Err(format!("no window {label}")));
532
263
  return;
533
264
  };
534
265
  let tx3 = tx2.clone();
535
266
  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())); }
267
+ let r = w.with_webview(move |pw| unsafe {
268
+ wv::snapshot(pw.inner() as *mut WKWebView, move |r| respond_png(&tx3, &id2, r))
269
+ });
270
+ if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
538
271
  });
539
272
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
540
273
  }
@@ -542,16 +275,14 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
542
275
  // Fire-and-forget JS injection into a window (debug aid + transport fallback probe).
543
276
  let label = v["label"].as_str().unwrap_or_default().to_string();
544
277
  let js = v["js"].as_str().unwrap_or_default().to_string();
545
- if profile_webview(&label).is_some() {
278
+ if wv::profile_webview(&label).is_some() {
546
279
  let idc = id.clone();
547
280
  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 })));
281
+ if let Some(wk) = wv::profile_webview(&label) {
282
+ unsafe { wv::inject_script(Retained::as_ptr(&wk) as *mut WKWebView, &js) };
283
+ respond(&tx2, &idc, Ok(json!({ "ok": true })));
553
284
  } else {
554
- respond_owned(&tx2, &idc, Err(format!("no window {label}")));
285
+ respond(&tx2, &idc, Err(format!("no window {label}")));
555
286
  }
556
287
  });
557
288
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
@@ -563,63 +294,52 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
563
294
  }
564
295
  }
565
296
  "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`).
297
+ // CSP-immune eval with a real (awaited) return value — callAsyncJavaScript. `js` is a
298
+ // FUNCTION BODY (use `return`). The crate delivers the RAW return string; we frame {json}.
569
299
  let label = v["label"].as_str().unwrap_or_default().to_string();
570
300
  let js = v["js"].as_str().unwrap_or_default().to_string();
571
301
  let idc = id.clone();
572
302
  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()) };
303
+ if let Some(wk) = wv::profile_webview(&label) {
304
+ let tx3 = tx2.clone();
305
+ let id2 = idc.clone();
306
+ unsafe { wv::eval_async(Retained::as_ptr(&wk) as *mut WKWebView, js.clone(), move |r| respond_eval(&tx3, &id2, r)) };
575
307
  return;
576
308
  }
577
309
  let Some(w) = app2.get_webview_window(&label) else {
578
- respond_owned(&tx2, &idc, Err(format!("no window {label}")));
310
+ respond(&tx2, &idc, Err(format!("no window {label}")));
579
311
  return;
580
312
  };
581
313
  let tx3 = tx2.clone();
582
314
  let id2 = idc.clone();
583
315
  let js2 = js.clone();
584
316
  let r = w.with_webview(move |pw| unsafe {
585
- eval_async_on(pw.inner() as *mut WKWebView, js2, tx3, id2);
317
+ wv::eval_async(pw.inner() as *mut WKWebView, js2, move |r| respond_eval(&tx3, &id2, r));
586
318
  });
587
- if let Err(e) = r { respond_owned(&tx2, &idc, Err(e.to_string())); }
319
+ if let Err(e) = r { respond(&tx2, &idc, Err(e.to_string())); }
588
320
  });
589
321
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
590
322
  }
591
323
  "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).
324
+ // Verification aid: drive the red-button path WITHOUT touching the registry, so the
325
+ // windowWillClose delegate is the sole remover — proves user-close hygiene headlessly.
595
326
  let label = v["label"].as_str().unwrap_or_default().to_string();
596
327
  let idc = id.clone();
597
328
  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
- }
329
+ let ok = wv::user_close(&label);
330
+ respond(&tx2, &idc, if ok { Ok(json!({ "ok": true })) } else { Err(format!("no window {label}")) });
611
331
  });
612
332
  if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
613
333
  }
614
334
  "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.
335
+ // Closed windows are filtered by the crate; actual reaping (which drops objc objects) is
336
+ // deferred to createWindow, which runs on the main thread.
617
337
  let mut labels: Vec<String> = app.webview_windows().keys().cloned().collect();
618
- let profiles: Vec<Value> = profile_labels()
338
+ let profiles: Vec<Value> = wv::list_webviews()
619
339
  .into_iter()
620
- .map(|(label, profile_id)| {
621
- labels.push(label.clone());
622
- json!({ "label": label, "profileId": profile_id })
340
+ .map(|info| {
341
+ labels.push(info.label.clone());
342
+ json!({ "label": info.label, "profileId": info.profile_id })
623
343
  })
624
344
  .collect();
625
345
  respond(tx, &id, Ok(json!({ "labels": labels, "profiles": profiles })));
@@ -628,37 +348,12 @@ fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
628
348
  }
629
349
  }
630
350
 
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);
351
+ /// Frame a RAW eval return string into the {json} envelope the shim reads (`r.json`).
352
+ fn respond_eval(tx: &Sender<String>, id: &str, r: Result<String, String>) {
353
+ respond(tx, id, r.map(|out| json!({ "json": out })));
634
354
  }
635
355
 
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);
356
+ /// Base64-encode the crate's RAW PNG bytes and frame into the {png} envelope.
357
+ fn respond_png(tx: &Sender<String>, id: &str, r: Result<Vec<u8>, String>) {
358
+ respond(tx, id, r.map(|bytes| json!({ "png": base64::engine::general_purpose::STANDARD.encode(bytes) })));
664
359
  }