@shipvo/runtime 0.1.3 → 0.1.5

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.
Files changed (29) hide show
  1. package/README.md +49 -0
  2. package/android/build.gradle +7 -0
  3. package/android/src/main/java/dev/shipvo/runtime/BootRules.kt +53 -0
  4. package/android/src/main/java/dev/shipvo/runtime/Clock.kt +9 -0
  5. package/android/src/main/java/dev/shipvo/runtime/HermesGuard.kt +42 -0
  6. package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +36 -0
  7. package/android/src/main/java/dev/shipvo/runtime/ResumableDownload.kt +39 -0
  8. package/android/src/main/java/dev/shipvo/runtime/Rotation.kt +134 -0
  9. package/android/src/main/java/dev/shipvo/runtime/ShipvoHostHandler.kt +4 -5
  10. package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +66 -10
  11. package/android/src/main/java/dev/shipvo/runtime/Store.kt +36 -3
  12. package/android/src/test/java/dev/shipvo/runtime/HermesGuardTest.kt +73 -0
  13. package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +45 -0
  14. package/android/src/test/java/dev/shipvo/runtime/ResumableDownloadTest.kt +90 -0
  15. package/android/src/test/java/dev/shipvo/runtime/RotationTest.kt +141 -0
  16. package/android/src/test/java/dev/shipvo/runtime/StabilityTest.kt +76 -0
  17. package/app.plugin.js +75 -2
  18. package/ios/BundleSelector.swift +21 -1
  19. package/ios/HermesGuard.swift +43 -0
  20. package/ios/Rotation.swift +147 -0
  21. package/ios/ShipvoRuntimeModule.swift +27 -0
  22. package/ios/Store.swift +47 -0
  23. package/ios/Updater.swift +88 -14
  24. package/package.json +9 -1
  25. package/skills/shipvo-runtime/SKILL.md +39 -0
  26. package/src/index.ts +102 -1
  27. package/src/index.web.ts +3 -0
  28. package/src/install.ts +74 -0
  29. package/src/runtimeEvents.ts +38 -0
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,44 @@ 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
32
+
33
+ The runtime reports refused and reverted releases for you. `Runtime.markHealthy()` and every `Runtime.checkForUpdate()` drain the device's event queue and send each event to `POST {baseUrl}/t`, using `baseUrl` and `appId` from `expo.extra.shipvo`. A refusal found during a background check is reported in the same session, without a restart.
34
+
35
+ Forwarded events: `crash_loop_revert`, `rolled_back`, `verify_failed`, `download_failed`, `hermes_bytecode_mismatch`, `native_module_missing`, `native_module_version`. `verify_failed`, `download_failed` and `hermes_bytecode_mismatch` are reported under the failed release id. The native-module reasons arrive as `pointer_rejected:<reason>` and are reported under the reason name. Sending is best effort: a failed send never throws and never blocks.
36
+
37
+ ```ts
38
+ import { reportRuntimeEvents, onRuntimeEvents } from "@shipvo/runtime";
39
+ reportRuntimeEvents(); // drain and send now (markHealthy and checkForUpdate already do)
40
+ onRuntimeEvents((events) => console.log(events)); // see the drained strings, for a log
41
+ ```
42
+
43
+ To turn the automatic report off, pass `reportEvents: false` to the config plugin (it lands in `expo.extra.shipvo`), and call `reportRuntimeEvents()` yourself. `forwardRuntimeEvents(events, send)` and `FORWARDED_EVENTS` are also exported for custom senders. If you call `Runtime.consumeEvents()` yourself, you take events away from the report.
44
+
45
+ The runtime does not emit `download_failed` yet. The control plane accepts it.
46
+
47
+ ## Signed telemetry
48
+
49
+ Call `enableSignedTelemetry` once at startup. The install registers once, keeps a secret key in your storage, and then signs each report. If registration fails, reports go out unsigned and the next launch tries again.
50
+
51
+ ```ts
52
+ import { enableSignedTelemetry } from "@shipvo/runtime";
53
+ import AsyncStorage from "@react-native-async-storage/async-storage";
54
+
55
+ await enableSignedTelemetry({
56
+ storage: AsyncStorage,
57
+ registerUrl: "https://cp.example.com/t/register",
58
+ // crypto: { randomBytes, hmacSha256 } // React Native has no crypto.subtle, so pass your own
59
+ });
60
+ ```
61
+
62
+ The sent body is the unsigned JSON with its closing `}` replaced by `,"sig":"<hex>"}`. The HMAC-SHA256 covers the unsigned JSON, closing `}` included. A second call replaces the first identity.
63
+
64
+ ## Crash after `markHealthy`
65
+
66
+ `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`.
67
+
68
+ 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.
69
+
70
+ 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.
@@ -13,9 +13,16 @@ android {
13
13
  versionName "0.0.1"
14
14
  consumerProguardFiles "proguard-rules.pro"
15
15
  }
