@shipvo/capacitor 0.0.2 → 0.0.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/Package.swift ADDED
@@ -0,0 +1,19 @@
1
+ // swift-tools-version:5.9
2
+ // Entry point for Capacitor's Swift Package Manager integration (`cap sync ios` links this package).
3
+ // The sources live in ios/Sources; ios/Package.swift runs the same ShipvoBundle target's tests.
4
+ import PackageDescription
5
+
6
+ let package = Package(
7
+ name: "ShipvoCapacitor",
8
+ platforms: [.iOS(.v15)],
9
+ products: [.library(name: "ShipvoCapacitor", targets: ["ShipvoCapacitor"])],
10
+ dependencies: [.package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")],
11
+ targets: [
12
+ .target(name: "ShipvoBundle", path: "ios/Sources/ShipvoBundle"),
13
+ .target(
14
+ name: "ShipvoCapacitor",
15
+ dependencies: [.product(name: "Capacitor", package: "capacitor-swift-pm"), "ShipvoBundle"],
16
+ path: "ios/Sources/ShipvoCapacitor"
17
+ ),
18
+ ]
19
+ )
@@ -0,0 +1,71 @@
1
+ #if canImport(CryptoKit)
2
+ import CryptoKit
3
+ #else
4
+ import Crypto
5
+ #endif
6
+ import Foundation
7
+
8
+ public enum BundleError: Error, Equatable {
9
+ case badSignature, badPointer, badHash, badStorage, shellTooOld
10
+ /// Same codes as `expect` in test-vectors/bundle.json.
11
+ public var code: String {
12
+ switch self {
13
+ case .badSignature: return "bad_signature"
14
+ case .badPointer: return "bad_pointer"
15
+ case .badHash: return "bad_hash"
16
+ case .badStorage: return "bad_storage"
17
+ case .shellTooOld: return "shell_too_old"
18
+ }
19
+ }
20
+ }
21
+
22
+ /// Fields of a signed web release pointer that the bundle check reads.
23
+ struct Pointer: Decodable {
24
+ struct Core: Decodable { let sha256: String; let size: Int }
25
+ let kind: String, platform: String?, app_id: String, shell_major: Int, band: String, release: Int, min_shell_version: String, core: Core
26
+ }
27
+
28
+ public struct Identity {
29
+ public var appId: String, shellMajor: Int, band: String
30
+ /// Installed app version, x.y.z.
31
+ public var shellVersion: String
32
+ /// Ed25519 public key (base64) built into the app. Never read from the network.
33
+ public var publicKey: String
34
+ public init(appId: String, shellMajor: Int, band: String, shellVersion: String, publicKey: String) {
35
+ self.appId = appId; self.shellMajor = shellMajor; self.band = band; self.shellVersion = shellVersion; self.publicKey = publicKey
36
+ }
37
+ }
38
+
39
+ /// a < b for x.y.z. Pre-release tags are ignored. Same as `semverLess` in the TypeScript core.
40
+ func semverLess(_ a: String, _ b: String) -> Bool {
41
+ func parts(_ v: String) -> [Int] { String(v.split(separator: "-", omittingEmptySubsequences: false).first ?? "").split(separator: ".", omittingEmptySubsequences: false).map { Int($0) ?? 0 } }
42
+ let pa = parts(a), pb = parts(b)
43
+ for i in 0..<3 {
44
+ let x = i < pa.count ? pa[i] : 0, y = i < pb.count ? pb[i] : 0
45
+ if x != y { return x < y }
46
+ }
47
+ return false
48
+ }
49
+
50
+ public enum BundleVerify {
51
+ public static func sha256Hex(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
52
+
53
+ static func signatureOK(body: String, signature: String, publicKey: String) -> Bool {
54
+ guard let sig = Data(base64Encoded: signature), let raw = Data(base64Encoded: publicKey),
55
+ let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw) else { return false }
56
+ return key.isValidSignature(sig, for: Data(body.utf8))
57
+ }
58
+
59
+ /// Same order as the TypeScript core: signature over the exact body bytes first, then the body, then the minimum shell version, then size and sha-256.
60
+ /// Returns the release number. Any failure throws and nothing is stored.
61
+ public static func check(body: String, signature: String, archive: Data, identity: Identity) throws -> Int {
62
+ guard signatureOK(body: body, signature: signature, publicKey: identity.publicKey) else { throw BundleError.badSignature }
63
+ guard let p = try? JSONDecoder().decode(Pointer.self, from: Data(body.utf8)),
64
+ p.kind == "web", (p.platform ?? "android") == "ios", p.app_id == identity.appId,
65
+ p.shell_major == identity.shellMajor, p.band == identity.band, p.release > 0 else { throw BundleError.badPointer }
66
+ // Rule 5: minimum shell version.
67
+ if semverLess(identity.shellVersion, p.min_shell_version) { throw BundleError.shellTooOld }
68
+ guard archive.count == p.core.size, sha256Hex(archive) == p.core.sha256 else { throw BundleError.badHash }
69
+ return p.release
70
+ }
71
+ }
@@ -0,0 +1,120 @@
1
+ import Foundation
2
+
3
+ /// Bundles under `<root>/bundles/release-<n>/bundle.zip` and release state in `<root>/state.json`.
4
+ /// Same release rules as the TypeScript core (10, 11, 12, 14): three trial boots for a pending release,
5
+ /// more than three boots in a minute reverts the current one, rollback blocks it.
6
+ public final class BundleStore {
7
+ public static let maxBoots = 3
8
+ public static let windowMs = 60_000
9
+
10
+ struct State: Codable {
11
+ var current = 0, pending = 0, previous = 0, blocked = 0, pendingBoots = 0
12
+ var bootTimes: [Int] = []
13
+ var hashes: [String: String] = [:]
14
+ }
15
+
16
+ private let root: URL
17
+ private let identity: Identity
18
+ private let now: () -> Int
19
+ private var s: State
20
+
21
+ public init(root: URL, identity: Identity, now: @escaping () -> Int = { Int(Date().timeIntervalSince1970 * 1000) }) throws {
22
+ self.root = root; self.identity = identity; self.now = now
23
+ do { try FileManager.default.createDirectory(at: root.appendingPathComponent("bundles"), withIntermediateDirectories: true) }
24
+ catch { throw BundleError.badStorage }
25
+ // A state file that cannot be read starts clean: no release is trusted without a verified record.
26
+ if let d = try? Data(contentsOf: root.appendingPathComponent("state.json")), let st = try? JSONDecoder().decode(State.self, from: d) { s = st }
27
+ else { s = State() }
28
+ }
29
+
30
+ public var current: Int { s.current }
31
+ public var pending: Int { s.pending }
32
+ public var previous: Int { s.previous }
33
+ public var blocked: Int { s.blocked }
34
+
35
+ public func directory(for release: Int) -> URL { root.appendingPathComponent("bundles/release-\(release)") }
36
+ public func archiveURL(for release: Int) -> URL { directory(for: release).appendingPathComponent("bundle.zip") }
37
+
38
+ /// Verifies, then stores the archive and makes it the pending release. A refused bundle changes nothing.
39
+ @discardableResult
40
+ public func stage(body: String, signature: String, archive: Data) throws -> Int {
41
+ let release: Int
42
+ do { release = try BundleVerify.check(body: body, signature: signature, archive: archive, identity: identity) }
43
+ catch BundleError.badHash {
44
+ // Like the TypeScript core: a release whose size or sha-256 does not match is blocked, so it is not tried again.
45
+ // The signature was valid, so the release number is trusted.
46
+ if let r = signedRelease(body: body, signature: signature) { s.blocked = max(s.blocked, r); try save() }
47
+ throw BundleError.badHash
48
+ }
49
+ if release <= max(s.current, s.pending, s.blocked) { throw BundleError.badPointer }
50
+ let dir = directory(for: release)
51
+ do {
52
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
53
+ try archive.write(to: archiveURL(for: release), options: .atomic)
54
+ } catch { throw BundleError.badStorage }
55
+ let old = s.pending
56
+ s.hashes[String(release)] = BundleVerify.sha256Hex(archive)
57
+ s.pending = release; s.pendingBoots = 0
58
+ if old != 0 && old != release { drop(old) }
59
+ try save()
60
+ return release
61
+ }
62
+
63
+ /// The release number of a pointer whose signature is valid, else nil.
64
+ private func signedRelease(body: String, signature: String) -> Int? {
65
+ struct R: Decodable { let release: Int }
66
+ guard BundleVerify.signatureOK(body: body, signature: signature, publicKey: identity.publicKey),
67
+ let r = try? JSONDecoder().decode(R.self, from: Data(body.utf8)) else { return nil }
68
+ return r.release
69
+ }
70
+
71
+ /// The release to serve now, or 0 for the embedded bundle. The stored hash is re-checked before a file is chosen.
72
+ public func boot() throws -> Int {
73
+ let t = now()
74
+ if s.pending != 0 {
75
+ if s.pendingBoots >= Self.maxBoots {
76
+ blockAndDrop(s.pending); s.pending = 0; s.pendingBoots = 0
77
+ } else if intact(s.pending) {
78
+ s.pendingBoots += 1; try save(); return s.pending
79
+ } else { blockAndDrop(s.pending); s.pending = 0; s.pendingBoots = 0 }
80
+ }
81
+ if s.current != 0 {
82
+ s.bootTimes = s.bootTimes.filter { t - $0 < Self.windowMs } + [t]
83
+ if s.bootTimes.count > Self.maxBoots || !intact(s.current) {
84
+ blockAndDrop(s.current); s.current = s.previous; s.previous = 0; s.bootTimes = []
85
+ }
86
+ }
87
+ try save()
88
+ return s.current
89
+ }
90
+
91
+ /// Rule 12: the pending release that is running becomes current.
92
+ public func markHealthy(running: Int) throws {
93
+ guard s.pending != 0, s.pending == running else { return }
94
+ if s.previous != 0 { drop(s.previous) }
95
+ s.previous = s.current; s.current = s.pending; s.pending = 0; s.pendingBoots = 0; s.bootTimes = []
96
+ try save()
97
+ }
98
+
99
+ /// Rule 14: drop pending, block the current release, go back to the previous one.
100
+ public func rollback() throws {
101
+ if s.pending != 0 { drop(s.pending); s.pending = 0; s.pendingBoots = 0 }
102
+ if s.current != 0 { blockAndDrop(s.current) }
103
+ s.current = s.previous; s.previous = 0; s.bootTimes = []
104
+ try save()
105
+ }
106
+
107
+ private func intact(_ release: Int) -> Bool {
108
+ guard let d = try? Data(contentsOf: archiveURL(for: release)) else { return false }
109
+ return BundleVerify.sha256Hex(d) == s.hashes[String(release)]
110
+ }
111
+ private func blockAndDrop(_ release: Int) { s.blocked = max(s.blocked, release); drop(release) }
112
+ private func drop(_ release: Int) {
113
+ s.hashes[String(release)] = nil
114
+ try? FileManager.default.removeItem(at: directory(for: release))
115
+ }
116
+ private func save() throws {
117
+ do { try JSONEncoder().encode(s).write(to: root.appendingPathComponent("state.json"), options: .atomic) }
118
+ catch { throw BundleError.badStorage }
119
+ }
120
+ }
@@ -0,0 +1,116 @@
1
+ import Capacitor
2
+ import Foundation
3
+ import ShipvoBundle
4
+
5
+ /// The native half of @shipvo/capacitor on iOS. Same plugin name (`Shipvo`), methods and results as the Android plugin
6
+ /// (`ShipvoNativePlugin` in src/native.ts). It backs the core's key-value store and bundle files, and points the web view
7
+ /// at an unpacked bundle. It makes no release decisions: the TypeScript core verifies the signature and hash, and this
8
+ /// class checks the hash it is given again before it unpacks.
9
+ @objc(ShipvoPlugin)
10
+ public class ShipvoPlugin: CAPPlugin, CAPBridgedPlugin {
11
+ public let identifier = "ShipvoPlugin"
12
+ public let jsName = "Shipvo"
13
+ public let pluginMethods: [CAPPluginMethod] = [
14
+ CAPPluginMethod(name: "kvGet", returnType: CAPPluginReturnPromise),
15
+ CAPPluginMethod(name: "kvSet", returnType: CAPPluginReturnPromise),
16
+ CAPPluginMethod(name: "fileWrite", returnType: CAPPluginReturnPromise),
17
+ CAPPluginMethod(name: "fileRead", returnType: CAPPluginReturnPromise),
18
+ CAPPluginMethod(name: "fileRemove", returnType: CAPPluginReturnPromise),
19
+ CAPPluginMethod(name: "fileList", returnType: CAPPluginReturnPromise),
20
+ CAPPluginMethod(name: "fileClear", returnType: CAPPluginReturnPromise),
21
+ CAPPluginMethod(name: "serve", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "status", returnType: CAPPluginReturnPromise),
23
+ ]
24
+
25
+ private let defaults = UserDefaults(suiteName: "shipvo") ?? .standard
26
+ // Instance state, like the Android plugin: it lasts as long as the bridge, and a new bridge starts on the embedded assets.
27
+ private var serving: String?
28
+ private var servingPath: String?
29
+
30
+ private lazy var root: URL = {
31
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("shipvo")
32
+ try? FileManager.default.createDirectory(at: base.appendingPathComponent("bundles"), withIntermediateDirectories: true)
33
+ return base
34
+ }()
35
+ private var bundles: URL { root.appendingPathComponent("bundles") }
36
+ private var served: URL { root.appendingPathComponent("served") }
37
+
38
+ private func file(_ name: String?) throws -> URL {
39
+ guard let n = name, n.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil, !n.contains("..") else {
40
+ throw Unzip.Failure(message: "bad file name")
41
+ }
42
+ return bundles.appendingPathComponent(n)
43
+ }
44
+
45
+ @objc func kvGet(_ call: CAPPluginCall) {
46
+ guard let key = call.getString("key"), let v = defaults.string(forKey: key) else { call.resolve([:]); return }
47
+ call.resolve(["value": v])
48
+ }
49
+
50
+ @objc func kvSet(_ call: CAPPluginCall) {
51
+ guard let key = call.getString("key"), let value = call.getString("value") else { call.reject("key and value are required"); return }
52
+ defaults.set(value, forKey: key)
53
+ // The boot counter must be on disk before the page runs.
54
+ defaults.synchronize()
55
+ call.resolve()
56
+ }
57
+
58
+ @objc func fileWrite(_ call: CAPPluginCall) {
59
+ do {
60
+ guard let data = Data(base64Encoded: call.getString("data") ?? "") else { throw Unzip.Failure(message: "bad data") }
61
+ try data.write(to: try file(call.getString("name")), options: .atomic)
62
+ call.resolve()
63
+ } catch { call.reject("could not write: \(error.localizedDescription)") }
64
+ }
65
+
66
+ @objc func fileRead(_ call: CAPPluginCall) {
67
+ do { call.resolve(["data": try Data(contentsOf: try file(call.getString("name"))).base64EncodedString()]) }
68
+ catch { call.reject(error.localizedDescription) }
69
+ }
70
+
71
+ @objc func fileRemove(_ call: CAPPluginCall) {
72
+ do { try? FileManager.default.removeItem(at: try file(call.getString("name"))); call.resolve() }
73
+ catch { call.reject(error.localizedDescription) }
74
+ }
75
+
76
+ @objc func fileList(_ call: CAPPluginCall) {
77
+ let names = ((try? FileManager.default.contentsOfDirectory(atPath: bundles.path)) ?? []).filter { !$0.hasSuffix(".tmp") }.sorted()
78
+ call.resolve(["names": names])
79
+ }
80
+
81
+ @objc func fileClear(_ call: CAPPluginCall) {
82
+ for n in (try? FileManager.default.contentsOfDirectory(atPath: bundles.path)) ?? [] { try? FileManager.default.removeItem(at: bundles.appendingPathComponent(n)) }
83
+ try? FileManager.default.removeItem(at: served)
84
+ serving = nil; servingPath = nil
85
+ call.resolve()
86
+ }
87
+
88
+ /// Serve a stored bundle. `sha256` is the hash the core verified; it is checked again before unpacking.
89
+ @objc func serve(_ call: CAPPluginCall) {
90
+ do {
91
+ let name = call.getString("name")
92
+ let zip = try Data(contentsOf: try file(name))
93
+ guard let want = call.getString("sha256"), BundleVerify.sha256Hex(zip).lowercased() == want.lowercased() else {
94
+ throw Unzip.Failure(message: "bundle hash does not match")
95
+ }
96
+ try? FileManager.default.removeItem(at: served)
97
+ let target = served.appendingPathComponent(name!)
98
+ try FileManager.default.createDirectory(at: target, withIntermediateDirectories: true)
99
+ do {
100
+ try Unzip.extract(zip, to: target)
101
+ guard FileManager.default.fileExists(atPath: target.appendingPathComponent("index.html").path) else { throw Unzip.Failure(message: "bundle has no index.html") }
102
+ } catch { try? FileManager.default.removeItem(at: served); throw error }
103
+ DispatchQueue.main.async {
104
+ guard let vc = self.bridge?.viewController as? CAPBridgeViewController else { call.reject("no web view to point at the bundle"); return }
105
+ vc.setServerBasePath(path: target.path)
106
+ self.serving = name; self.servingPath = target.path
107
+ call.resolve()
108
+ }
109
+ } catch { call.reject(error.localizedDescription) }
110
+ }
111
+
112
+ /// Which bundle this process is already serving, so the page it loads does not boot again.
113
+ @objc func status(_ call: CAPPluginCall) {
114
+ if let s = serving, servingPath != nil { call.resolve(["serving": s]) } else { call.resolve([:]) }
115
+ }
116
+ }
@@ -0,0 +1,50 @@
1
+ import Compression
2
+ import Foundation
3
+
4
+ /// Reads a zip archive held in memory (stored and deflate entries). Entries that would leave the target directory are refused.
5
+ enum Unzip {
6
+ struct Failure: Error, LocalizedError { let message: String; var errorDescription: String? { message } }
7
+ static let maxEntryBytes = 256 * 1024 * 1024
8
+
9
+ static func extract(_ zip: Data, to target: URL) throws {
10
+ let b = [UInt8](zip)
11
+ func u16(_ o: Int) -> Int { Int(b[o]) | Int(b[o + 1]) << 8 }
12
+ func u32(_ o: Int) -> Int { u16(o) | u16(o + 2) << 16 }
13
+ guard b.count >= 22 else { throw Failure(message: "bad entry in bundle") }
14
+ // End of central directory record: the last 22+ bytes, signature 0x06054b50.
15
+ var eocd = -1
16
+ var i = b.count - 22
17
+ while i >= max(0, b.count - 22 - 65_535) { if u32(i) == 0x0605_4b50 { eocd = i; break }; i -= 1 }
18
+ guard eocd >= 0 else { throw Failure(message: "bad entry in bundle") }
19
+ let count = u16(eocd + 10)
20
+ var p = u32(eocd + 16)
21
+ let root = target.standardizedFileURL.path + "/"
22
+ let fm = FileManager.default
23
+ for _ in 0..<count {
24
+ guard p + 46 <= b.count, u32(p) == 0x0201_4b50 else { throw Failure(message: "bad entry in bundle") }
25
+ let method = u16(p + 10), csize = u32(p + 20), usize = u32(p + 24)
26
+ let nlen = u16(p + 28), elen = u16(p + 30), clen = u16(p + 32), local = u32(p + 42)
27
+ guard p + 46 + nlen <= b.count, local + 30 <= b.count, u32(local) == 0x0403_4b50, usize <= maxEntryBytes else { throw Failure(message: "bad entry in bundle") }
28
+ let name = String(decoding: b[(p + 46)..<(p + 46 + nlen)], as: UTF8.self)
29
+ p += 46 + nlen + elen + clen
30
+ let out = target.appendingPathComponent(name).standardizedFileURL
31
+ guard out.path.hasPrefix(root) else { throw Failure(message: "bad entry in bundle") }
32
+ if name.hasSuffix("/") { try fm.createDirectory(at: out, withIntermediateDirectories: true); continue }
33
+ let start = local + 30 + u16(local + 26) + u16(local + 28)
34
+ guard start + csize <= b.count else { throw Failure(message: "bad entry in bundle") }
35
+ let raw = Data(b[start..<(start + csize)])
36
+ var data: Data
37
+ if method == 0 { data = raw }
38
+ else if method == 8 {
39
+ var dst = [UInt8](repeating: 0, count: max(usize, 1))
40
+ let n = raw.withUnsafeBytes { src in
41
+ compression_decode_buffer(&dst, dst.count, src.bindMemory(to: UInt8.self).baseAddress!, raw.count, nil, COMPRESSION_ZLIB)
42
+ }
43
+ guard n == usize else { throw Failure(message: "bad entry in bundle") }
44
+ data = Data(dst.prefix(n))
45
+ } else { throw Failure(message: "bad entry in bundle") }
46
+ try fm.createDirectory(at: out.deletingLastPathComponent(), withIntermediateDirectories: true)
47
+ try data.write(to: out)
48
+ }
49
+ }
50
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@shipvo/capacitor",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Signed over-the-air updates for Capacitor web bundles: the same release decisions and trust model as the Shipvo React Native runtime.",
7
7
  "main": "dist/index.js",
8
8
  "types": "dist/index.d.ts",
9
9
  "dependencies": {
10
- "@shipvo/schema": "^0.0.6"
10
+ "@shipvo/schema": "^0.0.7"
11
11
  },
12
12
  "devDependencies": {
13
13
  "@types/node": "^22.8.0",
@@ -33,6 +33,8 @@
33
33
  "dist",
34
34
  "android/build.gradle",
35
35
  "android/src/main",
36
+ "Package.swift",
37
+ "ios/Sources",
36
38
  "README.md"
37
39
  ],
38
40
  "publishConfig": {
@@ -42,6 +44,11 @@
42
44
  "engines": {
43
45
  "node": ">=20"
44
46
  },
47
+ "capacitor": {
48
+ "ios": {
49
+ "src": "ios"
50
+ }
51
+ },
45
52
  "scripts": {
46
53
  "build": "tsc -p tsconfig.json",
47
54
  "test": "vitest run"