@livx.cc/appwrap 0.42.4 → 0.43.0
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 +4 -2
- package/runtime/app/app.ts +15 -0
- package/runtime/app/main-page.ts +11 -2
- package/runtime/app/shell/custom-webview.ios.ts +18 -8
- package/runtime/app/shell/handlers-background.ts +1 -1
- package/runtime/app/shell/web-quirks.ts +63 -0
- package/runtime-desktop/autotest/index.html +77 -0
- package/runtime-desktop/src-tauri/Cargo.lock +5459 -0
- package/runtime-desktop/src-tauri/Cargo.toml +38 -0
- package/runtime-desktop/src-tauri/build.rs +3 -0
- package/runtime-desktop/src-tauri/capabilities/default.json +6 -0
- package/runtime-desktop/src-tauri/icons/icon.png +0 -0
- package/runtime-desktop/src-tauri/shell_config.json +10 -0
- package/runtime-desktop/src-tauri/src/biometrics_mac.rs +138 -0
- package/runtime-desktop/src-tauri/src/main.rs +683 -0
- package/runtime-desktop/src-tauri/src/notifications_mac.rs +202 -0
- package/runtime-desktop/src-tauri/src/oauth_mac.rs +139 -0
- package/runtime-desktop/src-tauri/src/popup_mac.rs +466 -0
- package/runtime-desktop/src-tauri/src/push_mac.rs +193 -0
- package/runtime-desktop/src-tauri/src/sidecar.rs +398 -0
- package/runtime-desktop/src-tauri/tauri.conf.json +13 -0
- package/scripts/stage-assets.mjs +4 -2
- package/src/cli.ts +606 -36
- package/src/config.ts +40 -1
- package/src/desktop.ts +246 -0
- package/src/handlers.ts +71 -0
- package/src/icon.ts +207 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
// macOS popup-window support — makes arbitrary wrapped web apps' `window.open` flows work
|
|
2
|
+
// (Firebase signInWithPopup dies with auth/popup-blocked without this).
|
|
3
|
+
//
|
|
4
|
+
// Approach: wrap wry's WKUIDelegate with our own that implements
|
|
5
|
+
// - webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:
|
|
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
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// APNs device-token registration for the SIGNED desktop lane (push.register / push.unregister).
|
|
2
|
+
//
|
|
3
|
+
// macOS delivers the token via NSApplicationDelegate callbacks. Tauri (tao) owns the app delegate
|
|
4
|
+
// and does not implement the two APNs selectors, so we ADD them to tao's delegate class at runtime
|
|
5
|
+
// (class_addMethod — pure addition, no swizzle, nothing existing is displaced). If a future tao
|
|
6
|
+
// ever implements them, class_addMethod returns false and we fail the request honestly instead of
|
|
7
|
+
// silently double-handling. Envelope mirrors the mobile shell (runtime/app/shell/handlers-push.ts):
|
|
8
|
+
// result = { platform: 'apns', token: <hex>, topic: <bundle id> }, 25s timeout, single-flight.
|
|
9
|
+
use std::ffi::CStr;
|
|
10
|
+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
11
|
+
use std::sync::Mutex;
|
|
12
|
+
|
|
13
|
+
use objc2::runtime::{AnyClass, AnyObject, Sel};
|
|
14
|
+
use objc2::{msg_send, sel, MainThreadMarker};
|
|
15
|
+
use objc2_app_kit::NSApplication;
|
|
16
|
+
use objc2_foundation::{NSData, NSError};
|
|
17
|
+
use serde_json::json;
|
|
18
|
+
|
|
19
|
+
use crate::{deliver, deliver_error, shell};
|
|
20
|
+
|
|
21
|
+
/// Pending register() waiting for the delegate callback (single-flight, like oauth).
|
|
22
|
+
static PENDING: Mutex<Option<(tauri::WebviewWindow, String)>> = Mutex::new(None);
|
|
23
|
+
/// Cached token — APNs tokens are stable per launch; later register() calls resolve instantly.
|
|
24
|
+
static TOKEN: Mutex<Option<String>> = Mutex::new(None);
|
|
25
|
+
static HOOKS_INSTALLED: AtomicBool = AtomicBool::new(false);
|
|
26
|
+
/// Monotonic id of the current in-flight register. Each new flight bumps it; the timeout thread
|
|
27
|
+
/// captures its own id and only fires if it's STILL the current flight — so a stale timer from a
|
|
28
|
+
/// fast-failed-then-retried flight can't cancel the later one (see `timeout_should_fire`).
|
|
29
|
+
static FLIGHT: AtomicU64 = AtomicU64::new(0);
|
|
30
|
+
|
|
31
|
+
/// Guard for the timeout thread: fire only if this flight is still the current one. Pure — unit-tested.
|
|
32
|
+
fn timeout_should_fire(my_flight: u64, current_flight: u64) -> bool {
|
|
33
|
+
my_flight == current_flight
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn deliver_token(window: &tauri::WebviewWindow, id: &str, token: &str) {
|
|
37
|
+
let resp = json!({ "v": 1, "id": id, "kind": "response", "result": {
|
|
38
|
+
"platform": "apns", "token": token, "topic": shell().identifier,
|
|
39
|
+
}});
|
|
40
|
+
deliver(window, &resp);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// `push.register` — resolve the APNs device token. Called from appwrap_invoke (any thread);
|
|
44
|
+
/// replies asynchronously from the injected delegate callback (main thread).
|
|
45
|
+
pub fn register(window: tauri::WebviewWindow, id: String) {
|
|
46
|
+
if let Some(tok) = TOKEN.lock().unwrap().clone() {
|
|
47
|
+
return deliver_token(&window, &id, &tok);
|
|
48
|
+
}
|
|
49
|
+
let my_flight;
|
|
50
|
+
{
|
|
51
|
+
let mut p = PENDING.lock().unwrap();
|
|
52
|
+
if p.is_some() {
|
|
53
|
+
return deliver_error(&window, &id, "NATIVE_ERROR", "push.register already in progress");
|
|
54
|
+
}
|
|
55
|
+
// fetch_add returns the prior value; FLIGHT now holds this flight's unique id.
|
|
56
|
+
my_flight = FLIGHT.fetch_add(1, Ordering::SeqCst) + 1;
|
|
57
|
+
*p = Some((window.clone(), id.clone()));
|
|
58
|
+
}
|
|
59
|
+
let res = window.run_on_main_thread(|| {
|
|
60
|
+
let mtm = MainThreadMarker::new().expect("run_on_main_thread guarantees main");
|
|
61
|
+
if let Err(msg) = install_hooks(mtm) {
|
|
62
|
+
if let Some((w, id)) = PENDING.lock().unwrap().take() {
|
|
63
|
+
deliver_error(&w, &id, "NATIVE_ERROR", &msg);
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let app = NSApplication::sharedApplication(mtm);
|
|
68
|
+
app.registerForRemoteNotifications();
|
|
69
|
+
eprintln!("[push] registerForRemoteNotifications requested");
|
|
70
|
+
});
|
|
71
|
+
if let Err(e) = res {
|
|
72
|
+
if let Some((w, id)) = PENDING.lock().unwrap().take() {
|
|
73
|
+
deliver_error(&w, &id, "NATIVE_ERROR", &format!("failed to reach main thread: {e}"));
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Mobile-shell parity: 25s cap (kit's push.register timeout is 30s). Only fire if THIS flight
|
|
78
|
+
// is still current — hold the PENDING lock across the check+take so a concurrent new flight
|
|
79
|
+
// can't slip in between (else a stale timer could cancel a later register).
|
|
80
|
+
std::thread::spawn(move || {
|
|
81
|
+
std::thread::sleep(std::time::Duration::from_secs(25));
|
|
82
|
+
let mut p = PENDING.lock().unwrap();
|
|
83
|
+
if timeout_should_fire(my_flight, FLIGHT.load(Ordering::SeqCst)) {
|
|
84
|
+
if let Some((w, id)) = p.take() {
|
|
85
|
+
deliver_error(&w, &id, "TIMEOUT", "APNs registration timed out");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// `push.unregister` — stop receiving remote pushes + drop the cached token.
|
|
92
|
+
pub fn unregister(window: tauri::WebviewWindow, id: String) {
|
|
93
|
+
TOKEN.lock().unwrap().take();
|
|
94
|
+
let res = window.clone().run_on_main_thread(move || {
|
|
95
|
+
let mtm = MainThreadMarker::new().expect("main");
|
|
96
|
+
NSApplication::sharedApplication(mtm).unregisterForRemoteNotifications();
|
|
97
|
+
let resp = json!({ "v": 1, "id": id, "kind": "response", "result": null });
|
|
98
|
+
deliver(&window, &resp);
|
|
99
|
+
});
|
|
100
|
+
if let Err(e) = res {
|
|
101
|
+
eprintln!("[push] unregister failed to reach main thread: {e}");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Add the two APNs delegate methods to tao's NSApplicationDelegate class (once).
|
|
106
|
+
fn install_hooks(mtm: MainThreadMarker) -> Result<(), String> {
|
|
107
|
+
if HOOKS_INSTALLED.load(Ordering::SeqCst) {
|
|
108
|
+
return Ok(());
|
|
109
|
+
}
|
|
110
|
+
let app = NSApplication::sharedApplication(mtm);
|
|
111
|
+
let delegate: *mut AnyObject = unsafe { msg_send![&*app, delegate] };
|
|
112
|
+
if delegate.is_null() {
|
|
113
|
+
return Err("no NSApplication delegate to hook".into());
|
|
114
|
+
}
|
|
115
|
+
let cls: &AnyClass = unsafe { msg_send![delegate, class] };
|
|
116
|
+
// v@:@@ = void ret, self, _cmd, NSApplication*, NSData*/NSError*.
|
|
117
|
+
let types: &CStr = c"v@:@@";
|
|
118
|
+
let ok = unsafe {
|
|
119
|
+
objc2::ffi::class_addMethod(
|
|
120
|
+
cls as *const AnyClass as *mut AnyClass,
|
|
121
|
+
sel!(application:didRegisterForRemoteNotificationsWithDeviceToken:),
|
|
122
|
+
std::mem::transmute::<
|
|
123
|
+
extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, *mut NSData),
|
|
124
|
+
unsafe extern "C-unwind" fn(),
|
|
125
|
+
>(did_register),
|
|
126
|
+
types.as_ptr(),
|
|
127
|
+
)
|
|
128
|
+
};
|
|
129
|
+
let ok2 = unsafe {
|
|
130
|
+
objc2::ffi::class_addMethod(
|
|
131
|
+
cls as *const AnyClass as *mut AnyClass,
|
|
132
|
+
sel!(application:didFailToRegisterForRemoteNotificationsWithError:),
|
|
133
|
+
std::mem::transmute::<
|
|
134
|
+
extern "C-unwind" fn(*mut AnyObject, Sel, *mut AnyObject, *mut NSError),
|
|
135
|
+
unsafe extern "C-unwind" fn(),
|
|
136
|
+
>(did_fail),
|
|
137
|
+
types.as_ptr(),
|
|
138
|
+
)
|
|
139
|
+
};
|
|
140
|
+
if !ok.as_bool() || !ok2.as_bool() {
|
|
141
|
+
return Err(format!(
|
|
142
|
+
"delegate class {} already implements an APNs selector — refusing to double-handle",
|
|
143
|
+
cls.name().to_string_lossy()
|
|
144
|
+
));
|
|
145
|
+
}
|
|
146
|
+
HOOKS_INSTALLED.store(true, Ordering::SeqCst);
|
|
147
|
+
eprintln!("[push] APNs delegate hooks installed on {}", cls.name().to_string_lossy());
|
|
148
|
+
Ok(())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
extern "C-unwind" fn did_register(_this: *mut AnyObject, _cmd: Sel, _app: *mut AnyObject, token: *mut NSData) {
|
|
152
|
+
// A null/empty NSData is a failed registration masquerading as success — never cache it or
|
|
153
|
+
// hand back an empty-string "token" (the app would upload garbage and silently get no pushes).
|
|
154
|
+
let bytes = if token.is_null() { Vec::new() } else { unsafe { (*token).to_vec() } };
|
|
155
|
+
if bytes.is_empty() {
|
|
156
|
+
eprintln!("[push] APNs delivered a null/empty device token — treating as failure");
|
|
157
|
+
if let Some((w, id)) = PENDING.lock().unwrap().take() {
|
|
158
|
+
deliver_error(&w, &id, "NATIVE_ERROR", "APNs returned an empty device token");
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
let hex = bytes.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
|
163
|
+
eprintln!("[push] APNs token: {hex}");
|
|
164
|
+
*TOKEN.lock().unwrap() = Some(hex.clone());
|
|
165
|
+
if let Some((w, id)) = PENDING.lock().unwrap().take() {
|
|
166
|
+
deliver_token(&w, &id, &hex);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
extern "C-unwind" fn did_fail(_this: *mut AnyObject, _cmd: Sel, _app: *mut AnyObject, error: *mut NSError) {
|
|
171
|
+
let msg = if error.is_null() {
|
|
172
|
+
"APNs registration failed".to_string()
|
|
173
|
+
} else {
|
|
174
|
+
unsafe { (*error).localizedDescription() }.to_string()
|
|
175
|
+
};
|
|
176
|
+
eprintln!("[push] APNs registration failed: {msg}");
|
|
177
|
+
if let Some((w, id)) = PENDING.lock().unwrap().take() {
|
|
178
|
+
deliver_error(&w, &id, "NATIVE_ERROR", &msg);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[cfg(test)]
|
|
183
|
+
mod tests {
|
|
184
|
+
use super::*;
|
|
185
|
+
|
|
186
|
+
#[test]
|
|
187
|
+
fn timeout_fires_only_for_the_current_flight() {
|
|
188
|
+
// Flight #1's timer must NOT fire once flight #2 has started (fast-fail + retry scenario).
|
|
189
|
+
assert!(timeout_should_fire(1, 1)); // still current → fire
|
|
190
|
+
assert!(!timeout_should_fire(1, 2)); // superseded → do NOT cancel the later flight
|
|
191
|
+
assert!(timeout_should_fire(2, 2));
|
|
192
|
+
}
|
|
193
|
+
}
|