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.
@@ -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
- staging = File(root, ".staging-$safeId-${UUID.randomUUID()}")
107
- val failure = stageUpdate(staging, launchUrl, launchHash, assets)
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 stageUpdate(staging: File, launchUrl: String, launchHash: String?, assets: List<UbriotDownloadAsset>): String? {
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
- downloadFile(launchUrl, File(staging, BUNDLE_FILE), launchHash)?.let { return it }
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
- downloadFile(asset.url, File(staging, relative), asset.hash)?.let { return it }
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())
@@ -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
- downloadUpdate?(request: NativeDownloadRequest): Promise<{
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 reportLaunch(outcome, updateId) {
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
- outcome,
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>;