react-native-bspatch 3.1.1 → 4.0.0

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 (53) hide show
  1. package/README.md +3 -3
  2. package/android/build.gradle +18 -40
  3. package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +148 -13
  4. package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -3
  5. package/cpp/CMakeLists.txt +0 -3
  6. package/cpp/bsdiff/bspatch_wrapper.c +3 -2
  7. package/ios/OtaBundleResolver.swift +22 -5
  8. package/ios/RNBsPatch.mm +220 -32
  9. package/lib/commonjs/NativeBsPatch.js.map +1 -1
  10. package/lib/commonjs/index.js +1 -13
  11. package/lib/commonjs/index.js.map +1 -1
  12. package/lib/commonjs/ota/OtaProvider.js +21 -21
  13. package/lib/commonjs/ota/OtaProvider.js.map +1 -1
  14. package/lib/commonjs/ota/otaCore.js +50 -88
  15. package/lib/commonjs/ota/otaCore.js.map +1 -1
  16. package/lib/commonjs/ota/otaMetadata.js +46 -23
  17. package/lib/commonjs/ota/otaMetadata.js.map +1 -1
  18. package/lib/commonjs/ota/storage/GitHubRawStorage.js +22 -12
  19. package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
  20. package/lib/module/NativeBsPatch.js.map +1 -1
  21. package/lib/module/index.js +0 -8
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/module/ota/OtaProvider.js +22 -22
  24. package/lib/module/ota/OtaProvider.js.map +1 -1
  25. package/lib/module/ota/otaCore.js +50 -87
  26. package/lib/module/ota/otaCore.js.map +1 -1
  27. package/lib/module/ota/otaMetadata.js +44 -21
  28. package/lib/module/ota/otaMetadata.js.map +1 -1
  29. package/lib/module/ota/storage/GitHubRawStorage.js +20 -10
  30. package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
  31. package/lib/typescript/NativeBsPatch.d.ts +36 -1
  32. package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
  33. package/lib/typescript/index.d.ts +0 -7
  34. package/lib/typescript/index.d.ts.map +1 -1
  35. package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
  36. package/lib/typescript/ota/otaCore.d.ts +2 -1
  37. package/lib/typescript/ota/otaCore.d.ts.map +1 -1
  38. package/lib/typescript/ota/otaMetadata.d.ts +5 -0
  39. package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
  40. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts +2 -0
  41. package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
  42. package/package.json +2 -3
  43. package/react-native-bspatch.podspec +3 -25
  44. package/src/NativeBsPatch.ts +44 -1
  45. package/src/index.ts +0 -7
  46. package/src/ota/OtaProvider.tsx +37 -23
  47. package/src/ota/otaCore.ts +55 -94
  48. package/src/ota/otaMetadata.ts +44 -25
  49. package/src/ota/storage/GitHubRawStorage.ts +17 -22
  50. package/tools/build_ota.sh +83 -58
  51. package/tools/update_registry.js +4 -2
  52. package/tools/upload_ota.sh +35 -19
  53. package/ios/RNBsPatch.h +0 -8
package/README.md CHANGED
@@ -25,15 +25,15 @@ Premium OTA update solution for React Native using **TurboModules** + **bsdiff b
25
25
 
26
26
  ## Requirements
27
27
 
28
- - React Native **0.76+** (New Architecture enabled by default)
29
- - `react-native-blob-util` **0.24.11+** — required peer dependency for file I/O (0.24.10 stops Android file downloads after the first 8 KB: "Download interrupted.")
28
+ - React Native **0.80+** with the New Architecture (TurboModule event emitters for download progress)
29
+ - No other runtime dependencies: downloads and file I/O are native to this module
30
30
 
31
31
  ---
32
32
 
33
33
  ## Installation
34
34
 
35
35
  ```bash
36
- npm install react-native-bspatch react-native-blob-util
36
+ npm install react-native-bspatch
37
37
  cd ios && pod install
38
38
  ```
39
39
 
