@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.
|
|
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.
|
|
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"
|