react-native-ota-client 0.2.0 → 0.3.1

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/OtaSdk.podspec CHANGED
@@ -14,7 +14,9 @@ Pod::Spec.new do |s|
14
14
  s.source = { :git => "https://github.com/arjunnarangali/react-native-ota-client.git", :tag => "v#{s.version}" }
15
15
 
16
16
  s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
17
+ s.exclude_files = "ios/Tests/**/*"
17
18
  s.private_header_files = "ios/**/*.h"
18
19
 
20
+ s.dependency "ZIPFoundation", "~> 0.9"
19
21
  install_modules_dependencies(s)
20
22
  end
package/README.md CHANGED
@@ -1,14 +1,28 @@
1
1
  # react-native-ota-client
2
2
 
3
- Android OTA update support for React Native applications using the New Architecture and Hermes. The SDK checks for signed releases, verifies the release manifest and packaged bundle, stages verified updates, selects the bundle on cold start, and rolls back a release that does not confirm healthy.
3
+ [![npm version](https://img.shields.io/npm/v/react-native-ota-client.svg)](https://www.npmjs.com/package/react-native-ota-client)
4
+ [![Documentation](https://img.shields.io/badge/docs-docs.ota.arjunis.me-blue)](https://docs.ota.arjunis.me)
4
5
 
5
- OTA can update JavaScript and bundled assets. Native code, permissions, React Native/Hermes versions, and Codegen contract changes require a new APK.
6
+ Android and iOS OTA update support for React Native applications using the New Architecture and Hermes. The SDK checks for signed releases, verifies the release manifest and packaged bundle, stages verified updates, selects the bundle on cold start, and rolls back a release that does not confirm healthy.
7
+
8
+ OTA can update JavaScript and bundled assets. Native code, permissions/entitlements, React Native/Hermes versions, and Codegen contract changes require a new APK/IPA.
9
+
10
+ ## Documentation
11
+
12
+ Full setup and API guides are at [docs.ota.arjunis.me](https://docs.ota.arjunis.me).
13
+
14
+ - [Installation](https://docs.ota.arjunis.me/guide/installation)
15
+ - [JavaScript API](https://docs.ota.arjunis.me/guide/javascript-api)
16
+ - [Android setup](https://docs.ota.arjunis.me/guide/android)
17
+ - [iOS setup](https://docs.ota.arjunis.me/guide/ios)
18
+ - [Server contract](https://docs.ota.arjunis.me/server)
6
19
 
7
20
  ## Requirements and support
8
21
 
9
- - Android only; iOS OTA support is not implemented.
22
+ - Android and iOS.
10
23
  - React Native 0.88.x, Android New Architecture, and Hermes.
11
24
  - Android min SDK 24.
25
+ - iOS New Architecture and Hermes; use the host app's iOS deployment target supported by its React Native version.
12
26
  - A host application with a release-signing setup and a public verification key.
13
27
 
14
28
  ## Installation
@@ -17,7 +31,7 @@ OTA can update JavaScript and bundled assets. Native code, permissions, React Na
17
31
  npm install react-native-ota-client
18
32
  ```
19
33
 
20
- React Native autolinking discovers the Android package. Rebuild the native application after installation; Metro alone cannot add native code.
34
+ React Native autolinking discovers the native package on Android and iOS. Rebuild the native application after installation; Metro alone cannot add native code.
21
35
 
22
36
  For local development, the example workspace consumes the SDK source directly. In a host app, install the package using the command above.
23
37
 
@@ -34,6 +48,8 @@ await OTAUpdates.closeAppToApplyUpdate();
34
48
 
35
49
  `markUpdateSuccessful()` should run after the first React screen has mounted. `closeAppToApplyUpdate()` only succeeds when a verified update is staged and waiting for a restart. Debug applications report OTA activation as disabled because Metro supplies the JavaScript bundle.
36
50
 
51
+ On iOS, apps cannot terminate themselves. When `closeAppToApplyUpdate()` returns `true`, ask the user to leave and reopen the app to load the staged bundle.
52
+
37
53
  The package also retains the generated-template `multiply()` example; it is not part of the OTA API.
38
54
 
39
55
  ## Android configuration
@@ -125,32 +141,44 @@ Use HTTPS for staging and production, with `allowInsecureHttp = false`. Local de
125
141
 
126
142
  `OtaSdkUpdates` exposes the Android startup and host integration API:
127
143
 
128
- - `initialize(context, config)` — configure the manager once before use.
129
- - `beginProcess()` — select/rollback the startup bundle before React Native starts.
130
- - `bundlePathForReactHost()` — return the selected bundle path, or `null` for the APK bundle.
131
- - `status()`, `checkForUpdate()`, `markHealthy()`, and `closeAppForUpdate(activity)` — operations used by the TurboModule implementation.
144
+ - `initialize(context, config)` configures the manager once before use.
145
+ - `beginProcess()` selects or rolls back the startup bundle before React Native starts.
146
+ - `bundlePathForReactHost()` returns the selected bundle path, or `null` for the APK bundle.
147
+ - `status()`, `checkForUpdate()`, `markHealthy()`, and `closeAppForUpdate(activity)` are the operations used by the TurboModule implementation.
132
148
 
133
149
  The SDK autolinks a single `OtaSdkPackage` containing both the sample `OtaSdk` TurboModule and the OTA `OTAUpdates` TurboModule. Do not add another manual package registration in the host app.
134
150
 
135
- ## Development and verification
136
-
137
- For implementation architecture, behavioral invariants, suggested change workflow, and verification commands, see the [coding-agent implementation guide](agent.md).
138
-
139
- From the SDK package directory, install the locked Yarn dependencies with the bundled Yarn executable and build its example:
140
-
141
- ```sh
142
- node .yarn/releases/yarn-4.18.1.cjs install
143
- node .yarn/releases/yarn-4.18.1.cjs workspace react-native-ota-client-example build:android
144
- ```
145
-
146
- The library unit tests run through the example's Gradle project:
147
-
148
- ```sh
149
- cd example/android
150
- ./gradlew :react-native-ota-client:testDebugUnitTest
151
+ ## iOS configuration
152
+
153
+ Configure the SDK in the host app before creating the React Native factory/runtime. `beginProcess()` must run before startup bundle selection. In `RCTDefaultReactNativeFactoryDelegate.bundleURL()`, retain Metro in Debug and use the OTA URL before the embedded bundle in Release:
154
+
155
+ ```swift
156
+ import OtaSdk
157
+
158
+ // In AppDelegate, before the SceneDelegate starts React Native:
159
+ OtaSdkUpdates.initialize(
160
+ serverUrl: serverURL,
161
+ runtimeVersion: runtimeVersion,
162
+ channel: channel,
163
+ baseVersion: NSNumber(value: baseVersion),
164
+ publicKeyPem: publicKeyPEM,
165
+ isDebugBuild: isDebugBuild,
166
+ appId: appId,
167
+ allowLocalHttp: allowLocalHTTP
168
+ )
169
+ OtaSdkUpdates.beginProcess()
170
+
171
+ // In the React Native factory delegate:
172
+ override func bundleURL() -> URL? {
173
+ #if DEBUG
174
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
175
+ #else
176
+ OtaSdkUpdates.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
177
+ #endif
178
+ }
151
179
  ```
152
180
 
153
- Build a development debug app to verify native linking and Metro. OTA activation requires a release APK; debug builds use Metro and report activation as disabled.
181
+ Provide the configuration from host-owned build settings and bundle only the public verification key. Use HTTPS for release builds. `allowLocalHttp` is for explicit local development only and is restricted to `localhost`, `127.0.0.1`, or `::1`; OTA downloads must match the configured server origin. iOS returns whether an update is staged from `closeAppToApplyUpdate()` and requires the user to relaunch. Call `markUpdateSuccessful()` after the first usable React screen mounts.
154
182
 
155
183
  For the companion OTA server API and release contract, see the [`react-native-ota-server` GitHub repository](https://github.com/arjunnarangali/react-native-ota-server).
156
184
 
@@ -0,0 +1,51 @@
1
+ #import <OtaSdkSpec/OtaSdkSpec.h>
2
+ #import "OtaSdk-Swift.h"
3
+
4
+ @interface OTAUpdates : NSObject <NativeOTAUpdatesSpec>
5
+ @end
6
+
7
+ @implementation OTAUpdates
8
+
9
+ RCT_EXPORT_MODULE(OTAUpdates)
10
+
11
+ RCT_EXPORT_METHOD(getStatus:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
12
+ {
13
+ resolve([OtaSdkUpdates status]);
14
+ }
15
+
16
+ RCT_EXPORT_METHOD(checkForUpdate:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
17
+ {
18
+ [OtaSdkUpdates checkForUpdateWithCompletion:^(NSDictionary * _Nullable status, NSError * _Nullable error) {
19
+ if (error != nil) {
20
+ NSMutableDictionary *failure = [[OtaSdkUpdates status] mutableCopy];
21
+ failure[@"status"] = @"error";
22
+ failure[@"message"] = error.localizedDescription;
23
+ failure[@"updateAvailable"] = @NO;
24
+ resolve(failure);
25
+ } else {
26
+ resolve(status ?: [OtaSdkUpdates status]);
27
+ }
28
+ }];
29
+ }
30
+
31
+ RCT_EXPORT_METHOD(markUpdateSuccessful:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
32
+ {
33
+ [OtaSdkUpdates markUpdateSuccessfulWithCompletion:^(NSDictionary * _Nullable status, NSError * _Nullable error) {
34
+ if (error != nil) reject(@"OTA_CONFIRM_FAILED", error.localizedDescription, error);
35
+ else resolve(status ?: [OtaSdkUpdates status]);
36
+ }];
37
+ }
38
+
39
+ RCT_EXPORT_METHOD(closeAppToApplyUpdate:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
40
+ {
41
+ // iOS cannot terminate itself. Resolve whether a verified release is waiting for a user relaunch.
42
+ resolve(@([OtaSdkUpdates hasPendingUpdate]));
43
+ }
44
+
45
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
46
+ (const facebook::react::ObjCTurboModule::InitParams &)params
47
+ {
48
+ return std::make_shared<facebook::react::NativeOTAUpdatesSpecJSI>(params);
49
+ }
50
+
51
+ @end
@@ -0,0 +1,78 @@
1
+ import Foundation
2
+
3
+ struct OtaActivationState: Equatable {
4
+ var activeReleaseId: String?
5
+ var activeVersion: Int
6
+ var pendingReleaseId: String?
7
+ var pendingVersion: Int?
8
+ var previousReleaseId: String?
9
+ var previousVersion: Int?
10
+ var pendingLaunchAttempted: Bool
11
+ var failedReleaseId: String?
12
+
13
+ init(activeReleaseId: String? = nil, activeVersion: Int, pendingReleaseId: String? = nil,
14
+ pendingVersion: Int? = nil, previousReleaseId: String? = nil, previousVersion: Int? = nil,
15
+ pendingLaunchAttempted: Bool = false, failedReleaseId: String? = nil) {
16
+ self.activeReleaseId = activeReleaseId
17
+ self.activeVersion = activeVersion
18
+ self.pendingReleaseId = pendingReleaseId
19
+ self.pendingVersion = pendingVersion
20
+ self.previousReleaseId = previousReleaseId
21
+ self.previousVersion = previousVersion
22
+ self.pendingLaunchAttempted = pendingLaunchAttempted
23
+ self.failedReleaseId = failedReleaseId
24
+ }
25
+ }
26
+
27
+ struct OtaStartupTransition {
28
+ let state: OtaActivationState
29
+ let selectedReleaseId: String?
30
+ let failedReleaseId: String?
31
+ }
32
+
33
+ enum OtaActivationPolicy {
34
+ static func beginStartup(_ state: OtaActivationState) -> OtaStartupTransition {
35
+ guard let pending = state.pendingReleaseId else {
36
+ return OtaStartupTransition(state: state, selectedReleaseId: state.activeReleaseId, failedReleaseId: nil)
37
+ }
38
+ guard state.pendingLaunchAttempted else {
39
+ var attempted = state
40
+ attempted.pendingLaunchAttempted = true
41
+ return OtaStartupTransition(state: attempted, selectedReleaseId: pending, failedReleaseId: nil)
42
+ }
43
+ var rolledBack = state
44
+ rolledBack.activeReleaseId = state.previousReleaseId
45
+ rolledBack.activeVersion = state.previousVersion ?? state.activeVersion
46
+ rolledBack.pendingReleaseId = nil
47
+ rolledBack.pendingVersion = nil
48
+ rolledBack.previousReleaseId = nil
49
+ rolledBack.previousVersion = nil
50
+ rolledBack.pendingLaunchAttempted = false
51
+ rolledBack.failedReleaseId = pending
52
+ return OtaStartupTransition(state: rolledBack, selectedReleaseId: rolledBack.activeReleaseId, failedReleaseId: pending)
53
+ }
54
+
55
+ static func stage(_ state: OtaActivationState, releaseId: String, version: Int) -> OtaActivationState {
56
+ var staged = state
57
+ staged.pendingReleaseId = releaseId
58
+ staged.pendingVersion = version
59
+ staged.previousReleaseId = state.activeReleaseId
60
+ staged.previousVersion = state.activeVersion
61
+ staged.pendingLaunchAttempted = false
62
+ return staged
63
+ }
64
+
65
+ static func markHealthy(_ state: OtaActivationState) -> OtaActivationState {
66
+ guard let pending = state.pendingReleaseId, state.pendingLaunchAttempted else { return state }
67
+ var healthy = state
68
+ healthy.activeReleaseId = pending
69
+ healthy.activeVersion = state.pendingVersion ?? state.activeVersion
70
+ healthy.pendingReleaseId = nil
71
+ healthy.pendingVersion = nil
72
+ healthy.previousReleaseId = nil
73
+ healthy.previousVersion = nil
74
+ healthy.pendingLaunchAttempted = false
75
+ healthy.failedReleaseId = nil
76
+ return healthy
77
+ }
78
+ }
@@ -0,0 +1,594 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import Security
4
+ import ZIPFoundation
5
+
6
+ private struct OtaConfiguration {
7
+ let serverURL: URL
8
+ let runtimeVersion: String
9
+ let channel: String
10
+ let baseVersion: Int
11
+ let publicKeyPEM: Data
12
+ let isDebugBuild: Bool
13
+ let appId: String
14
+ let allowLocalHTTP: Bool
15
+ }
16
+
17
+ private struct OtaRelease {
18
+ let id: String
19
+ let manifest: [String: Any]
20
+ let signature: String
21
+ let version: Int
22
+ let action: String
23
+ let downloadURL: URL
24
+ let artifactHash: String
25
+ let artifactSize: Int
26
+ }
27
+
28
+ /** Public iOS host-app API. Call initialize() and beginProcess() before React Native starts. */
29
+ @objc(OtaSdkUpdates)
30
+ public final class OtaSdkUpdates: NSObject {
31
+ private static let shared = OtaSdkUpdates()
32
+ private let defaults = UserDefaults.standard
33
+ private let lock = NSRecursiveLock()
34
+ private var configuration: OtaConfiguration?
35
+ private var selectedBundleURL: URL?
36
+ private var checkInProgress = false
37
+ private let rootDirectory: URL = {
38
+ FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
39
+ .appendingPathComponent("OtaSdk", isDirectory: true)
40
+ }()
41
+
42
+ private enum Key {
43
+ static let runtime = "ota.runtime"
44
+ static let installationId = "ota.installationId"
45
+ static let activeRelease = "ota.activeRelease"
46
+ static let activeVersion = "ota.activeVersion"
47
+ static let pendingRelease = "ota.pendingRelease"
48
+ static let pendingVersion = "ota.pendingVersion"
49
+ static let previousRelease = "ota.previousRelease"
50
+ static let previousVersion = "ota.previousVersion"
51
+ static let pendingAttempted = "ota.pendingLaunchAttempted"
52
+ static let failedRelease = "ota.failedRelease"
53
+ }
54
+
55
+ @objc(initializeWithServerUrl:runtimeVersion:channel:baseVersion:publicKeyPem:isDebugBuild:appId:allowLocalHttp:)
56
+ public static func initialize(
57
+ serverUrl: String,
58
+ runtimeVersion: String,
59
+ channel: String,
60
+ baseVersion: NSNumber,
61
+ publicKeyPem: String,
62
+ isDebugBuild: Bool,
63
+ appId: String,
64
+ allowLocalHttp: Bool
65
+ ) {
66
+ shared.configure(
67
+ serverUrl: serverUrl,
68
+ runtimeVersion: runtimeVersion,
69
+ channel: channel,
70
+ baseVersion: baseVersion.intValue,
71
+ publicKeyPEM: Data(publicKeyPem.utf8),
72
+ isDebugBuild: isDebugBuild,
73
+ appId: appId,
74
+ allowLocalHTTP: allowLocalHttp
75
+ )
76
+ }
77
+
78
+ @objc public static func beginProcess() { shared.beginProcess() }
79
+
80
+ @objc public static func bundleURL() -> URL? {
81
+ shared.lock.lock()
82
+ defer { shared.lock.unlock() }
83
+ return shared.selectedBundleURL
84
+ }
85
+
86
+ @objc public static func status() -> NSDictionary { shared.statusDictionary() as NSDictionary }
87
+ @objc public static func hasPendingUpdate() -> Bool {
88
+ shared.lock.lock()
89
+ defer { shared.lock.unlock() }
90
+ return shared.defaults.string(forKey: Key.pendingRelease) != nil
91
+ }
92
+
93
+ @objc(checkForUpdateWithCompletion:)
94
+ public static func checkForUpdate(completion: @escaping (NSDictionary?, NSError?) -> Void) {
95
+ Task {
96
+ do { completion(try await shared.checkForUpdate() as NSDictionary, nil) }
97
+ catch { completion(nil, error as NSError) }
98
+ }
99
+ }
100
+
101
+ @objc(markUpdateSuccessfulWithCompletion:)
102
+ public static func markUpdateSuccessful(completion: @escaping (NSDictionary?, NSError?) -> Void) {
103
+ do { completion(try shared.markHealthy() as NSDictionary, nil) }
104
+ catch { completion(nil, error as NSError) }
105
+ }
106
+
107
+ private func configure(
108
+ serverUrl: String, runtimeVersion: String, channel: String, baseVersion: Int,
109
+ publicKeyPEM: Data, isDebugBuild: Bool, appId: String, allowLocalHTTP: Bool
110
+ ) {
111
+ lock.lock()
112
+ defer { lock.unlock() }
113
+ guard let url = URL(string: serverUrl), url.host != nil, url.user == nil, url.password == nil,
114
+ ["https", "http"].contains(url.scheme?.lowercased() ?? ""),
115
+ !runtimeVersion.isEmpty, !channel.isEmpty, !appId.isEmpty, !publicKeyPEM.isEmpty,
116
+ baseVersion >= 0 else {
117
+ NSLog("[OtaSdkUpdates] Invalid iOS OTA configuration")
118
+ configuration = nil
119
+ return
120
+ }
121
+ configuration = OtaConfiguration(
122
+ serverURL: url, runtimeVersion: runtimeVersion, channel: channel, baseVersion: baseVersion,
123
+ publicKeyPEM: publicKeyPEM, isDebugBuild: isDebugBuild, appId: appId,
124
+ allowLocalHTTP: allowLocalHTTP
125
+ )
126
+ }
127
+
128
+ private func beginProcess() {
129
+ lock.lock()
130
+ defer { lock.unlock() }
131
+ guard let configuration else { return }
132
+ selectedBundleURL = nil
133
+ guard !configuration.isDebugBuild else { return }
134
+ if let runtime = defaults.string(forKey: Key.runtime), runtime != configuration.runtimeVersion {
135
+ defaults.set(configuration.runtimeVersion, forKey: Key.runtime)
136
+ clearActivationState()
137
+ try? FileManager.default.removeItem(at: updatesDirectory)
138
+ } else {
139
+ defaults.set(configuration.runtimeVersion, forKey: Key.runtime)
140
+ }
141
+ try? FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true)
142
+ try? FileManager.default.removeItem(at: stagingDirectory)
143
+
144
+ let startup = OtaActivationPolicy.beginStartup(readActivationState(baseVersion: configuration.baseVersion))
145
+ persistActivationState(startup.state)
146
+ if let releaseId = startup.selectedReleaseId,
147
+ let candidate = verifiedBundleURL(for: releaseId, configuration: configuration) {
148
+ selectedBundleURL = candidate
149
+ } else if let releaseId = startup.selectedReleaseId {
150
+ if startup.state.pendingReleaseId == releaseId {
151
+ let rollback = OtaActivationPolicy.beginStartup(startup.state)
152
+ persistActivationState(rollback.state)
153
+ selectedBundleURL = rollback.selectedReleaseId.flatMap { verifiedBundleURL(for: $0, configuration: configuration) }
154
+ } else {
155
+ var fallback = startup.state
156
+ fallback.activeReleaseId = nil
157
+ fallback.activeVersion = configuration.baseVersion
158
+ persistActivationState(fallback)
159
+ }
160
+ }
161
+ }
162
+
163
+ private var updatesDirectory: URL { rootDirectory.appendingPathComponent("updates", isDirectory: true) }
164
+ private var stagingDirectory: URL { rootDirectory.appendingPathComponent("staging", isDirectory: true) }
165
+
166
+ private func restorePreviousBundle(configuration: OtaConfiguration) {
167
+ let previous = defaults.string(forKey: Key.previousRelease)
168
+ let previousURL = previous.flatMap { verifiedBundleURL(for: $0, configuration: configuration) }
169
+ defaults.set(previousURL == nil ? nil : previous, forKey: Key.activeRelease)
170
+ defaults.set(previousURL == nil ? configuration.baseVersion : (defaults.object(forKey: Key.previousVersion) as? Int ?? configuration.baseVersion), forKey: Key.activeVersion)
171
+ clearPendingState()
172
+ selectedBundleURL = previousURL
173
+ }
174
+
175
+ private func checkForUpdate() async throws -> [String: Any] {
176
+ let configuration: OtaConfiguration
177
+ switch try beginCheck() {
178
+ case .finished(let result): return result
179
+ case .proceed(let configured): configuration = configured
180
+ }
181
+ defer { finishCheck() }
182
+
183
+ var request = URLRequest(url: configuration.serverURL.appendingPathComponent("api/v1/updates/check"))
184
+ request.httpMethod = "POST"
185
+ request.timeoutInterval = 20
186
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
187
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
188
+ let installationId = defaults.string(forKey: Key.installationId) ?? UUID().uuidString.lowercased()
189
+ defaults.set(installationId, forKey: Key.installationId)
190
+ request.httpBody = try JSONSerialization.data(withJSONObject: [
191
+ "platform": "ios", "app_id": configuration.appId,
192
+ "runtime_version": configuration.runtimeVersion, "channel": configuration.channel,
193
+ "current_version": defaults.object(forKey: Key.activeVersion) as? Int ?? configuration.baseVersion,
194
+ "installation_id": installationId,
195
+ "failed_release_id": defaults.string(forKey: Key.failedRelease) as Any? ?? NSNull(),
196
+ ], options: [.sortedKeys])
197
+
198
+ let session = URLSession(configuration: .ephemeral, delegate: OtaRedirectBlocker(), delegateQueue: nil)
199
+ defer { session.invalidateAndCancel() }
200
+ let (data, response) = try await session.data(for: request)
201
+ guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
202
+ throw otaError("OTA server returned an unsuccessful response")
203
+ }
204
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
205
+ let available = json["update_available"] as? Bool else { throw otaError("OTA server response is malformed") }
206
+ guard available else { return result(status: "up_to_date", message: "No compatible update is available.") }
207
+ guard let releaseJSON = json["release"] as? [String: Any] else { throw otaError("OTA release response is missing metadata") }
208
+ let release = try verifyRelease(releaseJSON, configuration: configuration,
209
+ currentVersion: defaults.object(forKey: Key.activeVersion) as? Int ?? configuration.baseVersion)
210
+ let archive = try await download(release, configuration: configuration)
211
+ defer { try? FileManager.default.removeItem(at: archive) }
212
+ try install(archive: archive, release: release, configuration: configuration)
213
+ return stage(release, baseVersion: configuration.baseVersion)
214
+ }
215
+
216
+ private func markHealthy() throws -> [String: Any] {
217
+ lock.lock()
218
+ defer { lock.unlock() }
219
+ guard let configuration else { throw otaError("OTA SDK has not been initialized") }
220
+ if configuration.isDebugBuild { return statusDictionary() }
221
+ persistActivationState(OtaActivationPolicy.markHealthy(readActivationState(baseVersion: configuration.baseVersion)))
222
+ return statusDictionary()
223
+ }
224
+
225
+ private func stage(_ release: OtaRelease, baseVersion: Int) -> [String: Any] {
226
+ lock.lock()
227
+ defer { lock.unlock() }
228
+ persistActivationState(OtaActivationPolicy.stage(readActivationState(baseVersion: baseVersion), releaseId: release.id, version: release.version))
229
+ return result(status: "restart_required", message: "Verified release \(release.version) is ready. Close and reopen the app to apply it.", pendingVersion: release.version, action: release.action)
230
+ }
231
+
232
+ private func statusDictionary() -> [String: Any] {
233
+ lock.lock()
234
+ defer { lock.unlock() }
235
+ guard let configuration else {
236
+ return [
237
+ "enabled": false, "status": "not_initialized", "message": "OTA SDK has not been initialized.",
238
+ "runtimeVersion": "", "channel": "", "currentVersion": 0,
239
+ "pendingVersion": NSNull(), "pendingRestart": false, "updateAvailable": false, "action": NSNull(),
240
+ ]
241
+ }
242
+ let debug = configuration.isDebugBuild
243
+ let pending = defaults.string(forKey: Key.pendingRelease) != nil
244
+ return [
245
+ "enabled": !debug, "status": debug ? "debug_build" : (pending ? "restart_required" : "ready"),
246
+ "message": debug ? "OTA activation is disabled in debug builds; Metro supplies JavaScript." : (pending ? "A verified update is ready. Close and reopen the app to apply it." : "This app is using its active bundle."),
247
+ "runtimeVersion": configuration.runtimeVersion, "channel": configuration.channel,
248
+ "currentVersion": defaults.object(forKey: Key.activeVersion) as? Int ?? configuration.baseVersion,
249
+ "pendingVersion": defaults.object(forKey: Key.pendingVersion) ?? NSNull(),
250
+ "pendingRestart": pending, "updateAvailable": pending, "action": NSNull(),
251
+ ]
252
+ }
253
+
254
+ private func result(status: String, message: String, pendingVersion: Int? = nil, action: String? = nil) -> [String: Any] {
255
+ var value = statusDictionary()
256
+ value["status"] = status
257
+ value["message"] = message
258
+ value["updateAvailable"] = status == "restart_required"
259
+ value["action"] = action as Any? ?? NSNull()
260
+ if let pendingVersion { value["pendingVersion"] = pendingVersion }
261
+ return value
262
+ }
263
+
264
+ private func verifyRelease(_ json: [String: Any], configuration: OtaConfiguration, currentVersion: Int) throws -> OtaRelease {
265
+ guard let manifest = json["manifest"] as? [String: Any], let signature = json["signature"] as? String,
266
+ let action = json["action"] as? String, let channel = json["channel"] as? String,
267
+ let downloadString = json["download_url"] as? String, let downloadURL = URL(string: downloadString),
268
+ let id = manifest["release_id"] as? String, let platform = manifest["platform"] as? String,
269
+ let appId = manifest["app_id"] as? String, let runtime = manifest["runtime_version"] as? String,
270
+ let version = manifest["version"] as? Int, let artifactHash = manifest["artifact_sha256"] as? String,
271
+ let artifactSize = manifest["artifact_size_bytes"] as? Int, let createdAt = manifest["created_at"] as? String,
272
+ let manifestVersion = manifest["manifest_version"] as? Int,
273
+ let rollout = json["rollout_percentage"] as? Int else { throw otaError("Release response is malformed") }
274
+ guard UUID(uuidString: id) != nil, manifestVersion == 1, platform == "ios", appId == configuration.appId,
275
+ runtime == configuration.runtimeVersion,
276
+ appId.range(of: "^[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+$", options: .regularExpression) != nil,
277
+ runtime.range(of: "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$", options: .regularExpression) != nil,
278
+ version > 0, artifactHash.range(of: "^[a-f0-9]{64}$", options: .regularExpression) != nil,
279
+ (1...(100 * 1024 * 1024)).contains(artifactSize), !createdAt.isEmpty, createdAt.count <= 64 else {
280
+ throw otaError("Release manifest does not match this iOS runtime")
281
+ }
282
+ guard (action == "update" && version > currentVersion) || (action == "rollback" && version < currentVersion) else {
283
+ throw otaError("Release version is incompatible with the current app version")
284
+ }
285
+ guard channel == configuration.channel, (0...100).contains(rollout), sameOrigin(downloadURL, configuration.serverURL),
286
+ supportsDownload(downloadURL, configuration: configuration) else { throw otaError("Bundle URL origin or channel is invalid") }
287
+ guard verifySignature(canonicalManifest(manifest), signature: signature, pem: configuration.publicKeyPEM) else {
288
+ throw otaError("Release manifest signature is invalid")
289
+ }
290
+ return OtaRelease(id: id, manifest: manifest, signature: signature, version: version, action: action,
291
+ downloadURL: downloadURL, artifactHash: artifactHash, artifactSize: artifactSize)
292
+ }
293
+
294
+ private func canonicalManifest(_ manifest: [String: Any]) -> Data {
295
+ let keys = ["app_id", "artifact_sha256", "artifact_size_bytes", "created_at", "manifest_version", "platform", "release_id", "runtime_version", "version"]
296
+ let values = keys.map { key -> String in
297
+ let value = manifest[key]!
298
+ let encoded: String
299
+ if let string = value as? String,
300
+ let data = try? JSONSerialization.data(withJSONObject: [string], options: [.fragmentsAllowed, .sortedKeys]),
301
+ let json = String(data: data, encoding: .utf8) {
302
+ encoded = String(json.dropFirst().dropLast())
303
+ } else { encoded = String(describing: value) }
304
+ return "\"\(key)\":\(encoded)"
305
+ }
306
+ return Data(("{" + values.joined(separator: ",") + "}").utf8)
307
+ }
308
+
309
+ private func verifySignature(_ message: Data, signature: String, pem: Data) -> Bool {
310
+ guard let keyData = sec1PublicKey(from: pem),
311
+ let key = SecKeyCreateWithData(keyData as CFData, [kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
312
+ kSecAttrKeyClass as String: kSecAttrKeyClassPublic, kSecAttrKeySizeInBits as String: 256] as CFDictionary, nil),
313
+ let signatureData = Data(base64Encoded: signature) else { return false }
314
+ return SecKeyVerifySignature(key, .ecdsaSignatureMessageX962SHA256, message as CFData, signatureData as CFData, nil)
315
+ }
316
+
317
+ private func sec1PublicKey(from pem: Data) -> Data? {
318
+ let text = String(decoding: pem, as: UTF8.self)
319
+ guard let der = Data(base64Encoded: text.components(separatedBy: .newlines).filter { !$0.hasPrefix("---") }.joined()) else { return nil }
320
+ let marker = Data([0x03, 0x42, 0x00, 0x04])
321
+ guard let range = der.firstRange(of: marker), range.lowerBound + 68 <= der.count else { return nil }
322
+ return der.subdata(in: (range.lowerBound + 3)..<(range.lowerBound + 68))
323
+ }
324
+
325
+ private func download(_ release: OtaRelease, configuration: OtaConfiguration) async throws -> URL {
326
+ var request = URLRequest(url: release.downloadURL)
327
+ request.timeoutInterval = 40
328
+ request.setValue("application/zip", forHTTPHeaderField: "Accept")
329
+ let session = URLSession(configuration: .ephemeral, delegate: OtaRedirectBlocker(), delegateQueue: nil)
330
+ defer { session.invalidateAndCancel() }
331
+ let (temporaryURL, response) = try await session.download(for: request)
332
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw otaError("Bundle server returned an unsuccessful response") }
333
+ let size = try temporaryURL.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? -1
334
+ guard size == release.artifactSize, size <= 100 * 1024 * 1024,
335
+ try sha256(temporaryURL) == release.artifactHash else {
336
+ throw otaError("Bundle hash or size does not match signed manifest")
337
+ }
338
+ try FileManager.default.createDirectory(at: stagingDirectory, withIntermediateDirectories: true)
339
+ let archive = stagingDirectory.appendingPathComponent("\(release.id).zip.part")
340
+ try? FileManager.default.removeItem(at: archive)
341
+ try FileManager.default.moveItem(at: temporaryURL, to: archive)
342
+ return archive
343
+ }
344
+
345
+ private func install(archive: URL, release: OtaRelease, configuration: OtaConfiguration) throws {
346
+ let zip: Archive
347
+ guard let opened = Archive(url: archive, accessMode: .read, preferredEncoding: nil) else {
348
+ throw otaError("Downloaded OTA archive is invalid")
349
+ }
350
+ zip = opened
351
+ let entries = Array(zip)
352
+ guard (2...10_000).contains(entries.count) else { throw otaError("OTA archive has an invalid number of entries") }
353
+ var expanded: Int64 = 0
354
+ var byName: [String: Entry] = [:]
355
+ for entry in entries {
356
+ let size = Int64(entry.uncompressedSize)
357
+ guard size >= 0, size <= 500 * 1024 * 1024 - expanded else { throw otaError("OTA archive exceeds package limits") }
358
+ expanded += size
359
+ guard safePath(entry.path), byName[entry.path] == nil, entry.type == .file else { throw otaError("OTA archive contains unsafe or duplicate paths") }
360
+ byName[entry.path] = entry
361
+ }
362
+ guard let manifestEntry = byName["ota-manifest.json"], manifestEntry.uncompressedSize <= 1_048_576,
363
+ let manifestData = try? zip.extractData(manifestEntry, maximumBytes: 1_048_576),
364
+ let package = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
365
+ package["format_version"] as? Int == 1, package["entrypoint"] as? String == "main.jsbundle",
366
+ package["entrypoint_format"] as? String == "hermes-bytecode",
367
+ let entryHash = package["entrypoint_sha256"] as? String, let assets = package["assets"] as? [[String: Any]],
368
+ let bundleEntry = byName["main.jsbundle"] else { throw otaError("iOS package manifest or entrypoint is invalid") }
369
+
370
+ var declared = Set(["ota-manifest.json", "main.jsbundle"])
371
+ var files: [(Entry, String, String, Int)] = [(bundleEntry, "main.jsbundle", entryHash, Int(bundleEntry.uncompressedSize))]
372
+ for asset in assets {
373
+ guard let path = asset["path"] as? String, path.hasPrefix("assets/"), safePath(path),
374
+ let hash = asset["sha256"] as? String, let size = asset["size_bytes"] as? Int,
375
+ let entry = byName[path], declared.insert(path).inserted,
376
+ !["main.jsbundle", "ota-manifest.json", "release-manifest.json", "release-signature.txt"].contains(String(path.dropFirst(7))) else {
377
+ throw otaError("iOS asset metadata is invalid")
378
+ }
379
+ files.append((entry, String(path.dropFirst(7)), hash, size))
380
+ }
381
+ guard declared == Set(byName.keys) else { throw otaError("OTA archive contains undeclared files") }
382
+ try FileManager.default.createDirectory(at: updatesDirectory, withIntermediateDirectories: true)
383
+ let staging = updatesDirectory.appendingPathComponent(".\(release.id).\(UUID().uuidString).staging", isDirectory: true)
384
+ let final = updatesDirectory.appendingPathComponent(release.id, isDirectory: true)
385
+ if FileManager.default.fileExists(atPath: final.path) {
386
+ guard verifiedBundleURL(for: release.id, configuration: configuration) != nil else {
387
+ throw otaError("A stored release with this ID failed local verification")
388
+ }
389
+ return
390
+ }
391
+ try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: false)
392
+ do {
393
+ for (entry, path, hash, size) in files {
394
+ let output = staging.appendingPathComponent(path)
395
+ try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true)
396
+ try extractAndVerify(zip, entry: entry, to: output, expectedHash: hash, expectedSize: size)
397
+ }
398
+ try manifestData.write(to: staging.appendingPathComponent("ota-manifest.json"), options: .atomic)
399
+ try canonicalManifest(release.manifest).write(to: staging.appendingPathComponent("release-manifest.json"), options: .atomic)
400
+ try Data(release.signature.utf8).write(to: staging.appendingPathComponent("release-signature.txt"), options: .atomic)
401
+ if FileManager.default.fileExists(atPath: final.path) { throw otaError("Release directory already exists") }
402
+ try FileManager.default.moveItem(at: staging, to: final)
403
+ } catch {
404
+ try? FileManager.default.removeItem(at: staging)
405
+ throw error
406
+ }
407
+ }
408
+
409
+ private func verifiedBundleURL(for releaseId: String, configuration: OtaConfiguration) -> URL? {
410
+ let directory = updatesDirectory.appendingPathComponent(releaseId, isDirectory: true)
411
+ do {
412
+ let manifestData = try Data(contentsOf: directory.appendingPathComponent("release-manifest.json"))
413
+ let signature = try String(contentsOf: directory.appendingPathComponent("release-signature.txt"), encoding: .ascii).trimmingCharacters(in: .whitespacesAndNewlines)
414
+ guard let manifest = try JSONSerialization.jsonObject(with: manifestData) as? [String: Any],
415
+ ["app_id", "artifact_sha256", "artifact_size_bytes", "created_at", "manifest_version", "platform", "release_id", "runtime_version", "version"].allSatisfy({ manifest[$0] != nil }),
416
+ manifest["release_id"] as? String == releaseId, manifest["platform"] as? String == "ios",
417
+ manifest["app_id"] as? String == configuration.appId, manifest["runtime_version"] as? String == configuration.runtimeVersion,
418
+ manifest["manifest_version"] as? Int == 1,
419
+ verifySignature(canonicalManifest(manifest), signature: signature, pem: configuration.publicKeyPEM),
420
+ let package = try JSONSerialization.jsonObject(with: Data(contentsOf: directory.appendingPathComponent("ota-manifest.json"))) as? [String: Any],
421
+ package["format_version"] as? Int == 1, package["entrypoint"] as? String == "main.jsbundle",
422
+ package["entrypoint_format"] as? String == "hermes-bytecode", let bundleHash = package["entrypoint_sha256"] as? String,
423
+ let assets = package["assets"] as? [[String: Any]] else { return nil }
424
+ let bundle = directory.appendingPathComponent("main.jsbundle")
425
+ try verifyFile(bundle, expectedHash: bundleHash, expectedSize: (try bundle.resourceValues(forKeys: [.fileSizeKey])).fileSize ?? -1)
426
+ var declared = Set(["main.jsbundle", "ota-manifest.json", "release-manifest.json", "release-signature.txt"])
427
+ for asset in assets {
428
+ guard let path = asset["path"] as? String, path.hasPrefix("assets/"), safePath(path),
429
+ let hash = asset["sha256"] as? String, let size = asset["size_bytes"] as? Int,
430
+ declared.insert(String(path.dropFirst(7))).inserted else { return nil }
431
+ try verifyFile(directory.appendingPathComponent(String(path.dropFirst(7))), expectedHash: hash, expectedSize: size)
432
+ }
433
+ guard let enumerator = FileManager.default.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey]) else { return nil }
434
+ var actual = Set<String>()
435
+ for case let file as URL in enumerator where (try file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true {
436
+ actual.insert(String(file.path.dropFirst(directory.path.count + 1)))
437
+ }
438
+ return actual == declared ? bundle : nil
439
+ } catch { return nil }
440
+ }
441
+
442
+ private func verifyFile(_ url: URL, expectedHash: String, expectedSize: Int) throws {
443
+ let fileSize = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? -1
444
+ guard expectedSize >= 0, fileSize == expectedSize else { throw otaError("OTA file size does not match package manifest") }
445
+ guard try sha256(url) == expectedHash else { throw otaError("OTA file hash does not match package manifest") }
446
+ if url.lastPathComponent == "main.jsbundle" {
447
+ let header = try Data(contentsOf: url, options: .mappedIfSafe).prefix(4)
448
+ guard header.elementsEqual([0xC6, 0x1F, 0xBC, 0x03]) else { throw otaError("iOS entrypoint is not Hermes bytecode") }
449
+ }
450
+ }
451
+
452
+ private func extractAndVerify(_ archive: Archive, entry: Entry, to output: URL, expectedHash: String, expectedSize: Int) throws {
453
+ guard expectedSize >= 0, expectedSize <= 500 * 1024 * 1024 else { throw otaError("OTA package entry exceeds limits") }
454
+ guard FileManager.default.createFile(atPath: output.path, contents: nil) else { throw otaError("Cannot create staged OTA file") }
455
+ let handle = try FileHandle(forWritingTo: output)
456
+ defer { try? handle.close() }
457
+ var hasher = SHA256()
458
+ var total = 0
459
+ _ = try archive.extract(entry) { chunk in
460
+ total += chunk.count
461
+ guard total <= expectedSize, total <= 500 * 1024 * 1024 else { throw self.otaError("OTA package entry exceeds declared limits") }
462
+ hasher.update(data: chunk)
463
+ try handle.write(contentsOf: chunk)
464
+ }
465
+ try handle.synchronize()
466
+ let digest = hasher.finalize().map { String(format: "%02x", $0) }.joined()
467
+ guard total == expectedSize, digest == expectedHash else { throw otaError("OTA file hash or size does not match package manifest") }
468
+ if output.lastPathComponent == "main.jsbundle" {
469
+ let header = try Data(contentsOf: output, options: .mappedIfSafe).prefix(4)
470
+ guard header.elementsEqual([0xC6, 0x1F, 0xBC, 0x03]) else { throw otaError("iOS entrypoint is not Hermes bytecode") }
471
+ }
472
+ }
473
+
474
+ private func sha256(_ url: URL) throws -> String {
475
+ let handle = try FileHandle(forReadingFrom: url)
476
+ defer { try? handle.close() }
477
+ var hasher = SHA256()
478
+ while let chunk = try handle.read(upToCount: 1024 * 1024), !chunk.isEmpty { hasher.update(data: chunk) }
479
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
480
+ }
481
+
482
+ private func safePath(_ path: String) -> Bool {
483
+ !path.isEmpty && !path.hasPrefix("/") && !path.contains("\\") && !path.contains(":") && !path.hasSuffix("/") &&
484
+ path.split(separator: "/", omittingEmptySubsequences: false).allSatisfy { !$0.isEmpty && $0 != "." && $0 != ".." }
485
+ }
486
+ private func supportsTransport(_ config: OtaConfiguration) -> Bool {
487
+ config.serverURL.scheme?.lowercased() == "https" || (config.allowLocalHTTP && isLocal(config.serverURL.host))
488
+ }
489
+ private func supportsDownload(_ url: URL, configuration: OtaConfiguration) -> Bool {
490
+ url.scheme?.lowercased() == "https" || (configuration.allowLocalHTTP && isLocal(url.host))
491
+ }
492
+ private func isLocal(_ host: String?) -> Bool { ["localhost", "127.0.0.1", "::1"].contains(host?.lowercased() ?? "") }
493
+ private func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
494
+ lhs.scheme?.lowercased() == rhs.scheme?.lowercased() && lhs.host?.lowercased() == rhs.host?.lowercased() &&
495
+ (lhs.port ?? defaultPort(lhs)) == (rhs.port ?? defaultPort(rhs)) && lhs.user == nil
496
+ }
497
+ private func defaultPort(_ url: URL) -> Int { url.scheme?.lowercased() == "https" ? 443 : 80 }
498
+ private func clearPendingState() {
499
+ [Key.pendingRelease, Key.pendingVersion, Key.previousRelease, Key.previousVersion].forEach { defaults.removeObject(forKey: $0) }
500
+ defaults.set(false, forKey: Key.pendingAttempted)
501
+ }
502
+ private func clearActivationState() {
503
+ [Key.activeRelease, Key.activeVersion, Key.failedRelease].forEach { defaults.removeObject(forKey: $0) }
504
+ clearPendingState()
505
+ }
506
+
507
+ private enum CheckStart {
508
+ case proceed(OtaConfiguration)
509
+ case finished([String: Any])
510
+ }
511
+
512
+ private func beginCheck() throws -> CheckStart {
513
+ lock.lock()
514
+ defer { lock.unlock() }
515
+ guard let configuration else { throw otaError("OTA SDK has not been initialized") }
516
+ if configuration.isDebugBuild { return .finished(statusDictionary()) }
517
+ guard supportsTransport(configuration) else { throw otaError("Release builds require HTTPS for OTA updates") }
518
+ if defaults.string(forKey: Key.pendingRelease) != nil || checkInProgress {
519
+ return .finished(statusDictionary())
520
+ }
521
+ checkInProgress = true
522
+ return .proceed(configuration)
523
+ }
524
+
525
+ private func finishCheck() {
526
+ lock.lock()
527
+ checkInProgress = false
528
+ lock.unlock()
529
+ }
530
+
531
+ private func readActivationState(baseVersion: Int) -> OtaActivationState {
532
+ OtaActivationState(
533
+ activeReleaseId: defaults.string(forKey: Key.activeRelease),
534
+ activeVersion: defaults.object(forKey: Key.activeVersion) as? Int ?? baseVersion,
535
+ pendingReleaseId: defaults.string(forKey: Key.pendingRelease),
536
+ pendingVersion: defaults.object(forKey: Key.pendingVersion) as? Int,
537
+ previousReleaseId: defaults.string(forKey: Key.previousRelease),
538
+ previousVersion: defaults.object(forKey: Key.previousVersion) as? Int,
539
+ pendingLaunchAttempted: defaults.bool(forKey: Key.pendingAttempted),
540
+ failedReleaseId: defaults.string(forKey: Key.failedRelease)
541
+ )
542
+ }
543
+
544
+ private func persistActivationState(_ state: OtaActivationState) {
545
+ defaults.set(state.activeReleaseId, forKey: Key.activeRelease)
546
+ defaults.set(state.activeVersion, forKey: Key.activeVersion)
547
+ defaults.set(state.pendingReleaseId, forKey: Key.pendingRelease)
548
+ defaults.set(state.pendingVersion, forKey: Key.pendingVersion)
549
+ defaults.set(state.previousReleaseId, forKey: Key.previousRelease)
550
+ defaults.set(state.previousVersion, forKey: Key.previousVersion)
551
+ defaults.set(state.pendingLaunchAttempted, forKey: Key.pendingAttempted)
552
+ defaults.set(state.failedReleaseId, forKey: Key.failedRelease)
553
+ }
554
+ private func otaError(_ message: String) -> NSError {
555
+ NSError(domain: "OtaSdkUpdates", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
556
+ }
557
+ }
558
+
559
+ private final class OtaRedirectBlocker: NSObject, URLSessionTaskDelegate, URLSessionDownloadDelegate {
560
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {}
561
+
562
+ func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
563
+ newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
564
+ completionHandler(nil)
565
+ }
566
+
567
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask,
568
+ didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
569
+ if totalBytesWritten > 100 * 1024 * 1024 { downloadTask.cancel() }
570
+ }
571
+ }
572
+
573
+ private extension Archive {
574
+ func extractData(_ entry: Entry, maximumBytes: Int) throws -> Data {
575
+ var result = Data()
576
+ _ = try extract(entry) { chunk in
577
+ guard result.count <= maximumBytes - chunk.count else {
578
+ throw NSError(domain: "OtaSdkUpdates", code: 1, userInfo: [NSLocalizedDescriptionKey: "OTA manifest exceeds size limit"])
579
+ }
580
+ result.append(chunk)
581
+ }
582
+ return result
583
+ }
584
+ }
585
+
586
+ private extension Data {
587
+ func firstRange(of needle: Data) -> Range<Int>? {
588
+ guard !needle.isEmpty, count >= needle.count else { return nil }
589
+ for start in 0...(count - needle.count) where self[start..<(start + needle.count)].elementsEqual(needle) {
590
+ return start..<(start + needle.count)
591
+ }
592
+ return nil
593
+ }
594
+ }
@@ -0,0 +1,29 @@
1
+ import Foundation
2
+
3
+ func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
4
+ if !condition() {
5
+ fputs("FAILED: \(message)\n", stderr)
6
+ exit(1)
7
+ }
8
+ }
9
+
10
+ let base = OtaActivationState(activeVersion: 0)
11
+ let staged = OtaActivationPolicy.stage(base, releaseId: "release-1", version: 1)
12
+ let firstStart = OtaActivationPolicy.beginStartup(staged)
13
+ expect(firstStart.selectedReleaseId == "release-1", "first launch selects pending release")
14
+ expect(firstStart.state.pendingLaunchAttempted, "first launch records attempt")
15
+ expect(firstStart.state.activeVersion == 0, "pending is not active before health confirmation")
16
+
17
+ let healthy = OtaActivationPolicy.markHealthy(firstStart.state)
18
+ expect(healthy.activeReleaseId == "release-1", "health confirmation commits release")
19
+ expect(healthy.activeVersion == 1 && healthy.pendingReleaseId == nil, "health confirmation clears pending state")
20
+
21
+ let current = OtaActivationState(activeReleaseId: "release-1", activeVersion: 1)
22
+ let next = OtaActivationPolicy.stage(current, releaseId: "release-2", version: 2)
23
+ let failedLaunch = OtaActivationPolicy.beginStartup(next)
24
+ let rollback = OtaActivationPolicy.beginStartup(failedLaunch.state)
25
+ expect(rollback.selectedReleaseId == "release-1", "unconfirmed launch restores previous release")
26
+ expect(rollback.failedReleaseId == "release-2", "rollback records failed release")
27
+ expect(rollback.state.activeVersion == 1 && rollback.state.pendingReleaseId == nil, "rollback restores previous version")
28
+
29
+ print("iOS OTA activation policy checks passed")
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-native-ota-client",
3
- "version": "0.2.0",
4
- "description": "Android over-the-air JavaScript updates for React Native applications",
3
+ "version": "0.3.1",
4
+ "description": "Android and iOS OTA updates and over-the-air JavaScript updates for React Native apps",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",
7
7
  "exports": {
@@ -42,7 +42,15 @@
42
42
  },
43
43
  "keywords": [
44
44
  "react-native",
45
- "android"
45
+ "react-native-ota",
46
+ "react-native-ota-updates",
47
+ "react-native-updates",
48
+ "ota",
49
+ "ota-updates",
50
+ "over-the-air",
51
+ "over-the-air-updates",
52
+ "android",
53
+ "ios"
46
54
  ],
47
55
  "repository": {
48
56
  "type": "git",
@@ -53,7 +61,7 @@
53
61
  "bugs": {
54
62
  "url": "https://github.com/arjunnarangali/react-native-ota-client/issues"
55
63
  },
56
- "homepage": "https://github.com/arjunnarangali/react-native-ota-client#readme",
64
+ "homepage": "https://docs.ota.arjunis.me/",
57
65
  "publishConfig": {
58
66
  "registry": "https://registry.npmjs.org/"
59
67
  },