@livx.cc/appwrap 0.30.2 → 0.35.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.30.2",
3
+ "version": "0.35.0",
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,22 @@ 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
+
257
273
  // ── backgroundTask — headless background execution (HEADLESS JS HANDLER) — opt-in, STRIPPABLE ──
258
274
  // The OS wakes the app (possibly cold, no visible WebView) for a permitted task id; the shell builds
259
275
  // an OFFSCREEN WebView, loads the app conveying the id (the handshake reports it), awaits the JS
@@ -273,7 +289,7 @@ export const MODULES: ModuleManifest[] = [
273
289
 
274
290
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
275
291
  * 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;
292
+ export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'tracking', 'backgroundTask'] as const;
277
293
 
278
294
  /** Resolve the active capability map for the handshake from a set of active capability names. */
279
295
  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);
@@ -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;
@@ -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();
@@ -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
  }));
@@ -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 = 'global-observer-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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appwrap-shell",
3
3
  "main": "app/app.ts",
4
- "version": "0.29.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
@@ -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';
@@ -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,7 @@ 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' },
180
182
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
181
183
  };
182
184
 
@@ -253,6 +255,19 @@ function stampEntitlements(outDir: string, cfg: AppwrapConfig, req: NativeReqs):
253
255
  console.log(` entl ← ${keys.join(', ')}`);
254
256
  }
255
257
 
258
+ /** Stamp the App Tracking Transparency declarations into the store-readiness privacy manifest
259
+ * (PrivacyInfo.xcprivacy). EXTENDS that single manifest — flips NSPrivacyTracking + fills
260
+ * NSPrivacyTrackingDomains only when the `tracking` module is active, else leaves the template's
261
+ * `false` + empty defaults. Idempotent both ways (a build that later drops the module resets them). */
262
+ function stampPrivacyManifest(outDir: string, cfg: AppwrapConfig, req: NativeReqs): void {
263
+ const file = join(outDir, 'App_Resources/iOS/PrivacyInfo.xcprivacy');
264
+ if (!existsSync(file)) return;
265
+ const active = req.activeOptionalGroups.includes('tracking');
266
+ const next = stampPrivacyTracking(readFileSync(file, 'utf8'), active, cfg.trackingDomains ?? []);
267
+ writeFileSync(file, next);
268
+ if (active) console.log(` priv ← NSPrivacyTracking=true${cfg.trackingDomains?.length ? ` (${cfg.trackingDomains.length} domain${cfg.trackingDomains.length > 1 ? 's' : ''})` : ''}`);
269
+ }
270
+
256
271
  /** Copy active modules' native source (runtime/modules-native/<name>/) into native/ — only when the
257
272
  * module is active, so module native code stays stripped from builds that don't use it. */
