@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 +26 -0
- package/android/build.gradle +1 -0
- package/android/src/main/java/dev/shipvo/runtime/BootRules.kt +53 -0
- package/android/src/main/java/dev/shipvo/runtime/HermesGuard.kt +42 -0
- package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +35 -0
- package/android/src/main/java/dev/shipvo/runtime/ResumableDownload.kt +39 -0
- package/android/src/main/java/dev/shipvo/runtime/ShipvoHostHandler.kt +4 -5
- package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +61 -9
- package/android/src/main/java/dev/shipvo/runtime/Store.kt +14 -3
- package/android/src/test/java/dev/shipvo/runtime/HermesGuardTest.kt +73 -0
- package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +42 -0
- package/android/src/test/java/dev/shipvo/runtime/ResumableDownloadTest.kt +90 -0
- package/android/src/test/java/dev/shipvo/runtime/StabilityTest.kt +76 -0
- package/app.plugin.js +38 -0
- package/ios/BundleSelector.swift +21 -1
- package/ios/HermesGuard.swift +43 -0
- package/ios/ShipvoRuntimeModule.swift +27 -0
- package/ios/Store.swift +26 -0
- package/ios/Updater.swift +83 -13
- package/package.json +7 -1
- package/src/index.ts +30 -1
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.
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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(
|
|
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 ==
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
};
|
package/ios/BundleSelector.swift
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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 ==
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 ==
|
|
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) ==
|
|
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
|
+
"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
|
-
|
|
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;
|