react-native-bspatch 3.1.2 → 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.
- package/README.md +3 -3
- package/android/build.gradle +18 -40
- package/android/src/main/java/vn/reactnativebspatch/BsPatchModule.kt +148 -13
- package/android/src/main/java/vn/reactnativebspatch/OtaBundleResolver.kt +21 -3
- package/cpp/CMakeLists.txt +0 -3
- package/cpp/bsdiff/bspatch_wrapper.c +3 -2
- package/ios/OtaBundleResolver.swift +22 -5
- package/ios/RNBsPatch.mm +220 -32
- package/lib/commonjs/NativeBsPatch.js.map +1 -1
- package/lib/commonjs/index.js +1 -13
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ota/OtaProvider.js +21 -21
- package/lib/commonjs/ota/OtaProvider.js.map +1 -1
- package/lib/commonjs/ota/otaCore.js +50 -88
- package/lib/commonjs/ota/otaCore.js.map +1 -1
- package/lib/commonjs/ota/otaMetadata.js +46 -23
- package/lib/commonjs/ota/otaMetadata.js.map +1 -1
- package/lib/commonjs/ota/storage/GitHubRawStorage.js +22 -12
- package/lib/commonjs/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/module/NativeBsPatch.js.map +1 -1
- package/lib/module/index.js +0 -8
- package/lib/module/index.js.map +1 -1
- package/lib/module/ota/OtaProvider.js +22 -22
- package/lib/module/ota/OtaProvider.js.map +1 -1
- package/lib/module/ota/otaCore.js +50 -87
- package/lib/module/ota/otaCore.js.map +1 -1
- package/lib/module/ota/otaMetadata.js +44 -21
- package/lib/module/ota/otaMetadata.js.map +1 -1
- package/lib/module/ota/storage/GitHubRawStorage.js +20 -10
- package/lib/module/ota/storage/GitHubRawStorage.js.map +1 -1
- package/lib/typescript/NativeBsPatch.d.ts +36 -1
- package/lib/typescript/NativeBsPatch.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +0 -7
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/ota/OtaProvider.d.ts.map +1 -1
- package/lib/typescript/ota/otaCore.d.ts +2 -1
- package/lib/typescript/ota/otaCore.d.ts.map +1 -1
- package/lib/typescript/ota/otaMetadata.d.ts +5 -0
- package/lib/typescript/ota/otaMetadata.d.ts.map +1 -1
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts +2 -0
- package/lib/typescript/ota/storage/GitHubRawStorage.d.ts.map +1 -1
- package/package.json +2 -3
- package/react-native-bspatch.podspec +3 -25
- package/src/NativeBsPatch.ts +44 -1
- package/src/index.ts +0 -7
- package/src/ota/OtaProvider.tsx +37 -23
- package/src/ota/otaCore.ts +55 -94
- package/src/ota/otaMetadata.ts +44 -25
- package/src/ota/storage/GitHubRawStorage.ts +17 -22
- package/tools/build_ota.sh +1 -1
- package/tools/upload_ota.sh +3 -2
- 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.
|
|
29
|
-
-
|
|
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
|
|
36
|
+
npm install react-native-bspatch
|
|
37
37
|
cd ios && pod install
|
|
38
38
|
```
|
|
39
39
|
|
package/android/build.gradle
CHANGED
|
@@ -1,27 +1,8 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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 "-
|
|
46
|
-
abiFilters
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
package/cpp/CMakeLists.txt
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 {
|