@shipvo/runtime 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +49 -0
  2. package/android/build.gradle +7 -0
  3. package/android/src/main/java/dev/shipvo/runtime/BootRules.kt +53 -0
  4. package/android/src/main/java/dev/shipvo/runtime/Clock.kt +9 -0
  5. package/android/src/main/java/dev/shipvo/runtime/HermesGuard.kt +42 -0
  6. package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +36 -0
  7. package/android/src/main/java/dev/shipvo/runtime/ResumableDownload.kt +39 -0
  8. package/android/src/main/java/dev/shipvo/runtime/Rotation.kt +134 -0
  9. package/android/src/main/java/dev/shipvo/runtime/ShipvoHostHandler.kt +4 -5
  10. package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +66 -10
  11. package/android/src/main/java/dev/shipvo/runtime/Store.kt +36 -3
  12. package/android/src/test/java/dev/shipvo/runtime/HermesGuardTest.kt +73 -0
  13. package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +45 -0
  14. package/android/src/test/java/dev/shipvo/runtime/ResumableDownloadTest.kt +90 -0
  15. package/android/src/test/java/dev/shipvo/runtime/RotationTest.kt +141 -0
  16. package/android/src/test/java/dev/shipvo/runtime/StabilityTest.kt +76 -0
  17. package/app.plugin.js +75 -2
  18. package/ios/BundleSelector.swift +21 -1
  19. package/ios/HermesGuard.swift +43 -0
  20. package/ios/Rotation.swift +147 -0
  21. package/ios/ShipvoRuntimeModule.swift +27 -0
  22. package/ios/Store.swift +47 -0
  23. package/ios/Updater.swift +88 -14
  24. package/package.json +9 -1
  25. package/skills/shipvo-runtime/SKILL.md +39 -0
  26. package/src/index.ts +102 -1
  27. package/src/index.web.ts +3 -0
  28. package/src/install.ts +74 -0
  29. package/src/runtimeEvents.ts +38 -0
