@livx.cc/appwrap 0.30.0 → 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.0",
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",
@@ -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,18 @@ 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
+
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
+
92
104
  private installDocumentStartShim(wv: android.webkit.WebView): void {
93
105
  try {
94
106
  const wkt = androidx.webkit;
@@ -74,6 +74,95 @@ 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
+
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
+
77
166
  createNativeView(): WKWebView {
78
167
  const config = WKWebViewConfiguration.new();
79
168
  config.allowsInlineMediaPlayback = true;
@@ -14,4 +14,17 @@ 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
+ }
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
+ }
17
30
  }
@@ -1,5 +1,7 @@
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.
@@ -215,6 +217,11 @@ export function registerBillingHandlers(): void {
215
217
  bridge.register('billing.manageSubscriptionsSheet', () => {
216
218
  return new Promise<void>((resolve, reject) => {
217
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();
218
225
  if (message) reject(err('NATIVE_ERROR', message));
219
226
  else resolve();
220
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,12 +5,17 @@ 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
+ //
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.
10
15
  @objc(AppwrapManageSubscriptions)
11
16
  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.
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.
14
19
  @objc public static func present(_ completion: @escaping (String?) -> Void) {
15
20
  guard #available(iOS 15.0, *) else {
16
21
  completion("StoreKit 2 manage-subscriptions sheet requires iOS 15+")
@@ -31,7 +36,7 @@ public final class AppwrapManageSubscriptions: NSObject {
31
36
  }
32
37
 
33
38
  // 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.
39
+ // window scene so a not-yet-foreground launch still finds a presenter.
35
40
  @MainActor private static func activeWindowScene() -> UIWindowScene? {
36
41
  let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
37
42
  return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first