@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
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
package com.scalebun.rn.ota
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import java.io.File
|
|
5
|
+
import java.io.FileOutputStream
|
|
6
|
+
import java.net.HttpURLConnection
|
|
7
|
+
import java.net.URL
|
|
8
|
+
import java.util.zip.GZIPInputStream
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Downloads an OTA bundle to a local file (OTA-05).
|
|
12
|
+
*
|
|
13
|
+
* WHY THIS EXISTS. `OtaOrchestrator.sync()` has always passed `bundle.url` — a
|
|
14
|
+
* presigned HTTPS URL — into `stageBundle()`, while both native slot managers
|
|
15
|
+
* treated that argument as a filesystem path. Android returned false from
|
|
16
|
+
* `File(url).exists()`, iOS threw from `copyItem`. No component anywhere in the
|
|
17
|
+
* SDK downloaded the bundle, so OTA could never install an update at all, and
|
|
18
|
+
* every safety mechanism downstream (boot guard, slot rollback, guard
|
|
19
|
+
* thresholds) had only ever run against synthetic events.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately built on `HttpURLConnection`: the SDK ships zero third-party
|
|
22
|
+
* runtime dependencies, and adding OkHttp for one download would put a
|
|
23
|
+
* transitive version constraint on every host app.
|
|
24
|
+
*
|
|
25
|
+
* C2a — gzip. `Content-Encoding: gzip` is decoded transparently here, so the
|
|
26
|
+
* hash the slot manager verifies is always of the *decompressed* bundle, which
|
|
27
|
+
* is what the server hashed and what the signature covers. `Accept-Encoding` is
|
|
28
|
+
* set explicitly because HttpURLConnection's automatic gzip handling is disabled
|
|
29
|
+
* once a caller touches that header, and the object store serves whatever the
|
|
30
|
+
* upload was encoded as.
|
|
31
|
+
*/
|
|
32
|
+
internal object BundleDownloader {
|
|
33
|
+
|
|
34
|
+
private const val TAG = "ScaleBunOta"
|
|
35
|
+
|
|
36
|
+
/** Matches the server-side finalize cap. A larger body is a bug or an attack. */
|
|
37
|
+
private const val MAX_BYTES = 50L * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
private const val CONNECT_TIMEOUT_MS = 15_000
|
|
40
|
+
private const val READ_TIMEOUT_MS = 60_000
|
|
41
|
+
private const val MAX_REDIRECTS = 5
|
|
42
|
+
|
|
43
|
+
/** Emit at most one progress callback per this interval — the byte loop is hot. */
|
|
44
|
+
private const val PROGRESS_INTERVAL_MS = 500L
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Download [url] into [destination]. Returns true on success.
|
|
48
|
+
*
|
|
49
|
+
* The caller owns [destination]; on any failure it is removed so a partial
|
|
50
|
+
* body can never be mistaken for a complete bundle. Integrity is NOT checked
|
|
51
|
+
* here — [SlotManager.stage] re-hashes what actually landed on disk, which is
|
|
52
|
+
* the only measurement that matters.
|
|
53
|
+
*
|
|
54
|
+
* [onProgress] receives (bytesRead, totalBytes) at most every
|
|
55
|
+
* [PROGRESS_INTERVAL_MS], plus one final call at completion. `totalBytes` is
|
|
56
|
+
* -1 when the server sent no Content-Length (chunked) or the body is gzip —
|
|
57
|
+
* the DECLARED length is the compressed size, which would make a progress
|
|
58
|
+
* bar overshoot 100% and walk backwards. Unknown is reported as unknown.
|
|
59
|
+
*/
|
|
60
|
+
fun download(
|
|
61
|
+
url: String,
|
|
62
|
+
destination: File,
|
|
63
|
+
onProgress: ((bytesRead: Long, totalBytes: Long) -> Unit)? = null,
|
|
64
|
+
): Boolean {
|
|
65
|
+
var connection: HttpURLConnection? = null
|
|
66
|
+
try {
|
|
67
|
+
var current = URL(url)
|
|
68
|
+
var redirects = 0
|
|
69
|
+
|
|
70
|
+
// Followed manually: HttpURLConnection will not follow across
|
|
71
|
+
// protocols, and presigned URLs on some object stores redirect
|
|
72
|
+
// http→https. Capped so a redirect loop cannot hang the update.
|
|
73
|
+
while (true) {
|
|
74
|
+
connection = (current.openConnection() as HttpURLConnection).apply {
|
|
75
|
+
connectTimeout = CONNECT_TIMEOUT_MS
|
|
76
|
+
readTimeout = READ_TIMEOUT_MS
|
|
77
|
+
requestMethod = "GET"
|
|
78
|
+
instanceFollowRedirects = false
|
|
79
|
+
setRequestProperty("Accept-Encoding", "gzip")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
val code = connection.responseCode
|
|
83
|
+
if (code == HttpURLConnection.HTTP_MOVED_PERM ||
|
|
84
|
+
code == HttpURLConnection.HTTP_MOVED_TEMP ||
|
|
85
|
+
code == HttpURLConnection.HTTP_SEE_OTHER ||
|
|
86
|
+
code == 307 || code == 308
|
|
87
|
+
) {
|
|
88
|
+
val location = connection.getHeaderField("Location")
|
|
89
|
+
connection.disconnect()
|
|
90
|
+
if (location.isNullOrBlank() || ++redirects > MAX_REDIRECTS) {
|
|
91
|
+
Log.e(TAG, "Download failed: redirect limit or missing Location")
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
current = URL(current, location)
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (code !in 200..299) {
|
|
99
|
+
Log.e(TAG, "Download failed: HTTP $code for ${current.host}")
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
val conn = connection ?: return false
|
|
106
|
+
|
|
107
|
+
// Reject an oversized body before reading it, when the server declares one.
|
|
108
|
+
val declared = conn.getHeaderFieldLong("Content-Length", -1L)
|
|
109
|
+
if (declared > MAX_BYTES) {
|
|
110
|
+
Log.e(TAG, "Download rejected: declared $declared bytes exceeds $MAX_BYTES")
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
val gzipped = conn.contentEncoding?.equals("gzip", ignoreCase = true) == true
|
|
115
|
+
val source = if (gzipped) GZIPInputStream(conn.inputStream) else conn.inputStream
|
|
116
|
+
|
|
117
|
+
// Total is only trustworthy when it measures the same stream we
|
|
118
|
+
// count: a gzip body's Content-Length is the COMPRESSED size while
|
|
119
|
+
// `total` counts decompressed bytes.
|
|
120
|
+
val progressTotal = if (gzipped) -1L else declared
|
|
121
|
+
|
|
122
|
+
destination.parentFile?.mkdirs()
|
|
123
|
+
|
|
124
|
+
var total = 0L
|
|
125
|
+
var lastProgressAt = 0L
|
|
126
|
+
source.use { input ->
|
|
127
|
+
FileOutputStream(destination).use { output ->
|
|
128
|
+
val buffer = ByteArray(64 * 1024)
|
|
129
|
+
while (true) {
|
|
130
|
+
val read = input.read(buffer)
|
|
131
|
+
if (read == -1) break
|
|
132
|
+
total += read
|
|
133
|
+
// Enforced during streaming too: Content-Length is absent on
|
|
134
|
+
// chunked responses, and gzip hides the decompressed size, so
|
|
135
|
+
// this is the only cap that holds for a zip bomb.
|
|
136
|
+
if (total > MAX_BYTES) {
|
|
137
|
+
Log.e(TAG, "Download aborted: exceeded $MAX_BYTES bytes")
|
|
138
|
+
output.close()
|
|
139
|
+
destination.delete()
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
output.write(buffer, 0, read)
|
|
143
|
+
|
|
144
|
+
if (onProgress != null) {
|
|
145
|
+
val now = System.currentTimeMillis()
|
|
146
|
+
if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
|
|
147
|
+
lastProgressAt = now
|
|
148
|
+
onProgress(total, progressTotal)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
output.fd.sync()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Terminal callback: bytesRead == totalBytes signals completion even
|
|
157
|
+
// when the whole body arrived inside one throttle window.
|
|
158
|
+
onProgress?.invoke(total, total)
|
|
159
|
+
|
|
160
|
+
Log.i(TAG, "Downloaded ${total / 1024} KB${if (gzipped) " (gzip)" else ""}")
|
|
161
|
+
return true
|
|
162
|
+
} catch (e: Exception) {
|
|
163
|
+
Log.e(TAG, "Download failed", e)
|
|
164
|
+
destination.delete()
|
|
165
|
+
return false
|
|
166
|
+
} finally {
|
|
167
|
+
connection?.disconnect()
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -28,7 +28,7 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
28
28
|
const val NAME = "ScaleBunOta"
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Static entry point
|
|
31
|
+
* Static entry point that resolves the active OTA bundle at launch.
|
|
32
32
|
*
|
|
33
33
|
* Called BEFORE React Native initializes JS — this is where the boot
|
|
34
34
|
* guard runs and the active bundle path is resolved.
|
|
@@ -36,13 +36,44 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
36
36
|
* Returns null in DEBUG builds (Metro always wins) or when no OTA
|
|
37
37
|
* bundle is installed.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
39
|
+
* ── Which integration to use ────────────────────────────────────────
|
|
40
|
+
*
|
|
41
|
+
* PREFERRED — React Native 0.81 and above, and REQUIRED on 0.82+.
|
|
42
|
+
* Pass the path directly to the ReactHost. This does not depend on
|
|
43
|
+
* `ReactNativeHost` at all, which matters because 0.82 REMOVED that
|
|
44
|
+
* class: the override form below does not compile there.
|
|
45
|
+
*
|
|
46
|
+
* ```kotlin
|
|
47
|
+
* override val reactHost: ReactHost by lazy {
|
|
48
|
+
* getDefaultReactHost(
|
|
49
|
+
* context = applicationContext,
|
|
50
|
+
* packageList = PackageList(this).packages,
|
|
51
|
+
* jsBundleFilePath = ScaleBunOtaModule.getJSBundleFile(applicationContext),
|
|
52
|
+
* )
|
|
53
|
+
* }
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* LEGACY — React Native 0.76.1 through 0.81, bridge mode any version:
|
|
57
|
+
*
|
|
40
58
|
* ```kotlin
|
|
41
59
|
* override fun getJSBundleFile(): String? {
|
|
42
60
|
* return ScaleBunOtaModule.getJSBundleFile(this)
|
|
43
61
|
* ?: super.getJSBundleFile()
|
|
44
62
|
* }
|
|
45
63
|
* ```
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ The legacy form is IGNORED on bridgeless below React Native
|
|
66
|
+
* 0.76.1. `DefaultReactNativeHost.toReactHost()` did not forward
|
|
67
|
+
* `jsBundleFile` until then, and `DefaultReactHost` hardcoded
|
|
68
|
+
* `JSBundleLoader.createAssetLoader(...)` — so an update installs,
|
|
69
|
+
* reports success, and the app keeps running the APK asset. The SDK
|
|
70
|
+
* detects that runtime and disables OTA rather than pretending to work
|
|
71
|
+
* (see `src/features/ota/environment.ts`).
|
|
72
|
+
*
|
|
73
|
+
* Whichever form is used, the SDK verifies after restart that the
|
|
74
|
+
* bundle it installed is the bundle actually executing, and reports
|
|
75
|
+
* APPLY_FAILED if it is not. A mis-wired integration surfaces as data
|
|
76
|
+
* rather than as silence.
|
|
46
77
|
*/
|
|
47
78
|
@JvmStatic
|
|
48
79
|
fun getJSBundleFile(context: Context): String? {
|
|
@@ -67,6 +98,36 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
67
98
|
|
|
68
99
|
override fun getName(): String = NAME
|
|
69
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Forward download progress to JS as a device event.
|
|
103
|
+
*
|
|
104
|
+
* The JS side (`OtaOrchestrator`) turns these into `DOWNLOAD_PROGRESS`
|
|
105
|
+
* events for the `useOtaUpdate` hook and throttled POSTs to the live
|
|
106
|
+
* tracker. Until this existed, the hook's `downloadProgress` field and the
|
|
107
|
+
* dashboard's live-downloads panel were declared on both ends and fed by
|
|
108
|
+
* neither — the download was one opaque promise.
|
|
109
|
+
*
|
|
110
|
+
* `RCTDeviceEventEmitter` works on both architectures (the New Architecture
|
|
111
|
+
* keeps it via the interop layer). Failures are swallowed: progress display
|
|
112
|
+
* must never be able to break the download it describes.
|
|
113
|
+
*/
|
|
114
|
+
private fun emitProgress(bytesRead: Long, totalBytes: Long) {
|
|
115
|
+
try {
|
|
116
|
+
if (!reactApplicationContext.hasActiveReactInstance()) return
|
|
117
|
+
val payload = com.facebook.react.bridge.Arguments.createMap().apply {
|
|
118
|
+
putDouble("bytesRead", bytesRead.toDouble())
|
|
119
|
+
putDouble("totalBytes", totalBytes.toDouble())
|
|
120
|
+
}
|
|
121
|
+
reactApplicationContext
|
|
122
|
+
.getJSModule(
|
|
123
|
+
com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter::class.java,
|
|
124
|
+
)
|
|
125
|
+
.emit("ScaleBunOtaProgress", payload)
|
|
126
|
+
} catch (_: Exception) {
|
|
127
|
+
// Never let progress reporting interfere with the download itself.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
70
131
|
@ReactMethod(isBlockingSynchronousMethod = true)
|
|
71
132
|
override fun getActiveBundle(): String? {
|
|
72
133
|
if (isDebugBuild(reactApplicationContext)) return null
|
|
@@ -78,13 +139,41 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
78
139
|
return slotManager.slotState().toString()
|
|
79
140
|
}
|
|
80
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Stage a bundle into the `next` slot.
|
|
144
|
+
*
|
|
145
|
+
* OTA-05: [sourcePath] may be an http(s) URL or a local file path. The JS
|
|
146
|
+
* orchestrator has always passed `bundle.url` here while this method assumed
|
|
147
|
+
* a filesystem path, so every install failed at staging. Accepting both keeps
|
|
148
|
+
* the documented local-path contract for any existing caller and makes the
|
|
149
|
+
* orchestrator correct without a coordinated JS+native change.
|
|
150
|
+
*
|
|
151
|
+
* Downloaded bytes land in a temp file that is always cleaned up:
|
|
152
|
+
* [SlotManager.stage] copies into its own `_tmp_` directory and verifies the
|
|
153
|
+
* SHA-256 there, so this file is never the one that gets promoted.
|
|
154
|
+
*/
|
|
81
155
|
@ReactMethod
|
|
82
156
|
override fun stageBundle(sourcePath: String, sha256: String, promise: Promise) {
|
|
157
|
+
var downloaded: File? = null
|
|
83
158
|
try {
|
|
84
|
-
val
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
159
|
+
val isRemote = sourcePath.startsWith("http://", true) ||
|
|
160
|
+
sourcePath.startsWith("https://", true)
|
|
161
|
+
|
|
162
|
+
val sourceFile: File
|
|
163
|
+
if (isRemote) {
|
|
164
|
+
val temp = File(reactApplicationContext.cacheDir, "scalebun_ota_dl_${System.nanoTime()}")
|
|
165
|
+
downloaded = temp
|
|
166
|
+
if (!BundleDownloader.download(sourcePath, temp, ::emitProgress)) {
|
|
167
|
+
promise.resolve(false)
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
sourceFile = temp
|
|
171
|
+
} else {
|
|
172
|
+
sourceFile = File(sourcePath)
|
|
173
|
+
if (!sourceFile.exists()) {
|
|
174
|
+
promise.resolve(false)
|
|
175
|
+
return
|
|
176
|
+
}
|
|
88
177
|
}
|
|
89
178
|
|
|
90
179
|
// Build meta.json from source file info
|
|
@@ -96,16 +185,34 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
96
185
|
promise.resolve(success)
|
|
97
186
|
} catch (e: Exception) {
|
|
98
187
|
promise.reject("STAGE_ERROR", e.message, e)
|
|
188
|
+
} finally {
|
|
189
|
+
downloaded?.delete()
|
|
99
190
|
}
|
|
100
191
|
}
|
|
101
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Binary-patch staging (S7-NAT-1) — NOT IMPLEMENTED on Android.
|
|
195
|
+
*
|
|
196
|
+
* [SlotManager] has no patch/bspatch path, so there is nothing to apply a diff
|
|
197
|
+
* with. The JS contract in `src/specs/NativeScaleBunOta.ts` defines `false` as
|
|
198
|
+
* "patching unavailable — fall back to a full-bundle download", which is exactly
|
|
199
|
+
* the honest answer here; OtaOrchestrator then downloads the whole bundle and the
|
|
200
|
+
* update still lands. Resolving false (rather than rejecting) keeps that fallback
|
|
201
|
+
* on its documented path instead of surfacing an error the caller must special-case.
|
|
202
|
+
*
|
|
203
|
+
* This override must exist regardless: on the New Architecture the codegen base
|
|
204
|
+
* (`NativeScaleBunOtaSpec`, generated from the JS spec) declares `stagePatch`
|
|
205
|
+
* abstract, so omitting it fails Kotlin compilation for every new-arch consumer.
|
|
206
|
+
*
|
|
207
|
+
* Replace the body with a real bspatch once SlotManager grows patch support.
|
|
208
|
+
*/
|
|
102
209
|
@ReactMethod
|
|
103
|
-
override fun stagePatch(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
210
|
+
override fun stagePatch(
|
|
211
|
+
patchUrl: String,
|
|
212
|
+
patchSha256: String,
|
|
213
|
+
expectedSha256: String,
|
|
214
|
+
promise: Promise,
|
|
215
|
+
) {
|
|
109
216
|
promise.resolve(false)
|
|
110
217
|
}
|
|
111
218
|
|
|
@@ -133,4 +240,45 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
|
|
|
133
240
|
promise.reject("REVERT_ERROR", e.message, e)
|
|
134
241
|
}
|
|
135
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Restart the React runtime so the applied bundle loads NOW.
|
|
246
|
+
*
|
|
247
|
+
* `DevSettings.reload()` is a silent no-op in release builds — verified
|
|
248
|
+
* live: the JS logged "Triggering app restart…" and the old bundle kept
|
|
249
|
+
* running in the same process. This is the production-grade path:
|
|
250
|
+
* bridgeless hosts reload through [com.facebook.react.ReactHost.reload],
|
|
251
|
+
* bridge hosts through `recreateReactContextInBackground()`. Both re-run
|
|
252
|
+
* the bundle-resolution path, which is what picks up the new `current/`.
|
|
253
|
+
*/
|
|
254
|
+
@ReactMethod
|
|
255
|
+
override fun restartApp() {
|
|
256
|
+
val application = reactApplicationContext.applicationContext
|
|
257
|
+
com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
|
|
258
|
+
try {
|
|
259
|
+
val reactApp = application as? com.facebook.react.ReactApplication
|
|
260
|
+
if (reactApp == null) {
|
|
261
|
+
android.util.Log.e(NAME, "restartApp: application is not a ReactApplication")
|
|
262
|
+
return@runOnUiThread
|
|
263
|
+
}
|
|
264
|
+
val host = try {
|
|
265
|
+
reactApp.reactHost
|
|
266
|
+
} catch (_: Throwable) {
|
|
267
|
+
null
|
|
268
|
+
}
|
|
269
|
+
if (host != null) {
|
|
270
|
+
android.util.Log.i(NAME, "restartApp: reloading via ReactHost (bridgeless)")
|
|
271
|
+
host.reload("ScaleBun OTA update")
|
|
272
|
+
return@runOnUiThread
|
|
273
|
+
}
|
|
274
|
+
android.util.Log.i(NAME, "restartApp: recreating React context (bridge)")
|
|
275
|
+
reactApp.reactNativeHost.reactInstanceManager
|
|
276
|
+
.recreateReactContextInBackground()
|
|
277
|
+
} catch (e: Throwable) {
|
|
278
|
+
// Never crash the host over a restart; the update still
|
|
279
|
+
// activates on the next cold start.
|
|
280
|
+
android.util.Log.e(NAME, "restartApp failed — update applies on next cold start", e)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
136
284
|
}
|
|
@@ -202,7 +202,10 @@ class SlotManager(private val filesDir: File) {
|
|
|
202
202
|
|
|
203
203
|
try {
|
|
204
204
|
val markerJson = JSONObject(bootMarker.readText())
|
|
205
|
-
|
|
205
|
+
// Read the marker's sha256, falling back to the legacy `bundleId`
|
|
206
|
+
// field so a marker written by a previous build is still readable.
|
|
207
|
+
val markerId = markerJson.optString("sha256", "")
|
|
208
|
+
.ifEmpty { markerJson.optString("bundleId", "") }
|
|
206
209
|
|
|
207
210
|
val metaFile = File(currentSlot, META_FILENAME)
|
|
208
211
|
if (!metaFile.exists()) {
|
|
@@ -212,10 +215,21 @@ class SlotManager(private val filesDir: File) {
|
|
|
212
215
|
}
|
|
213
216
|
|
|
214
217
|
val currentMeta = JSONObject(metaFile.readText())
|
|
215
|
-
val
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
218
|
+
val currentId = currentMeta.optString("sha256", "")
|
|
219
|
+
|
|
220
|
+
// Both sides must be non-empty for the comparison to mean anything.
|
|
221
|
+
// Treating "" == "" as a match is what made this check vacuous
|
|
222
|
+
// before; treating "" != "x" as corruption would revert every
|
|
223
|
+
// bundle staged by an older build. So: compare only when we have
|
|
224
|
+
// two real values, and say so plainly when we do not.
|
|
225
|
+
if (markerId.isEmpty() || currentId.isEmpty()) {
|
|
226
|
+
Log.w(
|
|
227
|
+
TAG,
|
|
228
|
+
"Boot guard cannot verify this bundle — marker or meta has no sha256. " +
|
|
229
|
+
"Leaving it in place; restage with a current SDK to re-enable the check.",
|
|
230
|
+
)
|
|
231
|
+
} else if (currentId != markerId) {
|
|
232
|
+
Log.w(TAG, "Boot marker sha256 mismatch — auto-reverting")
|
|
219
233
|
revert()
|
|
220
234
|
}
|
|
221
235
|
// If they match: this is the first launch of a new bundle.
|
|
@@ -244,7 +258,19 @@ class SlotManager(private val filesDir: File) {
|
|
|
244
258
|
val meta = readMeta(currentSlot)
|
|
245
259
|
if (meta != null) {
|
|
246
260
|
val marker = JSONObject()
|
|
247
|
-
|
|
261
|
+
// Identify the bundle by its SHA-256, not by `bundleId`.
|
|
262
|
+
//
|
|
263
|
+
// `stageBundle` writes a meta.json containing only `sha256` and
|
|
264
|
+
// `installedAt` — it is never told the bundleId. So this used to
|
|
265
|
+
// record an empty string, checkBootGuard() then compared that empty
|
|
266
|
+
// string against another empty string read from the same absent
|
|
267
|
+
// field, they were always equal, and the corruption check could
|
|
268
|
+
// never fire. The guard's most important comparison was a no-op.
|
|
269
|
+
//
|
|
270
|
+
// sha256 is the field that is actually present, and it is a
|
|
271
|
+
// strictly better identity for this purpose anyway: it detects a
|
|
272
|
+
// bundle whose CONTENT changed, not merely one whose id differs.
|
|
273
|
+
marker.put("sha256", meta.optString("sha256"))
|
|
248
274
|
marker.put("appliedAt", System.currentTimeMillis())
|
|
249
275
|
otaRoot.mkdirs()
|
|
250
276
|
bootMarker.writeText(marker.toString())
|
|
@@ -18,8 +18,14 @@ abstract class ScaleBunOtaSpec(reactContext: ReactApplicationContext) :
|
|
|
18
18
|
abstract fun getActiveBundle(): String?
|
|
19
19
|
abstract fun getSlotState(): String
|
|
20
20
|
abstract fun stageBundle(sourcePath: String, sha256: String, promise: Promise)
|
|
21
|
-
abstract fun stagePatch(
|
|
21
|
+
abstract fun stagePatch(
|
|
22
|
+
patchUrl: String,
|
|
23
|
+
patchSha256: String,
|
|
24
|
+
expectedSha256: String,
|
|
25
|
+
promise: Promise,
|
|
26
|
+
)
|
|
22
27
|
abstract fun applyUpdate(promise: Promise)
|
|
23
28
|
abstract fun markHealthy()
|
|
24
29
|
abstract fun revertToPrevious(promise: Promise)
|
|
30
|
+
abstract fun restartApp()
|
|
25
31
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
Downloads an OTA bundle to a local file (OTA-05).
|
|
5
|
+
|
|
6
|
+
WHY THIS EXISTS. `OtaOrchestrator.sync()` has always passed `bundle.url` — a
|
|
7
|
+
presigned HTTPS URL — into `stageBundle()`, while `OtaSlotManager.stage` treated
|
|
8
|
+
that argument as a filesystem path and called
|
|
9
|
+
`copyItem(at: URL(fileURLWithPath:))`, which throws for a remote URL. No part of
|
|
10
|
+
the SDK downloaded the bundle, so OTA could never install an update, and every
|
|
11
|
+
safety mechanism downstream had only ever run against synthetic events.
|
|
12
|
+
|
|
13
|
+
Synchronous by design: `stageBundle` is a promise-returning bridge method that
|
|
14
|
+
already runs off the main thread, and the slot manager's staging step is
|
|
15
|
+
synchronous. A semaphore here keeps the call site a straight line rather than
|
|
16
|
+
threading a completion handler through the bridge for no behavioural gain.
|
|
17
|
+
|
|
18
|
+
Delegate-based rather than completion-handler-based, for one reason: the size
|
|
19
|
+
cap must fire DURING the stream, not after it. The previous implementation
|
|
20
|
+
checked the file size only once the download completed — by which point a
|
|
21
|
+
hostile or buggy 5 GB body had already been written to the user's disk. The
|
|
22
|
+
Android downloader has always cancelled mid-stream; this brings iOS to parity.
|
|
23
|
+
*/
|
|
24
|
+
enum BundleDownloader {
|
|
25
|
+
|
|
26
|
+
/// Matches the server-side finalize cap. A larger body is a bug or an attack.
|
|
27
|
+
fileprivate static let maxBytes: Int64 = 50 * 1024 * 1024
|
|
28
|
+
fileprivate static let timeout: TimeInterval = 60
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
Download `urlString` to `destination`. Returns true on success.
|
|
32
|
+
|
|
33
|
+
Integrity is NOT checked here — `OtaSlotManager.stage` re-hashes what landed
|
|
34
|
+
on disk, which is the only measurement that matters. On any failure the
|
|
35
|
+
destination is removed so a partial body cannot be mistaken for a bundle.
|
|
36
|
+
|
|
37
|
+
C2a: `URLSession` advertises and transparently decodes gzip, so what reaches
|
|
38
|
+
disk is the decompressed bundle — the same bytes the server hashed and the
|
|
39
|
+
signature covers.
|
|
40
|
+
*/
|
|
41
|
+
static func download(urlString: String, destination: URL) -> Bool {
|
|
42
|
+
guard let url = URL(string: urlString) else {
|
|
43
|
+
NSLog("[ScaleBunOta] Download failed: malformed URL")
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let configuration = URLSessionConfiguration.ephemeral
|
|
48
|
+
configuration.timeoutIntervalForRequest = timeout
|
|
49
|
+
configuration.timeoutIntervalForResource = timeout * 4
|
|
50
|
+
|
|
51
|
+
let delegate = SizeCappedDownloadDelegate(destination: destination)
|
|
52
|
+
let session = URLSession(
|
|
53
|
+
configuration: configuration,
|
|
54
|
+
delegate: delegate,
|
|
55
|
+
delegateQueue: nil
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
// downloadTask streams to a temp file rather than buffering the whole
|
|
59
|
+
// bundle in memory — a 50 MB dataTask spike is enough to get the host app
|
|
60
|
+
// jetsammed on an older device.
|
|
61
|
+
session.downloadTask(with: url).resume()
|
|
62
|
+
_ = delegate.semaphore.wait(timeout: .now() + timeout * 4)
|
|
63
|
+
|
|
64
|
+
// The session retains its delegate until invalidated; without this,
|
|
65
|
+
// every download across the app's lifetime leaked a session + delegate.
|
|
66
|
+
session.invalidateAndCancel()
|
|
67
|
+
|
|
68
|
+
if !delegate.success {
|
|
69
|
+
try? FileManager.default.removeItem(at: destination)
|
|
70
|
+
}
|
|
71
|
+
return delegate.success
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
URLSession delegate enforcing the byte cap while the body streams in.
|
|
77
|
+
|
|
78
|
+
`urlSession(_:downloadTask:didWriteData:...)` fires per chunk; the moment the
|
|
79
|
+
running total (or the server's own Content-Length claim) exceeds the cap, the
|
|
80
|
+
task is cancelled — nothing more lands on disk. All terminal paths signal the
|
|
81
|
+
semaphore exactly once: didFinishDownloading only signals on success, and every
|
|
82
|
+
completion (success, cancel, error, timeout) also passes through
|
|
83
|
+
`didCompleteWithError`, where the single signal for failures lives.
|
|
84
|
+
*/
|
|
85
|
+
private final class SizeCappedDownloadDelegate: NSObject, URLSessionDownloadDelegate {
|
|
86
|
+
let semaphore = DispatchSemaphore(value: 0)
|
|
87
|
+
private(set) var success = false
|
|
88
|
+
|
|
89
|
+
private let destination: URL
|
|
90
|
+
private var cancelledForSize = false
|
|
91
|
+
|
|
92
|
+
init(destination: URL) {
|
|
93
|
+
self.destination = destination
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Emit at most one progress event per this interval — didWriteData is hot.
|
|
97
|
+
private static let progressIntervalMs: Int64 = 500
|
|
98
|
+
private var lastProgressAtMs: Int64 = 0
|
|
99
|
+
|
|
100
|
+
func urlSession(
|
|
101
|
+
_ session: URLSession,
|
|
102
|
+
downloadTask: URLSessionDownloadTask,
|
|
103
|
+
didWriteData bytesWritten: Int64,
|
|
104
|
+
totalBytesWritten: Int64,
|
|
105
|
+
totalBytesExpectedToWrite: Int64
|
|
106
|
+
) {
|
|
107
|
+
let claimed = totalBytesExpectedToWrite // NSURLSessionTransferSizeUnknown (-1) when absent
|
|
108
|
+
if totalBytesWritten > BundleDownloader.maxBytes ||
|
|
109
|
+
(claimed > 0 && claimed > BundleDownloader.maxBytes) {
|
|
110
|
+
cancelledForSize = true
|
|
111
|
+
NSLog(
|
|
112
|
+
"[ScaleBunOta] Download rejected: exceeds \(BundleDownloader.maxBytes) bytes " +
|
|
113
|
+
"(written=\(totalBytesWritten), claimed=\(claimed)) — cancelling mid-stream"
|
|
114
|
+
)
|
|
115
|
+
downloadTask.cancel()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Throttled progress to JS. `claimed` can be the COMPRESSED length on
|
|
120
|
+
// an encoded response while written bytes are decompressed — JS clamps
|
|
121
|
+
// the percentage, and -1 (unknown) is passed through as unknown.
|
|
122
|
+
let nowMs = Int64(Date().timeIntervalSince1970 * 1000)
|
|
123
|
+
if nowMs - lastProgressAtMs >= SizeCappedDownloadDelegate.progressIntervalMs {
|
|
124
|
+
lastProgressAtMs = nowMs
|
|
125
|
+
ScaleBunOtaEventsModule.emitProgress(
|
|
126
|
+
bytesRead: totalBytesWritten,
|
|
127
|
+
totalBytes: claimed
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
func urlSession(
|
|
133
|
+
_ session: URLSession,
|
|
134
|
+
downloadTask: URLSessionDownloadTask,
|
|
135
|
+
didFinishDownloadingTo location: URL
|
|
136
|
+
) {
|
|
137
|
+
if let http = downloadTask.response as? HTTPURLResponse,
|
|
138
|
+
!(200...299).contains(http.statusCode) {
|
|
139
|
+
NSLog("[ScaleBunOta] Download failed: HTTP \(http.statusCode)")
|
|
140
|
+
return // didCompleteWithError still fires and signals
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let fm = FileManager.default
|
|
144
|
+
|
|
145
|
+
// Belt over the streaming brace: a body small enough to never trip the
|
|
146
|
+
// per-chunk check is re-measured once whole.
|
|
147
|
+
let attributes = try? fm.attributesOfItem(atPath: location.path)
|
|
148
|
+
let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
|
149
|
+
if size > BundleDownloader.maxBytes {
|
|
150
|
+
NSLog("[ScaleBunOta] Download rejected: \(size) bytes exceeds \(BundleDownloader.maxBytes)")
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
do {
|
|
155
|
+
try? fm.removeItem(at: destination)
|
|
156
|
+
try fm.createDirectory(
|
|
157
|
+
at: destination.deletingLastPathComponent(),
|
|
158
|
+
withIntermediateDirectories: true
|
|
159
|
+
)
|
|
160
|
+
// The system deletes `location` as soon as this handler returns,
|
|
161
|
+
// so the file must be moved out synchronously, not scheduled.
|
|
162
|
+
try fm.moveItem(at: location, to: destination)
|
|
163
|
+
success = true
|
|
164
|
+
// Terminal tick: read == total signals completion even when the
|
|
165
|
+
// whole body arrived inside one throttle window.
|
|
166
|
+
ScaleBunOtaEventsModule.emitProgress(bytesRead: size, totalBytes: size)
|
|
167
|
+
} catch {
|
|
168
|
+
NSLog("[ScaleBunOta] Download failed to persist: \(error.localizedDescription)")
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func urlSession(
|
|
173
|
+
_ session: URLSession,
|
|
174
|
+
task: URLSessionTask,
|
|
175
|
+
didCompleteWithError error: Error?
|
|
176
|
+
) {
|
|
177
|
+
if let error = error, !cancelledForSize {
|
|
178
|
+
NSLog("[ScaleBunOta] Download failed: \(error.localizedDescription)")
|
|
179
|
+
}
|
|
180
|
+
semaphore.signal()
|
|
181
|
+
}
|
|
182
|
+
}
|