@@ -0,0 +1,147 @@
1
+ import Foundation
2
+
3
+ /// Time source for rotation. No rotation code reads the system time directly (design section 9).
4
+ protocol Clock { func now() -> Date }
5
+ struct SystemClock: Clock { func now() -> Date { Date() } }
6
+
7
+ /// Signing key rotation (docs/design/key-rotation.md, sections 3 to 9).
8
+ /// A statement is an envelope `{body, signature}` signed by the pinned root key. It replaces the current release key.
9
+ /// The pointer check in `Updater` uses `Rotation.currentKey`; nothing else about that check changes.
10
+ enum Rotation {
11
+ static let maxEntries = 8
12
+ static let maxBodyBytes = 1024
13
+ static let maxAheadDays = 90.0
14
+
15
+ struct Statement { let body: String, signature: String, sequence: Int, newKey: String, validFrom: Date }
16
+ struct Result: Equatable { let ok: Bool; let reason: String? }
17
+ struct Outcome { var rejectedWhole = false; var results: [Result] = [] }
18
+ struct State { var releaseKey: String, sequence: Int, pinnedKey: String, pending: [Statement] }
19
+
20
+ /// Test hook: how many root signature checks ran (design test 7).
21
+ static var signatureChecks = 0
22
+
23
+ // MARK: reading
24
+
25
+ /// The key that pointer signatures are checked against now: the pinned release key, or the one a root statement named.
26
+ /// Statements that came due since the last call are applied first.
27
+ static func currentKey(_ s: Store) -> String {
28
+ guard enabled(s) else { return s.publicKey }
29
+ return sync(s).releaseKey
30
+ }
31
+
32
+ static func enabled(_ s: Store) -> Bool { !s.rootKey.isEmpty }
33
+
34
+ /// Load the stored state and apply every pending statement that is due. Run on every launch and every fetch.
35
+ @discardableResult
36
+ static func sync(_ s: Store) -> State {
37
+ let before = load(s)
38
+ var st = before
39
+ settle(&st, incoming: [], now: s.clock.now())
40
+ if !same(st, before) { save(s, st) }
41
+ return st
42
+ }
43
+
44
+ // MARK: delivery
45
+
46
+ /// Handle the `rotations` array of a pointer response, before the pointer is looked at.
47
+ /// More than 8 entries are refused whole before any signature check.
48
+ @discardableResult
49
+ static func process(_ s: Store, rotations: Any?) -> Outcome {
50
+ var out = Outcome()
51
+ guard enabled(s), let list = rotations as? [Any] else { return out }
52
+ if list.count > maxEntries { out.rejectedWhole = true; return out }
53
+ let now = s.clock.now()
54
+ var st = load(s)
55
+ let effective = max(st.sequence, s.minSequence)
56
+ var incoming: [Statement] = []
57
+ for item in list {
58
+ let r = check(item, root: s.rootKey, appId: s.appId, ctx: (effective, now))
59
+ switch r {
60
+ case .failure(let f): out.results.append(Result(ok: false, reason: f.reason))
61
+ case .success(let stmt):
62
+ // Rule 4: the same sequence with other bytes does not replace the first one accepted.
63
+ if st.pending.contains(where: { $0.sequence == stmt.sequence }) || incoming.contains(where: { $0.sequence == stmt.sequence }) {
64
+ out.results.append(Result(ok: false, reason: "replay"))
65
+ } else { incoming.append(stmt); out.results.append(Result(ok: true, reason: nil)) }
66
+ }
67
+ }
68
+ let before = st
69
+ settle(&st, incoming: incoming, now: now)
70
+ if !same(st, before) { save(s, st) }
71
+ return out
72
+ }
73
+
74
+ // MARK: checks
75
+
76
+ struct Failure: Error { let reason: String }
77
+
78
+ /// Section 8 order: envelope shape, root signature, then the field rules of section 3.
79
+ /// `ctx` nil skips the replay and 90-day rules (used for statements already accepted and stored).
80
+ static func check(_ item: Any, root: String, appId: String, ctx: (effective: Int, now: Date)?) -> Swift.Result<Statement, Failure> {
81
+ guard let e = item as? [String: Any], let body = e["body"] as? String, let sig = e["signature"] as? String,
82
+ body.utf8.count <= maxBodyBytes else { return .failure(Failure(reason: "bad_envelope")) }
83
+ signatureChecks += 1
84
+ guard Verify.ed25519(Data(body.utf8), signatureB64: sig, publicKeyB64: root) else { return .failure(Failure(reason: "bad_signature")) }
85
+ guard let b = (try? JSONSerialization.jsonObject(with: Data(body.utf8))) as? [String: Any] else { return .failure(Failure(reason: "bad_body")) }
86
+ guard b["type"] as? String == "key-rotation" else { return .failure(Failure(reason: "wrong_type")) }
87
+ guard b["appId"] as? String == appId else { return .failure(Failure(reason: "wrong_app")) }
88
+ guard let seq = b["sequence"] as? Int, seq >= 1, seq <= 9_007_199_254_740_991 else { return .failure(Failure(reason: "bad_sequence")) }
89
+ if let ctx, seq <= ctx.effective { return .failure(Failure(reason: "replay")) }
90
+ guard let vf = (b["validFrom"] as? String).flatMap(parseDate) else { return .failure(Failure(reason: "bad_body")) }
91
+ if let ctx, vf.timeIntervalSince(ctx.now) > maxAheadDays * 86_400 { return .failure(Failure(reason: "too_far")) }
92
+ guard let key = b["newKey"] as? String, Data(base64Encoded: key)?.count == 32 else { return .failure(Failure(reason: "bad_key")) }
93
+ return .success(Statement(body: body, signature: sig, sequence: seq, newKey: key, validFrom: vf))
94
+ }
95
+
96
+ static func parseDate(_ s: String) -> Date? {
97
+ let f = ISO8601DateFormatter()
98
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
99
+ if let d = f.date(from: s) { return d }
100
+ f.formatOptions = [.withInternetDateTime]
101
+ return f.date(from: s)
102
+ }
103
+
104
+ // MARK: section 7
105
+
106
+ /// Apply the due statement with the highest sequence, then keep the later ones (at most 8, lowest sequences first).
107
+ private static func settle(_ st: inout State, incoming: [Statement], now: Date) {
108
+ let known = (st.pending + incoming).sorted { $0.sequence < $1.sequence }
109
+ if let top = known.last(where: { $0.validFrom <= now }), top.sequence > st.sequence {
110
+ st.releaseKey = top.newKey; st.sequence = top.sequence
111
+ }
112
+ st.pending = Array(known.filter { $0.sequence > st.sequence }.prefix(maxEntries))
113
+ }
114
+
115
+ // MARK: storage (one atomic UserDefaults write of a single dictionary)
116
+
117
+ private static func same(_ a: State, _ b: State) -> Bool {
118
+ a.releaseKey == b.releaseKey && a.sequence == b.sequence && a.pinnedKey == b.pinnedKey && a.pending.map { $0.body + $0.signature } == b.pending.map { $0.body + $0.signature }
119
+ }
120
+
121
+ private static func load(_ s: Store) -> State {
122
+ let pin = s.publicKey
123
+ let fresh = State(releaseKey: pin, sequence: 0, pinnedKey: pin, pending: [])
124
+ guard let d = s.rotationRecord else { return fresh }
125
+ // Missing or unreadable values fall back to the pinned key, never to "no check".
126
+ guard let seq = d["rotation.sequence"] as? Int, seq >= 0 else { return fresh }
127
+ var st = fresh; st.sequence = seq
128
+ // A new store build pinning another release key drops the stored key and keeps the sequence.
129
+ if d["rotation.pinnedKey"] as? String == pin, let k = d["rotation.releaseKey"] as? String, Data(base64Encoded: k)?.count == 32 { st.releaseKey = k }
130
+ if let raw = d["rotation.pending"] {
131
+ var list: [Statement] = []
132
+ var good = (raw as? [Any]) != nil
133
+ for item in (raw as? [Any]) ?? [] {
134
+ if case .success(let stmt) = check(item, root: s.rootKey, appId: s.appId, ctx: nil), stmt.sequence > seq { list.append(stmt) } else { good = false; break }
135
+ }
136
+ st.pending = good && list.count <= maxEntries ? list : [] // a corrupt list is cleared, key and sequence are kept
137
+ }
138
+ return st
139
+ }
140
+
141
+ private static func save(_ s: Store, _ st: State) {
142
+ s.rotationRecord = [
143
+ "rotation.releaseKey": st.releaseKey, "rotation.sequence": st.sequence, "rotation.pinnedKey": st.pinnedKey,
144
+ "rotation.pending": st.pending.map { ["body": $0.body, "signature": $0.signature] },
145
+ ]
146
+ }
147
+ }
@@ -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,8 @@ 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] = [], hermesEngine: String? = nil, nativeModules: [String: String] = [:]
10
+ var rootKey: String = "", minSequence: Int = 0, clock: Clock? = nil
9
11
  }
