@livx.cc/appwrap 0.30.1 → 0.32.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.
- package/package.json +1 -1
- package/runtime/app/shell/custom-webview.android.ts +6 -0
- package/runtime/app/shell/custom-webview.ios.ts +50 -0
- package/runtime/app/shell/custom-webview.ts +7 -0
- package/runtime/app/shell/handlers-billing.ts +7 -10
- package/runtime/app/shell/handlers-extended.ts +2 -0
- package/runtime/app/shell/handlers-oauth.ts +3 -0
- package/runtime/app/shell/handlers-push.ts +20 -6
- package/runtime/app/shell/handlers-system.ts +16 -0
- package/runtime/app/shell/handlers.ts +3 -0
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +13 -64
- package/runtime/package.json +1 -1
- package/src/cli.ts +293 -33
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
221
|
-
if (SHELL_CONFIG.debug) appwrapNativeLog(`[native:lifecycle] manageSubscriptionsSheet done err=${message ?? 'none'}
|
|
222
|
-
// The
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
|
|
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';
|
|
@@ -19,10 +19,24 @@ import { SHELL_CONFIG } from './config';
|
|
|
19
19
|
let pendingRegister: { resolve: (t: PushToken) => void; reject: (e: Error) => void } | null = null;
|
|
20
20
|
let cachedToken: string | null = null;
|
|
21
21
|
|
|
22
|
-
interface PushToken { platform: 'apns' | 'fcm'; token: string; }
|
|
22
|
+
interface PushToken { platform: 'apns' | 'fcm'; token: string; topic?: string; }
|
|
23
|
+
|
|
24
|
+
/** The app's bundle/package id — the APNs `apns-topic` MUST equal it or APNs rejects with
|
|
25
|
+
* `DeviceTokenNotForTopic`. iOS: NSBundle bundle id; Android: the application package id (FCM
|
|
26
|
+
* ignores apns-topic, so it's informational there). Resolved natively so consumers never hardcode it. */
|
|
27
|
+
function appTopic(): string | undefined {
|
|
28
|
+
try {
|
|
29
|
+
if (isIOS) return NSBundle.mainBundle.bundleIdentifier;
|
|
30
|
+
if (isAndroid) return Utils.android.getApplicationContext().getPackageName();
|
|
31
|
+
} catch (e: any) {
|
|
32
|
+
console.log('[push] bundle-id resolve failed: ' + (e?.message ?? e));
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
23
36
|
|
|
24
37
|
/** POST the device token to the app's configured backend NATIVELY (no WKWebView fetch → no app://
|
|
25
|
-
* cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured.
|
|
38
|
+
* cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured.
|
|
39
|
+
* Includes `topic` (the bundle/package id) so the backend can set the correct apns-topic header. */
|
|
26
40
|
function registerTokenWithBackend(platform: 'ios' | 'android', token: string): void {
|
|
27
41
|
const url = SHELL_CONFIG.pushRegistrationUrl;
|
|
28
42
|
if (!url) return;
|
|
@@ -31,7 +45,7 @@ function registerTokenWithBackend(platform: 'ios' | 'android', token: string): v
|
|
|
31
45
|
try {
|
|
32
46
|
Http.request({
|
|
33
47
|
url, method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
34
|
-
content: JSON.stringify({ token, platform }),
|
|
48
|
+
content: JSON.stringify({ token, platform, topic: appTopic() }),
|
|
35
49
|
})
|
|
36
50
|
.then((res) => console.log('[push] token registered with backend → ' + res.statusCode))
|
|
37
51
|
.catch((e: any) => console.log('[push] backend register failed: ' + (e?.message ?? e)));
|
|
@@ -48,7 +62,7 @@ export function onApnsToken(hexToken: string): void {
|
|
|
48
62
|
console.log('[push] APNs token: ' + hexToken);
|
|
49
63
|
registerTokenWithBackend('ios', hexToken);
|
|
50
64
|
if (pendingRegister) {
|
|
51
|
-
pendingRegister.resolve({ platform: 'apns', token: hexToken });
|
|
65
|
+
pendingRegister.resolve({ platform: 'apns', token: hexToken, topic: appTopic() });
|
|
52
66
|
pendingRegister = null;
|
|
53
67
|
}
|
|
54
68
|
}
|
|
@@ -143,7 +157,7 @@ export function registerPushHandlers(): void {
|
|
|
143
157
|
bridge.register('push.register', () => {
|
|
144
158
|
if (isIOS) {
|
|
145
159
|
return new Promise<PushToken>((resolve, reject) => {
|
|
146
|
-
if (cachedToken) return resolve({ platform: 'apns', token: cachedToken });
|
|
160
|
+
if (cachedToken) return resolve({ platform: 'apns', token: cachedToken, topic: appTopic() });
|
|
147
161
|
pendingRegister = { resolve, reject };
|
|
148
162
|
Utils.dispatchToMainThread(() => UIApplication.sharedApplication.registerForRemoteNotifications());
|
|
149
163
|
setTimeout(() => {
|
|
@@ -235,7 +249,7 @@ function androidGetToken(): Promise<PushToken> {
|
|
|
235
249
|
if (t.isSuccessful()) {
|
|
236
250
|
const tok = String(t.getResult());
|
|
237
251
|
registerTokenWithBackend('android', tok); // parity with iOS onApnsToken
|
|
238
|
-
resolve({ platform: 'fcm', token: tok });
|
|
252
|
+
resolve({ platform: 'fcm', token: tok, topic: appTopic() });
|
|
239
253
|
} else reject(Object.assign(new Error('FCM token fetch failed'), { code: 'NATIVE_ERROR' }));
|
|
240
254
|
},
|
|
241
255
|
}));
|
|
@@ -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);
|
package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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(
|
|
22
|
-
//
|
|
23
|
-
@objc public static func present(_ completion: @escaping (String
|
|
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+"
|
|
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"
|
|
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
|
-
|
|
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? {
|
package/runtime/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
|
|
10
10
|
*/
|
|
11
11
|
import { execFileSync } from 'child_process';
|
|
12
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
12
|
+
import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
|
|
13
13
|
import { networkInterfaces, tmpdir } from 'os';
|
|
14
14
|
import { dirname, join, resolve } from 'path';
|
|
15
15
|
import { pathToFileURL } from 'url';
|
|
@@ -336,11 +336,17 @@ async function readConfigFile(configPath: string): Promise<AppwrapConfig> {
|
|
|
336
336
|
return cfg as AppwrapConfig;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
339
|
+
/** Resolve the user's appwrap config file path the CLI loads from: explicit --config wins;
|
|
340
|
+
* otherwise probe ts → js → json (TS preferred). Single source of discovery (reused by the
|
|
341
|
+
* team-id pin-to-config writer so it never re-invents the probe). */
|
|
342
|
+
function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
|
|
343
|
+
return flags.config
|
|
342
344
|
? resolve(cwd, flags.config)
|
|
343
345
|
: (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
|
|
349
|
+
const configPath = resolveConfigPath(cwd, flags);
|
|
344
350
|
if (!existsSync(configPath)) {
|
|
345
351
|
console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
|
|
346
352
|
process.exit(1);
|
|
@@ -463,15 +469,31 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
|
|
|
463
469
|
writeFileSync(plist, src);
|
|
464
470
|
}
|
|
465
471
|
|
|
466
|
-
function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
|
|
472
|
+
function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; configPath: string }): void {
|
|
467
473
|
const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
474
|
+
// Resolution order: a real (non-placeholder) cfg.teamId wins; else $APPWRAP_TEAM_ID (headless/CI);
|
|
475
|
+
// else the enriched interactive picker (which then offers to pin its choice to the config). This
|
|
476
|
+
// intercepts BEFORE `ns build` runs so NS never shows its plain, unenriched prompt.
|
|
477
|
+
const isPlaceholder = !cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId);
|
|
478
|
+
if (isPlaceholder) {
|
|
479
|
+
const envTeam = process.env.APPWRAP_TEAM_ID?.trim();
|
|
480
|
+
if (envTeam) {
|
|
481
|
+
cfg.teamId = envTeam;
|
|
482
|
+
console.log(` team ← ${envTeam} (from $APPWRAP_TEAM_ID)`);
|
|
483
|
+
} else if (!process.stdout.isTTY) {
|
|
484
|
+
console.warn(`⚠ teamId is unset — set it in appwrap.config, set $APPWRAP_TEAM_ID, or run interactively to pick from your teams.`);
|
|
485
|
+
return;
|
|
486
|
+
} else {
|
|
487
|
+
const picked = pickTeamIdInteractively();
|
|
488
|
+
cfg.teamId = picked.teamId;
|
|
489
|
+
// Offer to persist the choice so the user isn't re-prompted on every deploy. No-TTY/headless is
|
|
490
|
+
// already handled above; promptYesNo additionally guards against a non-interactive stdin.
|
|
491
|
+
if (ctx && promptYesNo(` Pin "${picked.name} (${picked.teamId})" to appwrap.config so you're not asked again?`, true)) {
|
|
492
|
+
pinTeamIdToConfig(ctx.configPath, picked.teamId);
|
|
493
|
+
} else {
|
|
494
|
+
console.log(` ⓘ To skip this prompt: set teamId: "${picked.teamId}" in appwrap.config (or set $APPWRAP_TEAM_ID).`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
475
497
|
}
|
|
476
498
|
if (!existsSync(xcconfig)) return;
|
|
477
499
|
let src = readFileSync(xcconfig, 'utf8');
|
|
@@ -948,7 +970,7 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void
|
|
|
948
970
|
* Excludes the first-time scaffold (managed-guard, CI, .gitignore) + overrides/version-manifest, which the
|
|
949
971
|
* callers sequence around this so overrides win LAST and the marker writes after.
|
|
950
972
|
*/
|
|
951
|
-
function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean } = {}): void {
|
|
973
|
+
function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean; flags?: Record<string, string> } = {}): void {
|
|
952
974
|
const req = nativeReqs(cfg);
|
|
953
975
|
if (opts.firstRun && !req.explicit) {
|
|
954
976
|
console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
|
|
@@ -964,7 +986,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
964
986
|
stampShellConfig(outDir, cfg);
|
|
965
987
|
stampNativeScriptConfig(outDir, cfg);
|
|
966
988
|
stampIOSDisplayName(outDir, cfg, req);
|
|
967
|
-
stampTeamId(outDir, cfg);
|
|
989
|
+
stampTeamId(outDir, cfg, { cwd, configPath: resolveConfigPath(cwd, opts.flags ?? {}) });
|
|
968
990
|
stampAndroidAppName(outDir, cfg, req);
|
|
969
991
|
stampAndroidVersion(outDir, cfg);
|
|
970
992
|
stampAndroidGradleDeps(outDir, req.androidGradleDeps);
|
|
@@ -1004,7 +1026,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1004
1026
|
|
|
1005
1027
|
console.log(`🎁 appwrap init → ${outDir}`);
|
|
1006
1028
|
mkdirSync(outDir, { recursive: true });
|
|
1007
|
-
regenerateCore(cwd, outDir, cfg, { firstRun: true });
|
|
1029
|
+
regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
|
|
1008
1030
|
copyCiTemplates(cwd, outDir, cfg); // first-time scaffold (never overwrites)
|
|
1009
1031
|
writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
|
|
1010
1032
|
applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
|
|
@@ -1022,7 +1044,7 @@ async function sync(cwd: string, flags: Record<string, string>): Promise<void> {
|
|
|
1022
1044
|
console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
|
|
1023
1045
|
process.exit(1);
|
|
1024
1046
|
}
|
|
1025
|
-
regenerateCore(cwd, outDir, cfg);
|
|
1047
|
+
regenerateCore(cwd, outDir, cfg, { flags });
|
|
1026
1048
|
applyOverrides(cwd, outDir, cfg); // overrides win last
|
|
1027
1049
|
stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
|
|
1028
1050
|
console.log('✓ Synced.');
|
|
@@ -1121,6 +1143,7 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
|
|
|
1121
1143
|
}
|
|
1122
1144
|
}
|
|
1123
1145
|
|
|
1146
|
+
interface AppleTeam { teamId: string; name: string; email?: string; paid: boolean }
|
|
1124
1147
|
interface DeviceInfo { id: string; name: string; model: string; transport: string }
|
|
1125
1148
|
|
|
1126
1149
|
/** The subset of a `xcrun devicectl list devices --json-output` device entry appwrap reads. */
|
|
@@ -1131,6 +1154,217 @@ interface DevicectlDevice {
|
|
|
1131
1154
|
connectionProperties?: { tunnelState?: string; transportType?: string };
|
|
1132
1155
|
}
|
|
1133
1156
|
|
|
1157
|
+
/** Read Apple team metadata from provisioning profiles + distribution certs in the keychain.
|
|
1158
|
+
* Provisioning profiles give us the reliable teamId↔teamName mapping; distribution certs
|
|
1159
|
+
* often embed the account email in the display name. */
|
|
1160
|
+
function detectAppleTeams(): AppleTeam[] {
|
|
1161
|
+
const teams = new Map<string, AppleTeam>();
|
|
1162
|
+
|
|
1163
|
+
// 1. Provisioning profiles → teamId + teamName (most reliable)
|
|
1164
|
+
const profilesDir = join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles');
|
|
1165
|
+
if (existsSync(profilesDir)) {
|
|
1166
|
+
try {
|
|
1167
|
+
const files = readdirSync(profilesDir).filter((f) => f.endsWith('.mobileprovision'));
|
|
1168
|
+
for (const f of files) {
|
|
1169
|
+
try {
|
|
1170
|
+
const raw = execFileSync('security', ['cms', '-D', '-i', join(profilesDir, f)],
|
|
1171
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1172
|
+
const idMatch = raw.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/);
|
|
1173
|
+
const nameMatch = raw.match(/<key>TeamName<\/key>\s*<string>([^<]+)<\/string>/);
|
|
1174
|
+
if (idMatch && nameMatch) {
|
|
1175
|
+
const teamId = idMatch[1];
|
|
1176
|
+
const name = nameMatch[1];
|
|
1177
|
+
const free = /personal team/i.test(name);
|
|
1178
|
+
if (!teams.has(teamId)) teams.set(teamId, { teamId, name, paid: !free });
|
|
1179
|
+
}
|
|
1180
|
+
} catch { /* skip unreadable profile */ }
|
|
1181
|
+
}
|
|
1182
|
+
} catch { /* skip if dir unreadable */ }
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// 2. Keychain distribution/Developer-ID certs → teamId + possible email in name
|
|
1186
|
+
try {
|
|
1187
|
+
const out = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'],
|
|
1188
|
+
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1189
|
+
for (const line of out.split('\n')) {
|
|
1190
|
+
const m = line.match(/"(?:Apple Distribution|Developer ID Application): (.+?) \(([A-Z0-9]{10})\)"/);
|
|
1191
|
+
if (!m) continue;
|
|
1192
|
+
const [, label, teamId] = m;
|
|
1193
|
+
const email = label.includes('@') ? label.trim() : undefined;
|
|
1194
|
+
const existing = teams.get(teamId);
|
|
1195
|
+
if (existing) {
|
|
1196
|
+
if (email && !existing.email) existing.email = email;
|
|
1197
|
+
} else {
|
|
1198
|
+
teams.set(teamId, { teamId, name: label.trim(), email, paid: !/personal team/i.test(label) });
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
} catch { /* keychain unavailable */ }
|
|
1202
|
+
|
|
1203
|
+
return [...teams.values()];
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/** Arrow-key interactive selector. Returns the index of the chosen item. */
|
|
1207
|
+
function arrowSelect(prompt: string, items: string[]): number {
|
|
1208
|
+
const tty = openSync('/dev/tty', 'r+');
|
|
1209
|
+
const write = (s: string) => writeSync(tty, s);
|
|
1210
|
+
const ESC = '\x1b';
|
|
1211
|
+
|
|
1212
|
+
write(`${prompt}\n`);
|
|
1213
|
+
let idx = 0;
|
|
1214
|
+
const HINT = '\x1b[2m ↑↓ / j k to move · Enter to confirm\x1b[0m';
|
|
1215
|
+
const render = (clear: boolean) => {
|
|
1216
|
+
if (clear) write(`\x1b[${items.length + 1}A`); // +1 for the hint line
|
|
1217
|
+
for (let i = 0; i < items.length; i++)
|
|
1218
|
+
write(`\r\x1b[K${i === idx ? '❯ ' : ' '}${items[i]}\n`);
|
|
1219
|
+
write(`\r\x1b[K${HINT}\n`);
|
|
1220
|
+
};
|
|
1221
|
+
render(false);
|
|
1222
|
+
|
|
1223
|
+
// raw mode via stty
|
|
1224
|
+
execFileSync('stty', ['-icanon', '-echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
|
|
1225
|
+
const buf = Buffer.alloc(6);
|
|
1226
|
+
try {
|
|
1227
|
+
for (;;) {
|
|
1228
|
+
const n = readSync(tty, buf, 0, 6, null);
|
|
1229
|
+
const key = buf.slice(0, n).toString();
|
|
1230
|
+
if (key === `${ESC}[A` || key === 'k') { idx = (idx - 1 + items.length) % items.length; render(true); }
|
|
1231
|
+
else if (key === `${ESC}[B` || key === 'j') { idx = (idx + 1) % items.length; render(true); }
|
|
1232
|
+
else if (key === '\r' || key === '\n') break;
|
|
1233
|
+
else if (key === '\x03') { write('\n'); process.exit(1); } // Ctrl-C
|
|
1234
|
+
}
|
|
1235
|
+
} finally {
|
|
1236
|
+
execFileSync('stty', ['icanon', 'echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
|
|
1237
|
+
// Erase the hint line so the selected value prints cleanly after
|
|
1238
|
+
write(`\x1b[1A\r\x1b[K`);
|
|
1239
|
+
write('\n');
|
|
1240
|
+
closeSync(tty);
|
|
1241
|
+
}
|
|
1242
|
+
return idx;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** Interactively prompt for an Apple team when teamId is unset. Shows enriched metadata
|
|
1246
|
+
* (email, paid/free) sourced from local keychain + provisioning profiles. */
|
|
1247
|
+
function pickTeamIdInteractively(): { teamId: string; name: string } {
|
|
1248
|
+
const teams = detectAppleTeams();
|
|
1249
|
+
if (teams.length === 0) {
|
|
1250
|
+
console.error('✖ No Apple signing teams found in keychain/provisioning profiles.\n' +
|
|
1251
|
+
' Sign into Xcode → Settings → Accounts, then re-run.');
|
|
1252
|
+
process.exit(1);
|
|
1253
|
+
}
|
|
1254
|
+
if (teams.length === 1) {
|
|
1255
|
+
const t = teams[0];
|
|
1256
|
+
console.log(` team ← ${t.name} (${t.teamId})${t.email ? ` <${t.email}>` : ''} [${t.paid ? 'paid' : 'free'}] (only option)`);
|
|
1257
|
+
return { teamId: t.teamId, name: t.name };
|
|
1258
|
+
}
|
|
1259
|
+
const items = teams.map((t) => {
|
|
1260
|
+
const badge = t.paid ? '✓ paid' : '○ free';
|
|
1261
|
+
const email = t.email ? ` <${t.email}>` : '';
|
|
1262
|
+
return `${t.name} (${t.teamId})${email} [${badge}]`;
|
|
1263
|
+
});
|
|
1264
|
+
const idx = arrowSelect('Found multiple Apple teams — pick one to use for signing:', items);
|
|
1265
|
+
return { teamId: teams[idx].teamId, name: teams[idx].name };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** Y/n confirmation on the TTY (default-yes here). Reuses the global `prompt` primitive. A
|
|
1269
|
+
* non-interactive / piped stdin returns null → falls back to `def` ONLY when there's a real TTY;
|
|
1270
|
+
* a fully headless run never reaches here (callers gate on `process.stdout.isTTY` first), but be
|
|
1271
|
+
* defensive: if stdin can't be read, do NOT pin (safer to re-ask than to silently mutate config). */
|
|
1272
|
+
function promptYesNo(message: string, def: boolean): boolean {
|
|
1273
|
+
if (!process.stdin.isTTY) return false;
|
|
1274
|
+
const suffix = def ? ' [Y/n] ' : ' [y/N] ';
|
|
1275
|
+
const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(message + suffix);
|
|
1276
|
+
if (ans == null) return false;
|
|
1277
|
+
const a = ans.trim().toLowerCase();
|
|
1278
|
+
if (a === '') return def;
|
|
1279
|
+
return a === 'y' || a === 'yes';
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
/** Persist `teamId` into the user's appwrap config so the interactive picker isn't re-run every
|
|
1283
|
+
* deploy. Pure string surgery (returns the new file content) so it's unit-testable across both
|
|
1284
|
+
* supported formats:
|
|
1285
|
+
* - `.json` — set/replace the top-level `"teamId"` property (preserves 2-space indent).
|
|
1286
|
+
* - `.ts`/`.js` — replace an existing `teamId:` field value (incl. the `YOUR_APPLE_TEAM_ID`
|
|
1287
|
+
* placeholder), else insert a new `teamId: '<id>',` line near the other top-level fields
|
|
1288
|
+
* (after `id:`, matching its indentation/quote style). If the shape is unexpected, returns
|
|
1289
|
+
* `null` so the caller skips the write rather than corrupting the file. */
|
|
1290
|
+
export function pinTeamIdInConfigSource(src: string, teamId: string, isJson: boolean): string | null {
|
|
1291
|
+
if (isJson) {
|
|
1292
|
+
let obj: Record<string, unknown>;
|
|
1293
|
+
try { obj = JSON.parse(src) as Record<string, unknown>; } catch { return null; }
|
|
1294
|
+
if (typeof obj !== 'object' || obj == null) return null;
|
|
1295
|
+
obj.teamId = teamId;
|
|
1296
|
+
return JSON.stringify(obj, null, 2) + (src.endsWith('\n') ? '\n' : '');
|
|
1297
|
+
}
|
|
1298
|
+
// TS/JS: replace an existing teamId field value, preserving its quote style.
|
|
1299
|
+
const existing = /(\bteamId\s*:\s*)(['"`])[^'"`]*\2/;
|
|
1300
|
+
if (existing.test(src)) {
|
|
1301
|
+
return src.replace(existing, (_m, lead: string, q: string) => `${lead}${q}${teamId}${q}`);
|
|
1302
|
+
}
|
|
1303
|
+
// No teamId field — insert after the `id:` field (mirroring its indentation + quote style).
|
|
1304
|
+
const idLine = /^([ \t]*)id\s*:\s*(['"`])[^'"`]*\2\s*,?[ \t]*$/m;
|
|
1305
|
+
const m = idLine.exec(src);
|
|
1306
|
+
if (!m) return null; // unfamiliar shape — don't risk corrupting it
|
|
1307
|
+
const indent = m[1];
|
|
1308
|
+
const quote = m[2];
|
|
1309
|
+
return src.slice(0, m.index + m[0].length)
|
|
1310
|
+
+ `\n${indent}teamId: ${quote}${teamId}${quote},`
|
|
1311
|
+
+ src.slice(m.index + m[0].length);
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
/** Write the pinned teamId to the resolved config file (thin IO wrapper over the pure helper). */
|
|
1315
|
+
function pinTeamIdToConfig(configPath: string, teamId: string): void {
|
|
1316
|
+
if (!existsSync(configPath)) {
|
|
1317
|
+
console.warn(` ⚠ could not pin teamId — config not found at ${configPath}`);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
const src = readFileSync(configPath, 'utf8');
|
|
1321
|
+
const next = pinTeamIdInConfigSource(src, teamId, configPath.endsWith('.json'));
|
|
1322
|
+
if (next == null) {
|
|
1323
|
+
console.warn(` ⚠ couldn't safely edit ${configPath} (unexpected shape) — leaving it untouched. Set teamId: '${teamId}' manually.`);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
writeFileSync(configPath, next);
|
|
1327
|
+
console.log(` ✓ pinned teamId: '${teamId}' to ${configPath}`);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ── Build fingerprint for smart resume ───────────────────────────────────────────────────────────
|
|
1331
|
+
|
|
1332
|
+
/** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config.
|
|
1333
|
+
* App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
|
|
1334
|
+
* changes and would make the fingerprint permanently stale.
|
|
1335
|
+
* Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
|
|
1336
|
+
function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: string): string {
|
|
1337
|
+
const mtime = (p: string): number => {
|
|
1338
|
+
if (!existsSync(p)) return 0;
|
|
1339
|
+
try {
|
|
1340
|
+
const s = statSync(p);
|
|
1341
|
+
if (s.isDirectory()) {
|
|
1342
|
+
let sum = 0;
|
|
1343
|
+
for (const e of readdirSync(p, { withFileTypes: true }))
|
|
1344
|
+
sum += mtime(join(p, e.name));
|
|
1345
|
+
return sum;
|
|
1346
|
+
}
|
|
1347
|
+
return s.mtimeMs;
|
|
1348
|
+
} catch { return 0; }
|
|
1349
|
+
};
|
|
1350
|
+
const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
|
|
1351
|
+
const parts = [mtime(distDir), mtime(join(cwd, 'appwrap.config.ts'))];
|
|
1352
|
+
// Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
|
|
1353
|
+
let h = 5381;
|
|
1354
|
+
for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
|
|
1355
|
+
return h.toString(36);
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const BUILD_CACHE_FILE = '.appwrap-build-cache.json';
|
|
1359
|
+
interface BuildCache { fingerprint: string; ipaPath: string; builtAt: string }
|
|
1360
|
+
|
|
1361
|
+
function readBuildCache(outDir: string): BuildCache | null {
|
|
1362
|
+
try { return JSON.parse(readFileSync(join(outDir, BUILD_CACHE_FILE), 'utf8')); } catch { return null; }
|
|
1363
|
+
}
|
|
1364
|
+
function writeBuildCache(outDir: string, cache: BuildCache): void {
|
|
1365
|
+
try { writeFileSync(join(outDir, BUILD_CACHE_FILE), JSON.stringify(cache, null, 2)); } catch { /* non-fatal */ }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1134
1368
|
/** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
|
|
1135
1369
|
* tunnels and non-iOS (watch). Returns [] if none. */
|
|
1136
1370
|
function listIosDevices(): DeviceInfo[] {
|
|
@@ -1199,27 +1433,53 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
|
|
|
1199
1433
|
await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
|
|
1200
1434
|
// Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
|
|
1201
1435
|
stampShellConfig(outDir, { ...cfg, debug: true });
|
|
1202
|
-
console.log('▶ ns build ios --for-device (debug: keep-awake + inspector on)');
|
|
1203
|
-
try {
|
|
1204
|
-
execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
|
|
1205
|
-
} catch (e) {
|
|
1206
|
-
// The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
|
|
1207
|
-
console.error(
|
|
1208
|
-
'\n✖ Device build failed — if the errors above mention signing:\n' +
|
|
1209
|
-
` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
|
|
1210
|
-
' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
|
|
1211
|
-
' unique string and re-deploy.\n' +
|
|
1212
|
-
' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
|
|
1213
|
-
' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
|
|
1214
|
-
' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
|
|
1215
|
-
);
|
|
1216
|
-
process.exit(1);
|
|
1217
|
-
}
|
|
1218
1436
|
|
|
1219
1437
|
const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
|
|
1220
|
-
|
|
1438
|
+
|
|
1439
|
+
// Smart resume: skip ns build (pod install + xcodebuild) when inputs haven't changed.
|
|
1440
|
+
// --resume (-r): opt in to fingerprint-based skip (same logic as auto, but explicit — useful when
|
|
1441
|
+
// the auto check has no prior cache yet and you want to force a skip on first run after a manual build).
|
|
1442
|
+
// Auto: always checks fingerprint; never skips if sources/deps changed.
|
|
1443
|
+
const resume = 'resume' in flags || 'r' in flags;
|
|
1444
|
+
const force = 'force' in flags || 'f' in flags;
|
|
1445
|
+
const fp = buildFingerprint(cwd, cfg, outDir);
|
|
1446
|
+
const cache = readBuildCache(outDir);
|
|
1447
|
+
const existingIpa = existsSync(ipaDir)
|
|
1448
|
+
? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
|
|
1449
|
+
: undefined;
|
|
1450
|
+
const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.ipaPath === join(ipaDir, existingIpa);
|
|
1451
|
+
// --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
|
|
1452
|
+
// but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
|
|
1453
|
+
const noCache = existingIpa && !cache;
|
|
1454
|
+
const canSkipBuild = !force && (fingerprintMatch || (resume && noCache));
|
|
1455
|
+
|
|
1456
|
+
if (canSkipBuild) {
|
|
1457
|
+
const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
|
|
1458
|
+
console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
|
|
1459
|
+
} else {
|
|
1460
|
+
console.log(`▶ ns build ios --for-device (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
|
|
1461
|
+
try {
|
|
1462
|
+
execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
|
|
1463
|
+
} catch (e) {
|
|
1464
|
+
// The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
|
|
1465
|
+
console.error(
|
|
1466
|
+
'\n✖ Device build failed — if the errors above mention signing:\n' +
|
|
1467
|
+
` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
|
|
1468
|
+
' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
|
|
1469
|
+
' unique string and re-deploy.\n' +
|
|
1470
|
+
' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
|
|
1471
|
+
' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
|
|
1472
|
+
' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
|
|
1473
|
+
);
|
|
1474
|
+
process.exit(1);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const builtIpa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
|
|
1479
|
+
const ipa = builtIpa;
|
|
1221
1480
|
if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
|
|
1222
1481
|
const ipaPath = join(ipaDir, ipa);
|
|
1482
|
+
if (!canSkipBuild) writeBuildCache(outDir, { fingerprint: fp, ipaPath, builtAt: new Date().toISOString() });
|
|
1223
1483
|
|
|
1224
1484
|
console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
|
|
1225
1485
|
let installedViaUsbmux = false;
|