@scalebun/react-native 1.0.1 → 1.0.3

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 (111) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +170 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +160 -12
  3. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +32 -6
  4. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +7 -1
  5. package/ios/Ota/BundleDownloader.swift +182 -0
  6. package/ios/Ota/OtaSlotManager.swift +35 -9
  7. package/ios/Ota/ScaleBunOtaBridge.mm +9 -0
  8. package/ios/Ota/ScaleBunOtaEventsModule.swift +61 -0
  9. package/ios/Ota/ScaleBunOtaModule.swift +58 -5
  10. package/lib/commonjs/bootstrap/SDKBootstrapper.js +22 -0
  11. package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
  12. package/lib/commonjs/features/crash/CrashFeature.js +18 -0
  13. package/lib/commonjs/features/crash/CrashFeature.js.map +1 -1
  14. package/lib/commonjs/features/crash/rejectionHandler.js +123 -0
  15. package/lib/commonjs/features/crash/rejectionHandler.js.map +1 -0
  16. package/lib/commonjs/features/engage/EngagePromptProvider.js +4 -2
  17. package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
  18. package/lib/commonjs/features/engage/engageThrottle.js +41 -10
  19. package/lib/commonjs/features/engage/engageThrottle.js.map +1 -1
  20. package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
  21. package/lib/commonjs/features/navigation/AutoScreenDetector.js +53 -10
  22. package/lib/commonjs/features/navigation/AutoScreenDetector.js.map +1 -1
  23. package/lib/commonjs/features/ota/OtaOrchestrator.js +515 -20
  24. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  25. package/lib/commonjs/features/ota/environment.js +172 -0
  26. package/lib/commonjs/features/ota/environment.js.map +1 -0
  27. package/lib/commonjs/features/ota/retry.js +96 -0
  28. package/lib/commonjs/features/ota/retry.js.map +1 -0
  29. package/lib/commonjs/features/ota/signature.js +115 -0
  30. package/lib/commonjs/features/ota/signature.js.map +1 -0
  31. package/lib/commonjs/features/session/BackendSessionAdapter.js +231 -26
  32. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  33. package/lib/commonjs/index.js +7 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/public/ScaleBunErrorBoundary.js +79 -0
  36. package/lib/commonjs/public/ScaleBunErrorBoundary.js.map +1 -0
  37. package/lib/commonjs/specs/NativeScaleBunOta.js.map +1 -1
  38. package/lib/module/bootstrap/SDKBootstrapper.js +22 -0
  39. package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
  40. package/lib/module/features/crash/CrashFeature.js +18 -0
  41. package/lib/module/features/crash/CrashFeature.js.map +1 -1
  42. package/lib/module/features/crash/rejectionHandler.js +116 -0
  43. package/lib/module/features/crash/rejectionHandler.js.map +1 -0
  44. package/lib/module/features/engage/EngagePromptProvider.js +4 -2
  45. package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
  46. package/lib/module/features/engage/engageThrottle.js +41 -10
  47. package/lib/module/features/engage/engageThrottle.js.map +1 -1
  48. package/lib/module/features/engage/engageTypes.js.map +1 -1
  49. package/lib/module/features/navigation/AutoScreenDetector.js +53 -10
  50. package/lib/module/features/navigation/AutoScreenDetector.js.map +1 -1
  51. package/lib/module/features/ota/OtaOrchestrator.js +516 -21
  52. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  53. package/lib/module/features/ota/environment.js +165 -0
  54. package/lib/module/features/ota/environment.js.map +1 -0
  55. package/lib/module/features/ota/retry.js +87 -0
  56. package/lib/module/features/ota/retry.js.map +1 -0
  57. package/lib/module/features/ota/signature.js +109 -0
  58. package/lib/module/features/ota/signature.js.map +1 -0
  59. package/lib/module/features/session/BackendSessionAdapter.js +230 -26
  60. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  61. package/lib/module/index.js +6 -0
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/public/ScaleBunErrorBoundary.js +70 -0
  64. package/lib/module/public/ScaleBunErrorBoundary.js.map +1 -0
  65. package/lib/module/specs/NativeScaleBunOta.js.map +1 -1
  66. package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
  67. package/lib/typescript/features/crash/CrashFeature.d.ts.map +1 -1
  68. package/lib/typescript/features/crash/rejectionHandler.d.ts +46 -0
  69. package/lib/typescript/features/crash/rejectionHandler.d.ts.map +1 -0
  70. package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
  71. package/lib/typescript/features/engage/engageThrottle.d.ts +24 -4
  72. package/lib/typescript/features/engage/engageThrottle.d.ts.map +1 -1
  73. package/lib/typescript/features/engage/engageTypes.d.ts +6 -0
  74. package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
  75. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +9 -0
  76. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts.map +1 -1
  77. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +67 -1
  78. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  79. package/lib/typescript/features/ota/OtaTypes.d.ts +42 -0
  80. package/lib/typescript/features/ota/OtaTypes.d.ts.map +1 -1
  81. package/lib/typescript/features/ota/environment.d.ts +99 -0
  82. package/lib/typescript/features/ota/environment.d.ts.map +1 -0
  83. package/lib/typescript/features/ota/retry.d.ts +53 -0
  84. package/lib/typescript/features/ota/retry.d.ts.map +1 -0
  85. package/lib/typescript/features/ota/signature.d.ts +66 -0
  86. package/lib/typescript/features/ota/signature.d.ts.map +1 -0
  87. package/lib/typescript/features/session/BackendSessionAdapter.d.ts +93 -2
  88. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  89. package/lib/typescript/index.d.ts +6 -0
  90. package/lib/typescript/index.d.ts.map +1 -1
  91. package/lib/typescript/public/ScaleBunErrorBoundary.d.ts +44 -0
  92. package/lib/typescript/public/ScaleBunErrorBoundary.d.ts.map +1 -0
  93. package/lib/typescript/specs/NativeScaleBunOta.d.ts +11 -0
  94. package/lib/typescript/specs/NativeScaleBunOta.d.ts.map +1 -1
  95. package/package.json +3 -3
  96. package/src/bootstrap/SDKBootstrapper.ts +24 -0
  97. package/src/features/crash/CrashFeature.ts +19 -0
  98. package/src/features/crash/rejectionHandler.ts +134 -0
  99. package/src/features/engage/EngagePromptProvider.tsx +4 -2
  100. package/src/features/engage/engageThrottle.ts +44 -6
  101. package/src/features/engage/engageTypes.ts +6 -0
  102. package/src/features/navigation/AutoScreenDetector.ts +59 -4
  103. package/src/features/ota/OtaOrchestrator.ts +594 -23
  104. package/src/features/ota/OtaTypes.ts +56 -0
  105. package/src/features/ota/environment.ts +199 -0
  106. package/src/features/ota/retry.ts +123 -0
  107. package/src/features/ota/signature.ts +123 -0
  108. package/src/features/session/BackendSessionAdapter.ts +233 -25
  109. package/src/index.ts +6 -0
  110. package/src/public/ScaleBunErrorBoundary.tsx +79 -0
  111. package/src/specs/NativeScaleBunOta.ts +12 -0
