@livx.cc/appwrap 0.32.0 → 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 +1 -1
- package/runtime/app/main-page.ts +27 -0
- package/runtime/app/shell/capabilities.manifest.ts +17 -1
- package/runtime/app/shell/custom-webview.ios.ts +12 -1
- package/runtime/app/shell/events.ts +17 -9
- package/runtime/app/shell/handlers-billing.ts +3 -0
- package/runtime/app/shell/handlers-tracking.ts +60 -0
- package/runtime/app/shell/handlers.ts +7 -2
- package/runtime/package.json +1 -1
- package/runtime/references.d.ts +2 -0
- package/src/cli.ts +37 -5
- package/src/config.ts +7 -1
- package/src/derive.ts +27 -0
package/package.json
CHANGED
package/runtime/app/main-page.ts
CHANGED
|
@@ -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
|
-
*
|
|
52
|
-
*
|
|
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();
|
|
@@ -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 = '
|
|
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
|
|
package/runtime/package.json
CHANGED
package/runtime/references.d.ts
CHANGED
|
@@ -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,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 {
|
|
@@ -935,15 +950,28 @@ function gitRoot(start: string): string {
|
|
|
935
950
|
}
|
|
936
951
|
}
|
|
937
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
|
+
|
|
938
961
|
/** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/). Never overwrites. */
|
|
939
962
|
function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void {
|
|
940
963
|
if (!existsSync(CI_TEMPLATE_DIR)) return;
|
|
964
|
+
const repoRoot = gitRoot(cwd);
|
|
941
965
|
// GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
|
|
942
966
|
// package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
|
|
943
|
-
const targets: Array<[string, string]> = [
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
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
|
+
}
|
|
947
975
|
for (const [from, to] of targets) {
|
|
948
976
|
mkdirSync(to, { recursive: true });
|
|
949
977
|
cpSync(from, to, { recursive: true, force: false, errorOnExist: false });
|
|
@@ -959,7 +987,10 @@ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void
|
|
|
959
987
|
.replaceAll('__TEAM_ID__', cfg.teamId ?? '');
|
|
960
988
|
writeFileSync(p, stamped);
|
|
961
989
|
}
|
|
962
|
-
|
|
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);
|
|
963
994
|
}
|
|
964
995
|
|
|
965
996
|
/**
|
|
@@ -997,6 +1028,7 @@ function regenerateCore(cwd: string, outDir: string, cfg: AppwrapConfig, opts: {
|
|
|
997
1028
|
stampStoreKit(cwd, outDir, cfg);
|
|
998
1029
|
stampPush(cwd, outDir, cfg);
|
|
999
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
|
|
1000
1032
|
generateIcons(cwd, outDir, cfg);
|
|
1001
1033
|
copyPwa(cwd, outDir, cfg);
|
|
1002
1034
|
}
|
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}"/>`),
|