react-native-ota-client 0.1.1 → 0.2.0-beta.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,15 @@
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
+ 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.
4
4
 
5
- OTA can update JavaScript and bundled assets. Native code, permissions, React Native/Hermes versions, and Codegen contract changes require a new APK.
5
+ OTA can update JavaScript and bundled assets. Native code, permissions/entitlements, React Native/Hermes versions, and Codegen contract changes require a new APK/IPA.
6
6
 
7
7
  ## Requirements and support
8
8
 
9
- - Android only; iOS OTA support is not implemented.
9
+ - Android and iOS.
10
10
  - React Native 0.88.x, Android New Architecture, and Hermes.
11
11
  - Android min SDK 24.
12
+ - iOS New Architecture and Hermes; use the host app's iOS deployment target supported by its React Native version.
12
13
  - A host application with a release-signing setup and a public verification key.
13
14
 
14
15
  ## Installation
@@ -17,7 +18,7 @@ OTA can update JavaScript and bundled assets. Native code, permissions, React Na
17
18
  npm install react-native-ota-client
18
19
  ```
19
20
 
20
- React Native autolinking discovers the Android package. Rebuild the native application after installation; Metro alone cannot add native code.
21
+ React Native autolinking discovers the native package on Android and iOS. Rebuild the native application after installation; Metro alone cannot add native code.
21
22
 
22
23
  For local development, the example workspace consumes the SDK source directly. In a host app, install the package using the command above.
23
24
 
@@ -34,6 +35,8 @@ await OTAUpdates.closeAppToApplyUpdate();
34
35
 
35
36
  `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
37
 
38
+ On iOS, apps cannot terminate themselves. When `closeAppToApplyUpdate()` returns `true`, ask the user to leave and reopen the app to load the staged bundle.
39
+
37
40
  The package also retains the generated-template `multiply()` example; it is not part of the OTA API.
38
41
 
39
42
  ## Android configuration
@@ -47,6 +50,7 @@ import com.facebook.react.ReactApplication
47
50
  import com.facebook.react.ReactHost
48
51
  import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
49
52
  import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
53
+ import com.otasdk.OtaSdkConfig
50
54
  import com.otasdk.OtaSdkUpdates
51
55
 
