@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +76 -2
  3. package/Rovq.podspec +19 -0
  4. package/android/build.gradle +86 -0
  5. package/android/src/main/AndroidManifest.xml +4 -0
  6. package/android/src/main/java/com/rovq/engine/EngineState.kt +176 -0
  7. package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +173 -0
  8. package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +188 -0
  9. package/android/src/main/java/com/rovq/engine/PackageRules.kt +39 -0
  10. package/android/src/main/java/com/rovq/engine/ProgressThrottle.kt +17 -0
  11. package/android/src/main/java/com/rovq/engine/RovqEngine.kt +543 -0
  12. package/android/src/main/java/com/rovq/engine/RovqException.kt +31 -0
  13. package/android/src/main/java/com/rovq/engine/SafePackageExtractor.kt +121 -0
  14. package/android/src/main/java/com/rovq/engine/StrictBase64.kt +37 -0
  15. package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +94 -0
  16. package/android/src/main/java/com/rovq/engine/UpdateService.kt +139 -0
  17. package/android/src/main/java/com/rovq/engine/ZipStructureCheck.kt +194 -0
  18. package/android/src/react/java/com/rovq/engine/react/Rovq.kt +101 -0
  19. package/android/src/react/java/com/rovq/engine/react/RovqModule.kt +164 -0
  20. package/android/src/react/java/com/rovq/engine/react/RovqPackage.kt +26 -0
  21. package/android/src/react/java/com/rovq/engine/react/RovqReactHost.kt +74 -0
  22. package/ios/Engine/EngineState.swift +119 -0
  23. package/ios/Engine/ManifestVerifier.swift +197 -0
  24. package/ios/Engine/PackageDownloader.swift +231 -0
  25. package/ios/Engine/PackageRules.swift +38 -0
  26. package/ios/Engine/ProgressThrottle.swift +20 -0
  27. package/ios/Engine/RovqEngine.swift +633 -0
  28. package/ios/Engine/RovqError.swift +35 -0
  29. package/ios/Engine/SafePackageExtractor.swift +107 -0
  30. package/ios/Engine/UpdateInstaller.swift +126 -0
  31. package/ios/Engine/UpdateService.swift +158 -0
  32. package/ios/Engine/ZipReader.swift +378 -0
  33. package/ios/React/RovqHost.swift +253 -0
  34. package/ios/React/RovqModule.mm +89 -0
  35. package/package.json +74 -3
  36. package/react-native.config.js +11 -0
  37. package/src/NativeRovq.ts +72 -0
  38. package/src/errors.ts +44 -0
  39. 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
+ }