@rovq/react-native 0.1.0 → 0.2.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/README.md +45 -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 +5 -1
- 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
package/README.md
CHANGED
|
@@ -68,10 +68,54 @@ useEffect(() => {
|
|
|
68
68
|
}, []);
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
For full control, use `checkForUpdate`, `downloadUpdate({ onProgress })`, `installUpdate`, and `restartApp`. They throw `RovqError` with a stable `code`.
|
|
72
|
+
|
|
73
|
+
## Updating in the foreground or the background
|
|
74
|
+
|
|
75
|
+
Wrap your app in `RovqUpdateGate`, and choose how updates arrive:
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
import { RovqUpdateGate } from '@rovq/react-native';
|
|
79
|
+
|
|
80
|
+
export default function Root() {
|
|
81
|
+
return (
|
|
82
|
+
<RovqUpdateGate mode="auto" variant="bar">
|
|
83
|
+
<App />
|
|
84
|
+
</RovqUpdateGate>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
| `mode` | What the user sees |
|
|
90
|
+
| ---------------- | --------------------------------------------------------------------------------- |
|
|
91
|
+
| `background` | Nothing. The update downloads while they use the app, and loads on the next start |
|
|
92
|
+
| `foreground` | A progress screen, then the app restarts on the new version at once |
|
|
93
|
+
| `auto` (default) | Foreground for a release published with `--mandatory`, background otherwise |
|
|
94
|
+
|
|
95
|
+
`variant` is `bar` or `percent`. The gate shows your app while it checks, so a slow server never holds it up, and it confirms the running release with `notifyApplicationReady` for you. Pass `title`, `color`, and `backgroundColor`, or draw your own screen with `renderProgress={(state) => ...}`.
|
|
96
|
+
|
|
97
|
+
To build your own interface, use the hook and the progress component:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { RovqUpdateProgress, useRovqUpdate } from '@rovq/react-native';
|
|
101
|
+
|
|
102
|
+
const { phase, progress, update, error, check } = useRovqUpdate({ mode: 'background' });
|
|
103
|
+
// phase: idle, checking, upToDate, downloading, installing, ready, restarting, or error
|
|
104
|
+
{
|
|
105
|
+
progress !== null && <RovqUpdateProgress progress={progress} variant="percent" />;
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A failure never throws. It ends in the `error` phase, and the app keeps running what it has.
|
|
110
|
+
|
|
111
|
+
"Background" means while the app is open. When the user leaves the app, the system can pause the download, and it continues from where it stopped the next time the app runs.
|
|
72
112
|
|
|
73
113
|
Publish updates with [`@rovq/cli`](https://www.npmjs.com/package/@rovq/cli).
|
|
74
114
|
|
|
115
|
+
## Downloading only what changed
|
|
116
|
+
|
|
117
|
+
A release published by `@rovq/cli` 0.2 lists its files, and the SDK downloads only those it does not already have. Files from the release before, and assets inside the app itself, are checked against the signed hash and reused. A change to one screen downloads only the bundle, however many images and sounds the app has.
|
|
118
|
+
|
|
75
119
|
## License
|
|
76
120
|
|
|
77
121
|
MIT
|
|
@@ -2,14 +2,14 @@ package com.rovq.engine
|
|
|
2
2
|
|
|
3
3
|
import com.google.crypto.tink.subtle.Ed25519Verify
|
|
4
4
|
import java.security.GeneralSecurityException
|
|
5
|
+
import org.json.JSONArray
|
|
5
6
|
import org.json.JSONException
|
|
6
7
|
import org.json.JSONObject
|
|
7
8
|
|
|
8
9
|
private const val PUBLIC_KEY_SIZE = 32
|
|
9
10
|
private const val SIGNATURE_SIZE = 64
|
|
10
|
-
private const val MANIFEST_VERSION = 1
|
|
11
11
|
private const val PLATFORM = "ANDROID"
|
|
12
|
-
private val
|
|
12
|
+
private val STREAM_FIELDS = setOf(
|
|
13
13
|
"version",
|
|
14
14
|
"releaseId",
|
|
15
15
|
"deploymentKey",
|
|
@@ -18,19 +18,30 @@ private val MANIFEST_FIELDS = setOf(
|
|
|
18
18
|
"binaryVersion",
|
|
19
19
|
"sequence",
|
|
20
20
|
"mandatory",
|
|
21
|
-
"package",
|
|
22
21
|
)
|
|
22
|
+
|
|
23
|
+
// Version 1 names one ZIP package. Version 2 lists files. See docs/file-packages.md.
|
|
24
|
+
private val ZIP_MANIFEST_FIELDS = STREAM_FIELDS + "package"
|
|
25
|
+
private val FILES_MANIFEST_FIELDS = STREAM_FIELDS + "files"
|
|
23
26
|
private val PACKAGE_FIELDS = setOf("hash", "size")
|
|
27
|
+
private val FILE_FIELDS = setOf("path", "hash", "size")
|
|
24
28
|
private val UUID_PATTERN = Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
25
29
|
private val HASH_PATTERN = Regex("^[0-9a-f]{64}$")
|
|
26
30
|
|
|
31
|
+
/** One file of a release made of files, from a verified manifest. */
|
|
32
|
+
data class ManifestFile(val path: String, val hash: String, val size: Long)
|
|
33
|
+
|
|
27
34
|
/** Facts from a manifest whose signature has been verified. Nothing else should be trusted. */
|
|
28
35
|
data class VerifiedManifest(
|
|
29
36
|
val releaseId: String,
|
|
30
37
|
val sequence: Long,
|
|
31
38
|
val mandatory: Boolean,
|
|
39
|
+
/** The ZIP package's hash. Empty for a release made of files. */
|
|
32
40
|
val packageHash: String,
|
|
41
|
+
/** The ZIP package's size, or all files together. */
|
|
33
42
|
val packageSize: Long,
|
|
43
|
+
/** Set for a release made of files, sorted by path. Null for a ZIP package. */
|
|
44
|
+
val files: List<ManifestFile>? = null,
|
|
34
45
|
)
|
|
35
46
|
|
|
36
47
|
/**
|
|
@@ -112,8 +123,9 @@ class ManifestVerifier(
|
|
|
112
123
|
|
|
113
124
|
private fun parseAndCheck(manifest: String): VerifiedManifest {
|
|
114
125
|
val root = parseObject(manifest)
|
|
115
|
-
|
|
116
|
-
if (
|
|
126
|
+
val version = longField(root, "version")
|
|
127
|
+
if (version != 1L && version != 2L) invalid("Unknown manifest version")
|
|
128
|
+
requireOnlyFields(root, if (version == 1L) ZIP_MANIFEST_FIELDS else FILES_MANIFEST_FIELDS)
|
|
117
129
|
|
|
118
130
|
val releaseId = stringField(root, "releaseId")
|
|
119
131
|
// Later used as a folder name, so it must be exactly a UUID.
|
|
@@ -125,6 +137,16 @@ class ManifestVerifier(
|
|
|
125
137
|
val mandatory = root.opt("mandatory") as? Boolean ?: invalid("mandatory must be a boolean")
|
|
126
138
|
stringField(root, "binaryVersion")
|
|
127
139
|
|
|
140
|
+
// A valid signature for another deployment, platform or runtime must not install here.
|
|
141
|
+
requireMatch("deploymentKey", stringField(root, "deploymentKey"), deploymentKey)
|
|
142
|
+
requireMatch("platform", stringField(root, "platform"), PLATFORM)
|
|
143
|
+
requireMatch("runtimeVersion", stringField(root, "runtimeVersion"), runtimeVersion)
|
|
144
|
+
|
|
145
|
+
if (version == 2L) {
|
|
146
|
+
val files = fileList(root.opt("files"))
|
|
147
|
+
return VerifiedManifest(releaseId, sequence, mandatory, "", files.sumOf { it.size }, files)
|
|
148
|
+
}
|
|
149
|
+
|
|
128
150
|
val packageInfo = root.opt("package") as? JSONObject ?: invalid("package must be an object")
|
|
129
151
|
requireOnlyFields(packageInfo, PACKAGE_FIELDS)
|
|
130
152
|
val hash = stringField(packageInfo, "hash")
|
|
@@ -132,14 +154,49 @@ class ManifestVerifier(
|
|
|
132
154
|
val size = longField(packageInfo, "size")
|
|
133
155
|
if (size <= 0) invalid("package size must be positive")
|
|
134
156
|
|
|
135
|
-
// A valid signature for another deployment, platform or runtime must not install here.
|
|
136
|
-
requireMatch("deploymentKey", stringField(root, "deploymentKey"), deploymentKey)
|
|
137
|
-
requireMatch("platform", stringField(root, "platform"), PLATFORM)
|
|
138
|
-
requireMatch("runtimeVersion", stringField(root, "runtimeVersion"), runtimeVersion)
|
|
139
|
-
|
|
140
157
|
return VerifiedManifest(releaseId, sequence, mandatory, hash, size)
|
|
141
158
|
}
|
|
142
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The same rules as a ZIP package, because each path is written to disk the same way.
|
|
162
|
+
* Sorted by UTF-16 code units, as JavaScript and Kotlin both sort, so one set of files has
|
|
163
|
+
* one signed form.
|
|
164
|
+
*/
|
|
165
|
+
private fun fileList(value: Any?): List<ManifestFile> {
|
|
166
|
+
val list = value as? JSONArray ?: invalid("files must be an array")
|
|
167
|
+
if (list.length() == 0 || list.length() > PackageRules.MAX_ENTRIES) {
|
|
168
|
+
invalid("A package must have from 1 to ${PackageRules.MAX_ENTRIES} files")
|
|
169
|
+
}
|
|
170
|
+
val files = ArrayList<ManifestFile>(list.length())
|
|
171
|
+
val seen = HashSet<String>()
|
|
172
|
+
var total = 0L
|
|
173
|
+
for (index in 0 until list.length()) {
|
|
174
|
+
val entry = list.opt(index) as? JSONObject ?: invalid("Each file must be an object")
|
|
175
|
+
requireOnlyFields(entry, FILE_FIELDS)
|
|
176
|
+
val path = stringField(entry, "path")
|
|
177
|
+
PackageRules.findPathProblem(path)?.let { invalid("The file $path $it") }
|
|
178
|
+
val previous = files.lastOrNull()
|
|
179
|
+
if (previous != null && previous.path >= path) {
|
|
180
|
+
invalid("The files must be sorted by path, each path once")
|
|
181
|
+
}
|
|
182
|
+
if (!seen.add(PackageRules.pathIdentity(path))) {
|
|
183
|
+
invalid("The file $path differs from another only by letter case")
|
|
184
|
+
}
|
|
185
|
+
val hash = stringField(entry, "hash")
|
|
186
|
+
if (!HASH_PATTERN.matches(hash)) invalid("The file $path has a malformed hash")
|
|
187
|
+
val size = longField(entry, "size")
|
|
188
|
+
// Bounded before adding, so the total cannot overflow.
|
|
189
|
+
if (size < 0 || size > PackageRules.MAX_TOTAL_SIZE) invalid("The file $path has an invalid size")
|
|
190
|
+
total += size
|
|
191
|
+
if (total > PackageRules.MAX_TOTAL_SIZE) invalid("The files are larger than the allowed total")
|
|
192
|
+
files += ManifestFile(path, hash, size)
|
|
193
|
+
}
|
|
194
|
+
val bundle = files.firstOrNull { it.path == PackageRules.BUNDLE_FILE_NAME }
|
|
195
|
+
?: invalid("The bundle file is missing")
|
|
196
|
+
if (bundle.size == 0L) invalid("The bundle file is empty")
|
|
197
|
+
return files
|
|
198
|
+
}
|
|
199
|
+
|
|
143
200
|
private fun parseObject(text: String): JSONObject = try {
|
|
144
201
|
JSONObject(text)
|
|
145
202
|
} catch (error: JSONException) {
|
|
@@ -29,16 +29,21 @@ class PackageDownloader(
|
|
|
29
29
|
* deletes it. It counts as good only once the whole file matches the signed hash, so a partial
|
|
30
30
|
* or wrong file can never be mistaken for a good one.
|
|
31
31
|
*
|
|
32
|
+
* @param hash the signed SHA-256 of the whole file.
|
|
33
|
+
* @param size the signed size of the whole file.
|
|
34
|
+
* @param extraSpace room needed beyond the file itself, such as for extracting it.
|
|
32
35
|
* @param onProgress receives bytes received so far and the total.
|
|
33
36
|
*/
|
|
34
37
|
fun download(
|
|
35
38
|
url: String,
|
|
36
|
-
|
|
39
|
+
hash: String,
|
|
40
|
+
size: Long,
|
|
37
41
|
target: File,
|
|
42
|
+
extraSpace: Long = 0,
|
|
38
43
|
onProgress: (received: Long, total: Long) -> Unit = { _, _ -> },
|
|
39
44
|
) {
|
|
40
45
|
try {
|
|
41
|
-
downloadToFile(url,
|
|
46
|
+
downloadToFile(url, Expected(hash, size), target, extraSpace, onProgress)
|
|
42
47
|
} catch (error: RovqException) {
|
|
43
48
|
if (error.code != RovqErrorCode.DOWNLOAD_FAILED) target.delete()
|
|
44
49
|
throw error
|
|
@@ -48,10 +53,17 @@ class PackageDownloader(
|
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/** The signed facts a download must end up matching. */
|
|
57
|
+
private data class Expected(val hash: String, val size: Long)
|
|
58
|
+
|
|
59
|
+
/** Room left in a folder. Replaced in tests. */
|
|
60
|
+
internal fun freeSpaceIn(folder: File): Long = freeSpace(folder)
|
|
61
|
+
|
|
51
62
|
private fun downloadToFile(
|
|
52
63
|
url: String,
|
|
53
|
-
|
|
64
|
+
expected: Expected,
|
|
54
65
|
target: File,
|
|
66
|
+
extraSpace: Long,
|
|
55
67
|
onProgress: (Long, Long) -> Unit,
|
|
56
68
|
) {
|
|
57
69
|
val parsed = try {
|
|
@@ -64,11 +76,15 @@ class PackageDownloader(
|
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
val digest = MessageDigest.getInstance("SHA-256")
|
|
67
|
-
val existing = readExisting(target,
|
|
68
|
-
checkFreeSpace(target,
|
|
79
|
+
val existing = readExisting(target, expected, digest)
|
|
80
|
+
checkFreeSpace(target, expected.size - existing + extraSpace)
|
|
69
81
|
var received = existing
|
|
70
|
-
if (existing ==
|
|
71
|
-
|
|
82
|
+
if (existing == expected.size) {
|
|
83
|
+
// An empty file has nothing to download, but must still exist.
|
|
84
|
+
if (!target.isFile && !target.createNewFile()) {
|
|
85
|
+
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not create the file")
|
|
86
|
+
}
|
|
87
|
+
verifyHash(digest, expected)
|
|
72
88
|
return
|
|
73
89
|
}
|
|
74
90
|
|
|
@@ -119,7 +135,7 @@ class PackageDownloader(
|
|
|
119
135
|
if (count < 0) break
|
|
120
136
|
received += count
|
|
121
137
|
// Stops at once, so a wrong server cannot fill the disk.
|
|
122
|
-
if (received >
|
|
138
|
+
if (received > expected.size) {
|
|
123
139
|
throw RovqException(
|
|
124
140
|
RovqErrorCode.SIZE_MISMATCH,
|
|
125
141
|
"Download is larger than the signed size",
|
|
@@ -131,7 +147,7 @@ class PackageDownloader(
|
|
|
131
147
|
} catch (error: IOException) {
|
|
132
148
|
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Could not write the file", error)
|
|
133
149
|
}
|
|
134
|
-
onProgress(received,
|
|
150
|
+
onProgress(received, expected.size)
|
|
135
151
|
}
|
|
136
152
|
}
|
|
137
153
|
// Forces the bytes to disk, so power loss cannot leave a file that looks complete.
|
|
@@ -143,16 +159,16 @@ class PackageDownloader(
|
|
|
143
159
|
|
|
144
160
|
// A lost connection can look like a normal end of file, so a short download counts
|
|
145
161
|
// as a network failure, which is worth retrying.
|
|
146
|
-
if (received <
|
|
162
|
+
if (received < expected.size) {
|
|
147
163
|
throw RovqException(RovqErrorCode.DOWNLOAD_FAILED, "The download ended early")
|
|
148
164
|
}
|
|
149
|
-
verifyHash(digest,
|
|
165
|
+
verifyHash(digest, expected)
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
/** @return how many bytes are already there, after adding them to [digest]. */
|
|
153
|
-
private fun readExisting(target: File,
|
|
169
|
+
private fun readExisting(target: File, expected: Expected, digest: MessageDigest): Long {
|
|
154
170
|
if (!target.isFile) return 0
|
|
155
|
-
if (target.length() >
|
|
171
|
+
if (target.length() > expected.size) {
|
|
156
172
|
target.delete()
|
|
157
173
|
return 0
|
|
158
174
|
}
|
|
@@ -167,21 +183,16 @@ class PackageDownloader(
|
|
|
167
183
|
return target.length()
|
|
168
184
|
}
|
|
169
185
|
|
|
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
|
|
186
|
+
private fun checkFreeSpace(target: File, needed: Long) {
|
|
176
187
|
val folder = target.parentFile ?: target
|
|
177
188
|
if (freeSpace(folder) < needed) {
|
|
178
189
|
throw RovqException(RovqErrorCode.STORAGE_FAILED, "Not enough free space for this update")
|
|
179
190
|
}
|
|
180
191
|
}
|
|
181
192
|
|
|
182
|
-
private fun verifyHash(digest: MessageDigest,
|
|
193
|
+
private fun verifyHash(digest: MessageDigest, expected: Expected) {
|
|
183
194
|
val hash = digest.digest().joinToString("") { "%02x".format(it) }
|
|
184
|
-
if (hash !=
|
|
195
|
+
if (hash != expected.hash) {
|
|
185
196
|
throw RovqException(RovqErrorCode.HASH_MISMATCH, "Download does not match the signed hash")
|
|
186
197
|
}
|
|
187
198
|
}
|
|
@@ -104,6 +104,8 @@ class RovqEngine(
|
|
|
104
104
|
private val rootDir: File,
|
|
105
105
|
private val config: EngineConfig,
|
|
106
106
|
private val service: UpdateService = HttpUpdateService(config.apiUrl, config.allowInsecureUrls),
|
|
107
|
+
/** The app's own assets, which a release made of files reuses instead of downloading. */
|
|
108
|
+
builtInFiles: BuiltInFiles? = null,
|
|
107
109
|
private val clock: () -> Long = System::currentTimeMillis,
|
|
108
110
|
) {
|
|
109
111
|
private val store: EngineStateStore
|
|
@@ -126,6 +128,7 @@ class RovqEngine(
|
|
|
126
128
|
rootDir,
|
|
127
129
|
verifier,
|
|
128
130
|
PackageDownloader(config.allowInsecureUrls),
|
|
131
|
+
builtInFiles,
|
|
129
132
|
)
|
|
130
133
|
state = loadState()
|
|
131
134
|
verifier.revokedPublicKeys = state.revokedPublicKeys
|
|
@@ -396,7 +399,11 @@ class RovqEngine(
|
|
|
396
399
|
state.highestSequence
|
|
397
400
|
}
|
|
398
401
|
return try {
|
|
399
|
-
val
|
|
402
|
+
val deviceId = synchronized(this) { state.deviceId }
|
|
403
|
+
val links = FileLinks { releaseId, hashes ->
|
|
404
|
+
service.fileLinks(config.deploymentKey, deviceId, releaseId, hashes)
|
|
405
|
+
}
|
|
406
|
+
val manifest = installer.install(offered, highestSequence, links, onProgress).manifest
|
|
400
407
|
synchronized(this) {
|
|
401
408
|
save(
|
|
402
409
|
state.copy(
|