@livx.cc/appwrap 0.54.0 → 0.56.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.
Files changed (70) hide show
  1. package/package.json +5 -4
  2. package/runtime/app/main-page.ts +0 -2
  3. package/runtime/app/shell/capabilities.manifest.ts +27 -92
  4. package/runtime/app/shell/config.ts +4 -0
  5. package/runtime/app/shell/env.ts +8 -1
  6. package/runtime/app/shell/handlers.ts +2 -2
  7. package/runtime/tests/bridge-response-delivery.test.ts +10 -1
  8. package/runtime/tests/env-scheme.test.ts +50 -0
  9. package/scripts/stage-assets.mjs +1 -1
  10. package/src/cli.ts +424 -621
  11. package/src/config.ts +8 -1
  12. package/src/handlers.ts +1 -1
  13. package/src/packs.ts +174 -0
  14. package/src/testing.ts +173 -0
  15. package/templates/module-pack/README.md +44 -0
  16. package/templates/module-pack/handler.ts +9 -0
  17. package/templates/module-pack/manifest.ts +27 -0
  18. package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
  19. package/runtime/app/shell/billing-offer.ts +0 -54
  20. package/runtime/app/shell/handlers-billing.ts +0 -662
  21. package/runtime/app/shell/handlers-health.ts +0 -178
  22. package/runtime/app/shell/handlers-widget.ts +0 -97
  23. package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
  24. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
  25. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
  26. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
  27. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
  28. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
  29. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
  30. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
  31. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
  32. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
  33. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
  34. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
  35. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
  36. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
  37. package/runtime/tests/billing-offer.test.ts +0 -61
  38. package/runtime-desktop/autotest/index.html +0 -77
  39. package/runtime-desktop/bridge-shim/build.sh +0 -9
  40. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  41. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  42. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  43. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  44. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  45. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  46. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  47. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  48. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  49. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  50. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  51. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  52. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  53. package/runtime-desktop/src-tauri/build.rs +0 -3
  54. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  55. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  56. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  57. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  58. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  59. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  60. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  61. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  62. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  63. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  64. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  65. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  66. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  67. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  68. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  69. package/src/desktop.ts +0 -300
  70. package/src/plugin/host.ts +0 -242
