@shipvo/runtime 0.1.3 → 0.1.4

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
@@ -11,6 +11,14 @@ The native half of Shipvo's over-the-air updates, as an Expo module.
11
11
  { "expo": { "plugins": [["@shipvo/runtime", { "baseUrl": "https://<your control plane>", "publicKey": "<base64 Ed25519 public key>", "appId": "myshop" }]] } }
12
12
  ```
13
13
 
14
+ Optional plugin props declare what the shell can run, so the control plane can skip releases it cannot use:
15
+
16
+ ```json
17
+ { "hermesBytecodeVersions": [96], "nativeModules": { "react-native-mmkv": "3.1.0" } }
18
+ ```
19
+
20
+ They are written as Android meta-data `dev.shipvo.HERMES_BYTECODE_VERSIONS` (`96`) and `dev.shipvo.NATIVE_MODULES` (`react-native-mmkv@3.1.0`), and as Info.plist keys `ShipvoHermesBytecodeVersions` and `ShipvoNativeModules`. Leave them out and nothing is written. A non-integer version or an empty package name fails the build. The runtime does not read them yet.
21
+
14
22
  ```ts
15
23
  import { Runtime } from "@shipvo/runtime";
16
24
  const s = Runtime.getState(); // running / pending / previous release, band, baseUrl, appId
