@scalebun/react-native 1.10.5 → 1.10.7

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.
Files changed (64) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +75 -0
  2. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +12 -0
  3. package/dist/scalebun.full.js +485 -51
  4. package/dist/scalebun.slim.js +485 -51
  5. package/ios/Core/DeviceIdentity.swift +50 -0
  6. package/ios/Ota/ScaleBunOtaBridge.mm +6 -0
  7. package/ios/Ota/ScaleBunOtaModule.swift +67 -0
  8. package/ios/Profiler/ScaleBunProfilerModule.swift +2 -8
  9. package/ios/ReplaySdk.swift +3 -1
  10. package/lib/commonjs/analytics/EventTracker.js +22 -0
  11. package/lib/commonjs/core/config/schema.js +34 -1
  12. package/lib/commonjs/core/constants/version.js +1 -1
  13. package/lib/commonjs/core/context/device.js +5 -0
  14. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +24 -0
  15. package/lib/commonjs/features/journey/uiState.js +87 -0
  16. package/lib/commonjs/features/ota/crypto/builtinVerifier.js +248 -0
  17. package/lib/commonjs/features/ota/crypto/loadEd25519.js +40 -0
  18. package/lib/commonjs/features/ota/crypto/loadSha512.js +40 -0
  19. package/lib/commonjs/features/ota/crypto/nativeVerifier.js +121 -0
  20. package/lib/commonjs/features/ota/signature.js +87 -27
  21. package/lib/commonjs/metro/serializerCompose.js +32 -0
  22. package/lib/commonjs/public/ScaleBunFacade.js +108 -11
  23. package/lib/module/analytics/EventTracker.js +22 -0
  24. package/lib/module/core/config/schema.js +34 -1
  25. package/lib/module/core/constants/version.js +1 -1
  26. package/lib/module/core/context/device.js +5 -0
  27. package/lib/module/features/journey/ScaleBunDebugRoot.js +24 -0
  28. package/lib/module/features/journey/uiState.js +79 -0
  29. package/lib/module/features/ota/crypto/builtinVerifier.js +240 -0
  30. package/lib/module/features/ota/crypto/loadEd25519.js +34 -0
  31. package/lib/module/features/ota/crypto/loadSha512.js +34 -0
  32. package/lib/module/features/ota/crypto/nativeVerifier.js +113 -0
  33. package/lib/module/features/ota/signature.js +87 -27
  34. package/lib/module/metro/serializerCompose.js +32 -0
  35. package/lib/module/public/ScaleBunFacade.js +109 -12
  36. package/lib/typescript/analytics/EventTracker.d.ts +15 -0
  37. package/lib/typescript/core/config/schema.d.ts +2 -0
  38. package/lib/typescript/core/constants/version.d.ts +1 -1
  39. package/lib/typescript/core/context/device.d.ts +1 -0
  40. package/lib/typescript/features/journey/uiState.d.ts +53 -0
  41. package/lib/typescript/features/ota/OtaTypes.d.ts +50 -0
  42. package/lib/typescript/features/ota/crypto/builtinVerifier.d.ts +53 -0
  43. package/lib/typescript/features/ota/crypto/loadEd25519.d.ts +30 -0
  44. package/lib/typescript/features/ota/crypto/loadSha512.d.ts +15 -0
  45. package/lib/typescript/features/ota/crypto/nativeVerifier.d.ts +35 -0
  46. package/lib/typescript/features/ota/signature.d.ts +22 -7
  47. package/lib/typescript/public/ScaleBunFacade.d.ts +43 -6
  48. package/lib/typescript/specs/NativeScaleBunOta.d.ts +23 -0
  49. package/package.json +18 -3
  50. package/src/analytics/EventTracker.ts +19 -0
  51. package/src/core/config/schema.ts +30 -3
  52. package/src/core/constants/version.ts +1 -1
  53. package/src/core/context/device.ts +6 -0
  54. package/src/features/journey/ScaleBunDebugRoot.tsx +22 -0
  55. package/src/features/journey/uiState.ts +84 -0
  56. package/src/features/ota/OtaTypes.ts +51 -0
  57. package/src/features/ota/crypto/builtinVerifier.ts +257 -0
  58. package/src/features/ota/crypto/loadEd25519.ts +41 -0
  59. package/src/features/ota/crypto/loadSha512.ts +35 -0
  60. package/src/features/ota/crypto/nativeVerifier.ts +117 -0
  61. package/src/features/ota/signature.ts +108 -25
  62. package/src/metro/serializerCompose.ts +38 -2
  63. package/src/public/ScaleBunFacade.ts +128 -14
  64. package/src/specs/NativeScaleBunOta.ts +24 -0
