@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.
- package/LICENSE +21 -0
- package/README.md +235 -3
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +10 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTA.kt +33 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTAModule.kt +105 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTAPackage.kt +13 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTARestartActivity.kt +28 -0
- package/android/src/main/java/dev/fireota/reactnative/FireOTAStore.kt +207 -0
- package/fireota-react-native.podspec +19 -0
- package/ios/FireOTA.h +20 -0
- package/ios/FireOTA.m +346 -0
- package/package.json +48 -4
- package/src/index.ts +265 -0
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|