@shipvo/runtime 0.1.0 → 0.1.1
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 +1 -1
- package/app.plugin.js +8 -1
- package/expo-module.config.json +17 -2
- package/ios/BundleSelector.swift +92 -0
- package/ios/ShipvoReactDelegateHandler.swift +16 -0
- package/ios/ShipvoRuntime.podspec +25 -0
- package/ios/ShipvoRuntimeModule.swift +72 -0
- package/ios/Store.swift +102 -0
- package/ios/Updater.swift +73 -0
- package/ios/Verify.swift +30 -0
- package/package.json +2 -1
- package/src/index.ts +3 -3
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ The native half of Shipvo's over-the-air updates, as an Expo module.
|
|
|
5
5
|
- Verifies every release pointer (Ed25519) and bundle (SHA-256 + size) against a public key built into the shell.
|
|
6
6
|
- Downloads bsdiff patches when it has the previous release, full bundles otherwise; stages atomically.
|
|
7
7
|
- Applies without restart, marks the release healthy when the app says so, and discards a release that fails to boot 3 times.
|
|
8
|
-
- Android
|
|
8
|
+
- Android and iOS. iOS downloads full bundles (patches are Android-only for now) and is verified on the iOS Simulator in CI; in the web simulator the app runs the same release as the web build.
|
|
9
9
|
|
|
10
10
|
```json
|
|
11
11
|
{ "expo": { "plugins": [["@shipvo/runtime", { "baseUrl": "https://<your control plane>", "publicKey": "<base64 Ed25519 public key>", "appId": "myshop" }]] } }
|
package/app.plugin.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
// Shipvo config plugin: puts the trust root and control-plane address in the native shell (AndroidManifest
|
|
2
2
|
// meta-data, and expo.extra.shipvo for platforms without the OTA runtime), so an over-the-air bundle can never
|
|
3
3
|
// change which key it is verified against.
|
|
4
|
-
const { withAndroidManifest, AndroidConfig } = require("expo/config-plugins");
|
|
4
|
+
const { withAndroidManifest, withInfoPlist, AndroidConfig } = require("expo/config-plugins");
|
|
5
5
|
|
|
6
6
|
module.exports = function withShipvo(config, props) {
|
|
7
7
|
const { baseUrl, publicKey, appId, shellMajor = 1 } = props || {};
|
|
8
8
|
if (!baseUrl || !publicKey || !appId) throw new Error("@shipvo/runtime: baseUrl, publicKey and appId are required");
|
|
9
9
|
config.extra = { ...(config.extra ?? {}), shipvo: { baseUrl, appId, shellMajor } };
|
|
10
|
+
config = withInfoPlist(config, (cfg) => {
|
|
11
|
+
cfg.modResults.ShipvoBaseURL = baseUrl;
|
|
12
|
+
cfg.modResults.ShipvoPublicKey = publicKey;
|
|
13
|
+
cfg.modResults.ShipvoAppId = appId;
|
|
14
|
+
cfg.modResults.ShipvoShellMajor = String(shellMajor);
|
|
15
|
+
return cfg;
|
|
16
|
+
});
|
|
10
17
|
return withAndroidManifest(config, (cfg) => {
|
|
11
18
|
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(cfg.modResults);
|
|
12
19
|
const set = (name, value) => AndroidConfig.Manifest.addMetaDataItemToMainApplication(app, name, String(value));
|
package/expo-module.config.json
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
{
|
|
2
|
-
"platforms": [
|
|
3
|
-
|
|
2
|
+
"platforms": [
|
|
3
|
+
"android",
|
|
4
|
+
"apple"
|
|
5
|
+
],
|
|
6
|
+
"android": {
|
|
7
|
+
"modules": [
|
|
8
|
+
"dev.shipvo.runtime.ShipvoRuntimeModule"
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"apple": {
|
|
12
|
+
"modules": [
|
|
13
|
+
"ShipvoRuntimeModule"
|
|
14
|
+
],
|
|
15
|
+
"reactDelegateHandlers": [
|
|
16
|
+
"ShipvoReactDelegateHandler"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
4
19
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// The bundle-choice rules. Kept free of Expo and React imports so they run under `swift test` on a plain Mac.
|
|
4
|
+
enum ShipvoBundleSelector {
|
|
5
|
+
static let maxBoots = 3
|
|
6
|
+
static let windowSeconds: TimeInterval = 60
|
|
7
|
+
|
|
8
|
+
/// Idempotent per load: React Native asks for the URL more than once, and only the first ask counts as a boot.
|
|
9
|
+
static func select(_ s: Store) -> URL? {
|
|
10
|
+
Store.lock.lock(); defer { Store.lock.unlock() }
|
|
11
|
+
if Store.selection.set { return Store.selection.url }
|
|
12
|
+
let url = choose(s)
|
|
13
|
+
Store.selection = (url, true)
|
|
14
|
+
return url
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/// The app reports a healthy start: a pending release that is the one running becomes current. Returns whether it was promoted.
|
|
18
|
+
@discardableResult
|
|
19
|
+
static func promoteIfRunning(_ s: Store) -> Bool {
|
|
20
|
+
Store.lock.lock(); defer { Store.lock.unlock() }
|
|
21
|
+
var promoted = false
|
|
22
|
+
if s.pendingRelease != 0 && Store.runningRelease == s.pendingRelease {
|
|
23
|
+
if let old = s.previousPath, old != s.currentPath { remove(old) }
|
|
24
|
+
s.previousRelease = s.currentRelease; s.previousPath = s.currentPath
|
|
25
|
+
s.currentRelease = s.pendingRelease; s.currentPath = s.pendingPath; s.currentSha = s.pendingSha
|
|
26
|
+
s.pendingRelease = 0; s.pendingPath = nil; s.trialBoots = 0
|
|
27
|
+
s.addEvent("applied:\(s.currentRelease)")
|
|
28
|
+
promoted = true
|
|
29
|
+
}
|
|
30
|
+
s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
|
|
31
|
+
return promoted
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// Called before a reload so the next ask is a fresh load.
|
|
35
|
+
static func invalidate() { Store.lock.lock(); Store.selection = (nil, false); Store.lock.unlock() }
|
|
36
|
+
|
|
37
|
+
private static func exists(_ path: String?) -> Bool { path != nil && FileManager.default.fileExists(atPath: path!) }
|
|
38
|
+
private static func remove(_ path: String?) { if let p = path { try? FileManager.default.removeItem(atPath: p) } }
|
|
39
|
+
|
|
40
|
+
private static func choose(_ s: Store) -> URL? {
|
|
41
|
+
Store.bundleSelectedAt = Store.now()
|
|
42
|
+
Store.loadsInProcess += 1
|
|
43
|
+
|
|
44
|
+
let installed = s.installedBinaryVersion
|
|
45
|
+
if s.binaryVersion != installed {
|
|
46
|
+
let hadBundles = s.currentPath != nil || s.pendingPath != nil
|
|
47
|
+
s.resetForNewShell()
|
|
48
|
+
if hadBundles { s.addEvent("shell_changed:0") }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if let pending = s.pendingPath {
|
|
52
|
+
if s.trialBoots >= maxBoots || !exists(pending) {
|
|
53
|
+
s.addEvent("crash_loop_revert:\(s.pendingRelease)")
|
|
54
|
+
s.block(s.pendingRelease)
|
|
55
|
+
remove(pending)
|
|
56
|
+
s.pendingPath = nil; s.pendingRelease = 0; s.trialBoots = 0
|
|
57
|
+
} else {
|
|
58
|
+
s.trialBoots += 1
|
|
59
|
+
Store.runningRelease = s.pendingRelease
|
|
60
|
+
return URL(fileURLWithPath: pending)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if exists(s.currentPath) {
|
|
65
|
+
let now = Store.now()
|
|
66
|
+
if now - s.firstUnhealthyAt > windowSeconds { s.firstUnhealthyAt = now; s.unhealthyBoots = 0 }
|
|
67
|
+
s.unhealthyBoots += 1
|
|
68
|
+
if s.unhealthyBoots > maxBoots {
|
|
69
|
+
s.addEvent("crash_loop_revert:\(s.currentRelease)")
|
|
70
|
+
s.block(s.currentRelease)
|
|
71
|
+
revertToPrevious(s)
|
|
72
|
+
return currentOrEmbedded(s)
|
|
73
|
+
}
|
|
74
|
+
Store.runningRelease = s.currentRelease
|
|
75
|
+
return URL(fileURLWithPath: s.currentPath!)
|
|
76
|
+
}
|
|
77
|
+
Store.runningRelease = 0
|
|
78
|
+
return nil // the embedded bundle
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private static func currentOrEmbedded(_ s: Store) -> URL? {
|
|
82
|
+
if exists(s.currentPath) { Store.runningRelease = s.currentRelease; return URL(fileURLWithPath: s.currentPath!) }
|
|
83
|
+
Store.runningRelease = 0; return nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static func revertToPrevious(_ s: Store) {
|
|
87
|
+
if let c = s.currentPath, c != s.previousPath { remove(c) }
|
|
88
|
+
s.currentPath = s.previousPath; s.currentRelease = s.previousRelease; s.currentSha = nil
|
|
89
|
+
s.previousPath = nil; s.previousRelease = 0
|
|
90
|
+
s.unhealthyBoots = 0; s.firstUnhealthyAt = 0
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
/// Chooses the JS bundle at load. Order: a staged (pending) release on trial, else the last good release, else the
|
|
5
|
+
/// bundle embedded in the app (returning nil lets React Native use main.jsbundle). A pending release that does not
|
|
6
|
+
/// reach markHealthy within 3 loads is discarded; a current release that loads unhealthy 3 times inside 60 s falls
|
|
7
|
+
/// back to the previous one. Same rules as the Android host handler.
|
|
8
|
+
public final class ShipvoReactDelegateHandler: ExpoReactDelegateHandler {
|
|
9
|
+
public override func bundleURL(reactDelegate: ExpoReactDelegate) -> URL? {
|
|
10
|
+
#if DEBUG
|
|
11
|
+
return nil // development builds load from Metro
|
|
12
|
+
#else
|
|
13
|
+
return ShipvoBundleSelector.select(Store())
|
|
14
|
+
#endif
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
3
|
+
|
|
4
|
+
Pod::Spec.new do |s|
|
|
5
|
+
s.name = 'ShipvoRuntime'
|
|
6
|
+
s.version = package['version']
|
|
7
|
+
s.summary = package['description']
|
|
8
|
+
s.description = package['description']
|
|
9
|
+
s.license = package['license']
|
|
10
|
+
s.author = 'Shipvo'
|
|
11
|
+
s.homepage = 'https://shipvo.dev'
|
|
12
|
+
s.platforms = { :ios => '16.4' }
|
|
13
|
+
s.swift_version = '5.9'
|
|
14
|
+
s.source = { git: 'https://github.com/shipvo/shipvo.git' }
|
|
15
|
+
s.static_framework = true
|
|
16
|
+
|
|
17
|
+
s.dependency 'ExpoModulesCore'
|
|
18
|
+
s.dependency 'React-Core'
|
|
19
|
+
|
|
20
|
+
s.pod_target_xcconfig = {
|
|
21
|
+
'DEFINES_MODULE' => 'YES',
|
|
22
|
+
'SWIFT_COMPILATION_MODE' => 'wholemodule'
|
|
23
|
+
}
|
|
24
|
+
s.source_files = '**/*.{h,m,swift}'
|
|
25
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import Foundation
|
|
3
|
+
import React
|
|
4
|
+
|
|
5
|
+
/// JS-facing runtime: getState / checkForUpdate / markHealthy / consumeEvents / rollback / reload.
|
|
6
|
+
/// Same contract as the Android module so `Runtime` in src/index.ts is platform-neutral.
|
|
7
|
+
public final class ShipvoRuntimeModule: Module {
|
|
8
|
+
public func definition() -> ModuleDefinition {
|
|
9
|
+
Name("ShipvoRuntime")
|
|
10
|
+
|
|
11
|
+
Function("getState") { () -> [String: Any] in
|
|
12
|
+
let s = Store()
|
|
13
|
+
var out: [String: Any] = [
|
|
14
|
+
"cohort": s.cohort, "band": s.band,
|
|
15
|
+
"runningRelease": Store.runningRelease, "currentRelease": s.currentRelease,
|
|
16
|
+
"pendingRelease": s.pendingRelease, "previousRelease": s.previousRelease,
|
|
17
|
+
"shellVersion": s.installedBinaryVersion,
|
|
18
|
+
"baseUrl": s.baseUrl, "appId": s.appId, "shellMajor": s.shellMajor,
|
|
19
|
+
]
|
|
20
|
+
if s.blockedRelease != 0 { out["blockedRelease"] = s.blockedRelease }
|
|
21
|
+
return out
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
AsyncFunction("checkForUpdate") { (apply: Bool) async -> [String: Any] in
|
|
25
|
+
let r: [String: Any]
|
|
26
|
+
do { r = try await Updater.check(Store()) }
|
|
27
|
+
catch { r = ["status": "error", "error": (error as? UpdateError)?.message ?? error.localizedDescription] }
|
|
28
|
+
if apply, r["status"] as? String == "staged" { Self.reloadHost() }
|
|
29
|
+
return r
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Function("markHealthy") { () -> [String: Any] in
|
|
33
|
+
let s = Store()
|
|
34
|
+
let now = Store.now()
|
|
35
|
+
let coldStart = max(0, (Date().timeIntervalSince1970 - Self.processStart()) * 1000)
|
|
36
|
+
let sinceLoad = Store.bundleSelectedAt > 0 ? (now - Store.bundleSelectedAt) * 1000 : -1
|
|
37
|
+
let promoted = ShipvoBundleSelector.promoteIfRunning(s)
|
|
38
|
+
return ["release": Store.runningRelease, "coldStartMs": Int(coldStart), "sinceLoadMs": Int(sinceLoad), "promoted": promoted,
|
|
39
|
+
"kind": Store.loadsInProcess <= 1 ? "cold" : "reload"]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
Function("consumeEvents") { () -> [String] in Store().consumeEvents() }
|
|
43
|
+
|
|
44
|
+
Function("rollback") { () in
|
|
45
|
+
let s = Store()
|
|
46
|
+
Store.lock.lock()
|
|
47
|
+
if let p = s.pendingPath { try? FileManager.default.removeItem(atPath: p) }
|
|
48
|
+
s.pendingPath = nil; s.pendingRelease = 0; s.trialBoots = 0
|
|
49
|
+
s.addEvent("rolled_back:\(s.currentRelease)")
|
|
50
|
+
s.block(s.currentRelease)
|
|
51
|
+
ShipvoBundleSelector.revertToPrevious(s)
|
|
52
|
+
Store.lock.unlock()
|
|
53
|
+
Self.reloadHost()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
Function("reload") { () in Self.reloadHost() }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static func reloadHost() {
|
|
60
|
+
ShipvoBundleSelector.invalidate()
|
|
61
|
+
DispatchQueue.main.async { RCTTriggerReloadCommandListeners("shipvo-update") }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Seconds since 1970 at which this process started.
|
|
65
|
+
static func processStart() -> TimeInterval {
|
|
66
|
+
var info = kinfo_proc(); var size = MemoryLayout<kinfo_proc>.stride
|
|
67
|
+
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
|
|
68
|
+
guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else { return Date().timeIntervalSince1970 }
|
|
69
|
+
let t = info.kp_proc.p_un.__p_starttime
|
|
70
|
+
return TimeInterval(t.tv_sec) + TimeInterval(t.tv_usec) / 1_000_000
|
|
71
|
+
}
|
|
72
|
+
}
|
package/ios/Store.swift
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Persistent runtime state. Every bundle decision goes through here so the host handler and the module agree.
|
|
4
|
+
/// Mirrors the Android `Store`: same fields, same meaning.
|
|
5
|
+
final class Store {
|
|
6
|
+
/// Replaces what the app's Info.plist and file system would say. Tests use it; the app never does.
|
|
7
|
+
struct Overrides {
|
|
8
|
+
var suite: String, dir: URL, baseUrl: String, publicKey: String, appId: String, shellMajor: Int, shellVersion: String, binaryVersion: String
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private let d: UserDefaults
|
|
12
|
+
private let bundle: Bundle
|
|
13
|
+
private let o: Overrides?
|
|
14
|
+
|
|
15
|
+
init(bundle: Bundle = .main, overrides: Overrides? = nil) {
|
|
16
|
+
self.bundle = bundle; self.o = overrides
|
|
17
|
+
self.d = UserDefaults(suiteName: overrides?.suite ?? "shipvo.runtime") ?? .standard
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var dir: URL {
|
|
21
|
+
let base = o?.dir ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("shipvo", isDirectory: true)
|
|
22
|
+
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
|
23
|
+
return base
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// MARK: cohort and band
|
|
27
|
+
var cohort: Int {
|
|
28
|
+
if d.object(forKey: "cohort") == nil { d.set(Int.random(in: 0..<1000), forKey: "cohort") }
|
|
29
|
+
return d.integer(forKey: "cohort")
|
|
30
|
+
}
|
|
31
|
+
var band: String { let c = cohort; return c < 10 ? "b0" : (c < 100 ? "b1" : "b2") }
|
|
32
|
+
|
|
33
|
+
// MARK: releases
|
|
34
|
+
var currentRelease: Int { get { d.integer(forKey: "current_release") } set { d.set(newValue, forKey: "current_release") } }
|
|
35
|
+
var currentPath: String? { get { d.string(forKey: "current_path") } set { d.set(newValue, forKey: "current_path") } }
|
|
36
|
+
var currentSha: String? { get { d.string(forKey: "current_sha") } set { d.set(newValue, forKey: "current_sha") } }
|
|
37
|
+
var previousRelease: Int { get { d.integer(forKey: "previous_release") } set { d.set(newValue, forKey: "previous_release") } }
|
|
38
|
+
var previousPath: String? { get { d.string(forKey: "previous_path") } set { d.set(newValue, forKey: "previous_path") } }
|
|
39
|
+
var pendingRelease: Int { get { d.integer(forKey: "pending_release") } set { d.set(newValue, forKey: "pending_release") } }
|
|
40
|
+
var pendingPath: String? { get { d.string(forKey: "pending_path") } set { d.set(newValue, forKey: "pending_path") } }
|
|
41
|
+
var pendingSha: String? { get { d.string(forKey: "pending_sha") } set { d.set(newValue, forKey: "pending_sha") } }
|
|
42
|
+
var pendingIssuedAt: String? { get { d.string(forKey: "pending_issued_at") } set { d.set(newValue, forKey: "pending_issued_at") } }
|
|
43
|
+
/// Highest release this device refused (crash-looped or rolled back). Only newer releases are fetched.
|
|
44
|
+
var blockedRelease: Int { get { d.integer(forKey: "blocked_release") } set { d.set(newValue, forKey: "blocked_release") } }
|
|
45
|
+
/// Shell binary the stored OTA bundles were downloaded under.
|
|
46
|
+
var binaryVersion: String? { get { d.string(forKey: "binary_version") } set { d.set(newValue, forKey: "binary_version") } }
|
|
47
|
+
func block(_ release: Int) { if release > blockedRelease { blockedRelease = release } }
|
|
48
|
+
|
|
49
|
+
var trialBoots: Int { get { d.integer(forKey: "trial_boots") } set { d.set(newValue, forKey: "trial_boots") } }
|
|
50
|
+
var unhealthyBoots: Int { get { d.integer(forKey: "unhealthy_boots") } set { d.set(newValue, forKey: "unhealthy_boots") } }
|
|
51
|
+
var firstUnhealthyAt: Double { get { d.double(forKey: "first_unhealthy_at") } set { d.set(newValue, forKey: "first_unhealthy_at") } }
|
|
52
|
+
|
|
53
|
+
var installedBinaryVersion: String {
|
|
54
|
+
if let o { return o.binaryVersion }
|
|
55
|
+
let v = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
|
|
56
|
+
let b = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "0"
|
|
57
|
+
return "\(v)/\(b)"
|
|
58
|
+
}
|
|
59
|
+
/// The marketing version alone, compared against `min_shell_version`.
|
|
60
|
+
var shellVersion: String { if let o { return o.shellVersion }; return bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" }
|
|
61
|
+
|
|
62
|
+
/// After a shell (app binary) update, bundles built for the old native code must not run.
|
|
63
|
+
func resetForNewShell() {
|
|
64
|
+
if let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) {
|
|
65
|
+
for f in files { try? FileManager.default.removeItem(at: f) }
|
|
66
|
+
}
|
|
67
|
+
for k in ["current_release", "current_path", "current_sha", "previous_release", "previous_path", "pending_release", "pending_path",
|
|
68
|
+
"pending_sha", "pending_issued_at", "trial_boots", "unhealthy_boots", "first_unhealthy_at", "blocked_release"] { d.removeObject(forKey: k) }
|
|
69
|
+
binaryVersion = installedBinaryVersion
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// MARK: events
|
|
73
|
+
func addEvent(_ e: String) {
|
|
74
|
+
var list = (d.string(forKey: "events") ?? "").split(separator: "\n").map(String.init).filter { !$0.isEmpty }
|
|
75
|
+
list = Array(list.suffix(49)) + [e]
|
|
76
|
+
d.set(list.joined(separator: "\n"), forKey: "events")
|
|
77
|
+
}
|
|
78
|
+
func consumeEvents() -> [String] {
|
|
79
|
+
let list = (d.string(forKey: "events") ?? "").split(separator: "\n").map(String.init).filter { !$0.isEmpty }
|
|
80
|
+
d.removeObject(forKey: "events"); return list
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: trust root (Info.plist, written by the config plugin; an OTA bundle cannot change these)
|
|
84
|
+
private func plist(_ key: String) -> String? { bundle.object(forInfoDictionaryKey: key) as? String }
|
|
85
|
+
var baseUrl: String { var s = o?.baseUrl ?? plist("ShipvoBaseURL") ?? ""; while s.hasSuffix("/") { s.removeLast() }; return s }
|
|
86
|
+
var publicKey: String { o?.publicKey ?? plist("ShipvoPublicKey") ?? "" }
|
|
87
|
+
var appId: String { o?.appId ?? plist("ShipvoAppId") ?? "" }
|
|
88
|
+
var shellMajor: Int { if let o { return o.shellMajor }; return Int(plist("ShipvoShellMajor") ?? "1") ?? 1 }
|
|
89
|
+
|
|
90
|
+
// MARK: per-process state
|
|
91
|
+
/// Which release the JS running right now came from (set when the bundle is selected).
|
|
92
|
+
static var runningRelease = 0
|
|
93
|
+
static var bundleSelectedAt: TimeInterval = 0
|
|
94
|
+
/// How many times a bundle was selected in this process: 1 = cold start, more = in-process reload.
|
|
95
|
+
static var loadsInProcess = 0
|
|
96
|
+
/// The URL chosen for the current load. React Native asks for the bundle URL more than once per load,
|
|
97
|
+
/// so the choice (and its trial-boot accounting) is made once and cached until a reload clears it.
|
|
98
|
+
static var selection: (url: URL?, set: Bool) = (nil, false)
|
|
99
|
+
static let lock = NSRecursiveLock()
|
|
100
|
+
|
|
101
|
+
static func now() -> TimeInterval { ProcessInfo.processInfo.systemUptime }
|
|
102
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
struct UpdateError: Error { let message: String; init(_ m: String) { message = m } }
|
|
4
|
+
|
|
5
|
+
/// Fetch, verify and stage the newest signed release for this device's band. Mirrors Android `check()`.
|
|
6
|
+
/// iOS downloads full bundles only for now; a pointer's `patches` are ignored (an Android-only optimisation).
|
|
7
|
+
enum Updater {
|
|
8
|
+
private static func require(_ cond: Bool, _ msg: @autoclosure () -> String) throws { if !cond { throw UpdateError(msg()) } }
|
|
9
|
+
|
|
10
|
+
private static func request(_ url: String, etag: String?) throws -> URLRequest {
|
|
11
|
+
guard let u = URL(string: url) else { throw UpdateError("bad url \(url)") }
|
|
12
|
+
var r = URLRequest(url: u, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
|
|
13
|
+
if let etag { r.setValue(etag, forHTTPHeaderField: "If-None-Match") }
|
|
14
|
+
return r
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private static func resolve(_ s: Store, _ url: String) -> String { url.hasPrefix("http") ? url : "\(s.baseUrl)/\(url.drop(while: { $0 == "/" }))" }
|
|
18
|
+
|
|
19
|
+
static func check(_ s: Store) async throws -> [String: Any] {
|
|
20
|
+
try require(!s.baseUrl.isEmpty && !s.publicKey.isEmpty && !s.appId.isEmpty, "runtime not configured")
|
|
21
|
+
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))
|
|
23
|
+
let code = (resp as? HTTPURLResponse)?.statusCode ?? 0
|
|
24
|
+
if code == 304 || code == 404 { return ["status": "none"] }
|
|
25
|
+
try require(code == 200, "pointer HTTP \(code)")
|
|
26
|
+
guard let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
27
|
+
let bodyStr = envelope["body"] as? String, let sig = envelope["signature"] as? String else { throw UpdateError("pointer is not an envelope") }
|
|
28
|
+
|
|
29
|
+
let t0 = Store.now()
|
|
30
|
+
try require(Verify.ed25519(Data(bodyStr.utf8), signatureB64: sig, publicKeyB64: s.publicKey), "pointer signature invalid")
|
|
31
|
+
guard let body = try JSONSerialization.jsonObject(with: Data(bodyStr.utf8)) as? [String: Any] else { throw UpdateError("pointer body unreadable") }
|
|
32
|
+
try require(body["platform"] as? String == "ios", "pointer is not for iOS")
|
|
33
|
+
try require(body["app_id"] as? String == s.appId && body["shell_major"] as? Int == s.shellMajor, "pointer for another app/shell")
|
|
34
|
+
guard let release = body["release"] as? Int else { throw UpdateError("pointer has no release") }
|
|
35
|
+
if release <= known { return ["status": "none"] }
|
|
36
|
+
try require(!Verify.semverLess(s.shellVersion, body["min_shell_version"] as? String ?? "0.0.0"), "shell too old for release \(release)")
|
|
37
|
+
let verifyPointerMs = (Store.now() - t0) * 1000
|
|
38
|
+
|
|
39
|
+
guard let core = body["core"] as? [String: Any], let wantSha = core["sha256"] as? String,
|
|
40
|
+
let wantSize = (core["size"] as? NSNumber)?.intValue, let url = core["url"] as? String else { throw UpdateError("pointer core incomplete") }
|
|
41
|
+
let tmp = s.dir.appendingPathComponent("dl-\(release).tmp")
|
|
42
|
+
try? FileManager.default.removeItem(at: tmp)
|
|
43
|
+
|
|
44
|
+
let d0 = Store.now()
|
|
45
|
+
let (fileURL, dresp) = try await URLSession.shared.download(for: request(resolve(s, url), etag: nil))
|
|
46
|
+
let dcode = (dresp as? HTTPURLResponse)?.statusCode ?? 0
|
|
47
|
+
try require(dcode == 200, "bundle HTTP \(dcode)")
|
|
48
|
+
try FileManager.default.moveItem(at: fileURL, to: tmp)
|
|
49
|
+
let downloadMs = (Store.now() - d0) * 1000
|
|
50
|
+
|
|
51
|
+
let v0 = Store.now()
|
|
52
|
+
let size = (try? FileManager.default.attributesOfItem(atPath: tmp.path)[.size] as? Int) ?? -1
|
|
53
|
+
let ok = try (size == wantSize && Verify.sha256Hex(file: tmp) == wantSha)
|
|
54
|
+
if !ok { try? FileManager.default.removeItem(at: tmp); s.addEvent("verify_failed:\(release)"); throw UpdateError("bundle hash/size mismatch") }
|
|
55
|
+
let verifyMs = (Store.now() - v0) * 1000 + verifyPointerMs
|
|
56
|
+
|
|
57
|
+
let dest = s.dir.appendingPathComponent("r\(release).hbc")
|
|
58
|
+
try? FileManager.default.removeItem(at: dest)
|
|
59
|
+
try FileManager.default.moveItem(at: tmp, to: dest)
|
|
60
|
+
stage(s, dest: dest, release: release, sha: wantSha, issuedAt: body["issued_at"] as? String)
|
|
61
|
+
return ["status": "staged", "release": release, "bytes": size, "via": "full", "bundleBytes": size,
|
|
62
|
+
"downloadMs": Int(downloadMs), "verifyMs": Int(verifyMs), "issuedAt": body["issued_at"] as? String ?? ""]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Synchronous so the lock is never held across a suspension point.
|
|
66
|
+
private static func stage(_ s: Store, dest: URL, release: Int, sha: String, issuedAt: String?) {
|
|
67
|
+
Store.lock.lock(); defer { Store.lock.unlock() }
|
|
68
|
+
if let p = s.pendingPath, p != dest.path { try? FileManager.default.removeItem(atPath: p) }
|
|
69
|
+
s.pendingPath = dest.path; s.pendingRelease = release; s.trialBoots = 0; s.pendingSha = sha
|
|
70
|
+
if s.binaryVersion == nil { s.binaryVersion = s.installedBinaryVersion }
|
|
71
|
+
s.pendingIssuedAt = issuedAt
|
|
72
|
+
}
|
|
73
|
+
}
|
package/ios/Verify.swift
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import CryptoKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
enum Verify {
|
|
5
|
+
static func ed25519(_ message: Data, signatureB64: String, publicKeyB64: String) -> Bool {
|
|
6
|
+
guard let sig = Data(base64Encoded: signatureB64), let raw = Data(base64Encoded: publicKeyB64),
|
|
7
|
+
let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw) else { return false }
|
|
8
|
+
return key.isValidSignature(sig, for: message)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
static func sha256Hex(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
|
|
12
|
+
|
|
13
|
+
static func sha256Hex(file url: URL) throws -> String {
|
|
14
|
+
let h = try FileHandle(forReadingFrom: url); defer { try? h.close() }
|
|
15
|
+
var hasher = SHA256()
|
|
16
|
+
while let chunk = try h.read(upToCount: 64 * 1024), !chunk.isEmpty { hasher.update(data: chunk) }
|
|
17
|
+
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// a < b for x.y.z
|
|
21
|
+
static func semverLess(_ a: String, _ b: String) -> Bool {
|
|
22
|
+
func parts(_ s: String) -> [Int] { s.split(separator: "-")[0].split(separator: ".").map { Int($0) ?? 0 } }
|
|
23
|
+
let pa = parts(a), pb = parts(b)
|
|
24
|
+
for i in 0..<3 {
|
|
25
|
+
let x = i < pa.count ? pa[i] : 0, y = i < pb.count ? pb[i] : 0
|
|
26
|
+
if x != y { return x < y }
|
|
27
|
+
}
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipvo/runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"src",
|
|
20
20
|
"android",
|
|
21
|
+
"ios",
|
|
21
22
|
"app.plugin.js",
|
|
22
23
|
"expo-module.config.json",
|
|
23
24
|
"README.md"
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type RuntimeState = {
|
|
|
8
8
|
};
|
|
9
9
|
export type CheckResult = {
|
|
10
10
|
status: "none" | "staged" | "error"; release?: number; bytes?: number; downloadMs?: number; verifyMs?: number;
|
|
11
|
-
/** Shell 1.0.2
|
|
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
14
|
};
|
|
@@ -27,8 +27,8 @@ type Native = {
|
|
|
27
27
|
reload(): void;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
-
// The
|
|
31
|
-
// knows its control plane and app id from
|
|
30
|
+
// The native runtime exists on Android and iOS. Where the module is missing (web, Expo Go, a shell built without it) the
|
|
31
|
+
// app runs its embedded bundle and live content, and still knows its control plane and app id from expo.extra.shipvo.
|
|
32
32
|
const t0 = Date.now();
|
|
33
33
|
let extra: any = {};
|
|
34
34
|
try { extra = require("expo-constants").default?.expoConfig?.extra?.shipvo ?? {}; } catch {}
|