@otaupdate/react-native 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +321 -0
- package/android/build.gradle +70 -0
- package/android/src/expo/java/com/otaupdate/OtaUpdateExpoPackage.kt +37 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/com/otaupdate/OtaUpdate.kt +98 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateInstaller.kt +211 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateModule.kt +278 -0
- package/android/src/main/java/com/otaupdate/OtaUpdatePackage.kt +15 -0
- package/android/src/main/java/com/otaupdate/OtaUpdateStore.kt +268 -0
- package/app.plugin.js +3 -0
- package/expo-module.config.json +6 -0
- package/ios/OtaUpdate.h +46 -0
- package/ios/OtaUpdate.m +302 -0
- package/ios/OtaUpdateInstaller.h +25 -0
- package/ios/OtaUpdateInstaller.m +278 -0
- package/ios/OtaUpdateStore.h +69 -0
- package/ios/OtaUpdateStore.m +283 -0
- package/lib/OtaUpdate.d.ts +28 -0
- package/lib/OtaUpdate.d.ts.map +1 -0
- package/lib/OtaUpdate.js +254 -0
- package/lib/OtaUpdate.js.map +1 -0
- package/lib/api.d.ts +36 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +89 -0
- package/lib/api.js.map +1 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +37 -0
- package/lib/index.js.map +1 -0
- package/lib/native.d.ts +34 -0
- package/lib/native.d.ts.map +1 -0
- package/lib/native.js +34 -0
- package/lib/native.js.map +1 -0
- package/lib/types.d.ts +112 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +27 -0
- package/lib/types.js.map +1 -0
- package/lib/useOtaUpdate.d.ts +17 -0
- package/lib/useOtaUpdate.d.ts.map +1 -0
- package/lib/useOtaUpdate.js +81 -0
- package/lib/useOtaUpdate.js.map +1 -0
- package/lib/withOtaUpdate.d.ts +10 -0
- package/lib/withOtaUpdate.d.ts.map +1 -0
- package/lib/withOtaUpdate.js +21 -0
- package/lib/withOtaUpdate.js.map +1 -0
- package/package.json +54 -0
- package/plugin/build/index.d.ts +11 -0
- package/plugin/build/index.js +97 -0
- package/react-native-ota-update.podspec +43 -0
- package/react-native.config.js +21 -0
- package/src/OtaUpdate.ts +293 -0
- package/src/api.ts +122 -0
- package/src/index.ts +55 -0
- package/src/native.ts +64 -0
- package/src/types.ts +125 -0
- package/src/useOtaUpdate.ts +96 -0
- package/src/withOtaUpdate.tsx +22 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#import "OtaUpdateInstaller.h"
|
|
2
|
+
#import <CommonCrypto/CommonDigest.h>
|
|
3
|
+
#import <SSZipArchive/SSZipArchive.h>
|
|
4
|
+
|
|
5
|
+
NSString *const OtaUpdateErrorDomain = @"com.otaupdate.error";
|
|
6
|
+
|
|
7
|
+
static NSArray<NSString *> *OtaBundleCandidates(void) {
|
|
8
|
+
return @[ @"main.jsbundle", @"index.ios.bundle", @"index.bundle" ];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static NSError *OtaError(NSInteger code, NSString *message) {
|
|
12
|
+
return [NSError errorWithDomain:OtaUpdateErrorDomain
|
|
13
|
+
code:code
|
|
14
|
+
userInfo:@{NSLocalizedDescriptionKey : message}];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
#pragma mark - Download delegate
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* NSURLSessionDownloadTask streams to a temp file for us, which keeps large
|
|
21
|
+
* bundles off the heap. Hashing happens after the transfer completes.
|
|
22
|
+
*/
|
|
23
|
+
@interface OtaDownloadDelegate : NSObject <NSURLSessionDownloadDelegate>
|
|
24
|
+
@property (nonatomic, copy) void (^progressBlock)(long long received, long long total);
|
|
25
|
+
@property (nonatomic, copy) void (^completionBlock)(NSURL *_Nullable location, NSError *_Nullable error);
|
|
26
|
+
@property (nonatomic, assign) long long lastReported;
|
|
27
|
+
@end
|
|
28
|
+
|
|
29
|
+
@implementation OtaDownloadDelegate
|
|
30
|
+
|
|
31
|
+
- (void)URLSession:(NSURLSession *)session
|
|
32
|
+
downloadTask:(NSURLSessionDownloadTask *)downloadTask
|
|
33
|
+
didWriteData:(int64_t)bytesWritten
|
|
34
|
+
totalBytesWritten:(int64_t)totalBytesWritten
|
|
35
|
+
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
|
|
36
|
+
if (!self.progressBlock) return;
|
|
37
|
+
// Throttle to ~1% steps; the bridge cannot keep up with per-chunk events.
|
|
38
|
+
int64_t step = totalBytesExpectedToWrite > 0 ? totalBytesExpectedToWrite / 100 : 0;
|
|
39
|
+
if (step <= 0 || totalBytesWritten - self.lastReported >= step ||
|
|
40
|
+
totalBytesWritten == totalBytesExpectedToWrite) {
|
|
41
|
+
self.lastReported = totalBytesWritten;
|
|
42
|
+
self.progressBlock(totalBytesWritten, totalBytesExpectedToWrite);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
- (void)URLSession:(NSURLSession *)session
|
|
47
|
+
downloadTask:(NSURLSessionDownloadTask *)downloadTask
|
|
48
|
+
didFinishDownloadingToURL:(NSURL *)location {
|
|
49
|
+
NSHTTPURLResponse *response = (NSHTTPURLResponse *)downloadTask.response;
|
|
50
|
+
if ([response isKindOfClass:[NSHTTPURLResponse class]] &&
|
|
51
|
+
(response.statusCode < 200 || response.statusCode > 299)) {
|
|
52
|
+
self.completionBlock(nil, OtaError(1001, [NSString stringWithFormat:@"Bundle download failed with HTTP %ld",
|
|
53
|
+
(long)response.statusCode]));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `location` is deleted as soon as this method returns — move it first.
|
|
58
|
+
NSURL *stable = [[NSURL fileURLWithPath:NSTemporaryDirectory()]
|
|
59
|
+
URLByAppendingPathComponent:[NSString stringWithFormat:@"ota-%@.zip", [[NSUUID UUID] UUIDString]]];
|
|
60
|
+
NSError *moveError = nil;
|
|
61
|
+
[[NSFileManager defaultManager] moveItemAtURL:location toURL:stable error:&moveError];
|
|
62
|
+
self.completionBlock(moveError ? nil : stable, moveError);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
- (void)URLSession:(NSURLSession *)session
|
|
66
|
+
task:(NSURLSessionTask *)task
|
|
67
|
+
didCompleteWithError:(NSError *)error {
|
|
68
|
+
if (error) self.completionBlock(nil, error);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@end
|
|
72
|
+
|
|
73
|
+
#pragma mark - Installer
|
|
74
|
+
|
|
75
|
+
@implementation OtaUpdateInstaller
|
|
76
|
+
|
|
77
|
+
+ (void)installUpdateWithStore:(OtaUpdateStore *)store
|
|
78
|
+
downloadURL:(NSString *)downloadURL
|
|
79
|
+
expectedHash:(NSString *)expectedHash
|
|
80
|
+
progress:(void (^)(long long, long long))progress
|
|
81
|
+
completion:(void (^)(NSString *_Nullable, long long, NSError *_Nullable))completion {
|
|
82
|
+
|
|
83
|
+
if ([store hasFailed:expectedHash]) {
|
|
84
|
+
completion(nil, 0,
|
|
85
|
+
OtaError(1005, [NSString stringWithFormat:
|
|
86
|
+
@"Refusing to reinstall %@ — a previous attempt failed to boot",
|
|
87
|
+
expectedHash]));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
NSURL *targetDir = [store packageDirectoryForHash:expectedHash];
|
|
92
|
+
NSString *existing = [self resolveBundleInDirectory:targetDir];
|
|
93
|
+
if (existing) {
|
|
94
|
+
// Downloaded and verified on an earlier attempt.
|
|
95
|
+
completion(existing, [self directorySize:targetDir], nil);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
NSURL *url = [NSURL URLWithString:downloadURL];
|
|
100
|
+
if (!url) {
|
|
101
|
+
completion(nil, 0, OtaError(1002, @"The download URL returned by the server is not valid"));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
OtaDownloadDelegate *delegate = [[OtaDownloadDelegate alloc] init];
|
|
106
|
+
delegate.progressBlock = progress;
|
|
107
|
+
|
|
108
|
+
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
|
|
109
|
+
configuration.timeoutIntervalForRequest = 60;
|
|
110
|
+
configuration.timeoutIntervalForResource = 600;
|
|
111
|
+
NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration
|
|
112
|
+
delegate:delegate
|
|
113
|
+
delegateQueue:nil];
|
|
114
|
+
|
|
115
|
+
__block BOOL finished = NO;
|
|
116
|
+
delegate.completionBlock = ^(NSURL *location, NSError *error) {
|
|
117
|
+
// The delegate can fire both didFinishDownloading and didCompleteWithError.
|
|
118
|
+
if (finished) return;
|
|
119
|
+
finished = YES;
|
|
120
|
+
[session finishTasksAndInvalidate];
|
|
121
|
+
|
|
122
|
+
if (error || !location) {
|
|
123
|
+
completion(nil, 0, error ?: OtaError(1003, @"Bundle download failed"));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
NSError *installError = nil;
|
|
128
|
+
long long size = 0;
|
|
129
|
+
NSString *bundlePath = [self verifyAndUnzip:location
|
|
130
|
+
store:store
|
|
131
|
+
expectedHash:expectedHash
|
|
132
|
+
size:&size
|
|
133
|
+
error:&installError];
|
|
134
|
+
[[NSFileManager defaultManager] removeItemAtURL:location error:nil];
|
|
135
|
+
completion(bundlePath, size, installError);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
[[session downloadTaskWithURL:url] resume];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
+ (nullable NSString *)verifyAndUnzip:(NSURL *)zipURL
|
|
142
|
+
store:(OtaUpdateStore *)store
|
|
143
|
+
expectedHash:(NSString *)expectedHash
|
|
144
|
+
size:(long long *)outSize
|
|
145
|
+
error:(NSError **)outError {
|
|
146
|
+
NSString *actualHash = [self sha256OfFileAtURL:zipURL];
|
|
147
|
+
if (!actualHash) {
|
|
148
|
+
if (outError) *outError = OtaError(1004, @"Could not read the downloaded bundle");
|
|
149
|
+
return nil;
|
|
150
|
+
}
|
|
151
|
+
if ([actualHash caseInsensitiveCompare:expectedHash] != NSOrderedSame) {
|
|
152
|
+
if (outError) {
|
|
153
|
+
*outError = OtaError(1006, [NSString stringWithFormat:
|
|
154
|
+
@"Bundle integrity check failed: expected %@ but downloaded %@",
|
|
155
|
+
expectedHash, actualHash]);
|
|
156
|
+
}
|
|
157
|
+
return nil;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
NSURL *targetDir = [store packageDirectoryForHash:expectedHash];
|
|
161
|
+
[[NSFileManager defaultManager] removeItemAtURL:targetDir error:nil];
|
|
162
|
+
[[NSFileManager defaultManager] createDirectoryAtURL:targetDir
|
|
163
|
+
withIntermediateDirectories:YES
|
|
164
|
+
attributes:nil
|
|
165
|
+
error:nil];
|
|
166
|
+
|
|
167
|
+
NSError *unzipError = nil;
|
|
168
|
+
BOOL ok = [SSZipArchive unzipFileAtPath:zipURL.path
|
|
169
|
+
toDestination:targetDir.path
|
|
170
|
+
overwrite:YES
|
|
171
|
+
password:nil
|
|
172
|
+
error:&unzipError];
|
|
173
|
+
if (!ok) {
|
|
174
|
+
[[NSFileManager defaultManager] removeItemAtURL:targetDir error:nil];
|
|
175
|
+
if (outError) {
|
|
176
|
+
*outError = unzipError ?: OtaError(1007, @"Could not unzip the release package");
|
|
177
|
+
}
|
|
178
|
+
return nil;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
NSString *bundlePath = [self resolveBundleInDirectory:targetDir];
|
|
182
|
+
if (!bundlePath) {
|
|
183
|
+
[[NSFileManager defaultManager] removeItemAtURL:targetDir error:nil];
|
|
184
|
+
if (outError) {
|
|
185
|
+
*outError = OtaError(1008, [NSString stringWithFormat:
|
|
186
|
+
@"The release package contains no JS bundle (looked for %@)",
|
|
187
|
+
[OtaBundleCandidates() componentsJoinedByString:@", "]]);
|
|
188
|
+
}
|
|
189
|
+
return nil;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (outSize) *outSize = [self directorySize:targetDir];
|
|
193
|
+
return bundlePath;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Streams the file through CC_SHA256 so memory stays flat for large bundles. */
|
|
197
|
+
+ (nullable NSString *)sha256OfFileAtURL:(NSURL *)url {
|
|
198
|
+
NSFileHandle *handle = [NSFileHandle fileHandleForReadingAtPath:url.path];
|
|
199
|
+
if (!handle) return nil;
|
|
200
|
+
|
|
201
|
+
CC_SHA256_CTX context;
|
|
202
|
+
CC_SHA256_Init(&context);
|
|
203
|
+
|
|
204
|
+
const NSUInteger chunkSize = 64 * 1024;
|
|
205
|
+
@try {
|
|
206
|
+
while (YES) {
|
|
207
|
+
@autoreleasepool {
|
|
208
|
+
NSData *chunk = [handle readDataOfLength:chunkSize];
|
|
209
|
+
if (chunk.length == 0) break;
|
|
210
|
+
CC_SHA256_Update(&context, chunk.bytes, (CC_LONG)chunk.length);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} @finally {
|
|
214
|
+
[handle closeFile];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
unsigned char digest[CC_SHA256_DIGEST_LENGTH];
|
|
218
|
+
CC_SHA256_Final(digest, &context);
|
|
219
|
+
|
|
220
|
+
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
|
|
221
|
+
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
|
|
222
|
+
return hex;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Finds the JS bundle inside an extracted package, tolerating archives that
|
|
227
|
+
* wrap everything in a single top-level folder.
|
|
228
|
+
*/
|
|
229
|
+
+ (nullable NSString *)resolveBundleInDirectory:(NSURL *)directory {
|
|
230
|
+
NSFileManager *fm = [NSFileManager defaultManager];
|
|
231
|
+
BOOL isDirectory = NO;
|
|
232
|
+
if (![fm fileExistsAtPath:directory.path isDirectory:&isDirectory] || !isDirectory) return nil;
|
|
233
|
+
|
|
234
|
+
for (NSString *name in OtaBundleCandidates()) {
|
|
235
|
+
NSString *candidate = [directory.path stringByAppendingPathComponent:name];
|
|
236
|
+
if ([fm fileExistsAtPath:candidate]) return candidate;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
NSArray<NSURL *> *children = [fm contentsOfDirectoryAtURL:directory
|
|
240
|
+
includingPropertiesForKeys:@[ NSURLIsDirectoryKey ]
|
|
241
|
+
options:NSDirectoryEnumerationSkipsHiddenFiles
|
|
242
|
+
error:nil];
|
|
243
|
+
if (children.count == 1) {
|
|
244
|
+
NSNumber *dirFlag = nil;
|
|
245
|
+
[children.firstObject getResourceValue:&dirFlag forKey:NSURLIsDirectoryKey error:nil];
|
|
246
|
+
if (dirFlag.boolValue) {
|
|
247
|
+
NSString *nested = [self resolveBundleInDirectory:children.firstObject];
|
|
248
|
+
if (nested) return nested;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
NSDirectoryEnumerator *enumerator = [fm enumeratorAtURL:directory
|
|
253
|
+
includingPropertiesForKeys:nil
|
|
254
|
+
options:NSDirectoryEnumerationSkipsHiddenFiles
|
|
255
|
+
errorHandler:nil];
|
|
256
|
+
for (NSURL *file in enumerator) {
|
|
257
|
+
NSString *ext = file.pathExtension;
|
|
258
|
+
if ([ext isEqualToString:@"jsbundle"] || [ext isEqualToString:@"bundle"]) return file.path;
|
|
259
|
+
}
|
|
260
|
+
return nil;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
+ (long long)directorySize:(NSURL *)directory {
|
|
264
|
+
long long total = 0;
|
|
265
|
+
NSDirectoryEnumerator *enumerator =
|
|
266
|
+
[[NSFileManager defaultManager] enumeratorAtURL:directory
|
|
267
|
+
includingPropertiesForKeys:@[ NSURLFileSizeKey ]
|
|
268
|
+
options:0
|
|
269
|
+
errorHandler:nil];
|
|
270
|
+
for (NSURL *file in enumerator) {
|
|
271
|
+
NSNumber *size = nil;
|
|
272
|
+
[file getResourceValue:&size forKey:NSURLFileSizeKey error:nil];
|
|
273
|
+
total += size.longLongValue;
|
|
274
|
+
}
|
|
275
|
+
return total;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#import <Foundation/Foundation.h>
|
|
2
|
+
|
|
3
|
+
NS_ASSUME_NONNULL_BEGIN
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* On-disk state for downloaded JS bundles, and the rollback state machine.
|
|
7
|
+
*
|
|
8
|
+
* Layout under Application Support/OtaUpdate:
|
|
9
|
+
* status.json the state below
|
|
10
|
+
* packages/<sha256>/ one unzipped release each
|
|
11
|
+
*
|
|
12
|
+
* install() -> pending = hash, isLoading = NO
|
|
13
|
+
* next launch -> promote: current = pending, isLoading = YES
|
|
14
|
+
* notifyAppReady() -> confirm: lastConfirmed = current, pending = nil
|
|
15
|
+
* launch while loading-> the last boot never confirmed: roll back and
|
|
16
|
+
* blacklist the bad hash
|
|
17
|
+
*/
|
|
18
|
+
@interface OtaUpdateStore : NSObject
|
|
19
|
+
|
|
20
|
+
+ (instancetype)sharedStore;
|
|
21
|
+
|
|
22
|
+
/** YES when this launch swapped in a freshly installed bundle. */
|
|
23
|
+
@property (nonatomic, readonly) BOOL isFirstRunOfUpdate;
|
|
24
|
+
|
|
25
|
+
@property (nonatomic, copy, readonly, nullable) NSString *currentHash;
|
|
26
|
+
@property (nonatomic, copy, readonly, nullable) NSString *lastConfirmedHash;
|
|
27
|
+
@property (nonatomic, copy, readonly, nullable) NSString *pendingHash;
|
|
28
|
+
|
|
29
|
+
/** Runs once per process launch, before the bundle URL is resolved. YES if it rolled back. */
|
|
30
|
+
- (BOOL)initializeAfterRestart;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Promote a freshly installed package without ever treating the call as a
|
|
34
|
+
* failed boot. Used when the bundle URL is resolved again inside the same
|
|
35
|
+
* process — an IMMEDIATE or ON_NEXT_RESUME reload.
|
|
36
|
+
*/
|
|
37
|
+
- (BOOL)promotePendingIfAny;
|
|
38
|
+
|
|
39
|
+
- (void)notifyApplicationReady;
|
|
40
|
+
- (void)markPending:(NSString *)packageHash;
|
|
41
|
+
- (BOOL)hasFailed:(NSString *)packageHash;
|
|
42
|
+
- (BOOL)isPending;
|
|
43
|
+
|
|
44
|
+
- (NSURL *)packageDirectoryForHash:(NSString *)packageHash;
|
|
45
|
+
- (NSURL *)rootDirectory;
|
|
46
|
+
|
|
47
|
+
- (void)recordPackageWithHash:(NSString *)packageHash
|
|
48
|
+
label:(NSString *)label
|
|
49
|
+
bundlePath:(NSString *)bundlePath
|
|
50
|
+
description:(nullable NSString *)description
|
|
51
|
+
isMandatory:(BOOL)isMandatory
|
|
52
|
+
size:(long long)size
|
|
53
|
+
appVersion:(NSString *)appVersion;
|
|
54
|
+
|
|
55
|
+
- (nullable NSDictionary *)packageInfoForHash:(nullable NSString *)packageHash;
|
|
56
|
+
|
|
57
|
+
/** Absolute path of the bundle to load, or nil to use the one in the binary. */
|
|
58
|
+
- (nullable NSString *)currentBundlePath;
|
|
59
|
+
|
|
60
|
+
- (void)deletePackage:(NSString *)packageHash;
|
|
61
|
+
- (void)pruneOldPackages;
|
|
62
|
+
- (void)reset;
|
|
63
|
+
|
|
64
|
+
/** Stable random per-install id used for rollout bucketing. */
|
|
65
|
+
- (NSString *)clientUniqueId;
|
|
66
|
+
|
|
67
|
+
@end
|
|
68
|
+
|
|
69
|
+
NS_ASSUME_NONNULL_END
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#import "OtaUpdateStore.h"
|
|
2
|
+
|
|
3
|
+
static NSString *const kClientIdKey = @"com.otaupdate.clientUniqueId";
|
|
4
|
+
static NSString *const kLogTag = @"[OtaUpdate]";
|
|
5
|
+
|
|
6
|
+
@interface OtaUpdateStore ()
|
|
7
|
+
@property (nonatomic, copy, nullable) NSString *currentHash;
|
|
8
|
+
@property (nonatomic, copy, nullable) NSString *lastConfirmedHash;
|
|
9
|
+
@property (nonatomic, copy, nullable) NSString *pendingHash;
|
|
10
|
+
@property (nonatomic, assign) BOOL pendingIsLoading;
|
|
11
|
+
@property (nonatomic, strong) NSMutableSet<NSString *> *failedHashes;
|
|
12
|
+
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSDictionary *> *packages;
|
|
13
|
+
@property (nonatomic, assign) BOOL isFirstRunOfUpdate;
|
|
14
|
+
@property (nonatomic, strong) NSURL *rootURL;
|
|
15
|
+
@end
|
|
16
|
+
|
|
17
|
+
@implementation OtaUpdateStore
|
|
18
|
+
|
|
19
|
+
+ (instancetype)sharedStore {
|
|
20
|
+
static OtaUpdateStore *shared = nil;
|
|
21
|
+
static dispatch_once_t token;
|
|
22
|
+
dispatch_once(&token, ^{
|
|
23
|
+
shared = [[OtaUpdateStore alloc] init];
|
|
24
|
+
});
|
|
25
|
+
return shared;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
- (instancetype)init {
|
|
29
|
+
if (self = [super init]) {
|
|
30
|
+
_failedHashes = [NSMutableSet set];
|
|
31
|
+
_packages = [NSMutableDictionary dictionary];
|
|
32
|
+
|
|
33
|
+
// Application Support is excluded from iCloud backup churn concerns and is
|
|
34
|
+
// the right place for regenerable app data.
|
|
35
|
+
NSURL *appSupport = [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory
|
|
36
|
+
inDomains:NSUserDomainMask].firstObject;
|
|
37
|
+
_rootURL = [appSupport URLByAppendingPathComponent:@"OtaUpdate" isDirectory:YES];
|
|
38
|
+
[[NSFileManager defaultManager] createDirectoryAtURL:[self packagesRoot]
|
|
39
|
+
withIntermediateDirectories:YES
|
|
40
|
+
attributes:nil
|
|
41
|
+
error:nil];
|
|
42
|
+
[self load];
|
|
43
|
+
}
|
|
44
|
+
return self;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
- (NSURL *)rootDirectory {
|
|
48
|
+
return self.rootURL;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
- (NSURL *)packagesRoot {
|
|
52
|
+
return [self.rootURL URLByAppendingPathComponent:@"packages" isDirectory:YES];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
- (NSURL *)statusURL {
|
|
56
|
+
return [self.rootURL URLByAppendingPathComponent:@"status.json"];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#pragma mark - Persistence
|
|
60
|
+
|
|
61
|
+
- (void)load {
|
|
62
|
+
NSData *data = [NSData dataWithContentsOfURL:[self statusURL]];
|
|
63
|
+
if (!data) return;
|
|
64
|
+
|
|
65
|
+
NSError *error = nil;
|
|
66
|
+
NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
|
|
67
|
+
if (![json isKindOfClass:[NSDictionary class]]) {
|
|
68
|
+
// A corrupt status file must never stop the app booting.
|
|
69
|
+
NSLog(@"%@ status.json unreadable, resetting OTA state (%@)", kLogTag, error.localizedDescription);
|
|
70
|
+
[self reset];
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
self.currentHash = [json[@"currentHash"] isKindOfClass:[NSString class]] ? json[@"currentHash"] : nil;
|
|
75
|
+
self.lastConfirmedHash = [json[@"lastConfirmedHash"] isKindOfClass:[NSString class]] ? json[@"lastConfirmedHash"] : nil;
|
|
76
|
+
self.pendingHash = [json[@"pendingHash"] isKindOfClass:[NSString class]] ? json[@"pendingHash"] : nil;
|
|
77
|
+
self.pendingIsLoading = [json[@"pendingIsLoading"] boolValue];
|
|
78
|
+
|
|
79
|
+
NSArray *failed = json[@"failedHashes"];
|
|
80
|
+
if ([failed isKindOfClass:[NSArray class]]) [self.failedHashes addObjectsFromArray:failed];
|
|
81
|
+
|
|
82
|
+
NSDictionary *pkgs = json[@"packages"];
|
|
83
|
+
if ([pkgs isKindOfClass:[NSDictionary class]]) [self.packages addEntriesFromDictionary:pkgs];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
- (void)save {
|
|
87
|
+
NSMutableDictionary *json = [NSMutableDictionary dictionary];
|
|
88
|
+
json[@"currentHash"] = self.currentHash ?: [NSNull null];
|
|
89
|
+
json[@"lastConfirmedHash"] = self.lastConfirmedHash ?: [NSNull null];
|
|
90
|
+
json[@"pendingHash"] = self.pendingHash ?: [NSNull null];
|
|
91
|
+
json[@"pendingIsLoading"] = @(self.pendingIsLoading);
|
|
92
|
+
json[@"failedHashes"] = self.failedHashes.allObjects;
|
|
93
|
+
json[@"packages"] = self.packages;
|
|
94
|
+
|
|
95
|
+
NSError *error = nil;
|
|
96
|
+
NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:&error];
|
|
97
|
+
if (!data) {
|
|
98
|
+
NSLog(@"%@ failed to serialise OTA state: %@", kLogTag, error.localizedDescription);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
[[NSFileManager defaultManager] createDirectoryAtURL:self.rootURL
|
|
102
|
+
withIntermediateDirectories:YES
|
|
103
|
+
attributes:nil
|
|
104
|
+
error:nil];
|
|
105
|
+
[data writeToURL:[self statusURL] atomically:YES];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#pragma mark - Rollback state machine
|
|
109
|
+
|
|
110
|
+
- (BOOL)initializeAfterRestart {
|
|
111
|
+
@synchronized(self) {
|
|
112
|
+
NSString *pending = self.pendingHash;
|
|
113
|
+
if (!pending) return NO;
|
|
114
|
+
|
|
115
|
+
if (self.pendingIsLoading) {
|
|
116
|
+
NSLog(@"%@ update %@ failed to become ready — rolling back", kLogTag, pending);
|
|
117
|
+
[self.failedHashes addObject:pending];
|
|
118
|
+
self.currentHash = self.lastConfirmedHash;
|
|
119
|
+
self.pendingHash = nil;
|
|
120
|
+
self.pendingIsLoading = NO;
|
|
121
|
+
self.isFirstRunOfUpdate = NO;
|
|
122
|
+
[self save];
|
|
123
|
+
[self deletePackage:pending];
|
|
124
|
+
return YES;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
[self promotePendingIfAny];
|
|
128
|
+
return NO;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
- (BOOL)promotePendingIfAny {
|
|
133
|
+
@synchronized(self) {
|
|
134
|
+
NSString *pending = self.pendingHash;
|
|
135
|
+
if (!pending || self.pendingIsLoading) return NO;
|
|
136
|
+
|
|
137
|
+
NSLog(@"%@ booting into pending update %@", kLogTag, pending);
|
|
138
|
+
self.currentHash = pending;
|
|
139
|
+
self.pendingIsLoading = YES;
|
|
140
|
+
self.isFirstRunOfUpdate = YES;
|
|
141
|
+
[self save];
|
|
142
|
+
return YES;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
- (void)notifyApplicationReady {
|
|
147
|
+
@synchronized(self) {
|
|
148
|
+
if (!self.pendingHash) return;
|
|
149
|
+
NSLog(@"%@ update %@ confirmed healthy", kLogTag, self.pendingHash);
|
|
150
|
+
self.lastConfirmedHash = self.currentHash;
|
|
151
|
+
self.pendingHash = nil;
|
|
152
|
+
self.pendingIsLoading = NO;
|
|
153
|
+
[self save];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
- (void)markPending:(NSString *)packageHash {
|
|
158
|
+
@synchronized(self) {
|
|
159
|
+
self.pendingHash = packageHash;
|
|
160
|
+
self.pendingIsLoading = NO;
|
|
161
|
+
[self save];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
- (BOOL)hasFailed:(NSString *)packageHash {
|
|
166
|
+
@synchronized(self) {
|
|
167
|
+
return [self.failedHashes containsObject:packageHash];
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
- (BOOL)isPending {
|
|
172
|
+
@synchronized(self) {
|
|
173
|
+
return self.pendingHash != nil;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#pragma mark - Packages
|
|
178
|
+
|
|
179
|
+
- (NSURL *)packageDirectoryForHash:(NSString *)packageHash {
|
|
180
|
+
return [[self packagesRoot] URLByAppendingPathComponent:packageHash isDirectory:YES];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
- (void)recordPackageWithHash:(NSString *)packageHash
|
|
184
|
+
label:(NSString *)label
|
|
185
|
+
bundlePath:(NSString *)bundlePath
|
|
186
|
+
description:(NSString *)description
|
|
187
|
+
isMandatory:(BOOL)isMandatory
|
|
188
|
+
size:(long long)size
|
|
189
|
+
appVersion:(NSString *)appVersion {
|
|
190
|
+
@synchronized(self) {
|
|
191
|
+
self.packages[packageHash] = @{
|
|
192
|
+
@"label" : label ?: @"",
|
|
193
|
+
@"bundlePath" : bundlePath ?: @"",
|
|
194
|
+
@"description" : description ?: [NSNull null],
|
|
195
|
+
@"isMandatory" : @(isMandatory),
|
|
196
|
+
@"size" : @(size),
|
|
197
|
+
@"appVersion" : appVersion ?: @"",
|
|
198
|
+
@"installedAt" : @([[NSDate date] timeIntervalSince1970] * 1000),
|
|
199
|
+
};
|
|
200
|
+
[self save];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
- (NSDictionary *)packageInfoForHash:(NSString *)packageHash {
|
|
205
|
+
@synchronized(self) {
|
|
206
|
+
return packageHash ? self.packages[packageHash] : nil;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
- (NSString *)currentBundlePath {
|
|
211
|
+
@synchronized(self) {
|
|
212
|
+
if (!self.currentHash) return nil;
|
|
213
|
+
NSDictionary *info = self.packages[self.currentHash];
|
|
214
|
+
if (!info) return nil;
|
|
215
|
+
|
|
216
|
+
NSString *path = info[@"bundlePath"];
|
|
217
|
+
if (path.length > 0 && [[NSFileManager defaultManager] fileExistsAtPath:path]) return path;
|
|
218
|
+
|
|
219
|
+
// The file went missing (device cleanup, restore from backup, ...).
|
|
220
|
+
NSLog(@"%@ bundle for %@ is missing on disk — using the binary bundle", kLogTag, self.currentHash);
|
|
221
|
+
self.currentHash = [self.lastConfirmedHash isEqualToString:self.currentHash] ? nil : self.lastConfirmedHash;
|
|
222
|
+
[self save];
|
|
223
|
+
|
|
224
|
+
NSString *fallback = self.currentHash ? self.packages[self.currentHash][@"bundlePath"] : nil;
|
|
225
|
+
return (fallback.length > 0 && [[NSFileManager defaultManager] fileExistsAtPath:fallback]) ? fallback : nil;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
- (void)deletePackage:(NSString *)packageHash {
|
|
230
|
+
@synchronized(self) {
|
|
231
|
+
[self.packages removeObjectForKey:packageHash];
|
|
232
|
+
[[NSFileManager defaultManager] removeItemAtURL:[self packageDirectoryForHash:packageHash] error:nil];
|
|
233
|
+
[self save];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
- (void)pruneOldPackages {
|
|
238
|
+
@synchronized(self) {
|
|
239
|
+
NSMutableSet *keep = [NSMutableSet set];
|
|
240
|
+
if (self.currentHash) [keep addObject:self.currentHash];
|
|
241
|
+
if (self.lastConfirmedHash) [keep addObject:self.lastConfirmedHash];
|
|
242
|
+
if (self.pendingHash) [keep addObject:self.pendingHash];
|
|
243
|
+
|
|
244
|
+
for (NSString *hash in self.packages.allKeys) {
|
|
245
|
+
if ([keep containsObject:hash]) continue;
|
|
246
|
+
[self.packages removeObjectForKey:hash];
|
|
247
|
+
[[NSFileManager defaultManager] removeItemAtURL:[self packageDirectoryForHash:hash] error:nil];
|
|
248
|
+
}
|
|
249
|
+
[self save];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
- (void)reset {
|
|
254
|
+
@synchronized(self) {
|
|
255
|
+
self.currentHash = nil;
|
|
256
|
+
self.lastConfirmedHash = nil;
|
|
257
|
+
self.pendingHash = nil;
|
|
258
|
+
self.pendingIsLoading = NO;
|
|
259
|
+
self.isFirstRunOfUpdate = NO;
|
|
260
|
+
[self.failedHashes removeAllObjects];
|
|
261
|
+
[self.packages removeAllObjects];
|
|
262
|
+
[[NSFileManager defaultManager] removeItemAtURL:[self packagesRoot] error:nil];
|
|
263
|
+
[[NSFileManager defaultManager] createDirectoryAtURL:[self packagesRoot]
|
|
264
|
+
withIntermediateDirectories:YES
|
|
265
|
+
attributes:nil
|
|
266
|
+
error:nil];
|
|
267
|
+
[self save];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#pragma mark - Device identity
|
|
272
|
+
|
|
273
|
+
- (NSString *)clientUniqueId {
|
|
274
|
+
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
|
|
275
|
+
NSString *existing = [defaults stringForKey:kClientIdKey];
|
|
276
|
+
if (existing.length > 0) return existing;
|
|
277
|
+
|
|
278
|
+
NSString *identifier = [[NSUUID UUID] UUIDString];
|
|
279
|
+
[defaults setObject:identifier forKey:kClientIdKey];
|
|
280
|
+
return identifier;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
@end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { SyncStatus, type CheckResult, type CurrentPackage, type OtaConfiguration, type OtaOptions, type SyncOptions } from './types';
|
|
2
|
+
/** Asks the server whether a newer bundle applies to this device. */
|
|
3
|
+
export declare function checkForUpdate(deploymentKey?: string): Promise<CheckResult>;
|
|
4
|
+
/**
|
|
5
|
+
* Check → download → install, in one call. Safe to call repeatedly; concurrent
|
|
6
|
+
* calls share the in-flight run rather than downloading twice.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sync(options?: SyncOptions): Promise<SyncStatus>;
|
|
9
|
+
/**
|
|
10
|
+
* Confirms the running bundle works. Until this is called, the next app start
|
|
11
|
+
* treats the update as broken and rolls back to the previous bundle.
|
|
12
|
+
*
|
|
13
|
+
* Call it once your app has rendered and its critical startup path has run.
|
|
14
|
+
*/
|
|
15
|
+
export declare function notifyAppReady(): Promise<void>;
|
|
16
|
+
export declare function restartApp(onlyIfUpdateIsPending?: boolean): Promise<void>;
|
|
17
|
+
export declare function getCurrentPackage(): Promise<CurrentPackage | null>;
|
|
18
|
+
/** Wipes every downloaded package and reverts to the bundle inside the binary. */
|
|
19
|
+
export declare function clearUpdates(): Promise<void>;
|
|
20
|
+
export declare function getConfig(): Promise<OtaConfiguration>;
|
|
21
|
+
/**
|
|
22
|
+
* Starts the app-start / app-resume sync loop. `withOtaUpdate` calls this for
|
|
23
|
+
* you; call it directly if you are not using the HOC.
|
|
24
|
+
*/
|
|
25
|
+
export declare function startAutoSync(options?: OtaOptions): () => void;
|
|
26
|
+
/** Test seam — resets memoised configuration and sync state. */
|
|
27
|
+
export declare function __resetForTests(): void;
|
|
28
|
+
//# sourceMappingURL=OtaUpdate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OtaUpdate.d.ts","sourceRoot":"","sources":["../src/OtaUpdate.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,UAAU,EACV,KAAK,WAAW,EAChB,KAAK,cAAc,EAGnB,KAAK,gBAAgB,EACrB,KAAK,UAAU,EAEf,KAAK,WAAW,EACjB,MAAM,SAAS,CAAC;AAmGjB,qEAAqE;AACrE,wBAAsB,cAAc,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAOjF;AAED;;;GAGG;AACH,wBAAsB,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC,CAUzE;AAqDD;;;;;GAKG;AACH,wBAAsB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAcpD;AAED,wBAAsB,UAAU,CAAC,qBAAqB,UAAQ,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7E;AAED,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,cAAc,GAAG,IAAI,CAAC,CAaxE;AAED,kFAAkF;AAClF,wBAAsB,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC,CAElD;AAED,wBAAsB,SAAS,IAAI,OAAO,CAAC,gBAAgB,CAAC,CAE3D;AAMD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE,UAAe,GAAG,MAAM,IAAI,CAoClE;AAED,gEAAgE;AAChE,wBAAgB,eAAe,IAAI,IAAI,CAKtC"}
|