@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 +1 -1
- package/runtime/app/main-page.ts +45 -1
- package/runtime/app/shell/custom-webview.android.ts +6 -0
- package/runtime/app/shell/custom-webview.ios.ts +39 -0
- package/runtime/app/shell/custom-webview.ts +6 -0
- package/runtime/app/shell/handlers-billing.ts +12 -2
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +64 -8
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -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, () =>
|
|
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
|
-
|
|
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
|
});
|
package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift
CHANGED
|
@@ -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(
|
|
13
|
-
//
|
|
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
|
-
|
|
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-
|
|
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
|