@shipvo/runtime 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/android/build.gradle +7 -0
- package/android/src/main/java/dev/shipvo/runtime/BootRules.kt +53 -0
- package/android/src/main/java/dev/shipvo/runtime/Clock.kt +9 -0
- package/android/src/main/java/dev/shipvo/runtime/HermesGuard.kt +42 -0
- package/android/src/main/java/dev/shipvo/runtime/PointerQuery.kt +36 -0
- package/android/src/main/java/dev/shipvo/runtime/ResumableDownload.kt +39 -0
- package/android/src/main/java/dev/shipvo/runtime/Rotation.kt +134 -0
- package/android/src/main/java/dev/shipvo/runtime/ShipvoHostHandler.kt +4 -5
- package/android/src/main/java/dev/shipvo/runtime/ShipvoRuntimeModule.kt +66 -10
- package/android/src/main/java/dev/shipvo/runtime/Store.kt +36 -3
- package/android/src/test/java/dev/shipvo/runtime/HermesGuardTest.kt +73 -0
- package/android/src/test/java/dev/shipvo/runtime/PointerQueryTest.kt +45 -0
- package/android/src/test/java/dev/shipvo/runtime/ResumableDownloadTest.kt +90 -0
- package/android/src/test/java/dev/shipvo/runtime/RotationTest.kt +141 -0
- package/android/src/test/java/dev/shipvo/runtime/StabilityTest.kt +76 -0
- package/app.plugin.js +75 -2
- package/ios/BundleSelector.swift +21 -1
- package/ios/HermesGuard.swift +43 -0
- package/ios/Rotation.swift +147 -0
- package/ios/ShipvoRuntimeModule.swift +27 -0
- package/ios/Store.swift +47 -0
- package/ios/Updater.swift +88 -14
- package/package.json +9 -1
- package/skills/shipvo-runtime/SKILL.md +39 -0
- package/src/index.ts +102 -1
- package/src/index.web.ts +3 -0
- package/src/install.ts +74 -0
- package/src/runtimeEvents.ts +38 -0
|
@@ -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(
|
|
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 ==
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 ==
|
|
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) ==
|
|
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
|
+
"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
|
-
|
|
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 () => {}; }
|