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