@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.
Files changed (57) hide show
  1. package/README.md +321 -0
  2. package/android/build.gradle +70 -0
  3. package/android/src/expo/java/com/otaupdate/OtaUpdateExpoPackage.kt +37 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/otaupdate/OtaUpdate.kt +98 -0
  6. package/android/src/main/java/com/otaupdate/OtaUpdateInstaller.kt +211 -0
  7. package/android/src/main/java/com/otaupdate/OtaUpdateModule.kt +278 -0
  8. package/android/src/main/java/com/otaupdate/OtaUpdatePackage.kt +15 -0
  9. package/android/src/main/java/com/otaupdate/OtaUpdateStore.kt +268 -0
  10. package/app.plugin.js +3 -0
  11. package/expo-module.config.json +6 -0
  12. package/ios/OtaUpdate.h +46 -0
  13. package/ios/OtaUpdate.m +302 -0
  14. package/ios/OtaUpdateInstaller.h +25 -0
  15. package/ios/OtaUpdateInstaller.m +278 -0
  16. package/ios/OtaUpdateStore.h +69 -0
  17. package/ios/OtaUpdateStore.m +283 -0
  18. package/lib/OtaUpdate.d.ts +28 -0
  19. package/lib/OtaUpdate.d.ts.map +1 -0
  20. package/lib/OtaUpdate.js +254 -0
  21. package/lib/OtaUpdate.js.map +1 -0
  22. package/lib/api.d.ts +36 -0
  23. package/lib/api.d.ts.map +1 -0
  24. package/lib/api.js +89 -0
  25. package/lib/api.js.map +1 -0
  26. package/lib/index.d.ts +24 -0
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +37 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/native.d.ts +34 -0
  31. package/lib/native.d.ts.map +1 -0
  32. package/lib/native.js +34 -0
  33. package/lib/native.js.map +1 -0
  34. package/lib/types.d.ts +112 -0
  35. package/lib/types.d.ts.map +1 -0
  36. package/lib/types.js +27 -0
  37. package/lib/types.js.map +1 -0
  38. package/lib/useOtaUpdate.d.ts +17 -0
  39. package/lib/useOtaUpdate.d.ts.map +1 -0
  40. package/lib/useOtaUpdate.js +81 -0
  41. package/lib/useOtaUpdate.js.map +1 -0
  42. package/lib/withOtaUpdate.d.ts +10 -0
  43. package/lib/withOtaUpdate.d.ts.map +1 -0
  44. package/lib/withOtaUpdate.js +21 -0
  45. package/lib/withOtaUpdate.js.map +1 -0
  46. package/package.json +54 -0
  47. package/plugin/build/index.d.ts +11 -0
  48. package/plugin/build/index.js +97 -0
  49. package/react-native-ota-update.podspec +43 -0
  50. package/react-native.config.js +21 -0
  51. package/src/OtaUpdate.ts +293 -0
  52. package/src/api.ts +122 -0
  53. package/src/index.ts +55 -0
  54. package/src/native.ts +64 -0
  55. package/src/types.ts +125 -0
  56. package/src/useOtaUpdate.ts +96 -0
  57. 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
@@ -0,0 +1,3 @@
1
+ // Expo resolves `app.plugin.js` when a project lists this package in
2
+ // app.json's `plugins` array.
3
+ module.exports = require('./plugin/build/index').default;
@@ -0,0 +1,6 @@
1
+ {
2
+ "platforms": ["android"],
3
+ "android": {
4
+ "modules": []
5
+ }
6
+ }
@@ -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
@@ -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