@livx.cc/appwrap 0.43.0 → 0.46.1
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.
- package/package.json +1 -1
- package/runtime-desktop/bridge-shim/build.sh +9 -0
- package/runtime-desktop/bridge-shim/content-entry.ts +87 -0
- package/runtime-desktop/bridge-shim/shim.ts +317 -0
- package/runtime-desktop/src-tauri/Cargo.lock +2 -0
- package/runtime-desktop/src-tauri/Cargo.toml +2 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +664 -0
- package/runtime-desktop/src-tauri/src/main.rs +198 -2
- package/runtime-desktop/src-tauri/src/popup_mac.rs +63 -10
- package/runtime-desktop/src-tauri/src/server.rs +156 -0
- package/src/cli.ts +70 -13
- package/src/config.ts +36 -0
- package/src/desktop.ts +48 -1
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
// SPIKE: window-ops backend for the browser-bridge shim (bridge-shim/shim.ts).
|
|
2
|
+
// 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
|
+
use base64::Engine;
|
|
10
|
+
use serde_json::{json, Value};
|
|
11
|
+
use std::io::{Read, Write};
|
|
12
|
+
use std::net::TcpStream;
|
|
13
|
+
use std::sync::atomic::{AtomicBool, Ordering};
|
|
14
|
+
use std::sync::mpsc::{channel, Sender};
|
|
15
|
+
use std::sync::Mutex;
|
|
16
|
+
use std::time::Duration;
|
|
17
|
+
use tauri::{AppHandle, Manager};
|
|
18
|
+
|
|
19
|
+
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
|
+
);
|
|
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
|
+
}
|
|
350
|
+
fn rust_port() -> u16 {
|
|
351
|
+
std::env::var("APPWRAP_BRIDGE_RUST_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(9237)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
pub fn init(app: AppHandle) {
|
|
355
|
+
let js_path = match std::env::var("APPWRAP_BRIDGE_JS") {
|
|
356
|
+
Ok(p) if !p.is_empty() => p,
|
|
357
|
+
_ => return, // feature off
|
|
358
|
+
};
|
|
359
|
+
std::thread::spawn(move || loop {
|
|
360
|
+
match TcpStream::connect(("127.0.0.1", rust_port())) {
|
|
361
|
+
Ok(stream) => {
|
|
362
|
+
eprintln!("[bridge-shim] control connected");
|
|
363
|
+
run(&app, stream, &js_path);
|
|
364
|
+
eprintln!("[bridge-shim] control disconnected");
|
|
365
|
+
}
|
|
366
|
+
Err(_) => {}
|
|
367
|
+
}
|
|
368
|
+
std::thread::sleep(Duration::from_secs(1));
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
fn run(app: &AppHandle, mut stream: TcpStream, js_path: &str) {
|
|
373
|
+
let _ = stream.set_read_timeout(Some(Duration::from_millis(50)));
|
|
374
|
+
let (tx, rx) = channel::<String>();
|
|
375
|
+
let mut acc = String::new();
|
|
376
|
+
let mut buf = [0u8; 65536];
|
|
377
|
+
loop {
|
|
378
|
+
while let Ok(line) = rx.try_recv() {
|
|
379
|
+
if stream.write_all(line.as_bytes()).and_then(|_| stream.write_all(b"\n")).is_err() {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
match stream.read(&mut buf) {
|
|
384
|
+
Ok(0) => return,
|
|
385
|
+
Ok(n) => {
|
|
386
|
+
acc.push_str(&String::from_utf8_lossy(&buf[..n]));
|
|
387
|
+
while let Some(idx) = acc.find('\n') {
|
|
388
|
+
let line = acc[..idx].trim().to_string();
|
|
389
|
+
acc.drain(..=idx);
|
|
390
|
+
if line.is_empty() { continue; }
|
|
391
|
+
match serde_json::from_str::<Value>(&line) {
|
|
392
|
+
Ok(v) => handle_op(app, &tx, v, js_path),
|
|
393
|
+
Err(e) => eprintln!("[bridge-shim] bad line: {e}"),
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {}
|
|
398
|
+
Err(_) => return,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
fn respond(tx: &Sender<String>, id: &str, result: Result<Value, String>) {
|
|
404
|
+
let msg = match result {
|
|
405
|
+
Ok(r) => json!({ "id": id, "result": r }),
|
|
406
|
+
Err(e) => json!({ "id": id, "error": e }),
|
|
407
|
+
};
|
|
408
|
+
let _ = tx.send(msg.to_string());
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
fn handle_op(app: &AppHandle, tx: &Sender<String>, v: Value, js_path: &str) {
|
|
412
|
+
let id = v["id"].as_str().unwrap_or_default().to_string();
|
|
413
|
+
let op = v["op"].as_str().unwrap_or_default().to_string();
|
|
414
|
+
eprintln!("[bridge-shim] op={op} id={id}");
|
|
415
|
+
let app2 = app.clone();
|
|
416
|
+
let tx2 = tx.clone();
|
|
417
|
+
match op.as_str() {
|
|
418
|
+
"createWindow" => {
|
|
419
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
420
|
+
let url = v["url"].as_str().unwrap_or("about:blank").to_string();
|
|
421
|
+
let profile_id = v["profileId"].as_str().unwrap_or_default().to_string();
|
|
422
|
+
// Re-read the bundle each time — a rebuilt content.js applies to the next window (dev loop).
|
|
423
|
+
let init_js = match std::fs::read_to_string(js_path) {
|
|
424
|
+
Ok(bundle) => format!(
|
|
425
|
+
"window.__BRIDGE_LABEL={};window.__BRIDGE_PORT={};\n{}",
|
|
426
|
+
serde_json::to_string(&label).unwrap(), page_port(), bundle
|
|
427
|
+
),
|
|
428
|
+
Err(e) => { respond(tx, &id, Err(format!("read bundle {js_path}: {e}"))); return; }
|
|
429
|
+
};
|
|
430
|
+
// Profile windows take the MANUAL creation path (per-profile persistent data store).
|
|
431
|
+
// Windows without a profileId keep the original tauri path (back-compat).
|
|
432
|
+
if !profile_id.is_empty() {
|
|
433
|
+
let idc = id.clone();
|
|
434
|
+
let init_js2 = init_js.clone();
|
|
435
|
+
let r = app.run_on_main_thread(move || {
|
|
436
|
+
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);
|
|
439
|
+
});
|
|
440
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
let idc = id.clone();
|
|
444
|
+
let r = app.run_on_main_thread(move || {
|
|
445
|
+
let parsed = match tauri::Url::parse(&url) {
|
|
446
|
+
Ok(u) => u,
|
|
447
|
+
Err(e) => { respond_owned(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
|
|
448
|
+
};
|
|
449
|
+
let r = tauri::WebviewWindowBuilder::new(&app2, &label, tauri::WebviewUrl::External(parsed))
|
|
450
|
+
.title(&label)
|
|
451
|
+
.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).
|
|
454
|
+
.user_agent(crate::popup_mac::SAFARI_UA)
|
|
455
|
+
.initialization_script(&init_js)
|
|
456
|
+
.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).
|
|
460
|
+
if let Ok(w) = &r {
|
|
461
|
+
let tx4 = tx2.clone();
|
|
462
|
+
let _ = w.with_webview(move |pw| unsafe {
|
|
463
|
+
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
|
+
);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
respond_owned(&tx2, &idc, r.map(|_| json!({ "ok": true, "label": label })).map_err(|e| e.to_string()));
|
|
476
|
+
});
|
|
477
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
478
|
+
}
|
|
479
|
+
"closeWindow" => {
|
|
480
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
481
|
+
if profile_webview(&label).is_some() {
|
|
482
|
+
let idc = id.clone();
|
|
483
|
+
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}")) });
|
|
486
|
+
});
|
|
487
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
match app.get_webview_window(&label) {
|
|
491
|
+
Some(w) => respond(tx, &id, w.close().map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
|
|
492
|
+
None => respond(tx, &id, Err(format!("no window {label}"))),
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
"navigate" => {
|
|
496
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
497
|
+
let url = v["url"].as_str().unwrap_or_default().to_string();
|
|
498
|
+
let idc = id.clone();
|
|
499
|
+
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
|
+
}
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
let parsed = match tauri::Url::parse(&url) {
|
|
512
|
+
Ok(u) => u,
|
|
513
|
+
Err(e) => { respond_owned(&tx2, &idc, Err(format!("bad url: {e}"))); return; }
|
|
514
|
+
};
|
|
515
|
+
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}"))),
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
521
|
+
}
|
|
522
|
+
"screenshot" => {
|
|
523
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
524
|
+
let idc = id.clone();
|
|
525
|
+
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()) };
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
let Some(w) = app2.get_webview_window(&label) else {
|
|
531
|
+
respond_owned(&tx2, &idc, Err(format!("no window {label}")));
|
|
532
|
+
return;
|
|
533
|
+
};
|
|
534
|
+
let tx3 = tx2.clone();
|
|
535
|
+
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())); }
|
|
538
|
+
});
|
|
539
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
540
|
+
}
|
|
541
|
+
"evalJs" => {
|
|
542
|
+
// Fire-and-forget JS injection into a window (debug aid + transport fallback probe).
|
|
543
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
544
|
+
let js = v["js"].as_str().unwrap_or_default().to_string();
|
|
545
|
+
if profile_webview(&label).is_some() {
|
|
546
|
+
let idc = id.clone();
|
|
547
|
+
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 })));
|
|
553
|
+
} else {
|
|
554
|
+
respond_owned(&tx2, &idc, Err(format!("no window {label}")));
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
match app.get_webview_window(&label) {
|
|
561
|
+
Some(w) => respond(tx, &id, w.eval(&js).map(|_| json!({ "ok": true })).map_err(|e| e.to_string())),
|
|
562
|
+
None => respond(tx, &id, Err(format!("no window {label}"))),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
"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`).
|
|
569
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
570
|
+
let js = v["js"].as_str().unwrap_or_default().to_string();
|
|
571
|
+
let idc = id.clone();
|
|
572
|
+
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()) };
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
let Some(w) = app2.get_webview_window(&label) else {
|
|
578
|
+
respond_owned(&tx2, &idc, Err(format!("no window {label}")));
|
|
579
|
+
return;
|
|
580
|
+
};
|
|
581
|
+
let tx3 = tx2.clone();
|
|
582
|
+
let id2 = idc.clone();
|
|
583
|
+
let js2 = js.clone();
|
|
584
|
+
let r = w.with_webview(move |pw| unsafe {
|
|
585
|
+
eval_async_on(pw.inner() as *mut WKWebView, js2, tx3, id2);
|
|
586
|
+
});
|
|
587
|
+
if let Err(e) = r { respond_owned(&tx2, &idc, Err(e.to_string())); }
|
|
588
|
+
});
|
|
589
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
590
|
+
}
|
|
591
|
+
"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).
|
|
595
|
+
let label = v["label"].as_str().unwrap_or_default().to_string();
|
|
596
|
+
let idc = id.clone();
|
|
597
|
+
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
|
+
}
|
|
611
|
+
});
|
|
612
|
+
if let Err(e) = r { respond(tx, &id, Err(e.to_string())); }
|
|
613
|
+
}
|
|
614
|
+
"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.
|
|
617
|
+
let mut labels: Vec<String> = app.webview_windows().keys().cloned().collect();
|
|
618
|
+
let profiles: Vec<Value> = profile_labels()
|
|
619
|
+
.into_iter()
|
|
620
|
+
.map(|(label, profile_id)| {
|
|
621
|
+
labels.push(label.clone());
|
|
622
|
+
json!({ "label": label, "profileId": profile_id })
|
|
623
|
+
})
|
|
624
|
+
.collect();
|
|
625
|
+
respond(tx, &id, Ok(json!({ "labels": labels, "profiles": profiles })));
|
|
626
|
+
}
|
|
627
|
+
_ => respond(tx, &id, Err(format!("unknown op: {op}"))),
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
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);
|
|
634
|
+
}
|
|
635
|
+
|
|
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);
|
|
664
|
+
}
|