ubriot-updates-rn 0.1.4 → 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,
@@ -24,6 +33,7 @@ class UbriotUpdateState(
24
33
  var lastKnownGood: UbriotUpdateEntry? = null,
25
34
  var launchCount: Int = 0,
26
35
  var revertedFromId: String? = null,
36
+ var failedUpdateIds: MutableList<String> = mutableListOf(),
27
37
  )
28
38
 
29
39
  class UbriotUpdatesModule(
@@ -34,6 +44,19 @@ class UbriotUpdatesModule(
34
44
 
35
45
  private fun filesDir(): File = reactContext.applicationContext.filesDir
36
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
+
37
60
  @ReactMethod
38
61
  fun getState(promise: Promise) {
39
62
  try {
@@ -43,6 +66,73 @@ class UbriotUpdatesModule(
43
66
  }
44
67
  }
45
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
+
46
136
  @ReactMethod
47
137
  fun downloadBundle(url: String, manifestId: String, promise: Promise) {
48
138
  Thread {
@@ -113,11 +203,17 @@ class UbriotUpdatesModule(
113
203
  try {
114
204
  val state = readState(filesDir())
115
205
  val active = state.active
116
- 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) {
117
210
  state.verified = true
118
211
  state.lastKnownGood = active
119
212
  state.launchCount = 0
120
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)
121
217
  }
122
218
  promise.resolve(null)
123
219
  } catch (error: Throwable) {
@@ -144,7 +240,7 @@ class UbriotUpdatesModule(
144
240
  UiThreadUtil.runOnUiThread {
145
241
  try {
146
242
  if (application is ReactApplication) {
147
- application.reactHost.reload("Ubriot OTA update")
243
+ application.reactHost?.reload("Ubriot OTA update")
148
244
  }
149
245
  } catch (_: Throwable) {
150
246
  }
@@ -164,6 +260,9 @@ class UbriotUpdatesModule(
164
260
  map.putMap("lastKnownGood", entryToWritableMap(state.lastKnownGood))
165
261
  map.putInt("launchCount", state.launchCount)
166
262
  map.putString("revertedFromId", state.revertedFromId)
263
+ val failed = Arguments.createArray()
264
+ state.failedUpdateIds.forEach { failed.pushString(it) }
265
+ map.putArray("failedUpdateIds", failed)
167
266
  return map
168
267
  }
169
268
 
@@ -178,6 +277,17 @@ class UbriotUpdatesModule(
178
277
  companion object {
179
278
  private const val DIR_NAME = "ubriot-updates"
180
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
181
291
 
182
292
  private fun updatesDir(filesDir: File): File = File(filesDir, DIR_NAME)
183
293
 
@@ -188,6 +298,111 @@ class UbriotUpdatesModule(
188
298
  return if (cleaned.isEmpty()) "update" else cleaned
189
299
  }
190
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
+
191
406
  private fun entryFromJson(obj: JSONObject?): UbriotUpdateEntry? {
192
407
  if (obj == null) return null
193
408
  val id = obj.optString("id", "")
@@ -201,6 +416,22 @@ class UbriotUpdatesModule(
201
416
  return JSONObject().put("id", entry.id).put("bundlePath", entry.bundlePath)
202
417
  }
203
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
+
204
435
  @Synchronized
205
436
  fun readState(filesDir: File): UbriotUpdateState {
206
437
  return try {
@@ -215,6 +446,7 @@ class UbriotUpdatesModule(
215
446
  lastKnownGood = entryFromJson(json.optJSONObject("lastKnownGood")),
216
447
  launchCount = json.optInt("launchCount", 0),
217
448
  revertedFromId = json.optString("revertedFromId", "").ifEmpty { null },
449
+ failedUpdateIds = failedIdsFromJson(json.optJSONArray("failedUpdateIds")),
218
450
  )
219
451
  } catch (_: Throwable) {
220
452
  UbriotUpdateState()
@@ -234,7 +466,14 @@ class UbriotUpdatesModule(
234
466
  .put("lastKnownGood", entryToJson(state.lastKnownGood))
235
467
  .put("launchCount", state.launchCount)
236
468
  .put("revertedFromId", state.revertedFromId ?: JSONObject.NULL)
237
- stateFile(filesDir).writeText(json.toString())
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
+ }
238
477
  } catch (_: Throwable) {
239
478
  }
240
479
  }
@@ -246,18 +485,28 @@ class UbriotUpdatesModule(
246
485
  val state = readState(filesDir)
247
486
  val active = state.active
248
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
+ }
249
493
  if (state.verified) {
250
494
  state.runningId = active.id
251
495
  writeState(filesDir, state)
496
+ runningEntry = active
252
497
  return active.bundlePath
253
498
  }
254
499
  if (state.launchCount < 1) {
255
500
  state.launchCount = 1
256
501
  state.runningId = active.id
257
502
  writeState(filesDir, state)
503
+ bootedActive = active
504
+ runningEntry = active
258
505
  return active.bundlePath
259
506
  }
260
507
  state.revertedFromId = active.id
508
+ recordFailedId(state, active.id)
509
+ if (state.lastKnownGood?.id == active.id) state.lastKnownGood = null
261
510
  state.active = null
262
511
  state.verified = false
263
512
  state.launchCount = 0
@@ -271,13 +520,16 @@ class UbriotUpdatesModule(
271
520
  if (lastKnownGood != null && File(lastKnownGood.bundlePath).exists()) {
272
521
  state.runningId = lastKnownGood.id
273
522
  writeState(filesDir, state)
523
+ runningEntry = lastKnownGood
274
524
  return lastKnownGood.bundlePath
275
525
  }
276
526
 
277
527
  state.runningId = null
278
528
  writeState(filesDir, state)
529
+ runningEntry = null
279
530
  null
280
531
  } catch (_: Throwable) {
532
+ runningEntry = null
281
533
  null
282
534
  }
283
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
+ }
@@ -0,0 +1,76 @@
1
+ import type { UbriotUpdatesConfig } from "./manifest-headers";
2
+ export type UpdateCheckResult = {
3
+ available: boolean;
4
+ manifestId?: string;
5
+ };
6
+ export type DownloadResult = {
7
+ ready: boolean;
8
+ };
9
+ export type ApplyOptions = {
10
+ restart?: boolean;
11
+ };
12
+ export type SyncResult = "up-to-date" | "updated";
13
+ export type LaunchOutcome = "launched" | "crashed";
14
+ export type NativeEntry = {
15
+ id: string;
16
+ bundlePath: string;
17
+ } | null;
18
+ export type NativeState = {
19
+ runningId: string | null;
20
+ active: NativeEntry;
21
+ verified: boolean;
22
+ pending: NativeEntry;
23
+ lastKnownGood: NativeEntry;
24
+ launchCount: number;
25
+ revertedFromId?: string | null;
26
+ failedUpdateId?: string | null;
27
+ lastRevertedId?: string | null;
28
+ failedUpdateIds?: string[] | null;
29
+ };
30
+ export type NativeDownloadRequest = {
31
+ id: string;
32
+ launchAsset: {
33
+ url: string;
34
+ hash: string | null;
35
+ };
36
+ assets: {
37
+ key: string;
38
+ url: string;
39
+ hash: string | null;
40
+ fileExtension: string;
41
+ }[];
42
+ };
43
+ export type NativeUbriotUpdates = {
44
+ getState(): Promise<NativeState>;
45
+ downloadUpdate?(request: NativeDownloadRequest): Promise<{
46
+ ready: boolean;
47
+ bundlePath?: string;
48
+ }>;
49
+ downloadBundle?(url: string, manifestId: string): Promise<{
50
+ ready: boolean;
51
+ bundlePath: string;
52
+ }>;
53
+ applyPending(): Promise<void>;
54
+ markLaunchSuccessful(): Promise<void>;
55
+ reload(): Promise<void>;
56
+ clearRevertedFrom?(): Promise<void>;
57
+ };
58
+ type TimerHandle = unknown;
59
+ export type UpdatesClientDeps = {
60
+ getNative(): NativeUbriotUpdates | undefined;
61
+ fetch: typeof fetch;
62
+ platform: "ios" | "android";
63
+ setTimeout(callback: () => void, ms: number): TimerHandle;
64
+ clearTimeout(handle: TimerHandle): void;
65
+ };
66
+ export declare const DEFAULT_LAUNCH_FALLBACK_MS = 4000;
67
+ export declare function createUpdatesClient(deps: UpdatesClientDeps): {
68
+ configure: (next: UbriotUpdatesConfig) => void;
69
+ checkForUpdate: () => Promise<UpdateCheckResult>;
70
+ downloadUpdate: () => Promise<DownloadResult>;
71
+ applyUpdate: (options?: ApplyOptions) => Promise<void>;
72
+ sync: () => Promise<SyncResult>;
73
+ markLaunchSuccessful: () => void;
74
+ markLaunchSuccessfulAfter: (ms?: number) => () => void;
75
+ };
76
+ export {};