10
12
 
11
13
  private let d: UserDefaults
@@ -44,6 +46,19 @@ final class Store {
44
46
  var blockedRelease: Int { get { d.integer(forKey: "blocked_release") } set { d.set(newValue, forKey: "blocked_release") } }
45
47
  /// Shell binary the stored OTA bundles were downloaded under.
46
48
  var binaryVersion: String? { get { d.string(forKey: "binary_version") } set { d.set(newValue, forKey: "binary_version") } }
49
+ var appBundle: Bundle { bundle }
50
+ /// The `main.jsbundle` inside the app: the first release, and the patch base before any download.
51
+ func embeddedBundle() -> Data? {
52
+ guard let url = bundle.url(forResource: "main", withExtension: "jsbundle") else { return nil }
53
+ return try? Data(contentsOf: url)
54
+ }
55
+ /// Its sha256, cached per installed binary so the file is hashed once.
56
+ func embeddedSha() -> String? {
57
+ let key = "embedded_sha:" + installedBinaryVersion
58
+ if let v = d.string(forKey: key) { return v }
59
+ guard let data = embeddedBundle() else { return nil }
60
+ let sha = Verify.sha256Hex(data); d.set(sha, forKey: key); return sha
61
+ }
47
62
  func block(_ release: Int) { if release > blockedRelease { blockedRelease = release } }
48
63
 
49
64
  var trialBoots: Int { get { d.integer(forKey: "trial_boots") } set { d.set(newValue, forKey: "trial_boots") } }
@@ -84,7 +99,39 @@ final class Store {
84
99
  private func plist(_ key: String) -> String? { bundle.object(forInfoDictionaryKey: key) as? String }
85
100
  var baseUrl: String { var s = o?.baseUrl ?? plist("ShipvoBaseURL") ?? ""; while s.hasSuffix("/") { s.removeLast() }; return s }
86
101
  var publicKey: String { o?.publicKey ?? plist("ShipvoPublicKey") ?? "" }
102
+ /// Pinned root key (Info.plist `ShipvoRootKey`). Empty means key rotation is off for this shell.
103
+ var rootKey: String { o?.rootKey ?? plist("ShipvoRootKey") ?? "" }
104
+ /// Highest rotation statement sequence that existed when this build was made (Info.plist `ShipvoMinSequence`, default 0).
105
+ var minSequence: Int {
106
+ if let o { return o.minSequence }
107
+ let v = bundle.object(forInfoDictionaryKey: "ShipvoMinSequence")
108
+ return max(0, (v as? NSNumber)?.intValue ?? Int((v as? String) ?? "") ?? 0)
109
+ }
110
+ var clock: Clock { o?.clock ?? SystemClock() }
111
+ /// Rotation state: key, sequence, pinned key and pending statements, written as one dictionary so they change together.
112
+ var rotationRecord: [String: Any]? {
113
+ get { d.dictionary(forKey: "rotation") }
114
+ set { if let newValue { d.set(newValue, forKey: "rotation") } else { d.removeObject(forKey: "rotation") } }
115
+ }
87
116
  var appId: String { o?.appId ?? plist("ShipvoAppId") ?? "" }
117
+ /// Hermes bytecode versions this shell runs (Info.plist `ShipvoHermesBytecodeVersions`: an array of numbers or a "96,98" string).
118
+ var hermesBytecodeVersions: [Int] {
119
+ if let o { return o.hermesBytecodeVersions }
120
+ let v = bundle.object(forInfoDictionaryKey: "ShipvoHermesBytecodeVersions")
121
+ let list: [Any] = (v as? [Any]) ?? ((v as? String)?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } ?? [])
122
+ return list.compactMap { ($0 as? NSNumber)?.intValue ?? Int("\($0)") }.filter { $0 > 0 }
123
+ }
124
+ /// Hermes engine build id this shell runs (Info.plist `ShipvoHermesEngine`), or nil when not declared.
125
+ var hermesEngine: String? {
126
+ let v = o != nil ? o?.hermesEngine : plist("ShipvoHermesEngine")
127
+ let t = v?.trimmingCharacters(in: .whitespaces) ?? ""
128
+ return t.isEmpty ? nil : t
129
+ }
130
+ /// Native modules in this shell (Info.plist `ShipvoNativeModules`: a name-to-version dictionary).
131
+ var nativeModules: [String: String] {
132
+ if let o { return o.nativeModules }
133
+ return (bundle.object(forInfoDictionaryKey: "ShipvoNativeModules") as? [String: Any])?.compactMapValues { $0 as? String } ?? [:]
134
+ }
88
135
  var shellMajor: Int { if let o { return o.shellMajor }; return Int(plist("ShipvoShellMajor") ?? "1") ?? 1 }
89
136
 
90
137
  // 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,18 +16,42 @@ 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=`, `?hbe=` 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
+ if let hbe = s.hermesEngine { q.append("hbe=" + enc(hbe)) }
27
+ let nm = s.nativeModules
28
+ if !nm.isEmpty { q.append("nm=" + nm.keys.sorted().map { enc($0) + "%40" + enc(nm[$0]!) }.joined(separator: ",")) }
29
+ if !q.isEmpty { url += "?" + q.joined(separator: "&") }
30
+ return url
31
+ }
32
+
19
33
  static func check(_ s: Store) async throws -> [String: Any] {
20
34
  try require(!s.baseUrl.isEmpty && !s.publicKey.isEmpty && !s.appId.isEmpty, "runtime not configured")
35
+ Rotation.sync(s) // statements that came due since the last launch apply before any pointer check
21
36
  let known = max(s.currentRelease, s.pendingRelease, s.blockedRelease)
22
- let (data, resp) = try await URLSession.shared.data(for: request("\(s.baseUrl)/p/\(s.appId)/\(s.shellMajor)/ios/\(s.band).json", etag: known > 0 ? "\"\(known)\"" : nil))
37
+ let (data, resp) = try await URLSession.shared.data(for: request(pointerURL(s), etag: known > 0 ? "\"\(known)\"" : nil))
23
38
  let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
24
- if code == 304 || code == 404 { return ["status": "none"] }
39
+ if code == 404 {
40
+ // The control plane says this shell cannot run the release. Keep the current one; stage nothing.
41
+ let text = String(data: data, encoding: .utf8) ?? ""
42
+ let reason = ((try? JSONSerialization.jsonObject(with: data)) as? [String: Any])?["error"] as? String ?? text.trimmingCharacters(in: .whitespacesAndNewlines)
43
+ if ["hermes_bytecode_mismatch", "native_module_missing", "native_module_version"].contains(reason) { return ["status": "none", "reason": reason] }
44
+ return ["status": "none"]
45
+ }
46
+ if code == 304 { return ["status": "none"] }
25
47
  try require(code == 200, "pointer HTTP \(code)")
26
48
  guard let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
27
49
  let bodyStr = envelope["body"] as? String, let sig = envelope["signature"] as? String else { throw UpdateError("pointer is not an envelope") }
28
50
 
51
+ // Rotation statements first, so a release signed by a new key can arrive in the same fetch.
52
+ Rotation.process(s, rotations: envelope["rotations"])
29
53
  let t0 = Store.now()
30
- try require(Verify.ed25519(Data(bodyStr.utf8), signatureB64: sig, publicKeyB64: s.publicKey), "pointer signature invalid")
54
+ try require(Verify.ed25519(Data(bodyStr.utf8), signatureB64: sig, publicKeyB64: Rotation.currentKey(s)), "pointer signature invalid")
31
55
  guard let body = try JSONSerialization.jsonObject(with: Data(bodyStr.utf8)) as? [String: Any] else { throw UpdateError("pointer body unreadable") }
32
56
  try require(body["platform"] as? String == "ios", "pointer is not for iOS")
33
57
  try require(body["app_id"] as? String == s.appId && body["shell_major"] as? Int == s.shellMajor, "pointer for another app/shell")
@@ -35,11 +59,18 @@ enum Updater {
35
59
  if release <= known { return ["status": "none"] }
36
60
  try require(!Verify.semverLess(s.shellVersion, body["min_shell_version"] as? String ?? "0.0.0"), "shell too old for release \(release)")
37
61
  let verifyPointerMs = (Store.now() - t0) * 1000
62
+ // Beside the signature checks, not inside them: refuse a bundle built for another Hermes bytecode version.
63
+ if let ev = HermesGuard.mismatchEvent(release: release, releaseVersion: HermesGuard.releaseVersionOf(body["hermes_bytecode_version"]), shellVersion: HermesGuard.shellVersion(s.appBundle)) {
64
+ s.addEvent(ev); s.block(release)
65
+ return ["status": "none", "reason": HermesGuard.reason]
66
+ }
38
67
 
39
68
  guard let core = body["core"] as? [String: Any], let wantSha = core["sha256"] as? String,
40
69
  let wantSize = (core["size"] as? NSNumber)?.intValue, let url = core["url"] as? String else { throw UpdateError("pointer core incomplete") }
41
70
  let tmp = s.dir.appendingPathComponent("dl-\(release).tmp")
42
71
  try? FileManager.default.removeItem(at: tmp)
72
+ let part = partialURL(s, sha: wantSha)
73
+ dropOtherPartials(s, keep: part)
43
74
 
44
75
  var via = "full", downloadedBytes = 0
45
76
  let d0 = Store.now()
@@ -48,21 +79,20 @@ enum Updater {
48
79
  ready = true; via = "patch"; downloadedBytes = patch
49
80
  }
50
81
  var downloadMs = (Store.now() - d0) * 1000
82
+ var resumedFrom = 0
51
83
  if !ready {
52
84
  let f0 = Store.now()
53
- let (fileURL, dresp) = try await URLSession.shared.download(for: request(resolve(s, url), etag: nil))
54
- let dcode = (dresp as? HTTPURLResponse)?.statusCode ?? 0
55
- try require(dcode == 200, "bundle HTTP \(dcode)")
85
+ resumedFrom = try await downloadFull(s, url: resolve(s, url), part: part, wantSize: wantSize)
56
86
  try? FileManager.default.removeItem(at: tmp)
57
- try FileManager.default.moveItem(at: fileURL, to: tmp)
87
+ try FileManager.default.moveItem(at: part, to: tmp)
58
88
  downloadMs += (Store.now() - f0) * 1000
59
89
  }
60
90
 
61
91
  let v0 = Store.now()
62
92
  let size = (try? FileManager.default.attributesOfItem(atPath: tmp.path)[.size] as? Int) ?? -1
63
- if via == "full" { downloadedBytes = size }
93
+ if via == "full" { downloadedBytes = max(0, size - resumedFrom) }
64
94
  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") }
95
+ 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
96
  let verifyMs = (Store.now() - v0) * 1000 + verifyPointerMs
67
97
 
68
98
  let dest = s.dir.appendingPathComponent("r\(release).hbc")
@@ -73,19 +103,63 @@ enum Updater {
73
103
  "downloadMs": Int(downloadMs), "verifyMs": Int(verifyMs), "issuedAt": body["issued_at"] as? String ?? ""]
74
104
  }
75
105
 
106
+ /// Partial full-bundle download, named by the release sha256 so a different release never reuses it.
107
+ static func partialURL(_ s: Store, sha: String) -> URL { s.dir.appendingPathComponent("dl-\(sha).part") }
108
+
109
+ /// The release changed: partial files for other hashes can never complete.
110
+ private static func dropOtherPartials(_ s: Store, keep: URL) {
111
+ let files = (try? FileManager.default.contentsOfDirectory(at: s.dir, includingPropertiesForKeys: nil)) ?? []
112
+ for f in files where f.pathExtension == "part" && f.lastPathComponent != keep.lastPathComponent { try? FileManager.default.removeItem(at: f) }
113
+ }
114
+
115
+ /// Download the full bundle into `part`, resuming with `Range: bytes=<n>-` when a partial file exists.
116
+ /// A 200 answer means the server ignored the range: start over. Returns how many bytes were already on disk.
117
+ /// If the connection drops, the partial file stays for the next attempt. The caller still checks the signed hash.
118
+ private static func downloadFull(_ s: Store, url: String, part: URL, wantSize: Int) async throws -> Int {
119
+ let fm = FileManager.default
120
+ func have() -> Int { (try? fm.attributesOfItem(atPath: part.path)[.size] as? Int) ?? 0 }
121
+ var offset = have()
122
+ if offset > wantSize { try? fm.removeItem(at: part); offset = 0 }
123
+ if offset == wantSize { return offset }
124
+ for attempt in 0..<2 {
125
+ var req = try request(url, etag: nil)
126
+ if offset > 0 { req.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") }
127
+ let (stream, resp) = try await URLSession.shared.bytes(for: req)
128
+ let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
129
+ if code == 416 && offset > 0 && attempt == 0 { try? fm.removeItem(at: part); offset = 0; continue }
130
+ try require(code == 200 || (code == 206 && offset > 0), "bundle HTTP \(code)")
131
+ if code == 200 { try? fm.removeItem(at: part); offset = 0 }
132
+ if !fm.fileExists(atPath: part.path) { try require(fm.createFile(atPath: part.path, contents: nil), "cannot create partial file") }
133
+ let h = try FileHandle(forWritingTo: part)
134
+ defer { try? h.close() }
135
+ try h.seekToEnd()
136
+ var buf = Data(); buf.reserveCapacity(64 * 1024)
137
+ for try await b in stream {
138
+ buf.append(b)
139
+ if buf.count >= 64 * 1024 { try h.write(contentsOf: buf); buf.removeAll(keepingCapacity: true) }
140
+ }
141
+ if !buf.isEmpty { try h.write(contentsOf: buf) }
142
+ return offset
143
+ }
144
+ throw UpdateError("bundle download failed")
145
+ }
146
+
76
147
  /// Try the patch from the bundle this device runs. Writes the rebuilt bundle to `tmp` and returns the patch size,
77
148
  /// or throws (nothing left behind) so the caller can fetch the full bundle. The caller still checks the signed hash.
78
149
  private static func patched(_ s: Store, core: [String: Any], wantSha: String, wantSize: Int, to tmp: URL) async throws -> Int {
79
- guard let base = s.currentPath, s.currentSha != nil, FileManager.default.fileExists(atPath: base),
150
+ // Base: the bundle this device runs, or the embedded main.jsbundle before the first update.
151
+ let baseFile = s.currentPath.flatMap { FileManager.default.fileExists(atPath: $0) ? $0 : nil }
152
+ let baseSha: String? = baseFile != nil ? s.currentSha : s.embeddedSha()
153
+ guard let baseSha,
80
154
  let list = core["patches"] as? [[String: Any]],
81
- let entry = list.first(where: { $0["from_sha256"] as? String == s.currentSha }),
155
+ let entry = list.first(where: { $0["from_sha256"] as? String == baseSha }),
82
156
  let purl = entry["url"] as? String, let psha = entry["sha256"] as? String, let psize = (entry["size"] as? NSNumber)?.intValue,
83
157
  psize > 0, psize < wantSize else { throw UpdateError("no usable patch") }
84
158
  do {
85
159
  let (data, resp) = try await URLSession.shared.data(for: request(resolve(s, purl), etag: nil))
86
160
  try require((resp as? HTTPURLResponse)?.statusCode == 200 && data.count == psize && Verify.sha256Hex(data) == psha, "patch download invalid")
87
- let old = try Data(contentsOf: URL(fileURLWithPath: base))
88
- try require(Verify.sha256Hex(old) == s.currentSha, "current bundle changed on disk")
161
+ guard let old = try baseFile.map({ try Data(contentsOf: URL(fileURLWithPath: $0)) }) ?? s.embeddedBundle() else { throw UpdateError("no base bundle") }
162
+ try require(Verify.sha256Hex(old) == baseSha, "base bundle changed on disk")
89
163
  let new = try Patch.apply(old: old, patch: data)
90
164
  try require(new.count == wantSize && Verify.sha256Hex(new) == wantSha, "patched bundle does not match")
91
165
  try new.write(to: tmp)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipvo/runtime",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -16,6 +16,7 @@
16
16
  "./package.json": "./package.json"
17
17
  },
18
18
  "files": [
19
+ "skills",
19
20
  "src",
20
21
  "android",
21
22
  "ios",
@@ -56,5 +57,12 @@
56
57
  "publishConfig": {
57
58
  "access": "public",
58
59
  "provenance": true
60
+ },
61
+ "devDependencies": {
62
+ "typescript": "^5.6.3",
63
+ "vitest": "^2.1.4"
64
+ },
65
+ "scripts": {
66
+ "test": "vitest run"
59
67
  }
60
68
  }
@@ -0,0 +1,39 @@
1
+ ---
2
+ name: shipvo-runtime
3
+ description: Use the Shipvo over-the-air update runtime in an Expo or React Native app. Use when the project depends on @shipvo/runtime and you need to check for an update, mark a release healthy or forward update events.
4
+ ---
5
+
6
+ # @shipvo/runtime
7
+
8
+ The native half of Shipvo updates. It verifies every release (Ed25519 and SHA-256), applies patches, and goes back to the previous release after 3 unhealthy boots.
9
+
10
+ `shipvo init --dry-run` then `shipvo init --yes` wires it for you. Never print or commit `.shipvo/seed.txt`.
11
+
12
+ ## In the app
13
+
14
+ ```ts
15
+ import { Runtime } from "@shipvo/runtime";
16
+
17
+ const state = Runtime.getState(); // running, pending and previous release, baseUrl, appId
18
+ await Runtime.checkForUpdate(true); // verify, download, stage, apply
19
+ Runtime.markHealthy(); // once the first screen rendered
20
+ ```
21
+
22
+ The runtime reports its own events (`crash_loop_revert`, `rolled_back`, `verify_failed`) to telemetry. Use `onRuntimeEvents` to log them. `reportEvents: false` turns reporting off. Do not call `Runtime.consumeEvents()` unless you forward events yourself: it drains the queue before the runtime reports it.
23
+
24
+ ## Config plugin
25
+
26
+ ```json
27
+ { "expo": { "plugins": [["@shipvo/runtime", { "baseUrl": "https://<control plane>", "publicKey": "<base64 public key>", "appId": "myshop" }]] } }
28
+ ```
29
+
30
+ Rebuild the native app after you change plugin props.
31
+
32
+ ## Check and release
33
+
34
+ ```bash
35
+ shipvo doctor
36
+ shipvo status --json
37
+ shipvo publish --dry-run
38
+ shipvo rollback --to <release>
39
+ ```
package/src/index.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { requireOptionalNativeModule } from "expo-modules-core";
2
+ import { forwardRuntimeEvents, FORWARDED_EVENTS, type ForwardedEvent } from "./runtimeEvents";
3
+
4
+ import { ensureInstallIdentity, signReport, webCrypto, type InstallIdentity, type TelemetryCrypto, type TelemetryStorage } from "./install";
5
+
6
+ export { forwardRuntimeEvents, FORWARDED_EVENTS, ensureInstallIdentity, signReport, webCrypto };
7
+ export type { InstallIdentity, TelemetryCrypto, TelemetryStorage };
8
+ export type { ForwardedEvent };
2
9
 
3
10
  export type RuntimeState = {
4
11
  cohort: number; band: "b0" | "b1" | "b2";
@@ -11,6 +18,8 @@ export type CheckResult = {
11
18
  /** Shell 1.0.2+ (Android): "patch" when a bsdiff patch was applied, "full" otherwise. iOS always downloads "full". */
12
19
  via?: "patch" | "full"; bundleBytes?: number; patchError?: string | null;
13
20
  issuedAt?: string; error?: string;
21
+ /** Android only for now: why a "none" result happened when the control plane rejected the shell's bytecode or native modules. */
22
+ reason?: "hermes_bytecode_mismatch" | "native_module_missing" | "native_module_version";
14
23
  };
15
24
  export type HealthyResult = {
16
25
  release: number; coldStartMs: number; sinceLoadMs: number; promoted: boolean;
@@ -39,4 +48,96 @@ const fallback: Native = {
39
48
  markHealthy: () => ({ release: 0, coldStartMs: Date.now() - t0, sinceLoadMs: 0, promoted: false, kind: "cold" }),
40
49
  consumeEvents: () => [], rollback: () => {}, reload: () => {},
41
50
  };
42
- export const Runtime: Native = requireOptionalNativeModule<Native>("ShipvoRuntime") ?? fallback;
51
+ const native = requireOptionalNativeModule<Native & { noteCrash?(): void }>("ShipvoRuntime");
52
+
53
+ // After markHealthy, tell the native side about an uncaught JS error before React destroys the host. Destroying the host
54
+ // pauses the activity, and that pause must not count as "stable". Needs a shell with `noteCrash`; older shells skip it.
55
+ let crashHookInstalled = false;
56
+ function installCrashHook() {
57
+ if (crashHookInstalled || typeof native?.noteCrash !== "function") return;
58
+ const eu = (globalThis as any).ErrorUtils;
59
+ if (!eu?.setGlobalHandler || !eu.getGlobalHandler) return;
60
+ crashHookInstalled = true;
61
+ const prev = eu.getGlobalHandler();
62
+ eu.setGlobalHandler((e: unknown, isFatal?: boolean) => {
63
+ if (isFatal) { try { native.noteCrash!(); } catch {} }
64
+ prev?.(e, isFatal);
65
+ });
66
+ }
67
+
68
+ let identity: InstallIdentity | null = null;
69
+ let identityCrypto: TelemetryCrypto = webCrypto;
70
+ /**
71
+ * Turn on signed telemetry. Registers this install once (see `ensureInstallIdentity`); until that finishes, and if it
72
+ * fails, reports are sent unsigned. Pass your own `crypto` on React Native. Never throws. The identity is module-global:
73
+ * calling this again replaces the previous identity and crypto.
74
+ */
75
+ export async function enableSignedTelemetry(opts: { storage: TelemetryStorage; registerUrl: string; crypto?: TelemetryCrypto }): Promise<InstallIdentity | null> {
76
+ identityCrypto = opts.crypto ?? webCrypto;
77
+ identity = await ensureInstallIdentity(opts.storage, identityCrypto, opts.registerUrl);
78
+ return identity;
79
+ }
80
+
81
+ const listeners = new Set<(events: string[]) => void>();
82
+ /** Called with every batch of queued events the runtime drains, in `reportRuntimeEvents`. Returns an unsubscribe function. */
83
+ export function onRuntimeEvents(fn: (events: string[]) => void): () => void {
84
+ listeners.add(fn);
85
+ return () => { listeners.delete(fn); };
86
+ }
87
+
88
+ /**
89
+ * Drain the device's event queue and send each forwarded event to the control plane (`POST {baseUrl}/t`), using
90
+ * `baseUrl` and `appId` from the native state or `expo.extra.shipvo`. Best effort: it never throws, a failed send
91
+ * never blocks the others, and nothing is sent without a `baseUrl`. Returns the drained event strings.
92
+ * `Runtime.markHealthy` and `Runtime.checkForUpdate` call this for you. Set `extra.shipvo.reportEvents` to `false`
93
+ * to turn that off.
94
+ */
95
+ export function reportRuntimeEvents(): string[] {
96
+ let events: string[] = [];
97
+ try { events = native ? native.consumeEvents() : []; } catch { return []; }
98
+ if (!Array.isArray(events) || events.length === 0) return [];
99
+ for (const l of listeners) { try { l(events); } catch {} }
100
+ let state: Partial<RuntimeState> = {};
101
+ try { state = (native ?? fallback).getState() ?? {}; } catch {}
102
+ const baseUrl = state.baseUrl || extra.baseUrl;
103
+ const appId = state.appId || extra.appId;
104
+ if (!baseUrl || !appId) return events;
105
+ let androidApi: number | undefined;
106
+ try {
107
+ const rn = require("react-native");
108
+ if (rn.Platform?.OS === "android" && typeof rn.Platform.Version === "number") androidApi = rn.Platform.Version;
109
+ } catch {}
110
+ forwardRuntimeEvents(events, (event, release) => {
111
+ try {
112
+ // Same payload the sample app sent before this moved here: device_class is fixed to "low", android_api is set on Android only.
113
+ const body = { schema_version: "1.0", app_id: appId, release: release ?? state.runningRelease ?? 0, event,
114
+ device_class: "low", android_api: androidApi };
115
+ const post = (b: string) => Promise.resolve(fetch(`${baseUrl}/t`, { method: "POST", headers: { "content-type": "application/json" },
116
+ body: b })).catch(() => {});
117
+ const id = identity;
118
+ if (!id) { post(JSON.stringify(body)); return; }
119
+ const plain = JSON.stringify(body);
120
+ // `sig` is the HMAC of `signed`, closing `}` included. We send `signed` with that `}` replaced by
121
+ // `,"sig":"<hex>"}`. The server cuts that suffix, appends `}` and checks the HMAC. If signing fails, send unsigned.
122
+ const signed = JSON.stringify({ ...body, install_id: id.install_id });
123
+ signReport(id, signed, identityCrypto).then(
124
+ (r) => post(`${signed.slice(0, -1)},"sig":"${r.sig}"}`),
125
+ () => post(plain));
126
+ } catch {}
127
+ });
128
+ return events;
129
+ }
130
+
131
+ const autoReport = () => { if (extra.reportEvents !== false) reportRuntimeEvents(); };
132
+
133
+ // Every `Native` method must be listed here, or it is silently dropped from `Runtime`.
134
+ export const Runtime: Native = native
135
+ ? {
136
+ getState: () => native.getState(),
137
+ checkForUpdate: async (apply) => { try { return await native.checkForUpdate(apply); } finally { autoReport(); } },
138
+ markHealthy: () => { const r = native.markHealthy(); installCrashHook(); autoReport(); return r; },
139
+ consumeEvents: () => native.consumeEvents(),
140
+ rollback: () => native.rollback(),
141
+ reload: () => native.reload(),
142
+ }
143
+ : fallback;
package/src/index.web.ts CHANGED
@@ -16,3 +16,6 @@ export const Runtime = {
16
16
  rollback(): void {},
17
17
  reload(): void { (globalThis as any).location?.reload(); },
18
18
  };
19
+ export { FORWARDED_EVENTS, forwardRuntimeEvents } from "./runtimeEvents";
20
+ export function reportRuntimeEvents(): string[] { return []; }
21
+ export function onRuntimeEvents(_fn: (events: string[]) => void): () => void { return () => {}; }