@rovq/react-native 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +76 -2
- package/Rovq.podspec +19 -0
- package/android/build.gradle +86 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
- package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
- package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
- package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
- package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
- package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
- package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
- package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
- package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
- package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
- package/ios/Engine/EngineState.swift +119 -0
- package/ios/Engine/ManifestVerifier.swift +197 -0
- package/ios/Engine/PackageDownloader.swift +231 -0
- package/ios/Engine/PackageRules.swift +38 -0
- package/ios/Engine/ProgressThrottle.swift +20 -0
- package/ios/Engine/RovqEngine.swift +633 -0
- package/ios/Engine/RovqError.swift +35 -0
- package/ios/Engine/SafePackageExtractor.swift +107 -0
- package/ios/Engine/UpdateInstaller.swift +126 -0
- package/ios/Engine/UpdateService.swift +158 -0
- package/ios/Engine/ZipReader.swift +378 -0
- package/ios/React/RovqHost.swift +253 -0
- package/ios/React/RovqModule.mm +89 -0
- package/package.json +74 -3
- package/react-native.config.js +11 -0
- package/src/NativeRovq.ts +72 -0
- package/src/errors.ts +44 -0
- package/src/index.ts +228 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
package com.rovq.engine
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
import java.io.FileOutputStream
|
|
5
|
+
import java.io.IOException
|
|
6
|
+
import java.net.HttpURLConnection
|
|
7
|
+
import java.net.URL
|
|
8
|
+
import java.security.MessageDigest
|
|
9
|
+
|
|
10
|
+
private const val CONNECT_TIMEOUT_MS = 15_000
|
|
11
|
+
private const val READ_TIMEOUT_MS = 30_000
|
|
12
|
+
private const val BUFFER_SIZE = 64 * 1024
|
|
13
|
+
private const val HTTP_RANGE_NOT_SATISFIABLE = 416
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Downloads a package and proves it is the signed one. HTTPS protects the transfer,
|
|
17
|
+
* but the hash is what makes the file trustworthy.
|
|
18
|
+
*
|
|
19
|
+
* @param allowInsecureUrls allows http:// for local development only.
|
|
20
|
+
*/
|
|
21
|
+
class PackageDownloader(
|
|
22
|
+
private val allowInsecureUrls: Boolean = false,
|
|
23
|
+
private val freeSpace: (File) -> Long = File::getUsableSpace,
|
|
24
|
+
) {
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Continues from what [target] already holds, because on a poor connection a large file might
|
|
28
|
+
* otherwise never finish. A network failure keeps the file for that reason. Any other failure
|
|
29
|
+
* deletes it. It counts as good only once the whole file matches the signed hash, so a partial
|
|
30
|
+
* or wrong file can never be mistaken for a good one.
|
|
31
|
+
*
|
|
32
|
+
* @param onProgress receives bytes received so far and the total.
|
|
33
|
+
*/
|
|
34
|
+
fun download(
|
|
35
|
+
url: String,
|
|
36
|
+
manifest: VerifiedManifest,
|
|
37
|
+
target: File,
|
|
38
|
+
onProgress: (received: Long, total: Long) -> Unit = { _, _ -> },
|
|
39
|
+
) {
|
|
40
|
+
try {
|
|
41
|
+
downloadToFile(url, manifest, target, onProgress)
|
|
42
|
+
} catch (error: RovqException) {
|
|
43
|
+
if (error.code != RovqErrorCode.DOWNLOAD_FAILED) target.delete()
|
|
44
|
+
throw error
|
|
45
|
+
} catch (error: IOException) {
|
|
46
|
+
target.delete()
|
|
47
|
+
throw error
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private fun downloadToFile(
|
|
52
|
+
url: String,
|
|
53
|
+
manifest: VerifiedManifest,
|
|
54
|
+
target: File,
|
|
55
|
+
onProgress: (Long, Long) -> Unit,
|
|
56
|
+
) {
|
|
57
|
+
val parsed = try {
|
|
58
|
+
URL(url)
|
|
59
|
+
} catch (error: IOException) {
|
|
60
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "Malformed download URL", error)
|
|
61
|
+
}
|
|
62
|
+
if (parsed.protocol != "https" && !allowInsecureUrls) {
|
|
63
|
+
throw RovqException(RovqErrorCode.INSECURE_URL, "Download URL must use HTTPS")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
67
|
+
val existing = readExisting(target, manifest, digest)
|
|
68
|
+
checkFreeSpace(target, manifest, existing)
|
|
69
|
+
var received = existing
|
|
70
|
+
if (existing == manifest.packageSize) {
|
|
71
|
+
verifyHash(digest, manifest)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
val connection = try {
|
|
76
|
+
parsed.openConnection() as HttpURLConnection
|
|
77
|
+
} catch (error: IOException) {
|
|
78
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "Could not connect", error)
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
|
82
|
+
connection.readTimeout = READ_TIMEOUT_MS
|
|
83
|
+
if (existing > 0) connection.setRequestProperty("Range", "bytes=$existing-")
|
|
84
|
+
val status = try {
|
|
85
|
+
connection.responseCode
|
|
86
|
+
} catch (error: IOException) {
|
|
87
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "Could not connect", error)
|
|
88
|
+
}
|
|
89
|
+
val resuming = existing > 0 && status == HttpURLConnection.HTTP_PARTIAL
|
|
90
|
+
if (resuming && connection.getHeaderField("Content-Range")?.startsWith("bytes $existing-") != true) {
|
|
91
|
+
// Appending the wrong bytes could only fail the hash check later, so start over.
|
|
92
|
+
target.delete()
|
|
93
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The server continued from the wrong place")
|
|
94
|
+
}
|
|
95
|
+
if (!resuming && status != HttpURLConnection.HTTP_OK) {
|
|
96
|
+
if (status == HTTP_RANGE_NOT_SATISFIABLE) target.delete()
|
|
97
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "Server answered $status")
|
|
98
|
+
}
|
|
99
|
+
if (!resuming) {
|
|
100
|
+
// The server sent the whole file, whether or not a part was asked for.
|
|
101
|
+
digest.reset()
|
|
102
|
+
received = 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
val output = try {
|
|
106
|
+
FileOutputStream(target, resuming)
|
|
107
|
+
} catch (error: IOException) {
|
|
108
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the file", error)
|
|
109
|
+
}
|
|
110
|
+
output.use { file ->
|
|
111
|
+
connection.inputStream.use { input ->
|
|
112
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
113
|
+
while (true) {
|
|
114
|
+
val count = try {
|
|
115
|
+
input.read(buffer)
|
|
116
|
+
} catch (error: IOException) {
|
|
117
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "Connection lost", error)
|
|
118
|
+
}
|
|
119
|
+
if (count < 0) break
|
|
120
|
+
received += count
|
|
121
|
+
// Stops at once, so a wrong server cannot fill the disk.
|
|
122
|
+
if (received > manifest.packageSize) {
|
|
123
|
+
throw RovqException(
|
|
124
|
+
RovqErrorCode.SIZE_MISMATCH,
|
|
125
|
+
"Download is larger than the signed size",
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
digest.update(buffer, 0, count)
|
|
129
|
+
try {
|
|
130
|
+
file.write(buffer, 0, count)
|
|
131
|
+
} catch (error: IOException) {
|
|
132
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not write the file", error)
|
|
133
|
+
}
|
|
134
|
+
onProgress(received, manifest.packageSize)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// Forces the bytes to disk, so power loss cannot leave a file that looks complete.
|
|
138
|
+
file.fd.sync()
|
|
139
|
+
}
|
|
140
|
+
} finally {
|
|
141
|
+
connection.disconnect()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// A lost connection can look like a normal end of file, so a short download counts
|
|
145
|
+
// as a network failure, which is worth retrying.
|
|
146
|
+
if (received < manifest.packageSize) {
|
|
147
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The download ended early")
|
|
148
|
+
}
|
|
149
|
+
verifyHash(digest, manifest)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** @return how many bytes are already there, after adding them to [digest]. */
|
|
153
|
+
private fun readExisting(target: File, manifest: VerifiedManifest, digest: MessageDigest): Long {
|
|
154
|
+
if (!target.isFile) return 0
|
|
155
|
+
if (target.length() > manifest.packageSize) {
|
|
156
|
+
target.delete()
|
|
157
|
+
return 0
|
|
158
|
+
}
|
|
159
|
+
target.inputStream().use { input ->
|
|
160
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
161
|
+
while (true) {
|
|
162
|
+
val count = input.read(buffer)
|
|
163
|
+
if (count < 0) break
|
|
164
|
+
digest.update(buffer, 0, count)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return target.length()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Extracting needs room too, and its size is unknown until then, so this asks for the package
|
|
172
|
+
* twice. A package can still expand further, and extraction reports that if it happens.
|
|
173
|
+
*/
|
|
174
|
+
private fun checkFreeSpace(target: File, manifest: VerifiedManifest, existing: Long) {
|
|
175
|
+
val needed = manifest.packageSize - existing + manifest.packageSize
|
|
176
|
+
val folder = target.parentFile ?: target
|
|
177
|
+
if (freeSpace(folder) < needed) {
|
|
178
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Not enough free space for this update")
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private fun verifyHash(digest: MessageDigest, manifest: VerifiedManifest) {
|
|
183
|
+
val hash = digest.digest().joinToString("") { "%02x".format(it) }
|
|
184
|
+
if (hash != manifest.packageHash) {
|
|
185
|
+
throw RovqException(RovqErrorCode.HASH_MISMATCH, "Download does not match the signed hash")
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
package com.rovq.engine
|
|
2
|
+
|
|
3
|
+
import java.text.Normalizer
|
|
4
|
+
import java.util.Locale
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The same rules as packages/package-format/src/format.ts. The file
|
|
8
|
+
* packages/package-format/test/path-rules.json is checked against both, so a change
|
|
9
|
+
* on one side fails the tests until the other side matches.
|
|
10
|
+
*/
|
|
11
|
+
internal object PackageRules {
|
|
12
|
+
const val BUNDLE_FILE_NAME = "index.android.bundle"
|
|
13
|
+
const val MAX_ENTRIES = 10_000
|
|
14
|
+
const val MAX_TOTAL_SIZE = 2L * 1024 * 1024 * 1024
|
|
15
|
+
private const val MAX_PATH_LENGTH = 512
|
|
16
|
+
private val ASSET_FOLDER = Regex("^(drawable(-[a-z0-9]+)*|raw)$")
|
|
17
|
+
private val ABSOLUTE_DRIVE = Regex("^[A-Za-z]:.*")
|
|
18
|
+
|
|
19
|
+
/** Returns why a path is not allowed, or null if it is fine. */
|
|
20
|
+
fun findPathProblem(path: String): String? {
|
|
21
|
+
if (path.isEmpty() || path.length > MAX_PATH_LENGTH) return "has an invalid length"
|
|
22
|
+
if (path.any { it.code <= 0x1f || it.code == 0x7f }) return "contains a control character"
|
|
23
|
+
if ('\\' in path) return "contains a backslash"
|
|
24
|
+
if (path.startsWith("/") || ABSOLUTE_DRIVE.matches(path)) return "is an absolute path"
|
|
25
|
+
|
|
26
|
+
val segments = path.split("/")
|
|
27
|
+
if (segments.any { it.isEmpty() || it == "." || it == ".." }) {
|
|
28
|
+
return "contains an empty, \".\" or \"..\" segment"
|
|
29
|
+
}
|
|
30
|
+
if (path == BUNDLE_FILE_NAME) return null
|
|
31
|
+
if (segments.size < 2) return "is not the bundle file"
|
|
32
|
+
if (segments.size != 2) return "is nested too deeply for an Android resource folder"
|
|
33
|
+
return if (ASSET_FOLDER.matches(segments[0])) null else "is outside the Android resource folders"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The same name in another letter case or Unicode form is one file on some file systems. */
|
|
37
|
+
fun pathIdentity(path: String): String =
|
|
38
|
+
Normalizer.normalize(path, Normalizer.Form.NFC).lowercase(Locale.ROOT)
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.rovq.engine
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Passes on progress only when the whole percentage changes. A download reports every chunk,
|
|
5
|
+
* which is thousands of times for a large file, and each one sent to JavaScript has a cost.
|
|
6
|
+
*/
|
|
7
|
+
class ProgressThrottle(private val onChange: (received: Long, total: Long) -> Unit) {
|
|
8
|
+
private var lastPercent = -1L
|
|
9
|
+
|
|
10
|
+
fun report(received: Long, total: Long) {
|
|
11
|
+
val percent = received * 100 / total
|
|
12
|
+
if (percent != lastPercent) {
|
|
13
|
+
lastPercent = percent
|
|
14
|
+
onChange(received, total)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|