@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.
- package/README.md +49 -0
- package/android/build.gradle +7 -0
- package/android/src/main/java/dev/shipvo/runtime/BootRules.kt +53 -0
- package/android/src/main/java/dev/shipvo/runtime/Clock.kt +9 -0
- package/android/src/main/java/dev/shipvo/runtime/HermesGuard.kt +42 -0
- package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +36 -0
- package/android/src/main/java/dev/shipvo/runtime/ResumableDownload.kt +39 -0
- package/android/src/main/java/dev/shipvo/runtime/Rotation.kt +134 -0
- package/android/src/main/java/dev/shipvo/runtime/ShipvoHostHandler.kt +4 -5
- package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +66 -10
- package/android/src/main/java/dev/shipvo/runtime/Store.kt +36 -3
- package/android/src/test/java/dev/shipvo/runtime/HermesGuardTest.kt +73 -0
- package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +45 -0
- package/android/src/test/java/dev/shipvo/runtime/ResumableDownloadTest.kt +90 -0
- package/android/src/test/java/dev/shipvo/runtime/RotationTest.kt +141 -0
- package/android/src/test/java/dev/shipvo/runtime/StabilityTest.kt +76 -0
- package/app.plugin.js +75 -2
- package/ios/BundleSelector.swift +21 -1
- package/ios/HermesGuard.swift +43 -0
- package/ios/Rotation.swift +147 -0
- package/ios/ShipvoRuntimeModule.swift +27 -0
- package/ios/Store.swift +47 -0
- package/ios/Updater.swift +88 -14
- package/package.json +9 -1
- package/skills/shipvo-runtime/SKILL.md +39 -0
- package/src/index.ts +102 -1
- package/src/index.web.ts +3 -0
- package/src/install.ts +74 -0
- 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.
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 ==
|
|
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"),
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 {
|