@livx.cc/appwrap 0.30.0 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.30.0",
3
+ "version": "0.30.1",
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",
@@ -17,9 +17,44 @@ import { startDevMenu } from './shell/devmenu';
17
17
  import { SHELL_CONFIG } from './shell/config';
18
18
  import { bindStatusBarPage, setStatusBarStyle, applyThemeColor, enableAndroidEdgeToEdge, wireAndroidSafeArea } from './shell/status-bar';
19
19
  import { CustomWebView } from './shell/custom-webview';
20
+ import { appwrapNativeLog } from './shell/native-log';
20
21
 
21
22
  let initialized = false;
22
23
 
24
+ /** DEBUG iOS lifecycle tracer — logs NS suspend/resume/displayed AND the raw UIApplication
25
+ * activation notifications (willResignActive/didBecomeActive/didEnterBackground/willEnterForeground)
26
+ * with each connected scene's activationState, to Documents/appwrap-web.log. Diagnostic for the
27
+ * StoreKit-sheet freeze: a system sheet that only resigns-active (never backgrounds) produces a
28
+ * different event sequence than a real background, so the resume/wake path may not fire on dismiss. */
29
+ let _lifecycleTraced = false;
30
+ function traceIosLifecycle(): void {
31
+ if (_lifecycleTraced) return;
32
+ _lifecycleTraced = true;
33
+ const log = (e: string): void => {
34
+ let states = '';
35
+ try {
36
+ const scenes = UIApplication.sharedApplication.connectedScenes.allObjects;
37
+ for (let i = 0; i < scenes.count; i++) {
38
+ const s = scenes.objectAtIndex(i) as UIScene;
39
+ states += ` [${(s as any).session?.role ?? '?'}:act=${s.activationState}]`;
40
+ }
41
+ } catch { states = ' (scene-read-err)'; }
42
+ appwrapNativeLog(`[native:lifecycle] ${e}${states}`);
43
+ };
44
+ Application.on(Application.suspendEvent, () => log('NS.suspend'));
45
+ Application.on(Application.resumeEvent, () => log('NS.resume'));
46
+ Application.on(Application.displayedEvent, () => log('NS.displayed'));
47
+ const nc = NSNotificationCenter.defaultCenter;
48
+ const obs = (name: string, tag: string): void => {
49
+ nc.addObserverForNameObjectQueueUsingBlock(name, null, null, () => log(tag));
50
+ };
51
+ obs(UIApplicationWillResignActiveNotification, 'UIApp.willResignActive');
52
+ obs(UIApplicationDidBecomeActiveNotification, 'UIApp.didBecomeActive');
53
+ obs(UIApplicationDidEnterBackgroundNotification, 'UIApp.didEnterBackground');
54
+ obs(UIApplicationWillEnterForegroundNotification, 'UIApp.willEnterForeground');
55
+ appwrapNativeLog('[native:lifecycle] tracer armed');
56
+ }
57
+
23
58
  export function onPageLoaded(args: EventData): void {
24
59
  const page = args.object as Page;
25
60
  page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor };
@@ -62,7 +97,16 @@ export function onPageLoaded(args: EventData): void {
62
97
  // Halt the WebView render + JS-timer pipeline while backgrounded so a page running a continuous
63
98
  // animation (Android doesn't auto-pause rAF off-screen) stops burning CPU/battery. No-op on iOS.
64
99
  Application.on(Application.suspendEvent, () => webView.setRenderingActive(false));
65
- Application.on(Application.resumeEvent, () => webView.setRenderingActive(true));
100
+ Application.on(Application.resumeEvent, () => {
101
+ webView.setRenderingActive(true);
102
+ // iOS: wake the WebContent renderer NOW (it stays THROTTLED for ~30-45s after a full-window system
103
+ // surface — StoreKit manage-subscriptions sheet, itms-apps deep link, backgrounding — froze it).
104
+ webView.wakeWebContent();
105
+ });
106
+
107
+ // DEBUG: trace the iOS app-lifecycle event sequence to the pullable log sink so we can see WHICH
108
+ // resume signal does (or doesn't) fire when returning from the StoreKit sheet vs a normal background.
109
+ if (isIOS && SHELL_CONFIG.debug) traceIosLifecycle();
66
110
 
67
111
  // Android system back → WebView history (iOS gets this via the edge-swipe
68
112
  // gesture). Only swallow the press when there's history to pop; otherwise let
@@ -89,6 +89,12 @@ export class CustomWebView extends WebView {
89
89
  }
90
90
  }
