ubriot-updates-rn 0.2.0 → 2.0.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 +68 -4
- package/android/build.gradle +1 -0
- package/android/consumer-rules.pro +4 -0
- package/android/src/main/java/dev/ubriot/updates/UbriotCodeSigning.kt +334 -0
- package/android/src/main/java/dev/ubriot/updates/UbriotUpdatesModule.kt +322 -5
- package/code-signing.js +1 -0
- package/dist/client.d.ts +22 -1
- package/dist/client.js +68 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/manifest-headers.d.ts +1 -0
- package/ios/UbriotCodeSigning.h +42 -0
- package/ios/UbriotCodeSigning.m +521 -0
- package/ios/UbriotUpdates.m +309 -5
- package/package.json +6 -5
- package/plugin/__tests__/code-signing.test.js +67 -0
- package/plugin/__tests__/transforms.test.js +106 -0
- package/plugin/code-signing-config.js +75 -0
- package/plugin/transforms.js +22 -3
- package/plugin/withUbriotCodeSigning.js +24 -0
- package/plugin/withUbriotUpdates.js +22 -2
- package/src/client.ts +74 -17
- package/src/index.ts +4 -1
- package/src/manifest-headers.ts +1 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package dev.ubriot.updates
|
|
2
2
|
|
|
3
3
|
import android.content.Context
|
|
4
|
+
import android.content.pm.PackageManager
|
|
4
5
|
import android.util.Base64
|
|
5
6
|
import com.facebook.react.ReactApplication
|
|
6
7
|
import com.facebook.react.bridge.Arguments
|
|
@@ -19,12 +20,22 @@ import java.net.HttpURLConnection
|
|
|
19
20
|
import java.net.URL
|
|
20
21
|
import java.security.MessageDigest
|
|
21
22
|
import java.util.UUID
|
|
23
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
22
24
|
import java.util.concurrent.Executors
|
|
23
25
|
|
|
24
26
|
data class UbriotUpdateEntry(val id: String, val bundlePath: String)
|
|
25
27
|
|
|
26
28
|
data class UbriotDownloadAsset(val key: String, val url: String, val hash: String?, val fileExtension: String)
|
|
27
29
|
|
|
30
|
+
data class UbriotVerifiedManifest(
|
|
31
|
+
val id: String,
|
|
32
|
+
val runtimeVersion: String,
|
|
33
|
+
val createdAt: String?,
|
|
34
|
+
val launchUrl: String,
|
|
35
|
+
val launchHash: String?,
|
|
36
|
+
val assets: List<UbriotDownloadAsset>,
|
|
37
|
+
)
|
|
38
|
+
|
|
28
39
|
class UbriotUpdateState(
|
|
29
40
|
var runningId: String? = null,
|
|
30
41
|
var active: UbriotUpdateEntry? = null,
|
|
@@ -34,6 +45,8 @@ class UbriotUpdateState(
|
|
|
34
45
|
var launchCount: Int = 0,
|
|
35
46
|
var revertedFromId: String? = null,
|
|
36
47
|
var failedUpdateIds: MutableList<String> = mutableListOf(),
|
|
48
|
+
var newestAcceptedCreatedAt: MutableMap<String, String> = mutableMapOf(),
|
|
49
|
+
var acceptedUpdateIds: MutableList<String> = mutableListOf(),
|
|
37
50
|
)
|
|
38
51
|
|
|
39
52
|
class UbriotUpdatesModule(
|
|
@@ -48,6 +61,7 @@ class UbriotUpdatesModule(
|
|
|
48
61
|
val constants = HashMap<String, Any?>()
|
|
49
62
|
constants["embeddedBundleUrl"] = null
|
|
50
63
|
constants["supportsAssets"] = true
|
|
64
|
+
constants["codeSigningEnabled"] = signingConfig() != null
|
|
51
65
|
constants["runningUpdate"] = try {
|
|
52
66
|
val entry = runningEntry
|
|
53
67
|
if (entry == null) null else hashMapOf<String, Any?>("id" to entry.id, "assets" to assetFileUrls(entry.bundlePath, filesDir()))
|
|
@@ -66,8 +80,157 @@ class UbriotUpdatesModule(
|
|
|
66
80
|
}
|
|
67
81
|
}
|
|
68
82
|
|
|
83
|
+
private fun signingConfig(): UbriotCodeSigning.Config? = codeSigningConfig(reactContext.applicationContext)
|
|
84
|
+
|
|
85
|
+
@ReactMethod
|
|
86
|
+
fun fetchManifest(request: ReadableMap, promise: Promise) {
|
|
87
|
+
val url = optString(request, "url")
|
|
88
|
+
val runtimeVersion = optString(request, "runtimeVersion")
|
|
89
|
+
val appSlug = optString(request, "appSlug")
|
|
90
|
+
if (url == null || runtimeVersion == null || appSlug == null) {
|
|
91
|
+
promise.reject("ubriot_updates_bad_args", "url, runtimeVersion and appSlug are required")
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
val headers = HashMap<String, String>()
|
|
95
|
+
if (request.hasKey("headers") && request.getType("headers") == ReadableType.Map) {
|
|
96
|
+
val map = request.getMap("headers")
|
|
97
|
+
val iterator = map?.keySetIterator()
|
|
98
|
+
while (map != null && iterator != null && iterator.hasNextKey()) {
|
|
99
|
+
val key = iterator.nextKey()
|
|
100
|
+
optString(map, key)?.let { headers[key] = it }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
downloadExecutor.execute {
|
|
104
|
+
try {
|
|
105
|
+
promise.resolve(resolveManifest(url, headers, runtimeVersion, appSlug))
|
|
106
|
+
} catch (error: Throwable) {
|
|
107
|
+
promise.reject("ubriot_updates_fetch_failed", error)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private fun rejected(reason: String): WritableMap {
|
|
113
|
+
val map = Arguments.createMap()
|
|
114
|
+
map.putString("status", "rejected")
|
|
115
|
+
map.putString("reason", reason)
|
|
116
|
+
return map
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun readLimited(connection: HttpURLConnection): ByteArray? {
|
|
120
|
+
connection.inputStream.use { input ->
|
|
121
|
+
val buffer = java.io.ByteArrayOutputStream()
|
|
122
|
+
val chunk = ByteArray(16 * 1024)
|
|
123
|
+
while (true) {
|
|
124
|
+
val read = input.read(chunk)
|
|
125
|
+
if (read < 0) break
|
|
126
|
+
buffer.write(chunk, 0, read)
|
|
127
|
+
if (buffer.size() > MAX_MANIFEST_BYTES) return null
|
|
128
|
+
}
|
|
129
|
+
return buffer.toByteArray()
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private fun resolveManifest(url: String, headers: Map<String, String>, runtimeVersion: String, appSlug: String): WritableMap {
|
|
134
|
+
val config = signingConfig()
|
|
135
|
+
val connection = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
136
|
+
instanceFollowRedirects = true
|
|
137
|
+
connectTimeout = 30000
|
|
138
|
+
readTimeout = 60000
|
|
139
|
+
requestMethod = "GET"
|
|
140
|
+
useCaches = false
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
headers.forEach { (key, value) -> connection.setRequestProperty(key, value) }
|
|
144
|
+
connection.setRequestProperty("accept", "multipart/mixed,application/expo+json,application/json")
|
|
145
|
+
val primary = config?.trusted?.firstOrNull()
|
|
146
|
+
if (primary != null) {
|
|
147
|
+
connection.setRequestProperty("expo-expect-signature", UbriotCodeSigning.expectSignatureHeader(primary.keyid))
|
|
148
|
+
}
|
|
149
|
+
val status = connection.responseCode
|
|
150
|
+
if (status == 204 || status < 200 || status >= 300) {
|
|
151
|
+
val none = Arguments.createMap()
|
|
152
|
+
none.putString("status", "none")
|
|
153
|
+
return none
|
|
154
|
+
}
|
|
155
|
+
val body = readLimited(connection) ?: return rejected("parse-error")
|
|
156
|
+
val manifestBytes = if (config != null) {
|
|
157
|
+
val parsed = try {
|
|
158
|
+
UbriotCodeSigning.parseResponse(connection.contentType, connection.getHeaderField("expo-signature"), body)
|
|
159
|
+
} catch (error: UbriotCodeSigning.RejectedException) {
|
|
160
|
+
return rejected(error.reason)
|
|
161
|
+
}
|
|
162
|
+
when (val outcome = UbriotCodeSigning.verify(config, parsed)) {
|
|
163
|
+
is UbriotCodeSigning.Outcome.Rejected -> return rejected(outcome.reason)
|
|
164
|
+
is UbriotCodeSigning.Outcome.Verified -> outcome.body
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
body
|
|
168
|
+
}
|
|
169
|
+
val json = try {
|
|
170
|
+
JSONObject(String(manifestBytes, Charsets.UTF_8))
|
|
171
|
+
} catch (error: Throwable) {
|
|
172
|
+
return rejected("parse-error")
|
|
173
|
+
}
|
|
174
|
+
val manifest = parseVerifiedManifest(json, requireHashes = config != null) ?: return rejected("parse-error")
|
|
175
|
+
if (config != null) {
|
|
176
|
+
val state = readState(filesDir())
|
|
177
|
+
val extra = json.optJSONObject("extra")?.optJSONObject("ubriot")
|
|
178
|
+
val slugs = extra?.optJSONArray("appSlugs")?.let { array -> (0 until array.length()).map { array.optString(it, "") }.filter { it.isNotEmpty() } }
|
|
179
|
+
?: extra?.optString("appSlug", "")?.takeIf { it.isNotEmpty() }?.let { listOf(it) }
|
|
180
|
+
val boundAppId = if (extra != null && extra.has("appId") && !extra.isNull("appId")) extra.get("appId").toString() else null
|
|
181
|
+
val allowed = setOfNotNull(state.runningId, state.active?.id, state.lastKnownGood?.id) + state.acceptedUpdateIds
|
|
182
|
+
val mismatch = UbriotCodeSigning.checkBindings(
|
|
183
|
+
runtimeVersion,
|
|
184
|
+
appSlug,
|
|
185
|
+
manifest.runtimeVersion,
|
|
186
|
+
boundAppId,
|
|
187
|
+
slugs,
|
|
188
|
+
manifest.id,
|
|
189
|
+
manifest.createdAt,
|
|
190
|
+
state.newestAcceptedCreatedAt[runtimeVersion],
|
|
191
|
+
allowed,
|
|
192
|
+
"android",
|
|
193
|
+
extra?.optString("platform", "")?.takeIf { it.isNotEmpty() },
|
|
194
|
+
)
|
|
195
|
+
if (mismatch != null) return rejected("binding-mismatch")
|
|
196
|
+
}
|
|
197
|
+
val token = UUID.randomUUID().toString()
|
|
198
|
+
verifiedManifests.clear()
|
|
199
|
+
verifiedManifests[token] = manifest
|
|
200
|
+
val result = Arguments.createMap()
|
|
201
|
+
result.putString("status", "available")
|
|
202
|
+
result.putString("token", token)
|
|
203
|
+
result.putString("manifestId", manifest.id)
|
|
204
|
+
result.putString("verification", if (config != null) "verified" else "unsigned-allowed")
|
|
205
|
+
return result
|
|
206
|
+
} finally {
|
|
207
|
+
connection.disconnect()
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
69
211
|
@ReactMethod
|
|
70
212
|
fun downloadUpdate(request: ReadableMap, promise: Promise) {
|
|
213
|
+
val token = optString(request, "token")
|
|
214
|
+
if (token != null) {
|
|
215
|
+
val manifest = verifiedManifests.remove(token)
|
|
216
|
+
if (manifest == null) {
|
|
217
|
+
promise.reject("ubriot_updates_unknown_token", "Unknown or expired update token. Check for updates again.")
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
stageAndPersist(manifest.id, manifest.launchUrl, manifest.launchHash, manifest.assets, promise) { state ->
|
|
221
|
+
UbriotCodeSigning.laterInstant(state.newestAcceptedCreatedAt[manifest.runtimeVersion], manifest.createdAt)?.let {
|
|
222
|
+
state.newestAcceptedCreatedAt[manifest.runtimeVersion] = it
|
|
223
|
+
}
|
|
224
|
+
state.acceptedUpdateIds.remove(manifest.id)
|
|
225
|
+
state.acceptedUpdateIds.add(manifest.id)
|
|
226
|
+
while (state.acceptedUpdateIds.size > MAX_ACCEPTED_IDS) state.acceptedUpdateIds.removeAt(0)
|
|
227
|
+
}
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
if (signingConfig() != null) {
|
|
231
|
+
promise.reject("ubriot_updates_signing_required", "Code signing is enabled: download updates through fetchManifest")
|
|
232
|
+
return
|
|
233
|
+
}
|
|
71
234
|
val manifestId = if (request.hasKey("id") && request.getType("id") == ReadableType.String) request.getString("id") else null
|
|
72
235
|
if (manifestId.isNullOrEmpty()) {
|
|
73
236
|
promise.reject("ubriot_updates_bad_args", "id is required")
|
|
@@ -97,14 +260,27 @@ class UbriotUpdatesModule(
|
|
|
97
260
|
}
|
|
98
261
|
}
|
|
99
262
|
|
|
263
|
+
stageAndPersist(manifestId, launchUrl, launchHash, assets, promise) {}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private fun stageAndPersist(
|
|
267
|
+
manifestId: String,
|
|
268
|
+
launchUrl: String,
|
|
269
|
+
launchHash: String?,
|
|
270
|
+
assets: List<UbriotDownloadAsset>,
|
|
271
|
+
promise: Promise,
|
|
272
|
+
onPersisted: (UbriotUpdateState) -> Unit,
|
|
273
|
+
) {
|
|
100
274
|
downloadExecutor.execute {
|
|
101
275
|
var staging: File? = null
|
|
102
276
|
try {
|
|
103
277
|
val root = updatesDir(filesDir())
|
|
104
278
|
if (!root.exists()) root.mkdirs()
|
|
105
279
|
val safeId = sanitize(manifestId)
|
|
106
|
-
|
|
107
|
-
|
|
280
|
+
removeAbandonedStagingDirectories(root)
|
|
281
|
+
staging = File(root, "$STAGING_PREFIX$safeId-${UUID.randomUUID()}")
|
|
282
|
+
val cacheUsed = HashSet<String>()
|
|
283
|
+
val failure = stageUpdate(staging, File(root, CACHE_DIR_NAME), launchUrl, launchHash, assets, cacheUsed)
|
|
108
284
|
if (failure != null) {
|
|
109
285
|
staging.deleteRecursively()
|
|
110
286
|
promise.reject("ubriot_updates_download_failed", failure)
|
|
@@ -117,9 +293,11 @@ class UbriotUpdatesModule(
|
|
|
117
293
|
promise.reject("ubriot_updates_write_failed", "Could not persist update")
|
|
118
294
|
return@execute
|
|
119
295
|
}
|
|
296
|
+
pruneCache(File(root, CACHE_DIR_NAME), cacheUsed)
|
|
120
297
|
val bundle = File(finalDirectory, BUNDLE_FILE)
|
|
121
298
|
val state = readState(filesDir())
|
|
122
299
|
state.pending = UbriotUpdateEntry(manifestId, bundle.absolutePath)
|
|
300
|
+
onPersisted(state)
|
|
123
301
|
writeState(filesDir(), state)
|
|
124
302
|
|
|
125
303
|
val result = Arguments.createMap()
|
|
@@ -135,6 +313,10 @@ class UbriotUpdatesModule(
|
|
|
135
313
|
|
|
136
314
|
@ReactMethod
|
|
137
315
|
fun downloadBundle(url: String, manifestId: String, promise: Promise) {
|
|
316
|
+
if (signingConfig() != null) {
|
|
317
|
+
promise.reject("ubriot_updates_signing_required", "Code signing is enabled: download updates through fetchManifest")
|
|
318
|
+
return
|
|
319
|
+
}
|
|
138
320
|
Thread {
|
|
139
321
|
var connection: HttpURLConnection? = null
|
|
140
322
|
try {
|
|
@@ -279,7 +461,79 @@ class UbriotUpdatesModule(
|
|
|
279
461
|
private const val STATE_FILE = "state.json"
|
|
280
462
|
private const val BUNDLE_FILE = "index.jsbundle"
|
|
281
463
|
private const val ASSET_INDEX_FILE = "assets.json"
|
|
464
|
+
private const val CACHE_DIR_NAME = "cache"
|
|
465
|
+
private const val STAGING_PREFIX = ".staging-"
|
|
282
466
|
private const val MAX_FAILED_IDS = 20
|
|
467
|
+
private const val MAX_MANIFEST_BYTES = 5 * 1024 * 1024
|
|
468
|
+
private const val MAX_ACCEPTED_IDS = 50
|
|
469
|
+
private const val META_CERTIFICATE = "dev.ubriot.updates.CODE_SIGNING_CERTIFICATE"
|
|
470
|
+
private const val META_METADATA = "dev.ubriot.updates.CODE_SIGNING_METADATA"
|
|
471
|
+
private const val META_INCLUDE_CHAIN = "dev.ubriot.updates.CODE_SIGNING_INCLUDE_CHAIN"
|
|
472
|
+
private const val META_ADDITIONAL = "dev.ubriot.updates.ADDITIONAL_CERTIFICATES"
|
|
473
|
+
|
|
474
|
+
private val verifiedManifests = ConcurrentHashMap<String, UbriotVerifiedManifest>()
|
|
475
|
+
|
|
476
|
+
@Volatile
|
|
477
|
+
private var signingConfigLoaded = false
|
|
478
|
+
|
|
479
|
+
@Volatile
|
|
480
|
+
private var cachedSigningConfig: UbriotCodeSigning.Config? = null
|
|
481
|
+
|
|
482
|
+
fun codeSigningConfig(context: Context): UbriotCodeSigning.Config? {
|
|
483
|
+
if (signingConfigLoaded) return cachedSigningConfig
|
|
484
|
+
val config = try {
|
|
485
|
+
val metaData = context.packageManager.getApplicationInfo(context.packageName, PackageManager.GET_META_DATA).metaData
|
|
486
|
+
val certificate = metaData?.getString(META_CERTIFICATE)
|
|
487
|
+
if (metaData == null || certificate.isNullOrBlank()) {
|
|
488
|
+
null
|
|
489
|
+
} else {
|
|
490
|
+
val metadata = metaData.getString(META_METADATA)?.let { JSONObject(it) }
|
|
491
|
+
val keyid = metadata?.optString("keyid", "")?.ifEmpty { null } ?: UbriotCodeSigning.DEFAULT_KEYID
|
|
492
|
+
val trusted = mutableListOf(UbriotCodeSigning.TrustedCertificate(certificate, keyid))
|
|
493
|
+
metaData.getString(META_ADDITIONAL)?.let { raw ->
|
|
494
|
+
val array = JSONArray(raw)
|
|
495
|
+
for (index in 0 until array.length()) {
|
|
496
|
+
val entry = array.optJSONObject(index) ?: continue
|
|
497
|
+
val pem = entry.optString("certificate", "")
|
|
498
|
+
val entryKeyid = entry.optString("keyid", "")
|
|
499
|
+
if (pem.isNotEmpty() && entryKeyid.isNotEmpty()) trusted.add(UbriotCodeSigning.TrustedCertificate(pem, entryKeyid))
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
UbriotCodeSigning.Config(trusted, metaData.getBoolean(META_INCLUDE_CHAIN, false))
|
|
503
|
+
}
|
|
504
|
+
} catch (error: Throwable) {
|
|
505
|
+
UbriotCodeSigning.Config(emptyList(), false)
|
|
506
|
+
}
|
|
507
|
+
cachedSigningConfig = config
|
|
508
|
+
signingConfigLoaded = true
|
|
509
|
+
return config
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private fun parseVerifiedManifest(json: JSONObject, requireHashes: Boolean): UbriotVerifiedManifest? {
|
|
513
|
+
val id = json.optString("id", "")
|
|
514
|
+
val runtimeVersion = json.optString("runtimeVersion", "")
|
|
515
|
+
val launch = json.optJSONObject("launchAsset") ?: return null
|
|
516
|
+
val launchUrl = launch.optString("url", "")
|
|
517
|
+
val launchHash = launch.optString("hash", "").ifEmpty { null }
|
|
518
|
+
if (id.isEmpty() || launchUrl.isEmpty()) return null
|
|
519
|
+
if (requireHashes && launchHash == null) return null
|
|
520
|
+
val assets = mutableListOf<UbriotDownloadAsset>()
|
|
521
|
+
val array = json.optJSONArray("assets")
|
|
522
|
+
if (array != null) {
|
|
523
|
+
for (index in 0 until array.length()) {
|
|
524
|
+
val item = array.optJSONObject(index) ?: return null
|
|
525
|
+
val key = item.optString("key", "")
|
|
526
|
+
val url = item.optString("url", "")
|
|
527
|
+
val hash = item.optString("hash", "").ifEmpty { null }
|
|
528
|
+
if (key.isEmpty() || url.isEmpty()) return null
|
|
529
|
+
if (requireHashes && hash == null) return null
|
|
530
|
+
val extension = item.optString("fileExtension", "")
|
|
531
|
+
assets.add(UbriotDownloadAsset(key, url, hash, if (extension.isEmpty() || extension.startsWith(".")) extension else ".$extension"))
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
val createdAt = json.optString("createdAt", "").ifEmpty { null }
|
|
535
|
+
return UbriotVerifiedManifest(id, runtimeVersion, createdAt, launchUrl, launchHash, assets)
|
|
536
|
+
}
|
|
283
537
|
|
|
284
538
|
private val downloadExecutor = Executors.newSingleThreadExecutor()
|
|
285
539
|
|
|
@@ -365,16 +619,63 @@ class UbriotUpdatesModule(
|
|
|
365
619
|
}
|
|
366
620
|
}
|
|
367
621
|
|
|
368
|
-
private fun
|
|
622
|
+
private fun cacheNameForHash(hash: String?): String? {
|
|
623
|
+
if (hash.isNullOrEmpty()) return null
|
|
624
|
+
return sanitize(hash.replace('+', '-').replace('/', '_').trimEnd('='))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private fun fetchFile(url: String, destination: File, expectedHash: String?, cacheDir: File, cacheUsed: MutableSet<String>): String? {
|
|
628
|
+
val cacheName = cacheNameForHash(expectedHash) ?: return downloadFile(url, destination, expectedHash)
|
|
629
|
+
cacheUsed.add(cacheName)
|
|
630
|
+
val cached = File(cacheDir, cacheName)
|
|
631
|
+
if (cached.isFile) {
|
|
632
|
+
val reused = try {
|
|
633
|
+
matchesHash(cached, expectedHash) && run {
|
|
634
|
+
destination.parentFile?.mkdirs()
|
|
635
|
+
cached.copyTo(destination, overwrite = true)
|
|
636
|
+
true
|
|
637
|
+
}
|
|
638
|
+
} catch (_: Throwable) {
|
|
639
|
+
false
|
|
640
|
+
}
|
|
641
|
+
if (reused) return null
|
|
642
|
+
cached.delete()
|
|
643
|
+
}
|
|
644
|
+
downloadFile(url, destination, expectedHash)?.let { return it }
|
|
645
|
+
try {
|
|
646
|
+
cacheDir.mkdirs()
|
|
647
|
+
destination.copyTo(cached, overwrite = true)
|
|
648
|
+
} catch (_: Throwable) {
|
|
649
|
+
cached.delete()
|
|
650
|
+
}
|
|
651
|
+
return null
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
private fun pruneCache(cacheDir: File, keep: Set<String>) {
|
|
655
|
+
cacheDir.listFiles()?.forEach { file -> if (file.name !in keep) file.deleteRecursively() }
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private fun removeAbandonedStagingDirectories(root: File) {
|
|
659
|
+
root.listFiles()?.forEach { file -> if (file.name.startsWith(STAGING_PREFIX)) file.deleteRecursively() }
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private fun stageUpdate(
|
|
663
|
+
staging: File,
|
|
664
|
+
cacheDir: File,
|
|
665
|
+
launchUrl: String,
|
|
666
|
+
launchHash: String?,
|
|
667
|
+
assets: List<UbriotDownloadAsset>,
|
|
668
|
+
cacheUsed: MutableSet<String>,
|
|
669
|
+
): String? {
|
|
369
670
|
val assetsDirectory = File(staging, "assets")
|
|
370
671
|
if (!assetsDirectory.mkdirs() && !assetsDirectory.isDirectory) return "Could not create update directory"
|
|
371
|
-
|
|
672
|
+
fetchFile(launchUrl, File(staging, BUNDLE_FILE), launchHash, cacheDir, cacheUsed)?.let { return it }
|
|
372
673
|
val index = JSONObject()
|
|
373
674
|
for (asset in assets) {
|
|
374
675
|
if (index.has(asset.key)) continue
|
|
375
676
|
val extension = if (asset.fileExtension.isEmpty()) "" else sanitize(asset.fileExtension)
|
|
376
677
|
val relative = "assets/${sanitize(asset.key)}$extension"
|
|
377
|
-
|
|
678
|
+
fetchFile(asset.url, File(staging, relative), asset.hash, cacheDir, cacheUsed)?.let { return it }
|
|
378
679
|
index.put(asset.key, relative)
|
|
379
680
|
}
|
|
380
681
|
return try {
|
|
@@ -426,6 +727,18 @@ class UbriotUpdatesModule(
|
|
|
426
727
|
return ids
|
|
427
728
|
}
|
|
428
729
|
|
|
730
|
+
private fun newestFromJson(obj: JSONObject?): MutableMap<String, String> {
|
|
731
|
+
val out = mutableMapOf<String, String>()
|
|
732
|
+
if (obj == null) return out
|
|
733
|
+
val keys = obj.keys()
|
|
734
|
+
while (keys.hasNext()) {
|
|
735
|
+
val key = keys.next()
|
|
736
|
+
val value = obj.optString(key, "")
|
|
737
|
+
if (value.isNotEmpty()) out[key] = value
|
|
738
|
+
}
|
|
739
|
+
return out
|
|
740
|
+
}
|
|
741
|
+
|
|
429
742
|
private fun recordFailedId(state: UbriotUpdateState, id: String) {
|
|
430
743
|
state.failedUpdateIds.remove(id)
|
|
431
744
|
state.failedUpdateIds.add(id)
|
|
@@ -447,6 +760,8 @@ class UbriotUpdatesModule(
|
|
|
447
760
|
launchCount = json.optInt("launchCount", 0),
|
|
448
761
|
revertedFromId = json.optString("revertedFromId", "").ifEmpty { null },
|
|
449
762
|
failedUpdateIds = failedIdsFromJson(json.optJSONArray("failedUpdateIds")),
|
|
763
|
+
newestAcceptedCreatedAt = newestFromJson(json.optJSONObject("newestAcceptedCreatedAt")),
|
|
764
|
+
acceptedUpdateIds = failedIdsFromJson(json.optJSONArray("acceptedUpdateIds")),
|
|
450
765
|
)
|
|
451
766
|
} catch (_: Throwable) {
|
|
452
767
|
UbriotUpdateState()
|
|
@@ -467,6 +782,8 @@ class UbriotUpdatesModule(
|
|
|
467
782
|
.put("launchCount", state.launchCount)
|
|
468
783
|
.put("revertedFromId", state.revertedFromId ?: JSONObject.NULL)
|
|
469
784
|
.put("failedUpdateIds", JSONArray(state.failedUpdateIds))
|
|
785
|
+
.put("newestAcceptedCreatedAt", JSONObject(state.newestAcceptedCreatedAt as Map<*, *>))
|
|
786
|
+
.put("acceptedUpdateIds", JSONArray(state.acceptedUpdateIds))
|
|
470
787
|
val target = stateFile(filesDir)
|
|
471
788
|
val temp = File(directory, "$STATE_FILE.tmp")
|
|
472
789
|
temp.writeText(json.toString())
|
package/code-signing.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require("./plugin/withUbriotCodeSigning");
|
package/dist/client.d.ts
CHANGED
|
@@ -40,9 +40,29 @@ export type NativeDownloadRequest = {
|
|
|
40
40
|
fileExtension: string;
|
|
41
41
|
}[];
|
|
42
42
|
};
|
|
43
|
+
export type VerifiedManifestResult = {
|
|
44
|
+
status: "none";
|
|
45
|
+
} | {
|
|
46
|
+
status: "available";
|
|
47
|
+
token: string;
|
|
48
|
+
manifestId: string;
|
|
49
|
+
verification: "verified" | "unsigned-allowed";
|
|
50
|
+
} | {
|
|
51
|
+
status: "rejected";
|
|
52
|
+
reason: string;
|
|
53
|
+
};
|
|
43
54
|
export type NativeUbriotUpdates = {
|
|
44
55
|
getState(): Promise<NativeState>;
|
|
45
|
-
|
|
56
|
+
codeSigningEnabled?: boolean;
|
|
57
|
+
fetchManifest?(request: {
|
|
58
|
+
url: string;
|
|
59
|
+
headers: Record<string, string>;
|
|
60
|
+
runtimeVersion: string;
|
|
61
|
+
appSlug: string;
|
|
62
|
+
}): Promise<VerifiedManifestResult>;
|
|
63
|
+
downloadUpdate?(request: NativeDownloadRequest | {
|
|
64
|
+
token: string;
|
|
65
|
+
}): Promise<{
|
|
46
66
|
ready: boolean;
|
|
47
67
|
bundlePath?: string;
|
|
48
68
|
}>;
|
|
@@ -62,6 +82,7 @@ export type UpdatesClientDeps = {
|
|
|
62
82
|
platform: "ios" | "android";
|
|
63
83
|
setTimeout(callback: () => void, ms: number): TimerHandle;
|
|
64
84
|
clearTimeout(handle: TimerHandle): void;
|
|
85
|
+
codeSigningEnabled?(): boolean;
|
|
65
86
|
};
|
|
66
87
|
export declare const DEFAULT_LAUNCH_FALLBACK_MS = 4000;
|
|
67
88
|
export declare function createUpdatesClient(deps: UpdatesClientDeps): {
|
package/dist/client.js
CHANGED
|
@@ -10,9 +10,6 @@ const LAUNCH_PATH = "/ubriot/updates/launch";
|
|
|
10
10
|
function normalizeBase(apiBase) {
|
|
11
11
|
return apiBase.replace(/\/+$/, "");
|
|
12
12
|
}
|
|
13
|
-
async function verifyManifestSignature(_manifest) {
|
|
14
|
-
return true;
|
|
15
|
-
}
|
|
16
13
|
function toDownloadRequest(manifest) {
|
|
17
14
|
return {
|
|
18
15
|
id: manifest.id,
|
|
@@ -28,6 +25,7 @@ function toDownloadRequest(manifest) {
|
|
|
28
25
|
function createUpdatesClient(deps) {
|
|
29
26
|
let config = null;
|
|
30
27
|
let stagedManifest = null;
|
|
28
|
+
let stagedToken = null;
|
|
31
29
|
let launchMarked = false;
|
|
32
30
|
let fallbackTimer = null;
|
|
33
31
|
function optionalNative() {
|
|
@@ -47,19 +45,16 @@ function createUpdatesClient(deps) {
|
|
|
47
45
|
}
|
|
48
46
|
return config;
|
|
49
47
|
}
|
|
50
|
-
function
|
|
48
|
+
function postLaunchReport(body) {
|
|
51
49
|
try {
|
|
52
50
|
if (!config)
|
|
53
51
|
return;
|
|
54
|
-
if (!updateId)
|
|
55
|
-
return;
|
|
56
52
|
const cfg = config;
|
|
57
53
|
const payload = JSON.stringify({
|
|
58
54
|
appId: cfg.appSlug,
|
|
59
55
|
channel: cfg.channel,
|
|
60
|
-
updateId,
|
|
61
56
|
platform: deps.platform,
|
|
62
|
-
|
|
57
|
+
...body,
|
|
63
58
|
});
|
|
64
59
|
void deps
|
|
65
60
|
.fetch(`${cfg.apiBase}${LAUNCH_PATH}`, {
|
|
@@ -71,6 +66,25 @@ function createUpdatesClient(deps) {
|
|
|
71
66
|
}
|
|
72
67
|
catch { }
|
|
73
68
|
}
|
|
69
|
+
function reportLaunch(outcome, updateId) {
|
|
70
|
+
if (!updateId)
|
|
71
|
+
return;
|
|
72
|
+
postLaunchReport({ updateId, outcome });
|
|
73
|
+
}
|
|
74
|
+
function reportRejection(reason) {
|
|
75
|
+
postLaunchReport({ outcome: "rejected", reason });
|
|
76
|
+
}
|
|
77
|
+
function signingRequired(native, cfg) {
|
|
78
|
+
var _a;
|
|
79
|
+
if (cfg.requireCodeSigning === true || native.codeSigningEnabled === true)
|
|
80
|
+
return true;
|
|
81
|
+
try {
|
|
82
|
+
return ((_a = deps.codeSigningEnabled) === null || _a === void 0 ? void 0 : _a.call(deps)) === true;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
74
88
|
function reportRevertIfCrashed() {
|
|
75
89
|
try {
|
|
76
90
|
const native = optionalNative();
|
|
@@ -111,6 +125,7 @@ function createUpdatesClient(deps) {
|
|
|
111
125
|
runtimeVersion: next.runtimeVersion,
|
|
112
126
|
nativeBuildId: next.nativeBuildId,
|
|
113
127
|
nativeBuildCreatedAt: next.nativeBuildCreatedAt,
|
|
128
|
+
requireCodeSigning: next.requireCodeSigning,
|
|
114
129
|
};
|
|
115
130
|
reportRevertIfCrashed();
|
|
116
131
|
}
|
|
@@ -140,6 +155,33 @@ function createUpdatesClient(deps) {
|
|
|
140
155
|
});
|
|
141
156
|
const url = `${cfg.apiBase}${MANIFEST_PATH}?${query.toString()}`;
|
|
142
157
|
const headers = (0, manifest_headers_1.buildManifestHeaders)(cfg, deps.platform, runningId);
|
|
158
|
+
stagedManifest = null;
|
|
159
|
+
stagedToken = null;
|
|
160
|
+
if (signingRequired(native, cfg)) {
|
|
161
|
+
if (typeof native.fetchManifest !== "function") {
|
|
162
|
+
reportRejection("native-unsupported");
|
|
163
|
+
return { available: false };
|
|
164
|
+
}
|
|
165
|
+
let result;
|
|
166
|
+
try {
|
|
167
|
+
result = await native.fetchManifest({ url, headers, runtimeVersion: cfg.runtimeVersion, appSlug: cfg.appSlug });
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return { available: false };
|
|
171
|
+
}
|
|
172
|
+
if ((result === null || result === void 0 ? void 0 : result.status) === "rejected") {
|
|
173
|
+
reportRejection(result.reason);
|
|
174
|
+
return { available: false };
|
|
175
|
+
}
|
|
176
|
+
if ((result === null || result === void 0 ? void 0 : result.status) !== "available" || !result.token || !result.manifestId) {
|
|
177
|
+
return { available: false };
|
|
178
|
+
}
|
|
179
|
+
if (result.manifestId === runningId || result.manifestId === pendingId || failedIds.includes(result.manifestId)) {
|
|
180
|
+
return { available: false };
|
|
181
|
+
}
|
|
182
|
+
stagedToken = result.token;
|
|
183
|
+
return { available: true, manifestId: result.manifestId };
|
|
184
|
+
}
|
|
143
185
|
const response = await deps.fetch(url, { method: "GET", headers });
|
|
144
186
|
if (response.status === 204 || response.status === 304 || !response.ok) {
|
|
145
187
|
stagedManifest = null;
|
|
@@ -155,23 +197,34 @@ function createUpdatesClient(deps) {
|
|
|
155
197
|
stagedManifest = null;
|
|
156
198
|
return { available: false };
|
|
157
199
|
}
|
|
158
|
-
const trusted = await verifyManifestSignature(raw);
|
|
159
|
-
if (!trusted) {
|
|
160
|
-
stagedManifest = null;
|
|
161
|
-
return { available: false };
|
|
162
|
-
}
|
|
163
200
|
stagedManifest = manifest;
|
|
164
201
|
return { available: true, manifestId: manifest.id };
|
|
165
202
|
}
|
|
166
203
|
async function downloadUpdate() {
|
|
167
|
-
requireConfig();
|
|
204
|
+
const cfg = requireConfig();
|
|
168
205
|
const native = requireNative();
|
|
169
|
-
if (!stagedManifest) {
|
|
206
|
+
if (!stagedManifest && !stagedToken) {
|
|
170
207
|
const check = await checkForUpdate();
|
|
171
208
|
if (!check.available) {
|
|
172
209
|
return { ready: false };
|
|
173
210
|
}
|
|
174
211
|
}
|
|
212
|
+
if (stagedToken) {
|
|
213
|
+
const token = stagedToken;
|
|
214
|
+
stagedToken = null;
|
|
215
|
+
if (typeof native.downloadUpdate !== "function")
|
|
216
|
+
return { ready: false };
|
|
217
|
+
try {
|
|
218
|
+
const result = await native.downloadUpdate({ token });
|
|
219
|
+
return { ready: !!(result === null || result === void 0 ? void 0 : result.ready) };
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return { ready: false };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (signingRequired(native, cfg)) {
|
|
226
|
+
return { ready: false };
|
|
227
|
+
}
|
|
175
228
|
const manifest = stagedManifest;
|
|
176
229
|
if (!manifest) {
|
|
177
230
|
return { ready: false };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export type { UbriotUpdatesConfig } from "./manifest-headers";
|
|
2
2
|
export { buildManifestHeaders } from "./manifest-headers";
|
|
3
|
-
export type { UpdateCheckResult, DownloadResult, ApplyOptions, SyncResult, LaunchOutcome, } from "./client";
|
|
3
|
+
export type { UpdateCheckResult, DownloadResult, ApplyOptions, SyncResult, LaunchOutcome, VerifiedManifestResult, } from "./client";
|
|
4
4
|
export { DEFAULT_LAUNCH_FALLBACK_MS } from "./client";
|
|
5
5
|
export declare const configure: (next: import("./manifest-headers").UbriotUpdatesConfig) => void;
|
|
6
6
|
export declare const checkForUpdate: () => Promise<import("./client").UpdateCheckResult>;
|
package/dist/index.js
CHANGED
|
@@ -23,7 +23,7 @@ function nativeConstants() {
|
|
|
23
23
|
constants = (_a = native.getConstants()) !== null && _a !== void 0 ? _a : {};
|
|
24
24
|
}
|
|
25
25
|
else if (native) {
|
|
26
|
-
constants = { runningUpdate: native.runningUpdate, embeddedBundleUrl: native.embeddedBundleUrl };
|
|
26
|
+
constants = { runningUpdate: native.runningUpdate, embeddedBundleUrl: native.embeddedBundleUrl, codeSigningEnabled: native.codeSigningEnabled };
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
catch {
|
|
@@ -57,6 +57,7 @@ const client = (0, client_1.createUpdatesClient)({
|
|
|
57
57
|
platform: react_native_1.Platform.OS === "android" ? "android" : "ios",
|
|
58
58
|
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
59
59
|
clearTimeout: (handle) => clearTimeout(handle),
|
|
60
|
+
codeSigningEnabled: () => nativeConstants().codeSigningEnabled === true,
|
|
60
61
|
});
|
|
61
62
|
exports.configure = client.configure;
|
|
62
63
|
exports.checkForUpdate = client.checkForUpdate;
|
|
@@ -5,5 +5,6 @@ export type UbriotUpdatesConfig = {
|
|
|
5
5
|
runtimeVersion: string;
|
|
6
6
|
nativeBuildId?: string | number;
|
|
7
7
|
nativeBuildCreatedAt?: string;
|
|
8
|
+
requireCodeSigning?: boolean;
|
|
8
9
|
};
|
|
9
10
|
export declare function buildManifestHeaders(cfg: UbriotUpdatesConfig, platform: "ios" | "android", runningId: string | null): Record<string, string>;
|