16
+ // The shared key rotation vectors (also used by the CLI and iOS tests) are read as test resources.
17
+ sourceSets {
18
+ test.resources.srcDirs += ["../test-vectors"]
19
+ }
16
20
  }
17
21
 
18
22
  dependencies {
23
+ testImplementation 'junit:junit:4.13.2'
24
+ // The Android SDK's org.json is a stub in JVM unit tests.
25
+ testImplementation 'org.json:json:20231013'
19
26
  implementation 'com.facebook.react:react-android'
20
27
  // Ed25519 verification on Android 7+ (no platform Ed25519 before API 33). Pure Java, ~60 KB.
21
28
  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,9 @@
1
+ package dev.shipvo.runtime
2
+
3
+ /** Source of wall-clock time for rotation checks. Rotation code never reads the system time directly. */
4
+ internal fun interface Clock {
5
+ /** Milliseconds since the Unix epoch. */
6
+ fun now(): Long
7
+
8
+ object Wall : Clock { override fun now() = System.currentTimeMillis() }
9
+ }
@@ -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,36 @@
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"; `hbe` is the engine build id "250829098.0.10"; `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?, hbe: String? = null): 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
+ hbe?.trim()?.takeIf { it.isNotEmpty() }?.let { q += "hbe=" + enc(it) }
17
+ nm?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.takeIf { it.isNotEmpty() }
18
+ ?.let { q += "nm=" + it.joinToString(",") { v -> enc(v) } }
19
+ val path = "$base/p/$app/$shellMajor/$band.json"
20
+ return if (q.isEmpty()) path else path + "?" + q.joinToString("&")
21
+ }
22
+
23
+ /** The typed reason in a 404 body, or null for any other answer. */
24
+ fun mismatchReason(body: String?): String? {
25
+ val m = Regex("\"error\"\\s*:\\s*\"([a-z_]+)\"").find(body ?: return null) ?: return null
26
+ return m.groupValues[1].takeIf { it in MISMATCH_REASONS }
27
+ }
28
+
29
+ /**
30
+ * The event to queue for a rejected pointer, or null when the same reason is already queued.
31
+ * A shell that stays mismatched would otherwise add one event per check and push out
32
+ * `crash_loop_revert`, `rolled_back` and `verify_failed` (the queue keeps 50).
33
+ */
34
+ fun rejectionEvent(queued: List<String>, reason: String): String? =
35
+ "pointer_rejected:$reason".takeIf { it !in queued }
36
+ }
@@ -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
+ }
@@ -0,0 +1,134 @@
1
+ package dev.shipvo.runtime
2
+
3
+ import android.util.Base64
4
+ import org.json.JSONArray
5
+ import org.json.JSONObject
6
+ import java.text.SimpleDateFormat
7
+ import java.util.Locale
8
+ import java.util.TimeZone
9
+
10
+ /** What Rotation needs from storage. [commit] writes all three values in one atomic step. */
11
+ internal interface RotationState {
12
+ val storedReleaseKey: String?
13
+ val storedSequence: Long
14
+ val storedPinnedKey: String?
15
+ val storedPending: String?
16
+ fun commit(releaseKey: String?, sequence: Long, pinnedKey: String?, pending: String?)
17
+ }
18
+
19
+ /**
20
+ * Root-signed release key rotation (docs/design/key-rotation.md, sections 3 to 9).
21
+ * A statement is an envelope {body, signature}; the signature is by the pinned root key over the exact body string.
22
+ */
23
+ internal class Rotation(
24
+ private val state: RotationState,
25
+ private val appId: String,
26
+ private val pinnedReleaseKey: String,
27
+ private val rootKey: String,
28
+ private val minSequence: Long,
29
+ private val clock: Clock = Clock.Wall,
30
+ private val verify: (ByteArray, String, String) -> Boolean = Verify::ed25519,
31
+ private val decode: (String) -> ByteArray = { Base64.decode(it, Base64.DEFAULT) },
32
+ ) {
33
+ class Result(val ok: Boolean, val reason: String? = null)
34
+
35
+ private class Stmt(val body: String, val signature: String, val sequence: Long, val newKey: String, val validFrom: Long)
36
+
37
+ /** Signature checks made so far (tests count them). */
38
+ var signatureChecks = 0
39
+ private set
40
+
41
+ /** The release key pointers are verified against: the stored one, or the pinned one when nothing usable is stored. */
42
+ fun currentReleaseKey(): String {
43
+ val k = state.storedReleaseKey
44
+ return if (k != null && state.storedPinnedKey == pinnedReleaseKey && keyOk(k)) k else pinnedReleaseKey
45
+ }
46
+
47
+ fun effectiveSequence(): Long = maxOf(state.storedSequence, minSequence)
48
+
49
+ /** Applies due pending statements. Run on launch and before every pointer check. */
50
+ fun applyDue() { handle(null) }
51
+
52
+ /**
53
+ * Handles a delivered `rotations` array (null for none). Returns one result per envelope; empty when the array
54
+ * is refused whole. Verdicts are against the state before this delivery.
55
+ */
56
+ fun handle(rotations: JSONArray?): List<Result> {
57
+ if (rotations != null && rotations.length() > MAX) return emptyList()
58
+ val effective = effectiveSequence()
59
+ val known = HashMap<Long, Stmt>()
60
+ for (s in loadPending()) if (s.sequence > effective) known.putIfAbsent(s.sequence, s)
61
+ val results = ArrayList<Result>()
62
+ if (rotations != null) for (i in 0 until rotations.length()) {
63
+ val env = rotations.optJSONObject(i)
64
+ val body = env?.opt("body") as? String
65
+ val sig = env?.opt("signature") as? String
66
+ if (body == null || sig == null || body.toByteArray(Charsets.UTF_8).size > MAX_BODY) { results.add(Result(false, "malformed")); continue }
67
+ val r = check(body, sig, effective)
68
+ if (r is Stmt) {
69
+ // Same sequence twice: the first accepted stays.
70
+ if (known.containsKey(r.sequence)) results.add(Result(false, "replay")) else { known[r.sequence] = r; results.add(Result(true)) }
71
+ } else results.add(r as Result)
72
+ }
73
+ commitState(known.values.sortedBy { it.sequence }, effective)
74
+ return results
75
+ }
76
+
77
+ /** Section 3 rules 1 to 5 in order. Returns a [Stmt] when valid, else a [Result]. */
78
+ private fun check(body: String, sig: String, effective: Long, reload: Boolean = false): Any {
79
+ signatureChecks++
80
+ if (!verify(body.toByteArray(Charsets.UTF_8), sig, rootKey)) return Result(false, "bad_signature")
81
+ val j = try { JSONObject(body) } catch (_: Exception) { return Result(false, "malformed") }
82
+ if (j.opt("type") != "key-rotation") return Result(false, "bad_type")
83
+ if (j.opt("appId") != appId) return Result(false, "wrong_app")
84
+ val seq = j.opt("sequence").let { if (it is Int) it.toLong() else it as? Long } ?: return Result(false, "malformed")
85
+ if (seq < 1) return Result(false, "malformed")
86
+ if (seq <= effective) return Result(false, "replay")
87
+ val validFrom = (j.opt("validFrom") as? String)?.let { parseTime(it) } ?: return Result(false, "malformed")
88
+ if (!reload && validFrom > clock.now() + NINETY_DAYS_MS) return Result(false, "too_far")
89
+ val newKey = j.opt("newKey") as? String
90
+ if (newKey == null || !keyOk(newKey)) return Result(false, "bad_key")
91
+ return Stmt(body, sig, seq, newKey, validFrom)
92
+ }
93
+
94
+ /** Section 7: apply the due statement with the highest sequence, keep the later ones, at most 8. */
95
+ private fun commitState(all: List<Stmt>, effective: Long) {
96
+ val now = clock.now()
97
+ val due = all.filter { it.validFrom <= now }.maxByOrNull { it.sequence }
98
+ val keep = (if (due == null) all else all.filter { it.sequence > due.sequence }).take(MAX)
99
+ val pending = if (keep.isEmpty()) null else JSONArray(keep.map { JSONObject().put("body", it.body).put("signature", it.signature) }).toString()
100
+ val changed = due != null || pending != state.storedPending || state.storedPinnedKey != pinnedReleaseKey
101
+ if (!changed) return
102
+ // A different pin than the one that wrote the state: drop the stored key, keep the sequence (design section 4).
103
+ val key = due?.newKey ?: if (state.storedPinnedKey == pinnedReleaseKey) state.storedReleaseKey else null
104
+ state.commit(key, maxOf(due?.sequence ?: 0L, state.storedSequence), pinnedReleaseKey, pending)
105
+ }
106
+
107
+ /** A corrupt or unverifiable pending list is cleared by the next commit; key and sequence stay. */
108
+ private fun loadPending(): List<Stmt> {
109
+ val raw = state.storedPending ?: return emptyList()
110
+ return try {
111
+ val arr = JSONArray(raw)
112
+ if (arr.length() > MAX) return emptyList()
113
+ val out = ArrayList<Stmt>()
114
+ for (i in 0 until arr.length()) {
115
+ val o = arr.getJSONObject(i)
116
+ val r = check(o.getString("body"), o.getString("signature"), 0, reload = true)
117
+ if (r is Stmt) out.add(r)
118
+ }
119
+ out
120
+ } catch (_: Exception) { emptyList() }
121
+ }
122
+
123
+ private fun keyOk(k: String): Boolean = try { decode(k).size == 32 } catch (_: Exception) { false }
124
+
125
+ private fun parseTime(s: String): Long? = try {
126
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC"); isLenient = false }.parse(s)?.time
127
+ } catch (_: Exception) { null }
128
+
129
+ companion object {
130
+ const val MAX = 8
131
+ const val MAX_BODY = 1024
132
+ const val NINETY_DAYS_MS = 90L * 24 * 60 * 60 * 1000
133
+ }
134
+ }
@@ -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
  }