91
91
 
92
+ /** iOS-only renderer-wake (see custom-webview.ios.ts). No-op on Android: onResume()/resumeTimers()
93
+ * in setRenderingActive already restart the WebView pipeline on foreground. */
94
+ wakeWebContent(): void {
95
+ /* iOS-only */
96
+ }
97
+
92
98
  private installDocumentStartShim(wv: android.webkit.WebView): void {
93
99
  try {
94
100
  const wkt = androidx.webkit;
@@ -74,6 +74,45 @@ export class CustomWebView extends WebView {
74
74
  /* OS-managed on iOS */
75
75
  }
76
76
 
77
+ /**
78
+ * Proactively WAKE the WebContent renderer process on foreground (wired to Application.resumeEvent
79
+ * for iOS in main-page). WHY: when a full-window system surface covers the app — StoreKit's
80
+ * showManageSubscriptions(in:) (hosted out-of-process by com.apple.ios.StoreKitUIService), the
81
+ * itms-apps subscriptions deep link, or any backgrounding — the UIScene goes non-foreground and
82
+ * WebKit THROTTLES/SUSPENDS the WKWebView's WebContent process; its JS stops and the app UI (which
83
+ * IS the WebView) freezes. On return, WebKit re-acquires the scene assertion lazily (~30-45s),
84
+ * leaving the page frozen until then. An inbound `evaluateJavaScript` is a foreground WebContent
85
+ * activity (WebPageProxy::runJavaScriptInFrameInScriptWorld) — it forces WebKit to restore the
86
+ * process NOW instead of waiting out the throttle. The injected JS also re-fires a synthetic
87
+ * `visibilitychange`/`pageshow` so a loader:'server' web app reconnects its sockets immediately
88
+ * (it would otherwise wait for WebKit to deliver the native visibility event late). No-op on Android. */
89
+ wakeWebContent(): void {
90
+ const wk = this.nativeViewProtected as WKWebView;
91
+ if (!wk) return;
92
+ if (SHELL_CONFIG.debug) appendWebLog(`[native:lifecycle] wakeWebContent run (frame ${wk.frame.size.width}x${wk.frame.size.height})`);
93
+ // (1) COMPOSITOR NUDGE — force a layout + compositing pass so a WebView that returned from an
94
+ // occluding system surface (StoreKit sheet, deep link, backgrounding) repaints + re-hit-tests NOW
95
+ // rather than waiting out WebKit's lazy restore. A 1px frame perturbation, restored in the same
96
+ // runloop. (For the StoreKit-sheet freeze the primary fix is neutralising the orphaned tracking
97
+ // window in handlers-billing/the Swift shim; this nudge + the JS wake below complete the resume the
98
+ // OS never fires for that sheet — see handlers-billing.manageSubscriptionsSheet.)
99
+ const f = wk.frame;
100
+ if (f.size.width > 0 && f.size.height > 1) {
101
+ wk.frame = CGRectMake(f.origin.x, f.origin.y, f.size.width, f.size.height - 1);
102
+ wk.frame = f;
103
+ wk.setNeedsLayout();
104
+ wk.layoutIfNeeded();
105
+ }
106
+ // (2) Re-emit visibility so the page's own visibilitychange/pageshow listeners (socket reconnect,
107
+ // session refresh) run right away. document.hidden is already false on foreground — this just
108
+ // un-blocks resume handlers that gate on the event rather than polling.
109
+ wk.evaluateJavaScriptCompletionHandler(
110
+ `try{document.dispatchEvent(new Event('visibilitychange'));` +
111
+ `window.dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true}));}catch(e){}`,
112
+ () => { /* fire-and-forget; the eval itself is the wake */ }
113
+ );
114
+ }
115
+
77
116
  createNativeView(): WKWebView {
78
117
  const config = WKWebViewConfiguration.new();
79
118
  config.allowsInlineMediaPlayback = true;
@@ -14,4 +14,10 @@ export class CustomWebView extends WebView {
14
14
  setRenderingActive(_active: boolean): void {
15
15
  /* overridden per-platform */
16
16
  }
17
+
18
+ /** iOS: wake the WebContent renderer on foreground so a system-sheet/background throttle doesn't
19
+ * leave the WebView frozen for ~30-45s (see custom-webview.ios.ts). No-op on Android. */
20
+ wakeWebContent(): void {
21
+ /* overridden per-platform */
22
+ }
17
23
  }
@@ -1,9 +1,12 @@
1
1
  import { Utils, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
+ import { appwrapNativeLog } from './native-log';
4
+ import { SHELL_CONFIG } from './config';
3
5
 
4
6
  // @objc Swift shim (AppwrapManageSubscriptions.swift, billing nativeSrc) — no NS types; bridges
5
7
  // StoreKit 2's Swift-async showManageSubscriptions(in:) to a completion the ObjC bridge can call.
6
- declare const AppwrapManageSubscriptions: { present(completion: (message: string | null) => void): void };
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 };
7
10
 
8
11
  /**
9
12
  * In-app purchases — iOS StoreKit 1 (SKPaymentQueue). StoreKit 2 (Product/Transaction)
@@ -214,7 +217,14 @@ export function registerBillingHandlers(): void {
214
217
  // deep-link path above is untouched. Rejects on iOS <15, no scene, or a StoreKit error.
215
218
  bridge.register('billing.manageSubscriptionsSheet', () => {
216
219
  return new Promise<void>((resolve, reject) => {
217
- AppwrapManageSubscriptions.present((message: string | null) => {
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();
218
228
  if (message) reject(err('NATIVE_ERROR', message));
219
229
  else resolve();
220
230
  });
@@ -7,31 +7,87 @@ import UIKit
7
7
  // callback (NativeScript can't await Swift async) and runs it on the main actor against the active
8
8
  // foreground UIWindowScene. Shipped via the billing module's nativeSrc → compiled into the app target
9
9
  // only when `billing` is active. iOS 15+ (StoreKit 2); the JS layer guards the deep-link fallback.
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.
10
19
  @objc(AppwrapManageSubscriptions)
11
20
  public final class AppwrapManageSubscriptions: NSObject {
12
- // completion(nil) on success; completion(message) on failure. Always invoked on the main thread
13
- // so the NativeScript JS continuation (and WebView response delivery) resumes on the UI thread.
14
- @objc public static func present(_ completion: @escaping (String?) -> Void) {
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) {
15
24
  guard #available(iOS 15.0, *) else {
16
- completion("StoreKit 2 manage-subscriptions sheet requires iOS 15+")
25
+ completion("StoreKit 2 manage-subscriptions sheet requires iOS 15+", nil)
17
26
  return
18
27
  }
19
28
  Task { @MainActor in
20
29
  guard let scene = AppwrapManageSubscriptions.activeWindowScene() else {
21
- completion("No active UIWindowScene to present the subscriptions sheet")
30
+ completion("No active UIWindowScene to present the subscriptions sheet", nil)
22
31
  return
23
32
  }
33
+ var err: String? = nil
24
34
  do {
25
35
  try await AppStore.showManageSubscriptions(in: scene)
26
- completion(nil)
27
36
  } catch {
28
- completion(error.localizedDescription)
37
+ err = error.localizedDescription
29
38
  }
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)
30
45
  }
31
46
  }
32
47
 
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
+
33
89
  // The foreground-active window scene (the one allowed to present UI). Falls back to any connected
34
- // window scene so a not-yet-foregroundActive launch still finds a presenter.
90
+ // window scene so a not-yet-foreground launch still finds a presenter.
35
91
  @MainActor private static func activeWindowScene() -> UIWindowScene? {
36
92
  let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
37
93
  return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first