ubriot-updates-rn 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 CHANGED
@@ -14,8 +14,15 @@ native + JS runtime that consumes those updates in a bare app.
14
14
  - Talks to the existing `GET /ubriot/updates/manifest` endpoint (Expo-Updates-style
15
15
  manifest contract, including the `expo-runtime-version`, `expo-channel-name`, and
16
16
  `expo-current-update-id` request headers).
17
- - Downloads the manifest's `launchAsset` bundle to app storage (iOS
18
- `Library/ubriot-updates/`, Android `filesDir/ubriot-updates/`).
17
+ - Downloads the manifest's `launchAsset` bundle AND every asset in `manifest.assets`
18
+ (fonts, images, anything the bundle references) into one folder per update (iOS
19
+ `Library/ubriot-updates/<id>/`, Android `filesDir/ubriot-updates/<id>/`). Redirects are
20
+ followed and each file's SHA-256 is checked when the manifest carries a `hash`. The
21
+ update is only staged once every file is present and verified; any failure discards
22
+ it and the app keeps running what it has.
23
+ - Resolves the bundle's `require('./font.ttf')` / `require('./image.png')` references to
24
+ the downloaded files (matched by content hash), falling back to the copy embedded in
25
+ the native build for anything the update did not ship.
19
26
  - Persists which bundle is `pending`, `active`, `verified`, and `lastKnownGood` in a
20
27
  small `state.json` next to the bundles.
21
28
  - Boots the verified/active downloaded bundle instead of the embedded one, with
@@ -47,6 +54,8 @@ UbriotUpdates.configure({
47
54
  appSlug: "your-app-slug",
48
55
  channel: "production",
49
56
  runtimeVersion: "your-app-1.0.0",
57
+ nativeBuildId: "358",
58
+ nativeBuildCreatedAt: "2026-09-26T00:00:00.000Z",
50
59
  });
51
60
 
52
61
  // Convenience: check -> download -> apply (does not restart by default)
@@ -57,25 +66,60 @@ const { available, manifestId } = await UbriotUpdates.checkForUpdate();
57
66
  const { ready } = await UbriotUpdates.downloadUpdate();
58
67
  await UbriotUpdates.applyUpdate({ restart: true }); // reload JS now
59
68
 
60
- // Call once the app has rendered and run OK. Promotes the active bundle to
61
- // "verified" so it becomes the rollback target instead of being rolled back.
69
+ // Call once the first real screen has rendered (not the splash). Promotes the
70
+ // running bundle to "verified" so it becomes the rollback target instead of being
71
+ // rolled back on the next launch.
62
72
  UbriotUpdates.markLaunchSuccessful();
73
+
74
+ // Fallback only: mark the launch after a delay if you cannot hook a render.
75
+ // Returns a cancel function. An explicit markLaunchSuccessful() cancels it.
76
+ const cancel = UbriotUpdates.markLaunchSuccessfulAfter(4000);
63
77
  ```
64
78
 
65
- Recommended app-start pattern:
79
+ Import the package once at the top of your entry file (`index.js` / `App.tsx`) so its
80
+ asset resolver is installed before any font or image is resolved:
66
81
 
67
82
  ```ts
