@livx.cc/appwrap 0.35.0 → 0.37.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.35.0",
3
+ "version": "0.37.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",
@@ -270,6 +270,21 @@ export const MODULES: ModuleManifest[] = [
270
270
  ios: { permissions: [{ key: 'NSUserTrackingUsageDescription', domain: 'tracking', defaultUsage: 'Allow tracking to deliver a more personalized experience and measure ad performance.' }] },
271
271
  },
272
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
+
273
288
  // ── backgroundTask — headless background execution (HEADLESS JS HANDLER) — opt-in, STRIPPABLE ──
274
289
  // The OS wakes the app (possibly cold, no visible WebView) for a permitted task id; the shell builds
275
290
  // an OFFSCREEN WebView, loads the app conveying the id (the handshake reports it), awaits the JS
@@ -289,7 +304,7 @@ export const MODULES: ModuleManifest[] = [
289
304
 
290
305
  /** Opt-in registration groups that own their own NS handler file (strippable when inactive). Core
291
306
  * groups (core/extended/parity/system/media/billing) are always bundled; only these are CLI-gated. */
292
- export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'tracking', 'backgroundTask'] as const;
307
+ export const OPTIONAL_GROUPS = ['health', 'oauth', 'reviews', 'scanner', 'speech', 'tracking', 'appleSignIn', 'backgroundTask'] as const;
293
308
 
294
309
  /** Resolve the active capability map for the handshake from a set of active capability names. */