@@ -106,6 +106,9 @@ export function discoverArtifacts(packageRoot: string): ArtifactMapping[] {
106
106
  return out;
107
107
  }
108
108
 
109
+ /** One-shot guard so an Expo build does not print the pass-through notice per bundle. */
110
+ let warnedUnknownShape = false;
111
+
109
112
  /**
110
113
  * Build the composing serializer. `hostSerializer` is the app's own customSerializer, if any.
111
114
  */
@@ -122,10 +125,43 @@ export function createComposingSerializer(
122
125
  const hostResult = await hostSerializer(entryPoint, preModules, graph, options);
123
126
  if (typeof hostResult === 'string') {
124
127
  code = hostResult;
128
+ } else if (hostResult && typeof (hostResult as any).code !== 'string') {
129
+ /**
130
+ * A shape this wrapper does not understand — pass it back UNTOUCHED.
131
+ *
132
+ * Expo is the case that matters. `expo/metro-config` installs a
133
+ * serializer returning `{ artifacts: [...] }`, a multi-artifact
134
+ * shape with no top-level `code`. Reading `.code` off it yielded
135
+ * undefined, and returning `{ code: undefined, map }` made every
136
+ * `expo export:embed` die with:
137
+ *
138
+ * Serializer did not return expected format. The project copy
139
+ * of `expo/metro-config` may be out of date.
140
+ *
141
+ * — a message that sends you to upgrade Expo, which is not the
142
+ * problem. The result: `withScaleBun` broke PRODUCTION BUNDLING
143
+ * FOR EVERY EXPO APP, and the SDK's own marketing test app could
144
+ * not build. Found by bundling it.
145
+ *
146
+ * Passing it through costs ScaleBun's source-map composition on
147
+ * Expo (OTA stack traces stay mapped to the bundle rather than to
148
+ * original sources). That is a real loss and strictly better than
149
+ * a build that cannot run at all. Composing INTO an artifact
150
+ * array is the follow-up; it must not be guessed at here.
151
+ */
152
+ if (!warnedUnknownShape) {
153
+ warnedUnknownShape = true;
154
+ console.warn(
155
+ '[ScaleBun/metro] The host serializer returned a shape this SDK does not ' +
156
+ 'compose (Expo returns { artifacts }). Passing it through unchanged — ' +
157
+ 'the build is correct, but ScaleBun source-map composition is skipped.',
158
+ );
159
+ }
160
+ return hostResult as never;
125
161
  } else {
126
- code = hostResult.code;
162
+ code = (hostResult as { code: string; map: string }).code;
127
163
  try {
128
- mapObj = JSON.parse(hostResult.map);
164
+ mapObj = JSON.parse((hostResult as { code: string; map: string }).map);
129
165
  } catch {
130
166
  // Host emitted an unparsable map; fall through and rebuild from the graph so
131
167
  // the build still succeeds with a correct map rather than failing here.
@@ -20,7 +20,7 @@ import { PersistentQueue } from '../pipeline/queue/persistentQueue';
20
20
  import { MemoryBackend, createStorageBackend } from '../storage/StorageBackend';
21
21
  import { HttpClient } from '../transport/http/httpClient';
22
22
  import { getDeviceId } from '../core/id/deviceId';
23
- import { mergeDeviceContext, resolveEventPlatform } from '../core/context/device';
23
+ import { mergeDeviceContext, nativeDeviceContext, resolveEventPlatform } from '../core/context/device';
24
24
  import { coreContainer } from '../core/di/container';
25
25
  import { flushQueue } from '../pipeline/queue/flushQueue';
26
26
  import {
@@ -48,6 +48,7 @@ import type {
48
48
  } from '../features/replay/public/types';
49
49
  import { SDKBootstrapper } from '../bootstrap/SDKBootstrapper';
50
50
  import { EventTracker } from '../analytics/EventTracker';
51
+ import { bridgeAdapter } from '../features/replay/bridge/adapters/bridgeAdapter';
51
52
  import {
52
53
  emitAutomaticEvent,
53
54
  type AutomaticEventName,
@@ -274,12 +275,17 @@ class ScaleBunFacade {
274
275
  /**
275
276
  * Boot the OTA orchestrator when the init config asks for it.
276
277
  *
277
- * `publicSigningKey` is honoured here so signature enforcement is reachable
278
- * from configuration alone. The SDK ships no ed25519 implementation, so a
279
- * host that pins a key must also supply `ota.verifySignature`; pinning a key
280
- * without a verifier is fail-CLOSED by design (signature.ts) an update
281
- * that cannot be verified is not installed. We say that out loud rather than
282
- * letting the app discover it as a silent no-update condition.
278
+ * `publicSigningKey` / `publicSigningKeys` are honoured here so signature
279
+ * enforcement is reachable from configuration alone. The list form exists
280
+ * for key ROTATION: a build pinning [old, new] keeps verifying while the
281
+ * server moves to the new key, so replacing a key never needs an emergency
282
+ * store release. Both fields merge (deduplicated) into one pinned set.
283
+ *
284
+ * Verification comes from `ota.verifySignature` when supplied, else the
285
+ * built-in @noble-based verifier (optional peers). Pinning keys with
286
+ * NEITHER available is fail-CLOSED by design (signature.ts) — an update
287
+ * that cannot be verified is not installed. We say that out loud rather
288
+ * than letting the app discover it as a silent no-update condition.
283
289
  */
284
290
  private _maybeStartOta(rawConfig: any): void {
285
291
  const ota = rawConfig?.ota;
@@ -289,16 +295,49 @@ class ScaleBunFacade {
289
295
  // module (or touch the native spec) at import time.
290
296
  // eslint-disable-next-line @typescript-eslint/no-var-requires
291
297
  const { otaOrchestrator } = require('../features/ota/OtaOrchestrator');
292
- const publicKey = ota.publicSigningKey;
298
+ const rawSingle = ota.publicSigningKey;
299
+ const rawList = ota.publicSigningKeys;
300
+ const publicKeys = Array.from(
301
+ new Set(
302
+ [
303
+ ...(typeof rawSingle === 'string' && rawSingle ? [rawSingle] : []),
304
+ ...(Array.isArray(rawList) ? rawList : []),
305
+ ].filter((k: unknown): k is string => typeof k === 'string' && k.trim().length > 0),
306
+ ),
307
+ );
308
+ // Signing was REQUESTED if a usable key exists, or the host passed a
309
+ // keys array at all — an empty one included. `publicSigningKeys: []`
310
+ // is a broken attempt to enable signing, and it must flow through to
311
+ // signature.ts's fail-closed handling, not silently disable signing.
312
+ const signingRequested = publicKeys.length > 0 || Array.isArray(rawList);
293
313
  const verifier = ota.verifySignature;
294
- if (publicKey && typeof verifier !== 'function') {
295
- logger.warn(
296
- '[ScaleBun] ota.publicSigningKey is set but ota.verifySignature is not a function. ' +
297
- 'Signature checking is fail-closed: updates will be REJECTED until a verifier is supplied.',
298
- );
314
+ if (signingRequested && typeof verifier !== 'function') {
315
+ // A host verifier is optional since the built-in one landed — the
316
+ // old unconditional warning here told every correctly-configured
317
+ // app that its updates would be rejected. Warn only when neither
318
+ // verifier can actually be resolved.
319
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
320
+ const { getBuiltinVerifier } = require('../features/ota/crypto/builtinVerifier');
321
+ if (!getBuiltinVerifier()) {
322
+ logger.warn(
323
+ '[ScaleBun] A signing key is pinned but no signature verifier is available. ' +
324
+ 'Signature checking is fail-closed: updates will be REJECTED until one exists. ' +
325
+ 'Install the optional peers `@noble/ed25519` + `@noble/hashes` (no further ' +
326
+ 'code needed), or supply `ota.verifySignature`.',
327
+ );
328
+ }
299
329
  }
300
330
  otaOrchestrator.init({
301
- ...(publicKey ? { signature: { publicKey, verifier } } : {}),
331
+ ...(signingRequested
332
+ ? {
333
+ signature: {
334
+ // Single key stays a plain string — the shape every
335
+ // existing consumer and test already handles.
336
+ publicKey: publicKeys.length === 1 ? publicKeys[0] : publicKeys,
337
+ verifier,
338
+ },
339
+ }
340
+ : {}),
302
341
  ...(typeof ota.healthyTimeoutMs === 'number'
303
342
  ? { healthyTimeoutMs: ota.healthyTimeoutMs }
304
343
  : {}),
@@ -368,6 +407,8 @@ class ScaleBunFacade {
368
407
  * the whole time. The SDK knows the device; the integrator is guessing at it.
369
408
  * Everything outside the detected keys is still theirs.
370
409
  */
410
+ // Everything Platform.constants knows. On iOS that is neither the model nor the
411
+ // manufacturer, so the bridge completes it below once it answers.
371
412
  context: mergeDeviceContext(rawConfig?.context),
372
413
  // Use the same foreground id as replay/journey capture. The
373
414
  // callback is resolved per event because the session subsystem
@@ -384,6 +425,19 @@ class ScaleBunFacade {
384
425
  this._eventTracker.start();
385
426
  __DEV__ && logger.info('[ScaleBun] Envelope event tracking started → /v1/batch.');
386
427
 
428
+ // Complete the device context from the native bridge.
429
+ //
430
+ // `Platform.constants` answers Android synchronously — model and manufacturer ride the
431
+ // first event. It answers NEITHER on iOS, so without this every iOS installation
432
+ // reported no device model at all: `nativeDeviceContext` existed for exactly this and
433
+ // had no caller, and the bridge's own answer was `UIDevice.current.model` — the string
434
+ // "iPhone", identical on every iPhone ever made.
435
+ //
436
+ // Fire-and-forget on purpose. The bridge is async and absent entirely on Expo Go or an
437
+ // older native binary; waiting on it would delay the first event, and failing on it
438
+ // would take analytics down over a nice-to-have field.
439
+ this._applyNativeDeviceContext(this._eventTracker);
440
+
387
441
  // Android: capture the Play Install Referrer once per install → feeds the
388
442
  // deterministic click_id path + a one-off install_referrer event (UTM/gclid).
389
443
  // Fire-and-forget; no-op off Android.
@@ -405,6 +459,30 @@ class ScaleBunFacade {
405
459
  }
406
460
  }
407
461
 
462
+
463
+ /**
464
+ * Fold the native bridge's device facts into the analytics context once it answers.
465
+ *
466
+ * Detected values never overwrite a populated one with an empty one — `nativeDeviceContext`
467
+ * drops absent keys and `applyDetectedContext` ignores an empty patch — so a bridge that
468
+ * cannot answer leaves whatever `Platform.constants` already found.
469
+ */
470
+ private _applyNativeDeviceContext(tracker: EventTracker): void {
471
+ Promise.resolve()
472
+ .then(() => bridgeAdapter.getDeviceInfo())
473
+ .then(info => {
474
+ if (!info) return;
475
+ tracker.applyDetectedContext(
476
+ nativeDeviceContext({
477
+ osVersion: info.osVersion,
478
+ deviceModel: info.deviceModel,
479
+ // iOS ships no manufacturer over the bridge; it is a constant there and the
480
+ // static path already set it. Android's arrives via Platform.constants.
481
+ }),
482
+ );
483
+ })
484
+ .catch(() => { /* bridge unavailable — the static context stands */ });
485
+ }
408
486
  /**
409
487
  * Android-only: read the Play Install Referrer once per install and route it into
410
488
  * attribution. `scalebun_click_id` → the deterministic click path; the full
@@ -486,6 +564,42 @@ class ScaleBunFacade {
486
564
  }
487
565
 
488
566
  /** Convenience: emit a Phase 1 purchase event (revenue + currency + transaction_id). */
567
+ /**
568
+ * Declare which UI state the user is looking at, so taps are attributed to the surface they
569
+ * happened on rather than averaged across every variant of the screen.
570
+ *
571
+ * ScaleBun.setUiState('filter-sheet', 'open');
572
+ * ScaleBun.setUiState('checkout-step', 'payment');
573
+ *
574
+ * Call it when the state CHANGES — the value is read at tap time, so it only has to be right by
575
+ * the time the next tap lands. On React Native this is the ONLY source of UI state: unlike the web,
576
+ * there is no queryable accessibility tree that says a sheet is up, and guessing would produce a
577
+ * state key that is right on some apps and silently wrong on others.
578
+ *
579
+ * Names and values are identifiers, not content: they become query keys in the dashboard, so a
580
+ * person's name or a cart total does not belong in one. `;`, `:` and `|` are stripped and both
581
+ * halves are capped at 32 characters.
582
+ */
583
+ public setUiState(name: string, value: string): void {
584
+ try {
585
+ const { setUiState } = require('../features/journey/uiState');
586
+ setUiState(name, value);
587
+ } catch { /* no-throw: a state declaration must never break the host */ }
588
+ }
589
+
590
+ /**
591
+ * Stop reporting a UI state dimension — or, with no argument, all of them.
592
+ *
593
+ * Clear on screen unmount. A declaration left behind follows the user onto a surface where it means
594
+ * nothing, and every tap there is filed under a state that was not on screen.
595
+ */
596
+ public clearUiState(name?: string): void {
597
+ try {
598
+ const { clearUiState } = require('../features/journey/uiState');
599
+ clearUiState(name);
600
+ } catch { /* no-throw */ }
601
+ }
602
+
489
603
  public trackPurchase(input: { revenue: number; currency: string; transactionId: string; [k: string]: any }): void {
490
604
  noThrow(() => this._eventTracker?.trackPurchase(input));
491
605
  }
@@ -85,6 +85,30 @@ export interface Spec extends TurboModule {
85
85
  * `recreateReactContextInBackground()` (bridge). iOS: RCTReloadCommand.
86
86
  */
87
87
  restartApp(): void;
88
+
89
+ /**
90
+ * Verify a detached ed25519 signature in NATIVE code.
91
+ *
92
+ * Exists because the JS verifier runs inside the very bundle it protects —
93
+ * an attacker who lands one malicious bundle can neuter a JS check for every
94
+ * update after it. Platform crypto is outside the bundle's reach, and it also
95
+ * removes the runtime dependency on the optional @noble peers wherever the OS
96
+ * provides ed25519 (iOS 13+ CryptoKit everywhere; Android API 33+).
97
+ *
98
+ * All three arguments are lowercase hex, pre-validated by the JS caller:
99
+ * the 32-byte bundle SHA-256 (the signature is over its RAW BYTES, matching
100
+ * the CLI's signing contract), the 64-byte signature, the 32-byte public key.
101
+ *
102
+ * Resolves a verdict STRING, not a boolean, because "the signature is wrong"
103
+ * and "this OS cannot check" must never collapse into one value:
104
+ * 'valid' — signature verifies under the key
105
+ * 'invalid' — it does not; the caller must refuse the bundle
106
+ * 'unavailable' — this OS level has no ed25519 (Android < 33); the caller
107
+ * falls back to the JS verifier
108
+ * A rejected promise is machinery failure and the caller treats it as
109
+ * 'unavailable' — never as 'valid'.
110
+ */
111
+ verifyEd25519(messageHex: string, signatureHex: string, publicKeyHex: string): Promise<string>;
88
112
  }
89
113
 
90
114
  /**