@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.
- package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +170 -0
- package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +160 -12
- package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +32 -6
- package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +7 -1
- package/ios/Ota/BundleDownloader.swift +182 -0
- package/ios/Ota/OtaSlotManager.swift +35 -9
- package/ios/Ota/ScaleBunOtaBridge.mm +9 -0
- package/ios/Ota/ScaleBunOtaEventsModule.swift +61 -0
- package/ios/Ota/ScaleBunOtaModule.swift +58 -5
- package/lib/commonjs/bootstrap/SDKBootstrapper.js +22 -0
- package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
- package/lib/commonjs/features/crash/CrashFeature.js +18 -0
- package/lib/commonjs/features/crash/CrashFeature.js.map +1 -1
- package/lib/commonjs/features/crash/rejectionHandler.js +123 -0
- package/lib/commonjs/features/crash/rejectionHandler.js.map +1 -0
- package/lib/commonjs/features/engage/EngagePromptProvider.js +4 -2
- package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
- package/lib/commonjs/features/engage/engageThrottle.js +41 -10
- package/lib/commonjs/features/engage/engageThrottle.js.map +1 -1
- package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
- package/lib/commonjs/features/navigation/AutoScreenDetector.js +53 -10
- package/lib/commonjs/features/navigation/AutoScreenDetector.js.map +1 -1
- package/lib/commonjs/features/ota/OtaOrchestrator.js +515 -20
- package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/commonjs/features/ota/environment.js +172 -0
- package/lib/commonjs/features/ota/environment.js.map +1 -0
- package/lib/commonjs/features/ota/retry.js +96 -0
- package/lib/commonjs/features/ota/retry.js.map +1 -0
- package/lib/commonjs/features/ota/signature.js +115 -0
- package/lib/commonjs/features/ota/signature.js.map +1 -0
- package/lib/commonjs/features/session/BackendSessionAdapter.js +231 -26
- package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/commonjs/index.js +7 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/public/ScaleBunErrorBoundary.js +79 -0
- package/lib/commonjs/public/ScaleBunErrorBoundary.js.map +1 -0
- package/lib/commonjs/specs/NativeScaleBunOta.js.map +1 -1
- package/lib/module/bootstrap/SDKBootstrapper.js +22 -0
- package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
- package/lib/module/features/crash/CrashFeature.js +18 -0
- package/lib/module/features/crash/CrashFeature.js.map +1 -1
- package/lib/module/features/crash/rejectionHandler.js +116 -0
- package/lib/module/features/crash/rejectionHandler.js.map +1 -0
- package/lib/module/features/engage/EngagePromptProvider.js +4 -2
- package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
- package/lib/module/features/engage/engageThrottle.js +41 -10
- package/lib/module/features/engage/engageThrottle.js.map +1 -1
- package/lib/module/features/engage/engageTypes.js.map +1 -1
- package/lib/module/features/navigation/AutoScreenDetector.js +53 -10
- package/lib/module/features/navigation/AutoScreenDetector.js.map +1 -1
- package/lib/module/features/ota/OtaOrchestrator.js +516 -21
- package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
- package/lib/module/features/ota/environment.js +165 -0
- package/lib/module/features/ota/environment.js.map +1 -0
- package/lib/module/features/ota/retry.js +87 -0
- package/lib/module/features/ota/retry.js.map +1 -0
- package/lib/module/features/ota/signature.js +109 -0
- package/lib/module/features/ota/signature.js.map +1 -0
- package/lib/module/features/session/BackendSessionAdapter.js +230 -26
- package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
- package/lib/module/index.js +6 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/public/ScaleBunErrorBoundary.js +70 -0
- package/lib/module/public/ScaleBunErrorBoundary.js.map +1 -0
- package/lib/module/specs/NativeScaleBunOta.js.map +1 -1
- package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
- package/lib/typescript/features/crash/CrashFeature.d.ts.map +1 -1
- package/lib/typescript/features/crash/rejectionHandler.d.ts +46 -0
- package/lib/typescript/features/crash/rejectionHandler.d.ts.map +1 -0
- package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
- package/lib/typescript/features/engage/engageThrottle.d.ts +24 -4
- package/lib/typescript/features/engage/engageThrottle.d.ts.map +1 -1
- package/lib/typescript/features/engage/engageTypes.d.ts +6 -0
- package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +9 -0
- package/lib/typescript/features/navigation/AutoScreenDetector.d.ts.map +1 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts +67 -1
- package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
- package/lib/typescript/features/ota/OtaTypes.d.ts +42 -0
- package/lib/typescript/features/ota/OtaTypes.d.ts.map +1 -1
- package/lib/typescript/features/ota/environment.d.ts +99 -0
- package/lib/typescript/features/ota/environment.d.ts.map +1 -0
- package/lib/typescript/features/ota/retry.d.ts +53 -0
- package/lib/typescript/features/ota/retry.d.ts.map +1 -0
- package/lib/typescript/features/ota/signature.d.ts +66 -0
- package/lib/typescript/features/ota/signature.d.ts.map +1 -0
- package/lib/typescript/features/session/BackendSessionAdapter.d.ts +93 -2
- package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +6 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/public/ScaleBunErrorBoundary.d.ts +44 -0
- package/lib/typescript/public/ScaleBunErrorBoundary.d.ts.map +1 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts +11 -0
- package/lib/typescript/specs/NativeScaleBunOta.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/bootstrap/SDKBootstrapper.ts +24 -0
- package/src/features/crash/CrashFeature.ts +19 -0
- package/src/features/crash/rejectionHandler.ts +134 -0
- package/src/features/engage/EngagePromptProvider.tsx +4 -2
- package/src/features/engage/engageThrottle.ts +44 -6
- package/src/features/engage/engageTypes.ts +6 -0
- package/src/features/navigation/AutoScreenDetector.ts +59 -4
- package/src/features/ota/OtaOrchestrator.ts +594 -23
- package/src/features/ota/OtaTypes.ts +56 -0
- package/src/features/ota/environment.ts +199 -0
- package/src/features/ota/retry.ts +123 -0
- package/src/features/ota/signature.ts +123 -0
- package/src/features/session/BackendSessionAdapter.ts +233 -25
- package/src/index.ts +6 -0
- package/src/public/ScaleBunErrorBoundary.tsx +79 -0
- 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
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
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 (
|
|
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();
|