@@ -19,3 +27,21 @@ Runtime.markHealthy(); // call once the first screen rendered
19
27
  ```
20
28
 
21
29
  `shipvo init` wires all of this for you.
30
+
31
+ ## Events to forward
32
+
33
+ `Runtime.consumeEvents()` returns strings such as `verify_failed:<release>`. Forward `crash_loop_revert`, `rolled_back` and `verify_failed` to telemetry (`POST /t`), once per event. The sample app does this in `apps/tide-and-tackle/src/runtimeEvents.ts`. `verify_failed` is reported under the failed release id. A rejected or corrupt release then shows on the server, not only as repeated downloads.
34
+
35
+ The runtime does not emit `download_failed` yet. The sample app and the control plane accept it, so it can be forwarded once the runtime raises it.
36
+
37
+ `Runtime.consumeEvents()` can also return `pointer_rejected:<reason>` on Android when the control plane says this shell cannot run the newest release. It is queued once per reason. `/t` accepts `hermes_bytecode_mismatch`, `native_module_missing` and `native_module_version`. The sample app forwards the two native-module reasons under those names. `hermes_bytecode_mismatch` is its own event and carries a release id.
38
+
39
+ Events are read at launch. A `verify_failed` raised during `checkForUpdate` is reported on the next cold start.
40
+
41
+ ## Crash after `markHealthy`
42
+
43
+ `markHealthy` means the first screen rendered. It promotes a staged release, but it does not clear the unhealthy-boot count. The count is cleared when the app has stayed in the foreground for 30 seconds, or when it goes to background. If the process dies before that, the next launch counts as an unhealthy boot for that release. After 3 unhealthy boots inside 60 seconds the runtime blocks the release, goes back to the previous one and records `crash_loop_revert`.
44
+
45
+ A good release that you open and kill in the foreground within 30 seconds, 4 times in 60 seconds, is reverted too. This is rare.
46
+
47
+ Reverting does not undo changes the bad release made to app storage (files, databases, keychain, preferences). Write migrations so an older release can read newer data.
@@ -16,6 +16,7 @@ android {
16
16
  }
17
17
 
18
18
  dependencies {
19
+ testImplementation 'junit:junit:4.13.2'
19
20
  implementation 'com.facebook.react:react-android'
20
21
  // Ed25519 verification on Android 7+ (no platform Ed25519 before API 33). Pure Java, ~60 KB.
21
22
  implementation 'net.i2p.crypto:eddsa:0.3.0'
@@ -0,0 +1,53 @@
1
+ package dev.shipvo.runtime
2
+
3
+ /** The unhealthy-boot counters. `Store` implements it; tests use an in-memory copy. */
4
+ internal interface BootCounts {
5
+ var unhealthyBoots: Int
6
+ var firstUnhealthyAt: Long
7
+ }
8
+
9
+ /** The crash-loop counting rules, free of Android types so a plain JVM test can run them. */
10
+ internal object BootRules {
11
+ /** Counts one boot of the current release. Returns true when the release must be reverted. */
12
+ fun countBoot(c: BootCounts, now: Long, maxBoots: Int, windowMs: Long): Boolean {
13
+ if (now - c.firstUnhealthyAt > windowMs) { c.firstUnhealthyAt = now; c.unhealthyBoots = 0 }
14
+ c.unhealthyBoots = c.unhealthyBoots + 1
15
+ return c.unhealthyBoots > maxBoots
16
+ }
17
+
18
+ /** The promoting boot was a trial boot and was not counted. Count it now, until the app proves stable. */
19
+ fun countPromotedBoot(c: BootCounts, now: Long) { c.unhealthyBoots = 1; c.firstUnhealthyAt = now }
20
+
21
+ /** Whether a downloaded release may still be staged. Two overlapping checks can both pass the early "newer than known"
22
+ * test. The second one must not stage the same release again: that would reset `trialBoots` and give a crashing release
23
+ * more than MAX_BOOTS runs. Call this inside the handler lock, right before staging. */
24
+ fun mayStage(release: Int, current: Int, pending: Int, blocked: Int): Boolean = release > maxOf(current, pending, blocked)
25
+
26
+ fun clear(c: BootCounts) { c.unhealthyBoots = 0; c.firstUnhealthyAt = 0 }
27
+
28
+ /** The load that last reported healthy (0 = none). */
29
+ @Volatile var healthyLoad = 0
30
+
31
+ fun noteHealthy(load: Int) { healthyLoad = load }
32
+
33
+ /** The load that handled an uncaught JS error (0 = none). React destroys the host after such an error, which pauses the
34
+ * activity. That pause must not count as a clean trip to the background. */
35
+ @Volatile var crashedLoad = 0
36
+
37
+ fun noteCrash(load: Int) { crashedLoad = load }
38
+
39
+ /** What `markHealthy` does to the counts: the promoting boot (if any) is counted, and `load` is recorded as healthy.
40
+ * The module and the tests both call this, so they cannot drift apart. */
41
+ fun onHealthy(c: BootCounts, load: Int, now: Long, promoted: Boolean) {
42
+ if (promoted) countPromotedBoot(c, now)
43
+ noteHealthy(load)
44
+ }
45
+
46
+ /** Clears the counts only if `load` is the one that reported healthy and has not hit an uncaught JS error. A reloaded
47
+ * load that has not called markHealthy yet keeps its count, so a crash before markHealthy still adds up.
48
+ * Returns whether it cleared. */
49
+ fun markStableIfHealthy(c: BootCounts, load: Int): Boolean {
50
+ if (healthyLoad == 0 || healthyLoad != load || crashedLoad == load) return false
51
+ clear(c); return true
52
+ }
53
+ }
@@ -0,0 +1,42 @@
1
+ package dev.shipvo.runtime
2
+
3
+ /** Refuses a release built for a different Hermes bytecode version than the shell runs. Runs in addition to the
4
+ * signature and hash checks, never instead of them. Free of Android types so a plain JVM test can run it. */
5
+ internal object HermesGuard {
6
+ const val REASON = "hermes_bytecode_mismatch"
7
+ private val MAGIC = byteArrayOf(0xc6.toByte(), 0x1f, 0xbc.toByte(), 0x03, 0xc1.toByte(), 0x03, 0x19, 0x1f)
8
+
9
+ /** The bytecode version in a bundle header (8-byte magic, then a 32-bit little-endian version), or null for plain JS. */
10
+ fun versionOf(head: ByteArray): Int? {
11
+ if (head.size < 12 || (0 until 8).any { head[it] != MAGIC[it] }) return null
12
+ val v = (0 until 4).fold(0L) { acc, i -> acc or ((head[8 + i].toLong() and 0xff) shl (8 * i)) }
13
+ return if (v in 1..Int.MAX_VALUE) v.toInt() else null
14
+ }
15
+
16
+ /** The first 12 bytes of a bundle stream, read until full or EOF. Null when the stream is missing or unreadable
17
+ * ("unknown", so the release is applied as before). A short stream gives a short array, which `versionOf` maps to null. */
18
+ fun readHeader(open: () -> java.io.InputStream?): ByteArray? = try {
19
+ open()?.use { ins ->
20
+ val h = ByteArray(12); var n = 0
21
+ while (n < h.size) { val r = ins.read(h, n, h.size - n); if (r < 0) break; n += r }
22
+ h.copyOf(n)
23
+ }
24
+ } catch (_: java.io.IOException) { null }
25
+
26
+ /** The version field of a pointer body. Only a positive whole number counts; anything else is unknown. */
27
+ fun releaseVersionOf(raw: Any?): Int? = when (raw) {
28
+ is Int -> raw.takeIf { it > 0 }
29
+ is Long -> raw.takeIf { it in 1..Int.MAX_VALUE }?.toInt()
30
+ else -> null
31
+ }
32
+
33
+ /** The whole decision `check()` makes: the event to log when the release must be refused, or null to go on. */
34
+ fun decide(release: Int, rawReleaseVersion: Any?, openShellBundle: () -> java.io.InputStream?): String? =
35
+ mismatchEvent(release, releaseVersionOf(rawReleaseVersion), readHeader(openShellBundle)?.let { versionOf(it) })
36
+
37
+ /** The event to log when the release must be refused, or null when it may be staged.
38
+ * A missing field on either side means "unknown", so the release is applied as before. */
39
+ fun mismatchEvent(release: Int, releaseVersion: Int?, shellVersion: Int?): String? =
40
+ if (releaseVersion == null || shellVersion == null || releaseVersion == shellVersion) null
41
+ else "$REASON:$release:release=$releaseVersion:shell=$shellVersion"
42
+ }
@@ -0,0 +1,35 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import java.net.URLEncoder
4
+
5
+ /** Builds the pointer URL and reads the control plane's typed "not for this shell" answers. */
6
+ internal object PointerQuery {
7
+ val MISMATCH_REASONS = setOf("hermes_bytecode_mismatch", "native_module_missing", "native_module_version")
8
+
9
+ private fun enc(v: String) = URLEncoder.encode(v, "UTF-8")
10
+
11
+ /** `hbc` is "96,98"; `nm` is "name@1.2.3,@scope/pkg@2.0.0". A blank value is treated as not declared. */
12
+ fun url(base: String, app: String, shellMajor: Int, band: String, hbc: String?, nm: String?): String {
13
+ val q = ArrayList<String>()
14
+ hbc?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() }
15
+ ?.let { q += "hbc=" + it.joinToString(",") { v -> enc(v) } }
16
+ nm?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() }
17
+ ?.let { q += "nm=" + it.joinToString(",") { v -> enc(v) } }
18
+ val path = "$base/p/$app/$shellMajor/$band.json"
19
+ return if (q.isEmpty()) path else path + "?" + q.joinToString("&")
20
+ }
21
+
22
+ /** The typed reason in a 404 body, or null for any other answer. */
23
+ fun mismatchReason(body: String?): String? {
24
+ val m = Regex("\"error\"\\s*:\\s*\"([a-z_]+)\"").find(body ?: return null) ?: return null
25
+ return m.groupValues[1].takeIf { it in MISMATCH_REASONS }
26
+ }
27
+
28
+ /**
29
+ * The event to queue for a rejected pointer, or null when the same reason is already queued.
30
+ * A shell that stays mismatched would otherwise add one event per check and push out
31
+ * `crash_loop_revert`, `rolled_back` and `verify_failed` (the queue keeps 50).
32
+ */
33
+ fun rejectionEvent(queued: List<String>, reason: String): String? =
34
+ "pointer_rejected:$reason".takeIf { it !in queued }
35
+ }
@@ -0,0 +1,39 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import java.io.File
4
+ import java.io.FileOutputStream
5
+ import java.net.HttpURLConnection
6
+ import java.net.URL
7
+
8
+ /** Resumable full-bundle download. It only moves bytes; the caller still checks the signed sha256 and size. */
9
+ object ResumableDownload {
10
+ /** Partial download, named by the release sha256 so a different release never reuses it. */
11
+ fun partialFile(dir: File, sha: String) = File(dir, "dl-$sha.part")
12
+
13
+ /**
14
+ * Download into [part], resuming with `Range: bytes=<n>-` when a partial file exists. A 200 answer means the server
15
+ * ignored the range, so the file restarts. If the connection drops the partial file stays for the next attempt.
16
+ * Returns how many bytes were already on disk.
17
+ */
18
+ fun fetch(url: String, part: File, wantSize: Long): Long {
19
+ var offset = part.length()
20
+ if (offset > wantSize) { part.delete(); offset = 0 }
21
+ if (offset == wantSize) return offset
22
+ for (attempt in 0..1) {
23
+ val dl = (URL(url).openConnection() as HttpURLConnection).apply {
24
+ connectTimeout = 10_000; readTimeout = 30_000; useCaches = false
25
+ if (offset > 0) setRequestProperty("Range", "bytes=$offset-")
26
+ }
27
+ try {
28
+ val code = dl.responseCode
29
+ if (code == 416 && offset > 0 && attempt == 0) { part.delete(); offset = 0; continue }
30
+ require(code == 200 || (code == 206 && offset > 0)) { "bundle HTTP $code" }
31
+ val append = code == 206
32
+ if (!append) { part.delete(); offset = 0 }
33
+ dl.inputStream.use { ins -> FileOutputStream(part, append).use { ins.copyTo(it, 64 * 1024) } }
34
+ return offset
35
+ } finally { dl.disconnect() }
36
+ }
37
+ error("bundle download failed")
38
+ }
39
+ }
@@ -40,10 +40,7 @@ class ShipvoHostHandler(private val ctx: Context) : ReactNativeHostHandler {
40
40
 
41
41
  val current = s.currentPath
42
42
  if (current != null && File(current).exists()) {
43
- val now = s.now()
44
- if (now - s.firstUnhealthyAt > WINDOW_MS) { s.firstUnhealthyAt = now; s.unhealthyBoots = 0 }
45
- s.unhealthyBoots = s.unhealthyBoots + 1
46
- if (s.unhealthyBoots > MAX_BOOTS) {
43
+ if (BootRules.countBoot(s, s.now(), MAX_BOOTS, WINDOW_MS)) {
47
44
  s.addEvent("crash_loop_revert:${s.currentRelease}")
48
45
  s.block(s.currentRelease)
49
46
  revertToPrevious(s)
@@ -65,13 +62,15 @@ class ShipvoHostHandler(private val ctx: Context) : ReactNativeHostHandler {
65
62
  companion object {
66
63
  const val MAX_BOOTS = 3
67
64
  const val WINDOW_MS = 60_000L
65
+ /** A release is stable once its process stayed in the foreground this long after markHealthy. iOS uses the same value. */
66
+ const val STABILITY_MS = 30_000L
68
67
  val LOCK = Any()
69
68
 
70
69
  internal fun revertToPrevious(s: Store) {
71
70
  s.currentPath?.let { if (it != s.previousPath) File(it).delete() }
72
71
  s.currentPath = s.previousPath; s.currentRelease = s.previousRelease; s.currentSha = null
73
72
  s.previousPath = null; s.previousRelease = 0
74
- s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
73
+ BootRules.clear(s)
75
74
  }
76
75
  }
77
76
  }
@@ -52,12 +52,20 @@ class ShipvoRuntimeModule : Module() {
52
52
  s.addEvent("applied:${s.currentRelease}")
53
53
  promoted = true
54
54
  }
55
- s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
55
+ // The promoting boot was a trial boot and was not counted, so onHealthy counts it until the stability window ends.
56
+ // The counter is not cleared here: markStableIfHealthy does that after the window.
57
+ BootRules.onHealthy(s, Store.loadsInProcess, s.now(), promoted)
56
58
  }
59
+ scheduleStable()
57
60
  val kind = if (Store.loadsInProcess <= 1) "cold" else "reload"
58
61
  mapOf("release" to Store.runningRelease, "coldStartMs" to coldStart, "sinceLoadMs" to sinceLoad, "promoted" to promoted, "kind" to kind)
59
62
  }
60
63
 
64
+ // Called by the JS error handler before React destroys the host, so the pause that follows is not read as "stable".
65
+ Function("noteCrash") { synchronized(ShipvoHostHandler.LOCK) { BootRules.noteCrash(Store.loadsInProcess) } }
66
+
67
+ OnActivityEntersBackground { stableNow() }
68
+
61
69
  Function("consumeEvents") { Store(ctx).consumeEvents() }
62
70
 
63
71
  Function("rollback") {
@@ -75,7 +83,35 @@ class ShipvoRuntimeModule : Module() {
75
83
  Function("reload") { reloadHost() }
76
84
  }
77
85
 
86
+ /** Clears the unhealthy-boot count once the app stayed up for the stability window.
87
+ * The task is dropped after it runs and on reload, and `markStableIfHealthy` checks the load number. */
88
+ private fun scheduleStable() {
89
+ val app = ctx
90
+ synchronized(ShipvoHostHandler.LOCK) {
91
+ stableTask?.let { handler.removeCallbacks(it) }
92
+ val load = Store.loadsInProcess
93
+ val task = Runnable {
94
+ synchronized(ShipvoHostHandler.LOCK) { BootRules.markStableIfHealthy(Store(app), load); stableTask = null }
95
+ }
96
+ stableTask = task
97
+ handler.postDelayed(task, ShipvoHostHandler.STABILITY_MS)
98
+ }
99
+ }
100
+
101
+ /** Going to background cleanly counts as stable, but only if this load reported healthy (see BootRules). */
102
+ private fun stableNow() {
103
+ synchronized(ShipvoHostHandler.LOCK) {
104
+ if (BootRules.markStableIfHealthy(Store(ctx), Store.loadsInProcess)) {
105
+ stableTask?.let { handler.removeCallbacks(it) }; stableTask = null
106
+ }
107
+ }
108
+ }
109
+
110
+ private val handler = Handler(Looper.getMainLooper())
111
+ private var stableTask: Runnable? = null
112
+
78
113
  private fun reloadHost() {
114
+ synchronized(ShipvoHostHandler.LOCK) { stableTask?.let { handler.removeCallbacks(it) }; stableTask = null }
79
115
  Handler(Looper.getMainLooper()).post {
80
116
  (ctx as? ReactApplication)?.reactHost?.reload("shipvo-update")
81
117
  }
@@ -93,9 +129,17 @@ class ShipvoRuntimeModule : Module() {
93
129
  val s = Store(ctx)
94
130
  require(s.baseUrl.isNotEmpty() && s.publicKey.isNotEmpty() && s.appId.isNotEmpty()) { "runtime not configured" }
95
131
  val known = maxOf(s.currentRelease, s.pendingRelease, s.blockedRelease)
96
- val conn = http("${s.baseUrl}/p/${s.appId}/${s.shellMajor}/${s.band}.json", if (known > 0) "\"$known\"" else null)
132
+ val conn = http(PointerQuery.url(s.baseUrl, s.appId, s.shellMajor, s.band, s.hermesBytecodeVersions, s.nativeModules), if (known > 0) "\"$known\"" else null)
97
133
  val code = conn.responseCode
98
- if (code == 304 || code == 404) { conn.disconnect(); return mapOf("status" to "none") }
134
+ if (code == 404) {
135
+ // The shell cannot run the newest release. Keep the current one and say why.
136
+ val reason = PointerQuery.mismatchReason(try { conn.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null })
137
+ conn.disconnect()
138
+ if (reason == null) return mapOf("status" to "none")
139
+ PointerQuery.rejectionEvent(s.pendingEvents(), reason)?.let { s.addEvent(it) }
140
+ return mapOf("status" to "none", "reason" to reason)
141
+ }
142
+ if (code == 304) { conn.disconnect(); return mapOf("status" to "none") }
99
143
  require(code == 200) { "pointer HTTP $code" }
100
144
  val envelope = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
101
145
  conn.disconnect()
@@ -110,10 +154,18 @@ class ShipvoRuntimeModule : Module() {
110
154
  val shellVersion = ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0.0.0"
111
155
  require(!Verify.semverLess(shellVersion, body.getString("min_shell_version"))) { "shell too old for release $release" }
112
156
  val verifyPointerMs = SystemClock.elapsedRealtime() - t0
157
+ // Beside the signature checks, not inside them: refuse a bundle built for another Hermes bytecode version.
158
+ HermesGuard.decide(release, body.opt("hermes_bytecode_version")) { try { ctx.assets.open("index.android.bundle") } catch (_: java.io.IOException) { null } }?.let { ev ->
159
+ s.addEvent(ev); s.block(release)
160
+ return mapOf("status" to "none", "reason" to HermesGuard.REASON)
161
+ }
113
162
 
114
163
  val core = body.getJSONObject("core")
115
164
  val wantSha = core.getString("sha256"); val wantSize = core.getLong("size")
116
165
  val tmp = File(s.dir, "dl-$release.tmp")
166
+ val part = ResumableDownload.partialFile(s.dir, wantSha)
167
+ // The release changed: partial files for other hashes can never complete.
168
+ s.dir.listFiles()?.forEach { if (it.extension == "part" && it.name != part.name) it.delete() }
117
169
  val d0 = SystemClock.elapsedRealtime()
118
170
  var via = "full"; var bytesDownloaded = 0L; var patchError: String? = null
119
171
 
@@ -142,22 +194,22 @@ class ShipvoRuntimeModule : Module() {
142
194
 
143
195
  // 2. Fall back to the full bundle.
144
196
  if (via == "full") {
145
- val dl = http(resolve(s, core.getString("url")), null)
146
- require(dl.responseCode == 200) { "bundle HTTP ${dl.responseCode}" }
147
- dl.inputStream.use { ins -> tmp.outputStream().use { ins.copyTo(it, 64 * 1024) } }
148
- dl.disconnect()
149
- bytesDownloaded = tmp.length()
197
+ val resumedFrom = ResumableDownload.fetch(resolve(s, core.getString("url")), part, wantSize)
198
+ tmp.delete()
199
+ require(part.renameTo(tmp)) { "could not move downloaded bundle" }
200
+ bytesDownloaded = tmp.length() - resumedFrom
150
201
  }
151
202
  val downloadMs = SystemClock.elapsedRealtime() - d0
152
203
 
153
204
  val v0 = SystemClock.elapsedRealtime()
154
205
  val ok = tmp.length() == wantSize && Verify.sha256Hex(tmp) == wantSha
155
- if (!ok) { tmp.delete(); s.addEvent("verify_failed:$release"); throw IllegalStateException("bundle hash/size mismatch") }
206
+ if (!ok) { tmp.delete(); part.delete(); s.addEvent("verify_failed:$release"); throw IllegalStateException("bundle hash/size mismatch") }
156
207
  val verifyMs = SystemClock.elapsedRealtime() - v0 + verifyPointerMs
157
208
 
158
209
  val dest = File(s.dir, "r$release.hbc")
159
210
  require(tmp.renameTo(dest)) { "could not stage bundle" }
160
211
  synchronized(ShipvoHostHandler.LOCK) {
212
+ if (!BootRules.mayStage(release, s.currentRelease, s.pendingRelease, s.blockedRelease)) return mapOf("status" to "none")
161
213
  s.pendingPath?.let { if (it != dest.path) File(it).delete() }
162
214
  s.pendingPath = dest.path; s.pendingRelease = release; s.trialBoots = 0; s.pendingSha = wantSha
163
215
  if (s.binaryVersion == null) s.binaryVersion = s.installedBinaryVersion
@@ -8,7 +8,7 @@ import java.io.File
8
8
  import kotlin.random.Random
9
9
 
10
10
  /** Persistent runtime state. All bundle decisions go through here so the host handler and module agree. */
11
- internal class Store(private val ctx: Context) {
11
+ internal class Store(private val ctx: Context) : BootCounts {
12
12
  private val p: SharedPreferences = ctx.getSharedPreferences("shipvo.runtime", Context.MODE_PRIVATE)
13
13
  val dir: File get() = File(ctx.filesDir, "shipvo").apply { mkdirs() }
14
14
 
@@ -63,13 +63,15 @@ internal class Store(private val ctx: Context) {
63
63
  }
64
64
 
65
65
  var trialBoots: Int get() = p.getInt("trial_boots", 0); set(v) { p.edit().putInt("trial_boots", v).commit() }
66
- var unhealthyBoots: Int get() = p.getInt("unhealthy_boots", 0); set(v) { p.edit().putInt("unhealthy_boots", v).commit() }
67
- var firstUnhealthyAt: Long get() = p.getLong("first_unhealthy_at", 0); set(v) { p.edit().putLong("first_unhealthy_at", v).commit() }
66
+ override var unhealthyBoots: Int get() = p.getInt("unhealthy_boots", 0); set(v) { p.edit().putInt("unhealthy_boots", v).commit() }
67
+ override var firstUnhealthyAt: Long get() = p.getLong("first_unhealthy_at", 0); set(v) { p.edit().putLong("first_unhealthy_at", v).commit() }
68
68
 
69
69
  fun addEvent(e: String) {
70
70
  val list = p.getString("events", "")!!.split('\n').filter { it.isNotBlank() }.takeLast(49) + e
71
71
  p.edit().putString("events", list.joinToString("\n")).commit()
72
72
  }
73
+ /** Events queued and not yet consumed, oldest first. */
74
+ fun pendingEvents(): List<String> = p.getString("events", "")!!.split('\n').filter { it.isNotBlank() }
73
75
  fun consumeEvents(): List<String> {
74
76
  val list = p.getString("events", "")!!.split('\n').filter { it.isNotBlank() }
75
77
  p.edit().remove("events").commit(); return list
@@ -79,9 +81,18 @@ internal class Store(private val ctx: Context) {
79
81
  val ai = ctx.packageManager.getApplicationInfo(ctx.packageName, PackageManager.GET_META_DATA)
80
82
  ai.metaData?.getString(key)
81
83
  } catch (_: Exception) { null }
84
+ /** Like [meta], but also accepts numbers: a manifest can store a single version such as 96 as an integer. */
85
+ private fun metaAny(key: String): String? = try {
86
+ val ai = ctx.packageManager.getApplicationInfo(ctx.packageName, PackageManager.GET_META_DATA)
87
+ ai.metaData?.get(key)?.toString()
88
+ } catch (_: Exception) { null }
82
89
  val baseUrl: String get() = (meta("dev.shipvo.BASE_URL") ?: "").trimEnd('/')
83
90
  val publicKey: String get() = meta("dev.shipvo.PUBLIC_KEY") ?: ""
84
91
  val appId: String get() = meta("dev.shipvo.APP_ID") ?: ""
92
+ /** Hermes bytecode versions this shell runs, e.g. "96,98". Null when the shell does not declare them. */
93
+ val hermesBytecodeVersions: String? get() = metaAny("dev.shipvo.HERMES_BYTECODE_VERSIONS")?.takeIf { it.isNotBlank() }
94
+ /** Native modules in this shell, e.g. "react-native-mmkv@3.1.0,@scope/pkg@1.0.0". Null when not declared. */
95
+ val nativeModules: String? get() = metaAny("dev.shipvo.NATIVE_MODULES")?.takeIf { it.isNotBlank() }
85
96
  val shellMajor: Int get() = (meta("dev.shipvo.SHELL_MAJOR") ?: "1").toIntOrNull() ?: 1
86
97
 
87
98
  companion object {
@@ -0,0 +1,73 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+ import java.io.ByteArrayInputStream
7
+ import java.io.IOException
8
+ import java.io.InputStream
9
+
10
+ class HermesGuardTest {
11
+ private fun hbc(v: Int) = byteArrayOf(0xc6.toByte(), 0x1f, 0xbc.toByte(), 0x03, 0xc1.toByte(), 0x03, 0x19, 0x1f,
12
+ v.toByte(), (v shr 8).toByte(), (v shr 16).toByte(), (v shr 24).toByte())
13
+
14
+ @Test fun readsVersionFromHeader() {
15
+ assertEquals(96, HermesGuard.versionOf(hbc(96)))
16
+ assertEquals(300, HermesGuard.versionOf(hbc(300)))
17
+ assertNull(HermesGuard.versionOf("var a = 1; // js".toByteArray()))
18
+ assertNull(HermesGuard.versionOf(ByteArray(4)))
19
+ }
20
+
21
+ @Test fun mismatchIsRefusedWithTypedReason() {
22
+ assertEquals("hermes_bytecode_mismatch:7:release=98:shell=96", HermesGuard.mismatchEvent(7, 98, 96))
23
+ }
24
+
25
+ @Test fun matchIsAccepted() { assertNull(HermesGuard.mismatchEvent(7, 96, 96)) }
26
+
27
+ @Test fun missingFieldIsAccepted() {
28
+ assertNull(HermesGuard.mismatchEvent(7, null, 96))
29
+ assertNull(HermesGuard.mismatchEvent(7, 96, null))
30
+ }
31
+
32
+ /** Hands out at most `chunk` bytes per read, like a slow stream. */
33
+ private fun trickle(b: ByteArray, chunk: Int) = object : InputStream() {
34
+ var i = 0
35
+ override fun read(): Int = if (i < b.size) b[i++].toInt() and 0xff else -1
36
+ override fun read(buf: ByteArray, off: Int, len: Int): Int {
37
+ if (i >= b.size) return -1
38
+ val n = minOf(len, chunk, b.size - i); System.arraycopy(b, i, buf, off, n); i += n; return n
39
+ }
40
+ }
41
+
42
+ @Test fun headerIsReadAcrossShortReads() {
43
+ assertEquals(96, HermesGuard.readHeader { trickle(hbc(96) + ByteArray(50), 1) }?.let { HermesGuard.versionOf(it) })
44
+ }
45
+
46
+ @Test fun shortStreamAndMissingAssetAreUnknown() {
47
+ assertNull(HermesGuard.readHeader { trickle(hbc(96).copyOf(5), 3) }?.let { HermesGuard.versionOf(it) })
48
+ assertNull(HermesGuard.readHeader { ByteArrayInputStream(hbc(96).copyOf(9)) }?.let { HermesGuard.versionOf(it) })
49
+ assertNull(HermesGuard.readHeader { null })
50
+ assertNull(HermesGuard.readHeader { throw IOException("gone") })
51
+ assertNull(HermesGuard.decide(7, 98) { null })
52
+ assertNull(HermesGuard.decide(7, 98) { ByteArrayInputStream(hbc(96).copyOf(9)) })
53
+ }
54
+
55
+ @Test fun versionFieldMustBeAWholePositiveNumber() {
56
+ assertEquals(96, HermesGuard.releaseVersionOf(96))
57
+ assertEquals(96, HermesGuard.releaseVersionOf(96L))
58
+ assertNull(HermesGuard.releaseVersionOf(96.5))
59
+ assertNull(HermesGuard.releaseVersionOf("96"))
60
+ assertNull(HermesGuard.releaseVersionOf(0))
61
+ assertNull(HermesGuard.releaseVersionOf(-3))
62
+ assertNull(HermesGuard.releaseVersionOf(null))
63
+ }
64
+
65
+ @Test fun decideCoversMismatchMatchAndMissingField() {
66
+ val shell = { ByteArrayInputStream(hbc(96) + ByteArray(20)) }
67
+ assertEquals("hermes_bytecode_mismatch:7:release=98:shell=96", HermesGuard.decide(7, 98, shell))
68
+ assertNull(HermesGuard.decide(7, 96, shell))
69
+ assertNull(HermesGuard.decide(7, null, shell))
70
+ assertNull(HermesGuard.decide(7, 96.5, shell))
71
+ assertNull(HermesGuard.decide(7, 98) { ByteArrayInputStream("var a;".toByteArray()) })
72
+ }
73
+ }
@@ -0,0 +1,42 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class PointerQueryTest {
8
+ private fun url(hbc: String?, nm: String?) = PointerQuery.url("https://x.dev", "app", 1, "b2", hbc, nm)
9
+
10
+ @Test fun noQueryWhenNothingDeclared() {
11
+ assertEquals("https://x.dev/p/app/1/b2.json", url(null, null))
12
+ assertEquals("https://x.dev/p/app/1/b2.json", url("", " "))
13
+ }
14
+
15
+ @Test fun queryWhenDeclared() {
16
+ assertEquals("https://x.dev/p/app/1/b2.json?hbc=96,98", url("96,98", null))
17
+ assertEquals("https://x.dev/p/app/1/b2.json?hbc=96&nm=a%401.0.0", url("96", "a@1.0.0"))
18
+ }
19
+
20
+ @Test fun scopedNamesAreEncoded() {
21
+ assertEquals("https://x.dev/p/app/1/b2.json?nm=%40s%2Fp%401.2.3,b%402.0.0", url(null, "@s/p@1.2.3,b@2.0.0"))
22
+ }
23
+
24
+ @Test fun readsTypedReason() {
25
+ assertEquals("native_module_missing", PointerQuery.mismatchReason("{\"error\":\"native_module_missing\"}"))
26
+ assertEquals("hermes_bytecode_mismatch", PointerQuery.mismatchReason("{ \"error\": \"hermes_bytecode_mismatch\" }"))
27
+ assertNull(PointerQuery.mismatchReason("{\"error\":\"not_found\"}"))
28
+ assertNull(PointerQuery.mismatchReason(null))
29
+ }
30
+
31
+ @Test fun scopedNameWithoutVersionIsEncodedAsIs() {
32
+ // The control plane splits on the last "@", so "@s/p" has no version there.
33
+ assertEquals("https://x.dev/p/app/1/b2.json?nm=%40s%2Fp", url(null, "@s/p"))
34
+ }
35
+
36
+ @Test fun queuesRejectionOncePerReason() {
37
+ assertEquals("pointer_rejected:native_module_missing", PointerQuery.rejectionEvent(emptyList(), "native_module_missing"))
38
+ val queued = listOf("crash_loop_revert", "pointer_rejected:native_module_missing")
39
+ assertNull(PointerQuery.rejectionEvent(queued, "native_module_missing"))
40
+ assertEquals("pointer_rejected:hermes_bytecode_mismatch", PointerQuery.rejectionEvent(queued, "hermes_bytecode_mismatch"))
41
+ }
42
+ }
@@ -0,0 +1,90 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import java.io.File
4
+ import java.net.InetAddress
5
+ import java.net.ServerSocket
6
+ import kotlin.concurrent.thread
7
+ import org.junit.After
8
+ import org.junit.Assert.assertArrayEquals
9
+ import org.junit.Assert.assertEquals
10
+ import org.junit.Assert.assertFalse
11
+ import org.junit.Before
12
+ import org.junit.Test
13
+
14
+ class ResumableDownloadTest {
15
+ private val data = ByteArray(300_000) { (it * 31 % 251).toByte() }
16
+ private val ranges = mutableListOf<String?>()
17
+ private var honourRange = true
18
+ // A minimal HTTP/1.1 server on a plain socket: com.sun.net.httpserver is not on the Android unit-test classpath.
19
+ private lateinit var server: ServerSocket
20
+ private lateinit var dir: File
21
+ private val url get() = "http://127.0.0.1:${server.localPort}/b"
22
+
23
+ @Before fun start() {
24
+ dir = File.createTempFile("shipvo", "dl").apply { delete(); mkdirs() }
25
+ server = ServerSocket(0, 50, InetAddress.getByName("127.0.0.1"))
26
+ thread(isDaemon = true) {
27
+ while (!server.isClosed) {
28
+ val s = try { server.accept() } catch (e: Exception) { break }
29
+ s.use { sock ->
30
+ val reader = sock.getInputStream().bufferedReader(Charsets.ISO_8859_1)
31
+ reader.readLine() ?: return@use
32
+ var range: String? = null
33
+ while (true) {
34
+ val line = reader.readLine() ?: break
35
+ if (line.isEmpty()) break
36
+ val i = line.indexOf(':')
37
+ if (i > 0 && line.substring(0, i).trim().equals("Range", ignoreCase = true)) range = line.substring(i + 1).trim()
38
+ }
39
+ synchronized(ranges) { ranges.add(range) }
40
+ val start = if (honourRange && range != null) range.removePrefix("bytes=").removeSuffix("-").toInt() else 0
41
+ val body = data.copyOfRange(start, data.size)
42
+ val head = buildString {
43
+ append(if (start > 0) "HTTP/1.1 206 Partial Content\r\n" else "HTTP/1.1 200 OK\r\n")
44
+ if (start > 0) append("Content-Range: bytes $start-${data.size - 1}/${data.size}\r\n")
45
+ append("Content-Length: ${body.size}\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n")
46
+ }
47
+ val out = sock.getOutputStream()
48
+ out.write(head.toByteArray(Charsets.ISO_8859_1)); out.write(body); out.flush()
49
+ }
50
+ }
51
+ }
52
+ }
53
+ @After fun stop() { server.close(); dir.deleteRecursively() }
54
+
55
+ @Test fun secondRequestStartsAtThePartialSize() {
56
+ val part = ResumableDownload.partialFile(dir, "abc")
57
+ part.writeBytes(data.copyOfRange(0, 100_000))
58
+ val from = ResumableDownload.fetch(url, part, data.size.toLong())
59
+ assertEquals(100_000L, from)
60
+ assertEquals(listOf<String?>("bytes=100000-"), ranges)
61
+ assertArrayEquals(data, part.readBytes())
62
+ }
63
+
64
+ @Test fun a200AnswerToARangeRequestRestartsCleanly() {
65
+ honourRange = false
66
+ val part = ResumableDownload.partialFile(dir, "abc")
67
+ part.writeBytes(data.copyOfRange(0, 100_000))
68
+ ResumableDownload.fetch(url, part, data.size.toLong())
69
+ assertEquals(listOf<String?>("bytes=100000-"), ranges)
70
+ assertArrayEquals(data, part.readBytes())
71
+ }
72
+
73
+ @Test fun freshDownloadSendsNoRange() {
74
+ val part = ResumableDownload.partialFile(dir, "abc")
75
+ assertEquals(0L, ResumableDownload.fetch(url, part, data.size.toLong()))
76
+ assertEquals(listOf<String?>(null), ranges)
77
+ assertArrayEquals(data, part.readBytes())
78
+ }
79
+
80
+ @Test fun anOversizedPartialIsDiscarded() {
81
+ val part = ResumableDownload.partialFile(dir, "abc")
82
+ part.writeBytes(ByteArray(data.size + 5))
83
+ assertEquals(0L, ResumableDownload.fetch(url, part, data.size.toLong()))
84
+ assertArrayEquals(data, part.readBytes())
85
+ }
86
+
87
+ @Test fun partialFilesAreNamedByReleaseHash() {
88
+ assertFalse(ResumableDownload.partialFile(dir, "a") == ResumableDownload.partialFile(dir, "b"))
89
+ }
90
+ }
@@ -0,0 +1,76 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertFalse
5
+ import org.junit.Assert.assertTrue
6
+ import org.junit.Before
7
+ import org.junit.Test
8
+
9
+ class StabilityTest {
10
+ private class Counts : BootCounts { override var unhealthyBoots = 0; override var firstUnhealthyAt = 0L }
11
+ private val max = ShipvoHostHandler.MAX_BOOTS
12
+ private val win = ShipvoHostHandler.WINDOW_MS
13
+
14
+ @Before fun reset() { BootRules.healthyLoad = 0; BootRules.crashedLoad = 0 }
15
+
16
+ /** One launch of a promoted release: count it, then report healthy. Returns true if the launch reverts. */
17
+ private fun launch(c: Counts, now: Long, load: Int): Boolean {
18
+ if (BootRules.countBoot(c, now, max, win)) return true
19
+ BootRules.onHealthy(c, load, now, promoted = false); return false
20
+ }
21
+
22
+ @Test fun releaseThatDiesAfterMarkHealthyIsReverted() {
23
+ val c = Counts()
24
+ BootRules.onHealthy(c, 1, 0, promoted = true) // boot 1: promoted, counted as 1
25
+ assertFalse(launch(c, 5_000, 2)) // boot 2
26
+ assertFalse(launch(c, 10_000, 3)) // boot 3
27
+ assertTrue("fourth launch reverts", launch(c, 15_000, 4))
28
+ }
29
+
30
+ @Test fun releaseThatReachesStabilityIsKept() {
31
+ val c = Counts()
32
+ BootRules.onHealthy(c, 1, 0, promoted = true)
33
+ for (i in 2..20) {
34
+ assertFalse(launch(c, i * 5_000L, i))
35
+ assertTrue(BootRules.markStableIfHealthy(c, i))
36
+ }
37
+ }
38
+
39
+ @Test fun backgroundBeforeMarkHealthyDoesNotClearTheCount() {
40
+ val c = Counts()
41
+ BootRules.onHealthy(c, 1, 0, promoted = true) // load 1 reported healthy, then the app reloaded
42
+ BootRules.countBoot(c, 1_000, max, win) // load 2 starts
43
+ assertFalse(BootRules.markStableIfHealthy(c, 2))
44
+ assertEquals(2, c.unhealthyBoots)
45
+ }
46
+
47
+ @Test fun crashAfterMarkHealthyIsNotStableWhenHostIsDestroyed() {
48
+ val c = Counts()
49
+ BootRules.onHealthy(c, 1, 0, promoted = true)
50
+ BootRules.noteCrash(1) // JS error, then the host pause calls markStableIfHealthy
51
+ assertFalse(BootRules.markStableIfHealthy(c, 1))
52
+ assertEquals(1, c.unhealthyBoots)
53
+ }
54
+
55
+ @Test fun crashInOneLoadDoesNotBlockTheNextLoad() {
56
+ val c = Counts()
57
+ BootRules.onHealthy(c, 1, 0, promoted = true)
58
+ BootRules.noteCrash(1)
59
+ BootRules.onHealthy(c, 2, 1_000, promoted = false)
60
+ assertTrue(BootRules.markStableIfHealthy(c, 2))
61
+ }
62
+
63
+ @Test fun countsExpireOutsideTheWindow() {
64
+ val c = Counts()
65
+ BootRules.onHealthy(c, 1, 0, promoted = true)
66
+ assertFalse(BootRules.countBoot(c, win + 1, max, win))
67
+ assertEquals(1, c.unhealthyBoots)
68
+ }
69
+
70
+ @Test fun aReleaseAlreadyPendingCurrentOrBlockedIsNotStagedAgain() {
71
+ assertTrue(BootRules.mayStage(5, 3, 4, 2))
72
+ assertFalse(BootRules.mayStage(5, 3, 5, 0)) // pending: staging again would reset trialBoots
73
+ assertFalse(BootRules.mayStage(5, 5, 0, 0))
74
+ assertFalse(BootRules.mayStage(5, 3, 0, 5))
75
+ }
76
+ }
package/app.plugin.js CHANGED
@@ -3,15 +3,51 @@
3
3
  // change which key it is verified against.
4
4
  const { withAndroidManifest, withInfoPlist, AndroidConfig } = require("expo/config-plugins");
5
5
 
6
+ // Returns the comma list for the Hermes bytecode versions, or undefined when the prop is omitted.
7
+ function formatBytecodeVersions(value) {
8
+ if (value === undefined) return undefined;
9
+ if (!Array.isArray(value)) throw new Error("@shipvo/runtime: hermesBytecodeVersions must be an array of integers");
10
+ for (const v of value) {
11
+ if (!Number.isInteger(v) || v < 0) {
12
+ throw new Error(`@shipvo/runtime: hermesBytecodeVersions must be non-negative integers, got ${JSON.stringify(v)}`);
13
+ }
14
+ }
15
+ return value.join(",");
16
+ }
17
+
18
+ // Returns "name@version,..." for the native modules, or undefined when the prop is omitted.
19
+ function formatNativeModules(value) {
20
+ if (value === undefined) return undefined;
21
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
22
+ throw new Error("@shipvo/runtime: nativeModules must be an object of package name to version");
23
+ }
24
+ return Object.entries(value)
25
+ .map(([name, version]) => {
26
+ if (!name.trim()) throw new Error("@shipvo/runtime: nativeModules has an empty package name");
27
+ if (typeof version !== "string" || !version.trim()) {
28
+ throw new Error(`@shipvo/runtime: nativeModules version for "${name}" must be a non-empty string`);
29
+ }
30
+ if (/[,\s]/.test(version) || /[,\s]/.test(name)) {
31
+ throw new Error(`@shipvo/runtime: nativeModules entry "${name}": "${version}" has a comma or space where it cannot`);
32
+ }
33
+ return `${name}@${version}`;
34
+ })
35
+ .join(",");
36
+ }
37
+
6
38
  module.exports = function withShipvo(config, props) {
7
39
  const { baseUrl, publicKey, appId, shellMajor = 1 } = props || {};
8
40
  if (!baseUrl || !publicKey || !appId) throw new Error("@shipvo/runtime: baseUrl, publicKey and appId are required");
41
+ const hbc = formatBytecodeVersions(props.hermesBytecodeVersions);
42
+ const nm = formatNativeModules(props.nativeModules);
9
43
  config.extra = { ...(config.extra ?? {}), shipvo: { baseUrl, appId, shellMajor } };
10
44
  config = withInfoPlist(config, (cfg) => {
11
45
  cfg.modResults.ShipvoBaseURL = baseUrl;
12
46
  cfg.modResults.ShipvoPublicKey = publicKey;
13
47
  cfg.modResults.ShipvoAppId = appId;
14
48
  cfg.modResults.ShipvoShellMajor = String(shellMajor);
49
+ if (hbc !== undefined) cfg.modResults.ShipvoHermesBytecodeVersions = hbc;
50
+ if (nm !== undefined) cfg.modResults.ShipvoNativeModules = nm;
15
51
  return cfg;
16
52
  });
17
53
  return withAndroidManifest(config, (cfg) => {
@@ -21,6 +57,8 @@ module.exports = function withShipvo(config, props) {
21
57
  set("dev.shipvo.PUBLIC_KEY", publicKey);
22
58
  set("dev.shipvo.APP_ID", appId);
23
59
  set("dev.shipvo.SHELL_MAJOR", shellMajor);
60
+ if (hbc !== undefined) set("dev.shipvo.HERMES_BYTECODE_VERSIONS", hbc);
61
+ if (nm !== undefined) set("dev.shipvo.NATIVE_MODULES", nm);
24
62
  return cfg;
25
63
  });
26
64
  };
@@ -4,6 +4,8 @@ import Foundation
4
4
  enum ShipvoBundleSelector {
5
5
  static let maxBoots = 3
6
6
  static let windowSeconds: TimeInterval = 60
7
+ /// A release is stable once its process has stayed in the foreground this long after `markHealthy`. Android uses the same value.
8
+ static let stabilitySeconds: TimeInterval = 30
7
9
 
8
10
  /// Idempotent per load: React Native asks for the URL more than once, and only the first ask counts as a boot.
9
11
  static func select(_ s: Store) -> URL? {
@@ -25,12 +27,30 @@ enum ShipvoBundleSelector {
25
27
  s.currentRelease = s.pendingRelease; s.currentPath = s.pendingPath; s.currentSha = s.pendingSha
26
28
  s.pendingRelease = 0; s.pendingPath = nil; s.trialBoots = 0
27
29
  s.addEvent("applied:\(s.currentRelease)")
30
+ // The promoting boot was a trial boot and was not counted. Count it now, until the stability window ends.
31
+ s.unhealthyBoots = 1; s.firstUnhealthyAt = Store.now()
28
32
  promoted = true
29
33
  }
30
- s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
34
+ // The boot counter is not cleared here: `markStableIfHealthy` does that after the stability window.
31
35
  return promoted
32
36
  }
33
37
 
38
+ /// The load that last reported healthy. `markStableIfHealthy` only acts for that load.
39
+ private static var healthyLoad = 0
40
+
41
+ /// Called from `markHealthy`: the current load reached its first screen.
42
+ static func noteHealthy() { Store.lock.lock(); healthyLoad = Store.loadsInProcess; Store.lock.unlock() }
43
+
44
+ /// Clears the count only if the current load reported healthy. After a reload, the new load has not, so a crash
45
+ /// before its `markHealthy` still counts. Returns whether the count was cleared.
46
+ @discardableResult
47
+ static func markStableIfHealthy(_ s: Store) -> Bool {
48
+ Store.lock.lock(); defer { Store.lock.unlock() }
49
+ guard healthyLoad != 0, healthyLoad == Store.loadsInProcess else { return false }
50
+ s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
51
+ return true
52
+ }
53
+
34
54
  /// Called before a reload so the next ask is a fresh load.
35
55
  static func invalidate() { Store.lock.lock(); Store.selection = (nil, false); Store.lock.unlock() }
36
56
 
@@ -0,0 +1,43 @@
1
+ import Foundation
2
+
3
+ /// Refuses a release built for a different Hermes bytecode version than the shell runs. Runs in addition to the
4
+ /// signature and hash checks, never instead of them.
5
+ enum HermesGuard {
6
+ static let reason = "hermes_bytecode_mismatch"
7
+ // Same magic and layout as the CLI code that writes `hermes_bytecode_version` on publish.
8
+ private static let magic: [UInt8] = [0xc6, 0x1f, 0xbc, 0x03, 0xc1, 0x03, 0x19, 0x1f]
9
+
10
+ /// The bytecode version in a bundle header (8-byte magic, then a 32-bit little-endian version), or nil for plain JS.
11
+ static func version(of head: Data) -> Int? {
12
+ let b = [UInt8](head.prefix(12))
13
+ guard b.count >= 12, Array(b[0..<8]) == magic else { return nil }
14
+ let v = (0..<4).reduce(0) { $0 | Int(b[8 + $1]) << (8 * $1) }
15
+ return v > 0 ? v : nil
16
+ }
17
+
18
+ /// The release's version field, or nil ("unknown") unless it is a whole number from 1 to Int32.max.
19
+ /// Same rule as `releaseVersionOf` on Android: rejects Bool, fractions, strings, zero and negatives.
20
+ static func releaseVersionOf(_ value: Any?) -> Int? {
21
+ guard let n = value as? NSNumber, CFGetTypeID(n) != CFBooleanGetTypeID() else { return nil }
22
+ let d = n.doubleValue
23
+ guard d == d.rounded(), d > 0, d <= Double(Int32.max) else { return nil }
24
+ return Int(d)
25
+ }
26
+
27
+ /// The event to log when the release must be refused, or nil when it may be staged.
28
+ /// A missing field on either side means "unknown", so the release is applied as before.
29
+ static func mismatchEvent(release: Int, releaseVersion: Int?, shellVersion: Int?) -> String? {
30
+ guard let r = releaseVersion, let s = shellVersion, r != s else { return nil }
31
+ return "\(reason):\(release):release=\(r):shell=\(s)"
32
+ }
33
+
34
+ /// The version of the bundle embedded in the app, read from its header.
35
+ /// The embedded bundle header is the source of truth here, not `ShipvoHermesBytecodeVersions` in Info.plist
36
+ /// (that list is only sent to the control plane to filter releases).
37
+ /// Read by file path with `FileHandle`, not through `Bundle(path:)` on a plain folder.
38
+ static func shellVersion(_ bundle: Bundle) -> Int? {
39
+ guard let url = bundle.url(forResource: "main", withExtension: "jsbundle"), let h = try? FileHandle(forReadingFrom: url) else { return nil }
40
+ defer { try? h.close() }
41
+ return version(of: (try? h.read(upToCount: 12)) ?? Data())
42
+ }
43
+ }
@@ -35,10 +35,14 @@ public final class ShipvoRuntimeModule: Module {
35
35
  let coldStart = max(0, (Date().timeIntervalSince1970 - Self.processStart()) * 1000)
36
36
  let sinceLoad = Store.bundleSelectedAt > 0 ? (now - Store.bundleSelectedAt) * 1000 : -1
37
37
  let promoted = ShipvoBundleSelector.promoteIfRunning(s)
38
+ ShipvoBundleSelector.noteHealthy()
39
+ Self.scheduleStable()
38
40
  return ["release": Store.runningRelease, "coldStartMs": Int(coldStart), "sinceLoadMs": Int(sinceLoad), "promoted": promoted,
39
41
  "kind": Store.loadsInProcess <= 1 ? "cold" : "reload"]
40
42
  }
41
43
 
44
+ OnAppEntersBackground { Self.stableNow() }
45
+
42
46
  Function("consumeEvents") { () -> [String] in Store().consumeEvents() }
43
47
 
44
48
  Function("rollback") { () in
@@ -56,7 +60,30 @@ public final class ShipvoRuntimeModule: Module {
56
60
  Function("reload") { () in Self.reloadHost() }
57
61
  }
58
62
 
63
+ private static var stableTimer: DispatchWorkItem?
64
+
65
+ /// Clears the unhealthy-boot count once the app has stayed up for the stability window.
66
+ /// The timer is dropped after it fires and on reload, and `markStableIfHealthy` checks the load number.
67
+ static func scheduleStable() {
68
+ Store.lock.lock(); defer { Store.lock.unlock() }
69
+ stableTimer?.cancel()
70
+ let item = DispatchWorkItem {
71
+ ShipvoBundleSelector.markStableIfHealthy(Store())
72
+ Store.lock.lock(); stableTimer = nil; Store.lock.unlock()
73
+ }
74
+ stableTimer = item
75
+ DispatchQueue.main.asyncAfter(deadline: .now() + ShipvoBundleSelector.stabilitySeconds, execute: item)
76
+ }
77
+
78
+ /// Going to background cleanly counts as stable, but only if this load reported healthy.
79
+ static func stableNow() {
80
+ if ShipvoBundleSelector.markStableIfHealthy(Store()) {
81
+ Store.lock.lock(); stableTimer?.cancel(); stableTimer = nil; Store.lock.unlock()
82
+ }
83
+ }
84
+
59
85
  static func reloadHost() {
86
+ Store.lock.lock(); stableTimer?.cancel(); stableTimer = nil; Store.lock.unlock()
60
87
  ShipvoBundleSelector.invalidate()
61
88
  DispatchQueue.main.async { RCTTriggerReloadCommandListeners("shipvo-update") }
62
89
  }
package/ios/Store.swift CHANGED
@@ -6,6 +6,7 @@ final class Store {
6
6
  /// Replaces what the app's Info.plist and file system would say. Tests use it; the app never does.
7
7
  struct Overrides {
8
8
  var suite: String, dir: URL, baseUrl: String, publicKey: String, appId: String, shellMajor: Int, shellVersion: String, binaryVersion: String
9
+ var hermesBytecodeVersions: [Int] = [], nativeModules: [String: String] = [:]
9
10
  }
10
11
 
11
12
  private let d: UserDefaults
@@ -44,6 +45,19 @@ final class Store {
44
45
  var blockedRelease: Int { get { d.integer(forKey: "blocked_release") } set { d.set(newValue, forKey: "blocked_release") } }
45
46
  /// Shell binary the stored OTA bundles were downloaded under.
46
47
  var binaryVersion: String? { get { d.string(forKey: "binary_version") } set { d.set(newValue, forKey: "binary_version") } }
48
+ var appBundle: Bundle { bundle }
49
+ /// The `main.jsbundle` inside the app: the first release, and the patch base before any download.
50
+ func embeddedBundle() -> Data? {
51
+ guard let url = bundle.url(forResource: "main", withExtension: "jsbundle") else { return nil }
52
+ return try? Data(contentsOf: url)
53
+ }
54
+ /// Its sha256, cached per installed binary so the file is hashed once.
55
+ func embeddedSha() -> String? {
56
+ let key = "embedded_sha:" + installedBinaryVersion
57
+ if let v = d.string(forKey: key) { return v }
58
+ guard let data = embeddedBundle() else { return nil }
59
+ let sha = Verify.sha256Hex(data); d.set(sha, forKey: key); return sha
60
+ }
47
61
  func block(_ release: Int) { if release > blockedRelease { blockedRelease = release } }
48
62
 
49
63
  var trialBoots: Int { get { d.integer(forKey: "trial_boots") } set { d.set(newValue, forKey: "trial_boots") } }
@@ -85,6 +99,18 @@ final class Store {
85
99
  var baseUrl: String { var s = o?.baseUrl ?? plist("ShipvoBaseURL") ?? ""; while s.hasSuffix("/") { s.removeLast() }; return s }
86
100
  var publicKey: String { o?.publicKey ?? plist("ShipvoPublicKey") ?? "" }
87
101
  var appId: String { o?.appId ?? plist("ShipvoAppId") ?? "" }
102
+ /// Hermes bytecode versions this shell runs (Info.plist `ShipvoHermesBytecodeVersions`: an array of numbers or a "96,98" string).
103
+ var hermesBytecodeVersions: [Int] {
104
+ if let o { return o.hermesBytecodeVersions }
105
+ let v = bundle.object(forInfoDictionaryKey: "ShipvoHermesBytecodeVersions")
106
+ let list: [Any] = (v as? [Any]) ?? ((v as? String)?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } ?? [])
107
+ return list.compactMap { ($0 as? NSNumber)?.intValue ?? Int("\($0)") }.filter { $0 > 0 }
108
+ }
109
+ /// Native modules in this shell (Info.plist `ShipvoNativeModules`: a name-to-version dictionary).
110
+ var nativeModules: [String: String] {
111
+ if let o { return o.nativeModules }
112
+ return (bundle.object(forInfoDictionaryKey: "ShipvoNativeModules") as? [String: Any])?.compactMapValues { $0 as? String } ?? [:]
113
+ }
88
114
  var shellMajor: Int { if let o { return o.shellMajor }; return Int(plist("ShipvoShellMajor") ?? "1") ?? 1 }
89
115
 
90
116
  // MARK: per-process state
package/ios/Updater.swift CHANGED
@@ -3,7 +3,7 @@ import Foundation
3
3
  struct UpdateError: Error { let message: String; init(_ m: String) { message = m } }
4
4
 
5
5
  /// Fetch, verify and stage the newest signed release for this device's band. Mirrors Android `check()`.
6
- /// Uses a bsdiff patch when the pointer lists one from the bundle this device runs; any patch problem falls back to the full bundle.
6
+ /// Uses a bsdiff patch when the pointer lists one from the bundle this device runs (or the app's embedded bundle); any patch problem falls back to the full bundle.
7
7
  enum Updater {
8
8
  private static func require(_ cond: Bool, _ msg: @autoclosure () -> String) throws { if !cond { throw UpdateError(msg()) } }
9
9
 
@@ -16,12 +16,32 @@ enum Updater {
16
16
 
17
17
  private static func resolve(_ s: Store, _ url: String) -> String { url.hasPrefix("http") ? url : "\(s.baseUrl)/\(url.drop(while: { $0 == "/" }))" }
18
18
 
19
+ /// Pointer URL. `?hbc=` and `?nm=` are added only when the shell declares them. Scoped names are percent-encoded.
20
+ static func pointerURL(_ s: Store) -> String {
21
+ var url = "\(s.baseUrl)/p/\(s.appId)/\(s.shellMajor)/ios/\(s.band).json"
22
+ func enc(_ x: String) -> String { x.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")) ?? x }
23
+ var q: [String] = []
24
+ let hbc = s.hermesBytecodeVersions
25
+ if !hbc.isEmpty { q.append("hbc=" + hbc.map(String.init).joined(separator: ",")) }
26
+ let nm = s.nativeModules
27
+ if !nm.isEmpty { q.append("nm=" + nm.keys.sorted().map { enc($0) + "%40" + enc(nm[$0]!) }.joined(separator: ",")) }
28
+ if !q.isEmpty { url += "?" + q.joined(separator: "&") }
29
+ return url
30
+ }
31
+
19
32
  static func check(_ s: Store) async throws -> [String: Any] {
20
33
  try require(!s.baseUrl.isEmpty && !s.publicKey.isEmpty && !s.appId.isEmpty, "runtime not configured")
21
34
  let known = max(s.currentRelease, s.pendingRelease, s.blockedRelease)
22
- let (data, resp) = try await URLSession.shared.data(for: request("\(s.baseUrl)/p/\(s.appId)/\(s.shellMajor)/ios/\(s.band).json", etag: known > 0 ? "\"\(known)\"" : nil))
35
+ let (data, resp) = try await URLSession.shared.data(for: request(pointerURL(s), etag: known > 0 ? "\"\(known)\"" : nil))
23
36
  let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
24
- if code == 304 || code == 404 { return ["status": "none"] }
37
+ if code == 404 {
38
+ // The control plane says this shell cannot run the release. Keep the current one; stage nothing.
39
+ let text = String(data: data, encoding: .utf8) ?? ""
40
+ let reason = ((try? JSONSerialization.jsonObject(with: data)) as? [String: Any])?["error"] as? String ?? text.trimmingCharacters(in: .whitespacesAndNewlines)
41
+ if ["hermes_bytecode_mismatch", "native_module_missing", "native_module_version"].contains(reason) { return ["status": "none", "reason": reason] }
42
+ return ["status": "none"]
43
+ }
44
+ if code == 304 { return ["status": "none"] }
25
45
  try require(code == 200, "pointer HTTP \(code)")
26
46
  guard let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
27
47
  let bodyStr = envelope["body"] as? String, let sig = envelope["signature"] as? String else { throw UpdateError("pointer is not an envelope") }
@@ -35,11 +55,18 @@ enum Updater {
35
55
  if release <= known { return ["status": "none"] }
36
56
  try require(!Verify.semverLess(s.shellVersion, body["min_shell_version"] as? String ?? "0.0.0"), "shell too old for release \(release)")
37
57
  let verifyPointerMs = (Store.now() - t0) * 1000
58
+ // Beside the signature checks, not inside them: refuse a bundle built for another Hermes bytecode version.
59
+ if let ev = HermesGuard.mismatchEvent(release: release, releaseVersion: HermesGuard.releaseVersionOf(body["hermes_bytecode_version"]), shellVersion: HermesGuard.shellVersion(s.appBundle)) {
60
+ s.addEvent(ev); s.block(release)
61
+ return ["status": "none", "reason": HermesGuard.reason]
62
+ }
38
63
 
39
64
  guard let core = body["core"] as? [String: Any], let wantSha = core["sha256"] as? String,
40
65
  let wantSize = (core["size"] as? NSNumber)?.intValue, let url = core["url"] as? String else { throw UpdateError("pointer core incomplete") }
41
66
  let tmp = s.dir.appendingPathComponent("dl-\(release).tmp")
42
67
  try? FileManager.default.removeItem(at: tmp)
68
+ let part = partialURL(s, sha: wantSha)
69
+ dropOtherPartials(s, keep: part)
43
70
 
44
71
  var via = "full", downloadedBytes = 0
45
72
  let d0 = Store.now()
@@ -48,21 +75,20 @@ enum Updater {
48
75
  ready = true; via = "patch"; downloadedBytes = patch
49
76
  }
50
77
  var downloadMs = (Store.now() - d0) * 1000
78
+ var resumedFrom = 0
51
79
  if !ready {
52
80
  let f0 = Store.now()
53
- let (fileURL, dresp) = try await URLSession.shared.download(for: request(resolve(s, url), etag: nil))
54
- let dcode = (dresp as? HTTPURLResponse)?.statusCode ?? 0
55
- try require(dcode == 200, "bundle HTTP \(dcode)")
81
+ resumedFrom = try await downloadFull(s, url: resolve(s, url), part: part, wantSize: wantSize)
56
82
  try? FileManager.default.removeItem(at: tmp)
57
- try FileManager.default.moveItem(at: fileURL, to: tmp)
83
+ try FileManager.default.moveItem(at: part, to: tmp)
58
84
  downloadMs += (Store.now() - f0) * 1000
59
85
  }
60
86
 
61
87
  let v0 = Store.now()
62
88
  let size = (try? FileManager.default.attributesOfItem(atPath: tmp.path)[.size] as? Int) ?? -1
63
- if via == "full" { downloadedBytes = size }
89
+ if via == "full" { downloadedBytes = max(0, size - resumedFrom) }
64
90
  let ok = try (size == wantSize && Verify.sha256Hex(file: tmp) == wantSha)
65
- if !ok { try? FileManager.default.removeItem(at: tmp); s.addEvent("verify_failed:\(release)"); throw UpdateError("bundle hash/size mismatch") }
91
+ if !ok { try? FileManager.default.removeItem(at: tmp); try? FileManager.default.removeItem(at: part); s.addEvent("verify_failed:\(release)"); throw UpdateError("bundle hash/size mismatch") }
66
92
  let verifyMs = (Store.now() - v0) * 1000 + verifyPointerMs
67
93
 
68
94
  let dest = s.dir.appendingPathComponent("r\(release).hbc")
@@ -73,19 +99,63 @@ enum Updater {
73
99
  "downloadMs": Int(downloadMs), "verifyMs": Int(verifyMs), "issuedAt": body["issued_at"] as? String ?? ""]
74
100
  }
75
101
 
102
+ /// Partial full-bundle download, named by the release sha256 so a different release never reuses it.
103
+ static func partialURL(_ s: Store, sha: String) -> URL { s.dir.appendingPathComponent("dl-\(sha).part") }
104
+
105
+ /// The release changed: partial files for other hashes can never complete.
106
+ private static func dropOtherPartials(_ s: Store, keep: URL) {
107
+ let files = (try? FileManager.default.contentsOfDirectory(at: s.dir, includingPropertiesForKeys: nil)) ?? []
108
+ for f in files where f.pathExtension == "part" && f.lastPathComponent != keep.lastPathComponent { try? FileManager.default.removeItem(at: f) }
109
+ }
110
+
111
+ /// Download the full bundle into `part`, resuming with `Range: bytes=<n>-` when a partial file exists.
112
+ /// A 200 answer means the server ignored the range: start over. Returns how many bytes were already on disk.
113
+ /// If the connection drops, the partial file stays for the next attempt. The caller still checks the signed hash.
114
+ private static func downloadFull(_ s: Store, url: String, part: URL, wantSize: Int) async throws -> Int {
115
+ let fm = FileManager.default
116
+ func have() -> Int { (try? fm.attributesOfItem(atPath: part.path)[.size] as? Int) ?? 0 }
117
+ var offset = have()
118
+ if offset > wantSize { try? fm.removeItem(at: part); offset = 0 }
119
+ if offset == wantSize { return offset }
120
+ for attempt in 0..<2 {
121
+ var req = try request(url, etag: nil)
122
+ if offset > 0 { req.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") }
123
+ let (stream, resp) = try await URLSession.shared.bytes(for: req)
124
+ let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
125
+ if code == 416 && offset > 0 && attempt == 0 { try? fm.removeItem(at: part); offset = 0; continue }
126
+ try require(code == 200 || (code == 206 && offset > 0), "bundle HTTP \(code)")
127
+ if code == 200 { try? fm.removeItem(at: part); offset = 0 }
128
+ if !fm.fileExists(atPath: part.path) { try require(fm.createFile(atPath: part.path, contents: nil), "cannot create partial file") }
129
+ let h = try FileHandle(forWritingTo: part)
130
+ defer { try? h.close() }
131
+ try h.seekToEnd()
132
+ var buf = Data(); buf.reserveCapacity(64 * 1024)
133
+ for try await b in stream {
134
+ buf.append(b)
135
+ if buf.count >= 64 * 1024 { try h.write(contentsOf: buf); buf.removeAll(keepingCapacity: true) }
136
+ }
137
+ if !buf.isEmpty { try h.write(contentsOf: buf) }
138
+ return offset
139
+ }
140
+ throw UpdateError("bundle download failed")
141
+ }
142
+
76
143
  /// Try the patch from the bundle this device runs. Writes the rebuilt bundle to `tmp` and returns the patch size,
77
144
  /// or throws (nothing left behind) so the caller can fetch the full bundle. The caller still checks the signed hash.
78
145
  private static func patched(_ s: Store, core: [String: Any], wantSha: String, wantSize: Int, to tmp: URL) async throws -> Int {
79
- guard let base = s.currentPath, s.currentSha != nil, FileManager.default.fileExists(atPath: base),
146
+ // Base: the bundle this device runs, or the embedded main.jsbundle before the first update.
147
+ let baseFile = s.currentPath.flatMap { FileManager.default.fileExists(atPath: $0) ? $0 : nil }
148
+ let baseSha: String? = baseFile != nil ? s.currentSha : s.embeddedSha()
149
+ guard let baseSha,
80
150
  let list = core["patches"] as? [[String: Any]],
81
- let entry = list.first(where: { $0["from_sha256"] as? String == s.currentSha }),
151
+ let entry = list.first(where: { $0["from_sha256"] as? String == baseSha }),
82
152
  let purl = entry["url"] as? String, let psha = entry["sha256"] as? String, let psize = (entry["size"] as? NSNumber)?.intValue,
83
153
  psize > 0, psize < wantSize else { throw UpdateError("no usable patch") }
84
154
  do {
85
155
  let (data, resp) = try await URLSession.shared.data(for: request(resolve(s, purl), etag: nil))
86
156
  try require((resp as? HTTPURLResponse)?.statusCode == 200 && data.count == psize && Verify.sha256Hex(data) == psha, "patch download invalid")
87
- let old = try Data(contentsOf: URL(fileURLWithPath: base))
88
- try require(Verify.sha256Hex(old) == s.currentSha, "current bundle changed on disk")
157
+ guard let old = try baseFile.map({ try Data(contentsOf: URL(fileURLWithPath: $0)) }) ?? s.embeddedBundle() else { throw UpdateError("no base bundle") }
158
+ try require(Verify.sha256Hex(old) == baseSha, "base bundle changed on disk")
89
159
  let new = try Patch.apply(old: old, patch: data)
90
160
  try require(new.count == wantSize && Verify.sha256Hex(new) == wantSha, "patched bundle does not match")
91
161
  try new.write(to: tmp)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/runtime",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Shipvo native runtime: a tiny Expo module that verifies (Ed25519 + SHA-256), downloads (bsdiff patches), stages and applies signed over-the-air releases, with crash-loop rollback. Plus the config plugin that pins the trust root in the native shell.",
6
6
  "main": "src/index.ts",
@@ -56,5 +56,11 @@
56
56
  "publishConfig": {
57
57
  "access": "public",
58
58
  "provenance": true
59
+ },
60
+ "devDependencies": {
61
+ "vitest": "^2.1.4"
62
+ },
63
+ "scripts": {
64
+ "test": "vitest run"
59
65
  }
60
66
  }
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ export type CheckResult = {
11
11
  /** Shell 1.0.2+ (Android): "patch" when a bsdiff patch was applied, "full" otherwise. iOS always downloads "full". */
12
12
  via?: "patch" | "full"; bundleBytes?: number; patchError?: string | null;
13
13
  issuedAt?: string; error?: string;
14
+ /** Android only for now: why a "none" result happened when the control plane rejected the shell's bytecode or native modules. */
15
+ reason?: "hermes_bytecode_mismatch" | "native_module_missing" | "native_module_version";
14
16
  };
15
17
  export type HealthyResult = {
16
18
  release: number; coldStartMs: number; sinceLoadMs: number; promoted: boolean;
@@ -39,4 +41,31 @@ const fallback: Native = {
39
41
  markHealthy: () => ({ release: 0, coldStartMs: Date.now() - t0, sinceLoadMs: 0, promoted: false, kind: "cold" }),
40
42
  consumeEvents: () => [], rollback: () => {}, reload: () => {},
41
43
  };
42
- export const Runtime: Native = requireOptionalNativeModule<Native>("ShipvoRuntime") ?? fallback;
44
+ const native = requireOptionalNativeModule<Native & { noteCrash?(): void }>("ShipvoRuntime");
45
+
46
+ // After markHealthy, tell the native side about an uncaught JS error before React destroys the host. Destroying the host
47
+ // pauses the activity, and that pause must not count as "stable". Needs a shell with `noteCrash`; older shells skip it.
48
+ let crashHookInstalled = false;
49
+ function installCrashHook() {
50
+ if (crashHookInstalled || typeof native?.noteCrash !== "function") return;
51
+ const eu = (globalThis as any).ErrorUtils;
52
+ if (!eu?.setGlobalHandler || !eu.getGlobalHandler) return;
53
+ crashHookInstalled = true;
54
+ const prev = eu.getGlobalHandler();
55
+ eu.setGlobalHandler((e: unknown, isFatal?: boolean) => {
56
+ if (isFatal) { try { native.noteCrash!(); } catch {} }
57
+ prev?.(e, isFatal);
58
+ });
59
+ }
60
+
61
+ // Every `Native` method must be listed here, or it is silently dropped from `Runtime`.
62
+ export const Runtime: Native = native
63
+ ? {
64
+ getState: () => native.getState(),
65
+ checkForUpdate: (apply) => native.checkForUpdate(apply),
66
+ markHealthy: () => { const r = native.markHealthy(); installCrashHook(); return r; },
67
+ consumeEvents: () => native.consumeEvents(),
68
+ rollback: () => native.rollback(),
69
+ reload: () => native.reload(),
70
+ }
71
+ : fallback;