@livx.cc/appwrap 0.32.0 → 0.36.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.32.0",
3
+ "version": "0.36.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",
@@ -55,6 +55,32 @@ function traceIosLifecycle(): void {
55
55
  appwrapNativeLog('[native:lifecycle] tracer armed');
56
56
  }
57
57
 
58
+ /** iOS-only GLOBAL native-surface freeze recovery. Registers ONE observer on
59
+ * UIWindowDidBecomeHiddenNotification: whenever ANY window becomes hidden — which happens when ANY
60
+ * native modal/drawer/sheet dismisses (StoreKit manage-subscriptions sheet, OAuth session, share,
61
+ * pickers, Safari, alerts) — we run CustomWebView.recoverAfterNativeSurface(). That covers the
62
+ * pathological case where a same-scene system sheet dismisses with ZERO app-lifecycle events (no
63
+ * resumeEvent, no background) yet orphans an interactive UITrackingElementWindow above ours that
64
+ * swallows touches → WebView alive but frozen.
65
+ *
66
+ * WHY this generalizes the per-handler wiring: future native surfaces need NO per-handler call —
67
+ * the dismiss inherently hides a window, so this fires. The 5 existing per-handler calls remain
68
+ * (proven baseline); they become redundant once this observer is DEVICE-VERIFIED to fire on the
69
+ * StoreKit-sheet dismiss and catch the orphan at ~350ms.
70
+ *
71
+ * SAFE/CONSERVATIVE: recoverAfterNativeSurface is additive + idempotent (no stray window ⇒ no-op),
72
+ * COALESCES a notification burst (several windows hide per dismiss) into ONE pass, and only acts on a
73
+ * window's HIDE (dismiss), never on a window appearing — so legitimate window stacking is untouched. */
74
+ let _surfaceRecoveryArmed = false;
75
+ function armNativeSurfaceRecovery(webView: CustomWebView): void {
76
+ if (_surfaceRecoveryArmed) return;
77
+ _surfaceRecoveryArmed = true;
78
+ NSNotificationCenter.defaultCenter.addObserverForNameObjectQueueUsingBlock(
79
+ UIWindowDidBecomeHiddenNotification, null, null, () => webView.recoverAfterNativeSurface()
80
+ );
81
+ if (SHELL_CONFIG.debug) appwrapNativeLog('[native:recover] global UIWindowDidBecomeHidden observer armed');
82
+ }
83
+
58
84
  export function onPageLoaded(args: EventData): void {
59
85
  const page = args.object as Page;
60
86
  page.bindingContext = { backgroundColor: SHELL_CONFIG.backgroundColor };
@@ -90,6 +116,7 @@ export function onPageLoaded(args: EventData): void {
90
116
 
91
117
  const webView = page.getViewById<CustomWebView>('webview');
92
118
  bridge.attach(webView);
119
+ if (isIOS) armNativeSurfaceRecovery(webView); // global freeze-recovery on any native modal dismiss
93
120
  if (isAndroid) wireAndroidSafeArea(webView); // experimental edge-to-edge (no-op unless config on)
94
121
  startEventForwarding();
95
122
  loadBundle(webView);
@@ -254,6 +254,37 @@ export const MODULES: ModuleManifest[] = [
254
254
  nativeSrc: 'health',
255
255
  },
256
256
 
257
+ // ── tracking — App Tracking Transparency (iOS) — opt-in, STRIPPABLE (own handler + group) ──
258
+ // The native-only store-compliance seam for cross-company tracking (IDFA / cross-app identity):
259
+ // Apple REQUIRES the ATT prompt + NSUserTrackingUsageDescription and forbids tracking before
260
+ // consent. iOS-only (`ios:true`/`android:false`) — Android has NO ATT (the kit reports the cap
261
+ // 'none' there and degrades honestly). Stamping NSUserTrackingUsageDescription is what tells Apple
262
+ // the app tracks, so it's gated behind THIS module being active (no string = Apple assumes none).
263
+ // The CLI also flips the privacy manifest's NSPrivacyTracking → true + fills NSPrivacyTrackingDomains
264
+ // (from config `trackingDomains`) only when this module is active. iOS links
265
+ // AppTrackingTransparency.framework lazily via the runtime FFI (no extra link flag needed for a
266
+ // weak-import system framework referenced through NativeScript's interop). No native deps.
267
+ {
268
+ name: 'tracking', group: 'tracking',
269
+ capabilities: { tracking: { ios: true, android: false } },
270
+ ios: { permissions: [{ key: 'NSUserTrackingUsageDescription', domain: 'tracking', defaultUsage: 'Allow tracking to deliver a more personalized experience and measure ad performance.' }] },
271
+ },
272
+
273
+ // ── appleSignIn — native Sign in with Apple (iOS ASAuthorization) — opt-in, STRIPPABLE ──
274
+ // The native account-sheet alternative to the web-OAuth path: ASAuthorizationAppleIDProvider uses the
275
+ // App ID (bundle) and returns the identityToken + nonce DIRECTLY — no Services ID, no https Return URL,
276
+ // no browser redirect (which Apple rejects for custom-scheme redirect_uris). The PWA feeds the result
277
+ // to Firebase signInWithCredential('apple.com', { idToken, rawNonce }). iOS-only (`ios:true`/
278
+ // `android:false`) — Sign in with Apple has NO native Android SDK (the kit reports 'none' there and the
279
+ // app falls back to its web Apple auth). Stamps the `com.apple.developer.applesignin` entitlement ONLY
280
+ // when active (gated, like push's aps-environment) — a non-Apple-SignIn build signs without it. No
281
+ // permission string. Strippable own handler `handlers-apple-signin.ts` (in OPTIONAL_GROUPS).
282
+ {
283
+ name: 'appleSignIn', group: 'appleSignIn',
284
+ capabilities: { appleSignIn: { ios: true, android: false } },
285
+ ios: { entitlements: { 'com.apple.developer.applesignin': ['Default'] } },
286
+ },
287
+
257
288
  // ── backgroundTask — headless background execution (HEADLESS JS HANDLER) — opt-in, STRIPPABLE ──
258
289
  // The OS wakes the app (possibly cold, no visible WebView) for a permitted task id; the shell builds
259
290
  // an OFFSCREEN WebView, loads the app conveying the id (the handshake reports it), awaits the JS
@@ -273,7 +304,7 @@ export const MODULES: ModuleManifest[] = [
273
304
 
274
305
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
275
306
  * groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
276
- export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'backgroundTask'] as const;
307
+ export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask'] as const;
277
308
 
278
309
  /** Resolve the active capability map for the handshake from a set of active capability names. */
279
310
  export function buildCapabilityMap(
@@ -113,14 +113,25 @@ export class CustomWebView extends WebView {
113
113
  );
114
114
  }
115
115
 
116
+ /** Coalesce flag: at most one pending recovery pass at a time (see recoverAfterNativeSurface). */
117
+ private _recoveryPending = false;
118
+
116
119
  /** Call after dismissing ANY native surface presented over the WebView (StoreKit sheet, OAuth
117
120
  * ASWebAuthenticationSession, SFSafariViewController, pickers, share, alerts). Such surfaces can
118
121
  * leave the WebView frozen: a same-scene system sheet doesn't fire resumeEvent AND can orphan an
119
122
  * interactive window above ours that swallows touches. We neutralise any stray window FIRST (so the
120
123
  * 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. */
124
+ * orphan window first. Idempotent + safe (no stray ⇒ no-op). No-op on Android.
125
+ *
126
+ * COALESCED: a single dismiss fires UIWindowDidBecomeHiddenNotification multiple times and several
127
+ * windows can hide at once (the global observer in main-page.ts), so we collapse a burst to ONE pass
128
+ * via _recoveryPending rather than stacking dozens of 350ms timers. The per-handler callers also
129
+ * route through this, so handler + observer firing for the same dismiss = one recovery. */
122
130
  recoverAfterNativeSurface(): void {
131
+ if (this._recoveryPending) return;
132
+ this._recoveryPending = true;
123
133
  setTimeout(() => {
134
+ this._recoveryPending = false;
124
135
  try { this.neutralizeStrayWindows(); } catch (e) { /* best-effort */ }
125
136
  this.wakeWebContent();
126
137
  }, 350);
@@ -17,30 +17,54 @@ export function startDevMenu(): void {
17
17
  else if (isAndroid) startAndroidShake();
18
18
  }
19
19
 
20
- // ── shake detection (total acceleration magnitude in g incl. gravity, debounced) ──
21
- const SHAKE_G = 1.8; // spike vs. ~1g rest
20
+ // ── shake detection — deliberate, robust against incidental motion (walking, bumps) ──
21
+ // A real "shake to open" is a sustained back-and-forth: several HARD spikes, close together, that
22
+ // REVERSE direction each time. Walking/jostle produces occasional same-direction spikes with gaps —
23
+ // so we require (a) a high threshold, (b) direction reversal between consecutive spikes, and (c) a
24
+ // short inter-spike gap that RESETS the count if you pause. The bar is a firm wrist-shake, not a step.
25
+ const SHAKE_G = 2.3; // hard spike vs. ~1g rest (was 1.8 — too low, walking cleared it)
26
+ const SHAKE_SPIKES = 4; // direction-reversing spikes needed (≈2 full back-and-forth shakes)
27
+ const SHAKE_GAP_MS = 400; // max gap between spikes; a longer pause resets the streak
28
+ const MENU_DEBOUNCE_MS = 1500; // don't re-open right after a menu
22
29
  let shakeMm: CMMotionManager | null = null; // hold a ref so CoreMotion isn't GC'd
23
- let firstSpikeAt = 0;
24
30
  let spikes = 0;
31
+ let lastSpikeAt = 0;
32
+ let lastSpikeDir = 0; // sign of the dominant axis at the last spike; reversal = real shake
25
33
  let lastMenuAt = 0;
26
34
 
27
- function onAccelMagnitude(g: number): void {
35
+ /** @param g total accel magnitude in g (rest ≈ 1)
36
+ * @param dir sign (+1/-1) of the dominant-axis acceleration — used to detect back-and-forth reversal */
37
+ function onAccelMagnitude(g: number, dir: number): void {
28
38
  if (g < SHAKE_G) return;
29
39
  const now = Date.now();
30
- if (now - lastMenuAt < 1500) return; // don't re-open right after a menu
31
- if (now - firstSpikeAt > 800) {
32
- firstSpikeAt = now; // start a fresh window
40
+ if (now - lastMenuAt < MENU_DEBOUNCE_MS) return;
41
+ // A spike that's too soon after the last counts as the SAME thrust, not a new one — ignore it so a
42
+ // single hard jolt's ringing doesn't rack up the count.
43
+ if (now - lastSpikeAt < 80) return;
44
+ // Reset the streak unless this spike continues a fast, direction-reversing sequence.
45
+ if (now - lastSpikeAt > SHAKE_GAP_MS || dir === lastSpikeDir) {
33
46
  spikes = 1;
34
- return;
47
+ } else {
48
+ spikes++;
35
49
  }
36
- if (++spikes >= 2) {
50
+ lastSpikeAt = now;
51
+ lastSpikeDir = dir;
52
+ if (spikes >= SHAKE_SPIKES) {
37
53
  spikes = 0;
38
- firstSpikeAt = 0;
54
+ lastSpikeAt = 0;
55
+ lastSpikeDir = 0;
39
56
  lastMenuAt = now;
40
57
  void showDevMenu();
41
58
  }
42
59
  }
43
60
 
61
+ /** Sign of the largest-magnitude component — the axis the shake is thrusting along. */
62
+ function dominantDir(x: number, y: number, z: number): number {
63
+ const ax = Math.abs(x), ay = Math.abs(y), az = Math.abs(z);
64
+ const v = ax >= ay && ax >= az ? x : ay >= az ? y : z;
65
+ return v >= 0 ? 1 : -1;
66
+ }
67
+
44
68
  function startIOSShake(): void {
45
69
  // Poll `mm.deviceMotion` on a JS timer — CoreMotion's queue-handler block is fragile under
46
70
  // NativeScript (can silently stop firing on-device); mirrors the motion.start handler.
@@ -52,10 +76,12 @@ function startIOSShake(): void {
52
76
  setInterval(() => {
53
77
  const m = mm.deviceMotion;
54
78
  if (!m) return; // first sample not ready
55
- const x = m.userAcceleration.x + m.gravity.x; // total accel in g (rest ≈ 1)
56
- const y = m.userAcceleration.y + m.gravity.y;
57
- const z = m.userAcceleration.z + m.gravity.z;
58
- onAccelMagnitude(Math.sqrt(x * x + y * y + z * z));
79
+ const ux = m.userAcceleration.x, uy = m.userAcceleration.y, uz = m.userAcceleration.z;
80
+ const x = ux + m.gravity.x; // total accel in g (rest ≈ 1) — magnitude includes gravity
81
+ const y = uy + m.gravity.y;
82
+ const z = uz + m.gravity.z;
83
+ // Direction from gravity-free userAcceleration (gravity is constant → would mask the back-and-forth).
84
+ onAccelMagnitude(Math.sqrt(x * x + y * y + z * z), dominantDir(ux, uy, uz));
59
85
  }, 100);
60
86
  }
61
87
 
@@ -66,11 +92,19 @@ function startAndroidShake(): void {
66
92
  const accel = sm.getDefaultSensor(android.hardware.Sensor.TYPE_ACCELEROMETER);
67
93
  if (!accel) return;
68
94
  const G = 9.80665;
95
+ // Low-pass gravity estimate so we can derive gravity-free direction (mirrors iOS userAcceleration);
96
+ // the raw accelerometer includes gravity, which is constant and would mask the back-and-forth.
97
+ const grav = [0, 0, 0];
98
+ const alpha = 0.8;
69
99
  const listener = new android.hardware.SensorEventListener({
70
100
  onAccuracyChanged() {},
71
101
  onSensorChanged(e: android.hardware.SensorEvent) {
72
102
  const v = e.values;
73
- onAccelMagnitude(Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) / G);
103
+ grav[0] = alpha * grav[0] + (1 - alpha) * v[0];
104
+ grav[1] = alpha * grav[1] + (1 - alpha) * v[1];
105
+ grav[2] = alpha * grav[2] + (1 - alpha) * v[2];
106
+ const lx = v[0] - grav[0], ly = v[1] - grav[1], lz = v[2] - grav[2]; // linear (gravity-free)
107
+ onAccelMagnitude(Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) / G, dominantDir(lx, ly, lz));
74
108
  },
75
109
  });
76
110
  sm.registerListener(listener, accel, android.hardware.SensorManager.SENSOR_DELAY_UI);
@@ -26,7 +26,18 @@ export function onDeepLink(url: string): void {
26
26
  // plumbing, not an app deep link, so it's swallowed here and never forwarded to the PWA.
27
27
  if (deepLinkInterceptor?.(url)) return;
28
28
  if (pwaReady) bridge.emit('deeplink.open', { url });
29
- else pendingDeepLink = url; // buffer until the PWA handshakes
29
+ else pendingDeepLink = url; // buffer until the PWA handshakes — delivered IN the handshake response
30
+ }
31
+
32
+ /**
33
+ * Hand the cold-start deep link back IN the handshake response (read-once), so the PWA knows the
34
+ * target route BEFORE first paint and routes immediately — no `/home` flash, no fragile event timer.
35
+ * Returns null when the launch wasn't from a link (or it was already consumed / delivered warm).
36
+ */
37
+ export function consumePendingDeepLink(): string | null {
38
+ const url = pendingDeepLink;
39
+ pendingDeepLink = null;
40
+ return url;
30
41
  }
31
42
 
32
43
  /** A home-screen shortcut was activated (iOS performActionForShortcutItem / cold-start launchOptions;
@@ -47,18 +58,15 @@ export function onPushTap(payload: { data: Record<string, string> }): void {
47
58
  }
48
59
 
49
60
  /**
50
- * The PWA completed app.handshake → its JS is live and registers its
51
- * lifecycle listeners right after kit.ready() resolves. Flush any deep link
52
- * that arrived during launch (cold start), after a beat for listener install.
61
+ * The PWA completed app.handshake → its JS is live and registers its lifecycle listeners right after
62
+ * kit.ready() resolves. A cold-start DEEP LINK is delivered IN the handshake response itself (see
63
+ * `consumePendingDeepLink`), so the PWA routes before first paint — it is NOT flushed here. Push taps
64
+ * and shortcuts still flush as events (after a beat for listener install) — they aren't route-shaped,
65
+ * so a flash isn't a concern and the event path is the established contract.
53
66
  */
54
67
  export function onPwaHandshake(): void {
55
68
  if (pwaReady) return;
56
69
  pwaReady = true;
57
- if (pendingDeepLink) {
58
- const url = pendingDeepLink;
59
- pendingDeepLink = null;
60
- setTimeout(() => bridge.emit('deeplink.open', { url }), 500);
61
- }
62
70
  if (pendingPushTap) {
63
71
  const payload = pendingPushTap;
64
72
  pendingPushTap = null;
@@ -0,0 +1,169 @@
1
+ import { Utils, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { sha256Hex } from './sha256';
4
+
5
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
6
+
7
+ // Keep strong refs while the system sheet is up — ARC frees the controller + its delegate + context
8
+ // provider the moment the JS locals fall out of scope, which silently cancels the flow mid-handshake
9
+ // (same lesson as handlers-oauth.ts `activeSession`/`activeProvider`).
10
+ let activeController: ASAuthorizationController | null = null;
11
+ let activeDelegate: AppleSignInDelegate | null = null;
12
+ let activeProvider: AppleSignInContextProvider | null = null;
13
+
14
+ @NativeClass()
15
+ class AppleSignInContextProvider extends NSObject implements ASAuthorizationControllerPresentationContextProviding {
16
+ static ObjCProtocols = [ASAuthorizationControllerPresentationContextProviding];
17
+ static new(): AppleSignInContextProvider {
18
+ return <AppleSignInContextProvider>super.new();
19
+ }
20
+ presentationAnchorForAuthorizationController(_controller: ASAuthorizationController): UIWindow {
21
+ return (
22
+ Utils.ios.getRootViewController()?.view?.window ??
23
+ UIApplication.sharedApplication.keyWindow
24
+ );
25
+ }
26
+ }
27
+
28
+ /** Decode an Apple-returned NSData JWT/code blob into a UTF-8 string (identityToken/authorizationCode). */
29
+ function dataToUtf8(data: NSData | null): string | undefined {
30
+ if (!data) return undefined;
31
+ const s = NSString.alloc().initWithDataEncoding(data, NSUTF8StringEncoding);
32
+ return s ? String(s) : undefined;
33
+ }
34
+
35
+ @NativeClass()
36
+ class AppleSignInDelegate extends NSObject implements ASAuthorizationControllerDelegate {
37
+ static ObjCProtocols = [ASAuthorizationControllerDelegate];
38
+ // Per-call settlement (set right after `.new()`). The shared `settle` clears the ARC refs + recovers
39
+ // the WebView before resolving/rejecting, so it runs exactly once per flow.
40
+ rawNonce = '';
41
+ settle!: (fn: () => void) => void;
42
+ resolve!: (v: unknown) => void;
43
+ reject!: (e: unknown) => void;
44
+ static new(): AppleSignInDelegate {
45
+ return <AppleSignInDelegate>super.new();
46
+ }
47
+
48
+ authorizationControllerDidCompleteWithAuthorization(
49
+ _controller: ASAuthorizationController,
50
+ authorization: ASAuthorization
51
+ ): void {
52
+ const cred = authorization.credential as ASAuthorizationAppleIDCredential;
53
+ const identityToken = dataToUtf8(cred.identityToken ?? null);
54
+ if (!identityToken) {
55
+ this.settle(() => this.reject(err('NATIVE_ERROR', 'appleSignIn: Apple returned no identityToken')));
56
+ return;
57
+ }
58
+ const authorizationCode = dataToUtf8(cred.authorizationCode ?? null);
59
+
60
+ // First-authorization-only profile. Apple omits name/email on subsequent sign-ins per Apple ID.
61
+ let name: { givenName?: string; familyName?: string; displayName?: string } | undefined;
62
+ if (cred.fullName) {
63
+ const givenName = cred.fullName.givenName || undefined;
64
+ const familyName = cred.fullName.familyName || undefined;
65
+ // OS-composed display name (locale-aware) — convenience; the consumer can also build its own.
66
+ const composed = NSPersonNameComponentsFormatter.localizedStringFromPersonNameComponentsStyleOptions(
67
+ cred.fullName,
68
+ NSPersonNameComponentsFormatterStyle.Default,
69
+ 0 as NSPersonNameComponentsFormatterOptions
70
+ );
71
+ const displayName = (composed && String(composed)) || undefined;
72
+ if (givenName || familyName || displayName) name = { givenName, familyName, displayName };
73
+ }
74
+ const email = cred.email || undefined;
75
+ const user = name || email ? { ...(name ? { name } : {}), ...(email ? { email } : {}) } : undefined;
76
+
77
+ this.settle(() =>
78
+ this.resolve({ identityToken, authorizationCode, nonce: this.rawNonce, ...(user ? { user } : {}) })
79
+ );
80
+ }
81
+
82
+ authorizationControllerDidCompleteWithError(_controller: ASAuthorizationController, error: NSError): void {
83
+ // ASAuthorizationError.Canceled === 1001 (user dismissed the sheet) → resolve cancelled, never throw.
84
+ if (error.code === ASAuthorizationError.Canceled) {
85
+ this.settle(() => this.resolve({ cancelled: true }));
86
+ return;
87
+ }
88
+ this.settle(() => this.reject(err('NATIVE_ERROR', error.localizedDescription ?? 'appleSignIn failed')));
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Native Sign in with Apple (iOS, `appleSignIn` module). Strippable own-handler file (registered only
94
+ * when the module is active) — a build without it compiles NO ASAuthorization code and stamps no
95
+ * entitlement. Presents `ASAuthorizationController` (the system account sheet) and returns the Apple
96
+ * `identityToken` + raw nonce DIRECTLY to JS, so the PWA can do `signInWithCredential` on Firebase.
97
+ *
98
+ * The caller passes a RAW nonce; we send SHA256(nonce) to Apple (Apple binds the id_token to the hash)
99
+ * and return the raw nonce unchanged so the web app hands `rawNonce` to Firebase.
100
+ *
101
+ * Name/email come back ONLY on the first authorization per Apple ID — we relay them when present.
102
+ * Cancel resolves `{ cancelled: true }` (never throws), matching the scanner/oauth contract.
103
+ *
104
+ * iOS-only: the capability is gated `ios:true`/`android:false`, so the kit short-circuits off iOS
105
+ * before reaching the bridge. The isIOS guard is defence-in-depth.
106
+ *
107
+ * DEVICE-VERIFIED (iPhone 13 Pro Max, 2026-06-24, hello-pwa under paid team RDYDSWE9RB): the native
108
+ * ASAuthorization sheet presented, returned a real Apple-signed identityToken (JWT) + the raw nonce +
109
+ * first-auth user email, and dismissed without freezing (recoverAfterNativeSurface held). The
110
+ * com.apple.developer.applesignin entitlement signs only on a paid team — the free personal team can't
111
+ * hold the Sign-in-with-Apple capability (same class as push aps-environment).
112
+ */
113
+ export function registerAppleSignInHandlers(): void {
114
+ if (!isIOS) return;
115
+
116
+ bridge.register(
117
+ 'appleSignIn.signIn',
118
+ ({ nonce, scopes }: { nonce: string; scopes?: Array<'name' | 'email'> }) =>
119
+ new Promise((resolve, reject) => {
120
+ const rawNonce = String(nonce ?? '');
121
+ if (!rawNonce) { reject(err('NATIVE_ERROR', 'appleSignIn.signIn: empty nonce')); return; }
122
+ const want = new Set(scopes ?? ['name', 'email']);
123
+
124
+ Utils.dispatchToMainThread(() => {
125
+ try {
126
+ const provider = ASAuthorizationAppleIDProvider.alloc().init();
127
+ const request = provider.createRequest();
128
+ // Apple binds the returned id_token to SHA256(nonce); we hand back the RAW nonce to JS.
129
+ request.nonce = sha256Hex(rawNonce);
130
+ const scopeList: string[] = [];
131
+ if (want.has('name')) scopeList.push(ASAuthorizationScopeFullName);
132
+ if (want.has('email')) scopeList.push(ASAuthorizationScopeEmail);
133
+ request.requestedScopes = NSArray.arrayWithArray<string>(scopeList as any);
134
+
135
+ const settle = (fn: () => void) => {
136
+ activeController = null;
137
+ activeDelegate = null;
138
+ activeProvider = null;
139
+ // An ASAuthorization sheet can leave the WebView frozen on dismiss like the other native
140
+ // surfaces — recover from its completion (shared framework fix).
141
+ bridge.getWebView()?.recoverAfterNativeSurface();
142
+ fn();
143
+ };
144
+
145
+ const delegate = AppleSignInDelegate.new();
146
+ delegate.rawNonce = rawNonce;
147
+ delegate.settle = settle;
148
+ delegate.resolve = resolve;
149
+ delegate.reject = reject;
150
+
151
+ const controller = ASAuthorizationController.alloc().initWithAuthorizationRequests(
152
+ NSArray.arrayWithObject<ASAuthorizationRequest>(request)
153
+ );
154
+ activeDelegate = delegate;
155
+ activeProvider = AppleSignInContextProvider.new();
156
+ controller.delegate = delegate;
157
+ controller.presentationContextProvider = activeProvider;
158
+ activeController = controller;
159
+ controller.performRequests();
160
+ } catch (e: any) {
161
+ activeController = null;
162
+ activeDelegate = null;
163
+ activeProvider = null;
164
+ reject(err('NATIVE_ERROR', e?.message ?? 'appleSignIn failed to start'));
165
+ }
166
+ });
167
+ })
168
+ );
169
+ }
@@ -221,6 +221,9 @@ export function registerBillingHandlers(): void {
221
221
  // The sheet dismisses WITHOUT the app scene leaving foregroundActive → no resumeEvent, and it
222
222
  // orphans a touch-stealing window above ours. Recover from this completion (the only callback
223
223
  // that fires). Shared across all native surfaces; see CustomWebView.recoverAfterNativeSurface.
224
+ // NOTE: redundant with the global UIWindowDidBecomeHidden observer (armNativeSurfaceRecovery in
225
+ // main-page.ts) — kept as the proven baseline; remove the per-handler calls once that observer
226
+ // is device-verified to fire on this dismiss. Double-recovery is safe (coalesced + no-op clean).
224
227
  bridge.getWebView()?.recoverAfterNativeSurface();
225
228
  if (message) reject(err('NATIVE_ERROR', message));
226
229
  else resolve();
@@ -0,0 +1,60 @@
1
+ import { isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
5
+
6
+ /** Map the ATT status enum (ATTrackingManagerAuthorizationStatus: 0..3) to the kit's string union. */
7
+ function statusToString(raw: number): 'notDetermined' | 'restricted' | 'denied' | 'authorized' {
8
+ switch (raw) {
9
+ case ATTrackingManagerAuthorizationStatus.Authorized: return 'authorized';
10
+ case ATTrackingManagerAuthorizationStatus.Denied: return 'denied';
11
+ case ATTrackingManagerAuthorizationStatus.Restricted: return 'restricted';
12
+ default: return 'notDetermined';
13
+ }
14
+ }
15
+
16
+ /**
17
+ * App Tracking Transparency (iOS, `tracking` module). Strippable own-handler file (registered only
18
+ * when the module is active) — a build without `tracking` compiles NO ATT code. Bridges the three kit
19
+ * calls to ATTrackingManager / ASIdentifierManager.
20
+ *
21
+ * iOS-only: the capability is gated `ios:true`/`android:false`, so the kit short-circuits these on
22
+ * other platforms (`capability !== 'native'`) before they ever reach the bridge. The isIOS guard is
23
+ * defence-in-depth so the handler is a no-op if ever loaded elsewhere.
24
+ *
25
+ * DEVICE-UNVERIFIED (compile-verified-only): the ATT prompt + IDFA round-trip cannot be exercised on a
26
+ * USB dev-sideload in this environment. The FFI selectors are verified against @nativescript/types-ios
27
+ * (ATTrackingManager.requestTrackingAuthorizationWithCompletionHandler / .trackingAuthorizationStatus,
28
+ * ASIdentifierManager.sharedManager().advertisingIdentifier). Same honesty bar as the other recent
29
+ * native modules (oauth/billing-sheet): the compile path is proven, on-device behavior is not.
30
+ */
31
+ export function registerTrackingHandlers(): void {
32
+ if (!isIOS) return;
33
+
34
+ // requestPermission — show the ATT prompt; completion fires async with the chosen status. The
35
+ // completion runs off the main thread; we just translate + resolve. Dismiss-bound on the kit side
36
+ // (timeoutMs:'none') — the user decides at their leisure.
37
+ bridge.register('tracking.requestPermission', () =>
38
+ new Promise((resolve, reject) => {
39
+ try {
40
+ ATTrackingManager.requestTrackingAuthorizationWithCompletionHandler((status: number) => {
41
+ resolve(statusToString(status));
42
+ });
43
+ } catch (e: any) {
44
+ reject(err('NATIVE_ERROR', e?.message ?? 'tracking.requestPermission failed'));
45
+ }
46
+ })
47
+ );
48
+
49
+ // status — current authorization WITHOUT prompting.
50
+ bridge.register('tracking.status', () => statusToString(ATTrackingManager.trackingAuthorizationStatus));
51
+
52
+ // idfa — the advertising identifier, ONLY while authorized; else undefined. iOS returns an all-zero
53
+ // UUID when not authorized, so we gate on the status AND filter the placeholder.
54
+ bridge.register('tracking.idfa', () => {
55
+ if (ATTrackingManager.trackingAuthorizationStatus !== ATTrackingManagerAuthorizationStatus.Authorized) return undefined;
56
+ const id = ASIdentifierManager.sharedManager().advertisingIdentifier?.UUIDString;
57
+ if (!id || id === '00000000-0000-0000-0000-000000000000') return undefined;
58
+ return id;
59
+ });
60
+ }
@@ -1,7 +1,7 @@
1
1
  import { ApplicationSettings, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
- import { onPwaHandshake } from './events';
4
+ import { onPwaHandshake, consumePendingDeepLink } from './events';
5
5
  import { showToast } from './toast';
6
6
  import { showBanner, dismissBanner } from './banner';
7
7
  import { setStatusBarStyle } from './status-bar';
@@ -10,7 +10,7 @@ import { ACTIVE_MODULE_NAMES } from './active-modules.generated';
10
10
  import { consumePendingBackgroundTaskId } from './background-context';
11
11
 
12
12
  /** Build identifier for the native shell bundle — bump per deploy to spot stale bundles. */
13
- export const SHELL_BUILD = 'updates-devmenu-3';
13
+ export const SHELL_BUILD = 'apple-signin-1';
14
14
 
15
15
  /** Version status the web side (native-kit `kit.updates`) reports via `app.reportWebVersion`. */
16
16
  export interface WebVersionInfo { current?: string; latest?: string; build?: string | number; updateAvailable?: boolean; }
@@ -32,6 +32,10 @@ export function registerHandlers(): void {
32
32
  // (offscreen) WebView. Report it so `kit.backgroundTask` dispatches the registered handler. Consumed
33
33
  // (read-once) so a later foreground handshake in the same process never re-reports a stale wake.
34
34
  const backgroundTaskId = consumePendingBackgroundTaskId();
35
+ // A cold-start deep link buffered during launch is handed back HERE (read-once) so the PWA routes
36
+ // to the target before first paint — no `/home` flash. Warm links (app already running) still
37
+ // arrive via the `deeplink.open` event.
38
+ const deepLink = consumePendingDeepLink();
35
39
  return {
36
40
  protocol: 1,
37
41
  platform: isIOS ? 'ios' : 'android',
@@ -39,6 +43,7 @@ export function registerHandlers(): void {
39
43
  debug: { lastNotifTap: safeJson(ApplicationSettings.getString('kit:__notifTap', '')) },
40
44
  capabilities,
41
45
  ...(backgroundTaskId ? { backgroundTaskId } : {}),
46
+ ...(deepLink ? { deepLink } : {}),
42
47
  };
43
48
  });
44
49
 
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Minimal, dependency-free SHA-256 → lowercase hex. Used by the `appleSignIn` handler to hash the raw
3
+ * nonce before sending it to Apple (Apple binds the returned id_token to SHA256(nonce); the raw nonce
4
+ * goes back to JS for Firebase). NativeScript ships no WebCrypto, and a pointer-juggling CommonCrypto
5
+ * FFI call is exactly the fragile any-typed interop the project avoids — a small pure-JS hash is
6
+ * deterministic and unit-testable without a device. Input is treated as a UTF-8 string.
7
+ */
8
+
9
+ const K = new Uint32Array([
10
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
11
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
12
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
13
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
14
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
15
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
16
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
17
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
18
+ ]);
19
+
20
+ function utf8Bytes(str: string): number[] {
21
+ const out: number[] = [];
22
+ for (let i = 0; i < str.length; i++) {
23
+ let c = str.charCodeAt(i);
24
+ if (c < 0x80) out.push(c);
25
+ else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
26
+ else if (c >= 0xd800 && c <= 0xdbff) {
27
+ // Surrogate pair → full code point.
28
+ const lo = str.charCodeAt(++i);
29
+ c = 0x10000 + ((c & 0x3ff) << 10) + (lo & 0x3ff);
30
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
31
+ } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
32
+ }
33
+ return out;
34
+ }
35
+
36
+ const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
37
+
38
+ /** SHA-256 of a UTF-8 string, lowercase hex (64 chars). */
39
+ export function sha256Hex(message: string): string {
40
+ const bytes = utf8Bytes(message);
41
+ const bitLen = bytes.length * 8;
42
+ bytes.push(0x80);
43
+ while (bytes.length % 64 !== 56) bytes.push(0);
44
+ // 64-bit big-endian length (high word is 0 for any realistic nonce).
45
+ for (let i = 7; i >= 0; i--) bytes.push((bitLen / 2 ** (8 * i)) & 0xff);
46
+
47
+ const h = new Uint32Array([
48
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
49
+ ]);
50
+ const w = new Uint32Array(64);
51
+
52
+ for (let i = 0; i < bytes.length; i += 64) {
53
+ for (let t = 0; t < 16; t++) {
54
+ w[t] = (bytes[i + 4 * t] << 24) | (bytes[i + 4 * t + 1] << 16) | (bytes[i + 4 * t + 2] << 8) | bytes[i + 4 * t + 3];
55
+ }
56
+ for (let t = 16; t < 64; t++) {
57
+ const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ (w[t - 15] >>> 3);
58
+ const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ (w[t - 2] >>> 10);
59
+ w[t] = (w[t - 16] + s0 + w[t - 7] + s1) | 0;
60
+ }
61
+ let [a, b, c, d, e, f, g, hh] = [h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]];
62
+ for (let t = 0; t < 64; t++) {
63
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
64
+ const ch = (e & f) ^ (~e & g);
65
+ const t1 = (hh + S1 + ch + K[t] + w[t]) | 0;
66
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
67
+ const maj = (a & b) ^ (a & c) ^ (b & c);
68
+ const t2 = (S0 + maj) | 0;
69
+ hh = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
70
+ }
71
+ h[0] = (h[0] + a) | 0; h[1] = (h[1] + b) | 0; h[2] = (h[2] + c) | 0; h[3] = (h[3] + d) | 0;
72
+ h[4] = (h[4] + e) | 0; h[5] = (h[5] + f) | 0; h[6] = (h[6] + g) | 0; h[7] = (h[7] + hh) | 0;
73
+ }
74
+
75
+ let hex = '';
76
+ for (let i = 0; i < 8; i++) hex += (h[i] >>> 0).toString(16).padStart(8, '0');
77
+ return hex;
78
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrap-shell",
3
3
  "main": "app/app.ts",
4
- "version": "0.30.0",
4
+ "version": "0.32.0",
5
5
  "private": true,
6
6
  "scripts": {
7
7
  "dev:ios": "ns run ios",
@@ -21,3 +21,5 @@
21
21
  /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!BackgroundTasks.d.ts" />
22
22
  /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!AuthenticationServices.d.ts" />
23
23
  /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!Photos.d.ts" />
24
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!AppTrackingTransparency.d.ts" />
25
+ /// <reference path="./node_modules/@nativescript/types-ios/lib/ios/objc-x86_64/objc!AdSupport.d.ts" />
package/src/cli.ts CHANGED
@@ -29,6 +29,7 @@ import {
29
29
  stampAndroidQueries,
30
30
  stampPlistBackgroundTasks,
31
31
  stampPlistOrientations,
32
+ stampPrivacyTracking,
32
33
  } from './derive';
33
34
  import type { WebManifest } from './derive';
34
35
 
@@ -177,6 +178,8 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
177
178
  reviews: { file: './handlers-reviews', fn: 'registerReviewsHandlers' },
178
179
  scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
179
180
  speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
181
+ tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
182
+ appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
180
183
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
181
184
  };
182
185
 
@@ -253,6 +256,19 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
253
256
  console.log(` entl ← ${keys.join(', ')}`);
254
257
  }
255
258
 
259
+ /** Stamp the App Tracking Transparency declarations into the store-readiness privacy manifest
260
+ * (PrivacyInfo.xcprivacy). EXTENDS that single manifest — flips NSPrivacyTracking + fills
261
+ * NSPrivacyTrackingDomains only when the `tracking` module is active, else leaves the template's
262
+ * `false` + empty defaults. Idempotent both ways (a build that later drops the module resets them). */
263
+ function stampPrivacyManifest(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
264
+ const file = join(outDir, 'App_Resources/iOS/PrivacyInfo.xcprivacy');
265
+ if (!existsSync(file)) return;
266
+ const active = req.activeOptionalGroups.includes('tracking');
267
+ const next = stampPrivacyTracking(readFileSync(file, 'utf8'), active, cfg.trackingDomains ?? []);
268
+ writeFileSync(file, next);
269
+ if (active) console.log(` priv ← NSPrivacyTracking=true${cfg.trackingDomains?.length ? ` (${cfg.trackingDomains.length} domain${cfg.trackingDomains.length > 1 ? 's' : ''})` : ''}`);
270
+ }
271
+
256
272
  /** Copy active modules' native source (runtime/modules-native/<name>/) into native/ — only when the
257
273
  * module is active, so module native code stays stripped from builds that don't use it. */
258
274
  function copyModuleNativeSrc(outDir: string, req: NativeReqs): void {
@@ -935,15 +951,28 @@ function gitRoot(start: string): string {
935
951
  }
936
952
  }
937
953
 
954
+ /** True when `root` is the appwrap framework monorepo itself (not an external consumer project).
955
+ * Running `appwrap init` on an in-repo example (examples/*) resolves `gitRoot` to the framework root,
956
+ * so scaffolding consumer CI workflows there pollutes the framework's OWN `.github/workflows` with a
957
+ * stray app-template workflow each init. The framework manages its own CI — skip the workflow scaffold. */
958
+ export function isFrameworkRepo(root: string): boolean {
959
+ return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
960
+ }
961
+
938
962
  /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/). Never overwrites. */
939
963
  function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void {
940
964
  if (!existsSync(CI_TEMPLATE_DIR)) return;
965
+ const repoRoot = gitRoot(cwd);
941
966
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
942
967
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
943
- const targets: Array<[string, string]> = [
944
- [join(CI_TEMPLATE_DIR, 'github/workflows'), join(gitRoot(cwd), '.github/workflows')],
945
- [join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane')],
946
- ];
968
+ const targets: Array<[string, string]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane')]];
969
+ // …but if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold consumer
970
+ // workflows into the framework's .github — that's the stray-workflow-each-init bug.
971
+ if (isFrameworkRepo(repoRoot)) {
972
+ console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
973
+ } else {
974
+ targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows')]);
975
+ }
947
976
  for (const [from, to] of targets) {
948
977
  mkdirSync(to, { recursive: true });
949
978
  cpSync(from, to, { recursive: true, force: false, errorOnExist: false });
@@ -959,7 +988,10 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void
959
988
  .replaceAll('__TEAM_ID__', cfg.teamId ?? '');
960
989
  writeFileSync(p, stamped);
961
990
  }
962
- console.log(' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers');
991
+ const ci = isFrameworkRepo(repoRoot)
992
+ ? ' ci ← fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
993
+ : ' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers';
994
+ console.log(ci);
963
995
  }
964
996
 
965
997
  /**
@@ -997,6 +1029,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
997
1029
  stampStoreKit(cwd, outDir, cfg);
998
1030
  stampPush(cwd, outDir, cfg);
999
1031
  stampEntitlements(outDir, cfg, req); // unified app.entitlements: module entitlements + push aps-environment
1032
+ stampPrivacyManifest(outDir, cfg, req); // ATT tracking declarations into the store-readiness privacy manifest
1000
1033
  generateIcons(cwd, outDir, cfg);
1001
1034
  copyPwa(cwd, outDir, cfg);
1002
1035
  }
package/src/config.ts CHANGED
@@ -109,8 +109,14 @@ export interface AppwrapConfig {
109
109
  * (iOS: Info.plist usage string; Android: <uses-permission>). 'contacts' has no
110
110
  * iOS key (CNContactPicker needs none) — it only stamps Android READ_CONTACTS. */
111
111
  permissions?: Partial<
112
- Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion', string>
112
+ Record<'location' | 'photos' | 'camera' | 'microphone' | 'faceid' | 'calendar' | 'contacts' | 'motion' | 'tracking', string>
113
113
  >;
114
+ /** App Tracking Transparency tracking domains (iOS, `tracking` module). When the module is active
115
+ * the CLI sets the privacy manifest's `NSPrivacyTracking` → true and fills `NSPrivacyTrackingDomains`
116
+ * with these (the hosts the app/embedded SDKs contact while tracking — Apple validates them at
117
+ * upload). Empty/absent → `NSPrivacyTracking` true with an empty domains array (declare the prompt
118
+ * without listing domains). No-op entirely when the `tracking` module is inactive. */
119
+ trackingDomains?: string[];
114
120
  /** Monotonic build identifier. Stores reject a re-upload unless this is HIGHER than the last:
115
121
  * iOS `CFBundleVersion`, Android `versionCode` (the marketing `version` stays the user-facing
116
122
  * string). Default: an integer derived from `version` (0.2.1 → 201). Set an explicit number from a
package/src/derive.ts CHANGED
@@ -208,6 +208,33 @@ export function stampPlistBackgroundTasks(src: string, ids: string[] | undefined
208
208
  return src;
209
209
  }
210
210
 
211
+ /**
212
+ * Stamp the App Tracking Transparency declarations into PrivacyInfo.xcprivacy. Rewrites the two
213
+ * tracking keys IN PLACE (the template ships them, so we never restructure the doc — we only flip
214
+ * values), keeping the required-reason API declarations the store-readiness manifest carries intact.
215
+ * Fully idempotent both directions:
216
+ * - module ACTIVE → `NSPrivacyTracking` true + `NSPrivacyTrackingDomains` = `domains`.
217
+ * - module INACTIVE → `NSPrivacyTracking` false + empty `NSPrivacyTrackingDomains` (template default).
218
+ * This EXTENDS the store-readiness manifest (single source of truth) rather than emitting a second one.
219
+ */
220
+ export function stampPrivacyTracking(src: string, tracking: boolean, domains: string[] = []): string {
221
+ const list = (tracking ? domains : []).filter(Boolean);
222
+ // NSPrivacyTracking — true/false. Match the key + its following <true/>|<false/> element.
223
+ src = src.replace(
224
+ /(<key>NSPrivacyTracking<\/key>\s*)<(?:true|false)\/>/,
225
+ `$1<${tracking ? 'true' : 'false'}/>`
226
+ );
227
+ // NSPrivacyTrackingDomains — empty <array/> or a populated <array>…</array>. Match either form.
228
+ const domXml = list.length
229
+ ? `<array>\n${list.map((d) => `\t\t<string>${d}</string>`).join('\n')}\n\t</array>`
230
+ : `<array/>`;
231
+ src = src.replace(
232
+ /(<key>NSPrivacyTrackingDomains<\/key>\s*)(?:<array\/>|<array>[\s\S]*?<\/array>)/,
233
+ `$1${domXml}`
234
+ );
235
+ return src;
236
+ }
237
+
211
238
  export function stampAndroidQueries(src: string, queryPackages?: string[], queryUrlSchemes?: string[]): string {
212
239
  const children = [
213
240
  ...(queryPackages ?? []).map((p) => `\t\t<package android:name="${p}"/>`),