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