@@ -49,11 +49,24 @@ const STORAGE_PREFIX = 'scalebun_engage_throttle:';
49
49
  */
50
50
  export class EngageThrottleStore {
51
51
  private readonly backend: StorageBackend;
52
+ /**
53
+ * Shows in THIS session, backing `maxImpressionsPerSession`.
54
+ *
55
+ * Deliberately in-memory rather than persisted: on mobile a session is an app run, and this
56
+ * store is constructed once per provider mount, so the map's lifetime already is the session's.
57
+ * Persisting it would outlive the session it is meant to bound.
58
+ */
59
+ private readonly sessionShows = new Map<string, number>();
52
60
 
53
61
  constructor(backend: StorageBackend = createStorageBackend()) {
54
62
  this.backend = backend;
55
63
  }
56
64
 
65
+ /** How many times this surface has been shown in the current session. */
66
+ sessionShowCount(campaignId: string): number {
67
+ return this.sessionShows.get(campaignId) ?? 0;
68
+ }
69
+
57
70
  private keyFor(campaignId: string): string {
58
71
  return `${STORAGE_PREFIX}${campaignId}`;
59
72
  }
@@ -130,15 +143,28 @@ export class EngageThrottleStore {
130
143
  completed: fresh ? false : prev.completed,
131
144
  revision: revision ?? prev.revision,
132
145
  });
146
+ // A new revision is a new design: its session count starts over with its durable counters.
147
+ this.sessionShows.set(campaignId, fresh ? 1 : this.sessionShowCount(campaignId) + 1);
133
148
  }
134
149
 
135
- /** Mark a campaign as completed (response submitted) — a hard `showOnce` stop. */
136
- markCompleted(campaignId: string, now: number = Date.now()): void {
150
+ /**
151
+ * Mark a campaign as completed (response submitted, or an in-app message closed) a hard stop.
152
+ *
153
+ * The `revision` MUST be carried through. This wrote the record without one, which silently
154
+ * disabled the revision check in `passesThrottle` for the rest of that campaign's life on the
155
+ * device: that check only fires when BOTH the live config and the stored state carry a revision,
156
+ * so a state stamped `undefined` can never be invalidated. The result was the exact opposite of
157
+ * the web bug — once a user closed an in-app message, editing the campaign could never bring the
158
+ * new version back to them. Callers that know the served revision pass it; legacy callers that
159
+ * don't keep whatever was already stored rather than erasing it.
160
+ */
161
+ markCompleted(campaignId: string, now: number = Date.now(), revision?: string): void {
137
162
  const prev = this.get(campaignId);
138
163
  this.write(campaignId, {
139
164
  impressions: prev.impressions,
140
165
  lastShownAt: prev.lastShownAt || now,
141
166
  completed: true,
167
+ revision: revision ?? prev.revision,
142
168
  });
143
169
  }
144
170
  }
