@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 +1 -1
- package/runtime/app/main-page.ts +27 -0
- package/runtime/app/shell/capabilities.manifest.ts +32 -1
- package/runtime/app/shell/custom-webview.ios.ts +12 -1
- package/runtime/app/shell/devmenu.ts +49 -15
- package/runtime/app/shell/events.ts +17 -9
- package/runtime/app/shell/handlers-apple-signin.ts +169 -0
- 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/app/shell/sha256.ts +78 -0
- package/runtime/package.json +1 -1
- package/runtime/references.d.ts +2 -0
- package/src/cli.ts +38 -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,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
|
|
21
|
-
|
|
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
|
-
|
|
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 <
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
47
|
+
} else {
|
|
48
|
+
spikes++;
|
|
35
49
|
}
|
|
36
|
-
|
|
50
|
+
lastSpikeAt = now;
|
|
51
|
+
lastSpikeDir = dir;
|
|
52
|
+
if (spikes >= SHAKE_SPIKES) {
|
|
37
53
|
spikes = 0;
|
|
38
|
-
|
|
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
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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;
|
|
@@ -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 = '
|
|
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
|
+
}
|
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,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
|
-
|
|
945
|
-
|
|
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
|
-
|
|
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}"/>`),
|