@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
@@ -1,9 +1,136 @@
1
- import { DevSettings, NativeModules, Platform } from 'react-native';
1
+ import {
2
+ AppState,
3
+ DeviceEventEmitter,
4
+ DevSettings,
5
+ NativeEventEmitter,
6
+ NativeModules,
7
+ Platform,
8
+ } from 'react-native';
2
9
  import { logger } from '../../core/logger/internalLogger';
3
10
  import { noThrow } from '../../core/lifecycle/crashSafe';
4
11
  import NativeScaleBunOta from '../../specs/NativeScaleBunOta';
5
12
  import type { OtaCheckRequest, OtaCheckResponse, OtaBundlePayload } from './OtaTypes';
6
13
  import { otaEventEmitter } from './OtaEventEmitter';
14
+ import { verifyBundleSignature, type SignatureConfig } from './signature';
15
+ import { detectOtaEnvironment, type OtaEnvironment } from './environment';
16
+ import { retryWithBackoff, isRetryableNetworkError, HttpStatusError } from './retry';
17
+ import { createStorageBackend, type StorageBackend } from '../../storage/StorageBackend';
18
+
19
+ /**
20
+ * Identity marker injected into the bundle by `scalebun ota publish` before
21
+ * Hermes compilation. Lets the SDK prove that the bundle it *installed* is the
22
+ * bundle actually *running* — see `verifyRunningBundleIdentity`.
23
+ */
24
+ function readRunningBundleMarker(): string | null {
25
+ try {
26
+ const id = (globalThis as unknown as { __SCALEBUN_OTA_ID__?: string }).__SCALEBUN_OTA_ID__;
27
+ return typeof id === 'string' && id.length > 0 ? id : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Download progress: native → JS → (hook + backend live tracker).
35
+ *
36
+ * Both native downloaders emit throttled `ScaleBunOtaProgress` ticks — Android
37
+ * over `RCTDeviceEventEmitter`, iOS via the `ScaleBunOtaEvents` RCTEventEmitter.
38
+ * This subscribes for the duration of one download. Until this existed, the
39
+ * hook's `downloadProgress` and the backend's `/sdk/ota/progress` +
40
+ * live-downloads dashboard were declared on both ends and connected by
41
+ * NOTHING — the tracker had never received a single real row.
42
+ */
43
+ interface NativeProgressTick {
44
+ bytesRead: number;
45
+ /** -1 when the server sent no usable length (chunked / gzip). */
46
+ totalBytes: number;
47
+ }
48
+
49
+ function subscribeNativeProgress(
50
+ onTick: (tick: NativeProgressTick) => void,
51
+ ): () => void {
52
+ try {
53
+ if (Platform.OS === 'ios') {
54
+ const eventsModule = NativeModules.ScaleBunOtaEvents;
55
+ if (!eventsModule) return () => {};
56
+ const emitter = new NativeEventEmitter(eventsModule);
57
+ const sub = emitter.addListener('ScaleBunOtaProgress', onTick);
58
+ return () => sub.remove();
59
+ }
60
+ const sub = DeviceEventEmitter.addListener('ScaleBunOtaProgress', onTick);
61
+ return () => sub.remove();
62
+ } catch {
63
+ return () => {};
64
+ }
65
+ }
66
+
67
+ /** POST at most once per this interval; first and terminal ticks always go. */
68
+ const PROGRESS_POST_INTERVAL_MS = 1_000;
69
+
70
+ /**
71
+ * Deliver queued OTA lifecycle events to the backend's batch endpoint.
72
+ *
73
+ * ── Why the orchestrator owns this ──────────────────────────────────────────
74
+ *
75
+ * `otaEventEmitter.setFlushCallback` documents itself as "called by the SDK
76
+ * core to wire into the existing batch transport" — and nothing, anywhere,
77
+ * ever called it. `flush()` drained the queue into a null callback, so every
78
+ * OTA event the real SDK ever emitted was silently discarded in-process.
79
+ * All 129 ota_events rows in the dev database came from the test app's
80
+ * SIMULATOR; the funnel, adoption charts, and guard thresholds had never seen
81
+ * one byte of real-device telemetry.
82
+ *
83
+ * Owning delivery here (with the params sync() already has) instead of relying
84
+ * on bootstrap wiring means OTA telemetry works in every integration shape —
85
+ * full SDK, standalone orchestrator, CodePush shim — because the component
86
+ * that EMITS the events is the component that delivers them.
87
+ */
88
+ async function deliverOtaEvents(params: {
89
+ apiUrl: string;
90
+ clientKey: string;
91
+ appVersion: string;
92
+ installationId: string;
93
+ }): Promise<void> {
94
+ const events = otaEventEmitter.flush();
95
+ if (!events.length) return;
96
+
97
+ const items = events.map((e) => ({
98
+ kind: 'ota_event' as const,
99
+ type: e.type,
100
+ bundleId: e.bundleId,
101
+ installationId: params.installationId,
102
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
103
+ appVersion: params.appVersion,
104
+ durationMs: e.durationMs,
105
+ errorCode: e.error ?? e.reason,
106
+ patchUsed: e.patchUsed,
107
+ timestamp: e.timestamp,
108
+ }));
109
+
110
+ try {
111
+ const base = params.apiUrl.replace(/\/+$/, '');
112
+ // The batch lane is session-scoped for replay items; OTA rows carry their
113
+ // own installationId and ignore the session, so a synthetic id is correct.
114
+ const res = await fetch(
115
+ `${base}/ingestion/sessions/ota-${params.installationId}/batch`,
116
+ {
117
+ method: 'POST',
118
+ headers: {
119
+ 'Content-Type': 'application/json',
120
+ 'x-scalebun-client-key': params.clientKey,
121
+ },
122
+ body: JSON.stringify({ items }),
123
+ },
124
+ );
125
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
126
+ logger.debug(`[OTA] Delivered ${items.length} telemetry event(s)`);
127
+ } catch (err: any) {
128
+ // Failed delivery must not fail the update — but the events must not be
129
+ // lost either: requeue so the next sync retries them.
130
+ for (const e of events) otaEventEmitter.emit(e);
131
+ logger.debug(`[OTA] Telemetry delivery failed (will retry next sync): ${err?.message}`);
132
+ }
133
+ }
7
134
 
8
135
  export interface SyncResult {
9
136
  status: 'UP_TO_DATE' | 'UPDATE_INSTALLED' | 'ROLLED_BACK' | 'ERROR';
@@ -36,25 +163,262 @@ export class OtaOrchestrator {
36
163
  private isRestartRequiredState = false;
37
164
  private healthyTimer: ReturnType<typeof setTimeout> | null = null;
38
165
  private bootGuardConfig: BootGuardConfig = {};
166
+ private signatureConfig: SignatureConfig | undefined;
167
+ private environment: OtaEnvironment | null = null;
168
+ /** One-time boot checks have run in this process (init is idempotent). */
169
+ private initialized = false;
170
+ private storageBackend: StorageBackend | null = null;
39
171
 
40
172
  /**
41
173
  * Initialize the orchestrator. Checks for boot-guard recovery state.
174
+ *
175
+ * `signature` is optional: an app that has not adopted signing keeps working
176
+ * on SHA-256 integrity alone. Once a public key IS configured, a bundle that
177
+ * cannot be verified is refused rather than installed (OTA-03).
42
178
  */
43
- init(config?: BootGuardConfig): void {
179
+ init(config?: BootGuardConfig & { signature?: SignatureConfig }): void {
44
180
  noThrow(() => {
181
+ // Idempotent: init() runs its one-time checks ONCE per process. Found
182
+ // live — a host that calls init() again after installing an update (any
183
+ // retry/re-entry path does this) re-runs the identity verification in a
184
+ // process whose running JS legitimately predates the install, producing
185
+ // a false "INSTALL DID NOT TAKE EFFECT" alarm. The boot-time checks are
186
+ // only meaningful in a fresh process; a second init() must not repeat
187
+ // them. Config is still refreshed so late signature setup works.
188
+ if (this.initialized) {
189
+ this.bootGuardConfig = config ?? this.bootGuardConfig;
190
+ this.signatureConfig = config?.signature ?? this.signatureConfig;
191
+ return;
192
+ }
193
+
45
194
  if (!NativeScaleBunOta) {
46
195
  logger.warn('[OTA] NativeScaleBunOta module unavailable — OTA disabled');
47
196
  return;
48
197
  }
198
+
199
+ // ── RUNTIME GATE ─────────────────────────────────────────────────────
200
+ // Refuse to run on a runtime where the install would silently no-op.
201
+ // See environment.ts: bridgeless below RN 0.76.1 ignores
202
+ // getJSBundleFile() entirely, so a bundle installs, reports INSTALLED,
203
+ // and the app keeps running the APK asset. Staying loudly disabled is
204
+ // strictly better than reporting success that never happened — those
205
+ // false INSTALLED rows feed adoption metrics and guard thresholds.
206
+ this.environment = detectOtaEnvironment();
207
+ if (!this.environment.supported) {
208
+ logger.error(`[OTA] DISABLED — ${this.environment.blockReason}`);
209
+ this.enabled = false;
210
+ return;
211
+ }
212
+ if (this.environment.bridgeless && !this.environment.rnVersion) {
213
+ logger.warn(
214
+ '[OTA] Running bridgeless but the React Native version could not be determined. ' +
215
+ 'If this app is below RN 0.76.1, OTA updates will install and never load.',
216
+ );
217
+ }
218
+ if (this.environment.requiresReactHostIntegration) {
219
+ // RN 0.82 removed ReactNativeHost, so the legacy
220
+ // `override fun getJSBundleFile()` integration does not exist there.
221
+ // We cannot see which form the host used — the identity check after
222
+ // restart is what proves it — but naming the right fix up front turns
223
+ // a confusing silent failure into a one-line correction.
224
+ logger.debug(
225
+ '[OTA] React Native >= 0.82 detected. The only valid Android integration is ' +
226
+ 'getDefaultReactHost(..., jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(ctx)) — ' +
227
+ 'ReactNativeHost and its getJSBundleFile() override no longer exist.',
228
+ );
229
+ }
230
+
231
+ this.initialized = true;
49
232
  this.enabled = true;
50
233
  this.bootGuardConfig = config ?? {};
51
- logger.debug('[OTA] Orchestrator initialized successfully');
234
+ this.signatureConfig = config?.signature;
235
+ logger.debug(
236
+ `[OTA] Orchestrator initialized (RN ${this.environment.rnVersionString ?? 'unknown'}` +
237
+ `${this.environment.bridgeless ? ', bridgeless' : ''}` +
238
+ `${this.environment.hermes ? `, Hermes HBC v${this.environment.hermesBytecodeVersion ?? '?'}` : ''})`,
239
+ );
240
+
241
+ // Rehydrate which bundle we are running from the native slot state.
242
+ // Without this `currentBundle` stays null for the whole process after a
243
+ // restart, so every check reported no current bundle and the backend had
244
+ // no way to know what the device was actually on.
245
+ this.hydrateCurrentBundleFromSlots();
246
+
247
+ // Prove the bundle we installed is the bundle that loaded.
248
+ this.verifyRunningBundleIdentity();
52
249
 
53
250
  // Check if the boot guard fired on this launch (native reverted before JS loaded)
54
251
  this.checkBootGuardRecovery();
55
252
  });
56
253
  }
57
254
 
255
+ /**
256
+ * Read the active slot back into `currentBundle` so the next check reports
257
+ * what this device is genuinely running.
258
+ */
259
+ private hydrateCurrentBundleFromSlots(): void {
260
+ if (!NativeScaleBunOta) return;
261
+ try {
262
+ const state = JSON.parse(NativeScaleBunOta.getSlotState());
263
+ const current = state?.current;
264
+ if (!current?.sha256) return;
265
+
266
+ // Keyed on sha256, NOT bundleId. The native `stageBundle` is never told
267
+ // the bundleId — it writes a meta.json containing only `sha256` and
268
+ // `installedAt` — so `current.bundleId` is always absent. Hydrating from
269
+ // it left `currentBundle` null on every launch, which meant the device
270
+ // never told the server what it was running and the server re-offered
271
+ // the same bundle forever. sha256 is the field that is actually there.
272
+ const record = this.readInstallRecord();
273
+ if (record && record.sha256 === current.sha256) {
274
+ this.currentBundle = {
275
+ id: record.bundleId,
276
+ version: record.version,
277
+ sha256: record.sha256,
278
+ } as OtaBundlePayload;
279
+ logger.debug(`[OTA] Running bundle v${record.version} (${record.bundleId})`);
280
+ return;
281
+ }
282
+
283
+ // An OTA bundle is installed but we have no record of installing it —
284
+ // a reinstall of the app, cleared storage, or a bundle staged by an
285
+ // older SDK. Report the hash so the server can still recognise it.
286
+ this.currentBundle = { sha256: current.sha256 } as OtaBundlePayload;
287
+ logger.debug(
288
+ `[OTA] Running an OTA bundle (sha ${String(current.sha256).slice(0, 12)}…) with no local install record.`,
289
+ );
290
+ } catch {
291
+ // Slot state unreadable — treat as factory bundle.
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Compare the bundle the slot manager believes is active against the identity
297
+ * marker compiled into the bundle that actually loaded.
298
+ *
299
+ * A mismatch means the install path reported success while the runtime kept
300
+ * executing different code — the silent no-op this whole guard exists for.
301
+ * It is reported as APPLY_FAILED, because that is what happened, regardless
302
+ * of what the previous session's telemetry claimed.
303
+ *
304
+ * The expectation recorded at install time is what makes this conclusive.
305
+ * Without it, a missing marker is ambiguous — it could be a bundle published
306
+ * before markers existed, or an install that never took effect. Having
307
+ * written down "this bundle definitely carries marker X" before restarting,
308
+ * a missing marker afterwards can only mean the second.
309
+ */
310
+ private verifyRunningBundleIdentity(): void {
311
+ if (!this.currentBundle) return;
312
+
313
+ const running = readRunningBundleMarker();
314
+ const expected = this.readInstallExpectation();
315
+
316
+ // Stale record from an earlier install — the slot has moved on since.
317
+ if (expected && expected.bundleId !== this.currentBundle.id) {
318
+ this.clearInstallExpectation();
319
+ return;
320
+ }
321
+
322
+ if (expected) {
323
+ if (running === expected.identityToken) {
324
+ // Proven: the bundle we installed is the bundle executing.
325
+ logger.debug('[OTA] Install verified — running bundle matches what was installed.');
326
+ this.clearInstallExpectation();
327
+ return;
328
+ }
329
+
330
+ logger.error(
331
+ `[OTA] INSTALL DID NOT TAKE EFFECT — bundle ${this.currentBundle.id} was installed and ` +
332
+ `carries a known identity marker, but the running bundle reports ` +
333
+ `${running ?? 'no marker at all'}. The app is executing different code than the slot ` +
334
+ 'manager believes. Check that the host app resolves the OTA bundle path at launch ' +
335
+ '(see the ScaleBunOta integration for your React Native version).',
336
+ );
337
+ otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
338
+ error: `install_not_effective — expected ${expected.identityToken}, running ${running ?? 'none'}`,
339
+ });
340
+ // Deliberately NOT cleared: the condition is still true on the next boot
341
+ // and should keep reporting until the integration is fixed. Clearing here
342
+ // would make a permanently broken install look like a one-off.
343
+ return;
344
+ }
345
+
346
+ if (running && running !== this.currentBundle.id) {
347
+ // No recorded expectation (installed by an older SDK), but the running
348
+ // marker disagrees with the active slot outright. Still conclusive.
349
+ logger.error(
350
+ `[OTA] BUNDLE MISMATCH — slot says ${this.currentBundle.id} is active but the ` +
351
+ `running bundle identifies as ${running}.`,
352
+ );
353
+ otaEventEmitter.emitSimple('APPLY_FAILED', this.currentBundle.id, {
354
+ error: `bundle_identity_mismatch — running ${running}`,
355
+ });
356
+ }
357
+ }
358
+
359
+ // ── Install expectation ────────────────────────────────────────────────────
360
+ // Written just before the restart that activates a bundle, read on the next
361
+ // boot. Synchronous, disk-backed, and the same store used for the device id —
362
+ // it has to survive a process death that happens moments after the write.
363
+
364
+ private static readonly INSTALL_EXPECTATION_KEY = 'scalebun.ota.pendingInstall';
365
+
366
+ private recordInstallExpectation(bundle: OtaBundlePayload): void {
367
+ try {
368
+ this.storage().set(
369
+ OtaOrchestrator.INSTALL_EXPECTATION_KEY,
370
+ JSON.stringify({
371
+ bundleId: bundle.id,
372
+ version: bundle.version,
373
+ // The join key back to the native slot, which records sha256 and
374
+ // nothing else identifying.
375
+ sha256: bundle.sha256,
376
+ identityToken: bundle.identityToken ?? null,
377
+ }),
378
+ );
379
+ } catch {
380
+ // Verification is a safety net, never a precondition for installing.
381
+ }
382
+ }
383
+
384
+ private readInstallRecord(): {
385
+ bundleId: string;
386
+ version: number;
387
+ sha256: string;
388
+ identityToken: string | null;
389
+ } | null {
390
+ try {
391
+ const raw = this.storage().get(OtaOrchestrator.INSTALL_EXPECTATION_KEY);
392
+ if (!raw) return null;
393
+ const parsed = JSON.parse(raw);
394
+ return typeof parsed?.bundleId === 'string' && typeof parsed?.sha256 === 'string'
395
+ ? parsed
396
+ : null;
397
+ } catch {
398
+ return null;
399
+ }
400
+ }
401
+
402
+ private readInstallExpectation(): { bundleId: string; identityToken: string } | null {
403
+ const record = this.readInstallRecord();
404
+ return record && record.identityToken
405
+ ? { bundleId: record.bundleId, identityToken: record.identityToken }
406
+ : null;
407
+ }
408
+
409
+ private clearInstallExpectation(): void {
410
+ try {
411
+ this.storage().set(OtaOrchestrator.INSTALL_EXPECTATION_KEY, '');
412
+ } catch {
413
+ /* non-fatal */
414
+ }
415
+ }
416
+
417
+ private storage(): StorageBackend {
418
+ if (!this.storageBackend) this.storageBackend = createStorageBackend();
419
+ return this.storageBackend;
420
+ }
421
+
58
422
  /**
59
423
  * Sprint 5 (S5-NAT-1): Check if the native boot guard reverted us on this cold start.
60
424
  * If getSlotState() shows bootMarkerPresent=false but we have a 'previous' slot
@@ -95,31 +459,65 @@ export class OtaOrchestrator {
95
459
  appVersion: string;
96
460
  installationId: string;
97
461
  channelName?: string;
462
+ country?: string;
463
+ attributes?: Record<string, string | number>;
464
+ segmentIds?: string[];
465
+ lifecycleStage?: string;
98
466
  }): Promise<OtaCheckResponse> {
99
467
  try {
468
+ const env = this.environment ?? detectOtaEnvironment();
469
+
470
+ // C1: send the channel and targeting context. `channelName` was already an
471
+ // accepted parameter here and was then dropped on the floor — the payload
472
+ // never carried it — so every device resolved to `default` no matter what
473
+ // the host app asked for. Undefined fields are omitted by JSON.stringify,
474
+ // so an app that supplies nothing behaves exactly as before.
100
475
  const payload: OtaCheckRequest = {
101
476
  appVersion: requestParams.appVersion,
102
477
  platform: Platform.OS === 'ios' ? 'ios' : 'android',
103
478
  installationId: requestParams.installationId,
104
479
  currentBundleId: this.currentBundle?.id,
105
480
  currentBundleHash: this.currentBundle?.sha256,
481
+ channelName: requestParams.channelName,
482
+ country: requestParams.country,
483
+ attributes: requestParams.attributes,
484
+ segmentIds: requestParams.segmentIds,
485
+ lifecycleStage: requestParams.lifecycleStage,
486
+ // Runtime compatibility context. Without these the backend cannot tell
487
+ // whether a bundle is safe for this binary: appVersion is a marketing
488
+ // string, while the RN version and the Hermes bytecode format are the
489
+ // actual runtime contract. A bundle compiled against a different HBC
490
+ // version either fails to load or crashes later on a missing path.
491
+ rnVersion: env.rnVersionString ?? undefined,
492
+ hermes: env.hermes,
493
+ hermesBytecodeVersion: env.hermesBytecodeVersion ?? undefined,
494
+ bridgeless: env.bridgeless,
106
495
  };
107
496
 
108
497
  const url = `${requestParams.apiUrl.replace(/\/+$/, '')}/sdk/ota/check`;
109
- const res = await fetch(url, {
110
- method: 'POST',
111
- headers: {
112
- 'Content-Type': 'application/json',
113
- 'x-scalebun-client-key': requestParams.clientKey,
114
- },
115
- body: JSON.stringify(payload),
116
- });
117
498
 
118
- if (!res.ok) {
119
- throw new Error(`OTA check failed with status ${res.status}`);
120
- }
499
+ const data = await retryWithBackoff<OtaCheckResponse>(
500
+ async () => {
501
+ const res = await fetch(url, {
502
+ method: 'POST',
503
+ headers: {
504
+ 'Content-Type': 'application/json',
505
+ 'x-scalebun-client-key': requestParams.clientKey,
506
+ },
507
+ body: JSON.stringify(payload),
508
+ });
509
+
510
+ if (!res.ok) {
511
+ throw new HttpStatusError(
512
+ `OTA check failed with status ${res.status}`,
513
+ res.status,
514
+ );
515
+ }
516
+ return (await res.json()) as OtaCheckResponse;
517
+ },
518
+ { label: 'check', isRetryable: isRetryableNetworkError },
519
+ );
121
520
 
122
- const data: OtaCheckResponse = await res.json();
123
521
  return data;
124
522
  } catch (err: any) {
125
523
  logger.error(`[OTA] checkForUpdate failed: ${err.message}`);
@@ -176,11 +574,77 @@ export class OtaOrchestrator {
176
574
  const bundle = checkRes.bundle;
177
575
  let patchUsed = false;
178
576
 
577
+ // ── VERIFY AUTHENTICITY (OTA-03) ─────────────────────────────────────
578
+ // Before anything touches the disk. SHA-256 proves the bytes arrived
579
+ // intact; only the signature proves they came from you. Checking after
580
+ // staging would mean writing unverified code to the slot directory first.
581
+ const signatureOutcome = await verifyBundleSignature(
582
+ bundle.sha256,
583
+ bundle.signature,
584
+ this.signatureConfig,
585
+ );
586
+ if (!signatureOutcome.ok) {
587
+ otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
588
+ error: `Signature check failed: ${signatureOutcome.reason}`,
589
+ version: bundle.version,
590
+ });
591
+ return {
592
+ status: 'ERROR',
593
+ error: `Bundle signature check failed (${signatureOutcome.reason})`,
594
+ bundle,
595
+ };
596
+ }
597
+
179
598
  // ── DOWNLOAD ─────────────────────────────────────────────────────────
180
599
  otaEventEmitter.emitSimple('DOWNLOAD_STARTED', bundle.id, { version: bundle.version });
181
600
  const downloadStart = Date.now();
182
601
  logger.debug(`[OTA] Downloading update v${bundle.version}…`);
183
602
 
603
+ // Live progress: native tick → DOWNLOAD_PROGRESS (feeds useOtaUpdate) →
604
+ // throttled POST to the live tracker. `bundle.size` is the authoritative
605
+ // total — the server just told us — so an unknown native total (-1 on
606
+ // chunked/gzip bodies) still yields a truthful percentage.
607
+ let lastPostAt = 0;
608
+ const postProgress = (bytesRead: number, status: string) => {
609
+ const total = bundle.size > 0 ? bundle.size : Math.max(bytesRead, 1);
610
+ const percent = Math.min(100, Math.round((bytesRead / total) * 100));
611
+ const elapsedS = (Date.now() - downloadStart) / 1000;
612
+ const payload = {
613
+ installationId: params.installationId,
614
+ bundleId: bundle.id,
615
+ bytesRead,
616
+ totalBytes: total,
617
+ progressPercent: percent,
618
+ speedKbps: elapsedS > 0 ? Math.round(bytesRead / 1024 / elapsedS) : 0,
619
+ status,
620
+ };
621
+ // Fire-and-forget: progress display must never delay or fail the
622
+ // download it describes.
623
+ fetch(`${params.apiUrl.replace(/\/+$/, '')}/sdk/ota/progress`, {
624
+ method: 'POST',
625
+ headers: {
626
+ 'Content-Type': 'application/json',
627
+ 'x-scalebun-client-key': params.clientKey,
628
+ },
629
+ body: JSON.stringify(payload),
630
+ }).catch(() => {});
631
+ };
632
+
633
+ const unsubscribeProgress = subscribeNativeProgress(({ bytesRead }) => {
634
+ const total = bundle.size > 0 ? bundle.size : bytesRead;
635
+ otaEventEmitter.emit({
636
+ type: 'DOWNLOAD_PROGRESS',
637
+ bundleId: bundle.id,
638
+ timestamp: Date.now(),
639
+ progress: Math.min(100, Math.round((bytesRead / Math.max(total, 1)) * 100)),
640
+ });
641
+ const now = Date.now();
642
+ if (now - lastPostAt >= PROGRESS_POST_INTERVAL_MS) {
643
+ lastPostAt = now;
644
+ postProgress(bytesRead, 'DOWNLOADING');
645
+ }
646
+ });
647
+
184
648
  let staged = false;
185
649
 
186
650
  // Sprint 7: Try patch first if offered, fall back to full bundle
@@ -206,13 +670,36 @@ export class OtaOrchestrator {
206
670
  }
207
671
  }
208
672
 
209
- // Full-bundle fallback (or primary path when no patch is available)
673
+ // Full-bundle fallback (or primary path when no patch is available).
674
+ // Retried with backoff: on a mobile network a transient drop mid-download
675
+ // is the common case, and previously one failure meant waiting for the
676
+ // next cold start — often hours away, sometimes never.
210
677
  if (!staged) {
211
- staged = await NativeScaleBunOta.stageBundle(bundle.url, bundle.sha256);
678
+ try {
679
+ staged = await retryWithBackoff(
680
+ async () => {
681
+ const ok = await NativeScaleBunOta!.stageBundle(bundle.url, bundle.sha256);
682
+ if (!ok) {
683
+ // Native returns a bare boolean, so a hash mismatch and a
684
+ // dropped connection are indistinguishable here. Retrying a
685
+ // genuine mismatch a couple of times is cheap; not retrying a
686
+ // dropped connection costs the whole update.
687
+ throw new Error('stageBundle returned false');
688
+ }
689
+ return ok;
690
+ },
691
+ { label: `download v${bundle.version}`, attempts: 3 },
692
+ );
693
+ } catch {
694
+ staged = false;
695
+ }
212
696
  }
213
697
 
698
+ unsubscribeProgress();
699
+
214
700
  if (!staged) {
215
- logger.error('[OTA] Staging bundle failed');
701
+ postProgress(0, 'FAILED');
702
+ logger.error('[OTA] Staging bundle failed after retries');
216
703
  otaEventEmitter.emitSimple('APPLY_FAILED', bundle.id, {
217
704
  error: 'Staging failed — SHA-256 mismatch or download error',
218
705
  version: bundle.version,
@@ -220,6 +707,8 @@ export class OtaOrchestrator {
220
707
  return { status: 'ERROR', error: 'Staging bundle failed', bundle };
221
708
  }
222
709
 
710
+ postProgress(bundle.size, 'COMPLETED');
711
+
223
712
  const downloadDuration = Date.now() - downloadStart;
224
713
  otaEventEmitter.emitSimple('DOWNLOAD_COMPLETE', bundle.id, {
225
714
  version: bundle.version,
@@ -242,14 +731,34 @@ export class OtaOrchestrator {
242
731
  this.currentBundle = bundle;
243
732
  this.isRestartRequiredState = true;
244
733
 
734
+ // Written BEFORE the restart, so the next boot can prove whether this
735
+ // install actually took effect. Only recorded when the server told us the
736
+ // bundle carries a marker — otherwise there is nothing to compare against
737
+ // and a recorded expectation would produce a false alarm.
738
+ // Recorded unconditionally, not only when a marker is present: this is
739
+ // also the record that tells the next launch WHICH bundle the slot's
740
+ // sha256 belongs to, without which the device cannot report what it is
741
+ // running. Identity verification is the second job of this record, and
742
+ // it is skipped when there is no marker to compare.
743
+ this.recordInstallExpectation(bundle);
744
+
245
745
  otaEventEmitter.emitSimple('INSTALLED', bundle.id, { version: bundle.version });
246
746
  logger.debug(`[OTA] Update v${bundle.version} installed successfully!`);
247
747
 
248
- // Flush telemetry events before restart
249
- otaEventEmitter.flush();
250
-
251
- if (params.autoRestart) {
748
+ // Deliver telemetry BEFORE a restart tears the JS runtime down —
749
+ // an INSTALLED event queued but not sent would vanish with the process.
750
+ await deliverOtaEvents(params);
751
+
752
+ // Activation, per the release's installMode. `params.autoRestart` is the
753
+ // host app's own override and continues to force a restart regardless.
754
+ // These behaviours were documented (docs, CLI help, the CodePush shim's
755
+ // enum) for a year while nothing implemented them — every release
756
+ // behaved as ON_NEXT_RESTART no matter what was published.
757
+ const installMode = bundle.installMode ?? 'ON_NEXT_RESTART';
758
+ if (params.autoRestart || installMode === 'IMMEDIATE') {
252
759
  this.restart();
760
+ } else if (installMode === 'ON_NEXT_RESUME') {
761
+ this.armResumeRestart();
253
762
  }
254
763
 
255
764
  return {
@@ -260,6 +769,13 @@ export class OtaOrchestrator {
260
769
  } catch (err: any) {
261
770
  logger.error(`[OTA] Sync error: ${err.message}`);
262
771
  return { status: 'ERROR', error: err.message ?? 'Unexpected sync failure' };
772
+ } finally {
773
+ // Every other exit (UP_TO_DATE, ROLLBACK, staging failure, throw)
774
+ // delivers here. This also carries events queued at INIT — boot-guard
775
+ // AUTO_ROLLBACKs and identity-check APPLY_FAILEDs fire before any
776
+ // apiUrl/clientKey exist, so they wait in the queue for the first sync.
777
+ // Fire-and-forget: delivery is never allowed to delay a result.
778
+ void deliverOtaEvents(params);
263
779
  }
264
780
  }
265
781
 
@@ -305,13 +821,68 @@ export class OtaOrchestrator {
305
821
  }
306
822
  }
307
823
 
824
+ /** One-shot guard so repeated syncs cannot stack resume listeners. */
825
+ private resumeRestartArmed = false;
826
+
827
+ /**
828
+ * ON_NEXT_RESUME: restart the JS runtime the next time the app returns to
829
+ * the foreground after genuinely being in the background.
830
+ *
831
+ * The staged bundle is already applied at this point — the restart is purely
832
+ * about WHEN the user experiences the reload. Two deliberate choices:
833
+ *
834
+ * - Armed only after a real background→active transition, never on the
835
+ * initial 'active' state, so installing while the user is mid-session
836
+ * does not instantly yank the screen out from under them.
837
+ * - One-shot. The listener removes itself after firing (or after the
838
+ * restart makes the whole question moot).
839
+ */
840
+ private armResumeRestart(): void {
841
+ if (this.resumeRestartArmed) return;
842
+ this.resumeRestartArmed = true;
843
+
844
+ let wasBackground = false;
845
+ try {
846
+ const sub = AppState.addEventListener('change', (state) => {
847
+ if (state === 'background') {
848
+ wasBackground = true;
849
+ return;
850
+ }
851
+ if (state === 'active' && wasBackground) {
852
+ try {
853
+ sub.remove();
854
+ } catch {
855
+ /* the restart below makes cleanup moot */
856
+ }
857
+ this.resumeRestartArmed = false;
858
+ logger.info('[OTA] App resumed — activating the staged update now');
859
+ this.restart();
860
+ }
861
+ });
862
+ logger.debug('[OTA] Update staged — will activate on next resume (ON_NEXT_RESUME)');
863
+ } catch {
864
+ // AppState unavailable (tests, exotic hosts): the update still activates
865
+ // on the next cold start, which is the documented fallback behaviour.
866
+ this.resumeRestartArmed = false;
867
+ }
868
+ }
869
+
308
870
  /**
309
871
  * Reload the JS app bundle to apply the new update immediately.
872
+ *
873
+ * Order matters: the native `restartApp()` is the only path that works in
874
+ * RELEASE builds. `DevSettings.reload()` looks callable there too — it
875
+ * logged, returned, and reloaded nothing when tested live on a release APK,
876
+ * which made every "restart" this SDK ever promised a silent no-op in
877
+ * production. DevSettings remains as the dev-build fallback for hosts that
878
+ * have not rebuilt native yet.
310
879
  */
311
880
  restart(): void {
312
881
  noThrow(() => {
313
882
  logger.info('[OTA] Triggering app restart…');
314
- if (DevSettings && typeof DevSettings.reload === 'function') {
883
+ if (NativeScaleBunOta && typeof NativeScaleBunOta.restartApp === 'function') {
884
+ NativeScaleBunOta.restartApp();
885
+ } else if (DevSettings && typeof DevSettings.reload === 'function') {
315
886
  DevSettings.reload();
316
887
  } else if (NativeModules.RNReload) {
317
888
  NativeModules.RNReload.reload();