295
310
  export function buildCapabilityMap(
@@ -17,30 +17,54 @@ export function startDevMenu(): void {
17
17
  else if (isAndroid) startAndroidShake();
18
18
  }
19
19
 
20
- // ── shake detection (total acceleration magnitude in g incl. gravity, debounced) ──
21
- const SHAKE_G = 1.8; // spike vs. ~1g rest
20
+ // ── shake detection — deliberate, robust against incidental motion (walking, bumps) ──
21
+ // A real "shake to open" is a sustained back-and-forth: several HARD spikes, close together, that
22
+ // REVERSE direction each time. Walking/jostle produces occasional same-direction spikes with gaps —
23
+ // so we require (a) a high threshold, (b) direction reversal between consecutive spikes, and (c) a
24
+ // short inter-spike gap that RESETS the count if you pause. The bar is a firm wrist-shake, not a step.
25
+ const SHAKE_G = 2.3; // hard spike vs. ~1g rest (was 1.8 — too low, walking cleared it)
26
+ const SHAKE_SPIKES = 4; // direction-reversing spikes needed (≈2 full back-and-forth shakes)
27
+ const SHAKE_GAP_MS = 400; // max gap between spikes; a longer pause resets the streak
28
+ const MENU_DEBOUNCE_MS = 1500; // don't re-open right after a menu
22
29
  let shakeMm: CMMotionManager | null = null; // hold a ref so CoreMotion isn't GC'd
23
- let firstSpikeAt = 0;
24
30
  let spikes = 0;
31
+ let lastSpikeAt = 0;
32
+ let lastSpikeDir = 0; // sign of the dominant axis at the last spike; reversal = real shake
25
33
  let lastMenuAt = 0;
26
34
 
27
- function onAccelMagnitude(g: number): void {
35
+ /** @param g total accel magnitude in g (rest ≈ 1)
36
+ * @param dir sign (+1/-1) of the dominant-axis acceleration — used to detect back-and-forth reversal */
37
+ function onAccelMagnitude(g: number, dir: number): void {
28
38
  if (g < SHAKE_G) return;
29
39
  const now = Date.now();
30
- if (now - lastMenuAt < 1500) return; // don't re-open right after a menu
31
- if (now - firstSpikeAt > 800) {
32
- firstSpikeAt = now; // start a fresh window
40
+ if (now - lastMenuAt < MENU_DEBOUNCE_MS) return;
41
+ // A spike that's too soon after the last counts as the SAME thrust, not a new one — ignore it so a
42
+ // single hard jolt's ringing doesn't rack up the count.
43
+ if (now - lastSpikeAt < 80) return;
44
+ // Reset the streak unless this spike continues a fast, direction-reversing sequence.
45
+ if (now - lastSpikeAt > SHAKE_GAP_MS || dir === lastSpikeDir) {
33
46
  spikes = 1;
34
- return;
47
+ } else {
48
+ spikes++;
35
49
  }
36
- if (++spikes >= 2) {
50
+ lastSpikeAt = now;
51
+ lastSpikeDir = dir;
52
+ if (spikes >= SHAKE_SPIKES) {
37
53
  spikes = 0;
38
- firstSpikeAt = 0;
54
+ lastSpikeAt = 0;
55
+ lastSpikeDir = 0;
39
56
  lastMenuAt = now;
40
57
  void showDevMenu();
41
58
  }
42
59
  }
43
60
 
61
+ /** Sign of the largest-magnitude component — the axis the shake is thrusting along. */
62
+ function dominantDir(x: number, y: number, z: number): number {
63
+ const ax = Math.abs(x), ay = Math.abs(y), az = Math.abs(z);
64
+ const v = ax >= ay && ax >= az ? x : ay >= az ? y : z;
65
+ return v >= 0 ? 1 : -1;
66
+ }
67
+
44
68
  function startIOSShake(): void {
45
69
  // Poll `mm.deviceMotion` on a JS timer — CoreMotion's queue-handler block is fragile under
46
70
  // NativeScript (can silently stop firing on-device); mirrors the motion.start handler.
@@ -52,10 +76,12 @@ function startIOSShake(): void {
52
76
  setInterval(() => {
53
77
  const m = mm.deviceMotion;
54
78
  if (!m) return; // first sample not ready
55
- const x = m.userAcceleration.x + m.gravity.x; // total accel in g (rest ≈ 1)
56
- const y = m.userAcceleration.y + m.gravity.y;
57
- const z = m.userAcceleration.z + m.gravity.z;
58
- onAccelMagnitude(Math.sqrt(x * x + y * y + z * z));
79
+ const ux = m.userAcceleration.x, uy = m.userAcceleration.y, uz = m.userAcceleration.z;
80
+ const x = ux + m.gravity.x; // total accel in g (rest ≈ 1) — magnitude includes gravity
81
+ const y = uy + m.gravity.y;
82
+ const z = uz + m.gravity.z;
83
+ // Direction from gravity-free userAcceleration (gravity is constant → would mask the back-and-forth).
84
+ onAccelMagnitude(Math.sqrt(x * x + y * y + z * z), dominantDir(ux, uy, uz));
59
85
  }, 100);
60
86
  }
61
87
 
@@ -66,11 +92,19 @@ function startAndroidShake(): void {
66
92
  const accel = sm.getDefaultSensor(android.hardware.Sensor.TYPE_ACCELEROMETER);
67
93
  if (!accel) return;
68
94
  const G = 9.80665;
95
+ // Low-pass gravity estimate so we can derive gravity-free direction (mirrors iOS userAcceleration);
96
+ // the raw accelerometer includes gravity, which is constant and would mask the back-and-forth.
97
+ const grav = [0, 0, 0];
98
+ const alpha = 0.8;
69
99
  const listener = new android.hardware.SensorEventListener({
70
100
  onAccuracyChanged() {},
71
101
  onSensorChanged(e: android.hardware.SensorEvent) {
72
102
  const v = e.values;
73
- onAccelMagnitude(Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) / G);
103
+ grav[0] = alpha * grav[0] + (1 - alpha) * v[0];
104
+ grav[1] = alpha * grav[1] + (1 - alpha) * v[1];
105
+ grav[2] = alpha * grav[2] + (1 - alpha) * v[2];
106
+ const lx = v[0] - grav[0], ly = v[1] - grav[1], lz = v[2] - grav[2]; // linear (gravity-free)
107
+ onAccelMagnitude(Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]) / G, dominantDir(lx, ly, lz));
74
108
  },
75
109
  });
76
110
  sm.registerListener(listener, accel, android.hardware.SensorManager.SENSOR_DELAY_UI);
@@ -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
+ }
@@ -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 = 'global-observer-1';
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; }
@@ -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/src/cli.ts CHANGED
@@ -87,6 +87,12 @@ function resolveAssetRoot(rel: string): string {
87
87
  const TEMPLATE_DIR = resolveAssetRoot('runtime');
88
88
  const CI_TEMPLATE_DIR = resolveAssetRoot('templates/ci');
89
89
 
90
+ /** This CLI's own published version — used to pin the `bunx @livx.cc/appwrap@^x.y.z` invocations the
91
+ * emitted workflow runs. Pinning to THIS version's floor means CI fails LOUDLY ("version not found")
92
+ * until that version is published, instead of bunx silently resolving an older published build that
93
+ * lacks `release`/`init` flags. package.json sits one level above src/ (published) or the monorepo dir. */
94
+ const CLI_VERSION: string = (await import(resolve(import.meta.dir, '..', 'package.json'), { with: { type: 'json' } })).default.version;
95
+
90
96
  // Load the capability manifest VALUES from the resolved runtime (pure data — safe outside NativeScript).
91
97
  // Top-level await resolves before any command dispatches at the bottom of this file.
92
98
  const { MODULES, OPTIONAL_GROUPS } = (await import(
@@ -179,6 +185,7 @@ const OPTIONAL_GROUP_HANDLERS: Record<string, { file: string; fn: string }> = {
179
185
  scanner: { file: './handlers-scanner', fn: 'registerScannerHandlers' },
180
186
  speech: { file: './handlers-speech', fn: 'registerSpeechHandlers' },
181
187
  tracking: { file: './handlers-tracking', fn: 'registerTrackingHandlers' },
188
+ appleSignIn: { file: './handlers-apple-signin', fn: 'registerAppleSignInHandlers' },
182
189
  backgroundTask: { file: './handlers-background', fn: 'registerBackgroundTaskHandlers' },
183
190
  };
184
191
 
@@ -958,23 +965,34 @@ export function isFrameworkRepo(root: string): boolean {
958
965
  return existsSync(join(root, 'packages/appwrap-cli/src/cli.ts'));
959
966
  }
960
967
 
961
- /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/). Never overwrites. */
962
- function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig): void {
968
+ /** Emit CI scaffolding (GH Actions → git repo root, fastlane → native/).
969
+ * GH workflows are never overwritten (users may customize them). The fastlane lane IS appwrap-managed
970
+ * (the release recipe, not for hand-editing — see AGENTS.md), so it is RE-EMITTED on `--force` to keep
971
+ * the recipe current after a framework upgrade; without --force it's still first-time-only. */
972
+ function copyCiTemplates(cwd: string, outDir: string, cfg: AppwrapConfig, force = false): void {
963
973
  if (!existsSync(CI_TEMPLATE_DIR)) return;
964
974
  const repoRoot = gitRoot(cwd);
965
975
  // GitHub only reads `.github/workflows` at the REPO ROOT — in a monorepo, writing it under the
966
976
  // package cwd (e.g. packages/app/.github) is dead config and regenerates a stray workflow each init.
967
- const targets: Array<[string, string]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane')]];
977
+ // [from, to, overwritable]
978
+ const targets: Array<[string, string, boolean]> = [[join(CI_TEMPLATE_DIR, 'fastlane'), join(outDir, 'fastlane'), force]];
968
979
  // …but if the repo root IS the appwrap framework itself (in-repo example), DON'T scaffold consumer
969
980
  // workflows into the framework's .github — that's the stray-workflow-each-init bug.
970
981
  if (isFrameworkRepo(repoRoot)) {
971
982
  console.log(' ci ← GH Actions scaffold skipped (inside the appwrap framework repo — manages its own CI)');
972
983
  } else {
973
- targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows')]);
984
+ targets.unshift([join(CI_TEMPLATE_DIR, 'github/workflows'), join(repoRoot, '.github/workflows'), false]);
974
985
  }
975
- for (const [from, to] of targets) {
986
+ for (const [from, to, overwrite] of targets) {
976
987
  mkdirSync(to, { recursive: true });
977
- cpSync(from, to, { recursive: true, force: false, errorOnExist: false });
988
+ cpSync(from, to, { recursive: true, force: overwrite, errorOnExist: false });
989
+ }
990
+ // Pin the emitted workflow's `bunx @livx.cc/appwrap@^x.y.z` to THIS CLI's version floor, so a CI run
991
+ // using a freshly-emitted workflow can't silently resolve an older published build that lacks the
992
+ // `init`/`release` commands (it would 404 loudly instead). Idempotent: re-init finds no placeholder.
993
+ if (!isFrameworkRepo(repoRoot)) {
994
+ const wf = join(repoRoot, '.github/workflows/appwrap-release-ios.yml');
995
+ if (existsSync(wf)) writeFileSync(wf, readFileSync(wf, 'utf8').replaceAll('__APPWRAP_VERSION__', CLI_VERSION));
978
996
  }
979
997
  // Stamp the app id + team into the emitted fastlane (signing needs them; the templates ship
980
998
  // `__APP_ID__`/`__TEAM_ID__` placeholders). Idempotent: re-init finds no placeholders → no-op.
@@ -1059,7 +1077,7 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1059
1077
  console.log(`🎁 appwrap init → ${outDir}`);
1060
1078
  mkdirSync(outDir, { recursive: true });
1061
1079
  regenerateCore(cwd, outDir, cfg, { firstRun: true, flags });
1062
- copyCiTemplates(cwd, outDir, cfg); // first-time scaffold (never overwrites)
1080
+ copyCiTemplates(cwd, outDir, cfg, 'force' in flags); // GH workflows: first-time only; fastlane lane: re-emit on --force
1063
1081
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1064
1082
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
1065
1083
  stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
@@ -1175,6 +1193,84 @@ async function build(cwd: string, flags: Record<string, string>, positionals: st
1175
1193
  }
1176
1194
  }
1177
1195
 
1196
+ /** `appwrap release ios` — the ONE build+sign+upload-to-TestFlight command, identical locally and in CI.
1197
+ *
1198
+ * It re-stamps the config + PWA (`sync`) and then delegates the full archive/sign/upload to the emitted
1199
+ * fastlane lane (`native/fastlane` `:beta`) — the SINGLE source of truth for the iOS release recipe (the
1200
+ * lane runs `ns prepare ios --release` → match signing → build_app → upload_to_testflight). CI is a thin
1201
+ * wrapper that just calls this. Keeping the recipe in fastlane (not duplicated in TS) means local and CI
1202
+ * run byte-identical steps.
1203
+ *
1204
+ * Knobs (all optional; mirror the workflow):
1205
+ * --server-url <url> override loader:'server' serverUrl for this release (lab vs prod backend)
1206
+ * --env <name> convenience: resolve serverUrl from cfg.envs[name] when present (see config)
1207
+ * --build-number <n> set the store CFBundleVersion (sets APPWRAP_BUILD_NUMBER for the lane)
1208
+ *
1209
+ * Signing/ASC config is read from env by the lane (ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_P8 /
1210
+ * MATCH_GIT_URL / MATCH_PASSWORD) — secrets never live in appwrap.config. */
1211
+ async function release(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1212
+ const platform = positionals[0];
1213
+ if (platform !== 'ios') {
1214
+ console.error('Usage: appwrap release ios [--server-url <url>] [--env <name>] [--build-number <n>] [--config <path>] [--out native]\n' +
1215
+ ' (Android: `appwrap build android --release --aab` then `fastlane android beta`.)');
1216
+ process.exit(1);
1217
+ }
1218
+ const cfg = await loadConfig(cwd, flags);
1219
+ const outDir = resolve(cwd, flags.out ?? 'native');
1220
+ if (!existsSync(outDir)) {
1221
+ console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first (CI must \`init\`, native/ is gitignored).`);
1222
+ process.exit(1);
1223
+ }
1224
+ const fastfile = join(outDir, 'fastlane', 'Fastfile');
1225
+ if (!existsSync(fastfile)) {
1226
+ console.error(`✖ No fastlane lane at ${fastfile} — run \`appwrap init\` to emit it (it carries the release recipe).`);
1227
+ process.exit(1);
1228
+ }
1229
+
1230
+ // Optional backend-url override for loader:'server' apps (lab vs prod). --server-url wins; else
1231
+ // --env resolves from cfg.envs[name] when the consumer config declares it.
1232
+ let serverUrl = flags['server-url'] || undefined;
1233
+ if (!serverUrl && flags.env) {
1234
+ serverUrl = (cfg as { envs?: Record<string, string> }).envs?.[flags.env];
1235
+ if (!serverUrl) {
1236
+ console.error(`✖ --env ${flags.env} given but cfg.envs[${flags.env}] is not set in the config.`);
1237
+ process.exit(1);
1238
+ }
1239
+ }
1240
+ const stampCfg = serverUrl ? { ...cfg, loader: 'server' as const, serverUrl } : cfg;
1241
+
1242
+ // Build number: explicit flag → APPWRAP_BUILD_NUMBER (which the CLI's stamping already honors and
1243
+ // wins over the derived default). In CI the workflow sets APPWRAP_BUILD_NUMBER itself.
1244
+ const env = { ...process.env };
1245
+ if (flags['build-number']) {
1246
+ if (!/^\d+$/.test(flags['build-number'])) {
1247
+ console.error(`✖ --build-number must be a positive integer (got "${flags['build-number']}").`);
1248
+ process.exit(1);
1249
+ }
1250
+ env.APPWRAP_BUILD_NUMBER = flags['build-number'];
1251
+ }
1252
+
1253
+ // Re-stamp config + copy the latest PWA into native/ so the lane archives current sources. (The lane
1254
+ // also runs `ns prepare ios --release`; sync here makes the wrapper config/PWA authoritative first.)
1255
+ await sync(cwd, flags);
1256
+ if (serverUrl) {
1257
+ stampShellConfig(outDir, stampCfg);
1258
+ console.log(`✓ Release loader → ${serverUrl}${flags.env ? ` (env: ${flags.env})` : ''}`);
1259
+ }
1260
+
1261
+ console.log(`▶ fastlane ios beta (cwd: ${outDir}/fastlane → native/)${env.APPWRAP_BUILD_NUMBER ? ` build #${env.APPWRAP_BUILD_NUMBER}` : ''}`);
1262
+ try {
1263
+ execFileSync('fastlane', ['ios', 'beta'], { cwd: outDir, stdio: 'inherit', env });
1264
+ } catch {
1265
+ console.error('\n✖ TestFlight release failed. Common causes:\n' +
1266
+ ' • Missing ASC/match env: ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 (base64), MATCH_GIT_URL, MATCH_PASSWORD.\n' +
1267
+ ' • Certs/profiles not seeded — run `fastlane match appstore` once against MATCH_GIT_URL.\n' +
1268
+ ' • CFBundleVersion already used for this marketing version → pass a higher --build-number.');
1269
+ process.exit(1);
1270
+ }
1271
+ console.log('✓ Uploaded to TestFlight (App Store Connect processing — check the build list / wait for the email).');
1272
+ }
1273
+
1178
1274
  interface AppleTeam { teamId: string; name: string; email?: string; paid: boolean }
1179
1275
  interface DeviceInfo { id: string; name: string; model: string; transport: string }
1180
1276
 
@@ -1711,13 +1807,17 @@ async function main(): Promise<void> {
1711
1807
  case 'deploy':
1712
1808
  await deploy(cwd, flags, positionals);
1713
1809
  break;
1810
+ case 'release':
1811
+ await release(cwd, flags, positionals);
1812
+ break;
1714
1813
  case 'logs':
1715
1814
  await logs(cwd, flags, positionals);
1716
1815
  break;
1717
1816
  default:
1718
- console.log('Usage: appwrap <init|sync|dev|build|deploy|logs> [--config <path>] [--out native]\n' +
1817
+ console.log('Usage: appwrap <init|sync|dev|build|deploy|release|logs> [--config <path>] [--out native]\n' +
1719
1818
  ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
1720
1819
  ' build <ios|android> [--release] [--aab] deploy ios [--device <id|name>] [--no-launch]\n' +
1820
+ ' release ios [--server-url <url>] [--env <name>] [--build-number <n>] (build+sign+upload to TestFlight)\n' +
1721
1821
  ' logs ios [--once] [--native] dev [--url <url> | --port <p>]');
1722
1822
  process.exit(command ? 1 : 0);
1723
1823
  }
@@ -6,6 +6,36 @@ default_platform(:ios)
6
6
  APP_ID = '__APP_ID__'.freeze
7
7
  TEAM_ID = '__TEAM_ID__'.freeze
8
8
 
9
+ # Run `ns prepare` (which shells out to `pod install`) with a SANITIZED gem env.
10
+ #
11
+ # Why: fastlane runs under its own Ruby and exports GEM_HOME/GEM_PATH into every child process. On a
12
+ # Mac, NativeScript's `pod` is the system one (`/usr/local/bin/pod`, shebang-pinned to Ruby 2.6, whose
13
+ # CocoaPods lives in /Library/Ruby/Gems/2.6.0). The inherited GEM_HOME redirects its gem lookup to
14
+ # fastlane's Ruby dir → `can't find gem cocoapods (Gem::GemNotFoundException)` → NS prints
15
+ # `'arch install' command failed` (exit 127). Interactively `pod` works because no GEM_HOME is set.
16
+ #
17
+ # Fix: only when the active `pod` is that system-Ruby-2.6 binary, drop the leaked GEM_* vars so it
18
+ # falls back to its own default gem dir. CI runners use a self-managed Ruby (setup-ruby) whose `pod`
19
+ # is NOT the system 2.6 one, so the env is left untouched there — no behavior change on CI.
20
+ def appwrap_prepare_ios
21
+ prepare = "cd '#{File.expand_path('..', __dir__)}' && ns prepare ios --release"
22
+ pod = `command -v pod`.strip
23
+ system_pod = !pod.empty? && File.exist?(pod) &&
24
+ File.read(pod, 256).include?('Ruby.framework/Versions/2.6')
25
+ unless system_pod
26
+ sh(prepare)
27
+ return
28
+ end
29
+ leaked = %w[GEM_HOME GEM_PATH RUBYLIB RUBYOPT BUNDLE_GEMFILE BUNDLE_BIN_PATH]
30
+ saved = {}
31
+ leaked.each { |k| saved[k] = ENV[k]; ENV.delete(k) }
32
+ begin
33
+ sh(prepare)
34
+ ensure
35
+ saved.each { |k, v| ENV[k] = v unless v.nil? }
36
+ end
37
+ end
38
+
9
39
  platform :ios do
10
40
  desc 'Build the NativeScript wrapper and upload to TestFlight'
11
41
  lane :beta do
@@ -23,7 +53,8 @@ platform :ios do
23
53
  git_url: ENV['MATCH_GIT_URL'], api_key: api_key)
24
54
 
25
55
  # NativeScript prepares the Xcode project inside the wrapper dir (native/ = parent of fastlane/).
26
- sh("cd '#{File.expand_path('..', __dir__)}' && ns prepare ios --release")
56
+ # Wrapped to sanitize the gem env so the system `pod` can find CocoaPods (see appwrap_prepare_ios).
57
+ appwrap_prepare_ios
27
58
 
28
59
  # NS defaults the project to automatic signing → archive can't find a profile. Pin manual signing
29
60
  # to the match-provided App Store profile (name exported by match into ENV).
@@ -34,7 +34,13 @@ jobs:
34
34
  - run: bun install && bun run build
35
35
  # Pin global tools — unpinned installs can grab a breaking release mid-flight. Bump deliberately.
36
36
  - run: npm i -g nativescript@9.0.6
37
- - run: bunx @livx.cc/appwrap init # native/ is generated (gitignored) — regenerate it fresh in CI
37
+ # bunx is PINNED to the appwrap version that emitted this workflow (stamped at `init`). Pinning is
38
+ # load-bearing: an UNPINNED `bunx @livx.cc/appwrap` resolves to whatever is currently published,
39
+ # which may pre-date the `init`/`release` commands and would silently run old code + exit 1 with a
40
+ # confusing usage dump. With the pin, CI fails LOUDLY ("No matching version") until that version is
41
+ # published to the @livx.cc registry. PUBLISH-ORDERING: publish the appwrap CLI BEFORE the first CI
42
+ # run that uses a freshly-emitted workflow.
43
+ - run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ init # native/ is generated (gitignored) — regenerate it fresh in CI
38
44
  - working-directory: native
39
45
  run: npm install
40
46
  - uses: ruby/setup-ruby@v1
@@ -47,14 +53,15 @@ jobs:
47
53
  chmod 600 ~/.ssh/match_deploy_key
48
54
  printf 'Host github.com\n IdentityFile ~/.ssh/match_deploy_key\n IdentitiesOnly yes\n' >> ~/.ssh/config
49
55
  ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
56
+ # Single source of truth: the SAME command a developer runs locally. It re-stamps native/ and
57
+ # delegates the full archive/sign/upload to the emitted fastlane lane — no fastlane logic here.
50
58
  - name: Build & upload to TestFlight
51
- working-directory: native
52
59
  env:
53
60
  MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
54
61
  MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
55
62
  ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
56
63
  ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
57
64
  ASC_KEY_P8: ${{ secrets.ASC_KEY_P8 }}
58
- run: fastlane ios beta
65
+ run: bunx @livx.cc/appwrap@^__APPWRAP_VERSION__ release ios
59
66
 
60
67
  # Production promotion is a manual gate: promote the TestFlight build in App Store Connect.