@@ -1,27 +1,8 @@
1
- buildscript {
2
- def kotlin_version = rootProject.ext.has("kotlinVersion") ? rootProject.ext.get("kotlinVersion") : project.properties["BsPatch_kotlinVersion"]
3
-
4
- repositories {
5
- google()
6
- mavenCentral()
7
- }
8
-
9
- dependencies {
10
- classpath "com.android.tools.build:gradle:9.0.0"
11
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
12
- }
13
- }
14
-
15
- def isNewArchitectureEnabled() {
16
- return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
17
- }
18
-
1
+ // AGP and the Kotlin plugin come from the host app's root build classpath
19
2
  apply plugin: "com.android.library"
20
3
  apply plugin: "kotlin-android"
21
-
22
- if (isNewArchitectureEnabled()) {
23
- apply plugin: "com.facebook.react"
24
- }
4
+ // The module extends the codegen spec, so it only builds on the New Architecture
5
+ apply plugin: "com.facebook.react"
25
6
 
26
7
  def getExtOrDefault(name) {
27
8
  return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["BsPatch_" + name]
@@ -31,6 +12,12 @@ def getExtOrIntegerDefault(name) {
31
12
  return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["BsPatch_" + name]).toInteger()
32
13
  }
33
14
 
15
+ // Build only the ABIs the app builds (RN's `reactNativeArchitectures` gradle property)
16
+ def reactNativeArchitectures() {
17
+ def value = rootProject.getProperties().get("reactNativeArchitectures")
18
+ return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
19
+ }
20
+
34
21
  android {
35
22
  namespace "vn.reactnativebspatch"
36
23
 
@@ -39,11 +26,10 @@ android {
39
26
  defaultConfig {
40
27
  minSdkVersion getExtOrIntegerDefault("minSdkVersion")
41
28
  targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
42
- buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
43
29
  externalNativeBuild {
44
30
  cmake {
45
- cppFlags "-frtti -fexceptions -Wall -fstack-protector-all"
46
- abiFilters "x86", "x86_64", "armeabi-v7a", "arm64-v8a"
31
+ cppFlags "-Wall -fstack-protector-all"
32
+ abiFilters(*reactNativeArchitectures())
47
33
  }
48
34
  }
49
35
  }
@@ -67,14 +53,7 @@ android {
67
53
 
68
54
  sourceSets {
69
55
  main {
70
- if (isNewArchitectureEnabled()) {
71
- java.srcDirs += [
72
- "src/newarch",
73
- "${project.buildDir}/generated/source/codegen/java"
74
- ]
75
- } else {
76
- java.srcDirs += ["src/oldarch"]
77
- }
56
+ java.srcDirs += ["${project.buildDir}/generated/source/codegen/java"]
78
57
  }
79
58
  }
80
59
  }
@@ -87,14 +66,13 @@ repositories {
87
66
  def kotlin_version = getExtOrDefault("kotlinVersion")
88
67
 
89
68
  dependencies {
90
- implementation "com.facebook.react:react-native:+"
69
+ // Version comes from the React Native Gradle plugin
70
+ implementation "com.facebook.react:react-android"
91
71
  implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
92
72
  }
93
73
 
94
- if (isNewArchitectureEnabled()) {
95
- react {
96
- jsRootDir = file("../src/")
97
- libraryName = "RNBsPatchSpec"
98
- codegenJavaPackageName = "vn.reactnativebspatch"
99
- }
74
+ react {
75
+ jsRootDir = file("../src/")
76
+ libraryName = "RNBsPatchSpec"
77
+ codegenJavaPackageName = "vn.reactnativebspatch"
100
78
  }
@@ -2,13 +2,18 @@ package vn.reactnativebspatch
2
2
 
3
3
  import android.os.Handler
4
4
  import android.os.Looper
5
+ import android.os.SystemClock
6
+ import com.facebook.react.bridge.Arguments
5
7
  import com.facebook.react.bridge.ReactApplicationContext
6
- import com.facebook.react.bridge.ReactMethod
7
- import com.facebook.react.bridge.Callback
8
8
  import com.facebook.react.bridge.Promise
9
+ import com.facebook.react.common.LifecycleState
9
10
  import java.io.File
10
11
  import java.io.FileInputStream
11
- import java.io.BufferedInputStream
12
+ import java.io.FileOutputStream
13
+ import java.io.IOException
14
+ import java.net.HttpURLConnection
15
+ import java.net.URL
16
+ import java.security.MessageDigest
12
17
  import java.util.concurrent.Executors
13
18
 
14
19
  class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeBsPatchSpec(reactContext) {
@@ -16,7 +21,9 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
16
21
  companion object {
17
22
  const val NAME = "BsPatch"
18
23
  private const val IO_BUFFER_SIZE = 65536 // 64KB buffer for optimal flash storage I/O
19
- private val backgroundExecutor = Executors.newFixedThreadPool(2)
24
+ private const val PROGRESS_INTERVAL_MS = 250L
25
+ // Unbounded: JS runs up to 6 asset downloads at once next to patch/hash work
26
+ private val backgroundExecutor = Executors.newCachedThreadPool()
20
27
 
21
28
  init {
22
29
  System.loadLibrary("bspatch")
@@ -45,16 +52,16 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
45
52
  }
46
53
 
47
54
  override fun reloadBundle() {
48
- val activity = reactContext.currentActivity
49
- if (activity != null) {
50
- Handler(Looper.getMainLooper()).post {
51
- val intent = activity.packageManager.getLaunchIntentForPackage(activity.packageName)
52
- if (intent != null) {
53
- intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)
54
- activity.startActivity(intent)
55
- Runtime.getRuntime().exit(0)
56
- }
55
+ Handler(Looper.getMainLooper()).post {
56
+ // Backgrounded: just exit, the next launch is a cold start on the new bundle.
57
+ // Relaunching here would pull the app back to the foreground.
58
+ val activity = reactContext.currentActivity
59
+ if (activity != null && reactContext.lifecycleState == LifecycleState.RESUMED) {
60
+ val intent = activity.packageManager.getLaunchIntentForPackage(activity.packageName) ?: return@post
61
+ intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP)
62
+ activity.startActivity(intent)
57
63
  }
64
+ Runtime.getRuntime().exit(0)
58
65
  }
59
66
  }
60
67
 
@@ -124,4 +131,132 @@ class BsPatchModule(private val reactContext: ReactApplicationContext) : NativeB
124
131
  }
125
132
  }
126
133
 
134
+ // ─── File I/O ───────────────────────────────────────────────────────
135
+
136
+ /** Runs block off the JS thread; Unit resolves as null. */
137
+ private fun io(promise: Promise, block: () -> Any?) {
138
+ backgroundExecutor.execute {
139
+ try {
140
+ val result = block()
141
+ promise.resolve(if (result == Unit) null else result)
142
+ } catch (e: Exception) {
143
+ promise.reject("ERR_FS", e.message, e)
144
+ }
145
+ }
146
+ }
147
+
148
+ override fun getDocumentDir(): String = reactContext.filesDir.absolutePath
149
+
150
+ override fun downloadFile(url: String, destPath: String, timeoutMs: Double, promise: Promise) {
151
+ backgroundExecutor.execute {
152
+ val part = File("$destPath.part")
153
+ var conn: HttpURLConnection? = null
154
+ try {
155
+ conn = (URL(url).openConnection() as HttpURLConnection).apply {
156
+ connectTimeout = timeoutMs.toInt()
157
+ readTimeout = timeoutMs.toInt()
158
+ instanceFollowRedirects = true
159
+ setRequestProperty("Cache-Control", "no-cache")
160
+ }
161
+ val status = conn.responseCode
162
+ if (status !in 200..299) throw IOException("HTTP $status for $url")
163
+ // -1 when unknown (also when the body is transparently gunzipped)
164
+ val total = conn.contentLengthLong
165
+ part.parentFile?.mkdirs()
166
+ var received = 0L
167
+ var lastEmit = 0L
168
+ conn.inputStream.use { input ->
169
+ FileOutputStream(part).use { output ->
170
+ val buffer = ByteArray(IO_BUFFER_SIZE)
171
+ while (true) {
172
+ val n = input.read(buffer)
173
+ if (n < 0) break
174
+ output.write(buffer, 0, n)
175
+ received += n
176
+ val now = SystemClock.uptimeMillis()
177
+ if (now - lastEmit >= PROGRESS_INTERVAL_MS) {
178
+ lastEmit = now
179
+ emitProgress(destPath, received, total)
180
+ }
181
+ }
182
+ output.fd.sync()
183
+ }
184
+ }
185
+ if (total >= 0 && received != total) {
186
+ throw IOException("Download truncated: $received of $total bytes")
187
+ }
188
+ emitProgress(destPath, received, total)
189
+ if (!part.renameTo(File(destPath))) throw IOException("Rename to $destPath failed")
190
+ promise.resolve(received.toDouble())
191
+ } catch (e: Exception) {
192
+ part.delete()
193
+ promise.reject("ERR_DOWNLOAD", e.message, e)
194
+ } finally {
195
+ conn?.disconnect()
196
+ }
197
+ }
198
+ }
199
+
200
+ private fun emitProgress(path: String, received: Long, total: Long) {
201
+ emitOnDownloadProgress(Arguments.createMap().apply {
202
+ putString("path", path)
203
+ putDouble("received", received.toDouble())
204
+ putDouble("total", total.toDouble())
205
+ })
206
+ }
207
+
208
+ override fun hashFile(path: String, promise: Promise) = io(promise) {
209
+ val digest = MessageDigest.getInstance("SHA-256")
210
+ val buffer = ByteArray(IO_BUFFER_SIZE)
211
+ FileInputStream(path).use { input ->
212
+ while (true) {
213
+ val n = input.read(buffer)
214
+ if (n < 0) break
215
+ digest.update(buffer, 0, n)
216
+ }
217
+ }
218
+ digest.digest().joinToString("") { "%02x".format(it) }
219
+ }
220
+
221
+ override fun exists(path: String, promise: Promise) = io(promise) { File(path).exists() }
222
+
223
+ override fun mkdir(path: String, promise: Promise) = io(promise) {
224
+ val dir = File(path)
225
+ if (!dir.mkdirs() && !dir.isDirectory) throw IOException("mkdir failed: $path")
226
+ }
227
+
228
+ override fun removePath(path: String, promise: Promise) = io(promise) {
229
+ val file = File(path)
230
+ if (!file.deleteRecursively() && file.exists()) throw IOException("remove failed: $path")
231
+ }
232
+
233
+ override fun moveFile(from: String, to: String, promise: Promise) = io(promise) {
234
+ val src = File(from)
235
+ val dst = File(to)
236
+ // rename() replaces a file atomically; a non-empty directory must go first
237
+ if (!src.renameTo(dst)) {
238
+ dst.deleteRecursively()
239
+ if (!src.renameTo(dst)) throw IOException("move failed: $from → $to")
240
+ }
241
+ }
242
+
243
+ override fun copyFile(from: String, to: String, promise: Promise) = io(promise) {
244
+ File(from).copyTo(File(to), overwrite = true)
245
+ Unit
246
+ }
247
+
248
+ override fun readDir(path: String, promise: Promise) = io(promise) {
249
+ val names = File(path).list() ?: throw IOException("Not a directory: $path")
250
+ Arguments.createArray().apply { names.forEach { pushString(it) } }
251
+ }
252
+
253
+ override fun readFile(path: String, promise: Promise) = io(promise) { File(path).readText() }
254
+
255
+ override fun writeFile(path: String, content: String, promise: Promise) = io(promise) {
256
+ // Write-then-rename: a kill mid-write never leaves a truncated file
257
+ val file = File(path)
258
+ val tmp = File("$path.tmp")
259
+ tmp.writeText(content)
260
+ if (!tmp.renameTo(file)) throw IOException("rename failed: $path")
261
+ }
127
262
  }
@@ -36,6 +36,9 @@ object OtaBundleResolver {
36
36
  private const val DEFAULT_BASE_VERSION = 1
37
37
  private const val MAX_ROLLBACK = 3
38
38
 
39
+ /** Version this process booted (set by resolve), so markAsHealthy verifies only that one */
40
+ @Volatile private var launchedVersion: Int? = null
41
+
39
42
  /**
40
43
  * Resolves the JS bundle path from OTA metadata.
41
44
  * Handles crash detection and rollback entirely on the native side.
@@ -46,6 +49,12 @@ object OtaBundleResolver {
46
49
  */
47
50
  @JvmStatic
48
51
  fun resolve(context: Context, baseVersion: Int = DEFAULT_BASE_VERSION): String? {
52
+ val path = resolveBundle(context, baseVersion)
53
+ launchedVersion = path?.let { File(it).parentFile?.name?.removePrefix("v")?.toIntOrNull() } ?: baseVersion
54
+ return path
55
+ }
56
+
57
+ private fun resolveBundle(context: Context, baseVersion: Int): String? {
49
58
  return try {
50
59
  val otaDir = File(context.filesDir, OTA_DIR_NAME)
51
60
  val metadataFile = File(otaDir, METADATA_FILE)
@@ -97,7 +106,7 @@ object OtaBundleResolver {
97
106
  val rollbackBundle = File(rollbackVDir, "index.android.bundle")
98
107
  if (!rollbackBundle.exists()) {
99
108
  Log.w(TAG, "Rollback target index.android.bundle missing in V$newVersion → factory")
100
- resetToFactory(metadataFile, baseVersion)
109
+ resetToFactory(metadataFile, baseVersion, metadata)
101
110
  return null
102
111
  }
103
112
 
@@ -122,7 +131,7 @@ object OtaBundleResolver {
122
131
  val bundleFile = File(vDir, "index.android.bundle")
123
132
  if (!bundleFile.exists()) {
124
133
  Log.w(TAG, "index.android.bundle missing in V$currentVersion → APK bundle")
125
- resetToFactory(metadataFile, baseVersion)
134
+ resetToFactory(metadataFile, baseVersion, metadata)
126
135
  return null
127
136
  }
128
137
 
@@ -210,12 +219,15 @@ object OtaBundleResolver {
210
219
  /**
211
220
  * Resets metadata to factory defaults.
212
221
  */
213
- private fun resetToFactory(metadataFile: File, baseVersion: Int) {
222
+ private fun resetToFactory(metadataFile: File, baseVersion: Int, old: JSONObject) {
214
223
  val factory = JSONObject().apply {
215
224
  put("currentVersion", baseVersion)
216
225
  put("previousVersions", JSONArray())
217
226
  put("rollbackCount", 0)
218
227
  put("lastLaunchSuccessful", true)
228
+ // Keep: otherwise JS re-downloads a version that crashed on boot
229
+ old.optJSONArray("failedVersions")?.let { put("failedVersions", it) }
230
+ old.optString("appVersion", "").takeIf { it.isNotEmpty() }?.let { put("appVersion", it) }
219
231
  }
220
232
  writeMetadata(metadataFile, factory)
221
233
  }
@@ -228,6 +240,12 @@ object OtaBundleResolver {
228
240
  if (!metadataFile.exists()) return
229
241
 
230
242
  val metadata = JSONObject(metadataFile.readText())
243
+ // Same rule as JS markHealthy: a version installed since boot hasn't run yet, leave it pending
244
+ val launched = launchedVersion
245
+ if (launched != null) {
246
+ if (metadata.optInt("currentVersion", launched) != launched) return
247
+ metadata.put("verifiedVersion", launched)
248
+ }
231
249
  metadata.put("lastLaunchSuccessful", true)
232
250
  metadata.put("rollbackCount", 0)
233
251
 
@@ -13,9 +13,6 @@ set(BZIP2_SOURCES
13
13
  "bzip2/bzlib.c"
14
14
  )
15
15
 
16
- # React Native includes
17
- set(REACT_NATIVE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../../node_modules/react-native)
18
-
19
16
  # Add our custom library
20
17
  add_library(
21
18
  bspatch
@@ -112,8 +112,9 @@ int apply_bspatch(const char *oldfile, const char *newfile, const char *patchfil
112
112
  old = (uint8_t *)mmap(NULL, (size_t)oldsize, PROT_READ, MAP_SHARED, fd_old, 0);
113
113
  if (old != MAP_FAILED) {
114
114
  old_mmapped = 1;
115
- #if defined(MADV_SEQUENTIAL)
116
- madvise(old, (size_t)oldsize, MADV_SEQUENTIAL);
115
+ /* Not SEQUENTIAL: ctrl[2] seeks old backwards, so prefetch it all instead */
116
+ #if defined(MADV_WILLNEED)
117
+ madvise(old, (size_t)oldsize, MADV_WILLNEED);
117
118
  #endif
118
119
  } else {
119
120
  /* Fallback to malloc if mmap fails */
@@ -36,12 +36,21 @@ public class OtaBundleResolver: NSObject {
36
36
  private static let maxRollback = 3
37
37
  private static let maxRollbackIterations = 10 // Safety limit to prevent infinite loops
38
38
 
39
+ /// Version this process booted (set by resolve), so markAsHealthy verifies only that one
40
+ private static var launchedVersion: Int?
41
+
39
42
  /// Resolves the JS bundle path from OTA metadata.
40
43
  /// Handles crash detection and rollback entirely on the native side.
41
44
  ///
42
45
  /// - Parameter baseVersion: The OTA version baked into the IPA (default: 1)
43
46
  /// - Returns: URL to the OTA bundle, or nil to use the IPA default.
44
47
  @objc public static func resolve(baseVersion: Int = defaultBaseVersion) -> URL? {
48
+ let url = resolveBundle(baseVersion: baseVersion)
49
+ launchedVersion = url.flatMap { Int($0.deletingLastPathComponent().lastPathComponent.dropFirst()) } ?? baseVersion
50
+ return url
51
+ }
52
+
53
+ private static func resolveBundle(baseVersion: Int) -> URL? {
45
54
  guard let documentsDir = FileManager.default.urls(
46
55
  for: .documentDirectory,
47
56
  in: .userDomainMask
@@ -102,7 +111,7 @@ public class OtaBundleResolver: NSObject {
102
111
  let rollbackBundle = rollbackVDir.appendingPathComponent("main.jsbundle")
103
112
  if !FileManager.default.fileExists(atPath: rollbackBundle.path) {
104
113
  NSLog("[OtaBundleResolver] Rollback target main.jsbundle missing in V\(newVersion) → factory")
105
- resetToFactory(url: metadataURL, baseVersion: baseVersion)
114
+ resetToFactory(url: metadataURL, baseVersion: baseVersion, old: metadata)
106
115
  return nil
107
116
  }
108
117
 
@@ -127,7 +136,7 @@ public class OtaBundleResolver: NSObject {
127
136
  let bundleURL = vDir.appendingPathComponent("main.jsbundle")
128
137
  if !FileManager.default.fileExists(atPath: bundleURL.path) {
129
138
  NSLog("[OtaBundleResolver] main.jsbundle missing in V\(currentVersion) → IPA bundle")
130
- resetToFactory(url: metadataURL, baseVersion: baseVersion)
139
+ resetToFactory(url: metadataURL, baseVersion: baseVersion, old: metadata)
131
140
  return nil
132
141
  }
133
142
 
@@ -212,13 +221,16 @@ public class OtaBundleResolver: NSObject {
212
221
  ]
213
222
  }
214
223
 
215
- private static func resetToFactory(url: URL, baseVersion: Int) {
216
- let factory: [String: Any] = [
224
+ private static func resetToFactory(url: URL, baseVersion: Int, old: [String: Any]) {
225
+ var factory: [String: Any] = [
217
226
  "currentVersion": baseVersion,
218
227
  "previousVersions": [Int](),
219
228
  "rollbackCount": 0,
220
229
  "lastLaunchSuccessful": true
221
230
  ]
231
+ // Keep: otherwise JS re-downloads a version that crashed on boot
232
+ factory["failedVersions"] = old["failedVersions"]
233
+ factory["appVersion"] = old["appVersion"]
222
234
  writeMetadata(url: url, metadata: factory)
223
235
  }
224
236
 
@@ -243,9 +255,14 @@ public class OtaBundleResolver: NSObject {
243
255
  return
244
256
  }
245
257
 
258
+ // Same rule as JS markHealthy: a version installed since boot hasn't run yet, leave it pending
259
+ if let launched = launchedVersion {
260
+ if (metadata["currentVersion"] as? Int ?? launched) != launched { return }
261
+ metadata["verifiedVersion"] = launched
262
+ }
246
263
  metadata["lastLaunchSuccessful"] = true
247
264
  metadata["rollbackCount"] = 0
248
-
265
+
249
266
  writeMetadata(url: metadataURL, metadata: metadata)
250
267
  NSLog("[OtaBundleResolver] App marked as healthy")
251
268
  } catch {