@livx.cc/appwrap 0.43.0 → 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.
- package/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/AndroidManifest.xml +1 -0
- 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 +132 -0
- package/runtime-desktop/crates/webview-control/Cargo.lock +330 -0
- package/runtime-desktop/crates/webview-control/Cargo.toml +28 -0
- package/runtime-desktop/crates/webview-control/examples/host_harness.rs +245 -0
- package/runtime-desktop/crates/webview-control/src/embedded.rs +100 -0
- package/runtime-desktop/crates/webview-control/src/lib.rs +95 -0
- package/runtime-desktop/crates/webview-control/src/popup.rs +519 -0
- package/runtime-desktop/crates/webview-control/src/profiles.rs +45 -0
- package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +497 -0
- package/runtime-desktop/src-tauri/Cargo.lock +15 -0
- package/runtime-desktop/src-tauri/Cargo.toml +5 -0
- package/runtime-desktop/src-tauri/src/bridge_mac.rs +306 -0
- package/runtime-desktop/src-tauri/src/browser_tab.rs +193 -0
- package/runtime-desktop/src-tauri/src/main.rs +206 -3
- package/runtime-desktop/src-tauri/src/popup_mac.rs +5 -466
- package/runtime-desktop/src-tauri/src/server.rs +156 -0
- package/runtime-desktop/src-tauri/tauri.conf.json +1 -0
- package/src/cli.ts +86 -13
- package/src/config.ts +46 -1
- package/src/desktop.ts +48 -1
|
@@ -1,466 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
// The child WKWebView is alloc'd with the PASSED configuration — that's what preserves
|
|
7
|
-
// window.opener / postMessage and the shared session. Hosted in a plain NSWindow.
|
|
8
|
-
// - webViewDidClose: — pages that call window.close() (Firebase popups do) close their NSWindow.
|
|
9
|
-
// Everything else is forwarded to wry's original delegate via forwardingTargetForSelector /
|
|
10
|
-
// respondsToSelector (wry ALWAYS installs a WryWebViewUIDelegate; `uiDelegate` is a WEAK property,
|
|
11
|
-
// so we keep a strong ref to wry's delegate in our ivars — dropping it would break file-upload
|
|
12
|
-
// panels and media-capture grants).
|
|
13
|
-
//
|
|
14
|
-
// wry 0.55 does have a built-in popup path (NewWindowResponse::Allow via tauri's on_new_window),
|
|
15
|
-
// but it implements NO webViewDidClose and exposes no child handle to patch one in — a Firebase
|
|
16
|
-
// popup would linger as a dead window after auth. Hence the wrapper.
|
|
17
|
-
use std::ffi::c_void;
|
|
18
|
-
use std::sync::atomic::{AtomicBool, Ordering};
|
|
19
|
-
use std::sync::Mutex;
|
|
20
|
-
|
|
21
|
-
use objc2::rc::Retained;
|
|
22
|
-
use objc2::runtime::{AnyObject, NSObject, NSObjectProtocol, ProtocolObject, Sel};
|
|
23
|
-
use objc2::{define_class, msg_send, DefinedClass, MainThreadMarker, MainThreadOnly, Message};
|
|
24
|
-
use objc2_app_kit::{NSBackingStoreType, NSWindow, NSWindowDelegate, NSWindowStyleMask};
|
|
25
|
-
use objc2_foundation::{NSNotification, NSPoint, NSRect, NSSize, NSString, NSURL, NSURLRequest};
|
|
26
|
-
use objc2_web_kit::{
|
|
27
|
-
WKNavigationAction, WKNavigationActionPolicy, WKNavigationDelegate, WKUIDelegate, WKWebView,
|
|
28
|
-
WKWebViewConfiguration, WKWindowFeatures,
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/// When true, Google `signInWithPopup` OAuth popups get `prompt=select_account` appended so a
|
|
32
|
-
/// remembered Google session doesn't auto-sign — the user picks an account (multi-account apps like
|
|
33
|
-
/// Unclaw). Set once from the shell config at install(); read in the child's navigation delegate.
|
|
34
|
-
static GOOGLE_ACCOUNT_PICKER: AtomicBool = AtomicBool::new(false);
|
|
35
|
-
|
|
36
|
-
/// Rewrite a Google OAuth popup URL to force the account chooser, or None to leave it untouched.
|
|
37
|
-
/// Matches only `accounts.google.com` OAuth authorize / sign-in endpoints that don't already carry a
|
|
38
|
-
/// `prompt` param (so we don't clobber an app that set its own, and don't loop on our own reload).
|
|
39
|
-
fn google_account_picker_rewrite(url: &str) -> Option<String> {
|
|
40
|
-
let is_oauth = url.starts_with("https://accounts.google.com/o/oauth2")
|
|
41
|
-
|| url.starts_with("https://accounts.google.com/signin/oauth");
|
|
42
|
-
if !is_oauth {
|
|
43
|
-
return None;
|
|
44
|
-
}
|
|
45
|
-
let query = url.split_once('?').map(|(_, q)| q).unwrap_or("");
|
|
46
|
-
let has_prompt = query.split('&').any(|kv| kv == "prompt" || kv.starts_with("prompt="));
|
|
47
|
-
if has_prompt {
|
|
48
|
-
return None;
|
|
49
|
-
}
|
|
50
|
-
let sep = if url.contains('?') { '&' } else { '?' };
|
|
51
|
-
Some(format!("{url}{sep}prompt=select_account"))
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/// Safari-equivalent UA (no wry/Tauri token, full `Version/… Safari/…` suffix) — Google blocks
|
|
55
|
-
/// OAuth in webviews whose UA lacks it (disallowed_useragent). Applied to the shell webview
|
|
56
|
-
/// (main.rs builder) and every child popup.
|
|
57
|
-
pub const SAFARI_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15";
|
|
58
|
-
|
|
59
|
-
#[cfg(test)]
|
|
60
|
-
mod tests {
|
|
61
|
-
use super::google_account_picker_rewrite as r;
|
|
62
|
-
#[test]
|
|
63
|
-
fn appends_prompt_to_oauth_urls() {
|
|
64
|
-
assert_eq!(
|
|
65
|
-
r("https://accounts.google.com/o/oauth2/auth?client_id=x").as_deref(),
|
|
66
|
-
Some("https://accounts.google.com/o/oauth2/auth?client_id=x&prompt=select_account")
|
|
67
|
-
);
|
|
68
|
-
assert_eq!(
|
|
69
|
-
r("https://accounts.google.com/signin/oauth").as_deref(),
|
|
70
|
-
Some("https://accounts.google.com/signin/oauth?prompt=select_account")
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
#[test]
|
|
74
|
-
fn leaves_others_untouched() {
|
|
75
|
-
assert!(r("https://accounts.google.com/o/oauth2/auth?prompt=consent").is_none());
|
|
76
|
-
assert!(r("https://accounts.google.com/o/oauth2/auth?x=1&prompt=none").is_none());
|
|
77
|
-
assert!(r("https://example.com/o/oauth2/auth").is_none());
|
|
78
|
-
assert!(r("https://myapp.firebaseapp.com/__/auth/handler").is_none());
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
#[test]
|
|
82
|
-
fn descendants_resolve_transitively() {
|
|
83
|
-
use super::descendants_of;
|
|
84
|
-
// 1 → 2 → 3, 1 → 4; 5 hangs off an unrelated 9.
|
|
85
|
-
let pairs = [(2, 1), (3, 2), (4, 1), (5, 9)];
|
|
86
|
-
let mut d = descendants_of(&pairs, 1);
|
|
87
|
-
d.sort_unstable();
|
|
88
|
-
assert_eq!(d, vec![2, 3, 4]);
|
|
89
|
-
assert_eq!(descendants_of(&pairs, 3), Vec::<usize>::new());
|
|
90
|
-
assert_eq!(descendants_of(&pairs, 9), vec![5]);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
#[test]
|
|
94
|
-
fn descendants_cycle_safe() {
|
|
95
|
-
// Degenerate cycle must terminate and not include the root.
|
|
96
|
-
let pairs = [(2, 1), (1, 2)];
|
|
97
|
-
assert_eq!(super::descendants_of(&pairs, 1), vec![2]);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/// Strong refs for one open popup. Only touched on the main thread (Send impl exists purely so
|
|
102
|
-
/// the registry can live in a static Mutex — same pattern as oauth_mac::ActiveFlow).
|
|
103
|
-
struct Popup {
|
|
104
|
-
window: Retained<NSWindow>,
|
|
105
|
-
webview: Retained<WKWebView>,
|
|
106
|
-
/// Opener WKWebView pointer (shell webview for top-level popups, another popup's webview for
|
|
107
|
-
/// nested ones). Identity only — never dereferenced. Drives cascade-close.
|
|
108
|
-
parent: usize,
|
|
109
|
-
/// Google account-picker rewrite is applied AT MOST ONCE per popup — only the entry OAuth
|
|
110
|
-
/// navigation. Otherwise Google's own redirects to chooser/error pages (which drop the `prompt`
|
|
111
|
-
/// param) would re-match and re-trigger a cancel+reload loop. Tied to the popup so it's dropped
|
|
112
|
-
/// with the window.
|
|
113
|
-
rewritten: AtomicBool,
|
|
114
|
-
}
|
|
115
|
-
unsafe impl Send for Popup {}
|
|
116
|
-
static POPUPS: Mutex<Vec<Popup>> = Mutex::new(Vec::new());
|
|
117
|
-
|
|
118
|
-
/// Single idempotent teardown for a popup. Removes the (only) strong-ref entry from POPUPS so the
|
|
119
|
-
/// NSWindow + child WKWebView (JS timers, network) can finally be released. Both close paths route
|
|
120
|
-
/// here: page-initiated `window.close()` (webViewDidClose:, `close_window = true` — the window is
|
|
121
|
-
/// still open so we close it) and the user clicking the red button (windowWillClose:,
|
|
122
|
-
/// `close_window = false` — AppKit is already closing it). Calling it twice is a no-op (the second
|
|
123
|
-
/// path finds no entry), which is exactly what happens when either close triggers the other.
|
|
124
|
-
/// Main-thread only (guaranteed: both callers are WK/AppKit delegate methods).
|
|
125
|
-
// `&dyn Fn` (not `impl Fn`): cleanup_popup recurses for the cascade — a generic pred would
|
|
126
|
-
// monomorphize a fresh closure type per recursion level (compile error: recursion limit).
|
|
127
|
-
fn cleanup_popup(pred: &dyn Fn(&Popup) -> bool, close_window: bool) {
|
|
128
|
-
// One critical section: remove the entry AND snapshot the remaining (key, parent) pairs, then
|
|
129
|
-
// drop the lock BEFORE any window.close() — closing re-enters windowWillClose → cleanup_popup,
|
|
130
|
-
// which must not deadlock on POPUPS.
|
|
131
|
-
let (p, descendants) = {
|
|
132
|
-
let mut popups = POPUPS.lock().unwrap();
|
|
133
|
-
let Some(i) = popups.iter().position(|p| pred(p)) else {
|
|
134
|
-
eprintln!("[popup] cleanup: no matching entry (already torn down)");
|
|
135
|
-
return;
|
|
136
|
-
};
|
|
137
|
-
let p = popups.remove(i);
|
|
138
|
-
let key = Retained::as_ptr(&p.webview) as usize;
|
|
139
|
-
let pairs: Vec<(usize, usize)> = popups
|
|
140
|
-
.iter()
|
|
141
|
-
.map(|q| (Retained::as_ptr(&q.webview) as usize, q.parent))
|
|
142
|
-
.collect();
|
|
143
|
-
(p, descendants_of(&pairs, key))
|
|
144
|
-
};
|
|
145
|
-
// Stop observing `title` BEFORE releasing the webview (KVO throws if an observed object
|
|
146
|
-
// deallocates with observers attached). Registered unconditionally in create_web_view.
|
|
147
|
-
if let Some(observer) = installed_delegate() {
|
|
148
|
-
let key = NSString::from_str("title");
|
|
149
|
-
let _: () = unsafe { msg_send![&*p.webview, removeObserver: &*observer, forKeyPath: &*key] };
|
|
150
|
-
}
|
|
151
|
-
if close_window {
|
|
152
|
-
p.window.close();
|
|
153
|
-
}
|
|
154
|
-
// Cascade: close this popup's descendants through the same idempotent path. Any that already
|
|
155
|
-
// tore themselves down (e.g. their windowWillClose fired first) are a no-op above.
|
|
156
|
-
for key in descendants {
|
|
157
|
-
eprintln!("[popup] cascade-close child {key:#x}");
|
|
158
|
-
cleanup_popup(&move |q: &Popup| Retained::as_ptr(&q.webview) as usize == key, true);
|
|
159
|
-
}
|
|
160
|
-
// p (window + webview Retained refs) drops here → last strong refs released.
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/// All descendant keys of `root` in a (key, parent-key) edge list — breadth-ish order, cycle-safe.
|
|
164
|
-
/// Pure so the registry cascade logic is unit-testable without AppKit.
|
|
165
|
-
fn descendants_of(pairs: &[(usize, usize)], root: usize) -> Vec<usize> {
|
|
166
|
-
let mut out: Vec<usize> = Vec::new();
|
|
167
|
-
let mut queue = vec![root];
|
|
168
|
-
while let Some(k) = queue.pop() {
|
|
169
|
-
for &(child, parent) in pairs {
|
|
170
|
-
if parent == k && child != root && !out.contains(&child) {
|
|
171
|
-
out.push(child);
|
|
172
|
-
queue.push(child);
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
out
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/// Close every popup (main-window close / app teardown). Main-thread only — callers are tauri
|
|
180
|
-
/// window-event handlers, which run on the main thread on macOS.
|
|
181
|
-
pub fn close_all() {
|
|
182
|
-
loop {
|
|
183
|
-
let key = POPUPS
|
|
184
|
-
.lock()
|
|
185
|
-
.unwrap()
|
|
186
|
-
.first()
|
|
187
|
-
.map(|p| Retained::as_ptr(&p.webview) as usize);
|
|
188
|
-
match key {
|
|
189
|
-
Some(k) => cleanup_popup(&move |p: &Popup| Retained::as_ptr(&p.webview) as usize == k, true),
|
|
190
|
-
None => break,
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/// Strong ref to our installed Delegate (the KVO observer), if install() ran.
|
|
196
|
-
fn installed_delegate() -> Option<Retained<Delegate>> {
|
|
197
|
-
INSTALLED.lock().unwrap().0.as_ref().map(|(d, _)| d.clone())
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/// Strong refs to our delegate + wry's original (uiDelegate is weak on WKWebView).
|
|
201
|
-
struct Installed(Option<(Retained<Delegate>, Retained<AnyObject>)>);
|
|
202
|
-
unsafe impl Send for Installed {}
|
|
203
|
-
static INSTALLED: Mutex<Installed> = Mutex::new(Installed(None));
|
|
204
|
-
|
|
205
|
-
pub struct DelegateIvars {
|
|
206
|
-
/// wry's WryWebViewUIDelegate — unhandled WKUIDelegate messages forward here.
|
|
207
|
-
wry_delegate: Option<Retained<AnyObject>>,
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
define_class!(
|
|
211
|
-
#[unsafe(super(NSObject))]
|
|
212
|
-
#[thread_kind = MainThreadOnly]
|
|
213
|
-
#[name = "AppwrapUIDelegate"]
|
|
214
|
-
#[ivars = DelegateIvars]
|
|
215
|
-
struct Delegate;
|
|
216
|
-
|
|
217
|
-
unsafe impl NSObjectProtocol for Delegate {}
|
|
218
|
-
|
|
219
|
-
unsafe impl WKUIDelegate for Delegate {
|
|
220
|
-
#[unsafe(method_id(webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:))]
|
|
221
|
-
fn create_web_view(
|
|
222
|
-
&self,
|
|
223
|
-
opener: &WKWebView,
|
|
224
|
-
configuration: &WKWebViewConfiguration,
|
|
225
|
-
action: &WKNavigationAction,
|
|
226
|
-
features: &WKWindowFeatures,
|
|
227
|
-
) -> Option<Retained<WKWebView>> {
|
|
228
|
-
let mtm = self.mtm();
|
|
229
|
-
let url = unsafe { action.request().URL() }
|
|
230
|
-
.and_then(|u| u.absoluteString())
|
|
231
|
-
.map(|s| s.to_string())
|
|
232
|
-
.unwrap_or_default();
|
|
233
|
-
|
|
234
|
-
let width = unsafe { features.width() }.map(|n| n.doubleValue()).unwrap_or(720.0).max(200.0);
|
|
235
|
-
let height = unsafe { features.height() }.map(|n| n.doubleValue()).unwrap_or(640.0).max(200.0);
|
|
236
|
-
let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(width, height));
|
|
237
|
-
|
|
238
|
-
let style = NSWindowStyleMask::Titled
|
|
239
|
-
| NSWindowStyleMask::Closable
|
|
240
|
-
| NSWindowStyleMask::Miniaturizable
|
|
241
|
-
| NSWindowStyleMask::Resizable;
|
|
242
|
-
let window = unsafe {
|
|
243
|
-
NSWindow::initWithContentRect_styleMask_backing_defer(
|
|
244
|
-
mtm.alloc::<NSWindow>(),
|
|
245
|
-
rect,
|
|
246
|
-
style,
|
|
247
|
-
NSBackingStoreType::Buffered,
|
|
248
|
-
false,
|
|
249
|
-
)
|
|
250
|
-
};
|
|
251
|
-
// Required for NSWindows created without a window controller; we manage lifetime.
|
|
252
|
-
unsafe { window.setReleasedWhenClosed(false) };
|
|
253
|
-
|
|
254
|
-
// MUST use the PASSED configuration — anything else breaks window.opener/session
|
|
255
|
-
// (and WebKit raises if the returned webview wasn't made with it).
|
|
256
|
-
let child = unsafe {
|
|
257
|
-
WKWebView::initWithFrame_configuration(
|
|
258
|
-
mtm.alloc::<WKWebView>(),
|
|
259
|
-
window.frame(),
|
|
260
|
-
configuration,
|
|
261
|
-
)
|
|
262
|
-
};
|
|
263
|
-
unsafe {
|
|
264
|
-
child.setCustomUserAgent(Some(&NSString::from_str(SAFARI_UA)));
|
|
265
|
-
// Route the child's own UI-delegate messages (window.close(), nested
|
|
266
|
-
// window.open) back through us.
|
|
267
|
-
child.setUIDelegate(Some(ProtocolObject::from_ref(self)));
|
|
268
|
-
child.setAutoresizingMask(
|
|
269
|
-
objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
|
|
270
|
-
| objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
|
|
271
|
-
);
|
|
272
|
-
// Google multi-account: intercept the popup's OAuth navigation to append
|
|
273
|
-
// prompt=select_account (see decide_policy). Only wired when configured so the
|
|
274
|
-
// default remember-me behavior is untouched.
|
|
275
|
-
if GOOGLE_ACCOUNT_PICKER.load(Ordering::Relaxed) {
|
|
276
|
-
child.setNavigationDelegate(Some(ProtocolObject::from_ref(self)));
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
window.setContentView(Some(&child));
|
|
280
|
-
// Observe the red-button (user) close so we tear the popup down too — otherwise the
|
|
281
|
-
// NSWindow + live WKWebView leak forever (webViewDidClose: only fires for page-initiated
|
|
282
|
-
// window.close()). setDelegate is a WEAK ref; our Delegate is held strong for the app's
|
|
283
|
-
// lifetime in INSTALLED, so it outlives every child window.
|
|
284
|
-
window.setDelegate(Some(ProtocolObject::from_ref(self)));
|
|
285
|
-
window.center();
|
|
286
|
-
window.makeKeyAndOrderFront(None);
|
|
287
|
-
|
|
288
|
-
// Bind the NSWindow title to the page's real <title> (KVO on WKWebView.title) — anti-spoof
|
|
289
|
-
// + UX; removed in cleanup_popup. Options=New(1) so the change dict carries the value
|
|
290
|
-
// (we re-read from the webview anyway); context unused (we key off the object).
|
|
291
|
-
unsafe {
|
|
292
|
-
let key = NSString::from_str("title");
|
|
293
|
-
let _: () = msg_send![
|
|
294
|
-
&*child,
|
|
295
|
-
addObserver: self,
|
|
296
|
-
forKeyPath: &*key,
|
|
297
|
-
options: 1usize,
|
|
298
|
-
context: std::ptr::null_mut::<c_void>()
|
|
299
|
-
];
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
eprintln!("[popup] createWebView → {url} ({width}x{height})");
|
|
303
|
-
POPUPS.lock().unwrap().push(Popup {
|
|
304
|
-
window,
|
|
305
|
-
webview: child.clone(),
|
|
306
|
-
parent: opener as *const WKWebView as usize,
|
|
307
|
-
rewritten: AtomicBool::new(false),
|
|
308
|
-
});
|
|
309
|
-
// Returning the webview makes WebKit load the navigation action's request itself.
|
|
310
|
-
Some(child)
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
#[unsafe(method(webViewDidClose:))]
|
|
314
|
-
fn web_view_did_close(&self, webview: &WKWebView) {
|
|
315
|
-
eprintln!("[popup] webViewDidClose → cleanup + close window");
|
|
316
|
-
// Page called window.close(): window still open, so close it (windowWillClose: then
|
|
317
|
-
// fires but finds no entry → no-op).
|
|
318
|
-
cleanup_popup(&|p: &Popup| &*p.webview == webview, true);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Child-popup navigation policy — only consulted when GOOGLE_ACCOUNT_PICKER wired the child's
|
|
323
|
-
// navigationDelegate to us (create_web_view). Rewrites the Google OAuth navigation to force the
|
|
324
|
-
// account chooser; everything else is allowed unchanged.
|
|
325
|
-
unsafe impl WKNavigationDelegate for Delegate {
|
|
326
|
-
#[unsafe(method(webView:decidePolicyForNavigationAction:decisionHandler:))]
|
|
327
|
-
fn decide_policy(
|
|
328
|
-
&self,
|
|
329
|
-
webview: &WKWebView,
|
|
330
|
-
action: &WKNavigationAction,
|
|
331
|
-
handler: &block2::DynBlock<dyn Fn(WKNavigationActionPolicy)>,
|
|
332
|
-
) {
|
|
333
|
-
let url = unsafe { action.request().URL() }
|
|
334
|
-
.and_then(|u| u.absoluteString())
|
|
335
|
-
.map(|s| s.to_string())
|
|
336
|
-
.unwrap_or_default();
|
|
337
|
-
// Rewrite AT MOST ONCE per popup, and only the entry OAuth navigation — consume the
|
|
338
|
-
// once-flag lazily (only when we actually have a rewrite) so a non-OAuth first navigation
|
|
339
|
-
// doesn't burn it. Prevents re-triggering on Google's own chooser/error redirects.
|
|
340
|
-
if let Some(rewritten) = google_account_picker_rewrite(&url) {
|
|
341
|
-
let claim = {
|
|
342
|
-
let popups = POPUPS.lock().unwrap();
|
|
343
|
-
popups
|
|
344
|
-
.iter()
|
|
345
|
-
.find(|p| &*p.webview == webview)
|
|
346
|
-
.map(|p| !p.rewritten.swap(true, Ordering::Relaxed))
|
|
347
|
-
.unwrap_or(true)
|
|
348
|
-
};
|
|
349
|
-
if claim {
|
|
350
|
-
eprintln!("[popup] google account-picker: reload with prompt=select_account → {rewritten}");
|
|
351
|
-
// Cancel THIS load, then reload the SAME child webview with the rewritten URL.
|
|
352
|
-
// window.opener/postMessage survive a same-window navigation, so the Firebase flow
|
|
353
|
-
// still resolves; the reloaded URL carries `prompt`, so it's allowed (no loop).
|
|
354
|
-
handler.call((WKNavigationActionPolicy::Cancel,));
|
|
355
|
-
if let Some(nsurl) = NSURL::URLWithString(&NSString::from_str(&rewritten)) {
|
|
356
|
-
let req = NSURLRequest::requestWithURL(&nsurl);
|
|
357
|
-
let _ = unsafe { webview.loadRequest(&req) };
|
|
358
|
-
}
|
|
359
|
-
return;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
handler.call((WKNavigationActionPolicy::Allow,));
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
unsafe impl NSWindowDelegate for Delegate {
|
|
367
|
-
#[unsafe(method(windowWillClose:))]
|
|
368
|
-
fn window_will_close(&self, notification: &NSNotification) {
|
|
369
|
-
let Some(obj) = notification.object() else { return };
|
|
370
|
-
// NSNotification.object is the closing NSWindow.
|
|
371
|
-
let window: &NSWindow = unsafe { &*(Retained::as_ptr(&obj) as *const NSWindow) };
|
|
372
|
-
eprintln!("[popup] windowWillClose → cleanup (red-button or programmatic)");
|
|
373
|
-
// Window is already closing (AppKit drives it) → don't re-close.
|
|
374
|
-
cleanup_popup(&|p: &Popup| &*p.window == window, false);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/// Forward every WKUIDelegate message we don't implement to wry's original delegate
|
|
379
|
-
/// (file-upload open panel, media-capture permission, …).
|
|
380
|
-
impl Delegate {
|
|
381
|
-
/// KVO callback — only ever registered for popup WKWebViews' `title` (create_web_view), so
|
|
382
|
-
/// no context/keyPath dispatch or super call is needed. Mirrors the page title onto the
|
|
383
|
-
/// popup's NSWindow.
|
|
384
|
-
#[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
|
|
385
|
-
fn observe_value(
|
|
386
|
-
&self,
|
|
387
|
-
_key_path: Option<&NSString>,
|
|
388
|
-
object: Option<&AnyObject>,
|
|
389
|
-
_change: Option<&AnyObject>,
|
|
390
|
-
_context: *mut c_void,
|
|
391
|
-
) {
|
|
392
|
-
let Some(object) = object else { return };
|
|
393
|
-
let key = object as *const AnyObject as usize;
|
|
394
|
-
let (window, webview) = {
|
|
395
|
-
let popups = POPUPS.lock().unwrap();
|
|
396
|
-
let Some(p) = popups.iter().find(|p| Retained::as_ptr(&p.webview) as usize == key)
|
|
397
|
-
else {
|
|
398
|
-
return; // stale notification after teardown
|
|
399
|
-
};
|
|
400
|
-
(p.window.clone(), p.webview.clone())
|
|
401
|
-
};
|
|
402
|
-
if let Some(title) = unsafe { webview.title() } {
|
|
403
|
-
window.setTitle(&title);
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
#[unsafe(method(forwardingTargetForSelector:))]
|
|
408
|
-
fn forwarding_target(&self, _sel: Sel) -> *mut AnyObject {
|
|
409
|
-
match &self.ivars().wry_delegate {
|
|
410
|
-
Some(d) => Retained::as_ptr(d) as *mut AnyObject,
|
|
411
|
-
None => std::ptr::null_mut(),
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
#[unsafe(method(respondsToSelector:))]
|
|
416
|
-
fn responds_to(&self, sel: Sel) -> objc2::runtime::Bool {
|
|
417
|
-
let own: bool = unsafe { msg_send![super(self), respondsToSelector: sel] };
|
|
418
|
-
if own {
|
|
419
|
-
return objc2::runtime::Bool::YES;
|
|
420
|
-
}
|
|
421
|
-
let fwd = match &self.ivars().wry_delegate {
|
|
422
|
-
Some(d) => unsafe { msg_send![&**d, respondsToSelector: sel] },
|
|
423
|
-
None => false,
|
|
424
|
-
};
|
|
425
|
-
objc2::runtime::Bool::new(fwd)
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
/// Install the wrapper on the shell's WKWebView. `webview_ptr` is tauri's
|
|
431
|
-
/// `PlatformWebview::inner()` (a WKWebView on macOS); runs inside `with_webview`,
|
|
432
|
-
/// i.e. on the main thread.
|
|
433
|
-
pub fn install(webview_ptr: *mut std::ffi::c_void, google_account_picker: bool) {
|
|
434
|
-
GOOGLE_ACCOUNT_PICKER.store(google_account_picker, Ordering::Relaxed);
|
|
435
|
-
let Some(mtm) = MainThreadMarker::new() else {
|
|
436
|
-
eprintln!("[popup] install called off the main thread — skipped");
|
|
437
|
-
return;
|
|
438
|
-
};
|
|
439
|
-
if webview_ptr.is_null() {
|
|
440
|
-
eprintln!("[popup] null webview ptr — skipped");
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
let webview: &WKWebView = unsafe { &*(webview_ptr as *const WKWebView) };
|
|
444
|
-
|
|
445
|
-
// Keep wry's delegate alive (weak property) and as the forwarding target.
|
|
446
|
-
let wry_delegate: Option<Retained<AnyObject>> = unsafe { webview.UIDelegate() }
|
|
447
|
-
.map(|d| unsafe { Retained::cast_unchecked::<AnyObject>(d.retain()) });
|
|
448
|
-
eprintln!(
|
|
449
|
-
"[popup] wrapping uiDelegate (wry delegate present: {})",
|
|
450
|
-
wry_delegate.is_some()
|
|
451
|
-
);
|
|
452
|
-
|
|
453
|
-
let delegate = Delegate::alloc(mtm).set_ivars(DelegateIvars { wry_delegate: wry_delegate.clone() });
|
|
454
|
-
let delegate: Retained<Delegate> = unsafe { msg_send![super(delegate), init] };
|
|
455
|
-
unsafe { webview.setUIDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
|
|
456
|
-
|
|
457
|
-
// Shell-webview UA is normalized at BUILD time in main.rs (.user_agent) — setting it here
|
|
458
|
-
// (setup) is too late for the first document's navigator.userAgent snapshot.
|
|
459
|
-
|
|
460
|
-
INSTALLED.lock().unwrap().0 = Some((
|
|
461
|
-
delegate,
|
|
462
|
-
wry_delegate.unwrap_or_else(|| unsafe {
|
|
463
|
-
Retained::cast_unchecked::<AnyObject>(NSObject::new())
|
|
464
|
-
}),
|
|
465
|
-
));
|
|
466
|
-
}
|
|
1
|
+
// popup_mac moved into the `webview-control` crate (Phase 1 extraction) — this is a re-export shim
|
|
2
|
+
// so existing call sites (`crate::popup_mac::install` / `install_for_profile` / `close_all` /
|
|
3
|
+
// `SAFARI_UA` in main.rs + bridge_mac.rs) resolve unchanged. Edit the source in
|
|
4
|
+
// crates/webview-control/src/popup.rs, not here.
|
|
5
|
+
pub use webview_control::popup::*;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
//! `desktop.server` mode: boot a local server command, wait for its port, then the shell navigates the
|
|
2
|
+
//! window to it (see main.rs setup). This is the desktop analogue of iOS/Android `loader:'server'`, but
|
|
3
|
+
//! the URL is served by a child process THIS shell owns — so we also tear it down on window-close.
|
|
4
|
+
//!
|
|
5
|
+
//! Teardown correctness: the server (e.g. `para-chat`) itself spawns children (a backend + a vite
|
|
6
|
+
//! preview). A plain SIGKILL of the direct child would orphan those grandchildren. So we `setsid()` the
|
|
7
|
+
//! child into its OWN process group at spawn, then SIGTERM the WHOLE group on shutdown — every
|
|
8
|
+
//! descendant gets the signal and the server's own exit-guard can reap cleanly.
|
|
9
|
+
|
|
10
|
+
use std::io::{BufRead, BufReader};
|
|
11
|
+
use std::net::TcpStream;
|
|
12
|
+
use std::os::unix::process::CommandExt;
|
|
13
|
+
use std::process::{Child, Command, Stdio};
|
|
14
|
+
use std::sync::mpsc::{self, Receiver};
|
|
15
|
+
use std::sync::{Mutex, OnceLock};
|
|
16
|
+
use std::time::{Duration, Instant};
|
|
17
|
+
|
|
18
|
+
/// The booted child + its process-group id (== pid after setsid). Recorded for teardown.
|
|
19
|
+
struct Running {
|
|
20
|
+
child: Child,
|
|
21
|
+
pgid: i32,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static RUNNING: OnceLock<Mutex<Option<Running>>> = OnceLock::new();
|
|
25
|
+
|
|
26
|
+
fn slot() -> &'static Mutex<Option<Running>> {
|
|
27
|
+
RUNNING.get_or_init(|| Mutex::new(None))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Resolved server config (mirrors `ServerConfig` in main.rs).
|
|
31
|
+
pub struct ServerCfg<'a> {
|
|
32
|
+
pub command: &'a str,
|
|
33
|
+
pub args: &'a [String],
|
|
34
|
+
pub cwd: &'a str,
|
|
35
|
+
pub path: &'a str,
|
|
36
|
+
/// Non-empty → capture the printed URL from stdout (see `spawn`). Empty → caller uses wait_for_port.
|
|
37
|
+
pub url_marker: &'a str,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// Extract the first `http://…`/`https://…` token from a line (up to the next whitespace). Pure.
|
|
41
|
+
fn extract_url(line: &str) -> Option<String> {
|
|
42
|
+
let start = line.find("http://").or_else(|| line.find("https://"))?;
|
|
43
|
+
let rest = &line[start..];
|
|
44
|
+
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
|
|
45
|
+
Some(rest[..end].to_string())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/// Spawn the server command in its own process group (setsid), with the CLI-stamped PATH (a
|
|
49
|
+
/// GUI-launched .app has a minimal launchd PATH). Records the child for teardown.
|
|
50
|
+
///
|
|
51
|
+
/// When `url_marker` is non-empty, stdout is piped and a reader thread tees each line to the shell's
|
|
52
|
+
/// stderr AND, on the first line containing the marker, extracts the printed URL and sends it back —
|
|
53
|
+
/// so the shell loads whatever port the server auto-picked (no hardcoded port). Returns that receiver.
|
|
54
|
+
/// When `url_marker` is empty, stdout is inherited and `None` is returned (caller uses `wait_for_port`).
|
|
55
|
+
pub fn spawn(cfg: &ServerCfg) -> std::io::Result<Option<Receiver<String>>> {
|
|
56
|
+
let capture = !cfg.url_marker.is_empty();
|
|
57
|
+
let mut cmd = Command::new(cfg.command);
|
|
58
|
+
cmd.args(cfg.args);
|
|
59
|
+
if !cfg.cwd.is_empty() {
|
|
60
|
+
cmd.current_dir(cfg.cwd);
|
|
61
|
+
}
|
|
62
|
+
if !cfg.path.is_empty() {
|
|
63
|
+
cmd.env("PATH", cfg.path);
|
|
64
|
+
}
|
|
65
|
+
cmd.stdin(Stdio::null()).stderr(Stdio::inherit());
|
|
66
|
+
cmd.stdout(if capture { Stdio::piped() } else { Stdio::inherit() });
|
|
67
|
+
// New session/process group so we can signal every descendant at teardown. setsid() also makes the
|
|
68
|
+
// child the group leader → its pgid equals its pid.
|
|
69
|
+
unsafe {
|
|
70
|
+
cmd.pre_exec(|| {
|
|
71
|
+
if libc::setsid() == -1 {
|
|
72
|
+
return Err(std::io::Error::last_os_error());
|
|
73
|
+
}
|
|
74
|
+
Ok(())
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
let mut child = cmd.spawn()?;
|
|
78
|
+
let pgid = child.id() as i32;
|
|
79
|
+
|
|
80
|
+
let rx = if capture {
|
|
81
|
+
let stdout = child.stdout.take().expect("piped stdout");
|
|
82
|
+
let marker = cfg.url_marker.to_string();
|
|
83
|
+
let (tx, rx) = mpsc::channel::<String>();
|
|
84
|
+
std::thread::spawn(move || {
|
|
85
|
+
let mut sent = false;
|
|
86
|
+
for line in BufReader::new(stdout).lines().map_while(Result::ok) {
|
|
87
|
+
eprintln!("[server] {line}");
|
|
88
|
+
if !sent && line.contains(&marker) {
|
|
89
|
+
if let Some(url) = extract_url(&line) {
|
|
90
|
+
let _ = tx.send(url);
|
|
91
|
+
sent = true; // keep draining stdout so the child never blocks on a full pipe
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
Some(rx)
|
|
97
|
+
} else {
|
|
98
|
+
None
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
*slot().lock().unwrap() = Some(Running { child, pgid });
|
|
102
|
+
Ok(rx)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Poll `127.0.0.1:port` with a TCP connect until it accepts (server is listening) or the timeout
|
|
106
|
+
/// elapses. TCP-accept is a sufficient readiness signal — vite/preview binds the port only once it's
|
|
107
|
+
/// ready to serve. Returns false on timeout.
|
|
108
|
+
pub fn wait_for_port(port: u16, timeout_ms: u64) -> bool {
|
|
109
|
+
let deadline = Instant::now() + Duration::from_millis(timeout_ms);
|
|
110
|
+
let addr: std::net::SocketAddr = ([127, 0, 0, 1], port).into();
|
|
111
|
+
loop {
|
|
112
|
+
if TcpStream::connect_timeout(&addr, Duration::from_millis(500)).is_ok() {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
if Instant::now() >= deadline {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
std::thread::sleep(Duration::from_millis(300));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Signal the child's whole process group and reap (idempotent). Called on app exit so the local stack
|
|
123
|
+
/// never outlives the shell. SIGTERM first (lets the server run its exit-guard + reap its own children),
|
|
124
|
+
/// then a short grace, then SIGKILL as a backstop — the server may TRAP SIGTERM without exiting, and a
|
|
125
|
+
/// blocking `wait()` on such a child would hang the exit forever, so we hard-kill the group and only
|
|
126
|
+
/// `try_wait()` (non-blocking) to reap. The negative pgid targets every descendant.
|
|
127
|
+
pub fn shutdown() {
|
|
128
|
+
if let Some(m) = RUNNING.get() {
|
|
129
|
+
if let Some(mut r) = m.lock().unwrap().take() {
|
|
130
|
+
unsafe { libc::kill(-r.pgid, libc::SIGTERM) };
|
|
131
|
+
std::thread::sleep(Duration::from_millis(1500));
|
|
132
|
+
unsafe { libc::kill(-r.pgid, libc::SIGKILL) };
|
|
133
|
+
let _ = r.child.try_wait();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#[cfg(test)]
|
|
139
|
+
mod tests {
|
|
140
|
+
use super::extract_url;
|
|
141
|
+
|
|
142
|
+
#[test]
|
|
143
|
+
fn extracts_first_url_up_to_whitespace() {
|
|
144
|
+
assert_eq!(
|
|
145
|
+
extract_url(" Frontend: https://localhost:5050 (ready)"),
|
|
146
|
+
Some("https://localhost:5050".to_string())
|
|
147
|
+
);
|
|
148
|
+
assert_eq!(extract_url("Backend: http://localhost:5051"), Some("http://localhost:5051".to_string()));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#[test]
|
|
152
|
+
fn none_when_no_url() {
|
|
153
|
+
assert_eq!(extract_url("Frontend ready on port 5050"), None);
|
|
154
|
+
assert_eq!(extract_url(""), None);
|
|
155
|
+
}
|
|
156
|
+
}
|