@shipvo/runtime 0.1.2 → 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 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 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.
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" }]] } }
@@ -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
- /// iOS downloads full bundles only for now; a pointer's `patches` are ignored (an Android-only optimisation).
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
- 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
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": size, "via": "full", "bundleBytes": size,
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.2",
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",