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 +2 -0
- package/README.md +77 -9
- package/ios/OTAUpdates.mm +51 -0
- package/ios/OtaActivationPolicy.swift +78 -0
- package/ios/OtaSdkUpdates.swift +594 -0
- package/ios/Tests/main.swift +29 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
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,
|
|
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
|
|
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