@@ -92,17 +128,29 @@ class ShipvoRuntimeModule : Module() {
92
128
  private fun check(): Map<String, Any?> {
93
129
  val s = Store(ctx)
94
130
  require(s.baseUrl.isNotEmpty() && s.publicKey.isNotEmpty() && s.appId.isNotEmpty()) { "runtime not configured" }
131
+ val rotation = s.rotation()
132
+ rotation.applyDue() // due root-signed key rotations apply before any pointer is checked
95
133
  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)
134
+ val conn = http(PointerQuery.url(s.baseUrl, s.appId, s.shellMajor, s.band, s.hermesBytecodeVersions, s.nativeModules, s.hermesEngine), if (known > 0) "\"$known\"" else null)
97
135
  val code = conn.responseCode
98
- if (code == 304 || code == 404) { conn.disconnect(); return mapOf("status" to "none") }
136
+ if (code == 404) {
137
+ // The shell cannot run the newest release. Keep the current one and say why.
138
+ val reason = PointerQuery.mismatchReason(try { conn.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null })
139
+ conn.disconnect()
140
+ if (reason == null) return mapOf("status" to "none")
141
+ PointerQuery.rejectionEvent(s.pendingEvents(), reason)?.let { s.addEvent(it) }
142
+ return mapOf("status" to "none", "reason" to reason)
143
+ }
144
+ if (code == 304) { conn.disconnect(); return mapOf("status" to "none") }
99
145
  require(code == 200) { "pointer HTTP $code" }
100
146
  val envelope = JSONObject(conn.inputStream.bufferedReader().use { it.readText() })
101
147
  conn.disconnect()
102
148
 
149
+ // Root-signed rotation statements ride along with the pointer; handle them before the pointer is verified.
150
+ envelope.optJSONArray("rotations")?.let { rotation.handle(it) }
103
151
  val t0 = SystemClock.elapsedRealtime()
104
152
  val bodyStr = envelope.getString("body")
105
- require(Verify.ed25519(bodyStr.toByteArray(Charsets.UTF_8), envelope.getString("signature"), s.publicKey)) { "pointer signature invalid" }
153
+ require(Verify.ed25519(bodyStr.toByteArray(Charsets.UTF_8), envelope.getString("signature"), rotation.currentReleaseKey())) { "pointer signature invalid" }
106
154
  val body = JSONObject(bodyStr)
107
155
  require(body.getString("app_id") == s.appId && body.getInt("shell_major") == s.shellMajor) { "pointer for another app/shell" }
108
156
  val release = body.getInt("release")
@@ -110,10 +158,18 @@ class ShipvoRuntimeModule : Module() {
110
158
  val shellVersion = ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0.0.0"
111
159
  require(!Verify.semverLess(shellVersion, body.getString("min_shell_version"))) { "shell too old for release $release" }
112
160
  val verifyPointerMs = SystemClock.elapsedRealtime() - t0
161
+ // Beside the signature checks, not inside them: refuse a bundle built for another Hermes bytecode version.
162
+ HermesGuard.decide(release, body.opt("hermes_bytecode_version")) { try { ctx.assets.open("index.android.bundle") } catch (_: java.io.IOException) { null } }?.let { ev ->
163
+ s.addEvent(ev); s.block(release)
164
+ return mapOf("status" to "none", "reason" to HermesGuard.REASON)
165
+ }
113
166
 
114
167
  val core = body.getJSONObject("core")
115
168
  val wantSha = core.getString("sha256"); val wantSize = core.getLong("size")
116
169
  val tmp = File(s.dir, "dl-$release.tmp")
170
+ val part = ResumableDownload.partialFile(s.dir, wantSha)
171
+ // The release changed: partial files for other hashes can never complete.
172
+ s.dir.listFiles()?.forEach { if (it.extension == "part" && it.name != part.name) it.delete() }
117
173
  val d0 = SystemClock.elapsedRealtime()
118
174
  var via = "full"; var bytesDownloaded = 0L; var patchError: String? = null
119
175
 
@@ -142,22 +198,22 @@ class ShipvoRuntimeModule : Module() {
142
198
 
143
199
  // 2. Fall back to the full bundle.
144
200
  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()
201
+ val resumedFrom = ResumableDownload.fetch(resolve(s, core.getString("url")), part, wantSize)
202
+ tmp.delete()
203
+ require(part.renameTo(tmp)) { "could not move downloaded bundle" }
204
+ bytesDownloaded = tmp.length() - resumedFrom
150
205
  }
151
206
  val downloadMs = SystemClock.elapsedRealtime() - d0
152
207
 
153
208
  val v0 = SystemClock.elapsedRealtime()
154
209
  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") }
210
+ if (!ok) { tmp.delete(); part.delete(); s.addEvent("verify_failed:$release"); throw IllegalStateException("bundle hash/size mismatch") }
156
211
  val verifyMs = SystemClock.elapsedRealtime() - v0 + verifyPointerMs
157
212
 
158
213
  val dest = File(s.dir, "r$release.hbc")
159
214
  require(tmp.renameTo(dest)) { "could not stage bundle" }
160
215
  synchronized(ShipvoHostHandler.LOCK) {
216
+ if (!BootRules.mayStage(release, s.currentRelease, s.pendingRelease, s.blockedRelease)) return mapOf("status" to "none")
161
217
  s.pendingPath?.let { if (it != dest.path) File(it).delete() }
162
218
  s.pendingPath = dest.path; s.pendingRelease = release; s.trialBoots = 0; s.pendingSha = wantSha
163
219
  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, RotationState {
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,31 @@ 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
+
69
+ override val storedReleaseKey: String? get() = p.getString("rotation.releaseKey", null)
70
+ override val storedSequence: Long get() = try { p.getLong("rotation.sequence", 0) } catch (_: ClassCastException) { 0 }
71
+ override val storedPinnedKey: String? get() = p.getString("rotation.pinnedKey", null)
72
+ override val storedPending: String? get() = p.getString("rotation.pending", null)
73
+ /** One commit() writes key, sequence, pinned key and pending list together. */
74
+ override fun commit(releaseKey: String?, sequence: Long, pinnedKey: String?, pending: String?) {
75
+ p.edit().apply {
76
+ if (releaseKey != null) putString("rotation.releaseKey", releaseKey) else remove("rotation.releaseKey")
77
+ putLong("rotation.sequence", sequence)
78
+ if (pinnedKey != null) putString("rotation.pinnedKey", pinnedKey) else remove("rotation.pinnedKey")
79
+ if (pending != null) putString("rotation.pending", pending) else remove("rotation.pending")
80
+ }.commit()
81
+ }
82
+ /** Key rotation for this shell: root-signed statements can replace the release key (docs/design/key-rotation.md). */
83
+ fun rotation(clock: Clock = Clock.Wall) = Rotation(this, appId, publicKey, rootKey, minSequence, clock)
68
84
 
69
85
  fun addEvent(e: String) {
70
86
  val list = p.getString("events", "")!!.split('\n').filter { it.isNotBlank() }.takeLast(49) + e
71
87
  p.edit().putString("events", list.joinToString("\n")).commit()
72
88
  }
89
+ /** Events queued and not yet consumed, oldest first. */
90
+ fun pendingEvents(): List<String> = p.getString("events", "")!!.split('\n').filter { it.isNotBlank() }
73
91
  fun consumeEvents(): List<String> {
74
92
  val list = p.getString("events", "")!!.split('\n').filter { it.isNotBlank() }
75
93
  p.edit().remove("events").commit(); return list
@@ -79,9 +97,24 @@ internal class Store(private val ctx: Context) {
79
97
  val ai = ctx.packageManager.getApplicationInfo(ctx.packageName, PackageManager.GET_META_DATA)
80
98
  ai.metaData?.getString(key)
81
99
  } catch (_: Exception) { null }
100
+ /** Like [meta], but also accepts numbers: a manifest can store a single version such as 96 as an integer. */
101
+ private fun metaAny(key: String): String? = try {
102
+ val ai = ctx.packageManager.getApplicationInfo(ctx.packageName, PackageManager.GET_META_DATA)
103
+ ai.metaData?.get(key)?.toString()
104
+ } catch (_: Exception) { null }
82
105
  val baseUrl: String get() = (meta("dev.shipvo.BASE_URL") ?: "").trimEnd('/')
83
106
  val publicKey: String get() = meta("dev.shipvo.PUBLIC_KEY") ?: ""
107
+ /** Root public key that signs rotation statements. Empty when the shell does not declare one: rotation is then off. */
108
+ val rootKey: String get() = meta("dev.shipvo.ROOT_KEY") ?: ""
109
+ /** Highest rotation sequence that existed when this shell was built. */
110
+ val minSequence: Long get() = metaAny("dev.shipvo.MIN_SEQUENCE")?.toLongOrNull() ?: 0
84
111
  val appId: String get() = meta("dev.shipvo.APP_ID") ?: ""
112
+ /** Hermes bytecode versions this shell runs, e.g. "96,98". Null when the shell does not declare them. */
113
+ val hermesBytecodeVersions: String? get() = metaAny("dev.shipvo.HERMES_BYTECODE_VERSIONS")?.takeIf { it.isNotBlank() }
114
+ /** Hermes engine build id this shell runs, e.g. "250829098.0.10". Null when the shell does not declare it. */
115
+ val hermesEngine: String? get() = metaAny("dev.shipvo.HERMES_ENGINE")?.takeIf { it.isNotBlank() }
116
+ /** Native modules in this shell, e.g. "react-native-mmkv@3.1.0,@scope/pkg@1.0.0". Null when not declared. */
117
+ val nativeModules: String? get() = metaAny("dev.shipvo.NATIVE_MODULES")?.takeIf { it.isNotBlank() }
85
118
  val shellMajor: Int get() = (meta("dev.shipvo.SHELL_MAJOR") ?: "1").toIntOrNull() ?: 1
86
119
 
87
120
  companion object {