@otaupdate/react-native 1.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 +321 -0
- package/android/build.gradle +70 -0
- package/android/src/expo/java/com/otaupdate/OtaUpdateExpoPackage.kt +37 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/otaupdate/OtaUpdate.kt +98 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateInstaller.kt +211 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateModule.kt +278 -0
- package/android/src/main/java/com/otaupdate/OtaUpdatePackage.kt +15 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateStore.kt +268 -0
- package/app.plugin.js +3 -0
- package/expo-module.config.json +6 -0
- package/ios/OtaUpdate.h +46 -0
- package/ios/OtaUpdate.m +302 -0
- package/ios/OtaUpdateInstaller.h +25 -0
- package/ios/OtaUpdateInstaller.m +278 -0
- package/ios/OtaUpdateStore.h +69 -0
- package/ios/OtaUpdateStore.m +283 -0
- package/lib/OtaUpdate.d.ts +28 -0
- package/lib/OtaUpdate.d.ts.map +1 -0
- package/lib/OtaUpdate.js +254 -0
- package/lib/OtaUpdate.js.map +1 -0
- package/lib/api.d.ts +36 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +89 -0
- package/lib/api.js.map +1 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +37 -0
- package/lib/index.js.map +1 -0
- package/lib/native.d.ts +34 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +34 -0
- package/lib/native.js.map +1 -0
- package/lib/types.d.ts +112 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +27 -0
- package/lib/types.js.map +1 -0
- package/lib/useOtaUpdate.d.ts +17 -0
- package/lib/useOtaUpdate.d.ts.map +1 -0
- package/lib/useOtaUpdate.js +81 -0
- package/lib/useOtaUpdate.js.map +1 -0
- package/lib/withOtaUpdate.d.ts +10 -0
- package/lib/withOtaUpdate.d.ts.map +1 -0
- package/lib/withOtaUpdate.js +21 -0
- package/lib/withOtaUpdate.js.map +1 -0
- package/package.json +54 -0
- package/plugin/build/index.d.ts +11 -0
- package/plugin/build/index.js +97 -0
- package/react-native-ota-update.podspec +43 -0
- package/react-native.config.js +21 -0
- package/src/OtaUpdate.ts +293 -0
- package/src/api.ts +122 -0
- package/src/index.ts +55 -0
- package/src/native.ts +64 -0
- package/src/types.ts +125 -0
- package/src/useOtaUpdate.ts +96 -0
- package/src/withOtaUpdate.tsx +22 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
package com.otaupdate
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import java.io.File
|
|
6
|
+
import java.util.UUID
|
|
7
|
+
import org.json.JSONArray
|
|
8
|
+
import org.json.JSONObject
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* On-disk state for downloaded JS bundles, and the rollback state machine.
|
|
12
|
+
*
|
|
13
|
+
* Layout under `filesDir/ota`:
|
|
14
|
+
* status.json the state below
|
|
15
|
+
* packages/<sha256>/ one unzipped release each
|
|
16
|
+
*
|
|
17
|
+
* The state machine exists so a bundle that crashes on boot cannot brick the
|
|
18
|
+
* app:
|
|
19
|
+
*
|
|
20
|
+
* install() -> pending = {hash, isLoading = false}
|
|
21
|
+
* next app start -> promote: current = pending.hash, isLoading = true
|
|
22
|
+
* notifyAppReady() -> confirm: lastConfirmed = current, pending = null
|
|
23
|
+
* start while loading -> the previous boot never confirmed: roll back to
|
|
24
|
+
* lastConfirmed and blacklist the bad hash
|
|
25
|
+
*/
|
|
26
|
+
class OtaUpdateStore(private val context: Context) {
|
|
27
|
+
|
|
28
|
+
companion object {
|
|
29
|
+
const val TAG = "OtaUpdate"
|
|
30
|
+
private const val PREFS = "com.otaupdate.prefs"
|
|
31
|
+
private const val CLIENT_ID_KEY = "client_unique_id"
|
|
32
|
+
|
|
33
|
+
@Volatile private var didUpdateThisLaunch: Boolean = false
|
|
34
|
+
|
|
35
|
+
/** True when this launch swapped in a freshly installed bundle. */
|
|
36
|
+
@JvmStatic fun isFirstRunOfUpdate(): Boolean = didUpdateThisLaunch
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private val rootDir: File = File(context.filesDir, "ota")
|
|
40
|
+
private val packagesDir: File = File(rootDir, "packages")
|
|
41
|
+
private val statusFile: File = File(rootDir, "status.json")
|
|
42
|
+
|
|
43
|
+
var currentHash: String? = null
|
|
44
|
+
private set
|
|
45
|
+
var lastConfirmedHash: String? = null
|
|
46
|
+
private set
|
|
47
|
+
var pendingHash: String? = null
|
|
48
|
+
private set
|
|
49
|
+
private var pendingIsLoading: Boolean = false
|
|
50
|
+
private val failedHashes = mutableSetOf<String>()
|
|
51
|
+
private val packages = mutableMapOf<String, JSONObject>()
|
|
52
|
+
|
|
53
|
+
init {
|
|
54
|
+
packagesDir.mkdirs()
|
|
55
|
+
load()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Persistence ----------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@Synchronized
|
|
61
|
+
private fun load() {
|
|
62
|
+
if (!statusFile.exists()) return
|
|
63
|
+
try {
|
|
64
|
+
val json = JSONObject(statusFile.readText())
|
|
65
|
+
currentHash = json.optString("currentHash").ifEmpty { null }
|
|
66
|
+
lastConfirmedHash = json.optString("lastConfirmedHash").ifEmpty { null }
|
|
67
|
+
pendingHash = json.optString("pendingHash").ifEmpty { null }
|
|
68
|
+
pendingIsLoading = json.optBoolean("pendingIsLoading", false)
|
|
69
|
+
|
|
70
|
+
val failed = json.optJSONArray("failedHashes") ?: JSONArray()
|
|
71
|
+
for (i in 0 until failed.length()) failedHashes.add(failed.getString(i))
|
|
72
|
+
|
|
73
|
+
val pkgs = json.optJSONObject("packages") ?: JSONObject()
|
|
74
|
+
for (key in pkgs.keys()) packages[key] = pkgs.getJSONObject(key)
|
|
75
|
+
} catch (e: Exception) {
|
|
76
|
+
// A corrupt status file must not stop the app booting: fall back to the
|
|
77
|
+
// bundle shipped in the APK.
|
|
78
|
+
Log.e(TAG, "status.json unreadable, resetting OTA state", e)
|
|
79
|
+
reset()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Synchronized
|
|
84
|
+
private fun save() {
|
|
85
|
+
try {
|
|
86
|
+
rootDir.mkdirs()
|
|
87
|
+
val json = JSONObject()
|
|
88
|
+
json.put("currentHash", currentHash ?: JSONObject.NULL)
|
|
89
|
+
json.put("lastConfirmedHash", lastConfirmedHash ?: JSONObject.NULL)
|
|
90
|
+
json.put("pendingHash", pendingHash ?: JSONObject.NULL)
|
|
91
|
+
json.put("pendingIsLoading", pendingIsLoading)
|
|
92
|
+
json.put("failedHashes", JSONArray(failedHashes.toList()))
|
|
93
|
+
json.put("packages", JSONObject(packages as Map<*, *>))
|
|
94
|
+
statusFile.writeText(json.toString())
|
|
95
|
+
} catch (e: Exception) {
|
|
96
|
+
Log.e(TAG, "failed to persist OTA state", e)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Rollback state machine ----------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Called once per **process** start, before the JS bundle path is resolved.
|
|
104
|
+
* Returns true if a rollback happened.
|
|
105
|
+
*/
|
|
106
|
+
@Synchronized
|
|
107
|
+
fun initializeAfterRestart(): Boolean {
|
|
108
|
+
val pending = pendingHash ?: return false
|
|
109
|
+
|
|
110
|
+
if (pendingIsLoading) {
|
|
111
|
+
// We already booted into this package and never heard notifyAppReady().
|
|
112
|
+
Log.w(TAG, "update $pending failed to become ready — rolling back")
|
|
113
|
+
failedHashes.add(pending)
|
|
114
|
+
currentHash = lastConfirmedHash
|
|
115
|
+
pendingHash = null
|
|
116
|
+
pendingIsLoading = false
|
|
117
|
+
didUpdateThisLaunch = false
|
|
118
|
+
save()
|
|
119
|
+
deletePackage(pending)
|
|
120
|
+
return true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
promotePendingIfAny()
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Promote a freshly installed package without ever interpreting the call as a
|
|
129
|
+
* failed boot.
|
|
130
|
+
*
|
|
131
|
+
* IMMEDIATE and ON_NEXT_RESUME reload the JS bundle *inside the running
|
|
132
|
+
* process*, so the bundle path is resolved a second time with no new process
|
|
133
|
+
* start. Routing that through `initializeAfterRestart` would see
|
|
134
|
+
* `pendingIsLoading == true` and wrongly roll the update back; skipping it
|
|
135
|
+
* entirely would reload the old bundle. This is the middle ground.
|
|
136
|
+
*/
|
|
137
|
+
@Synchronized
|
|
138
|
+
fun promotePendingIfAny(): Boolean {
|
|
139
|
+
val pending = pendingHash ?: return false
|
|
140
|
+
if (pendingIsLoading) return false
|
|
141
|
+
|
|
142
|
+
Log.i(TAG, "booting into pending update $pending")
|
|
143
|
+
currentHash = pending
|
|
144
|
+
pendingIsLoading = true
|
|
145
|
+
didUpdateThisLaunch = true
|
|
146
|
+
save()
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Confirms the running bundle. After this it can never be rolled back. */
|
|
151
|
+
@Synchronized
|
|
152
|
+
fun notifyApplicationReady() {
|
|
153
|
+
if (pendingHash == null) return
|
|
154
|
+
Log.i(TAG, "update ${pendingHash} confirmed healthy")
|
|
155
|
+
lastConfirmedHash = currentHash
|
|
156
|
+
pendingHash = null
|
|
157
|
+
pendingIsLoading = false
|
|
158
|
+
save()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@Synchronized
|
|
162
|
+
fun markPending(hash: String) {
|
|
163
|
+
pendingHash = hash
|
|
164
|
+
pendingIsLoading = false
|
|
165
|
+
save()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@Synchronized
|
|
169
|
+
fun hasFailed(hash: String): Boolean = failedHashes.contains(hash)
|
|
170
|
+
|
|
171
|
+
@Synchronized
|
|
172
|
+
fun isPending(): Boolean = pendingHash != null
|
|
173
|
+
|
|
174
|
+
// --- Packages -------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
fun packageDir(hash: String): File = File(packagesDir, hash)
|
|
177
|
+
|
|
178
|
+
@Synchronized
|
|
179
|
+
fun recordPackage(
|
|
180
|
+
hash: String,
|
|
181
|
+
label: String,
|
|
182
|
+
bundlePath: String,
|
|
183
|
+
description: String?,
|
|
184
|
+
isMandatory: Boolean,
|
|
185
|
+
size: Long,
|
|
186
|
+
appVersion: String,
|
|
187
|
+
) {
|
|
188
|
+
packages[hash] = JSONObject().apply {
|
|
189
|
+
put("label", label)
|
|
190
|
+
put("bundlePath", bundlePath)
|
|
191
|
+
put("description", description ?: JSONObject.NULL)
|
|
192
|
+
put("isMandatory", isMandatory)
|
|
193
|
+
put("size", size)
|
|
194
|
+
put("appVersion", appVersion)
|
|
195
|
+
put("installedAt", System.currentTimeMillis())
|
|
196
|
+
}
|
|
197
|
+
save()
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@Synchronized
|
|
201
|
+
fun packageInfo(hash: String?): JSONObject? = hash?.let { packages[it] }
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Absolute path of the JS bundle to load, or null to use the one bundled in
|
|
205
|
+
* the APK. Falls back to the binary bundle if the file has vanished.
|
|
206
|
+
*/
|
|
207
|
+
@Synchronized
|
|
208
|
+
fun currentBundlePath(): String? {
|
|
209
|
+
val hash = currentHash ?: return null
|
|
210
|
+
val info = packages[hash] ?: return null
|
|
211
|
+
val path = info.optString("bundlePath")
|
|
212
|
+
if (path.isEmpty() || !File(path).exists()) {
|
|
213
|
+
Log.w(TAG, "bundle for $hash is missing on disk — using the binary bundle")
|
|
214
|
+
currentHash = lastConfirmedHash.takeIf { it != hash }
|
|
215
|
+
save()
|
|
216
|
+
return currentHash?.let { packages[it]?.optString("bundlePath") }?.takeIf { File(it).exists() }
|
|
217
|
+
}
|
|
218
|
+
return path
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
@Synchronized
|
|
222
|
+
fun deletePackage(hash: String) {
|
|
223
|
+
packages.remove(hash)
|
|
224
|
+
packageDir(hash).deleteRecursively()
|
|
225
|
+
save()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Drops everything except the running and last-confirmed packages. */
|
|
229
|
+
@Synchronized
|
|
230
|
+
fun pruneOldPackages() {
|
|
231
|
+
val keep = setOfNotNull(currentHash, lastConfirmedHash, pendingHash)
|
|
232
|
+
for (hash in packages.keys.toList()) {
|
|
233
|
+
if (hash !in keep) {
|
|
234
|
+
packages.remove(hash)
|
|
235
|
+
packageDir(hash).deleteRecursively()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
save()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
@Synchronized
|
|
242
|
+
fun reset() {
|
|
243
|
+
currentHash = null
|
|
244
|
+
lastConfirmedHash = null
|
|
245
|
+
pendingHash = null
|
|
246
|
+
pendingIsLoading = false
|
|
247
|
+
failedHashes.clear()
|
|
248
|
+
packages.clear()
|
|
249
|
+
didUpdateThisLaunch = false
|
|
250
|
+
packagesDir.deleteRecursively()
|
|
251
|
+
packagesDir.mkdirs()
|
|
252
|
+
save()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- Device identity ------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Stable per-install id used for rollout bucketing. Not a hardware id — it
|
|
259
|
+
* is a random UUID that lives and dies with the app's data directory.
|
|
260
|
+
*/
|
|
261
|
+
fun clientUniqueId(): String {
|
|
262
|
+
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
263
|
+
prefs.getString(CLIENT_ID_KEY, null)?.let { return it }
|
|
264
|
+
val id = UUID.randomUUID().toString()
|
|
265
|
+
prefs.edit().putString(CLIENT_ID_KEY, id).apply()
|
|
266
|
+
return id
|
|
267
|
+
}
|
|
268
|
+
}
|
package/app.plugin.js
ADDED
package/ios/OtaUpdate.h
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// OtaUpdate.h
|
|
3
|
+
// react-native-ota-update
|
|
4
|
+
//
|
|
5
|
+
// Public entry point for the host app.
|
|
6
|
+
//
|
|
7
|
+
// Objective-C rather than Swift on purpose: the header below has to be
|
|
8
|
+
// importable from an AppDelegate written in either language, and a plain
|
|
9
|
+
// ObjC module (module_name = OtaUpdate) is importable from both without a
|
|
10
|
+
// bridging header or a generated -Swift.h.
|
|
11
|
+
//
|
|
12
|
+
// AppDelegate.mm:
|
|
13
|
+
// #import <OtaUpdate/OtaUpdate.h>
|
|
14
|
+
// - (NSURL *)bundleURL { return [OtaUpdate bundleURL]; }
|
|
15
|
+
//
|
|
16
|
+
// AppDelegate.swift:
|
|
17
|
+
// import OtaUpdate
|
|
18
|
+
// override func bundleURL() -> URL? { OtaUpdate.bundleURL() }
|
|
19
|
+
//
|
|
20
|
+
|
|
21
|
+
#import <React/RCTBridgeModule.h>
|
|
22
|
+
#import <React/RCTEventEmitter.h>
|
|
23
|
+
|
|
24
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
25
|
+
|
|
26
|
+
@interface OtaUpdate : RCTEventEmitter <RCTBridgeModule>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The JS bundle React Native should load for this launch: the newest healthy
|
|
30
|
+
* downloaded bundle, or the one inside the app binary when there is none.
|
|
31
|
+
*
|
|
32
|
+
* Also advances the rollback state machine, so call it exactly where React
|
|
33
|
+
* Native asks for its bundle URL and nowhere else.
|
|
34
|
+
*/
|
|
35
|
+
+ (nullable NSURL *)bundleURL;
|
|
36
|
+
|
|
37
|
+
/** Same as `bundleURL`, but for a binary bundle with a non-default name. */
|
|
38
|
+
+ (nullable NSURL *)bundleURLForResource:(NSString *)resourceName
|
|
39
|
+
withExtension:(NSString *)extension;
|
|
40
|
+
|
|
41
|
+
/** The bundle inside the app binary, ignoring any downloaded update. */
|
|
42
|
+
+ (nullable NSURL *)binaryBundleURL;
|
|
43
|
+
|
|
44
|
+
@end
|
|
45
|
+
|
|
46
|
+
NS_ASSUME_NONNULL_END
|
package/ios/OtaUpdate.m
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#import "OtaUpdate.h"
|
|
2
|
+
#import "OtaUpdateInstaller.h"
|
|
3
|
+
#import "OtaUpdateStore.h"
|
|
4
|
+
|
|
5
|
+
#import <React/RCTBridge.h>
|
|
6
|
+
#import <React/RCTReloadCommand.h>
|
|
7
|
+
#import <React/RCTUtils.h>
|
|
8
|
+
|
|
9
|
+
static NSString *const kProgressEvent = @"OtaUpdateDownloadProgress";
|
|
10
|
+
static NSString *const kDeploymentKeyPlist = @"OtaDeploymentKey";
|
|
11
|
+
static NSString *const kServerUrlPlist = @"OtaServerUrl";
|
|
12
|
+
|
|
13
|
+
typedef NS_ENUM(NSInteger, OtaInstallMode) {
|
|
14
|
+
OtaInstallModeOnNextRestart = 0,
|
|
15
|
+
OtaInstallModeOnNextResume = 1,
|
|
16
|
+
OtaInstallModeImmediate = 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
@interface OtaUpdate ()
|
|
20
|
+
@property (nonatomic, assign) BOOL hasListeners;
|
|
21
|
+
@property (nonatomic, assign) BOOL restartOnResume;
|
|
22
|
+
@property (nonatomic, assign) NSTimeInterval minimumBackgroundDuration;
|
|
23
|
+
@property (nonatomic, assign) NSTimeInterval backgroundedAt;
|
|
24
|
+
@end
|
|
25
|
+
|
|
26
|
+
@implementation OtaUpdate
|
|
27
|
+
|
|
28
|
+
RCT_EXPORT_MODULE()
|
|
29
|
+
|
|
30
|
+
+ (BOOL)requiresMainQueueSetup {
|
|
31
|
+
return NO;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
- (dispatch_queue_t)methodQueue {
|
|
35
|
+
// Downloads and unzipping must never touch the main queue.
|
|
36
|
+
return dispatch_queue_create("com.otaupdate.module", DISPATCH_QUEUE_SERIAL);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
- (NSArray<NSString *> *)supportedEvents {
|
|
40
|
+
return @[ kProgressEvent ];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
- (void)startObserving {
|
|
44
|
+
self.hasListeners = YES;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
- (void)stopObserving {
|
|
48
|
+
self.hasListeners = NO;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
- (NSDictionary *)constantsToExport {
|
|
52
|
+
return @{
|
|
53
|
+
@"INSTALL_MODE_ON_NEXT_RESTART" : @(OtaInstallModeOnNextRestart),
|
|
54
|
+
@"INSTALL_MODE_ON_NEXT_RESUME" : @(OtaInstallModeOnNextResume),
|
|
55
|
+
@"INSTALL_MODE_IMMEDIATE" : @(OtaInstallModeImmediate),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
- (instancetype)init {
|
|
60
|
+
if (self = [super init]) {
|
|
61
|
+
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
62
|
+
selector:@selector(applicationDidEnterBackground)
|
|
63
|
+
name:UIApplicationDidEnterBackgroundNotification
|
|
64
|
+
object:nil];
|
|
65
|
+
[[NSNotificationCenter defaultCenter] addObserver:self
|
|
66
|
+
selector:@selector(applicationWillEnterForeground)
|
|
67
|
+
name:UIApplicationWillEnterForegroundNotification
|
|
68
|
+
object:nil];
|
|
69
|
+
}
|
|
70
|
+
return self;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
- (void)dealloc {
|
|
74
|
+
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#pragma mark - Bundle resolution (called from AppDelegate)
|
|
78
|
+
|
|
79
|
+
+ (NSURL *)bundleURL {
|
|
80
|
+
return [self bundleURLForResource:@"main" withExtension:@"jsbundle"];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
+ (NSURL *)binaryBundleURL {
|
|
84
|
+
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
+ (NSURL *)bundleURLForResource:(NSString *)resourceName withExtension:(NSString *)extension {
|
|
88
|
+
static dispatch_once_t onceToken;
|
|
89
|
+
__block BOOL firstResolutionInProcess = NO;
|
|
90
|
+
dispatch_once(&onceToken, ^{
|
|
91
|
+
// The rollback decision belongs to a process start, and only that.
|
|
92
|
+
firstResolutionInProcess = YES;
|
|
93
|
+
[[OtaUpdateStore sharedStore] initializeAfterRestart];
|
|
94
|
+
});
|
|
95
|
+
if (!firstResolutionInProcess) {
|
|
96
|
+
// Resolved again without a new process: an in-process reload from an
|
|
97
|
+
// IMMEDIATE or ON_NEXT_RESUME install. Promote the pending package, but do
|
|
98
|
+
// not let it look like a boot that failed to confirm.
|
|
99
|
+
[[OtaUpdateStore sharedStore] promotePendingIfAny];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@try {
|
|
103
|
+
NSString *path = [[OtaUpdateStore sharedStore] currentBundlePath];
|
|
104
|
+
if (path.length > 0) return [NSURL fileURLWithPath:path];
|
|
105
|
+
} @catch (NSException *exception) {
|
|
106
|
+
// Never let OTA state break app start-up.
|
|
107
|
+
NSLog(@"[OtaUpdate] failed to resolve bundle (%@) — using the binary bundle", exception.reason);
|
|
108
|
+
}
|
|
109
|
+
return [[NSBundle mainBundle] URLForResource:resourceName withExtension:extension];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#pragma mark - Configuration
|
|
113
|
+
|
|
114
|
+
RCT_EXPORT_METHOD(getConfiguration
|
|
115
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
116
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
117
|
+
OtaUpdateStore *store = [OtaUpdateStore sharedStore];
|
|
118
|
+
NSDictionary *info = [store packageInfoForHash:store.currentHash];
|
|
119
|
+
NSDictionary *plist = [[NSBundle mainBundle] infoDictionary];
|
|
120
|
+
|
|
121
|
+
resolve(@{
|
|
122
|
+
@"deploymentKey" : plist[kDeploymentKeyPlist] ?: @"",
|
|
123
|
+
@"serverUrl" : plist[kServerUrlPlist] ?: @"",
|
|
124
|
+
@"appVersion" : plist[@"CFBundleShortVersionString"] ?: @"0.0.0",
|
|
125
|
+
@"clientUniqueId" : [store clientUniqueId],
|
|
126
|
+
@"packageHash" : store.currentHash ?: [NSNull null],
|
|
127
|
+
@"label" : info[@"label"] ?: [NSNull null],
|
|
128
|
+
// Read from the native runtime, not settable from JS — this is what makes
|
|
129
|
+
// backend bundle-identifier enforcement meaningful.
|
|
130
|
+
@"bundleIdentifier" : [[NSBundle mainBundle] bundleIdentifier] ?: @"",
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
RCT_EXPORT_METHOD(getCurrentPackage
|
|
135
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
136
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
137
|
+
OtaUpdateStore *store = [OtaUpdateStore sharedStore];
|
|
138
|
+
NSString *hash = store.currentHash;
|
|
139
|
+
NSDictionary *info = [store packageInfoForHash:hash];
|
|
140
|
+
if (!hash || !info) {
|
|
141
|
+
resolve([NSNull null]);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
NSString *description = info[@"description"];
|
|
146
|
+
resolve(@{
|
|
147
|
+
@"packageHash" : hash,
|
|
148
|
+
@"label" : info[@"label"] ?: @"",
|
|
149
|
+
@"description" : [description isKindOfClass:[NSString class]] ? description : [NSNull null],
|
|
150
|
+
@"isMandatory" : info[@"isMandatory"] ?: @NO,
|
|
151
|
+
@"bundlePath" : info[@"bundlePath"] ?: [NSNull null],
|
|
152
|
+
@"size" : info[@"size"] ?: @0,
|
|
153
|
+
@"appVersion" : [[NSBundle mainBundle] infoDictionary][@"CFBundleShortVersionString"] ?: @"",
|
|
154
|
+
@"isPending" : @([store isPending]),
|
|
155
|
+
@"isFirstRun" : @(store.isFirstRunOfUpdate),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
RCT_EXPORT_METHOD(isFirstRun
|
|
160
|
+
: (NSString *)packageHash resolve
|
|
161
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
162
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
163
|
+
OtaUpdateStore *store = [OtaUpdateStore sharedStore];
|
|
164
|
+
resolve(@(store.isFirstRunOfUpdate && [store.currentHash isEqualToString:packageHash]));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#pragma mark - Download
|
|
168
|
+
|
|
169
|
+
RCT_EXPORT_METHOD(downloadUpdate
|
|
170
|
+
: (NSDictionary *)update resolve
|
|
171
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
172
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
173
|
+
NSString *downloadUrl = update[@"downloadUrl"];
|
|
174
|
+
NSString *packageHash = update[@"packageHash"];
|
|
175
|
+
NSString *label = update[@"label"] ?: @"";
|
|
176
|
+
|
|
177
|
+
if (downloadUrl.length == 0 || packageHash.length == 0) {
|
|
178
|
+
reject(@"ota_invalid_update", @"downloadUrl and packageHash are required", nil);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
OtaUpdateStore *store = [OtaUpdateStore sharedStore];
|
|
183
|
+
__weak __typeof(self) weakSelf = self;
|
|
184
|
+
|
|
185
|
+
[OtaUpdateInstaller
|
|
186
|
+
installUpdateWithStore:store
|
|
187
|
+
downloadURL:downloadUrl
|
|
188
|
+
expectedHash:packageHash
|
|
189
|
+
progress:^(long long received, long long total) {
|
|
190
|
+
__strong __typeof(weakSelf) self = weakSelf;
|
|
191
|
+
if (!self || !self.hasListeners) return;
|
|
192
|
+
[self sendEventWithName:kProgressEvent
|
|
193
|
+
body:@{
|
|
194
|
+
@"receivedBytes" : @(received),
|
|
195
|
+
@"totalBytes" : @(total),
|
|
196
|
+
}];
|
|
197
|
+
}
|
|
198
|
+
completion:^(NSString *bundlePath, long long size, NSError *error) {
|
|
199
|
+
if (error || !bundlePath) {
|
|
200
|
+
reject(@"ota_download_failed",
|
|
201
|
+
error.localizedDescription ?: @"Update download failed", error);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
NSString *description = update[@"description"];
|
|
206
|
+
[store recordPackageWithHash:packageHash
|
|
207
|
+
label:label
|
|
208
|
+
bundlePath:bundlePath
|
|
209
|
+
description:[description isKindOfClass:[NSString class]] ? description : nil
|
|
210
|
+
isMandatory:[update[@"isMandatory"] boolValue]
|
|
211
|
+
size:size
|
|
212
|
+
appVersion:[[NSBundle mainBundle] infoDictionary][@"CFBundleShortVersionString"] ?: @""];
|
|
213
|
+
|
|
214
|
+
resolve(@{
|
|
215
|
+
@"bundlePath" : bundlePath,
|
|
216
|
+
@"packageHash" : packageHash,
|
|
217
|
+
@"label" : label,
|
|
218
|
+
@"size" : @(size),
|
|
219
|
+
});
|
|
220
|
+
}];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#pragma mark - Install
|
|
224
|
+
|
|
225
|
+
RCT_EXPORT_METHOD(installUpdate
|
|
226
|
+
: (NSString *)packageHash installMode
|
|
227
|
+
: (nonnull NSNumber *)installMode minimumBackgroundDuration
|
|
228
|
+
: (nonnull NSNumber *)minimumBackgroundDuration resolve
|
|
229
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
230
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
231
|
+
OtaUpdateStore *store = [OtaUpdateStore sharedStore];
|
|
232
|
+
[store markPending:packageHash];
|
|
233
|
+
|
|
234
|
+
switch (installMode.integerValue) {
|
|
235
|
+
case OtaInstallModeImmediate:
|
|
236
|
+
resolve(nil);
|
|
237
|
+
[self reload];
|
|
238
|
+
break;
|
|
239
|
+
case OtaInstallModeOnNextResume:
|
|
240
|
+
self.restartOnResume = YES;
|
|
241
|
+
self.minimumBackgroundDuration = minimumBackgroundDuration.doubleValue;
|
|
242
|
+
resolve(nil);
|
|
243
|
+
break;
|
|
244
|
+
default:
|
|
245
|
+
// ON_NEXT_RESTART: bundleURL picks it up on the next cold start.
|
|
246
|
+
resolve(nil);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
RCT_EXPORT_METHOD(notifyApplicationReady
|
|
252
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
253
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
254
|
+
OtaUpdateStore *store = [OtaUpdateStore sharedStore];
|
|
255
|
+
[store notifyApplicationReady];
|
|
256
|
+
// Reclaim disk from superseded packages now that this one is known good.
|
|
257
|
+
[store pruneOldPackages];
|
|
258
|
+
resolve(nil);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
RCT_EXPORT_METHOD(restartApp
|
|
262
|
+
: (BOOL)onlyIfUpdateIsPending resolve
|
|
263
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
264
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
265
|
+
if (onlyIfUpdateIsPending && ![[OtaUpdateStore sharedStore] isPending]) {
|
|
266
|
+
resolve(@NO);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
resolve(@YES);
|
|
270
|
+
[self reload];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
RCT_EXPORT_METHOD(clearUpdates
|
|
274
|
+
: (RCTPromiseResolveBlock)resolve reject
|
|
275
|
+
: (RCTPromiseRejectBlock)reject) {
|
|
276
|
+
[[OtaUpdateStore sharedStore] reset];
|
|
277
|
+
resolve(nil);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
- (void)reload {
|
|
281
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
282
|
+
// Works under both the bridge and bridgeless runtimes.
|
|
283
|
+
RCTTriggerReloadCommandListeners(@"OTA update applied");
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
#pragma mark - ON_NEXT_RESUME wiring
|
|
288
|
+
|
|
289
|
+
- (void)applicationDidEnterBackground {
|
|
290
|
+
self.backgroundedAt = [[NSDate date] timeIntervalSince1970];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
- (void)applicationWillEnterForeground {
|
|
294
|
+
if (!self.restartOnResume || self.backgroundedAt <= 0) return;
|
|
295
|
+
NSTimeInterval backgroundedFor = [[NSDate date] timeIntervalSince1970] - self.backgroundedAt;
|
|
296
|
+
if (backgroundedFor >= self.minimumBackgroundDuration) {
|
|
297
|
+
self.restartOnResume = NO;
|
|
298
|
+
[self reload];
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
@end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import "OtaUpdateStore.h"
|
|
3
|
+
|
|
4
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
5
|
+
|
|
6
|
+
extern NSString *const OtaUpdateErrorDomain;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Downloads a release zip, verifies its SHA-256 against the value published by
|
|
10
|
+
* the server, then unzips it. Verification happens before anything is
|
|
11
|
+
* unpacked, so a corrupt or tampered payload never becomes a runnable bundle.
|
|
12
|
+
*/
|
|
13
|
+
@interface OtaUpdateInstaller : NSObject
|
|
14
|
+
|
|
15
|
+
+ (void)installUpdateWithStore:(OtaUpdateStore *)store
|
|
16
|
+
downloadURL:(NSString *)downloadURL
|
|
17
|
+
expectedHash:(NSString *)expectedHash
|
|
18
|
+
progress:(void (^)(long long received, long long total))progress
|
|
19
|
+
completion:(void (^)(NSString *_Nullable bundlePath,
|
|
20
|
+
long long size,
|
|
21
|
+
NSError *_Nullable error))completion;
|
|
22
|
+
|
|
23
|
+
@end
|
|
24
|
+
|
|
25
|
+
NS_ASSUME_NONNULL_END
|