@scalebun/react-native 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +170 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +160 -12
  3. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +32 -6
  4. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +7 -1
  5. package/ios/Ota/BundleDownloader.swift +182 -0
  6. package/ios/Ota/OtaSlotManager.swift +35 -9
  7. package/ios/Ota/ScaleBunOtaBridge.mm +9 -0
  8. package/ios/Ota/ScaleBunOtaEventsModule.swift +61 -0
  9. package/ios/Ota/ScaleBunOtaModule.swift +58 -5
  10. package/lib/commonjs/bootstrap/SDKBootstrapper.js +22 -0
  11. package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
  12. package/lib/commonjs/features/crash/CrashFeature.js +18 -0
  13. package/lib/commonjs/features/crash/CrashFeature.js.map +1 -1
  14. package/lib/commonjs/features/crash/rejectionHandler.js +123 -0
  15. package/lib/commonjs/features/crash/rejectionHandler.js.map +1 -0
  16. package/lib/commonjs/features/engage/EngagePromptProvider.js +4 -2
  17. package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
  18. package/lib/commonjs/features/engage/engageThrottle.js +41 -10
  19. package/lib/commonjs/features/engage/engageThrottle.js.map +1 -1
  20. package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
  21. package/lib/commonjs/features/navigation/AutoScreenDetector.js +53 -10
  22. package/lib/commonjs/features/navigation/AutoScreenDetector.js.map +1 -1
  23. package/lib/commonjs/features/ota/OtaOrchestrator.js +515 -20
  24. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  25. package/lib/commonjs/features/ota/environment.js +172 -0
  26. package/lib/commonjs/features/ota/environment.js.map +1 -0
  27. package/lib/commonjs/features/ota/retry.js +96 -0
  28. package/lib/commonjs/features/ota/retry.js.map +1 -0
  29. package/lib/commonjs/features/ota/signature.js +115 -0
  30. package/lib/commonjs/features/ota/signature.js.map +1 -0
  31. package/lib/commonjs/features/session/BackendSessionAdapter.js +231 -26
  32. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  33. package/lib/commonjs/index.js +7 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/public/ScaleBunErrorBoundary.js +79 -0
  36. package/lib/commonjs/public/ScaleBunErrorBoundary.js.map +1 -0
  37. package/lib/commonjs/specs/NativeScaleBunOta.js.map +1 -1
  38. package/lib/module/bootstrap/SDKBootstrapper.js +22 -0
  39. package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
  40. package/lib/module/features/crash/CrashFeature.js +18 -0
  41. package/lib/module/features/crash/CrashFeature.js.map +1 -1
  42. package/lib/module/features/crash/rejectionHandler.js +116 -0
  43. package/lib/module/features/crash/rejectionHandler.js.map +1 -0
  44. package/lib/module/features/engage/EngagePromptProvider.js +4 -2
  45. package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
  46. package/lib/module/features/engage/engageThrottle.js +41 -10
  47. package/lib/module/features/engage/engageThrottle.js.map +1 -1
  48. package/lib/module/features/engage/engageTypes.js.map +1 -1
  49. package/lib/module/features/navigation/AutoScreenDetector.js +53 -10
  50. package/lib/module/features/navigation/AutoScreenDetector.js.map +1 -1
  51. package/lib/module/features/ota/OtaOrchestrator.js +516 -21
  52. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  53. package/lib/module/features/ota/environment.js +165 -0
  54. package/lib/module/features/ota/environment.js.map +1 -0
  55. package/lib/module/features/ota/retry.js +87 -0
  56. package/lib/module/features/ota/retry.js.map +1 -0
  57. package/lib/module/features/ota/signature.js +109 -0
  58. package/lib/module/features/ota/signature.js.map +1 -0
  59. package/lib/module/features/session/BackendSessionAdapter.js +230 -26
  60. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  61. package/lib/module/index.js +6 -0
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/public/ScaleBunErrorBoundary.js +70 -0
  64. package/lib/module/public/ScaleBunErrorBoundary.js.map +1 -0
  65. package/lib/module/specs/NativeScaleBunOta.js.map +1 -1
  66. package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
  67. package/lib/typescript/features/crash/CrashFeature.d.ts.map +1 -1
  68. package/lib/typescript/features/crash/rejectionHandler.d.ts +46 -0
  69. package/lib/typescript/features/crash/rejectionHandler.d.ts.map +1 -0
  70. package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
  71. package/lib/typescript/features/engage/engageThrottle.d.ts +24 -4
  72. package/lib/typescript/features/engage/engageThrottle.d.ts.map +1 -1
  73. package/lib/typescript/features/engage/engageTypes.d.ts +6 -0
  74. package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
  75. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +9 -0
  76. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts.map +1 -1
  77. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +67 -1
  78. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  79. package/lib/typescript/features/ota/OtaTypes.d.ts +42 -0
  80. package/lib/typescript/features/ota/OtaTypes.d.ts.map +1 -1
  81. package/lib/typescript/features/ota/environment.d.ts +99 -0
  82. package/lib/typescript/features/ota/environment.d.ts.map +1 -0
  83. package/lib/typescript/features/ota/retry.d.ts +53 -0
  84. package/lib/typescript/features/ota/retry.d.ts.map +1 -0
  85. package/lib/typescript/features/ota/signature.d.ts +66 -0
  86. package/lib/typescript/features/ota/signature.d.ts.map +1 -0
  87. package/lib/typescript/features/session/BackendSessionAdapter.d.ts +93 -2
  88. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  89. package/lib/typescript/index.d.ts +6 -0
  90. package/lib/typescript/index.d.ts.map +1 -1
  91. package/lib/typescript/public/ScaleBunErrorBoundary.d.ts +44 -0
  92. package/lib/typescript/public/ScaleBunErrorBoundary.d.ts.map +1 -0
  93. package/lib/typescript/specs/NativeScaleBunOta.d.ts +11 -0
  94. package/lib/typescript/specs/NativeScaleBunOta.d.ts.map +1 -1
  95. package/package.json +3 -3
  96. package/src/bootstrap/SDKBootstrapper.ts +24 -0
  97. package/src/features/crash/CrashFeature.ts +19 -0
  98. package/src/features/crash/rejectionHandler.ts +134 -0
  99. package/src/features/engage/EngagePromptProvider.tsx +4 -2
  100. package/src/features/engage/engageThrottle.ts +44 -6
  101. package/src/features/engage/engageTypes.ts +6 -0
  102. package/src/features/navigation/AutoScreenDetector.ts +59 -4
  103. package/src/features/ota/OtaOrchestrator.ts +594 -23
  104. package/src/features/ota/OtaTypes.ts +56 -0
  105. package/src/features/ota/environment.ts +199 -0
  106. package/src/features/ota/retry.ts +123 -0
  107. package/src/features/ota/signature.ts +123 -0
  108. package/src/features/session/BackendSessionAdapter.ts +233 -25
  109. package/src/index.ts +6 -0
  110. package/src/public/ScaleBunErrorBoundary.tsx +79 -0
  111. package/src/specs/NativeScaleBunOta.ts +12 -0
@@ -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 for MainApplication.getJSBundleFile().
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
- * Usage in MainApplication.kt:
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 sourceFile = File(sourcePath)
85
- if (!sourceFile.exists()) {
86
- promise.resolve(false)
87
- return
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(patchUrl: String, patchSha256: String, expectedSha256: String, promise: Promise) {
104
- // Delta/patch updates (native bspatch) are not implemented yet. The TS spec
105
- // declares stagePatch, so the codegen New-Arch base makes it an abstract
106
- // member this class MUST override — otherwise @scalebun/react-native fails
107
- // to compile on Android. Resolve false so OtaOrchestrator falls back to
108
- // full-bundle staging (which is the only supported path today).
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
- val markerBundleId = markerJson.optString("bundleId", "")
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 currentBundleId = currentMeta.optString("bundleId", "")
216
-
217
- if (currentBundleId != markerBundleId) {
218
- Log.w(TAG, "Boot marker bundleId mismatch auto-reverting")
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
- marker.put("bundleId", meta.optString("bundleId"))
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(patchUrl: String, patchSha256: String, expectedSha256: String, promise: Promise)
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
+ }