@@ -168,6 +194,7 @@ function passesThrottle(
168
194
  state: CampaignThrottleState,
169
195
  now: number,
170
196
  revision?: string,
197
+ sessionShows = 0,
171
198
  ): boolean {
172
199
  // Revision invalidation: if the live config carries a newer revision than the one
173
200
  // the persisted state was recorded against, the campaign's content/design changed —
@@ -192,6 +219,15 @@ function passesThrottle(
192
219
  return false;
193
220
  }
194
221
 
222
+ // maxImpressionsPerSession: cap within this app run, under any lifetime cap above.
223
+ if (
224
+ typeof throttle.maxImpressionsPerSession === 'number' &&
225
+ throttle.maxImpressionsPerSession > 0 &&
226
+ sessionShows >= throttle.maxImpressionsPerSession
227
+ ) {
228
+ return false;
229
+ }
230
+
195
231
  // cooldownHours: enforce a quiet window since the last impression.
196
232
  if (typeof throttle.cooldownHours === 'number' && state.lastShownAt > 0) {
197
233
  const elapsedMs = now - state.lastShownAt;
@@ -219,9 +255,10 @@ export function isCampaignEligible(
219
255
  campaign: EngageCampaignConfig,
220
256
  state: CampaignThrottleState,
221
257
  now: number = Date.now(),
258
+ sessionShows = 0,
222
259
  ): boolean {
223
260
  if (!campaign.enabled) return false;
224
- return passesThrottle(campaign.id, campaign.throttle, state, now);
261
+ return passesThrottle(campaign.id, campaign.throttle, state, now, undefined, sessionShows);
225
262
  }
226
263
 
227
264
  /**
@@ -234,8 +271,9 @@ export function isInAppEligible(
234
271
  message: InAppMessageConfig,
235
272
  state: CampaignThrottleState,
236
273
  now: number = Date.now(),
274
+ sessionShows = 0,
237
275
  ): boolean {
238
- return passesThrottle(message.id, message.throttle, state, now, message.revision);
276
+ return passesThrottle(message.id, message.throttle, state, now, message.revision, sessionShows);
239
277
  }
240
278
 
241
279
  /**
@@ -292,7 +330,7 @@ export function selectCampaignToRender(
292
330
  typeof campaign.triggerConfig?.seconds === 'number' &&
293
331
  campaign.triggerConfig.seconds !== options.seconds
294
332
  ) continue;
295
- if (isCampaignEligible(campaign, store.get(campaign.id), now)) {
333
+ if (isCampaignEligible(campaign, store.get(campaign.id), now, store.sessionShowCount(campaign.id))) {
296
334
  return campaign;
297
335
  }
298
336
  }
@@ -342,7 +380,7 @@ export function selectInAppToRender(
342
380
  typeof options.seconds === 'number' &&
343
381
  message.delaySeconds !== options.seconds
344
382
  ) continue;
345
- if (isInAppEligible(message, store.get(message.id), now)) {
383
+ if (isInAppEligible(message, store.get(message.id), now, store.sessionShowCount(message.id))) {
346
384
  return message;
347
385
  }
348
386
  }
@@ -69,6 +69,12 @@ export interface Throttle {
69
69
  showOnce?: boolean;
70
70
  cooldownHours?: number;
71
71
  maxImpressions?: number;
72
+ /**
73
+ * Cap WITHIN one session (one app run), independent of the lifetime caps above. The in-app
74
+ * builder writes this on every campaign and `deriveInAppMirror` now carries it to the wire, so
75
+ * ignoring it here would be offering an authoring control that does nothing on this platform.
76
+ */
77
+ maxImpressionsPerSession?: number;
72
78
  samplePct?: number;
73
79
  }
74
80
 
@@ -56,6 +56,8 @@ export class AutoScreenDetector {
56
56
  private _started = false;
57
57
  private _lastEmittedScreen: string | null = null;
58
58
  private _navDebounceTimer: ReturnType<typeof setTimeout> | null = null;
59
+ /** Whether the React Navigation exports were actually replaced — see _autoHookReactNavigation. */
60
+ private _autoHookInstalled = false;
59
61
 
60
62
  /** Cooldown: after manual screen is set, suppress auto-detection for this duration */
61
63
  private static MANUAL_COOLDOWN_MS = 2000;
@@ -330,6 +332,37 @@ export class AutoScreenDetector {
330
332
  if (!reactNav || reactNav.__scalebunAutoHooked) return;
331
333
 
332
334
  const detector = this;
335
+ /** How many entry points were verifiably replaced — 0 means detection is dead. */
336
+ let installed = 0;
337
+
338
+ /**
339
+ * Replace an export, on a module whose exports are GETTERS.
340
+ *
341
+ * `@react-navigation/native` ships ESM whose index is only re-exports, and every bundler
342
+ * compiles `export { X } from './X.js'` to an accessor with a getter and no setter. Plain
343
+ * assignment to that throws in strict mode — which is what the three patch points here
344
+ * used to do, inside a `catch` that discarded the error. The hook reported success, no
345
+ * container ref was ever captured, and the whole screen-name pipeline stayed empty.
346
+ *
347
+ * Defining the property works where assigning does not, and the read-back is what makes
348
+ * a future failure visible instead of silent.
349
+ */
350
+ const redefine = (key: string, value: unknown): boolean => {
351
+ try {
352
+ Object.defineProperty(reactNav, key, {
353
+ value,
354
+ configurable: true,
355
+ enumerable: true,
356
+ writable: true,
357
+ });
358
+ } catch {
359
+ try {
360
+ reactNav[key] = value;
361
+ } catch { /* genuinely frozen — reported by the read-back below */ }
362
+ }
363
+ return reactNav[key] === value;
364
+ };
365
+
333
366
  const register = (ref: any) => {
334
367
  try {
335
368
  const container = ref?.current ?? ref;
@@ -362,7 +395,7 @@ export class AutoScreenDetector {
362
395
  return React.createElement(Original, { ...props, ref: setRef });
363
396
  });
364
397
  (Wrapped as any).displayName = 'ScaleBunNavigationContainer';
365
- try { reactNav.NavigationContainer = Wrapped; } catch { /* frozen export — skip */ }
398
+ if (redefine('NavigationContainer', Wrapped)) installed++;
366
399
  }
367
400
 
368
401
  // 2. createNavigationContainerRef — apps that build the ref manually.
@@ -373,7 +406,7 @@ export class AutoScreenDetector {
373
406
  try { detector.setNavigationRef(ref); } catch { /* no-throw */ }
374
407
  return ref;
375
408
  };
376
- try { reactNav.createNavigationContainerRef = patchedCreate; } catch { /* skip */ }
409
+ if (redefine('createNavigationContainerRef', patchedCreate)) installed++;
377
410
  }
378
411
 
379
412
  // 3. useNavigationContainerRef — hook variant of (2).
@@ -384,14 +417,36 @@ export class AutoScreenDetector {
384
417
  try { detector.setNavigationRef(ref); } catch { /* no-throw */ }
385
418
  return ref;
386
419
  };
387
- try { reactNav.useNavigationContainerRef = patchedHook; } catch { /* skip */ }
420
+ if (redefine('useNavigationContainerRef', patchedHook)) installed++;
388
421
  }
389
422
 
423
+ this._autoHookInstalled = installed > 0;
390
424
  try { reactNav.__scalebunAutoHooked = true; } catch { /* frozen — best-effort */ }
391
- logger.debug('[AutoScreenDetector] React Navigation auto-hook installed');
425
+
426
+ if (installed === 0) {
427
+ // Said out loud. The previous silence is why this shipped broken: an app with screen
428
+ // detection completely dead looked identical to one with it working.
429
+ logger.warn(
430
+ '[AutoScreenDetector] React Navigation is installed but none of its exports could be ' +
431
+ 'wrapped — screens will not be detected automatically. Pass your container ref to ' +
432
+ 'ScaleBun.setNavigationRef(ref) to restore screen names.',
433
+ );
434
+ } else {
435
+ logger.debug(`[AutoScreenDetector] React Navigation auto-hook installed (${installed}/3 entry points)`);
436
+ }
392
437
  } catch { /* @react-navigation/native not installed — heuristic remains fallback */ }
393
438
  }
394
439
 
440
+ /**
441
+ * Did the zero-config hook actually take effect?
442
+ *
443
+ * Exposed because "we tried" and "it worked" were previously the same observable state, and the
444
+ * difference between them is every screen name in the product.
445
+ */
446
+ isAutoHookInstalled(): boolean {
447
+ return this._autoHookInstalled;
448
+ }
449
+
395
450
  /** Stop auto-detection and clean up */
396
451
  stop(): void {
397
452
  if (this._navDebounceTimer) {