68
- useEffect(() => {
69
- UbriotUpdates.configure({ /* ... */ });
70
- UbriotUpdates.sync().catch(() => {});
71
- const t = setTimeout(() => UbriotUpdates.markLaunchSuccessful(), 4000);
72
- return () => clearTimeout(t);
73
- }, []);
83
+ import * as UbriotUpdates from "ubriot-updates-rn";
74
84
  ```
75
85
 
86
+ Recommended app-start pattern:
87
+
88
+ ```tsx
89
+ function Root() {
90
+ useEffect(() => {
91
+ UbriotUpdates.configure({ /* ... */ });
92
+ UbriotUpdates.sync().catch(() => {});
93
+ }, []);
94
+ return <App onFirstScreenRendered={() => UbriotUpdates.markLaunchSuccessful()} />;
95
+ }
96
+ ```
97
+
98
+ Call `markLaunchSuccessful()` from the first screen that proves the app works (for
99
+ example in a `useEffect` of the signed-in or signed-out home screen, after fonts have
100
+ loaded). Do not call it from a timer on the splash: a timer marks a hung or broken
101
+ update as good, and then it is never rolled back. `markLaunchSuccessfulAfter(ms)` exists
102
+ only as a fallback for apps that cannot hook a render; it gives crash-on-launch
103
+ protection but not hang protection.
104
+
76
105
  `sync()` downloads and stages the update; it applies on the next launch. Use
77
106
  `applyUpdate({ restart: true })` if you want to reload immediately.
78
107
 
108
+ `nativeBuildId` and `nativeBuildCreatedAt` are optional and let the server-side
109
+ staleness guard tell a stale native shell apart from a stale OTA bundle. Expo apps can
110
+ read these from `Constants.expoConfig?.extra?.ubriotBuild` (set by the config plugin at
111
+ build time) and pass them straight into `configure()`. Bare RN apps that don't use Expo
112
+ config can supply their own build metadata the same way. `nativeBuildCreatedAt` must be
113
+ a UTC ISO string (e.g. `2026-09-26T00:00:00.000Z`).
114
+
115
+ Apps that use a dynamic Expo config (`app.config.js` / `app.config.ts` instead of
116
+ `app.json` / `app.config.json`) are not auto-stamped: the Ubriot build worker only
117
+ writes `expo.extra.ubriotBuild` into static JSON config files, and skips dynamic config
118
+ with a warning in the build logs. Those apps must supply `nativeBuildId` and
119
+ `nativeBuildCreatedAt` to `configure()` themselves (for example, by reading build
120
+ metadata baked in through `app.config.js`'s own logic), otherwise per-device OTA
121
+ staleness checks are skipped for that app.
122
+
79
123
  ## Rollback model
80
124
 
81
125
  - A freshly applied bundle is `active` but unverified.
@@ -84,8 +128,19 @@ useEffect(() => {
84
128
  - If the app boots that bundle again while still unverified (meaning the previous launch
85
129
  never reached `markLaunchSuccessful()`, e.g. it crashed on load), the client reverts to
86
130
  the last known good bundle (or the embedded bundle) instead.
87
- - `markLaunchSuccessful()` marks the active bundle verified and records it as the new last
88
- known good.
131
+ - `markLaunchSuccessful()` marks the bundle that is actually running verified and records
132
+ it as the new last known good. An update that was downloaded and applied during this
133
+ session (and has not run yet) is never marked verified by it.
134
+ - An update that was rolled back is remembered (`failedUpdateIds`) and is not downloaded
135
+ again, so a bad update cannot loop.
136
+
137
+ ## Upgrading from 0.1.x
138
+
139
+ 0.2.0 changes native code, so it needs a new native build (TestFlight / Play). On an old
140
+ native build the new JS falls back to the old bundle-only download. Replace any
141
+ `setTimeout(() => markLaunchSuccessful(), 4000)` with a call from your first rendered
142
+ screen. If you added your own asset source transformer as a workaround for missing OTA
143
+ assets, remove it, or register it after importing this package.
89
144
 
90
145
  ## Native integration
91
146
 
@@ -1,21 +1,30 @@
1
1
  package dev.ubriot.updates
2
2
 
3
3
  import android.content.Context
4
+ import android.util.Base64
4
5
  import com.facebook.react.ReactApplication
5
6
  import com.facebook.react.bridge.Arguments
6
7
  import com.facebook.react.bridge.Promise
7
8
  import com.facebook.react.bridge.ReactApplicationContext
8
9
  import com.facebook.react.bridge.ReactContextBaseJavaModule
9
10
  import com.facebook.react.bridge.ReactMethod
11
+ import com.facebook.react.bridge.ReadableMap
12
+ import com.facebook.react.bridge.ReadableType
10
13
  import com.facebook.react.bridge.UiThreadUtil
11
14
  import com.facebook.react.bridge.WritableMap
15
+ import org.json.JSONArray
12
16
  import org.json.JSONObject
13
17
  import java.io.File
14
18
  import java.net.HttpURLConnection
15
19
  import java.net.URL
20
+ import java.security.MessageDigest
21
+ import java.util.UUID
22
+ import java.util.concurrent.Executors
16
23
 
17
24
  data class UbriotUpdateEntry(val id: String, val bundlePath: String)
18
25
 
26
+ data class UbriotDownloadAsset(val key: String, val url: String, val hash: String?, val fileExtension: String)
27
+
19
28
  class UbriotUpdateState(
20
29
  var runningId: String? = null,
21
30
  var active: UbriotUpdateEntry? = null,
@@ -23,6 +32,8 @@ class UbriotUpdateState(
23
32
  var pending: UbriotUpdateEntry? = null,
24
33
  var lastKnownGood: UbriotUpdateEntry? = null,
25
34
  var launchCount: Int = 0,
35
+ var revertedFromId: String? = null,
36
+ var failedUpdateIds: MutableList<String> = mutableListOf(),
26
37
  )
27
38
 
28
39
  class UbriotUpdatesModule(
@@ -33,6 +44,19 @@ class UbriotUpdatesModule(
33
44
 
34
45
  private fun filesDir(): File = reactContext.applicationContext.filesDir
35
46
 
47
+ override fun getConstants(): MutableMap<String, Any?> {
48
+ val constants = HashMap<String, Any?>()
49
+ constants["embeddedBundleUrl"] = null
50
+ constants["supportsAssets"] = true
51
+ constants["runningUpdate"] = try {
52
+ val entry = runningEntry
53
+ if (entry == null) null else hashMapOf<String, Any?>("id" to entry.id, "assets" to assetFileUrls(entry.bundlePath, filesDir()))
54
+ } catch (_: Throwable) {
55
+ null
56
+ }
57
+ return constants
58
+ }
59
+
36
60
  @ReactMethod
37
61
  fun getState(promise: Promise) {
38
62
  try {
@@ -42,6 +66,73 @@ class UbriotUpdatesModule(
42
66
  }
43
67
  }
44
68
 
69
+ @ReactMethod
70
+ fun downloadUpdate(request: ReadableMap, promise: Promise) {
71
+ val manifestId = if (request.hasKey("id") && request.getType("id") == ReadableType.String) request.getString("id") else null
72
+ if (manifestId.isNullOrEmpty()) {
73
+ promise.reject("ubriot_updates_bad_args", "id is required")
74
+ return
75
+ }
76
+ val launchAsset = if (request.hasKey("launchAsset") && request.getType("launchAsset") == ReadableType.Map) request.getMap("launchAsset") else null
77
+ val launchUrl = launchAsset?.let { optString(it, "url") }
78
+ if (launchUrl.isNullOrEmpty()) {
79
+ promise.reject("ubriot_updates_bad_args", "launchAsset.url is required")
80
+ return
81
+ }
82
+ val launchHash = launchAsset?.let { optString(it, "hash") }
83
+ val assets = mutableListOf<UbriotDownloadAsset>()
84
+ if (request.hasKey("assets") && request.getType("assets") == ReadableType.Array) {
85
+ val array = request.getArray("assets")
86
+ if (array != null) {
87
+ for (index in 0 until array.size()) {
88
+ val item = if (array.getType(index) == ReadableType.Map) array.getMap(index) else null
89
+ val key = item?.let { optString(it, "key") }
90
+ val url = item?.let { optString(it, "url") }
91
+ if (item == null || key.isNullOrEmpty() || url.isNullOrEmpty()) {
92
+ promise.reject("ubriot_updates_bad_args", "Asset key and url are required")
93
+ return
94
+ }
95
+ assets.add(UbriotDownloadAsset(key, url, optString(item, "hash"), optString(item, "fileExtension") ?: ""))
96
+ }
97
+ }
98
+ }
99
+
100
+ downloadExecutor.execute {
101
+ var staging: File? = null
102
+ try {
103
+ val root = updatesDir(filesDir())
104
+ if (!root.exists()) root.mkdirs()
105
+ val safeId = sanitize(manifestId)
106
+ staging = File(root, ".staging-$safeId-${UUID.randomUUID()}")
107
+ val failure = stageUpdate(staging, launchUrl, launchHash, assets)
108
+ if (failure != null) {
109
+ staging.deleteRecursively()
110
+ promise.reject("ubriot_updates_download_failed", failure)
111
+ return@execute
112
+ }
113
+ val finalDirectory = File(root, safeId)
114
+ if (finalDirectory.exists()) finalDirectory.deleteRecursively()
115
+ if (!staging.renameTo(finalDirectory)) {
116
+ staging.deleteRecursively()
117
+ promise.reject("ubriot_updates_write_failed", "Could not persist update")
118
+ return@execute
119
+ }
120
+ val bundle = File(finalDirectory, BUNDLE_FILE)
121
+ val state = readState(filesDir())
122
+ state.pending = UbriotUpdateEntry(manifestId, bundle.absolutePath)
123
+ writeState(filesDir(), state)
124
+
125
+ val result = Arguments.createMap()
126
+ result.putBoolean("ready", true)
127
+ result.putString("bundlePath", bundle.absolutePath)
128
+ promise.resolve(result)
129
+ } catch (error: Throwable) {
130
+ staging?.deleteRecursively()
131
+ promise.reject("ubriot_updates_download_failed", error)
132
+ }
133
+ }
134
+ }
135
+
45
136
  @ReactMethod
46
137
  fun downloadBundle(url: String, manifestId: String, promise: Promise) {
47
138
  Thread {
@@ -112,11 +203,17 @@ class UbriotUpdatesModule(
112
203
  try {
113
204
  val state = readState(filesDir())
114
205
  val active = state.active
115
- if (active != null && !state.verified) {
206
+ val runningId = state.runningId
207
+ val activeIsRunning = active != null && runningId != null && active.id == runningId
208
+ val running = runningEntry
209
+ if (activeIsRunning && !state.verified) {
116
210
  state.verified = true
117
211
  state.lastKnownGood = active
118
212
  state.launchCount = 0
119
213
  writeState(filesDir(), state)
214
+ } else if (!activeIsRunning && runningId != null && state.lastKnownGood?.id != runningId && running != null && running.id == runningId) {
215
+ state.lastKnownGood = running
216
+ writeState(filesDir(), state)
120
217
  }
121
218
  promise.resolve(null)
122
219
  } catch (error: Throwable) {
@@ -124,6 +221,18 @@ class UbriotUpdatesModule(
124
221
  }
125
222
  }
126
223
 
224
+ @ReactMethod
225
+ fun clearRevertedFrom(promise: Promise) {
226
+ try {
227
+ val state = readState(filesDir())
228
+ state.revertedFromId = null
229
+ writeState(filesDir(), state)
230
+ promise.resolve(null)
231
+ } catch (error: Throwable) {
232
+ promise.reject("ubriot_updates_clear_reverted_failed", error)
233
+ }
234
+ }
235
+
127
236
  @ReactMethod
128
237
  fun reload(promise: Promise) {
129
238
  try {
@@ -131,7 +240,7 @@ class UbriotUpdatesModule(
131
240
  UiThreadUtil.runOnUiThread {
132
241
  try {
133
242
  if (application is ReactApplication) {
134
- application.reactHost.reload("Ubriot OTA update")
243
+ application.reactHost?.reload("Ubriot OTA update")
135
244
  }
136
245
  } catch (_: Throwable) {
137
246
  }
@@ -150,6 +259,10 @@ class UbriotUpdatesModule(
150
259
  map.putMap("pending", entryToWritableMap(state.pending))
151
260
  map.putMap("lastKnownGood", entryToWritableMap(state.lastKnownGood))
152
261
  map.putInt("launchCount", state.launchCount)
262
+ map.putString("revertedFromId", state.revertedFromId)
263
+ val failed = Arguments.createArray()
264
+ state.failedUpdateIds.forEach { failed.pushString(it) }
265
+ map.putArray("failedUpdateIds", failed)
153
266
  return map
154
267
  }
155
268
 
@@ -164,6 +277,17 @@ class UbriotUpdatesModule(
164
277
  companion object {
165
278
  private const val DIR_NAME = "ubriot-updates"
166
279
  private const val STATE_FILE = "state.json"
280
+ private const val BUNDLE_FILE = "index.jsbundle"
281
+ private const val ASSET_INDEX_FILE = "assets.json"
282
+ private const val MAX_FAILED_IDS = 20
283
+
284
+ private val downloadExecutor = Executors.newSingleThreadExecutor()
285
+
286
+ @Volatile
287
+ private var runningEntry: UbriotUpdateEntry? = null
288
+
289
+ @Volatile
290
+ private var bootedActive: UbriotUpdateEntry? = null
167
291
 
168
292
  private fun updatesDir(filesDir: File): File = File(filesDir, DIR_NAME)
169
293
 
@@ -174,6 +298,111 @@ class UbriotUpdatesModule(
174
298
  return if (cleaned.isEmpty()) "update" else cleaned
175
299
  }
176
300
 
301
+ private fun optString(map: ReadableMap, key: String): String? {
302
+ if (!map.hasKey(key) || map.getType(key) != ReadableType.String) return null
303
+ return map.getString(key)?.ifEmpty { null }
304
+ }
305
+
306
+ private fun sha256(file: File): ByteArray {
307
+ val digest = MessageDigest.getInstance("SHA-256")
308
+ file.inputStream().use { input ->
309
+ val buffer = ByteArray(64 * 1024)
310
+ while (true) {
311
+ val read = input.read(buffer)
312
+ if (read < 0) break
313
+ digest.update(buffer, 0, read)
314
+ }
315
+ }
316
+ return digest.digest()
317
+ }
318
+
319
+ private fun matchesHash(file: File, expected: String?): Boolean {
320
+ if (expected.isNullOrEmpty()) return true
321
+ val digest = sha256(file)
322
+ val base64Url = Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
323
+ val hex = digest.joinToString("") { "%02x".format(it) }
324
+ val normalized = expected.replace('+', '-').replace('/', '_').trimEnd('=')
325
+ return normalized == base64Url || expected.lowercase() == hex
326
+ }
327
+
328
+ private fun downloadFile(url: String, destination: File, expectedHash: String?): String? {
329
+ var connection: HttpURLConnection? = null
330
+ try {
331
+ var current = URL(url)
332
+ var redirects = 0
333
+ while (true) {
334
+ connection = (current.openConnection() as HttpURLConnection).apply {
335
+ instanceFollowRedirects = false
336
+ connectTimeout = 30000
337
+ readTimeout = 60000
338
+ requestMethod = "GET"
339
+ useCaches = false
340
+ }
341
+ val status = connection.responseCode
342
+ if (status in 300..399) {
343
+ val location = connection.getHeaderField("Location")
344
+ connection.disconnect()
345
+ connection = null
346
+ if (location.isNullOrEmpty() || redirects >= 5) return "$url: bad redirect"
347
+ current = URL(current, location)
348
+ redirects += 1
349
+ continue
350
+ }
351
+ if (status < 200 || status >= 300) return "$url: HTTP $status"
352
+ destination.parentFile?.mkdirs()
353
+ connection.inputStream.use { input ->
354
+ destination.outputStream().use { output -> input.copyTo(output) }
355
+ }
356
+ break
357
+ }
358
+ if (!destination.exists() || destination.length() == 0L) return "$url: empty file"
359
+ if (!matchesHash(destination, expectedHash)) return "$url: hash mismatch"
360
+ return null
361
+ } catch (error: Throwable) {
362
+ return "$url: ${error.message ?: error.javaClass.simpleName}"
363
+ } finally {
364
+ connection?.disconnect()
365
+ }
366
+ }
367
+
368
+ private fun stageUpdate(staging: File, launchUrl: String, launchHash: String?, assets: List<UbriotDownloadAsset>): String? {
369
+ val assetsDirectory = File(staging, "assets")
370
+ if (!assetsDirectory.mkdirs() && !assetsDirectory.isDirectory) return "Could not create update directory"
371
+ downloadFile(launchUrl, File(staging, BUNDLE_FILE), launchHash)?.let { return it }
372
+ val index = JSONObject()
373
+ for (asset in assets) {
374
+ if (index.has(asset.key)) continue
375
+ val extension = if (asset.fileExtension.isEmpty()) "" else sanitize(asset.fileExtension)
376
+ val relative = "assets/${sanitize(asset.key)}$extension"
377
+ downloadFile(asset.url, File(staging, relative), asset.hash)?.let { return it }
378
+ index.put(asset.key, relative)
379
+ }
380
+ return try {
381
+ File(staging, ASSET_INDEX_FILE).writeText(index.toString())
382
+ null
383
+ } catch (_: Throwable) {
384
+ "Could not write asset index"
385
+ }
386
+ }
387
+
388
+ private fun assetFileUrls(bundlePath: String, filesDir: File): HashMap<String, Any?> {
389
+ val urls = HashMap<String, Any?>()
390
+ val directory = File(bundlePath).parentFile ?: return urls
391
+ if (directory.absolutePath == updatesDir(filesDir).absolutePath) return urls
392
+ val indexFile = File(directory, ASSET_INDEX_FILE)
393
+ if (!indexFile.exists()) return urls
394
+ val index = JSONObject(indexFile.readText())
395
+ val keys = index.keys()
396
+ while (keys.hasNext()) {
397
+ val key = keys.next()
398
+ val relative = index.optString(key, "")
399
+ if (relative.isEmpty()) continue
400
+ val file = File(directory, relative)
401
+ if (file.exists()) urls[key] = "file://${file.absolutePath}"
402
+ }
403
+ return urls
404
+ }
405
+
177
406
  private fun entryFromJson(obj: JSONObject?): UbriotUpdateEntry? {
178
407
  if (obj == null) return null
179
408
  val id = obj.optString("id", "")
@@ -187,6 +416,22 @@ class UbriotUpdatesModule(
187
416
  return JSONObject().put("id", entry.id).put("bundlePath", entry.bundlePath)
188
417
  }
189
418
 
419
+ private fun failedIdsFromJson(array: JSONArray?): MutableList<String> {
420
+ val ids = mutableListOf<String>()
421
+ if (array == null) return ids
422
+ for (index in 0 until array.length()) {
423
+ val value = array.optString(index, "")
424
+ if (value.isNotEmpty()) ids.add(value)
425
+ }
426
+ return ids
427
+ }
428
+
429
+ private fun recordFailedId(state: UbriotUpdateState, id: String) {
430
+ state.failedUpdateIds.remove(id)
431
+ state.failedUpdateIds.add(id)
432
+ while (state.failedUpdateIds.size > MAX_FAILED_IDS) state.failedUpdateIds.removeAt(0)
433
+ }
434
+
190
435
  @Synchronized
191
436
  fun readState(filesDir: File): UbriotUpdateState {
192
437
  return try {
@@ -200,6 +445,8 @@ class UbriotUpdatesModule(
200
445
  pending = entryFromJson(json.optJSONObject("pending")),
201
446
  lastKnownGood = entryFromJson(json.optJSONObject("lastKnownGood")),
202
447
  launchCount = json.optInt("launchCount", 0),
448
+ revertedFromId = json.optString("revertedFromId", "").ifEmpty { null },
449
+ failedUpdateIds = failedIdsFromJson(json.optJSONArray("failedUpdateIds")),
203
450
  )
204
451
  } catch (_: Throwable) {
205
452
  UbriotUpdateState()
@@ -218,7 +465,15 @@ class UbriotUpdatesModule(
218
465
  .put("pending", entryToJson(state.pending))
219
466
  .put("lastKnownGood", entryToJson(state.lastKnownGood))
220
467
  .put("launchCount", state.launchCount)
221
- stateFile(filesDir).writeText(json.toString())
468
+ .put("revertedFromId", state.revertedFromId ?: JSONObject.NULL)
469
+ .put("failedUpdateIds", JSONArray(state.failedUpdateIds))
470
+ val target = stateFile(filesDir)
471
+ val temp = File(directory, "$STATE_FILE.tmp")
472
+ temp.writeText(json.toString())
473
+ if (!temp.renameTo(target)) {
474
+ target.writeText(json.toString())
475
+ temp.delete()
476
+ }
222
477
  } catch (_: Throwable) {
223
478
  }
224
479
  }
@@ -230,17 +485,28 @@ class UbriotUpdatesModule(
230
485
  val state = readState(filesDir)
231
486
  val active = state.active
232
487
  if (active != null && File(active.bundlePath).exists()) {
488
+ val booted = bootedActive
489
+ if (booted != null && booted.id == active.id) {
490
+ runningEntry = active
491
+ return booted.bundlePath
492
+ }
233
493
  if (state.verified) {
234
494
  state.runningId = active.id
235
495
  writeState(filesDir, state)
496
+ runningEntry = active
236
497
  return active.bundlePath
237
498
  }
238
499
  if (state.launchCount < 1) {
239
500
  state.launchCount = 1
240
501
  state.runningId = active.id
241
502
  writeState(filesDir, state)
503
+ bootedActive = active
504
+ runningEntry = active
242
505
  return active.bundlePath
243
506
  }
507
+ state.revertedFromId = active.id
508
+ recordFailedId(state, active.id)
509
+ if (state.lastKnownGood?.id == active.id) state.lastKnownGood = null
244
510
  state.active = null
245
511
  state.verified = false
246
512
  state.launchCount = 0
@@ -254,13 +520,16 @@ class UbriotUpdatesModule(
254
520
  if (lastKnownGood != null && File(lastKnownGood.bundlePath).exists()) {
255
521
  state.runningId = lastKnownGood.id
256
522
  writeState(filesDir, state)
523
+ runningEntry = lastKnownGood
257
524
  return lastKnownGood.bundlePath
258
525
  }
259
526
 
260
527
  state.runningId = null
261
528
  writeState(filesDir, state)
529
+ runningEntry = null
262
530
  null
263
531
  } catch (_: Throwable) {
532
+ runningEntry = null
264
533
  null
265
534
  }
266
535
  }
package/app.plugin.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = require("./plugin/withUbriotUpdates");
@@ -0,0 +1,28 @@
1
+ export type RunningUpdate = {
2
+ id: string;
3
+ assets: Record<string, string>;
4
+ };
5
+ export type PackagerAssetLike = {
6
+ scales: number[];
7
+ hash: string;
8
+ fileHashes?: string[];
9
+ name: string;
10
+ type: string;
11
+ width?: number | null;
12
+ height?: number | null;
13
+ };
14
+ export type ResolvedSource = {
15
+ uri: string;
16
+ [key: string]: unknown;
17
+ };
18
+ export type AssetResolverLike = {
19
+ asset: PackagerAssetLike;
20
+ isLoadedFromServer(): boolean;
21
+ defaultAsset(): ResolvedSource;
22
+ };
23
+ export type OtaAssetTransformerOptions = {
24
+ getRunning(): RunningUpdate | null;
25
+ embeddedBundleUrl: string | null;
26
+ pixelRatio(): number;
27
+ };
28
+ export declare function createOtaAssetTransformer(options: OtaAssetTransformerOptions): (resolver: AssetResolverLike) => ResolvedSource | null;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createOtaAssetTransformer = createOtaAssetTransformer;
4
+ function pickScale(scales, deviceScale) {
5
+ for (const scale of scales) {
6
+ if (scale >= deviceScale)
7
+ return scale;
8
+ }
9
+ return scales[scales.length - 1] || 1;
10
+ }
11
+ function candidateIndexes(scales, preferred) {
12
+ const preferredIndex = scales.indexOf(preferred);
13
+ const rest = scales.map((_, index) => index).filter((index) => index !== preferredIndex);
14
+ return preferredIndex >= 0 ? [preferredIndex, ...rest] : rest;
15
+ }
16
+ function hashAt(asset, index) {
17
+ if (asset.fileHashes && asset.fileHashes.length > 0)
18
+ return asset.fileHashes[index];
19
+ return index === 0 ? asset.hash : undefined;
20
+ }
21
+ function downloadedSource(asset, running, deviceScale) {
22
+ const scales = asset.scales && asset.scales.length > 0 ? asset.scales : [1];
23
+ const preferred = pickScale(scales, deviceScale);
24
+ for (const index of candidateIndexes(scales, preferred)) {
25
+ const hash = hashAt(asset, index);
26
+ const uri = hash ? running.assets[hash] : undefined;
27
+ if (uri) {
28
+ return {
29
+ __packager_asset: true,
30
+ width: asset.width,
31
+ height: asset.height,
32
+ uri,
33
+ scale: scales[index],
34
+ };
35
+ }
36
+ }
37
+ return null;
38
+ }
39
+ function embeddedSource(resolver, embeddedBundleUrl) {
40
+ const Resolver = resolver.constructor;
41
+ return new Resolver(null, embeddedBundleUrl, resolver.asset).defaultAsset();
42
+ }
43
+ function createOtaAssetTransformer(options) {
44
+ return (resolver) => {
45
+ var _a;
46
+ try {
47
+ const running = options.getRunning();
48
+ if (!running)
49
+ return null;
50
+ if (resolver.isLoadedFromServer())
51
+ return null;
52
+ return (_a = downloadedSource(resolver.asset, running, options.pixelRatio())) !== null && _a !== void 0 ? _a : embeddedSource(resolver, options.embeddedBundleUrl);
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ };
58
+ }