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