@viettelpost/react-native-ota 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/android/build.gradle +48 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAHashUtils.kt +21 -0
- package/android/src/main/java/com/viettelpost/otakit/OTATestReceiver.kt +51 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateBundleResolver.kt +405 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateCleanup.kt +186 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateDownloader.kt +649 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateMetadata.kt +72 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateModule.kt +140 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdatePackage.kt +30 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateSignatureVerifier.kt +63 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateStorage.kt +62 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAZipUtils.kt +100 -0
- package/android/src/main/res/raw/ota_public_key.pem +9 -0
- package/bin/cli/assets-zip.js +77 -0
- package/bin/cli/bundle.js +72 -0
- package/bin/cli/deploy.js +224 -0
- package/bin/cli/sign.js +97 -0
- package/bin/cli/upload.js +109 -0
- package/bin/ota.js +200 -0
- package/docs/BACKEND_CONTRACT.md +93 -0
- package/docs/DEPLOY_CLI.md +39 -0
- package/docs/INTEGRATION_ANDROID.md +20 -0
- package/docs/INTEGRATION_IOS.md +21 -0
- package/docs/RELEASE_WORKFLOW.md +14 -0
- package/ios/OTAHashUtils.swift +22 -0
- package/ios/OTAUpdateBundleResolver.swift +359 -0
- package/ios/OTAUpdateCleanup.swift +269 -0
- package/ios/OTAUpdateDownloader.swift +709 -0
- package/ios/OTAUpdateMetadata.swift +47 -0
- package/ios/OTAUpdateModule.mm +190 -0
- package/ios/OTAUpdateSignatureVerifier.swift +81 -0
- package/ios/OTAUpdateStorage.swift +83 -0
- package/ios/OTAZipUtils.swift +103 -0
- package/ios/ota_public_key.pem +9 -0
- package/lib/NativeOTAUpdate.d.ts +77 -0
- package/lib/NativeOTAUpdate.js +59 -0
- package/lib/OTAClient.d.ts +27 -0
- package/lib/OTAClient.js +101 -0
- package/lib/config.d.ts +14 -0
- package/lib/config.js +29 -0
- package/lib/devtools.d.ts +10 -0
- package/lib/devtools.js +54 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +32 -0
- package/lib/spec/NativeOTAUpdate.d.ts +16 -0
- package/lib/spec/NativeOTAUpdate.js +4 -0
- package/package.json +82 -0
- package/react-native-ota.podspec +21 -0
- package/scripts/run-bin.js +67 -0
- package/src/NativeOTAUpdate.ts +144 -0
- package/src/OTAClient.ts +151 -0
- package/src/config.ts +41 -0
- package/src/devtools.ts +64 -0
- package/src/index.ts +69 -0
- package/src/spec/NativeOTAUpdate.ts +21 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct OTAUpdateMetadata: Codable {
|
|
4
|
+
var schemaVersion: Int
|
|
5
|
+
var embeddedBundleVersion: String
|
|
6
|
+
var activeBundleVersion: String
|
|
7
|
+
var previousBundleVersion: String?
|
|
8
|
+
var pendingBundleVersion: String?
|
|
9
|
+
var failedBundleVersion: String?
|
|
10
|
+
var runningBundleVersion: String
|
|
11
|
+
var status: String
|
|
12
|
+
var launchCountForPending: Int
|
|
13
|
+
var lastSuccessfulLaunchAt: String?
|
|
14
|
+
var lastFailureReason: String?
|
|
15
|
+
|
|
16
|
+
static func `default`() -> OTAUpdateMetadata {
|
|
17
|
+
OTAUpdateMetadata(
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
embeddedBundleVersion: "embedded",
|
|
20
|
+
activeBundleVersion: "embedded",
|
|
21
|
+
previousBundleVersion: nil,
|
|
22
|
+
pendingBundleVersion: nil,
|
|
23
|
+
failedBundleVersion: nil,
|
|
24
|
+
runningBundleVersion: "embedded",
|
|
25
|
+
status: "active",
|
|
26
|
+
launchCountForPending: 0,
|
|
27
|
+
lastSuccessfulLaunchAt: nil,
|
|
28
|
+
lastFailureReason: nil
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static func metadataParseFailed() -> OTAUpdateMetadata {
|
|
33
|
+
OTAUpdateMetadata(
|
|
34
|
+
schemaVersion: 1,
|
|
35
|
+
embeddedBundleVersion: "embedded",
|
|
36
|
+
activeBundleVersion: "embedded",
|
|
37
|
+
previousBundleVersion: nil,
|
|
38
|
+
pendingBundleVersion: nil,
|
|
39
|
+
failedBundleVersion: nil,
|
|
40
|
+
runningBundleVersion: "embedded",
|
|
41
|
+
status: "failed",
|
|
42
|
+
launchCountForPending: 0,
|
|
43
|
+
lastSuccessfulLaunchAt: nil,
|
|
44
|
+
lastFailureReason: "metadata_parse_failed"
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
#import <React/RCTBridgeModule.h>
|
|
3
|
+
|
|
4
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
5
|
+
#import <ReactCommon/RCTTurboModule.h>
|
|
6
|
+
#import "ReactNativeOta-Swift.h"
|
|
7
|
+
#import <ReactNativeOtaSpec/ReactNativeOtaSpec.h>
|
|
8
|
+
|
|
9
|
+
@interface OTAUpdateModule : NSObject <NativeOTAUpdateSpec>
|
|
10
|
+
@end
|
|
11
|
+
|
|
12
|
+
@implementation OTAUpdateModule
|
|
13
|
+
|
|
14
|
+
RCT_EXPORT_MODULE(OTAUpdate)
|
|
15
|
+
|
|
16
|
+
static NSString *OTARejectCodeFromError(NSError *error, NSString *fallbackCode)
|
|
17
|
+
{
|
|
18
|
+
NSString *code = error.userInfo[@"otaCode"];
|
|
19
|
+
if (code.length > 0) {
|
|
20
|
+
return code;
|
|
21
|
+
}
|
|
22
|
+
return fallbackCode;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static NSString *OTARejectMessageFromError(NSError *error, NSString *fallbackMessage)
|
|
26
|
+
{
|
|
27
|
+
NSString *code = OTARejectCodeFromError(error, @"");
|
|
28
|
+
if ([code isEqualToString:@"OTA_SIGNATURE_INVALID"]) {
|
|
29
|
+
return @"OTA signature mismatch. Check that version, platform, fileName, lowercase sha256, and assetsSha256 when assets are included match the signed payload exactly.";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
NSString *message = error.userInfo[NSLocalizedDescriptionKey];
|
|
33
|
+
if (message.length > 0) {
|
|
34
|
+
return message;
|
|
35
|
+
}
|
|
36
|
+
return fallbackMessage;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
- (void)getMetadata:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
40
|
+
{
|
|
41
|
+
NSError *error = nil;
|
|
42
|
+
NSString *metadata = [[OTAUpdateStorage shared] getMetadataJSONStringAndReturnError:&error];
|
|
43
|
+
if (error != nil || metadata == nil) {
|
|
44
|
+
reject(@"OTA_GET_METADATA_FAILED", @"Failed to read OTA metadata", error);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
resolve(metadata);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
- (void)resetMetadata:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
52
|
+
{
|
|
53
|
+
NSError *error = nil;
|
|
54
|
+
NSNumber *didReset = [[OTAUpdateStorage shared] resetMetadataAndReturnError:&error];
|
|
55
|
+
if (error != nil || didReset == nil) {
|
|
56
|
+
reject(@"OTA_RESET_METADATA_FAILED", @"Failed to reset OTA metadata", error);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
resolve(didReset);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
- (void)getOTADirectory:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
64
|
+
{
|
|
65
|
+
NSError *error = nil;
|
|
66
|
+
NSString *directory = [[OTAUpdateBundleResolver shared] getOTADirectoryPathAndReturnError:&error];
|
|
67
|
+
if (error != nil || directory == nil) {
|
|
68
|
+
reject(@"OTA_GET_DIRECTORY_FAILED", @"Failed to read OTA directory", error);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
resolve(directory);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
- (void)prepareManualInstall:(NSString *)bundleVersion resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
76
|
+
{
|
|
77
|
+
NSError *error = nil;
|
|
78
|
+
NSNumber *didPrepare = [[OTAUpdateBundleResolver shared] prepareManualInstall:bundleVersion error:&error];
|
|
79
|
+
if (error != nil || didPrepare == nil) {
|
|
80
|
+
NSString *code = @"OTA_PREPARE_MANUAL_INSTALL_FAILED";
|
|
81
|
+
if (error.code == 1) {
|
|
82
|
+
code = @"OTA_INVALID_BUNDLE_VERSION";
|
|
83
|
+
} else if (error.code == 2) {
|
|
84
|
+
code = @"OTA_BUNDLE_FILE_NOT_FOUND";
|
|
85
|
+
}
|
|
86
|
+
reject(code, @"Failed to prepare OTA manual install", error);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
resolve(didPrepare);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
- (void)markSuccess:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
94
|
+
{
|
|
95
|
+
NSError *error = nil;
|
|
96
|
+
NSNumber *didMarkSuccess = [[OTAUpdateBundleResolver shared] markSuccessAndReturnError:&error];
|
|
97
|
+
if (error != nil || didMarkSuccess == nil) {
|
|
98
|
+
reject(@"OTA_MARK_SUCCESS_FAILED", @"Failed to mark OTA success", error);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
resolve(didMarkSuccess);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
- (void)copyBundleFromDocuments:(NSString *)bundleVersion fileName:(NSString *)fileName resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
106
|
+
{
|
|
107
|
+
NSError *error = nil;
|
|
108
|
+
NSNumber *didCopy = [[OTAUpdateBundleResolver shared] copyBundleFromDocuments:bundleVersion fileName:fileName error:&error];
|
|
109
|
+
if (error != nil || didCopy == nil) {
|
|
110
|
+
NSString *code = @"OTA_COPY_FROM_DOCUMENTS_FAILED";
|
|
111
|
+
if (error.code == 1) {
|
|
112
|
+
code = @"OTA_INVALID_BUNDLE_VERSION";
|
|
113
|
+
} else if (error.code == 2) {
|
|
114
|
+
code = @"OTA_BUNDLE_FILE_NOT_FOUND";
|
|
115
|
+
}
|
|
116
|
+
reject(code, @"Failed to copy OTA bundle from Documents", error);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
resolve(didCopy);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
- (void)getCurrentBundleInfo:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
124
|
+
{
|
|
125
|
+
NSError *error = nil;
|
|
126
|
+
NSString *bundleInfo = [[OTAUpdateBundleResolver shared] getCurrentBundleInfoJSONStringAndReturnError:&error];
|
|
127
|
+
if (error != nil || bundleInfo == nil) {
|
|
128
|
+
reject(@"OTA_GET_CURRENT_BUNDLE_INFO_FAILED", @"Failed to read OTA bundle info", error);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
resolve(bundleInfo);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
- (void)downloadAndInstallBundle:(NSString *)updateJson resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
136
|
+
{
|
|
137
|
+
[[OTAUpdateDownloader shared] downloadAndInstallBundle:updateJson completion:^(NSNumber *didInstall, NSError *error) {
|
|
138
|
+
if (error != nil || didInstall == nil) {
|
|
139
|
+
reject(OTARejectCodeFromError(error, @"OTA_DOWNLOAD_AND_INSTALL_FAILED"), OTARejectMessageFromError(error, @"Failed to download and install OTA bundle"), error);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
resolve(didInstall);
|
|
144
|
+
}];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
- (void)getDownloadInfo:(NSString *)version resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
148
|
+
{
|
|
149
|
+
NSError *error = nil;
|
|
150
|
+
NSString *downloadInfo = [[OTAUpdateDownloader shared] getDownloadInfo:version error:&error];
|
|
151
|
+
if (error != nil || downloadInfo == nil) {
|
|
152
|
+
reject(OTARejectCodeFromError(error, @"OTA_GET_DOWNLOAD_INFO_FAILED"), @"Failed to read OTA download info", error);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
resolve(downloadInfo);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
- (void)getOTADiskUsage:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
160
|
+
{
|
|
161
|
+
NSError *error = nil;
|
|
162
|
+
NSString *diskUsage = [[OTAUpdateCleanup shared] getOTADiskUsageJSONStringAndReturnError:&error];
|
|
163
|
+
if (error != nil || diskUsage == nil) {
|
|
164
|
+
reject(@"OTA_GET_DISK_USAGE_FAILED", @"Failed to read OTA disk usage", error);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
resolve(diskUsage);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
- (void)cleanupOTAStorage:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject
|
|
172
|
+
{
|
|
173
|
+
NSError *error = nil;
|
|
174
|
+
NSNumber *didCleanup = [[OTAUpdateCleanup shared] cleanupOTAStorageAndReturnError:&error];
|
|
175
|
+
if (error != nil || didCleanup == nil) {
|
|
176
|
+
reject(@"OTA_CLEANUP_STORAGE_FAILED", @"Failed to cleanup OTA storage", error);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
resolve(didCleanup);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
184
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
185
|
+
{
|
|
186
|
+
return std::make_shared<facebook::react::NativeOTAUpdateSpecJSI>(params);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
@end
|
|
190
|
+
#endif
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Security
|
|
3
|
+
|
|
4
|
+
enum OTAUpdateSignatureVerifier {
|
|
5
|
+
static func canonicalPayload(
|
|
6
|
+
version: String,
|
|
7
|
+
platform: String,
|
|
8
|
+
fileName: String,
|
|
9
|
+
sha256: String,
|
|
10
|
+
assetsSha256: String? = nil
|
|
11
|
+
) -> String {
|
|
12
|
+
var lines = [
|
|
13
|
+
"version=\(version)",
|
|
14
|
+
"platform=\(platform)",
|
|
15
|
+
"fileName=\(fileName)",
|
|
16
|
+
"sha256=\(sha256)",
|
|
17
|
+
]
|
|
18
|
+
if let assetsSha256 = assetsSha256?.trimmingCharacters(in: .whitespacesAndNewlines), !assetsSha256.isEmpty {
|
|
19
|
+
lines.append("assetsSha256=\(assetsSha256.lowercased())")
|
|
20
|
+
}
|
|
21
|
+
return lines.joined(separator: "\n")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
static func verifySignature(canonicalPayload: String, base64Signature: String) throws -> Bool {
|
|
25
|
+
let publicKey = try loadPublicKey()
|
|
26
|
+
guard let signatureData = Data(base64Encoded: base64Signature, options: [.ignoreUnknownCharacters]) else {
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
guard let payloadData = canonicalPayload.data(using: .utf8) else {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
var error: Unmanaged<CFError>?
|
|
34
|
+
let isValid = SecKeyVerifySignature(
|
|
35
|
+
publicKey,
|
|
36
|
+
.rsaSignatureMessagePKCS1v15SHA256,
|
|
37
|
+
payloadData as CFData,
|
|
38
|
+
signatureData as CFData,
|
|
39
|
+
&error
|
|
40
|
+
)
|
|
41
|
+
if let error = error {
|
|
42
|
+
throw error.takeRetainedValue() as Error
|
|
43
|
+
}
|
|
44
|
+
return isValid
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static func loadPublicKey() throws -> SecKey {
|
|
48
|
+
let url = Bundle.main.url(forResource: "ota_public_key", withExtension: "pem")
|
|
49
|
+
?? Bundle(for: OTAUpdateStorage.self).url(forResource: "ota_public_key", withExtension: "pem")
|
|
50
|
+
|
|
51
|
+
guard let url = url else {
|
|
52
|
+
throw OTAUpdateDownloaderError(code: "OTA_PUBLIC_KEY_NOT_FOUND", message: "OTA public key resource was not found")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let pem = try String(contentsOf: url, encoding: .utf8)
|
|
56
|
+
let normalizedPem = pem
|
|
57
|
+
.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
|
|
58
|
+
.replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
|
|
59
|
+
.components(separatedBy: .whitespacesAndNewlines)
|
|
60
|
+
.joined()
|
|
61
|
+
|
|
62
|
+
guard let keyData = Data(base64Encoded: normalizedPem) else {
|
|
63
|
+
throw OTAUpdateDownloaderError(code: "OTA_PUBLIC_KEY_INVALID", message: "OTA public key PEM is invalid")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let attributes: [String: Any] = [
|
|
67
|
+
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
|
68
|
+
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
|
69
|
+
kSecAttrKeySizeInBits as String: 2048,
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
var error: Unmanaged<CFError>?
|
|
73
|
+
guard let key = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, &error) else {
|
|
74
|
+
if let error = error {
|
|
75
|
+
throw error.takeRetainedValue() as Error
|
|
76
|
+
}
|
|
77
|
+
throw OTAUpdateDownloaderError(code: "OTA_PUBLIC_KEY_INVALID", message: "Unable to create OTA public key")
|
|
78
|
+
}
|
|
79
|
+
return key
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
@objc(OTAUpdateStorage)
|
|
4
|
+
final class OTAUpdateStorage: NSObject {
|
|
5
|
+
@objc static let shared = OTAUpdateStorage()
|
|
6
|
+
|
|
7
|
+
private let encoder: JSONEncoder
|
|
8
|
+
private let decoder: JSONDecoder
|
|
9
|
+
private let fileManager: FileManager
|
|
10
|
+
|
|
11
|
+
override init() {
|
|
12
|
+
encoder = JSONEncoder()
|
|
13
|
+
decoder = JSONDecoder()
|
|
14
|
+
fileManager = FileManager.default
|
|
15
|
+
super.init()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@objc func getMetadataJSONString() throws -> String {
|
|
19
|
+
let metadata = try readMetadata()
|
|
20
|
+
let data = try encoder.encode(metadata)
|
|
21
|
+
guard let jsonString = String(data: data, encoding: .utf8) else {
|
|
22
|
+
throw OTAUpdateStorageError.jsonEncodingFailed
|
|
23
|
+
}
|
|
24
|
+
return jsonString
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@objc func resetMetadata() throws -> NSNumber {
|
|
28
|
+
try writeMetadata(.default())
|
|
29
|
+
return true as NSNumber
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func readMetadata() throws -> OTAUpdateMetadata {
|
|
33
|
+
let url = try metadataURL()
|
|
34
|
+
guard fileManager.fileExists(atPath: url.path) else {
|
|
35
|
+
let metadata = OTAUpdateMetadata.default()
|
|
36
|
+
try writeMetadata(metadata)
|
|
37
|
+
return metadata
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
do {
|
|
41
|
+
let data = try Data(contentsOf: url)
|
|
42
|
+
return try decoder.decode(OTAUpdateMetadata.self, from: data)
|
|
43
|
+
} catch {
|
|
44
|
+
let fallback = OTAUpdateMetadata.metadataParseFailed()
|
|
45
|
+
try writeMetadata(fallback)
|
|
46
|
+
return fallback
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func writeMetadata(_ metadata: OTAUpdateMetadata) throws {
|
|
51
|
+
let url = try metadataURL()
|
|
52
|
+
let directoryURL = url.deletingLastPathComponent()
|
|
53
|
+
try fileManager.createDirectory(
|
|
54
|
+
at: directoryURL,
|
|
55
|
+
withIntermediateDirectories: true,
|
|
56
|
+
attributes: nil
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
let data = try encoder.encode(metadata)
|
|
60
|
+
let tempURL = directoryURL.appendingPathComponent("metadata.json.tmp")
|
|
61
|
+
try data.write(to: tempURL, options: .atomic)
|
|
62
|
+
|
|
63
|
+
if fileManager.fileExists(atPath: url.path) {
|
|
64
|
+
try fileManager.removeItem(at: url)
|
|
65
|
+
}
|
|
66
|
+
try fileManager.moveItem(at: tempURL, to: url)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func metadataURL() throws -> URL {
|
|
70
|
+
let supportURL = try fileManager.url(
|
|
71
|
+
for: .applicationSupportDirectory,
|
|
72
|
+
in: .userDomainMask,
|
|
73
|
+
appropriateFor: nil,
|
|
74
|
+
create: true
|
|
75
|
+
)
|
|
76
|
+
return supportURL.appendingPathComponent("OTA", isDirectory: true)
|
|
77
|
+
.appendingPathComponent("metadata.json")
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
enum OTAUpdateStorageError: Error {
|
|
82
|
+
case jsonEncodingFailed
|
|
83
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import ZIPFoundation
|
|
3
|
+
|
|
4
|
+
enum OTAZipUtils {
|
|
5
|
+
private static let requiredRootDirectory = "assets"
|
|
6
|
+
private static let blockedRootDirectories: Set<String> = ["proc", "sys", "data", "etc"]
|
|
7
|
+
|
|
8
|
+
static func validate(zipURL: URL) throws {
|
|
9
|
+
let archive = try openArchive(zipURL: zipURL)
|
|
10
|
+
var entryCount = 0
|
|
11
|
+
|
|
12
|
+
for entry in archive {
|
|
13
|
+
try validateEntryPath(entry.path)
|
|
14
|
+
entryCount += 1
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
guard entryCount > 0 else {
|
|
18
|
+
throw zipError("OTA assets ZIP is empty")
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static func unzip(zipURL: URL, destinationURL: URL) throws {
|
|
23
|
+
let archive = try openArchive(zipURL: zipURL)
|
|
24
|
+
let fileManager = FileManager.default
|
|
25
|
+
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil)
|
|
26
|
+
|
|
27
|
+
var entryCount = 0
|
|
28
|
+
for entry in archive {
|
|
29
|
+
entryCount += 1
|
|
30
|
+
let targetURL = try safeDestinationURL(for: entry.path, destinationURL: destinationURL)
|
|
31
|
+
|
|
32
|
+
switch entry.type {
|
|
33
|
+
case .directory:
|
|
34
|
+
try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true, attributes: nil)
|
|
35
|
+
case .file:
|
|
36
|
+
let parentURL = targetURL.deletingLastPathComponent()
|
|
37
|
+
try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true, attributes: nil)
|
|
38
|
+
_ = try archive.extract(entry, to: targetURL)
|
|
39
|
+
default:
|
|
40
|
+
throw zipError("OTA assets ZIP contains unsupported entry type at \(entry.path)")
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
guard entryCount > 0 else {
|
|
45
|
+
throw zipError("OTA assets ZIP is empty")
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private static func openArchive(zipURL: URL) throws -> Archive {
|
|
50
|
+
guard let archive = Archive(url: zipURL, accessMode: .read) else {
|
|
51
|
+
throw zipError("OTA assets ZIP could not be opened")
|
|
52
|
+
}
|
|
53
|
+
return archive
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private static func safeDestinationURL(for entryPath: String, destinationURL: URL) throws -> URL {
|
|
57
|
+
try validateEntryPath(entryPath)
|
|
58
|
+
|
|
59
|
+
let rootPath = destinationURL.standardizedFileURL.path
|
|
60
|
+
let targetURL = destinationURL.appendingPathComponent(entryPath).standardizedFileURL
|
|
61
|
+
let targetPath = targetURL.path
|
|
62
|
+
guard targetPath == rootPath || targetPath.hasPrefix(rootPath + "/") else {
|
|
63
|
+
throw zipError("OTA assets ZIP entry escapes destination: \(entryPath)")
|
|
64
|
+
}
|
|
65
|
+
return targetURL
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private static func validateEntryPath(_ entryPath: String) throws {
|
|
69
|
+
var normalizedPath = entryPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
70
|
+
while normalizedPath.hasSuffix("/") {
|
|
71
|
+
normalizedPath.removeLast()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
guard !normalizedPath.isEmpty else {
|
|
75
|
+
throw zipError("OTA assets ZIP contains an empty path")
|
|
76
|
+
}
|
|
77
|
+
guard !normalizedPath.hasPrefix("/") && !normalizedPath.hasPrefix("\\") else {
|
|
78
|
+
throw zipError("OTA assets ZIP contains an absolute path: \(entryPath)")
|
|
79
|
+
}
|
|
80
|
+
guard !normalizedPath.contains("\\") else {
|
|
81
|
+
throw zipError("OTA assets ZIP contains a backslash path: \(entryPath)")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let components = normalizedPath.split(separator: "/", omittingEmptySubsequences: false).map(String.init)
|
|
85
|
+
guard let firstComponent = components.first?.lowercased(),
|
|
86
|
+
!blockedRootDirectories.contains(firstComponent) else {
|
|
87
|
+
throw zipError("OTA assets ZIP contains a blocked root directory: \(entryPath)")
|
|
88
|
+
}
|
|
89
|
+
guard firstComponent == requiredRootDirectory else {
|
|
90
|
+
throw zipError("OTA assets ZIP entries must be under assets/: \(entryPath)")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for component in components {
|
|
94
|
+
if component.isEmpty || component == "." || component == ".." {
|
|
95
|
+
throw zipError("OTA assets ZIP contains an unsafe path: \(entryPath)")
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private static func zipError(_ message: String) -> OTAUpdateDownloaderError {
|
|
101
|
+
OTAUpdateDownloaderError(code: "OTA_ASSETS_ZIP_INVALID", message: message)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-----BEGIN PUBLIC KEY-----
|
|
2
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8FidxBN6K3wQeyIGkv0l
|
|
3
|
+
NvxrpkruxpTZgF10Eb2mDVcahDZqZVvEaFELhmz7++nfPdHoE1cuyoki2uLkZVeT
|
|
4
|
+
thAeD2+KMwHtocnn9uELySbm25bf4/ZtvdpaTeiq+qrm9bpgdsFc+GIdGu3P2xmJ
|
|
5
|
+
y/RZu9J6zcjV7SHPgPgRJ0yFXwhecmGfbVKoMYg4NWWAABR6Sr+2JoVmQRKYLkgj
|
|
6
|
+
VVEZgbmS9vrDcxOpbNXILsWWEk4deiGHF/8A2JkTYA17ZFm8swNPnrBjWy+23huA
|
|
7
|
+
UQoDtywNreXJLyh3+R8LAkVF+vplGQXEKViD29ZgJ2Y1xl3k1LzUMp37Qmz2zsGr
|
|
8
|
+
LQIDAQAB
|
|
9
|
+
-----END PUBLIC KEY-----
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
export type OTAStatus = 'active' | 'downloading' | 'download_failed' | 'downloaded' | 'verifying' | 'verified' | 'verify_failed' | 'pending' | 'failed' | 'rolled_back';
|
|
2
|
+
export type OTAMetadata = {
|
|
3
|
+
schemaVersion: number;
|
|
4
|
+
embeddedBundleVersion: string;
|
|
5
|
+
activeBundleVersion: string;
|
|
6
|
+
previousBundleVersion: string | null;
|
|
7
|
+
pendingBundleVersion: string | null;
|
|
8
|
+
failedBundleVersion: string | null;
|
|
9
|
+
runningBundleVersion: string;
|
|
10
|
+
status: OTAStatus;
|
|
11
|
+
launchCountForPending: number;
|
|
12
|
+
lastSuccessfulLaunchAt: string | null;
|
|
13
|
+
lastFailureReason: string | null;
|
|
14
|
+
};
|
|
15
|
+
export type OTABundleInfo = {
|
|
16
|
+
runningBundleVersion: string;
|
|
17
|
+
activeBundleVersion: string;
|
|
18
|
+
pendingBundleVersion: string | null;
|
|
19
|
+
status: OTAStatus;
|
|
20
|
+
bundlePath: string | null;
|
|
21
|
+
assetsDirectoryPath?: string | null;
|
|
22
|
+
assetsDirectoryExists?: boolean;
|
|
23
|
+
isEmbedded: boolean;
|
|
24
|
+
};
|
|
25
|
+
export type OTAUpdatePayload = {
|
|
26
|
+
version: string;
|
|
27
|
+
bundleUrl: string;
|
|
28
|
+
platform: 'android' | 'ios';
|
|
29
|
+
fileName: string;
|
|
30
|
+
sha256: string;
|
|
31
|
+
signature: string;
|
|
32
|
+
assetsUrl?: string;
|
|
33
|
+
assetsSha256?: string;
|
|
34
|
+
};
|
|
35
|
+
export type OTADownloadInfo = {
|
|
36
|
+
version: string;
|
|
37
|
+
tempBundlePath: string;
|
|
38
|
+
finalBundlePath: string;
|
|
39
|
+
tempAssetsZipPath: string;
|
|
40
|
+
finalAssetsDirectoryPath: string;
|
|
41
|
+
tempExists: boolean;
|
|
42
|
+
finalExists: boolean;
|
|
43
|
+
tempAssetsZipExists: boolean;
|
|
44
|
+
finalAssetFileCount: number;
|
|
45
|
+
tempSize: number;
|
|
46
|
+
finalSize: number;
|
|
47
|
+
tempAssetsZipSize: number;
|
|
48
|
+
finalAssetsSize: number;
|
|
49
|
+
tempSha256: string | null;
|
|
50
|
+
finalSha256: string | null;
|
|
51
|
+
tempAssetsSha256: string | null;
|
|
52
|
+
signatureRequired: boolean;
|
|
53
|
+
};
|
|
54
|
+
export type OTADiskUsage = {
|
|
55
|
+
otaRootPath: string;
|
|
56
|
+
totalBytes: number;
|
|
57
|
+
tmpBytes: number;
|
|
58
|
+
bundlesBytes: number;
|
|
59
|
+
bundles: Array<{
|
|
60
|
+
version: string;
|
|
61
|
+
bytes: number;
|
|
62
|
+
isActive: boolean;
|
|
63
|
+
isPending: boolean;
|
|
64
|
+
isFailed: boolean;
|
|
65
|
+
}>;
|
|
66
|
+
};
|
|
67
|
+
export declare function getOTAMetadata(): Promise<OTAMetadata>;
|
|
68
|
+
export declare function resetOTAMetadata(): Promise<boolean>;
|
|
69
|
+
export declare function getOTADirectory(): Promise<string>;
|
|
70
|
+
export declare function prepareManualInstall(bundleVersion: string): Promise<boolean>;
|
|
71
|
+
export declare function markOTASuccess(): Promise<boolean>;
|
|
72
|
+
export declare function copyBundleFromDocuments(bundleVersion: string, fileName: string): Promise<boolean>;
|
|
73
|
+
export declare function getCurrentBundleInfo(): Promise<OTABundleInfo>;
|
|
74
|
+
export declare function downloadAndInstallBundle(update: OTAUpdatePayload): Promise<boolean>;
|
|
75
|
+
export declare function getDownloadInfo(version: string): Promise<OTADownloadInfo>;
|
|
76
|
+
export declare function getOTADiskUsage(): Promise<OTADiskUsage>;
|
|
77
|
+
export declare function cleanupOTAStorage(): Promise<boolean>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.cleanupOTAStorage = exports.getOTADiskUsage = exports.getDownloadInfo = exports.downloadAndInstallBundle = exports.getCurrentBundleInfo = exports.copyBundleFromDocuments = exports.markOTASuccess = exports.prepareManualInstall = exports.getOTADirectory = exports.resetOTAMetadata = exports.getOTAMetadata = void 0;
|
|
7
|
+
const react_native_1 = require("react-native");
|
|
8
|
+
const NativeOTAUpdate_1 = __importDefault(require("./spec/NativeOTAUpdate"));
|
|
9
|
+
async function getOTAMetadata() {
|
|
10
|
+
const metadata = await NativeOTAUpdate_1.default.getMetadata();
|
|
11
|
+
return JSON.parse(metadata);
|
|
12
|
+
}
|
|
13
|
+
exports.getOTAMetadata = getOTAMetadata;
|
|
14
|
+
async function resetOTAMetadata() {
|
|
15
|
+
return NativeOTAUpdate_1.default.resetMetadata();
|
|
16
|
+
}
|
|
17
|
+
exports.resetOTAMetadata = resetOTAMetadata;
|
|
18
|
+
async function getOTADirectory() {
|
|
19
|
+
return NativeOTAUpdate_1.default.getOTADirectory();
|
|
20
|
+
}
|
|
21
|
+
exports.getOTADirectory = getOTADirectory;
|
|
22
|
+
async function prepareManualInstall(bundleVersion) {
|
|
23
|
+
return NativeOTAUpdate_1.default.prepareManualInstall(bundleVersion);
|
|
24
|
+
}
|
|
25
|
+
exports.prepareManualInstall = prepareManualInstall;
|
|
26
|
+
async function markOTASuccess() {
|
|
27
|
+
return NativeOTAUpdate_1.default.markSuccess();
|
|
28
|
+
}
|
|
29
|
+
exports.markOTASuccess = markOTASuccess;
|
|
30
|
+
async function copyBundleFromDocuments(bundleVersion, fileName) {
|
|
31
|
+
if (react_native_1.Platform.OS !== 'ios') {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return NativeOTAUpdate_1.default.copyBundleFromDocuments(bundleVersion, fileName);
|
|
35
|
+
}
|
|
36
|
+
exports.copyBundleFromDocuments = copyBundleFromDocuments;
|
|
37
|
+
async function getCurrentBundleInfo() {
|
|
38
|
+
const bundleInfo = await NativeOTAUpdate_1.default.getCurrentBundleInfo();
|
|
39
|
+
return JSON.parse(bundleInfo);
|
|
40
|
+
}
|
|
41
|
+
exports.getCurrentBundleInfo = getCurrentBundleInfo;
|
|
42
|
+
async function downloadAndInstallBundle(update) {
|
|
43
|
+
return NativeOTAUpdate_1.default.downloadAndInstallBundle(JSON.stringify(update));
|
|
44
|
+
}
|
|
45
|
+
exports.downloadAndInstallBundle = downloadAndInstallBundle;
|
|
46
|
+
async function getDownloadInfo(version) {
|
|
47
|
+
const downloadInfo = await NativeOTAUpdate_1.default.getDownloadInfo(version);
|
|
48
|
+
return JSON.parse(downloadInfo);
|
|
49
|
+
}
|
|
50
|
+
exports.getDownloadInfo = getDownloadInfo;
|
|
51
|
+
async function getOTADiskUsage() {
|
|
52
|
+
const diskUsage = await NativeOTAUpdate_1.default.getOTADiskUsage();
|
|
53
|
+
return JSON.parse(diskUsage);
|
|
54
|
+
}
|
|
55
|
+
exports.getOTADiskUsage = getOTADiskUsage;
|
|
56
|
+
async function cleanupOTAStorage() {
|
|
57
|
+
return NativeOTAUpdate_1.default.cleanupOTAStorage();
|
|
58
|
+
}
|
|
59
|
+
exports.cleanupOTAStorage = cleanupOTAStorage;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type OTABundleInfo, type OTAMetadata } from './NativeOTAUpdate';
|
|
2
|
+
import { type OTAConfig } from './config';
|
|
3
|
+
export type OTACheckUpdateResponse = {
|
|
4
|
+
version: string;
|
|
5
|
+
bundleUrl: string;
|
|
6
|
+
fileName: string;
|
|
7
|
+
platform: 'android' | 'ios';
|
|
8
|
+
sha256: string;
|
|
9
|
+
signature: string;
|
|
10
|
+
assetsUrl?: string;
|
|
11
|
+
assetsSha256?: string;
|
|
12
|
+
};
|
|
13
|
+
export type OTAUpdateResult = {
|
|
14
|
+
success: true;
|
|
15
|
+
version: string;
|
|
16
|
+
alreadyUpToDate?: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
success: false;
|
|
19
|
+
code: string;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
export declare function checkAndInstallOTA(otaBaseURL: string, options?: Pick<OTAConfig, 'headers' | 'checkUpdatePath'>): Promise<OTAUpdateResult>;
|
|
23
|
+
export declare function sync(): Promise<OTAUpdateResult>;
|
|
24
|
+
export declare function getCurrentOTAStatus(): Promise<{
|
|
25
|
+
metadata: OTAMetadata;
|
|
26
|
+
bundleInfo: OTABundleInfo;
|
|
27
|
+
}>;
|