258
273
  function copyModuleNativeSrc(outDir: string, req: NativeReqs): void {
@@ -336,11 +351,17 @@ async function readConfigFile(configPath: string): Promise<AppwrapConfig> {
336
351
  return cfg as AppwrapConfig;
337
352
  }
338
353
 
339
- async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
340
- // Explicit --config wins; otherwise probe ts → js → json (TS preferred).
341
- const configPath = flags.config
354
+ /** Resolve the user's appwrap config file path the CLI loads from: explicit --config wins;
355
+ * otherwise probe ts → js → json (TS preferred). Single source of discovery (reused by the
356
+ * team-id pin-to-config writer so it never re-invents the probe). */
357
+ function resolveConfigPath(cwd: string, flags: Record<string, string>): string {
358
+ return flags.config
342
359
  ? resolve(cwd, flags.config)
343
360
  : (CONFIG_CANDIDATES.map((f) => resolve(cwd, f)).find(existsSync) ?? resolve(cwd, CONFIG_CANDIDATES[0]));
361
+ }
362
+
363
+ async function loadConfig(cwd: string, flags: Record<string, string>): Promise<AppwrapConfig> {
364
+ const configPath = resolveConfigPath(cwd, flags);
344
365
  if (!existsSync(configPath)) {
345
366
  console.error(`✖ Config not found — looked for ${CONFIG_CANDIDATES.join(' / ')} in ${cwd}`);
346
367
  process.exit(1);
@@ -463,15 +484,31 @@ function stampIOSDisplayName(outDir: string, cfg: AppwrapConfig, req: NativeReqs
463
484
  writeFileSync(plist, src);
464
485
  }
465
486
 
466
- function stampTeamId(outDir: string, cfg: AppwrapConfig): void {
487
+ function stampTeamId(outDir: string, cfg: AppwrapConfig, ctx?: { cwd: string; configPath: string }): void {
467
488
  const xcconfig = join(outDir, 'App_Resources/iOS/build.xcconfig');
468
- // A placeholder/empty teamId silently produces an unsignable build (DEVELOPMENT_TEAM = YOUR_APPLE_TEAM_ID
469
- // → xcodebuild "No Account for Team"). Warn loudly rather than let a long build fail cryptically.
470
- if (!cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId)) {
471
- console.warn(`⚠ teamId is unset/placeholder ("${cfg.teamId ?? ''}") — device builds won't sign.\n` +
472
- ' Set it to your Apple Team ID in appwrap.config (Xcode → Settings → Accounts shows it; ' +
473
- 'Individual = paid, Personal Team = free).');
474
- return;
489
+ // Resolution order: a real (non-placeholder) cfg.teamId wins; else $APPWRAP_TEAM_ID (headless/CI);
490
+ // else the enriched interactive picker (which then offers to pin its choice to the config). This
491
+ // intercepts BEFORE `ns build` runs so NS never shows its plain, unenriched prompt.
492
+ const isPlaceholder = !cfg.teamId || /YOUR_APPLE_TEAM_ID|^$/.test(cfg.teamId);
493
+ if (isPlaceholder) {
494
+ const envTeam = process.env.APPWRAP_TEAM_ID?.trim();
495
+ if (envTeam) {
496
+ cfg.teamId = envTeam;
497
+ console.log(` team ← ${envTeam} (from $APPWRAP_TEAM_ID)`);
498
+ } else if (!process.stdout.isTTY) {
499
+ console.warn(`⚠ teamId is unset — set it in appwrap.config, set $APPWRAP_TEAM_ID, or run interactively to pick from your teams.`);
500
+ return;
501
+ } else {
502
+ const picked = pickTeamIdInteractively();
503
+ cfg.teamId = picked.teamId;
504
+ // Offer to persist the choice so the user isn't re-prompted on every deploy. No-TTY/headless is
505
+ // already handled above; promptYesNo additionally guards against a non-interactive stdin.
506
+ if (ctx && promptYesNo(` Pin "${picked.name} (${picked.teamId})" to appwrap.config so you're not asked again?`, true)) {
507
+ pinTeamIdToConfig(ctx.configPath, picked.teamId);
508
+ } else {
509
+ console.log(` ⓘ To skip this prompt: set teamId: "${picked.teamId}" in appwrap.config (or set $APPWRAP_TEAM_ID).`);
510
+ }
511
+ }
475
512
  }
476
513
  if (!existsSync(xcconfig)) return;
477
514
  let src = readFileSync(xcconfig, 'utf8');
@@ -913,15 +950,28 @@ function gitRoot(start: string): string {
913
950
  }
914
951
  }
915
952
 
953
+ /** True when `root` is the appwrap framework monorepo itself (not an external consumer project).
954
+ * Running `appwrap init` on an in-repo example (examples/*) resolves `gitRoot` to the framework root,
955
+ * so scaffolding consumer CI workflows there pollutes the framework's OWN `.github/workflows` with a
956
+ * stray app-template workflow each init. The framework manages its own CI — skip the workflow scaffold. */
957
+ export function isFrameworkRepo(root: string): boolean {
958
+ return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
959
+ }
960
+
916
961
  /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/). Never overwrites. */
917
962
  function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void {
918
963
  if (!existsSync(CI_TEMPLATE_DIR)) return;
964
+ const repoRoot = gitRoot(cwd);
919
965
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
920
966
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
921
- const targets: Array<[string, string]> = [
922
- [join(CI_TEMPLATE_DIR, 'github/workflows'), join(gitRoot(cwd), '.github/workflows')],
923
- [join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane')],
924
- ];
967
+ const targets: Array<[string, string]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane')]];
968
+ // …but if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold consumer
969
+ // workflows into the framework's .github — that's the stray-workflow-each-init bug.
970
+ if (isFrameworkRepo(repoRoot)) {
971
+ console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
972
+ } else {
973
+ targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows')]);
974
+ }
925
975
  for (const [from, to] of targets) {
926
976
  mkdirSync(to, { recursive: true });
927
977
  cpSync(from, to, { recursive: true, force: false, errorOnExist: false });
@@ -937,7 +987,10 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void
937
987
  .replaceAll('__TEAM_ID__', cfg.teamId ?? '');
938
988
  writeFileSync(p, stamped);
939
989
  }
940
- console.log(' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers');
990
+ const ci = isFrameworkRepo(repoRoot)
991
+ ? ' ci ← fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers'
992
+ : ' ci ← GH Actions (.github/workflows) + fastlane (native/fastlane, signing stamped) — see secrets contract in workflow headers';
993
+ console.log(ci);
941
994
  }
942
995
 
943
996
  /**
@@ -948,7 +1001,7 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void
948
1001
  * Excludes the first-time scaffold (managed-guard, CI, .gitignore) + overrides/version-manifest, which the
949
1002
  * callers sequence around this so overrides win LAST and the marker writes after.
950
1003
  */
951
- function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean } = {}): void {
1004
+ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: { firstRun?: boolean; flags?: Record<string, string> } = {}): void {
952
1005
  const req = nativeReqs(cfg);
953
1006
  if (opts.firstRun && !req.explicit) {
954
1007
  console.log(' ℹ no `modules` in the appwrap config → all capabilities active. Declare `modules` to shrink the store build (strip unused handlers/perms).');
@@ -964,7 +1017,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
964
1017
  stampShellConfig(outDir, cfg);
965
1018
  stampNativeScriptConfig(outDir, cfg);
966
1019
  stampIOSDisplayName(outDir, cfg, req);
967
- stampTeamId(outDir, cfg);
1020
+ stampTeamId(outDir, cfg, { cwd, configPath: resolveConfigPath(cwd, opts.flags ?? {}) });
968
1021
  stampAndroidAppName(outDir, cfg, req);
969
1022
  stampAndroidVersion(outDir, cfg);
970
1023
  stampAndroidGradleDeps(outDir, req.androidGradleDeps);
@@ -975,6 +1028,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
975
1028
  stampStoreKit(cwd, outDir, cfg);
976
1029
  stampPush(cwd, outDir, cfg);
977
1030
  stampEntitlements(outDir, cfg, req); // unified app.entitlements: module entitlements + push aps-environment
1031
+ stampPrivacyManifest(outDir, cfg, req); // ATT tracking declarations into the store-readiness privacy manifest
978
1032
  generateIcons(cwd, outDir, cfg);
979
1033
  copyPwa(cwd, outDir, cfg);
980
1034
  }
@@ -1004,7 +1058,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1004
1058
 
1005
1059
  console.log(`🎁 appwrap init → ${outDir}`);
1006
1060
  mkdirSync(outDir, { recursive: true });
1007
- regenerateCore(cwd, outDir, cfg, { firstRun: true });
1061
+ regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1008
1062
  copyCiTemplates(cwd, outDir, cfg); // first-time scaffold (never overwrites)
1009
1063
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1010
1064
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
@@ -1022,7 +1076,7 @@ async function sync(cwd: string, flags: Record<string, string>): Promise<void> {
1022
1076
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1023
1077
  process.exit(1);
1024
1078
  }
1025
- regenerateCore(cwd, outDir, cfg);
1079
+ regenerateCore(cwd, outDir, cfg, { flags });
1026
1080
  applyOverrides(cwd, outDir, cfg); // overrides win last
1027
1081
  stampVersionManifest(outDir, cfg); // keep the managed-marker / provenance current
1028
1082
  console.log('✓ Synced.');
@@ -1121,6 +1175,7 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
1121
1175
  }
1122
1176
  }
1123
1177
 
1178
+ interface AppleTeam { teamId: string; name: string; email?: string; paid: boolean }
1124
1179
  interface DeviceInfo { id: string; name: string; model: string; transport: string }
1125
1180
 
1126
1181
  /** The subset of a `xcrun devicectl list devices --json-output` device entry appwrap reads. */
@@ -1131,6 +1186,217 @@ interface DevicectlDevice {
1131
1186
  connectionProperties?: { tunnelState?: string; transportType?: string };
1132
1187
  }
1133
1188
 
1189
+ /** Read Apple team metadata from provisioning profiles + distribution certs in the keychain.
1190
+ * Provisioning profiles give us the reliable teamId↔teamName mapping; distribution certs
1191
+ * often embed the account email in the display name. */
1192
+ function detectAppleTeams(): AppleTeam[] {
1193
+ const teams = new Map<string, AppleTeam>();
1194
+
1195
+ // 1. Provisioning profiles → teamId + teamName (most reliable)
1196
+ const profilesDir = join(process.env.HOME ?? '', 'Library/MobileDevice/Provisioning Profiles');
1197
+ if (existsSync(profilesDir)) {
1198
+ try {
1199
+ const files = readdirSync(profilesDir).filter((f) => f.endsWith('.mobileprovision'));
1200
+ for (const f of files) {
1201
+ try {
1202
+ const raw = execFileSync('security', ['cms', '-D', '-i', join(profilesDir, f)],
1203
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
1204
+ const idMatch = raw.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/);
1205
+ const nameMatch = raw.match(/<key>TeamName<\/key>\s*<string>([^<]+)<\/string>/);
1206
+ if (idMatch && nameMatch) {
1207
+ const teamId = idMatch[1];
1208
+ const name = nameMatch[1];
1209
+ const free = /personal team/i.test(name);
1210
+ if (!teams.has(teamId)) teams.set(teamId, { teamId, name, paid: !free });
1211
+ }
1212
+ } catch { /* skip unreadable profile */ }
1213
+ }
1214
+ } catch { /* skip if dir unreadable */ }
1215
+ }
1216
+
1217
+ // 2. Keychain distribution/Developer-ID certs → teamId + possible email in name
1218
+ try {
1219
+ const out = execFileSync('security', ['find-identity', '-v', '-p', 'codesigning'],
1220
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
1221
+ for (const line of out.split('\n')) {
1222
+ const m = line.match(/"(?:Apple Distribution|Developer ID Application): (.+?) \(([A-Z0-9]{10})\)"/);
1223
+ if (!m) continue;
1224
+ const [, label, teamId] = m;
1225
+ const email = label.includes('@') ? label.trim() : undefined;
1226
+ const existing = teams.get(teamId);
1227
+ if (existing) {
1228
+ if (email && !existing.email) existing.email = email;
1229
+ } else {
1230
+ teams.set(teamId, { teamId, name: label.trim(), email, paid: !/personal team/i.test(label) });
1231
+ }
1232
+ }
1233
+ } catch { /* keychain unavailable */ }
1234
+
1235
+ return [...teams.values()];
1236
+ }
1237
+
1238
+ /** Arrow-key interactive selector. Returns the index of the chosen item. */
1239
+ function arrowSelect(prompt: string, items: string[]): number {
1240
+ const tty = openSync('/dev/tty', 'r+');
1241
+ const write = (s: string) => writeSync(tty, s);
1242
+ const ESC = '\x1b';
1243
+
1244
+ write(`${prompt}\n`);
1245
+ let idx = 0;
1246
+ const HINT = '\x1b[2m ↑↓ / j k to move · Enter to confirm\x1b[0m';
1247
+ const render = (clear: boolean) => {
1248
+ if (clear) write(`\x1b[${items.length + 1}A`); // +1 for the hint line
1249
+ for (let i = 0; i < items.length; i++)
1250
+ write(`\r\x1b[K${i === idx ? '❯ ' : ' '}${items[i]}\n`);
1251
+ write(`\r\x1b[K${HINT}\n`);
1252
+ };
1253
+ render(false);
1254
+
1255
+ // raw mode via stty
1256
+ execFileSync('stty', ['-icanon', '-echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
1257
+ const buf = Buffer.alloc(6);
1258
+ try {
1259
+ for (;;) {
1260
+ const n = readSync(tty, buf, 0, 6, null);
1261
+ const key = buf.slice(0, n).toString();
1262
+ if (key === `${ESC}[A` || key === 'k') { idx = (idx - 1 + items.length) % items.length; render(true); }
1263
+ else if (key === `${ESC}[B` || key === 'j') { idx = (idx + 1) % items.length; render(true); }
1264
+ else if (key === '\r' || key === '\n') break;
1265
+ else if (key === '\x03') { write('\n'); process.exit(1); } // Ctrl-C
1266
+ }
1267
+ } finally {
1268
+ execFileSync('stty', ['icanon', 'echo'], { stdio: ['inherit', 'inherit', 'inherit'] });
1269
+ // Erase the hint line so the selected value prints cleanly after
1270
+ write(`\x1b[1A\r\x1b[K`);
1271
+ write('\n');
1272
+ closeSync(tty);
1273
+ }
1274
+ return idx;
1275
+ }
1276
+
1277
+ /** Interactively prompt for an Apple team when teamId is unset. Shows enriched metadata
1278
+ * (email, paid/free) sourced from local keychain + provisioning profiles. */
1279
+ function pickTeamIdInteractively(): { teamId: string; name: string } {
1280
+ const teams = detectAppleTeams();
1281
+ if (teams.length === 0) {
1282
+ console.error('✖ No Apple signing teams found in keychain/provisioning profiles.\n' +
1283
+ ' Sign into Xcode → Settings → Accounts, then re-run.');
1284
+ process.exit(1);
1285
+ }
1286
+ if (teams.length === 1) {
1287
+ const t = teams[0];
1288
+ console.log(` team ← ${t.name} (${t.teamId})${t.email ? ` <${t.email}>` : ''} [${t.paid ? 'paid' : 'free'}] (only option)`);
1289
+ return { teamId: t.teamId, name: t.name };
1290
+ }
1291
+ const items = teams.map((t) => {
1292
+ const badge = t.paid ? '✓ paid' : '○ free';
1293
+ const email = t.email ? ` <${t.email}>` : '';
1294
+ return `${t.name} (${t.teamId})${email} [${badge}]`;
1295
+ });
1296
+ const idx = arrowSelect('Found multiple Apple teams — pick one to use for signing:', items);
1297
+ return { teamId: teams[idx].teamId, name: teams[idx].name };
1298
+ }
1299
+
1300
+ /** Y/n confirmation on the TTY (default-yes here). Reuses the global `prompt` primitive. A
1301
+ * non-interactive / piped stdin returns null → falls back to `def` ONLY when there's a real TTY;
1302
+ * a fully headless run never reaches here (callers gate on `process.stdout.isTTY` first), but be
1303
+ * defensive: if stdin can't be read, do NOT pin (safer to re-ask than to silently mutate config). */
1304
+ function promptYesNo(message: string, def: boolean): boolean {
1305
+ if (!process.stdin.isTTY) return false;
1306
+ const suffix = def ? ' [Y/n] ' : ' [y/N] ';
1307
+ const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(message + suffix);
1308
+ if (ans == null) return false;
1309
+ const a = ans.trim().toLowerCase();
1310
+ if (a === '') return def;
1311
+ return a === 'y' || a === 'yes';
1312
+ }
1313
+
1314
+ /** Persist `teamId` into the user's appwrap config so the interactive picker isn't re-run every
1315
+ * deploy. Pure string surgery (returns the new file content) so it's unit-testable across both
1316
+ * supported formats:
1317
+ * - `.json` — set/replace the top-level `"teamId"` property (preserves 2-space indent).
1318
+ * - `.ts`/`.js` — replace an existing `teamId:` field value (incl. the `YOUR_APPLE_TEAM_ID`
1319
+ * placeholder), else insert a new `teamId: '<id>',` line near the other top-level fields
1320
+ * (after `id:`, matching its indentation/quote style). If the shape is unexpected, returns
1321
+ * `null` so the caller skips the write rather than corrupting the file. */
1322
+ export function pinTeamIdInConfigSource(src: string, teamId: string, isJson: boolean): string | null {
1323
+ if (isJson) {
1324
+ let obj: Record<string, unknown>;
1325
+ try { obj = JSON.parse(src) as Record<string, unknown>; } catch { return null; }
1326
+ if (typeof obj !== 'object' || obj == null) return null;
1327
+ obj.teamId = teamId;
1328
+ return JSON.stringify(obj, null, 2) + (src.endsWith('\n') ? '\n' : '');
1329
+ }
1330
+ // TS/JS: replace an existing teamId field value, preserving its quote style.
1331
+ const existing = /(\bteamId\s*:\s*)(['"`])[^'"`]*\2/;
1332
+ if (existing.test(src)) {
1333
+ return src.replace(existing, (_m, lead: string, q: string) => `${lead}${q}${teamId}${q}`);
1334
+ }
1335
+ // No teamId field — insert after the `id:` field (mirroring its indentation + quote style).
1336
+ const idLine = /^([ \t]*)id\s*:\s*(['"`])[^'"`]*\2\s*,?[ \t]*$/m;
1337
+ const m = idLine.exec(src);
1338
+ if (!m) return null; // unfamiliar shape — don't risk corrupting it
1339
+ const indent = m[1];
1340
+ const quote = m[2];
1341
+ return src.slice(0, m.index + m[0].length)
1342
+ + `\n${indent}teamId: ${quote}${teamId}${quote},`
1343
+ + src.slice(m.index + m[0].length);
1344
+ }
1345
+
1346
+ /** Write the pinned teamId to the resolved config file (thin IO wrapper over the pure helper). */
1347
+ function pinTeamIdToConfig(configPath: string, teamId: string): void {
1348
+ if (!existsSync(configPath)) {
1349
+ console.warn(` ⚠ could not pin teamId — config not found at ${configPath}`);
1350
+ return;
1351
+ }
1352
+ const src = readFileSync(configPath, 'utf8');
1353
+ const next = pinTeamIdInConfigSource(src, teamId, configPath.endsWith('.json'));
1354
+ if (next == null) {
1355
+ console.warn(` ⚠ couldn't safely edit ${configPath} (unexpected shape) — leaving it untouched. Set teamId: '${teamId}' manually.`);
1356
+ return;
1357
+ }
1358
+ writeFileSync(configPath, next);
1359
+ console.log(` ✓ pinned teamId: '${teamId}' to ${configPath}`);
1360
+ }
1361
+
1362
+ // ── Build fingerprint for smart resume ───────────────────────────────────────────────────────────
1363
+
1364
+ /** Cheap fingerprint of SOURCE build inputs: mtime sum of the PWA dist/ + appwrap config.
1365
+ * App_Resources/ is intentionally excluded — sync() rewrites it every run, so its mtime always
1366
+ * changes and would make the fingerprint permanently stale.
1367
+ * Collision risk is acceptable — a false "match" just skips a redundant build, not a correctness bug. */
1368
+ function buildFingerprint(cwd: string, cfg: { pwaDist?: string }, _outDir: string): string {
1369
+ const mtime = (p: string): number => {
1370
+ if (!existsSync(p)) return 0;
1371
+ try {
1372
+ const s = statSync(p);
1373
+ if (s.isDirectory()) {
1374
+ let sum = 0;
1375
+ for (const e of readdirSync(p, { withFileTypes: true }))
1376
+ sum += mtime(join(p, e.name));
1377
+ return sum;
1378
+ }
1379
+ return s.mtimeMs;
1380
+ } catch { return 0; }
1381
+ };
1382
+ const distDir = cfg.pwaDist ? resolve(cwd, cfg.pwaDist) : join(cwd, 'dist');
1383
+ const parts = [mtime(distDir), mtime(join(cwd, 'appwrap.config.ts'))];
1384
+ // Simple djb2-style hash — good enough for a build-skip check (not cryptographic).
1385
+ let h = 5381;
1386
+ for (const n of parts) h = (((h << 5) + h) ^ (n | 0)) >>> 0;
1387
+ return h.toString(36);
1388
+ }
1389
+
1390
+ const BUILD_CACHE_FILE = '.appwrap-build-cache.json';
1391
+ interface BuildCache { fingerprint: string; ipaPath: string; builtAt: string }
1392
+
1393
+ function readBuildCache(outDir: string): BuildCache | null {
1394
+ try { return JSON.parse(readFileSync(join(outDir, BUILD_CACHE_FILE), 'utf8')); } catch { return null; }
1395
+ }
1396
+ function writeBuildCache(outDir: string, cache: BuildCache): void {
1397
+ try { writeFileSync(join(outDir, BUILD_CACHE_FILE), JSON.stringify(cache, null, 2)); } catch { /* non-fatal */ }
1398
+ }
1399
+
1134
1400
  /** Discover usable physical iOS devices via devicectl (USB + network). Excludes 'unavailable'
1135
1401
  * tunnels and non-iOS (watch). Returns [] if none. */
1136
1402
  function listIosDevices(): DeviceInfo[] {
@@ -1199,27 +1465,53 @@ async function deploy(cwd: string, flags: Record<string, string>, positionals: s
1199
1465
  await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1200
1466
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1201
1467
  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
1468
 
1219
1469
  const ipaDir = join(outDir, 'platforms/ios/build/Debug-iphoneos');
1220
- const ipa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
1470
+
1471
+ // Smart resume: skip ns build (pod install + xcodebuild) when inputs haven't changed.
1472
+ // --resume (-r): opt in to fingerprint-based skip (same logic as auto, but explicit — useful when
1473
+ // the auto check has no prior cache yet and you want to force a skip on first run after a manual build).
1474
+ // Auto: always checks fingerprint; never skips if sources/deps changed.
1475
+ const resume = 'resume' in flags || 'r' in flags;
1476
+ const force = 'force' in flags || 'f' in flags;
1477
+ const fp = buildFingerprint(cwd, cfg, outDir);
1478
+ const cache = readBuildCache(outDir);
1479
+ const existingIpa = existsSync(ipaDir)
1480
+ ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa'))
1481
+ : undefined;
1482
+ const fingerprintMatch = !force && existingIpa && cache?.fingerprint === fp && cache?.ipaPath === join(ipaDir, existingIpa);
1483
+ // --resume also accepts a missing cache file (e.g. after a manual Xcode build or first run),
1484
+ // but ONLY when the fingerprint matches what's currently on disk — never skips a needed build.
1485
+ const noCache = existingIpa && !cache;
1486
+ const canSkipBuild = !force && (fingerprintMatch || (resume && noCache));
1487
+
1488
+ if (canSkipBuild) {
1489
+ const reason = fingerprintMatch ? 'inputs unchanged since last build' : '--resume (first run, .ipa present)';
1490
+ console.log(`⚡ Skipping build — ${reason} (${existingIpa})`);
1491
+ } else {
1492
+ console.log(`▶ ns build ios --for-device (debug: keep-awake + inspector on)${force ? ' [--force: skipping cache]' : ''}`);
1493
+ try {
1494
+ execFileSync('ns', ['build', 'ios', '--for-device'], { cwd: outDir, stdio: 'inherit' });
1495
+ } catch (e) {
1496
+ // The xcodebuild dump above is cryptic; surface the two signing failures we actually hit most.
1497
+ console.error(
1498
+ '\n✖ Device build failed — if the errors above mention signing:\n' +
1499
+ ` • "Failed Registering Bundle Identifier … not available" → the App ID "${cfg.id}" is already\n` +
1500
+ ' registered to another team (e.g. a prior free-team build). Change `id` in appwrap.config to a\n' +
1501
+ ' unique string and re-deploy.\n' +
1502
+ ' • "profile doesn\'t include the … entitlement" (e.g. HealthKit) → that capability needs a PAID\n' +
1503
+ ' team (Individual). A free Personal Team can\'t hold it — switch teamId or drop the module.\n' +
1504
+ ' • "No Account for Team" → sign that Apple ID into Xcode → Settings → Accounts first.'
1505
+ );
1506
+ process.exit(1);
1507
+ }
1508
+ }
1509
+
1510
+ const builtIpa = existsSync(ipaDir) ? readdirSync(ipaDir).find((f) => f.endsWith('.ipa')) : undefined;
1511
+ const ipa = builtIpa;
1221
1512
  if (!ipa) { console.error(`✖ No .ipa produced in ${ipaDir}`); process.exit(1); }
1222
1513
  const ipaPath = join(ipaDir, ipa);
1514
+ if (!canSkipBuild) writeBuildCache(outDir, { fingerprint: fp, ipaPath, builtAt: new Date().toISOString() });
1223
1515
 
1224
1516
  console.log(`▶ installing ${ipa} → ${device.name} [${device.transport}]`);
1225
1517
  let installedViaUsbmux = false;
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}"/>`),