52
56
  class MainApplication : Application(), ReactApplication {
@@ -61,8 +65,19 @@ class MainApplication : Application(), ReactApplication {
61
65
  override fun onCreate() {
62
66
  super.onCreate()
63
67
  val publicKeyPem = resources.openRawResource(R.raw.ota_publisher_public).use { it.readBytes() }
64
- val config = otaSdkConfigForCurrentVariant(publicKeyPem)
65
- OtaSdkUpdates.initialize(this, config)
68
+ OtaSdkUpdates.initialize(
69
+ this,
70
+ OtaSdkConfig(
71
+ serverUrl = BuildConfig.OTA_SERVER_URL,
72
+ runtimeVersion = BuildConfig.OTA_RUNTIME_VERSION,
73
+ channel = BuildConfig.OTA_CHANNEL,
74
+ baseVersion = BuildConfig.OTA_BASE_VERSION,
75
+ publicKeyPem = publicKeyPem,
76
+ allowInsecureHttp = BuildConfig.OTA_ALLOW_INSECURE_HTTP,
77
+ isDebugBuild = BuildConfig.DEBUG,
78
+ appId = BuildConfig.APPLICATION_ID,
79
+ ),
80
+ )
66
81
  OtaSdkUpdates.beginProcess()
67
82
  loadReactNative(this)
68
83
  }
@@ -75,7 +90,7 @@ The `ota_publisher_public` Android raw resource contains only the verification k
75
90
 
76
91
  ### Flavor-specific configuration
77
92
 
78
- Define each environment as an Android `productFlavor` and provide the SDK values as flavor-specific `BuildConfig` fields. The flavor controls the app ID, runtime version, channel, base version, and public key source set; use distinct runtime versions and channels when environments must receive separate updates. For example:
93
+ Define each environment as an Android `productFlavor` and provide the SDK values as flavor-specific `BuildConfig` fields. The flavor controls the app ID, runtime version, channel, server URL, base version, public verification key, and build-type debug state; use distinct runtime versions and channels when environments must receive separate updates. For example:
79
94
 
80
95
  ```groovy
81
96
  android {
@@ -84,22 +99,30 @@ android {
84
99
  development {
85
100
  dimension "environment"
86
101
  applicationIdSuffix ".dev"
102
+ buildConfigField "String", "OTA_SERVER_URL", '"https://dev.example.com"'
87
103
  buildConfigField "String", "OTA_RUNTIME_VERSION", '"rn-0.88-android-dev"'
88
104
  buildConfigField "String", "OTA_CHANNEL", '"development"'
89
105
  buildConfigField "int", "OTA_BASE_VERSION", "0"
106
+ buildConfigField "boolean", "OTA_ALLOW_INSECURE_HTTP", "false"
90
107
  }
91
108
  staging {
92
109
  dimension "environment"
93
110
  applicationIdSuffix ".staging"
111
+ buildConfigField "String", "OTA_SERVER_URL", '"https://staging.example.com"'
94
112
  buildConfigField "String", "OTA_RUNTIME_VERSION", '"rn-0.88-android-staging"'
95
113
  buildConfigField "String", "OTA_CHANNEL", '"staging"'
96
114
  buildConfigField "int", "OTA_BASE_VERSION", "0"
115
+ buildConfigField "boolean", "OTA_ALLOW_INSECURE_HTTP", "false"
97
116
  }
98
117
  }
99
118
  }
100
119
  ```
101
120
 
102
- The host application's configuration helper should import `com.otasdk.OtaSdkConfig` and build a fully populated config for the selected variant. The `OTA_*` field names and types must match those read by that helper. Set `react.debuggableVariants` for every flavor's debug variants (for example, `developmentDebug` and `stagingDebug`) so React Native uses Metro for those builds. `BuildConfig.DEBUG` is supplied by the selected build type, while `BuildConfig.APPLICATION_ID` includes any flavor suffix.
121
+ The `OTA_*` field names and types must match those read in `MainApplication.kt`. Set `react.debuggableVariants` for every flavor's debug variants (for example, `developmentDebug` and `stagingDebug`) so React Native uses Metro for those builds. `BuildConfig.DEBUG` is supplied by the selected build type, while `BuildConfig.APPLICATION_ID` includes any flavor suffix.
122
+
123
+ ### Transport security
124
+
125
+ Use HTTPS for staging and production, with `allowInsecureHttp = false`. Local development may opt in to HTTP only when the URL host is a local development host supported by the SDK (`localhost`, `127.0.0.1`, `::1`, or Android emulator host `10.0.2.2`). The setting is not a general HTTP override. OTA download URLs must also match the configured server origin.
103
126
 
104
127
  ## Native startup operations
105
128
 
@@ -112,6 +135,40 @@ The host application's configuration helper should import `com.otasdk.OtaSdkConf
112
135
 
113
136
  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.
114
137
 
138
+ ## iOS configuration
139
+
140
+ 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:
141
+
142
+ ```swift
143
+ import OtaSdk
144
+
145
+ // In AppDelegate, before the SceneDelegate starts React Native:
146
+ OtaSdkUpdates.initialize(
147
+ serverUrl: serverURL,
148
+ runtimeVersion: runtimeVersion,
149
+ channel: channel,
150
+ baseVersion: NSNumber(value: baseVersion),
151
+ publicKeyPem: publicKeyPEM,
152
+ isDebugBuild: isDebugBuild,
153
+ appId: appId,
154
+ allowLocalHttp: allowLocalHTTP
155
+ )
156
+ OtaSdkUpdates.beginProcess()
157
+
158
+ // In the React Native factory delegate:
159
+ override func bundleURL() -> URL? {
160
+ #if DEBUG
161
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
162
+ #else
163
+ OtaSdkUpdates.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
164
+ #endif
165
+ }
166
+ ```
167
+
168
+ 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.
169
+
170
+ The example app reads `OTA_SERVER_URL`, `OTA_RUNTIME_VERSION`, and `OTA_CHANNEL` from its Info.plist build substitutions and expects an `ota_publisher_public.pem` public key resource in the app bundle. Configure these values and add the matching public key to exercise its OTA flow.
171
+
115
172
  ## Development and verification
116
173
 
117
174
  For implementation architecture, behavioral invariants, suggested change workflow, and verification commands, see the [coding-agent implementation guide](agent.md).
@@ -123,7 +180,7 @@ node .yarn/releases/yarn-4.18.1.cjs install
123
180
  node .yarn/releases/yarn-4.18.1.cjs workspace react-native-ota-client-example build:android
124
181
  ```
125
182
 
126
- The library unit tests run through the example's Gradle project:
183
+ The Android library unit tests run through the example's Gradle project:
127
184
 
128
185
  ```sh
129
186
  cd example/android
@@ -132,6 +189,17 @@ cd example/android
132
189
 
133
190
  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.
134
191
 
192
+ For iOS, run `pod install` from `example/ios`, then build the `OtaSdkExample` scheme for Debug and Release. Debug uses Metro; Release selects a verified OTA bundle when configured and otherwise uses the embedded bundle. The standalone activation-state checks can be run from the package root with:
193
+
194
+ ```sh
195
+ swiftc ios/OtaActivationPolicy.swift ios/Tests/main.swift -o /tmp/ota-activation-policy-tests
196
+ /tmp/ota-activation-policy-tests
197
+ ```
198
+
199
+ Signature/package validation tests and live simulator/device activation and rollback exercises remain required before claiming end-to-end OTA readiness.
200
+
201
+ 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).
202
+
135
203
  ## License
136
204
 
137
205
  MIT
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-ota-client",
3
- "version": "0.1.1",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Android over-the-air JavaScript updates for React Native applications",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",