@rovq/react-native 0.1.0 → 0.2.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/README.md +47 -1
- package/android/src/main/java/com/rovq/engine/ManifestVerifier.kt +67 -10
- package/android/src/main/java/com/rovq/engine/PackageDownloader.kt +32 -21
- package/android/src/main/java/com/rovq/engine/RovqEngine.kt +8 -1
- package/android/src/main/java/com/rovq/engine/UpdateInstaller.kt +267 -14
- package/android/src/main/java/com/rovq/engine/UpdateService.kt +37 -1
- package/android/src/react/java/com/rovq/engine/react/ResourceFiles.kt +52 -0
- package/android/src/react/java/com/rovq/engine/react/Rovq.kt +1 -0
- package/ios/Engine/ManifestVerifier.swift +94 -12
- package/ios/Engine/PackageDownloader.swift +36 -18
- package/ios/Engine/RovqEngine.swift +16 -3
- package/ios/Engine/UpdateInstaller.swift +249 -27
- package/ios/Engine/UpdateService.swift +29 -2
- package/ios/React/RovqHost.swift +3 -1
- package/ios/React/RovqModule.mm +3 -1
- package/package.json +6 -2
- package/src/api.ts +228 -0
- package/src/index.ts +10 -228
- package/src/react.tsx +158 -0
- package/src/update-flow.ts +91 -0
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
package com.rovq.engine
|
|
2
2
|
|
|
3
3
|
import java.io.File
|
|
4
|
+
import java.io.IOException
|
|
5
|
+
import java.io.InputStream
|
|
6
|
+
import java.security.MessageDigest
|
|
7
|
+
|
|
8
|
+
// Download links last an hour, so they are fetched a few files at a time, just before use.
|
|
9
|
+
private const val LINK_BATCH_SIZE = 20
|
|
10
|
+
private const val BUFFER_SIZE = 64 * 1024
|
|
11
|
+
private const val EMPTY_FILE_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
|
4
12
|
|
|
5
13
|
/** What the server offered. None of this is trusted until the manifest has been verified. */
|
|
6
14
|
data class OfferedUpdate(
|
|
7
15
|
val manifest: String,
|
|
8
16
|
val signature: String,
|
|
9
17
|
val keyId: String,
|
|
10
|
-
|
|
18
|
+
/** Only for a ZIP package. A release made of files is fetched file by file. */
|
|
19
|
+
val downloadUrl: String?,
|
|
11
20
|
)
|
|
12
21
|
|
|
13
22
|
/** A verified release, fully extracted in its own folder. */
|
|
@@ -17,6 +26,18 @@ data class InstalledRelease(
|
|
|
17
26
|
val bundleFile: File,
|
|
18
27
|
)
|
|
19
28
|
|
|
29
|
+
/** Asks the server for download links, by hash, for files of one release. */
|
|
30
|
+
fun interface FileLinks {
|
|
31
|
+
/** @throws IOException if the server cannot be reached or answers with an error. */
|
|
32
|
+
fun linksFor(releaseId: String, hashes: List<String>): Map<String, String>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The app's own copies of package files, which a release made of files can reuse. */
|
|
36
|
+
fun interface BuiltInFiles {
|
|
37
|
+
/** Opens the app's copy of a package path, or returns null when it has none. */
|
|
38
|
+
fun open(path: String): InputStream?
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/**
|
|
21
42
|
* Takes an offered update to a verified folder on disk, without activating it.
|
|
22
43
|
*
|
|
@@ -24,11 +45,13 @@ data class InstalledRelease(
|
|
|
24
45
|
* path would show old images after a reload. See docs/android-bundle-loading.md.
|
|
25
46
|
*
|
|
26
47
|
* @param rootDir a folder only the engine uses, normally inside the app's private storage.
|
|
48
|
+
* @param builtInFiles the app's own assets, which can be reused instead of downloaded.
|
|
27
49
|
*/
|
|
28
50
|
class UpdateInstaller(
|
|
29
51
|
rootDir: File,
|
|
30
52
|
private val verifier: ManifestVerifier,
|
|
31
53
|
private val downloader: PackageDownloader,
|
|
54
|
+
private val builtInFiles: BuiltInFiles? = null,
|
|
32
55
|
) {
|
|
33
56
|
private val updatesDir = File(rootDir, "updates")
|
|
34
57
|
private val workDir = File(rootDir, "work")
|
|
@@ -39,12 +62,14 @@ class UpdateInstaller(
|
|
|
39
62
|
|
|
40
63
|
/**
|
|
41
64
|
* @param highestSequence the highest sequence this device has accepted in this stream.
|
|
65
|
+
* @param fileLinks download links for a release made of files.
|
|
42
66
|
* @throws RovqException if any step fails. Nothing is left behind in that case.
|
|
43
67
|
*/
|
|
44
68
|
@Synchronized
|
|
45
69
|
fun install(
|
|
46
70
|
offer: OfferedUpdate,
|
|
47
71
|
highestSequence: Long,
|
|
72
|
+
fileLinks: FileLinks = FileLinks { _, _ -> throw IOException("No way to fetch files") },
|
|
48
73
|
onProgress: (received: Long, total: Long) -> Unit = { _, _ -> },
|
|
49
74
|
): InstalledRelease {
|
|
50
75
|
// Verified first, so an unsigned update causes no network traffic or disk use.
|
|
@@ -53,35 +78,61 @@ class UpdateInstaller(
|
|
|
53
78
|
val folder = File(updatesDir, manifest.releaseId)
|
|
54
79
|
// Folders appear only through the atomic rename below, so one that exists is complete.
|
|
55
80
|
if (!folder.isDirectory) {
|
|
56
|
-
|
|
81
|
+
val files = manifest.files
|
|
82
|
+
if (files != null) {
|
|
83
|
+
installFileList(files, manifest.releaseId, folder, fileLinks, onProgress)
|
|
84
|
+
} else {
|
|
85
|
+
installZip(offer, manifest, folder, onProgress)
|
|
86
|
+
}
|
|
57
87
|
}
|
|
58
88
|
return InstalledRelease(manifest, folder, File(folder, PackageRules.BUNDLE_FILE_NAME))
|
|
59
89
|
}
|
|
60
90
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
manifest: VerifiedManifest,
|
|
64
|
-
folder: File,
|
|
65
|
-
onProgress: (Long, Long) -> Unit,
|
|
66
|
-
) {
|
|
91
|
+
/** Creates an empty work folder for assembling a release, and the folders around it. */
|
|
92
|
+
private fun startWork(): File {
|
|
67
93
|
// Removes anything an earlier run left when the app was killed or the phone lost power.
|
|
68
94
|
workDir.deleteRecursively()
|
|
69
|
-
val
|
|
70
|
-
if (!
|
|
95
|
+
val assembled = File(workDir, "extracted")
|
|
96
|
+
if (!assembled.mkdirs() ||
|
|
71
97
|
!(updatesDir.isDirectory || updatesDir.mkdirs()) ||
|
|
72
98
|
!(partialDir.isDirectory || partialDir.mkdirs())
|
|
73
99
|
) {
|
|
74
100
|
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the engine folders")
|
|
75
101
|
}
|
|
102
|
+
return assembled
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private fun moveIntoPlace(assembled: File, folder: File) {
|
|
106
|
+
if (!assembled.renameTo(folder)) {
|
|
107
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not move the release into place")
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private fun installZip(
|
|
112
|
+
offer: OfferedUpdate,
|
|
113
|
+
manifest: VerifiedManifest,
|
|
114
|
+
folder: File,
|
|
115
|
+
onProgress: (Long, Long) -> Unit,
|
|
116
|
+
) {
|
|
117
|
+
val downloadUrl = offer.downloadUrl
|
|
118
|
+
?: throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The server sent no download URL")
|
|
119
|
+
val extracted = startWork()
|
|
76
120
|
val packageFile = File(partialDir, "${manifest.packageHash}.zip")
|
|
77
121
|
// Only the current offer is worth finishing.
|
|
78
122
|
partialDir.listFiles()?.filter { it != packageFile }?.forEach { it.delete() }
|
|
79
123
|
try {
|
|
80
|
-
|
|
124
|
+
// Extracting needs room too, and its size is unknown until then, so this asks for
|
|
125
|
+
// the package twice. A package can still expand further, and extraction reports that.
|
|
126
|
+
downloader.download(
|
|
127
|
+
downloadUrl,
|
|
128
|
+
manifest.packageHash,
|
|
129
|
+
manifest.packageSize,
|
|
130
|
+
packageFile,
|
|
131
|
+
extraSpace = manifest.packageSize,
|
|
132
|
+
onProgress = onProgress,
|
|
133
|
+
)
|
|
81
134
|
extractor.extract(packageFile, extracted)
|
|
82
|
-
|
|
83
|
-
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not move the release into place")
|
|
84
|
-
}
|
|
135
|
+
moveIntoPlace(extracted, folder)
|
|
85
136
|
packageFile.delete()
|
|
86
137
|
} catch (error: RovqException) {
|
|
87
138
|
// A file that failed anything but the network is not worth continuing from.
|
|
@@ -91,4 +142,206 @@ class UpdateInstaller(
|
|
|
91
142
|
workDir.deleteRecursively()
|
|
92
143
|
}
|
|
93
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Builds a release from its files: each comes from a kept release, from the app itself, or
|
|
148
|
+
* from the network, in that order, and only after its content matches the signed hash. See
|
|
149
|
+
* docs/file-packages.md.
|
|
150
|
+
*/
|
|
151
|
+
private fun installFileList(
|
|
152
|
+
list: List<ManifestFile>,
|
|
153
|
+
releaseId: String,
|
|
154
|
+
folder: File,
|
|
155
|
+
fileLinks: FileLinks,
|
|
156
|
+
onProgress: (Long, Long) -> Unit,
|
|
157
|
+
) {
|
|
158
|
+
val assembled = startWork()
|
|
159
|
+
try {
|
|
160
|
+
val wanted = list.map { it.hash }.toSet()
|
|
161
|
+
// Only the current offer is worth finishing.
|
|
162
|
+
partialDir.listFiles()?.filter { it.name !in wanted }?.forEach { it.delete() }
|
|
163
|
+
val root = assembled.canonicalPath + File.separator
|
|
164
|
+
|
|
165
|
+
// A hash already placed, so the same content at another path is linked, not fetched.
|
|
166
|
+
val placed = HashMap<String, File>()
|
|
167
|
+
// Files with no local copy at their own path. Another path may still provide them.
|
|
168
|
+
val unresolved = ArrayList<Pair<ManifestFile, File>>()
|
|
169
|
+
|
|
170
|
+
for (file in list) {
|
|
171
|
+
val target = File(assembled, file.path)
|
|
172
|
+
val parent = target.parentFile ?: assembled
|
|
173
|
+
if (!(parent.isDirectory || parent.mkdirs())) {
|
|
174
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create a folder")
|
|
175
|
+
}
|
|
176
|
+
// A second, independent check: whatever the rules allow, the real path must stay
|
|
177
|
+
// inside the folder.
|
|
178
|
+
if (!(parent.canonicalPath + File.separator).startsWith(root)) {
|
|
179
|
+
throw RovqException(RovqErrorCode.INVALID_PACKAGE, "The file ${file.path} leaves the folder")
|
|
180
|
+
}
|
|
181
|
+
val source = placed[file.hash]
|
|
182
|
+
when {
|
|
183
|
+
source != null -> link(source, target)
|
|
184
|
+
file.size == 0L -> {
|
|
185
|
+
if (file.hash != EMPTY_FILE_HASH) {
|
|
186
|
+
throw RovqException(RovqErrorCode.HASH_MISMATCH, "An empty file does not match its signed hash")
|
|
187
|
+
}
|
|
188
|
+
if (!target.createNewFile()) {
|
|
189
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the file")
|
|
190
|
+
}
|
|
191
|
+
placed[file.hash] = target
|
|
192
|
+
}
|
|
193
|
+
placeLocalCopy(file, target, folder) -> placed[file.hash] = target
|
|
194
|
+
else -> unresolved += file to target
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Files to download, by hash, with every path that needs each one, in manifest order.
|
|
199
|
+
val targets = LinkedHashMap<String, MutableList<File>>()
|
|
200
|
+
val missing = ArrayList<ManifestFile>()
|
|
201
|
+
for ((file, target) in unresolved) {
|
|
202
|
+
val source = placed[file.hash]
|
|
203
|
+
when {
|
|
204
|
+
source != null -> link(source, target)
|
|
205
|
+
targets.containsKey(file.hash) -> targets.getValue(file.hash) += target
|
|
206
|
+
else -> {
|
|
207
|
+
targets[file.hash] = mutableListOf(target)
|
|
208
|
+
missing += file
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
download(missing, targets, releaseId, fileLinks, onProgress)
|
|
214
|
+
moveIntoPlace(assembled, folder)
|
|
215
|
+
// Linked into the release, so the content stays where it is needed.
|
|
216
|
+
missing.forEach { File(partialDir, it.hash).delete() }
|
|
217
|
+
} finally {
|
|
218
|
+
workDir.deleteRecursively()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private fun download(
|
|
223
|
+
missing: List<ManifestFile>,
|
|
224
|
+
targets: Map<String, List<File>>,
|
|
225
|
+
releaseId: String,
|
|
226
|
+
fileLinks: FileLinks,
|
|
227
|
+
onProgress: (Long, Long) -> Unit,
|
|
228
|
+
) {
|
|
229
|
+
if (missing.isEmpty()) return
|
|
230
|
+
val total = missing.sumOf { it.size }
|
|
231
|
+
// Checked for the whole release first, so a full disk fails before any download.
|
|
232
|
+
val remaining = missing.sumOf { file ->
|
|
233
|
+
val partial = File(partialDir, file.hash)
|
|
234
|
+
file.size - (if (partial.isFile) minOf(partial.length(), file.size) else 0L)
|
|
235
|
+
}
|
|
236
|
+
if (downloader.freeSpaceIn(partialDir) < remaining) {
|
|
237
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Not enough free space for this update")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
var finished = 0L
|
|
241
|
+
onProgress(0, total)
|
|
242
|
+
for (batch in missing.chunked(LINK_BATCH_SIZE)) {
|
|
243
|
+
val links = try {
|
|
244
|
+
fileLinks.linksFor(releaseId, batch.map { it.hash })
|
|
245
|
+
} catch (error: IOException) {
|
|
246
|
+
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, error.message ?: "Could not get download links", error)
|
|
247
|
+
}
|
|
248
|
+
for (file in batch) {
|
|
249
|
+
val url = links[file.hash]
|
|
250
|
+
?: throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The server sent no link for a file")
|
|
251
|
+
val partial = File(partialDir, file.hash)
|
|
252
|
+
val before = finished
|
|
253
|
+
downloader.download(url, file.hash, file.size, partial) { received, _ ->
|
|
254
|
+
onProgress(before + received, total)
|
|
255
|
+
}
|
|
256
|
+
finished += file.size
|
|
257
|
+
targets[file.hash]?.forEach { link(partial, it) }
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Places the same path from a kept release, or from the app itself, if its content is the
|
|
264
|
+
* signed one. Hashed every time, because reusing a damaged file would break the new release.
|
|
265
|
+
*/
|
|
266
|
+
private fun placeLocalCopy(file: ManifestFile, target: File, folder: File): Boolean {
|
|
267
|
+
val kept = updatesDir.listFiles()?.filter { it.isDirectory && it.name != folder.name }.orEmpty()
|
|
268
|
+
for (release in kept) {
|
|
269
|
+
val candidate = File(release, file.path)
|
|
270
|
+
if (candidate.isFile && candidate.length() == file.size && hashOf(candidate) == file.hash) {
|
|
271
|
+
link(candidate, target)
|
|
272
|
+
return true
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return copyBuiltIn(file, target)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Copies the app's own file while hashing it, and keeps the copy only if it matches. */
|
|
279
|
+
private fun copyBuiltIn(file: ManifestFile, target: File): Boolean {
|
|
280
|
+
val input = try {
|
|
281
|
+
builtInFiles?.open(file.path)
|
|
282
|
+
} catch (error: Exception) {
|
|
283
|
+
null
|
|
284
|
+
} ?: return false
|
|
285
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
286
|
+
var copied = 0L
|
|
287
|
+
try {
|
|
288
|
+
input.use { source ->
|
|
289
|
+
target.outputStream().use { output ->
|
|
290
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
291
|
+
while (true) {
|
|
292
|
+
val count = source.read(buffer)
|
|
293
|
+
if (count < 0) break
|
|
294
|
+
copied += count
|
|
295
|
+
if (copied > file.size) break
|
|
296
|
+
digest.update(buffer, 0, count)
|
|
297
|
+
output.write(buffer, 0, count)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
} catch (error: IOException) {
|
|
302
|
+
target.delete()
|
|
303
|
+
return false
|
|
304
|
+
}
|
|
305
|
+
val matches = copied == file.size && digest.digest().toHex() == file.hash
|
|
306
|
+
if (!matches) target.delete()
|
|
307
|
+
return matches
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private fun hashOf(file: File): String? = try {
|
|
311
|
+
val digest = MessageDigest.getInstance("SHA-256")
|
|
312
|
+
file.inputStream().use { input ->
|
|
313
|
+
val buffer = ByteArray(BUFFER_SIZE)
|
|
314
|
+
while (true) {
|
|
315
|
+
val count = input.read(buffer)
|
|
316
|
+
if (count < 0) break
|
|
317
|
+
digest.update(buffer, 0, count)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
digest.digest().toHex()
|
|
321
|
+
} catch (error: IOException) {
|
|
322
|
+
null
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* A hard link costs no space. Files in a release never change, so sharing them is safe.
|
|
329
|
+
* Android before 8.0 has no java.nio.file, so it copies instead.
|
|
330
|
+
*/
|
|
331
|
+
private fun link(source: File, target: File) {
|
|
332
|
+
val linked = try {
|
|
333
|
+
java.nio.file.Files.createLink(target.toPath(), source.toPath())
|
|
334
|
+
true
|
|
335
|
+
} catch (error: Exception) {
|
|
336
|
+
false
|
|
337
|
+
} catch (error: LinkageError) {
|
|
338
|
+
false
|
|
339
|
+
}
|
|
340
|
+
if (linked) return
|
|
341
|
+
try {
|
|
342
|
+
source.copyTo(target, overwrite = true)
|
|
343
|
+
} catch (error: IOException) {
|
|
344
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not place a file", error)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
94
347
|
}
|
|
@@ -42,6 +42,14 @@ interface UpdateService {
|
|
|
42
42
|
|
|
43
43
|
/** @throws IOException if the events could not be delivered. */
|
|
44
44
|
fun report(deploymentKey: String, deviceId: String, events: List<QueuedEvent>)
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Download links for files of a release made of files, by hash. Each file is checked against
|
|
48
|
+
* the signed manifest, so the links are trusted for nothing.
|
|
49
|
+
*
|
|
50
|
+
* @throws IOException if the server cannot be reached or answers with an error.
|
|
51
|
+
*/
|
|
52
|
+
fun fileLinks(deploymentKey: String, deviceId: String, releaseId: String, hashes: List<String>): Map<String, String>
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
class HttpUpdateService(
|
|
@@ -57,6 +65,8 @@ class HttpUpdateService(
|
|
|
57
65
|
.put("runtimeVersion", request.runtimeVersion)
|
|
58
66
|
.put("binaryVersion", request.binaryVersion)
|
|
59
67
|
.put("highestSequence", request.highestSequence)
|
|
68
|
+
// Both a ZIP package and a list of files. See docs/file-packages.md.
|
|
69
|
+
.put("packageFormats", JSONArray().put(1).put(2))
|
|
60
70
|
request.currentReleaseId?.let { body.put("currentReleaseId", it) }
|
|
61
71
|
|
|
62
72
|
return try {
|
|
@@ -71,7 +81,8 @@ class HttpUpdateService(
|
|
|
71
81
|
manifest = update.getString("manifest"),
|
|
72
82
|
signature = update.getString("signature"),
|
|
73
83
|
keyId = update.getString("keyId"),
|
|
74
|
-
|
|
84
|
+
// A release made of files has no single download URL.
|
|
85
|
+
downloadUrl = update.optString("downloadUrl").ifEmpty { null },
|
|
75
86
|
),
|
|
76
87
|
)
|
|
77
88
|
}
|
|
@@ -111,6 +122,31 @@ class HttpUpdateService(
|
|
|
111
122
|
)
|
|
112
123
|
}
|
|
113
124
|
|
|
125
|
+
override fun fileLinks(
|
|
126
|
+
deploymentKey: String,
|
|
127
|
+
deviceId: String,
|
|
128
|
+
releaseId: String,
|
|
129
|
+
hashes: List<String>,
|
|
130
|
+
): Map<String, String> {
|
|
131
|
+
val response = post(
|
|
132
|
+
"/device/updates/files",
|
|
133
|
+
JSONObject()
|
|
134
|
+
.put("deploymentKey", deploymentKey)
|
|
135
|
+
.put("deviceId", deviceId)
|
|
136
|
+
.put("releaseId", releaseId)
|
|
137
|
+
.put("hashes", JSONArray(hashes)),
|
|
138
|
+
)
|
|
139
|
+
return try {
|
|
140
|
+
val files = response.getJSONArray("files")
|
|
141
|
+
(0 until files.length()).associate { index ->
|
|
142
|
+
val file = files.getJSONObject(index)
|
|
143
|
+
file.getString("hash") to file.getString("url")
|
|
144
|
+
}
|
|
145
|
+
} catch (error: JSONException) {
|
|
146
|
+
throw IOException("The server sent an unreadable response", error)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
114
150
|
private fun post(path: String, body: JSONObject): JSONObject {
|
|
115
151
|
val url = URL(apiUrl.trimEnd('/') + path)
|
|
116
152
|
if (url.protocol != "https" && !allowInsecureUrls) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
package com.rovq.engine.react
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.TypedValue
|
|
5
|
+
import com.rovq.engine.BuiltInFiles
|
|
6
|
+
import java.io.InputStream
|
|
7
|
+
|
|
8
|
+
// The densities react-native bundle writes drawables for, in dots per inch.
|
|
9
|
+
private val DENSITIES = mapOf(
|
|
10
|
+
"ldpi" to 120,
|
|
11
|
+
"mdpi" to 160,
|
|
12
|
+
"hdpi" to 240,
|
|
13
|
+
"xhdpi" to 320,
|
|
14
|
+
"xxhdpi" to 480,
|
|
15
|
+
"xxxhdpi" to 640,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads the app's own copy of a package file, so a release made of files can reuse it instead
|
|
20
|
+
* of downloading it again. In an APK, react-native bundle's files are resources: a path such as
|
|
21
|
+
* raw/src_assets_audio_1.mp3 is the raw resource src_assets_audio_1, and drawable-xhdpi/x.png is
|
|
22
|
+
* the drawable x at 320 dpi.
|
|
23
|
+
*
|
|
24
|
+
* Whatever this returns is hashed and compared with the signed hash, so a resource that the
|
|
25
|
+
* build changed, such as a PNG that was compressed again, is simply downloaded instead.
|
|
26
|
+
*/
|
|
27
|
+
internal class ResourceFiles(private val context: Context) : BuiltInFiles {
|
|
28
|
+
override fun open(path: String): InputStream? {
|
|
29
|
+
val segments = path.split('/')
|
|
30
|
+
if (segments.size != 2) return null
|
|
31
|
+
val (folder, fileName) = segments
|
|
32
|
+
val type = folder.substringBefore('-')
|
|
33
|
+
val name = fileName.substringBeforeLast('.')
|
|
34
|
+
val resources = context.resources
|
|
35
|
+
// The only way to find a resource by the name a package uses.
|
|
36
|
+
@Suppress("DiscouragedApi")
|
|
37
|
+
val id = resources.getIdentifier(name, type, context.packageName)
|
|
38
|
+
if (id == 0) return null
|
|
39
|
+
return when (type) {
|
|
40
|
+
"raw" -> resources.openRawResource(id)
|
|
41
|
+
"drawable" -> {
|
|
42
|
+
val density = DENSITIES[folder.substringAfter('-', "")] ?: return null
|
|
43
|
+
val value = TypedValue()
|
|
44
|
+
resources.getValueForDensity(id, density, value, true)
|
|
45
|
+
val file = value.string?.toString() ?: return null
|
|
46
|
+
// Works for files stored uncompressed in the APK, which images and audio are.
|
|
47
|
+
resources.assets.openNonAssetFd(value.assetCookie, file).createInputStream()
|
|
48
|
+
}
|
|
49
|
+
else -> null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -3,23 +3,53 @@ import Foundation
|
|
|
3
3
|
|
|
4
4
|
private let publicKeySize = 32
|
|
5
5
|
private let signatureSize = 64
|
|
6
|
-
private let manifestVersion: Int64 = 1
|
|
7
6
|
private let platform = "IOS"
|
|
8
|
-
private let
|
|
7
|
+
private let streamFields: Set<String> = [
|
|
9
8
|
"version", "releaseId", "deploymentKey", "platform", "runtimeVersion", "binaryVersion",
|
|
10
|
-
"sequence", "mandatory",
|
|
9
|
+
"sequence", "mandatory",
|
|
11
10
|
]
|
|
11
|
+
/// Version 1 names one ZIP package. Version 2 lists files. See docs/file-packages.md.
|
|
12
|
+
private let zipManifestFields = streamFields.union(["package"])
|
|
13
|
+
private let filesManifestFields = streamFields.union(["files"])
|
|
12
14
|
private let packageFields: Set<String> = ["hash", "size"]
|
|
15
|
+
private let fileFields: Set<String> = ["path", "hash", "size"]
|
|
13
16
|
private let uuidPattern = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
14
17
|
private let hashPattern = "^[0-9a-f]{64}$"
|
|
15
18
|
|
|
19
|
+
/// One file of a release made of files, from a verified manifest.
|
|
20
|
+
public struct ManifestFile: Equatable {
|
|
21
|
+
public let path: String
|
|
22
|
+
public let hash: String
|
|
23
|
+
public let size: Int64
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
/// Facts from a manifest whose signature has been verified. Nothing else should be trusted.
|
|
17
27
|
public struct VerifiedManifest: Equatable {
|
|
18
28
|
public let releaseId: String
|
|
19
29
|
public let sequence: Int64
|
|
20
30
|
public let mandatory: Bool
|
|
31
|
+
/// The ZIP package's hash. Empty for a release made of files.
|
|
21
32
|
public let packageHash: String
|
|
33
|
+
/// The ZIP package's size, or all files together.
|
|
22
34
|
public let packageSize: Int64
|
|
35
|
+
/// Set for a release made of files, sorted by path. Nil for a ZIP package.
|
|
36
|
+
public let files: [ManifestFile]?
|
|
37
|
+
|
|
38
|
+
init(
|
|
39
|
+
releaseId: String,
|
|
40
|
+
sequence: Int64,
|
|
41
|
+
mandatory: Bool,
|
|
42
|
+
packageHash: String,
|
|
43
|
+
packageSize: Int64,
|
|
44
|
+
files: [ManifestFile]? = nil
|
|
45
|
+
) {
|
|
46
|
+
self.releaseId = releaseId
|
|
47
|
+
self.sequence = sequence
|
|
48
|
+
self.mandatory = mandatory
|
|
49
|
+
self.packageHash = packageHash
|
|
50
|
+
self.packageSize = packageSize
|
|
51
|
+
self.files = files
|
|
52
|
+
}
|
|
23
53
|
}
|
|
24
54
|
|
|
25
55
|
/// Decodes standard Base64, accepting only one spelling of each value, as the server does.
|
|
@@ -118,10 +148,9 @@ public final class ManifestVerifier {
|
|
|
118
148
|
guard let root = parsed as? [String: Any] else {
|
|
119
149
|
throw invalid("Manifest is not a JSON object")
|
|
120
150
|
}
|
|
121
|
-
try
|
|
122
|
-
guard
|
|
123
|
-
|
|
124
|
-
}
|
|
151
|
+
let version = try integerField(root, "version")
|
|
152
|
+
guard version == 1 || version == 2 else { throw invalid("Unknown manifest version") }
|
|
153
|
+
try requireOnlyFields(root, allowed: version == 1 ? zipManifestFields : filesManifestFields)
|
|
125
154
|
|
|
126
155
|
let releaseId = try stringField(root, "releaseId")
|
|
127
156
|
// Later used as a folder name, so it must be exactly a UUID.
|
|
@@ -135,6 +164,23 @@ public final class ManifestVerifier {
|
|
|
135
164
|
}
|
|
136
165
|
_ = try stringField(root, "binaryVersion")
|
|
137
166
|
|
|
167
|
+
// A valid signature for another deployment, platform or runtime must not install here.
|
|
168
|
+
try requireMatch("deploymentKey", try stringField(root, "deploymentKey"), deploymentKey)
|
|
169
|
+
try requireMatch("platform", try stringField(root, "platform"), platform)
|
|
170
|
+
try requireMatch("runtimeVersion", try stringField(root, "runtimeVersion"), runtimeVersion)
|
|
171
|
+
|
|
172
|
+
if version == 2 {
|
|
173
|
+
let files = try fileList(root["files"])
|
|
174
|
+
return VerifiedManifest(
|
|
175
|
+
releaseId: releaseId,
|
|
176
|
+
sequence: sequence,
|
|
177
|
+
mandatory: mandatoryNumber.boolValue,
|
|
178
|
+
packageHash: "",
|
|
179
|
+
packageSize: files.reduce(0) { $0 + $1.size },
|
|
180
|
+
files: files
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
138
184
|
guard let package = root["package"] as? [String: Any] else {
|
|
139
185
|
throw invalid("package must be an object")
|
|
140
186
|
}
|
|
@@ -144,11 +190,6 @@ public final class ManifestVerifier {
|
|
|
144
190
|
let size = try integerField(package, "size")
|
|
145
191
|
guard size > 0 else { throw invalid("package size must be positive") }
|
|
146
192
|
|
|
147
|
-
// A valid signature for another deployment, platform or runtime must not install here.
|
|
148
|
-
try requireMatch("deploymentKey", try stringField(root, "deploymentKey"), deploymentKey)
|
|
149
|
-
try requireMatch("platform", try stringField(root, "platform"), platform)
|
|
150
|
-
try requireMatch("runtimeVersion", try stringField(root, "runtimeVersion"), runtimeVersion)
|
|
151
|
-
|
|
152
193
|
return VerifiedManifest(
|
|
153
194
|
releaseId: releaseId,
|
|
154
195
|
sequence: sequence,
|
|
@@ -158,6 +199,47 @@ public final class ManifestVerifier {
|
|
|
158
199
|
)
|
|
159
200
|
}
|
|
160
201
|
|
|
202
|
+
/// The same rules as a ZIP package, because each path is written to disk the same way.
|
|
203
|
+
/// Sorted by UTF-16 code units, as JavaScript sorts, so one set of files has one signed form.
|
|
204
|
+
private func fileList(_ value: Any?) throws -> [ManifestFile] {
|
|
205
|
+
guard let list = value as? [Any] else { throw invalid("files must be an array") }
|
|
206
|
+
if list.isEmpty || list.count > PackageRules.maxEntries {
|
|
207
|
+
throw invalid("A package must have from 1 to \(PackageRules.maxEntries) files")
|
|
208
|
+
}
|
|
209
|
+
var files: [ManifestFile] = []
|
|
210
|
+
var seen = Set<String>()
|
|
211
|
+
var total: Int64 = 0
|
|
212
|
+
for item in list {
|
|
213
|
+
guard let entry = item as? [String: Any] else { throw invalid("Each file must be an object") }
|
|
214
|
+
try requireOnlyFields(entry, allowed: fileFields)
|
|
215
|
+
let path = try stringField(entry, "path")
|
|
216
|
+
if let problem = PackageRules.findPathProblem(path) {
|
|
217
|
+
throw invalid("The file \(path) \(problem)")
|
|
218
|
+
}
|
|
219
|
+
if let previous = files.last, !Array(previous.path.utf16).lexicographicallyPrecedes(path.utf16) {
|
|
220
|
+
throw invalid("The files must be sorted by path, each path once")
|
|
221
|
+
}
|
|
222
|
+
if !seen.insert(PackageRules.pathIdentity(path)).inserted {
|
|
223
|
+
throw invalid("The file \(path) differs from another only by letter case")
|
|
224
|
+
}
|
|
225
|
+
let hash = try stringField(entry, "hash")
|
|
226
|
+
guard matches(hash, hashPattern) else { throw invalid("The file \(path) has a malformed hash") }
|
|
227
|
+
let size = try integerField(entry, "size")
|
|
228
|
+
// Bounded before adding, because an overflow would stop the app.
|
|
229
|
+
guard size >= 0, size <= PackageRules.maxTotalSize else {
|
|
230
|
+
throw invalid("The file \(path) has an invalid size")
|
|
231
|
+
}
|
|
232
|
+
total += size
|
|
233
|
+
if total > PackageRules.maxTotalSize { throw invalid("The files are larger than the allowed total") }
|
|
234
|
+
files.append(ManifestFile(path: path, hash: hash, size: size))
|
|
235
|
+
}
|
|
236
|
+
guard let bundle = files.first(where: { $0.path == PackageRules.bundleFileName }) else {
|
|
237
|
+
throw invalid("The bundle file is missing")
|
|
238
|
+
}
|
|
239
|
+
if bundle.size == 0 { throw invalid("The bundle file is empty") }
|
|
240
|
+
return files
|
|
241
|
+
}
|
|
242
|
+
|
|
161
243
|
private func matches(_ value: String, _ pattern: String) -> Bool {
|
|
162
244
|
value.range(of: pattern, options: .regularExpression) != nil
|
|
163
245
|
}
|