@fireota/react-native 0.0.0-stage → 0.1.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.
@@ -0,0 +1,207 @@
1
+ package dev.fireota.reactnative
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+ import android.content.pm.PackageManager
6
+ import android.os.Build
7
+ import java.io.DataInputStream
8
+ import java.io.File
9
+ import java.io.FileInputStream
10
+ import java.io.FileOutputStream
11
+ import java.security.MessageDigest
12
+ import java.util.UUID
13
+ import java.util.zip.GZIPInputStream
14
+ import org.json.JSONObject
15
+
16
+ /**
17
+ * On-device OTA state. Bundles live in app-private storage:
18
+ * filesDir/fireota/releases/<releaseId>/index.android.bundle (+ drawable-* assets)
19
+ *
20
+ * Launch rules (resolveLaunchBundle, once per process):
21
+ * - a new native binary (store update / reinstall) discards every OTA bundle;
22
+ * - a pending release is promoted to current and must confirm (markSuccess) during this launch;
23
+ * - a current release that reaches a second launch unconfirmed is rolled back to the previous one.
24
+ */
25
+ internal class FireOTAStore(private val context: Context) {
26
+ private val prefs: SharedPreferences = context.getSharedPreferences("dev.fireota", Context.MODE_PRIVATE)
27
+ private val root = File(context.filesDir, "fireota/releases")
28
+
29
+ fun releaseDir(releaseId: String) = File(root, safe(releaseId))
30
+ fun bundleFile(releaseId: String) = File(releaseDir(releaseId), BUNDLE_NAME)
31
+
32
+ val installationId: String
33
+ get() = prefs.getString(KEY_INSTALLATION, null) ?: UUID.randomUUID().toString().also {
34
+ prefs.edit().putString(KEY_INSTALLATION, it).apply()
35
+ }
36
+
37
+ fun get(key: String): String? = prefs.getString(key, null)
38
+ fun isUnconfirmed() = prefs.getBoolean(KEY_UNCONFIRMED, false)
39
+
40
+ @Synchronized
41
+ fun resolveLaunchBundle(): String? {
42
+ val binary = binaryVersion()
43
+ if (prefs.getString(KEY_BINARY, null) != binary) {
44
+ root.deleteRecursively()
45
+ prefs.edit()
46
+ .remove(KEY_CURRENT).remove(KEY_PREVIOUS).remove(KEY_PENDING).remove(KEY_DOWNLOADED)
47
+ .putBoolean(KEY_UNCONFIRMED, false).putInt(KEY_ATTEMPTS, 0)
48
+ .putString(KEY_BINARY, binary)
49
+ .commit()
50
+ }
51
+
52
+ val edit = prefs.edit()
53
+ val pending = prefs.getString(KEY_PENDING, null)
54
+ if (pending != null && bundleFile(pending).exists()) {
55
+ edit.putString(KEY_PREVIOUS, prefs.getString(KEY_CURRENT, null))
56
+ .putString(KEY_CURRENT, pending)
57
+ .putBoolean(KEY_UNCONFIRMED, true)
58
+ .putInt(KEY_ATTEMPTS, 0)
59
+ }
60
+ edit.remove(KEY_PENDING).commit()
61
+
62
+ if (prefs.getBoolean(KEY_UNCONFIRMED, false)) {
63
+ val attempts = prefs.getInt(KEY_ATTEMPTS, 0) + 1
64
+ if (attempts >= 2) {
65
+ val failed = prefs.getString(KEY_CURRENT, null)
66
+ prefs.edit()
67
+ .putString(KEY_FAILED, failed)
68
+ .putString(KEY_CURRENT, prefs.getString(KEY_PREVIOUS, null))
69
+ .remove(KEY_PREVIOUS)
70
+ .putBoolean(KEY_UNCONFIRMED, false)
71
+ .putInt(KEY_ATTEMPTS, 0)
72
+ .commit()
73
+ failed?.let { releaseDir(it).deleteRecursively() }
74
+ } else {
75
+ prefs.edit().putInt(KEY_ATTEMPTS, attempts).commit()
76
+ }
77
+ }
78
+
79
+ val current = prefs.getString(KEY_CURRENT, null) ?: return null
80
+ val file = bundleFile(current)
81
+ if (!file.exists()) {
82
+ prefs.edit().remove(KEY_CURRENT).putBoolean(KEY_UNCONFIRMED, false).commit()
83
+ return null
84
+ }
85
+ return file.absolutePath
86
+ }
87
+
88
+ fun markSuccess() {
89
+ prefs.edit().putBoolean(KEY_UNCONFIRMED, false).putInt(KEY_ATTEMPTS, 0).commit()
90
+ val keep = setOf(get(KEY_CURRENT), get(KEY_PREVIOUS), get(KEY_PENDING), get(KEY_DOWNLOADED)).filterNotNull().map(::safe)
91
+ root.listFiles()?.filter { it.name !in keep }?.forEach { it.deleteRecursively() }
92
+ }
93
+
94
+ fun setPending(releaseId: String) {
95
+ require(bundleFile(releaseId).exists()) { "Release $releaseId has not been downloaded." }
96
+ prefs.edit().putString(KEY_PENDING, releaseId).remove(KEY_DOWNLOADED).commit()
97
+ }
98
+
99
+ fun clear(key: String) = prefs.edit().remove(key).commit()
100
+
101
+ /** Streams the package to disk, verifies its SHA-256, then unpacks it atomically. */
102
+ fun download(url: String, expectedSha256: String, releaseId: String) {
103
+ require(url.startsWith("https://")) { "Updates must be downloaded over HTTPS." }
104
+ root.mkdirs()
105
+ val tmp = File(root, ".${safe(releaseId)}.download")
106
+ val connection = java.net.URL(url).openConnection() as java.net.HttpURLConnection
107
+ connection.connectTimeout = 15_000
108
+ connection.readTimeout = 60_000
109
+ connection.instanceFollowRedirects = true
110
+ try {
111
+ if (connection.responseCode !in 200..299) throw IllegalStateException("Download failed: HTTP ${connection.responseCode}")
112
+ val digest = MessageDigest.getInstance("SHA-256")
113
+ connection.inputStream.use { input ->
114
+ FileOutputStream(tmp).use { output ->
115
+ val buffer = ByteArray(64 * 1024)
116
+ while (true) {
117
+ val read = input.read(buffer)
118
+ if (read < 0) break
119
+ digest.update(buffer, 0, read)
120
+ output.write(buffer, 0, read)
121
+ }
122
+ }
123
+ }
124
+ val actual = digest.digest().joinToString("") { "%02x".format(it) }
125
+ if (!actual.equals(expectedSha256, ignoreCase = true)) throw SecurityException("Checksum mismatch: the update was rejected.")
126
+
127
+ val staging = File(root, ".${safe(releaseId)}.staging")
128
+ staging.deleteRecursively()
129
+ unpack(tmp, staging)
130
+ if (!File(staging, BUNDLE_NAME).exists()) throw IllegalStateException("The update package has no $BUNDLE_NAME.")
131
+ val target = releaseDir(releaseId)
132
+ target.deleteRecursively()
133
+ if (!staging.renameTo(target)) throw IllegalStateException("Could not store the update.")
134
+ prefs.edit().putString(KEY_DOWNLOADED, releaseId).commit()
135
+ } finally {
136
+ connection.disconnect()
137
+ tmp.delete()
138
+ }
139
+ }
140
+
141
+ /**
142
+ * FireOTA package: gzip( "FOTA1\n" | u32 header length | JSON {files:[{path,size}]} | file bytes... ).
143
+ * Paths are validated so a package can never write outside its release directory.
144
+ */
145
+ private fun unpack(archive: File, into: File) {
146
+ DataInputStream(GZIPInputStream(FileInputStream(archive), 64 * 1024)).use { input ->
147
+ val magic = ByteArray(6)
148
+ input.readFully(magic)
149
+ if (String(magic, Charsets.US_ASCII) != "FOTA1\n") throw IllegalStateException("Not a FireOTA package.")
150
+ val headerLength = input.readInt()
151
+ if (headerLength <= 0 || headerLength > 4 * 1024 * 1024) throw IllegalStateException("Invalid package header.")
152
+ val header = ByteArray(headerLength)
153
+ input.readFully(header)
154
+ val files = JSONObject(String(header, Charsets.UTF_8)).getJSONArray("files")
155
+ val base = into.canonicalFile
156
+ for (i in 0 until files.length()) {
157
+ val entry = files.getJSONObject(i)
158
+ val path = entry.getString("path")
159
+ val size = entry.getLong("size")
160
+ val out = File(base, path).canonicalFile
161
+ if (path.startsWith("/") || path.contains("..") || !out.path.startsWith(base.path + File.separator)) {
162
+ throw SecurityException("Unsafe path in update package: $path")
163
+ }
164
+ out.parentFile?.mkdirs()
165
+ FileOutputStream(out).use { output ->
166
+ var remaining = size
167
+ val buffer = ByteArray(64 * 1024)
168
+ while (remaining > 0) {
169
+ val read = input.read(buffer, 0, minOf(buffer.size.toLong(), remaining).toInt())
170
+ if (read < 0) throw IllegalStateException("Truncated update package.")
171
+ output.write(buffer, 0, read)
172
+ remaining -= read
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+
179
+ fun nativeVersion(): String = packageInfo()?.versionName ?: ""
180
+
181
+ private fun binaryVersion(): String {
182
+ val info = packageInfo() ?: return ""
183
+ val code = if (Build.VERSION.SDK_INT >= 28) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode.toLong()
184
+ return "${info.versionName}-$code-${info.lastUpdateTime}"
185
+ }
186
+
187
+ private fun packageInfo() = try {
188
+ context.packageManager.getPackageInfo(context.packageName, 0)
189
+ } catch (_: PackageManager.NameNotFoundException) {
190
+ null
191
+ }
192
+
193
+ private fun safe(id: String) = id.replace(Regex("[^A-Za-z0-9_-]"), "")
194
+
195
+ companion object {
196
+ const val BUNDLE_NAME = "index.android.bundle"
197
+ const val KEY_INSTALLATION = "installationId"
198
+ const val KEY_BINARY = "binary"
199
+ const val KEY_CURRENT = "current"
200
+ const val KEY_PREVIOUS = "previous"
201
+ const val KEY_PENDING = "pending"
202
+ const val KEY_DOWNLOADED = "downloaded"
203
+ const val KEY_FAILED = "failed"
204
+ const val KEY_UNCONFIRMED = "unconfirmed"
205
+ const val KEY_ATTEMPTS = "attempts"
206
+ }
207
+ }
@@ -0,0 +1,19 @@
1
+ require "json"
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = "fireota-react-native"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"] || "https://www.npmjs.com/package/@fireota/react-native"
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+ s.platforms = { :ios => "13.4" }
13
+ # Installed from node_modules by React Native autolinking; the git source is only used for trunk.
14
+ repo = (package["repository"].is_a?(Hash) ? package["repository"]["url"] : package["repository"]).to_s.sub(/^git\+/, "")
15
+ s.source = { :git => repo, :tag => "v#{s.version}" }
16
+ s.source_files = "ios/**/*.{h,m}"
17
+ s.libraries = "z"
18
+ s.dependency "React-Core"
19
+ end
package/ios/FireOTA.h ADDED
@@ -0,0 +1,20 @@
1
+ #import <Foundation/Foundation.h>
2
+ #import <React/RCTBridgeModule.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+
6
+ /**
7
+ * FireOTA native module. In AppDelegate (release builds):
8
+ *
9
+ * - (NSURL *)bundleURL {
10
+ * return [FireOTA bundleURL] ?: [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
11
+ * }
12
+ */
13
+ @interface FireOTA : NSObject <RCTBridgeModule>
14
+
15
+ /// The OTA bundle to load, or nil to use the bundle shipped in the app binary.
16
+ + (nullable NSURL *)bundleURL;
17
+
18
+ @end
19
+
20
+ NS_ASSUME_NONNULL_END
package/ios/FireOTA.m ADDED
@@ -0,0 +1,346 @@
1
+ #import "FireOTA.h"
2
+
3
+ #import <CommonCrypto/CommonDigest.h>
4
+ #import <React/RCTReloadCommand.h>
5
+ #import <zlib.h>
6
+
7
+ static NSString *const kBundleName = @"main.jsbundle";
8
+ static NSString *const kSuite = @"dev.fireota";
9
+ static NSString *const kInstallation = @"installationId";
10
+ static NSString *const kBinary = @"binary";
11
+ static NSString *const kCurrent = @"current";
12
+ static NSString *const kPrevious = @"previous";
13
+ static NSString *const kPending = @"pending";
14
+ static NSString *const kDownloaded = @"downloaded";
15
+ static NSString *const kFailed = @"failed";
16
+ static NSString *const kUnconfirmed = @"unconfirmed";
17
+ static NSString *const kAttempts = @"attempts";
18
+
19
+ static NSUserDefaults *FOTADefaults(void) { return [[NSUserDefaults alloc] initWithSuiteName:kSuite]; }
20
+
21
+ static NSString *FOTASafe(NSString *value)
22
+ {
23
+ NSCharacterSet *allowed = [NSCharacterSet characterSetWithCharactersInString:
24
+ @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"];
25
+ return [[value componentsSeparatedByCharactersInSet:allowed.invertedSet] componentsJoinedByString:@""];
26
+ }
27
+
28
+ static NSURL *FOTARoot(void)
29
+ {
30
+ NSURL *support = [[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory
31
+ inDomains:NSUserDomainMask].firstObject;
32
+ NSURL *root = [support URLByAppendingPathComponent:@"fireota/releases" isDirectory:YES];
33
+ [[NSFileManager defaultManager] createDirectoryAtURL:root withIntermediateDirectories:YES attributes:nil error:nil];
34
+ [root setResourceValue:@YES forKey:NSURLIsExcludedFromBackupKey error:nil];
35
+ return root;
36
+ }
37
+
38
+ static NSURL *FOTAReleaseDir(NSString *releaseId)
39
+ {
40
+ return [FOTARoot() URLByAppendingPathComponent:FOTASafe(releaseId) isDirectory:YES];
41
+ }
42
+
43
+ static NSURL *FOTABundleFile(NSString *releaseId)
44
+ {
45
+ return [FOTAReleaseDir(releaseId) URLByAppendingPathComponent:kBundleName];
46
+ }
47
+
48
+ /// Changes on every App Store / TestFlight install, which invalidates bundles built for the old binary.
49
+ static NSString *FOTABinaryVersion(void)
50
+ {
51
+ NSDictionary *info = [NSBundle mainBundle].infoDictionary;
52
+ NSDictionary *attrs = [[NSFileManager defaultManager] attributesOfItemAtPath:[NSBundle mainBundle].executablePath error:nil];
53
+ return [NSString stringWithFormat:@"%@-%@-%.0f", info[@"CFBundleShortVersionString"] ?: @"", info[@"CFBundleVersion"] ?: @"",
54
+ [attrs.fileModificationDate timeIntervalSince1970]];
55
+ }
56
+
57
+ static BOOL FOTAExists(NSURL *url) { return [[NSFileManager defaultManager] fileExistsAtPath:url.path]; }
58
+
59
+ static BOOL FOTARead(gzFile gz, void *into, unsigned length)
60
+ {
61
+ unsigned done = 0;
62
+ while (done < length) {
63
+ int n = gzread(gz, (char *)into + done, length - done);
64
+ if (n <= 0) return NO;
65
+ done += (unsigned)n;
66
+ }
67
+ return YES;
68
+ }
69
+
70
+ @implementation FireOTA
71
+
72
+ RCT_EXPORT_MODULE(FireOTA)
73
+
74
+ + (BOOL)requiresMainQueueSetup { return NO; }
75
+
76
+ /// Same launch rules as Android: a new binary clears OTA state, a pending release is promoted and
77
+ /// must confirm, and a release reaching a second launch unconfirmed is rolled back.
78
+ + (nullable NSURL *)bundleURL
79
+ {
80
+ static NSURL *resolved = nil;
81
+ static BOOL didResolve = NO;
82
+ @synchronized(self) {
83
+ NSUserDefaults *d = FOTADefaults();
84
+ BOOL hasPending = [d stringForKey:kPending] != nil;
85
+ if (didResolve && !hasPending) return resolved; // once per launch, unless a reload applies an update
86
+
87
+ NSString *binary = FOTABinaryVersion();
88
+ if (![[d stringForKey:kBinary] isEqualToString:binary]) {
89
+ [[NSFileManager defaultManager] removeItemAtURL:FOTARoot() error:nil];
90
+ for (NSString *key in @[ kCurrent, kPrevious, kPending, kDownloaded ]) [d removeObjectForKey:key];
91
+ [d setBool:NO forKey:kUnconfirmed];
92
+ [d setInteger:0 forKey:kAttempts];
93
+ [d setObject:binary forKey:kBinary];
94
+ }
95
+
96
+ NSString *pending = [d stringForKey:kPending];
97
+ if (pending && FOTAExists(FOTABundleFile(pending))) {
98
+ NSString *current = [d stringForKey:kCurrent];
99
+ if (current) [d setObject:current forKey:kPrevious]; else [d removeObjectForKey:kPrevious];
100
+ [d setObject:pending forKey:kCurrent];
101
+ [d setBool:YES forKey:kUnconfirmed];
102
+ [d setInteger:0 forKey:kAttempts];
103
+ }
104
+ [d removeObjectForKey:kPending];
105
+
106
+ if ([d boolForKey:kUnconfirmed]) {
107
+ NSInteger attempts = [d integerForKey:kAttempts] + 1;
108
+ if (attempts >= 2) {
109
+ NSString *failed = [d stringForKey:kCurrent];
110
+ if (failed) {
111
+ [d setObject:failed forKey:kFailed];
112
+ [[NSFileManager defaultManager] removeItemAtURL:FOTAReleaseDir(failed) error:nil];
113
+ }
114
+ NSString *previous = [d stringForKey:kPrevious];
115
+ if (previous) [d setObject:previous forKey:kCurrent]; else [d removeObjectForKey:kCurrent];
116
+ [d removeObjectForKey:kPrevious];
117
+ [d setBool:NO forKey:kUnconfirmed];
118
+ [d setInteger:0 forKey:kAttempts];
119
+ } else {
120
+ [d setInteger:attempts forKey:kAttempts];
121
+ }
122
+ }
123
+
124
+ resolved = nil;
125
+ NSString *current = [d stringForKey:kCurrent];
126
+ if (current) {
127
+ NSURL *file = FOTABundleFile(current);
128
+ if (FOTAExists(file)) {
129
+ resolved = file;
130
+ } else {
131
+ [d removeObjectForKey:kCurrent];
132
+ [d setBool:NO forKey:kUnconfirmed];
133
+ }
134
+ }
135
+ didResolve = YES;
136
+ return resolved;
137
+ }
138
+ }
139
+
140
+ RCT_EXPORT_METHOD(getState:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
141
+ {
142
+ NSUserDefaults *d = FOTADefaults();
143
+ NSString *installation = [d stringForKey:kInstallation];
144
+ if (!installation) {
145
+ installation = [NSUUID UUID].UUIDString.lowercaseString;
146
+ [d setObject:installation forKey:kInstallation];
147
+ }
148
+ NSString *version = [NSBundle mainBundle].infoDictionary[@"CFBundleShortVersionString"] ?: @"";
149
+ resolve(@{
150
+ @"installationId" : installation,
151
+ @"nativeVersion" : version,
152
+ @"appVersion" : version,
153
+ @"currentReleaseId" : [d stringForKey:kCurrent] ?: [NSNull null],
154
+ @"pendingReleaseId" : [d stringForKey:kPending] ?: [NSNull null],
155
+ @"previousReleaseId" : [d stringForKey:kPrevious] ?: [NSNull null],
156
+ @"failedReleaseId" : [d stringForKey:kFailed] ?: [NSNull null],
157
+ @"isFirstRun" : @([d boolForKey:kUnconfirmed]),
158
+ });
159
+ }
160
+
161
+ RCT_EXPORT_METHOD(downloadUpdate:(NSString *)urlString
162
+ sha256:(NSString *)expected
163
+ releaseId:(NSString *)releaseId
164
+ resolve:(RCTPromiseResolveBlock)resolve
165
+ reject:(RCTPromiseRejectBlock)reject)
166
+ {
167
+ NSURL *url = [NSURL URLWithString:urlString];
168
+ if (!url || ![url.scheme isEqualToString:@"https"]) {
169
+ reject(@"FIREOTA_DOWNLOAD", @"Updates must be downloaded over HTTPS.", nil);
170
+ return;
171
+ }
172
+ NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
173
+ config.timeoutIntervalForRequest = 60;
174
+ NSURLSession *session = [NSURLSession sessionWithConfiguration:config];
175
+ [[session downloadTaskWithURL:url
176
+ completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
177
+ NSInteger status =
178
+ [response isKindOfClass:[NSHTTPURLResponse class]] ? ((NSHTTPURLResponse *)response).statusCode : 0;
179
+ if (error || !location || status < 200 || status > 299) {
180
+ reject(@"FIREOTA_DOWNLOAD", [NSString stringWithFormat:@"Download failed (HTTP %ld).", (long)status], error);
181
+ return;
182
+ }
183
+ if (![[FireOTA sha256OfFile:location] isEqualToString:expected.lowercaseString]) {
184
+ reject(@"FIREOTA_CHECKSUM", @"Checksum mismatch: the update was rejected.", nil);
185
+ return;
186
+ }
187
+ NSFileManager *fm = [NSFileManager defaultManager];
188
+ NSURL *staging =
189
+ [FOTARoot() URLByAppendingPathComponent:[NSString stringWithFormat:@".%@.staging", FOTASafe(releaseId)]];
190
+ [fm removeItemAtURL:staging error:nil];
191
+ NSString *problem = [FireOTA unpack:location into:staging];
192
+ if (!problem && !FOTAExists([staging URLByAppendingPathComponent:kBundleName])) {
193
+ problem = @"The update package has no main.jsbundle.";
194
+ }
195
+ if (problem) {
196
+ [fm removeItemAtURL:staging error:nil];
197
+ reject(@"FIREOTA_DOWNLOAD", problem, nil);
198
+ return;
199
+ }
200
+ NSURL *target = FOTAReleaseDir(releaseId);
201
+ [fm removeItemAtURL:target error:nil];
202
+ NSError *moveError = nil;
203
+ if (![fm moveItemAtURL:staging toURL:target error:&moveError]) {
204
+ reject(@"FIREOTA_DOWNLOAD", @"Could not store the update.", moveError);
205
+ return;
206
+ }
207
+ [FOTADefaults() setObject:releaseId forKey:kDownloaded];
208
+ resolve(nil);
209
+ }] resume];
210
+ }
211
+
212
+ RCT_EXPORT_METHOD(installUpdate:(NSString *)releaseId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
213
+ {
214
+ if (!FOTAExists(FOTABundleFile(releaseId))) {
215
+ reject(@"FIREOTA_INSTALL", @"This release has not been downloaded.", nil);
216
+ return;
217
+ }
218
+ NSUserDefaults *d = FOTADefaults();
219
+ [d setObject:releaseId forKey:kPending];
220
+ [d removeObjectForKey:kDownloaded];
221
+ resolve(nil);
222
+ }
223
+
224
+ RCT_EXPORT_METHOD(markSuccess:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
225
+ {
226
+ NSUserDefaults *d = FOTADefaults();
227
+ [d setBool:NO forKey:kUnconfirmed];
228
+ [d setInteger:0 forKey:kAttempts];
229
+ NSMutableSet *keep = [NSMutableSet set];
230
+ for (NSString *key in @[ kCurrent, kPrevious, kPending, kDownloaded ]) {
231
+ NSString *value = [d stringForKey:key];
232
+ if (value) [keep addObject:FOTASafe(value)];
233
+ }
234
+ NSFileManager *fm = [NSFileManager defaultManager];
235
+ for (NSURL *dir in [fm contentsOfDirectoryAtURL:FOTARoot() includingPropertiesForKeys:nil options:0 error:nil]) {
236
+ if (![keep containsObject:dir.lastPathComponent]) [fm removeItemAtURL:dir error:nil];
237
+ }
238
+ resolve(nil);
239
+ }
240
+
241
+ RCT_EXPORT_METHOD(clearPending:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
242
+ {
243
+ [FOTADefaults() removeObjectForKey:kPending];
244
+ [FOTADefaults() removeObjectForKey:kDownloaded];
245
+ resolve(nil);
246
+ }
247
+
248
+ RCT_EXPORT_METHOD(clearFailed:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
249
+ {
250
+ [FOTADefaults() removeObjectForKey:kFailed];
251
+ resolve(nil);
252
+ }
253
+
254
+ /// Reloads JavaScript; React Native asks AppDelegate.bundleURL again, which applies the pending release.
255
+ RCT_EXPORT_METHOD(restartApp:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
256
+ {
257
+ resolve(nil);
258
+ dispatch_async(dispatch_get_main_queue(), ^{
259
+ RCTTriggerReloadCommandListeners(@"FireOTA update");
260
+ });
261
+ }
262
+
263
+ + (NSString *)sha256OfFile:(NSURL *)url
264
+ {
265
+ NSInputStream *stream = [NSInputStream inputStreamWithURL:url];
266
+ [stream open];
267
+ CC_SHA256_CTX ctx;
268
+ CC_SHA256_Init(&ctx);
269
+ uint8_t buffer[65536];
270
+ NSInteger read;
271
+ while ((read = [stream read:buffer maxLength:sizeof(buffer)]) > 0) CC_SHA256_Update(&ctx, buffer, (CC_LONG)read);
272
+ [stream close];
273
+ unsigned char digest[CC_SHA256_DIGEST_LENGTH];
274
+ CC_SHA256_Final(digest, &ctx);
275
+ NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
276
+ for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", digest[i]];
277
+ return hex;
278
+ }
279
+
280
+ /// FireOTA package: gzip("FOTA1\n" | u32 big-endian header length | JSON {files:[{path,size}]} | bytes).
281
+ /// Paths are validated so a package can never write outside its release directory.
282
+ + (nullable NSString *)unpack:(NSURL *)archive into:(NSURL *)dir
283
+ {
284
+ gzFile gz = gzopen(archive.fileSystemRepresentation, "rb");
285
+ if (!gz) return @"Could not open the update package.";
286
+ char magic[6];
287
+ uint8_t lengthBytes[4];
288
+ if (!FOTARead(gz, magic, 6) || memcmp(magic, "FOTA1\n", 6) != 0 || !FOTARead(gz, lengthBytes, 4)) {
289
+ gzclose(gz);
290
+ return @"Not a FireOTA package.";
291
+ }
292
+ uint32_t headerLength = ((uint32_t)lengthBytes[0] << 24) | ((uint32_t)lengthBytes[1] << 16) |
293
+ ((uint32_t)lengthBytes[2] << 8) | lengthBytes[3];
294
+ if (headerLength == 0 || headerLength > 4 * 1024 * 1024) {
295
+ gzclose(gz);
296
+ return @"Invalid package header.";
297
+ }
298
+ NSMutableData *header = [NSMutableData dataWithLength:headerLength];
299
+ if (!FOTARead(gz, header.mutableBytes, headerLength)) {
300
+ gzclose(gz);
301
+ return @"Truncated update package.";
302
+ }
303
+ id json = [NSJSONSerialization JSONObjectWithData:header options:0 error:nil];
304
+ NSArray *files = [json isKindOfClass:[NSDictionary class]] ? json[@"files"] : nil;
305
+ if (![files isKindOfClass:[NSArray class]]) {
306
+ gzclose(gz);
307
+ return @"Invalid package header.";
308
+ }
309
+
310
+ NSString *problem = nil;
311
+ NSFileManager *fm = [NSFileManager defaultManager];
312
+ NSString *base = [dir.path stringByStandardizingPath];
313
+ [fm createDirectoryAtURL:dir withIntermediateDirectories:YES attributes:nil error:nil];
314
+ uint8_t buffer[65536];
315
+ for (NSDictionary *entry in files) {
316
+ NSString *path = entry[@"path"];
317
+ if (![path isKindOfClass:[NSString class]]) {
318
+ problem = @"Invalid package header.";
319
+ break;
320
+ }
321
+ NSString *out = [[base stringByAppendingPathComponent:path] stringByStandardizingPath];
322
+ if ([path hasPrefix:@"/"] || [path containsString:@".."] || ![out hasPrefix:[base stringByAppendingString:@"/"]]) {
323
+ problem = [NSString stringWithFormat:@"Unsafe path in update package: %@", path];
324
+ break;
325
+ }
326
+ [fm createDirectoryAtPath:[out stringByDeletingLastPathComponent] withIntermediateDirectories:YES attributes:nil error:nil];
327
+ [fm createFileAtPath:out contents:nil attributes:nil];
328
+ NSFileHandle *handle = [NSFileHandle fileHandleForWritingAtPath:out];
329
+ unsigned long long remaining = [entry[@"size"] unsignedLongLongValue];
330
+ while (remaining > 0) {
331
+ unsigned chunk = (unsigned)MIN((unsigned long long)sizeof(buffer), remaining);
332
+ if (!FOTARead(gz, buffer, chunk)) {
333
+ problem = @"Truncated update package.";
334
+ break;
335
+ }
336
+ [handle writeData:[NSData dataWithBytes:buffer length:chunk]];
337
+ remaining -= chunk;
338
+ }
339
+ [handle closeFile];
340
+ if (problem) break;
341
+ }
342
+ gzclose(gz);
343
+ return problem;
344
+ }
345
+
346
+ @end
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@fireota/react-native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Over-the-air JavaScript updates for React Native apps, served from your own Firebase project by FireOTA.",
5
+ "license": "MIT",
6
+ "author": "FireOTA",
7
+ "main": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "react-native": "src/index.ts",
10
+ "files": [
11
+ "src",
12
+ "android/build.gradle",
13
+ "android/src",
14
+ "ios",
15
+ "fireota-react-native.podspec",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "typecheck": "tsc -p tsconfig.json --noEmit"
21
+ },
22
+ "peerDependencies": {
23
+ "react-native": ">=0.73.0"
24
+ },
25
+ "dependencies": {
26
+ "@fireota/react-native": "file:fireota-react-native-0.1.0.tgz"
27
+ },
28
+ "keywords": [
29
+ "react-native",
30
+ "ota",
31
+ "over-the-air",
32
+ "code-push",
33
+ "hot-update",
34
+ "firebase",
35
+ "updates",
36
+ "hermes"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Sujay-Dynamisch/FireOTA.git",
44
+ "directory": "packages/react-native"
45
+ },
46
+ "homepage": "https://github.com/Sujay-Dynamisch/FireOTA/tree/main/packages/react-native#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/Sujay-Dynamisch/FireOTA/issues"
49
+ }
50
+ }