@shipvo/runtime 0.1.1 → 0.1.3
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/ios/Patch.swift +70 -0
- package/ios/Updater.swift +40 -7
- package/package.json +8 -4
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 and iOS. iOS
|
|
8
|
+
- Android and iOS. iOS applies patches from the bundle it is running (not yet from the app's embedded bundle) and downloads the full bundle otherwise. It 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/ios/Patch.swift
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// bspatch for the standard BSDIFF40 format (same as Android `Patch.kt`): a 32-byte header, then bzip2 ctrl, diff and extra blocks.
|
|
4
|
+
/// bzip2 comes from the system libbz2 (present in the iOS SDK and on macOS), loaded at run time so no build setting changes.
|
|
5
|
+
enum Patch {
|
|
6
|
+
private typealias BuffDecompress = @convention(c) (UnsafeMutablePointer<CChar>?, UnsafeMutablePointer<UInt32>?, UnsafeMutablePointer<CChar>?, UInt32, Int32, Int32) -> Int32
|
|
7
|
+
|
|
8
|
+
private static let decompressFn: BuffDecompress? = {
|
|
9
|
+
let handle = dlopen("libbz2.1.0.dylib", RTLD_NOW) ?? dlopen("libbz2.so.1.0", RTLD_NOW) ?? dlopen(nil, RTLD_NOW)
|
|
10
|
+
guard let h = handle, let sym = dlsym(h, "BZ2_bzBuffToBuffDecompress") else { return nil }
|
|
11
|
+
return unsafeBitCast(sym, to: BuffDecompress.self)
|
|
12
|
+
}()
|
|
13
|
+
|
|
14
|
+
private static let maxOutput = 64 << 20
|
|
15
|
+
|
|
16
|
+
private static func offtin(_ b: [UInt8], _ o: Int) -> Int64 {
|
|
17
|
+
var y = Int64(b[o + 7] & 0x7F)
|
|
18
|
+
for i in stride(from: 6, through: 0, by: -1) { y = (y << 8) | Int64(b[o + i]) }
|
|
19
|
+
return (b[o + 7] & 0x80) != 0 ? -y : y
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/// Decompress one whole bzip2 stream. Grows the output buffer until it fits, up to `maxOutput`.
|
|
23
|
+
private static func bunzip(_ src: [UInt8], expectAtLeast: Int) throws -> [UInt8] {
|
|
24
|
+
guard let fn = decompressFn else { throw UpdateError("bzip2 unavailable") }
|
|
25
|
+
var cap = max(expectAtLeast, 4096)
|
|
26
|
+
var input = src
|
|
27
|
+
while true {
|
|
28
|
+
var out = [UInt8](repeating: 0, count: cap)
|
|
29
|
+
var outLen = UInt32(cap)
|
|
30
|
+
let rc: Int32 = out.withUnsafeMutableBytes { o in
|
|
31
|
+
input.withUnsafeMutableBytes { i in
|
|
32
|
+
fn(o.baseAddress!.assumingMemoryBound(to: CChar.self), &outLen, i.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(i.count), 0, 0)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if rc == 0 { return Array(out[0..<Int(outLen)]) }
|
|
36
|
+
if rc == -8 /* BZ_OUTBUFF_FULL */ && cap < maxOutput * 4 { cap *= 4; continue }
|
|
37
|
+
throw UpdateError("patch block is not valid bzip2 (\(rc))")
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
static func apply(old: Data, patch: Data) throws -> Data {
|
|
42
|
+
let p = [UInt8](patch)
|
|
43
|
+
guard p.count >= 32, String(decoding: p[0..<8], as: UTF8.self) == "BSDIFF40" else { throw UpdateError("not a BSDIFF40 patch") }
|
|
44
|
+
let ctrlLen = offtin(p, 8), diffLen = offtin(p, 16), newSize = offtin(p, 24)
|
|
45
|
+
guard ctrlLen >= 0, diffLen >= 0, newSize >= 0, newSize <= Int64(maxOutput), 32 + ctrlLen + diffLen <= Int64(p.count) else { throw UpdateError("bad patch header") }
|
|
46
|
+
let n = Int(newSize), c0 = 32, d0 = 32 + Int(ctrlLen), e0 = d0 + Int(diffLen)
|
|
47
|
+
let ctrl = try bunzip(Array(p[c0..<d0]), expectAtLeast: 24)
|
|
48
|
+
let diff = try bunzip(Array(p[d0..<e0]), expectAtLeast: n)
|
|
49
|
+
let extra = try bunzip(Array(p[e0...]), expectAtLeast: 16)
|
|
50
|
+
let oldBytes = [UInt8](old)
|
|
51
|
+
|
|
52
|
+
var out = [UInt8](repeating: 0, count: n)
|
|
53
|
+
var oldPos: Int64 = 0, newPos = 0, ci = 0, di = 0, ei = 0
|
|
54
|
+
while newPos < n {
|
|
55
|
+
guard ci + 24 <= ctrl.count else { throw UpdateError("patch truncated") }
|
|
56
|
+
let x = offtin(ctrl, ci), y = offtin(ctrl, ci + 8), z = offtin(ctrl, ci + 16)
|
|
57
|
+
ci += 24
|
|
58
|
+
guard x >= 0, y >= 0, Int64(newPos) + x + y <= newSize, di + Int(x) <= diff.count, ei + Int(y) <= extra.count else { throw UpdateError("corrupt patch control") }
|
|
59
|
+
for i in 0..<Int(x) {
|
|
60
|
+
let op = oldPos + Int64(i)
|
|
61
|
+
let base: UInt8 = (op >= 0 && op < Int64(oldBytes.count)) ? oldBytes[Int(op)] : 0
|
|
62
|
+
out[newPos + i] = diff[di + i] &+ base
|
|
63
|
+
}
|
|
64
|
+
di += Int(x); newPos += Int(x); oldPos += x
|
|
65
|
+
for i in 0..<Int(y) { out[newPos + i] = extra[ei + i] }
|
|
66
|
+
ei += Int(y); newPos += Int(y); oldPos += z
|
|
67
|
+
}
|
|
68
|
+
return Data(out)
|
|
69
|
+
}
|
|
70
|
+
}
|
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
|
-
///
|
|
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.
|
|
7
7
|
enum Updater {
|
|
8
8
|
private static func require(_ cond: Bool, _ msg: @autoclosure () -> String) throws { if !cond { throw UpdateError(msg()) } }
|
|
9
9
|
|
|
@@ -41,15 +41,26 @@ enum Updater {
|
|
|
41
41
|
let tmp = s.dir.appendingPathComponent("dl-\(release).tmp")
|
|
42
42
|
try? FileManager.default.removeItem(at: tmp)
|
|
43
43
|
|
|
44
|
+
var via = "full", downloadedBytes = 0
|
|
44
45
|
let d0 = Store.now()
|
|
45
|
-
|
|
46
|
-
let
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
var ready = false
|
|
47
|
+
if let patch = try? await patched(s, core: core, wantSha: wantSha, wantSize: wantSize, to: tmp) {
|
|
48
|
+
ready = true; via = "patch"; downloadedBytes = patch
|
|
49
|
+
}
|
|
50
|
+
var downloadMs = (Store.now() - d0) * 1000
|
|
51
|
+
if !ready {
|
|
52
|
+
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)")
|
|
56
|
+
try? FileManager.default.removeItem(at: tmp)
|
|
57
|
+
try FileManager.default.moveItem(at: fileURL, to: tmp)
|
|
58
|
+
downloadMs += (Store.now() - f0) * 1000
|
|
59
|
+
}
|
|
50
60
|
|
|
51
61
|
let v0 = Store.now()
|
|
52
62
|
let size = (try? FileManager.default.attributesOfItem(atPath: tmp.path)[.size] as? Int) ?? -1
|
|
63
|
+
if via == "full" { downloadedBytes = size }
|
|
53
64
|
let ok = try (size == wantSize && Verify.sha256Hex(file: tmp) == wantSha)
|
|
54
65
|
if !ok { try? FileManager.default.removeItem(at: tmp); s.addEvent("verify_failed:\(release)"); throw UpdateError("bundle hash/size mismatch") }
|
|
55
66
|
let verifyMs = (Store.now() - v0) * 1000 + verifyPointerMs
|
|
@@ -58,10 +69,32 @@ enum Updater {
|
|
|
58
69
|
try? FileManager.default.removeItem(at: dest)
|
|
59
70
|
try FileManager.default.moveItem(at: tmp, to: dest)
|
|
60
71
|
stage(s, dest: dest, release: release, sha: wantSha, issuedAt: body["issued_at"] as? String)
|
|
61
|
-
return ["status": "staged", "release": release, "bytes":
|
|
72
|
+
return ["status": "staged", "release": release, "bytes": downloadedBytes, "via": via, "bundleBytes": size,
|
|
62
73
|
"downloadMs": Int(downloadMs), "verifyMs": Int(verifyMs), "issuedAt": body["issued_at"] as? String ?? ""]
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
/// Try the patch from the bundle this device runs. Writes the rebuilt bundle to `tmp` and returns the patch size,
|
|
77
|
+
/// or throws (nothing left behind) so the caller can fetch the full bundle. The caller still checks the signed hash.
|
|
78
|
+
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),
|
|
80
|
+
let list = core["patches"] as? [[String: Any]],
|
|
81
|
+
let entry = list.first(where: { $0["from_sha256"] as? String == s.currentSha }),
|
|
82
|
+
let purl = entry["url"] as? String, let psha = entry["sha256"] as? String, let psize = (entry["size"] as? NSNumber)?.intValue,
|
|
83
|
+
psize > 0, psize < wantSize else { throw UpdateError("no usable patch") }
|
|
84
|
+
do {
|
|
85
|
+
let (data, resp) = try await URLSession.shared.data(for: request(resolve(s, purl), etag: nil))
|
|
86
|
+
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")
|
|
89
|
+
let new = try Patch.apply(old: old, patch: data)
|
|
90
|
+
try require(new.count == wantSize && Verify.sha256Hex(new) == wantSha, "patched bundle does not match")
|
|
91
|
+
try new.write(to: tmp)
|
|
92
|
+
return psize
|
|
93
|
+
} catch {
|
|
94
|
+
try? FileManager.default.removeItem(at: tmp); s.addEvent("patch_failed"); throw error
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
65
98
|
/// Synchronous so the lock is never held across a suspension point.
|
|
66
99
|
private static func stage(_ s: Store, dest: URL, release: Int, sha: String, issuedAt: String?) {
|
|
67
100
|
Store.lock.lock(); defer { Store.lock.unlock() }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipvo/runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -36,12 +36,16 @@
|
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"shipvo",
|
|
39
|
-
"expo",
|
|
40
39
|
"react-native",
|
|
40
|
+
"expo",
|
|
41
41
|
"ota",
|
|
42
42
|
"over-the-air",
|
|
43
|
-
"updates",
|
|
44
|
-
"
|
|
43
|
+
"ota-updates",
|
|
44
|
+
"codepush-alternative",
|
|
45
|
+
"hermes",
|
|
46
|
+
"ed25519",
|
|
47
|
+
"code-signing",
|
|
48
|
+
"rollback"
|
|
45
49
|
],
|
|
46
50
|
"homepage": "https://github.com/shipvo/shipvo#readme",
|
|
47
51
|
"repository": {
|