@livx.cc/appwrap 0.30.1 → 0.30.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -95,6 +95,12 @@ export class CustomWebView extends WebView {
95
95
  /* iOS-only */
96
96
  }
97
97
 
98
+ /** iOS-only post-native-surface recovery (see custom-webview.ios.ts). No-op on Android: dismissing a
99
+ * native screen returns through the normal activity resume, which already restores the WebView. */
100
+ recoverAfterNativeSurface(): void {
101
+ /* iOS-only */
102
+ }
103
+
98
104
  private installDocumentStartShim(wv: android.webkit.WebView): void {
99
105
  try {
100
106
  const wkt = androidx.webkit;
@@ -113,6 +113,56 @@ export class CustomWebView extends WebView {
113
113
  );
114
114
  }
115
115
 
116
+ /** Call after dismissing ANY native surface presented over the WebView (StoreKit sheet, OAuth
117
+ * ASWebAuthenticationSession, SFSafariViewController, pickers, share, alerts). Such surfaces can
118
+ * leave the WebView frozen: a same-scene system sheet doesn't fire resumeEvent AND can orphan an
119
+ * interactive window above ours that swallows touches. We neutralise any stray window FIRST (so the
120
+ * wake re-attaches over a clean stack), then wake. Deferred so the dismissal animation finalizes the
121
+ * orphan window first. Idempotent + safe (no stray ⇒ no-op). No-op on Android. */
122
+ recoverAfterNativeSurface(): void {
123
+ setTimeout(() => {
124
+ try { this.neutralizeStrayWindows(); } catch (e) { /* best-effort */ }
125
+ this.wakeWebContent();
126
+ }, 350);
127
+ }
128
+
129
+ /** Remove any window sitting ABOVE our main app window (a leftover system-surface window that can
130
+ * intercept touches): disable interaction, hide, and detach from the scene. Re-key our main window.
131
+ * Main = lowest-level non-hidden window with a rootViewController. Logs before/after (debug). */
132
+ private neutralizeStrayWindows(): void {
133
+ const dbg = SHELL_CONFIG.debug;
134
+ const scenes = UIApplication.sharedApplication.connectedScenes.allObjects;
135
+ for (let i = 0; i < scenes.count; i++) {
136
+ const scene = scenes.objectAtIndex(i);
137
+ if (!scene.isKindOfClass(UIWindowScene.class())) continue;
138
+ const ws = scene as UIWindowScene;
139
+ const windows = ws.windows;
140
+ let main: UIWindow | null = null;
141
+ for (let j = 0; j < windows.count; j++) {
142
+ const w = windows.objectAtIndex(j);
143
+ if (w.hidden || !w.rootViewController) continue;
144
+ if (!main || w.windowLevel < main.windowLevel) main = w;
145
+ }
146
+ for (let j = 0; j < windows.count; j++) {
147
+ const w = windows.objectAtIndex(j);
148
+ if (main && w !== main && w.windowLevel > 0 && !w.hidden) {
149
+ if (dbg) appendWebLog(`[native:recover] stray win[${j}] lvl=${w.windowLevel} uie=${w.userInteractionEnabled} key=${w.keyWindow}`);
150
+ w.userInteractionEnabled = false;
151
+ w.hidden = true;
152
+ w.windowScene = null; // detach from the scene — strongest removal
153
+ if (dbg) appendWebLog(`[native:recover] after set: hidden=${w.hidden} uie=${w.userInteractionEnabled} scene=${w.windowScene ? 'set' : 'nil'}`);
154
+ }
155
+ }
156
+ if (main) main.makeKeyAndVisible();
157
+ if (dbg) {
158
+ let s = '';
159
+ const after = ws.windows;
160
+ for (let k = 0; k < after.count; k++) { const w = after.objectAtIndex(k); s += ` [${k}]lvl${w.windowLevel}h${w.hidden}k${w.keyWindow}`; }
161
+ appendWebLog(`[native:recover] post mainKey=${main ? main.keyWindow : '?'} windows:${s}`);
162
+ }
163
+ }
164
+ }
165
+
116
166
  createNativeView(): WKWebView {
117
167
  const config = WKWebViewConfiguration.new();
118
168
  config.allowsInlineMediaPlayback = true;
@@ -20,4 +20,11 @@ export class CustomWebView extends WebView {
20
20
  wakeWebContent(): void {
21
21
  /* overridden per-platform */
22
22
  }
23
+
24
+ /** Call after dismissing ANY native surface presented over the WebView (StoreKit sheet, OAuth,
25
+ * SFSafariViewController, pickers, share, alerts). iOS: neutralises an orphaned touch-stealing
26
+ * window the surface may leave + wakes the renderer (see custom-webview.ios.ts). No-op on Android. */
27
+ recoverAfterNativeSurface(): void {
28
+ /* overridden per-platform */
29
+ }
23
30
  }
@@ -5,8 +5,7 @@ import { SHELL_CONFIG } from './config';
5
5
 
6
6
  // @objc Swift shim (AppwrapManageSubscriptions.swift, billing nativeSrc) — no NS types; bridges
7
7
  // StoreKit 2's Swift-async showManageSubscriptions(in:) to a completion the ObjC bridge can call.
8
- // completion(errorMessage, diagnostic): diagnostic is a post-dismiss window-hierarchy dump (DEBUG).
9
- declare const AppwrapManageSubscriptions: { present(completion: (message: string | null, diagnostic: string | null) => void): void };
8
+ declare const AppwrapManageSubscriptions: { present(completion: (message: string | null) => void): void };
10
9
 
11
10
  /**
12
11
  * In-app purchases — iOS StoreKit 1 (SKPaymentQueue). StoreKit 2 (Product/Transaction)
@@ -217,14 +216,12 @@ export function registerBillingHandlers(): void {
217
216
  // deep-link path above is untouched. Rejects on iOS <15, no scene, or a StoreKit error.
218
217
  bridge.register('billing.manageSubscriptionsSheet', () => {
219
218
  return new Promise<void>((resolve, reject) => {
220
- AppwrapManageSubscriptions.present((message: string | null, diagnostic: string | null) => {
221
- if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:lifecycle] manageSubscriptionsSheet done err=${message ?? 'none'}\n${diagnostic ?? '(no diag)'}`);
222
- // The StoreKit sheet presents/dismisses WITHOUT the app scene leaving foregroundActive, so iOS
223
- // never fires resumeEvent → the WebView's normal foreground wake (wakeWebContent) doesn't run.
224
- // Drive it explicitly here (the only callback that fires) to un-throttle/repaint the WebView.
225
- // (The native shim separately neutralises the orphaned StoreKit tracking-window that otherwise
226
- // floats above our window and swallows touches — together they clear the post-sheet freeze.)
227
- bridge.getWebView()?.wakeWebContent();
219
+ AppwrapManageSubscriptions.present((message: string | null) => {
220
+ if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:lifecycle] manageSubscriptionsSheet done err=${message ?? 'none'}`);
221
+ // The sheet dismisses WITHOUT the app scene leaving foregroundActive → no resumeEvent, and it
222
+ // orphans a touch-stealing window above ours. Recover from this completion (the only callback
223
+ // that fires). Shared across all native surfaces; see CustomWebView.recoverAfterNativeSurface.
224
+ bridge.getWebView()?.recoverAfterNativeSurface();
228
225
  if (message) reject(err('NATIVE_ERROR', message));
229
226
  else resolve();
230
227
  });
@@ -57,6 +57,8 @@ class PhotoPickerDelegate extends NSObject implements PHPickerViewControllerDele
57
57
  }
58
58
  pickerDidFinishPicking(picker: PHPickerViewController, results: NSArray<PHPickerResult>): void {
59
59
  picker.dismissViewControllerAnimatedCompletion(true, null);
60
+ // Recover the WebView after the picker dismisses (orphaned-window / renderer-throttle guard).
61
+ bridge.getWebView()?.recoverAfterNativeSurface();
60
62
  const result = results.count > 0 ? results.objectAtIndex(0) : null;
61
63
  if (!result) return this.onResult?.({ picked: false });
62
64
  result.itemProvider.loadObjectOfClassCompletionHandler(UIImage.class(), (img: UIImage) => {
@@ -51,6 +51,9 @@ export function registerOAuthHandlers(): void {
51
51
  (callbackURL: NSURL, error: NSError) => {
52
52
  activeSession = null;
53
53
  activeProvider = null;
54
+ // ASWebAuthenticationSession can leave the WebView frozen on dismiss the same way the
55
+ // StoreKit sheet does (orphaned tracking window / no resume) — recover from its completion.
56
+ bridge.getWebView()?.recoverAfterNativeSurface();
54
57
  if (error) {
55
58
  // ASWebAuthenticationSessionErrorCodeCanceledLogin === 1 (user dismissed the sheet).
56
59
  const code = error.code === 1 ? 'CANCELLED' : 'NATIVE_ERROR';
@@ -5,6 +5,20 @@ declare const androidx: any; // no NS types: androidx
5
5
 
6
6
  const err = (code: string, message: string) => Object.assign(new Error(message), { code });
7
7
 
8
+ // SFSafariViewController is a same-app presented surface — on "Done" it can leave the WebView frozen
9
+ // (orphaned tracking window / throttled renderer), so recover from its didFinish. Strong-ref the
10
+ // delegate while the browser is up (ARC would otherwise free it). Mirrors the OAuth delegate pattern.
11
+ let activeSafariDelegate: NSObject | null = null;
12
+ @NativeClass()
13
+ class SafariDelegate extends NSObject implements SFSafariViewControllerDelegate {
14
+ static ObjCProtocols = [SFSafariViewControllerDelegate];
15
+ static new(): SafariDelegate { return <SafariDelegate>super.new(); }
16
+ safariViewControllerDidFinish(_controller: SFSafariViewController): void {
17
+ activeSafariDelegate = null;
18
+ bridge.getWebView()?.recoverAfterNativeSurface();
19
+ }
20
+ }
21
+
8
22
  /** Running on a simulator/emulator? (mirrors env.ts detection; kept local to avoid a cross-import). */
9
23
  function detectEmulator(): boolean {
10
24
  if (isIOS) {
@@ -190,6 +204,8 @@ export function registerSystemHandlers(): void {
190
204
  Utils.dispatchToMainThread(() => {
191
205
  const vc = SFSafariViewController.alloc().initWithURL(NSURL.URLWithString(target));
192
206
  if (toolbarColor) vc.preferredControlTintColor = new Color(String(toolbarColor)).ios;
207
+ activeSafariDelegate = SafariDelegate.new();
208
+ vc.delegate = activeSafariDelegate as SFSafariViewControllerDelegate;
193
209
  Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(vc, true, null);
194
210
  });
195
211
  } else if (isAndroid) {
@@ -88,6 +88,9 @@ export function registerHandlers(): void {
88
88
  if (controller.popoverPresentationController) {
89
89
  controller.popoverPresentationController.sourceView = rootVC.view;
90
90
  }
91
+ // Recover the WebView on dismiss — a presented sheet can orphan a touch-stealing window / leave
92
+ // the renderer throttled (see CustomWebView.recoverAfterNativeSurface).
93
+ controller.completionWithItemsHandler = () => { bridge.getWebView()?.recoverAfterNativeSurface(); };
91
94
  rootVC.presentViewControllerAnimatedCompletion(controller, true, null);
92
95
  } else if (isAndroid) {
93
96
  const intent = new android.content.Intent(android.content.Intent.ACTION_SEND);
@@ -5,87 +5,36 @@ import UIKit
5
5
  // StoreKit 2's `AppStore.showManageSubscriptions(in:)` is Swift-only + async, so it can't be reached
6
6
  // from the ObjC bridge NativeScript uses. This @objc wrapper bridges the async call to a completion
7
7
  // callback (NativeScript can't await Swift async) and runs it on the main actor against the active
8
- // foreground UIWindowScene. Shipped via the billing module's nativeSrc → compiled into the app target
9
- // only when `billing` is active. iOS 15+ (StoreKit 2); the JS layer guards the deep-link fallback.
8
+ // foreground UIWindowScene. Shipped via the billing module's nativeSrc → compiled only when `billing`
9
+ // is active. iOS 15+ (StoreKit 2); the JS layer guards the deep-link fallback.
10
10
  //
11
- // THE FREEZE IT FIXES: showManageSubscriptions(in:) presents/dismisses WITHOUT the app scene ever
12
- // leaving foregroundActive (verified: zero app-lifecycle events fire across present→dismiss), so (a)
13
- // the OS never fires a resume the WebView could wake on, and (b) the dismissal leaves an orphaned
14
- // interactive UITrackingElementWindow at windowLevel 1 ABOVE our app window that swallows every touch —
15
- // the app underneath stays alive but unresponsive ("frozen for minutes" until iOS reaps the window).
16
- // We recover explicitly from THIS completion (the only thing that fires): after letting the dismissal
17
- // settle, neutralise any stray above-our-main window (uie=false + hide) and re-key our window. The JS
18
- // layer additionally drives the WebView's own resume (wakeWebContent) since no native resume arrives.
11
+ // NOTE: this sheet presents/dismisses WITHOUT the app scene leaving foregroundActive, so iOS fires no
12
+ // resumeEvent and leaves an orphaned interactive tracking-window above ours that swallows touches. The
13
+ // JS handler recovers from THIS completion via `CustomWebView.recoverAfterNativeSurface()` (shared with
14
+ // every other native surface) — so this shim only needs to present + report success/failure.
19
15
  @objc(AppwrapManageSubscriptions)
20
16
  public final class AppwrapManageSubscriptions: NSObject {
21
- // completion(error, diagnostic): error nil on success / message on failure; diagnostic is a
22
- // before/after window-hierarchy dump (DEBUG aid) the JS layer logs to the file sink. Always on main.
23
- @objc public static func present(_ completion: @escaping (String?, String?) -> Void) {
17
+ // completion(nil) on success; completion(message) on failure. Always invoked on the main thread so
18
+ // the NS JS continuation (which then drives the WebView recovery) resumes on the UI thread.
19
+ @objc public static func present(_ completion: @escaping (String?) -> Void) {
24
20
  guard #available(iOS 15.0, *) else {
25
- completion("StoreKit 2 manage-subscriptions sheet requires iOS 15+", nil)
21
+ completion("StoreKit 2 manage-subscriptions sheet requires iOS 15+")
26
22
  return
27
23
  }
28
24
  Task { @MainActor in
29
25
  guard let scene = AppwrapManageSubscriptions.activeWindowScene() else {
30
- completion("No active UIWindowScene to present the subscriptions sheet", nil)
26
+ completion("No active UIWindowScene to present the subscriptions sheet")
31
27
  return
32
28
  }
33
- var err: String? = nil
34
29
  do {
35
30
  try await AppStore.showManageSubscriptions(in: scene)
31
+ completion(nil)
36
32
  } catch {
37
- err = error.localizedDescription
33
+ completion(error.localizedDescription)
38
34
  }
39
- let before = AppwrapManageSubscriptions.dump(scene, tag: "BEFORE")
40
- // The await can return before the dismissal animation finishes finalizing the orphan tracking
41
- // window — settle first, then neutralise it (hiding at completion-time was too early to stick).
42
- try? await Task.sleep(nanoseconds: 350_000_000)
43
- let after = AppwrapManageSubscriptions.recover(scene)
44
- completion(err, before + "\n" + after)
45
35
  }
46
36
  }
47
37
 
48
- // Read-only dump of the scene's windows (+ presented-VC chain, key/interaction flags) for diagnosis.
49
- @available(iOS 15.0, *)
50
- @MainActor private static func dump(_ scene: UIWindowScene, tag: String) -> String {
51
- var lines: [String] = ["\(tag): windows=\(scene.windows.count) keyWin=\(scene.keyWindow.map { ptr($0) } ?? "nil")"]
52
- for (i, w) in scene.windows.enumerated() {
53
- var presented: [String] = []
54
- var vc = w.rootViewController
55
- while let p = vc?.presentedViewController { presented.append(String(describing: type(of: p))); vc = p }
56
- lines.append(" [\(i)] \(ptr(w)) level=\(w.windowLevel.rawValue) key=\(w.isKeyWindow) hidden=\(w.isHidden) uie=\(w.isUserInteractionEnabled) root=\(w.rootViewController.map { String(describing: type(of: $0)) } ?? "nil") presented=\(presented.isEmpty ? "none" : presented.joined(separator: ">"))")
57
- }
58
- return lines.joined(separator: "\n")
59
- }
60
-
61
- // Neutralise any window sitting ABOVE our main app window (touch-stealer), dismiss a lingering
62
- // presented controller, and re-assert our main window as key. Returns the post-recovery dump.
63
- @available(iOS 15.0, *)
64
- @MainActor private static func recover(_ scene: UIWindowScene) -> String {
65
- var lines: [String] = []
66
- let main = scene.windows
67
- .filter { !$0.isHidden && $0.rootViewController != nil }
68
- .min { $0.windowLevel.rawValue < $1.windowLevel.rawValue }
69
- for (i, w) in scene.windows.enumerated() {
70
- if let presented = w.rootViewController?.presentedViewController {
71
- presented.dismiss(animated: false)
72
- lines.append(" recover: dismissed \(String(describing: type(of: presented))) on win[\(i)]")
73
- }
74
- if let m = main, w !== m, w.windowLevel.rawValue >= m.windowLevel.rawValue, w.windowLevel.rawValue > 0 {
75
- // Pass touches THROUGH to our app window below, and take it out of the hierarchy.
76
- w.isUserInteractionEnabled = false
77
- w.isHidden = true
78
- lines.append(" recover: neutralised win[\(i)] level=\(w.windowLevel.rawValue) root=\(w.rootViewController.map { String(describing: type(of: $0)) } ?? "nil")")
79
- }
80
- }
81
- if let m = main { m.makeKeyAndVisible(); lines.append(" recover: re-keyed main") }
82
- return AppwrapManageSubscriptions.dump(scene, tag: "AFTER-RECOVER") + "\n" + lines.joined(separator: "\n")
83
- }
84
-
85
- @MainActor private static func ptr(_ w: UIWindow) -> String {
86
- return "UIWindow<\(UInt(bitPattern: ObjectIdentifier(w).hashValue) & 0xffffff)>"
87
- }
88
-
89
38
  // The foreground-active window scene (the one allowed to present UI). Falls back to any connected
90
39
  // window scene so a not-yet-foreground launch still finds a presenter.
91
40
  @MainActor private static func activeWindowScene() -> UIWindowScene? {