@@ -1,522 +0,0 @@
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
- WKFrameInfo, WKMediaCaptureType, WKNavigationAction, WKNavigationActionPolicy, WKNavigationDelegate,
28
- WKPermissionDecision, WKSecurityOrigin, WKUIDelegate, WKWebView, WKWebViewConfiguration,
29
- WKWindowFeatures,
30
- };
31
-
32
- /// When true, Google `signInWithPopup` OAuth popups get `prompt=select_account` appended so a
33
- /// remembered Google session doesn't auto-sign — the user picks an account (multi-account apps like
34
- /// Unclaw). Set once from the shell config at install(); read in the child's navigation delegate.
35
- static GOOGLE_ACCOUNT_PICKER: AtomicBool = AtomicBool::new(false);
36
-
37
- /// Rewrite a Google OAuth popup URL to force the account chooser, or None to leave it untouched.
38
- /// Matches only `accounts.google.com` OAuth authorize / sign-in endpoints that don't already carry a
39
- /// `prompt` param (so we don't clobber an app that set its own, and don't loop on our own reload).
40
- fn google_account_picker_rewrite(url: &str) -> Option<String> {
41
- let is_oauth = url.starts_with("https://accounts.google.com/o/oauth2")
42
- || url.starts_with("https://accounts.google.com/signin/oauth");
43
- if !is_oauth {
44
- return None;
45
- }
46
- let query = url.split_once('?').map(|(_, q)| q).unwrap_or("");
47
- let has_prompt = query.split('&').any(|kv| kv == "prompt" || kv.starts_with("prompt="));
48
- if has_prompt {
49
- return None;
50
- }
51
- let sep = if url.contains('?') { '&' } else { '?' };
52
- Some(format!("{url}{sep}prompt=select_account"))
53
- }
54
-
55
- /// Safari-equivalent UA (no wry/Tauri token, full `Version/… Safari/…` suffix) — Google blocks
56
- /// OAuth in webviews whose UA lacks it (disallowed_useragent). Applied to the shell webview
57
- /// (main.rs builder) and every child popup.
58
- 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";
59
-
60
- #[cfg(test)]
61
- mod tests {
62
- use super::google_account_picker_rewrite as r;
63
- #[test]
64
- fn appends_prompt_to_oauth_urls() {
65
- assert_eq!(
66
- r("https://accounts.google.com/o/oauth2/auth?client_id=x").as_deref(),
67
- Some("https://accounts.google.com/o/oauth2/auth?client_id=x&prompt=select_account")
68
- );
69
- assert_eq!(
70
- r("https://accounts.google.com/signin/oauth").as_deref(),
71
- Some("https://accounts.google.com/signin/oauth?prompt=select_account")
72
- );
73
- }
74
- #[test]
75
- fn leaves_others_untouched() {
76
- assert!(r("https://accounts.google.com/o/oauth2/auth?prompt=consent").is_none());
77
- assert!(r("https://accounts.google.com/o/oauth2/auth?x=1&prompt=none").is_none());
78
- assert!(r("https://example.com/o/oauth2/auth").is_none());
79
- assert!(r("https://myapp.firebaseapp.com/__/auth/handler").is_none());
80
- }
81
-
82
- #[test]
83
- fn descendants_resolve_transitively() {
84
- use super::descendants_of;
85
- // 1 → 2 → 3, 1 → 4; 5 hangs off an unrelated 9.
86
- let pairs = [(2, 1), (3, 2), (4, 1), (5, 9)];
87
- let mut d = descendants_of(&pairs, 1);
88
- d.sort_unstable();
89
- assert_eq!(d, vec![2, 3, 4]);
90
- assert_eq!(descendants_of(&pairs, 3), Vec::<usize>::new());
91
- assert_eq!(descendants_of(&pairs, 9), vec![5]);
92
- }
93
-
94
- #[test]
95
- fn descendants_cycle_safe() {
96
- // Degenerate cycle must terminate and not include the root.
97
- let pairs = [(2, 1), (1, 2)];
98
- assert_eq!(super::descendants_of(&pairs, 1), vec![2]);
99
- }
100
- }
101
-
102
- /// Strong refs for one open popup. Only touched on the main thread (Send impl exists purely so
103
- /// the registry can live in a static Mutex — same pattern as oauth_mac::ActiveFlow).
104
- struct Popup {
105
- window: Retained<NSWindow>,
106
- webview: Retained<WKWebView>,
107
- /// Opener WKWebView pointer (shell webview for top-level popups, another popup's webview for
108
- /// nested ones). Identity only — never dereferenced. Drives cascade-close.
109
- parent: usize,
110
- /// Google account-picker rewrite is applied AT MOST ONCE per popup — only the entry OAuth
111
- /// navigation. Otherwise Google's own redirects to chooser/error pages (which drop the `prompt`
112
- /// param) would re-match and re-trigger a cancel+reload loop. Tied to the popup so it's dropped
113
- /// with the window.
114
- rewritten: AtomicBool,
115
- /// The Delegate that created this popup (the opener's UI delegate — the global INSTALLED one for
116
- /// the main shell window, or a per-profile-window delegate). It's the KVO `title` observer we
117
- /// registered on `webview`, so teardown MUST remove the observer using THIS same object (not a
118
- /// global) or KVO throws / leaks. Held strong so it outlives the observed webview.
119
- observer: Retained<Delegate>,
120
- }
121
- unsafe impl Send for Popup {}
122
- static POPUPS: Mutex<Vec<Popup>> = Mutex::new(Vec::new());
123
-
124
- /// Single idempotent teardown for a popup. Removes the (only) strong-ref entry from POPUPS so the
125
- /// NSWindow + child WKWebView (JS timers, network) can finally be released. Both close paths route
126
- /// here: page-initiated `window.close()` (webViewDidClose:, `close_window = true` — the window is
127
- /// still open so we close it) and the user clicking the red button (windowWillClose:,
128
- /// `close_window = false` — AppKit is already closing it). Calling it twice is a no-op (the second
129
- /// path finds no entry), which is exactly what happens when either close triggers the other.
130
- /// Main-thread only (guaranteed: both callers are WK/AppKit delegate methods).
131
- // `&dyn Fn` (not `impl Fn`): cleanup_popup recurses for the cascade — a generic pred would
132
- // monomorphize a fresh closure type per recursion level (compile error: recursion limit).
133
- fn cleanup_popup(pred: &dyn Fn(&Popup) -> bool, close_window: bool) {
134
- // One critical section: remove the entry AND snapshot the remaining (key, parent) pairs, then
135
- // drop the lock BEFORE any window.close() — closing re-enters windowWillClose → cleanup_popup,
136
- // which must not deadlock on POPUPS.
137
- let (p, descendants) = {
138
- let mut popups = POPUPS.lock().unwrap();
139
- let Some(i) = popups.iter().position(|p| pred(p)) else {
140
- eprintln!("[popup] cleanup: no matching entry (already torn down)");
141
- return;
142
- };
143
- let p = popups.remove(i);
144
- let key = Retained::as_ptr(&p.webview) as usize;
145
- let pairs: Vec<(usize, usize)> = popups
146
- .iter()
147
- .map(|q| (Retained::as_ptr(&q.webview) as usize, q.parent))
148
- .collect();
149
- (p, descendants_of(&pairs, key))
150
- };
151
- // Stop observing `title` BEFORE releasing the webview (KVO throws if an observed object
152
- // deallocates with observers attached). Registered in create_web_view with p.observer as the
153
- // observer — remove with the SAME object (per-window delegates make a global lookup wrong).
154
- {
155
- let key = NSString::from_str("title");
156
- let _: () = unsafe { msg_send![&*p.webview, removeObserver: &*p.observer, forKeyPath: &*key] };
157
- }
158
- if close_window {
159
- p.window.close();
160
- }
161
- // Cascade: close this popup's descendants through the same idempotent path. Any that already
162
- // tore themselves down (e.g. their windowWillClose fired first) are a no-op above.
163
- for key in descendants {
164
- eprintln!("[popup] cascade-close child {key:#x}");
165
- cleanup_popup(&move |q: &Popup| Retained::as_ptr(&q.webview) as usize == key, true);
166
- }
167
- // p (window + webview Retained refs) drops here → last strong refs released.
168
- }
169
-
170
- /// All descendant keys of `root` in a (key, parent-key) edge list — breadth-ish order, cycle-safe.
171
- /// Pure so the registry cascade logic is unit-testable without AppKit.
172
- fn descendants_of(pairs: &[(usize, usize)], root: usize) -> Vec<usize> {
173
- let mut out: Vec<usize> = Vec::new();
174
- let mut queue = vec![root];
175
- while let Some(k) = queue.pop() {
176
- for &(child, parent) in pairs {
177
- if parent == k && child != root && !out.contains(&child) {
178
- out.push(child);
179
- queue.push(child);
180
- }
181
- }
182
- }
183
- out
184
- }
185
-
186
- /// Close every popup (main-window close / app teardown). Main-thread only — callers are tauri
187
- /// window-event handlers, which run on the main thread on macOS.
188
- pub fn close_all() {
189
- loop {
190
- let key = POPUPS
191
- .lock()
192
- .unwrap()
193
- .first()
194
- .map(|p| Retained::as_ptr(&p.webview) as usize);
195
- match key {
196
- Some(k) => cleanup_popup(&move |p: &Popup| Retained::as_ptr(&p.webview) as usize == k, true),
197
- None => break,
198
- }
199
- }
200
- }
201
-
202
- /// Strong refs to our delegate + wry's original (uiDelegate is weak on WKWebView).
203
- struct Installed(Option<(Retained<Delegate>, Retained<AnyObject>)>);
204
- unsafe impl Send for Installed {}
205
- static INSTALLED: Mutex<Installed> = Mutex::new(Installed(None));
206
-
207
- pub struct DelegateIvars {
208
- /// wry's WryWebViewUIDelegate — unhandled WKUIDelegate messages forward here.
209
- wry_delegate: Option<Retained<AnyObject>>,
210
- }
211
-
212
- define_class!(
213
- #[unsafe(super(NSObject))]
214
- #[thread_kind = MainThreadOnly]
215
- #[name = "AppwrapUIDelegate"]
216
- #[ivars = DelegateIvars]
217
- struct Delegate;
218
-
219
- unsafe impl NSObjectProtocol for Delegate {}
220
-
221
- unsafe impl WKUIDelegate for Delegate {
222
- #[unsafe(method_id(webView:createWebViewWithConfiguration:forNavigationAction:windowFeatures:))]
223
- fn create_web_view(
224
- &self,
225
- opener: &WKWebView,
226
- configuration: &WKWebViewConfiguration,
227
- action: &WKNavigationAction,
228
- features: &WKWindowFeatures,
229
- ) -> Option<Retained<WKWebView>> {
230
- let mtm = self.mtm();
231
- let url = unsafe { action.request().URL() }
232
- .and_then(|u| u.absoluteString())
233
- .map(|s| s.to_string())
234
- .unwrap_or_default();
235
-
236
- // Verification #4 proof: the SSO popup inherits the opener's data store (its profile's
237
- // cookie jar). WebKit hands us a configuration derived from the opener, so its
238
- // websiteDataStore is the SAME object as the opener's — log the pointer identity.
239
- unsafe {
240
- let opener_store = opener.configuration().websiteDataStore();
241
- let child_store = configuration.websiteDataStore();
242
- eprintln!(
243
- "[popup] shared-store check: opener={:p} child={:p} shared={}",
244
- Retained::as_ptr(&opener_store),
245
- Retained::as_ptr(&child_store),
246
- Retained::as_ptr(&opener_store) == Retained::as_ptr(&child_store),
247
- );
248
- }
249
-
250
- let width = unsafe { features.width() }.map(|n| n.doubleValue()).unwrap_or(720.0).max(200.0);
251
- let height = unsafe { features.height() }.map(|n| n.doubleValue()).unwrap_or(640.0).max(200.0);
252
- let rect = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(width, height));
253
-
254
- let style = NSWindowStyleMask::Titled
255
- | NSWindowStyleMask::Closable
256
- | NSWindowStyleMask::Miniaturizable
257
- | NSWindowStyleMask::Resizable;
258
- let window = unsafe {
259
- NSWindow::initWithContentRect_styleMask_backing_defer(
260
- mtm.alloc::<NSWindow>(),
261
- rect,
262
- style,
263
- NSBackingStoreType::Buffered,
264
- false,
265
- )
266
- };
267
- // Required for NSWindows created without a window controller; we manage lifetime.
268
- unsafe { window.setReleasedWhenClosed(false) };
269
-
270
- // MUST use the PASSED configuration — anything else breaks window.opener/session
271
- // (and WebKit raises if the returned webview wasn't made with it).
272
- let child = unsafe {
273
- WKWebView::initWithFrame_configuration(
274
- mtm.alloc::<WKWebView>(),
275
- window.frame(),
276
- configuration,
277
- )
278
- };
279
- unsafe {
280
- child.setCustomUserAgent(Some(&NSString::from_str(SAFARI_UA)));
281
- // Safari Web Inspector on window.open() child popups too (SSO flows etc.) —
282
- // gated identically (debug always; release only under APPWRAP_DEVTOOLS).
283
- crate::wkwebview_backend::set_inspectable(Retained::as_ptr(&child) as *mut WKWebView);
284
- // Route the child's own UI-delegate messages (window.close(), nested
285
- // window.open) back through us.
286
- child.setUIDelegate(Some(ProtocolObject::from_ref(self)));
287
- child.setAutoresizingMask(
288
- objc2_app_kit::NSAutoresizingMaskOptions::ViewWidthSizable
289
- | objc2_app_kit::NSAutoresizingMaskOptions::ViewHeightSizable,
290
- );
291
- // Google multi-account: intercept the popup's OAuth navigation to append
292
- // prompt=select_account (see decide_policy). Only wired when configured so the
293
- // default remember-me behavior is untouched.
294
- if GOOGLE_ACCOUNT_PICKER.load(Ordering::Relaxed) {
295
- child.setNavigationDelegate(Some(ProtocolObject::from_ref(self)));
296
- }
297
- }
298
- window.setContentView(Some(&child));
299
- // Observe the red-button (user) close so we tear the popup down too — otherwise the
300
- // NSWindow + live WKWebView leak forever (webViewDidClose: only fires for page-initiated
301
- // window.close()). setDelegate is a WEAK ref; our Delegate is held strong for the app's
302
- // lifetime in INSTALLED, so it outlives every child window.
303
- window.setDelegate(Some(ProtocolObject::from_ref(self)));
304
- window.center();
305
- window.makeKeyAndOrderFront(None);
306
-
307
- // Bind the NSWindow title to the page's real <title> (KVO on WKWebView.title) — anti-spoof
308
- // + UX; removed in cleanup_popup. Options=New(1) so the change dict carries the value
309
- // (we re-read from the webview anyway); context unused (we key off the object).
310
- unsafe {
311
- let key = NSString::from_str("title");
312
- let _: () = msg_send![
313
- &*child,
314
- addObserver: self,
315
- forKeyPath: &*key,
316
- options: 1usize,
317
- context: std::ptr::null_mut::<c_void>()
318
- ];
319
- }
320
-
321
- eprintln!("[popup] createWebView → {url} ({width}x{height})");
322
- POPUPS.lock().unwrap().push(Popup {
323
- window,
324
- webview: child.clone(),
325
- parent: opener as *const WKWebView as usize,
326
- rewritten: AtomicBool::new(false),
327
- observer: self.retain(),
328
- });
329
- // Returning the webview makes WebKit load the navigation action's request itself.
330
- Some(child)
331
- }
332
-
333
- #[unsafe(method(webViewDidClose:))]
334
- fn web_view_did_close(&self, webview: &WKWebView) {
335
- eprintln!("[popup] webViewDidClose → cleanup + close window");
336
- // Page called window.close(): window still open, so close it (windowWillClose: then
337
- // fires but finds no entry → no-op).
338
- cleanup_popup(&|p: &Popup| &*p.webview == webview, true);
339
- }
340
-
341
- // getUserMedia (mic/camera — e.g. voice mode): without this WKUIDelegate method the WebView
342
- // DENIES capture by default, so `navigator.mediaDevices.getUserMedia` rejects. We GRANT it —
343
- // the page is our own trusted (local/first-party) content. macOS still gates the actual device
344
- // behind TCC, which needs the app's Info.plist usage string (NSMicrophoneUsageDescription, added
345
- // when the `media` module is declared) + the user's one-time consent; this only lifts the
346
- // WebKit-layer denial so that TCC prompt can appear at all.
347
- #[unsafe(method(webView:requestMediaCapturePermissionForOrigin:initiatedByFrame:type:decisionHandler:))]
348
- fn request_media_capture(
349
- &self,
350
- _web_view: &WKWebView,
351
- _origin: &WKSecurityOrigin,
352
- _frame: &WKFrameInfo,
353
- _capture_type: WKMediaCaptureType,
354
- decision_handler: &block2::DynBlock<dyn Fn(WKPermissionDecision)>,
355
- ) {
356
- decision_handler.call((WKPermissionDecision::Grant,));
357
- }
358
- }
359
-
360
- // Child-popup navigation policy — only consulted when GOOGLE_ACCOUNT_PICKER wired the child's
361
- // navigationDelegate to us (create_web_view). Rewrites the Google OAuth navigation to force the
362
- // account chooser; everything else is allowed unchanged.
363
- unsafe impl WKNavigationDelegate for Delegate {
364
- #[unsafe(method(webView:decidePolicyForNavigationAction:decisionHandler:))]
365
- fn decide_policy(
366
- &self,
367
- webview: &WKWebView,
368
- action: &WKNavigationAction,
369
- handler: &block2::DynBlock<dyn Fn(WKNavigationActionPolicy)>,
370
- ) {
371
- let url = unsafe { action.request().URL() }
372
- .and_then(|u| u.absoluteString())
373
- .map(|s| s.to_string())
374
- .unwrap_or_default();
375
- // Rewrite AT MOST ONCE per popup, and only the entry OAuth navigation — consume the
376
- // once-flag lazily (only when we actually have a rewrite) so a non-OAuth first navigation
377
- // doesn't burn it. Prevents re-triggering on Google's own chooser/error redirects.
378
- if let Some(rewritten) = google_account_picker_rewrite(&url) {
379
- let claim = {
380
- let popups = POPUPS.lock().unwrap();
381
- popups
382
- .iter()
383
- .find(|p| &*p.webview == webview)
384
- .map(|p| !p.rewritten.swap(true, Ordering::Relaxed))
385
- .unwrap_or(true)
386
- };
387
- if claim {
388
- eprintln!("[popup] google account-picker: reload with prompt=select_account → {rewritten}");
389
- // Cancel THIS load, then reload the SAME child webview with the rewritten URL.
390
- // window.opener/postMessage survive a same-window navigation, so the Firebase flow
391
- // still resolves; the reloaded URL carries `prompt`, so it's allowed (no loop).
392
- handler.call((WKNavigationActionPolicy::Cancel,));
393
- if let Some(nsurl) = NSURL::URLWithString(&NSString::from_str(&rewritten)) {
394
- let req = NSURLRequest::requestWithURL(&nsurl);
395
- let _ = unsafe { webview.loadRequest(&req) };
396
- }
397
- return;
398
- }
399
- }
400
- handler.call((WKNavigationActionPolicy::Allow,));
401
- }
402
- }
403
-
404
- unsafe impl NSWindowDelegate for Delegate {
405
- #[unsafe(method(windowWillClose:))]
406
- fn window_will_close(&self, notification: &NSNotification) {
407
- let Some(obj) = notification.object() else { return };
408
- // NSNotification.object is the closing NSWindow.
409
- let window: &NSWindow = unsafe { &*(Retained::as_ptr(&obj) as *const NSWindow) };
410
- eprintln!("[popup] windowWillClose → cleanup (red-button or programmatic)");
411
- // Window is already closing (AppKit drives it) → don't re-close.
412
- cleanup_popup(&|p: &Popup| &*p.window == window, false);
413
- }
414
- }
415
-
416
- /// Forward every WKUIDelegate message we don't implement to wry's original delegate
417
- /// (file-upload open panel, media-capture permission, …).
418
- impl Delegate {
419
- /// KVO callback — only ever registered for popup WKWebViews' `title` (create_web_view), so
420
- /// no context/keyPath dispatch or super call is needed. Mirrors the page title onto the
421
- /// popup's NSWindow.
422
- #[unsafe(method(observeValueForKeyPath:ofObject:change:context:))]
423
- fn observe_value(
424
- &self,
425
- _key_path: Option<&NSString>,
426
- object: Option<&AnyObject>,
427
- _change: Option<&AnyObject>,
428
- _context: *mut c_void,
429
- ) {
430
- let Some(object) = object else { return };
431
- let key = object as *const AnyObject as usize;
432
- let (window, webview) = {
433
- let popups = POPUPS.lock().unwrap();
434
- let Some(p) = popups.iter().find(|p| Retained::as_ptr(&p.webview) as usize == key)
435
- else {
436
- return; // stale notification after teardown
437
- };
438
- (p.window.clone(), p.webview.clone())
439
- };
440
- if let Some(title) = unsafe { webview.title() } {
441
- window.setTitle(&title);
442
- }
443
- }
444
-
445
- #[unsafe(method(forwardingTargetForSelector:))]
446
- fn forwarding_target(&self, _sel: Sel) -> *mut AnyObject {
447
- match &self.ivars().wry_delegate {
448
- Some(d) => Retained::as_ptr(d) as *mut AnyObject,
449
- None => std::ptr::null_mut(),
450
- }
451
- }
452
-
453
- #[unsafe(method(respondsToSelector:))]
454
- fn responds_to(&self, sel: Sel) -> objc2::runtime::Bool {
455
- let own: bool = unsafe { msg_send![super(self), respondsToSelector: sel] };
456
- if own {
457
- return objc2::runtime::Bool::YES;
458
- }
459
- let fwd = match &self.ivars().wry_delegate {
460
- Some(d) => unsafe { msg_send![&**d, respondsToSelector: sel] },
461
- None => false,
462
- };
463
- objc2::runtime::Bool::new(fwd)
464
- }
465
- }
466
- );
467
-
468
- /// Install the wrapper on the shell's WKWebView. `webview_ptr` is tauri's
469
- /// `PlatformWebview::inner()` (a WKWebView on macOS); runs inside `with_webview`,
470
- /// i.e. on the main thread.
471
- pub fn install(webview_ptr: *mut std::ffi::c_void, google_account_picker: bool) {
472
- GOOGLE_ACCOUNT_PICKER.store(google_account_picker, Ordering::Relaxed);
473
- let Some(mtm) = MainThreadMarker::new() else {
474
- eprintln!("[popup] install called off the main thread — skipped");
475
- return;
476
- };
477
- if webview_ptr.is_null() {
478
- eprintln!("[popup] null webview ptr — skipped");
479
- return;
480
- }
481
- let webview: &WKWebView = unsafe { &*(webview_ptr as *const WKWebView) };
482
-
483
- // Keep wry's delegate alive (weak property) and as the forwarding target.
484
- let wry_delegate: Option<Retained<AnyObject>> = unsafe { webview.UIDelegate() }
485
- .map(|d| unsafe { Retained::cast_unchecked::<AnyObject>(d.retain()) });
486
- eprintln!(
487
- "[popup] wrapping uiDelegate (wry delegate present: {})",
488
- wry_delegate.is_some()
489
- );
490
-
491
- let delegate = Delegate::alloc(mtm).set_ivars(DelegateIvars { wry_delegate: wry_delegate.clone() });
492
- let delegate: Retained<Delegate> = unsafe { msg_send![super(delegate), init] };
493
- unsafe { webview.setUIDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
494
-
495
- // Shell-webview UA is normalized at BUILD time in main.rs (.user_agent) — setting it here
496
- // (setup) is too late for the first document's navigator.userAgent snapshot.
497
-
498
- INSTALLED.lock().unwrap().0 = Some((
499
- delegate,
500
- wry_delegate.unwrap_or_else(|| unsafe {
501
- Retained::cast_unchecked::<AnyObject>(NSObject::new())
502
- }),
503
- ));
504
- }
505
-
506
- /// Install a DEDICATED popup delegate on a manually-created profile window's WKWebView, and return
507
- /// it so the caller retains it for that window's lifetime (uiDelegate is a WEAK property). Unlike
508
- /// install(), this does NOT touch the global INSTALLED — every concurrent profile window keeps its
509
- /// own delegate, so SSO popups work in all of them simultaneously (not last-writer-wins).
510
- ///
511
- /// There is no wry delegate to forward to (the webview was built by us, not wry), so file-upload /
512
- /// media panels aren't forwarded — acceptable (trusted OS input is out of scope). The child popup is
513
- /// alloc'd with the PARENT's passed WKWebViewConfiguration (create_web_view), which carries the
514
- /// parent's websiteDataStore — so the popup shares the profile's cookie jar (verification #4).
515
- pub fn install_for_profile(webview_ptr: *mut std::ffi::c_void, mtm: MainThreadMarker) -> Retained<AnyObject> {
516
- let webview: &WKWebView = unsafe { &*(webview_ptr as *const WKWebView) };
517
- let delegate = Delegate::alloc(mtm).set_ivars(DelegateIvars { wry_delegate: None });
518
- let delegate: Retained<Delegate> = unsafe { msg_send![super(delegate), init] };
519
- unsafe { webview.setUIDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
520
- // Opaque to the caller (Delegate is private) — retained only for the window's lifetime.
521
- unsafe { Retained::cast_unchecked::<AnyObject>(delegate) }
522
- }
@@ -1,57 +0,0 @@
1
- // Per-profile persistent + isolated WKWebsiteDataStore identity map (THE CRUX).
2
- //
3
- // A "profile" is a named, persistent cookie/localStorage/IndexedDB jar. On macOS 14+ WebKit gives
4
- // us persistent, isolated stores via `WKWebsiteDataStore(forIdentifier:)` keyed by a stable NSUUID.
5
- // We persist a profileId->UUID map on disk so the SAME store is reattached across app quits
6
- // (persistence) while different profiles get different stores (isolation).
7
- //
8
- // Lifted verbatim from bridge_mac.rs's `mod profiles` (Phase 1 extraction).
9
- use objc2_foundation::NSUUID;
10
- use std::collections::HashMap;
11
- use std::path::PathBuf;
12
-
13
- fn map_path() -> PathBuf {
14
- let base = std::env::var("APPWRAP_PROFILE_DIR")
15
- .map(PathBuf::from)
16
- .unwrap_or_else(|_| {
17
- PathBuf::from(std::env::var("HOME").unwrap_or_default())
18
- .join("Library/Application Support/appwrap-desktop")
19
- });
20
- base.join("browser-profiles.json")
21
- }
22
-
23
- fn load() -> HashMap<String, String> {
24
- std::fs::read_to_string(map_path())
25
- .ok()
26
- .and_then(|s| serde_json::from_str(&s).ok())
27
- .unwrap_or_default()
28
- }
29
-
30
- /// Sorted list of known profile ids from the on-disk map (the dropdown source). Always includes
31
- /// "default" so the chrome always has at least one selectable profile, even on a fresh install.
32
- pub fn list() -> Vec<String> {
33
- let mut ids: Vec<String> = load().into_keys().collect();
34
- if !ids.iter().any(|k| k == "default") {
35
- ids.push("default".to_string());
36
- }
37
- ids.sort();
38
- ids.dedup();
39
- ids
40
- }
41
-
42
- /// Stable UUID string for a profile id — generated once and persisted so the
43
- /// same on-disk data store is reattached on every subsequent launch.
44
- pub fn uuid_for(profile_id: &str) -> String {
45
- let mut map = load();
46
- if let Some(u) = map.get(profile_id) {
47
- return u.clone();
48
- }
49
- let uuid = NSUUID::UUID().UUIDString().to_string();
50
- map.insert(profile_id.to_string(), uuid.clone());
51
- let p = map_path();
52
- if let Some(dir) = p.parent() {
53
- let _ = std::fs::create_dir_all(dir);
54
- }
55
- let _ = std::fs::write(&p, serde_json::to_string_pretty(&map).unwrap_or_default());
56
- uuid
57
- }