@otakit/capacitor-updater 1.0.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +183 -0
  3. package/UpdatekitUpdater.podspec +18 -0
  4. package/android/build.gradle +49 -0
  5. package/android/src/main/AndroidManifest.xml +3 -0
  6. package/android/src/main/java/com/updatekit/updater/BundleInfo.java +100 -0
  7. package/android/src/main/java/com/updatekit/updater/BundleStatus.java +28 -0
  8. package/android/src/main/java/com/updatekit/updater/BundleStore.java +264 -0
  9. package/android/src/main/java/com/updatekit/updater/DateUtils.java +17 -0
  10. package/android/src/main/java/com/updatekit/updater/HashUtils.java +32 -0
  11. package/android/src/main/java/com/updatekit/updater/HostedManifestKeys.java +37 -0
  12. package/android/src/main/java/com/updatekit/updater/ManifestClient.java +209 -0
  13. package/android/src/main/java/com/updatekit/updater/ManifestVerifier.java +146 -0
  14. package/android/src/main/java/com/updatekit/updater/StatsClient.java +80 -0
  15. package/android/src/main/java/com/updatekit/updater/UpdaterPlugin.java +963 -0
  16. package/android/src/main/java/com/updatekit/updater/ZipUtils.java +72 -0
  17. package/dist/esm/definitions.d.ts +229 -0
  18. package/dist/esm/definitions.d.ts.map +1 -0
  19. package/dist/esm/definitions.js +17 -0
  20. package/dist/esm/definitions.js.map +1 -0
  21. package/dist/esm/index.d.ts +5 -0
  22. package/dist/esm/index.d.ts.map +1 -0
  23. package/dist/esm/index.js +85 -0
  24. package/dist/esm/index.js.map +1 -0
  25. package/dist/esm/web.d.ts +25 -0
  26. package/dist/esm/web.d.ts.map +1 -0
  27. package/dist/esm/web.js +56 -0
  28. package/dist/esm/web.js.map +1 -0
  29. package/dist/plugin.cjs.js +165 -0
  30. package/dist/plugin.cjs.js.map +1 -0
  31. package/dist/plugin.js +168 -0
  32. package/dist/plugin.js.map +1 -0
  33. package/ios/Sources/UpdaterPlugin/BundleInfo.swift +39 -0
  34. package/ios/Sources/UpdaterPlugin/BundleStatus.swift +9 -0
  35. package/ios/Sources/UpdaterPlugin/BundleStore.swift +233 -0
  36. package/ios/Sources/UpdaterPlugin/Downloader.swift +120 -0
  37. package/ios/Sources/UpdaterPlugin/HashUtils.swift +33 -0
  38. package/ios/Sources/UpdaterPlugin/HostedManifestKeys.swift +23 -0
  39. package/ios/Sources/UpdaterPlugin/ManifestClient.swift +161 -0
  40. package/ios/Sources/UpdaterPlugin/ManifestVerifier.swift +115 -0
  41. package/ios/Sources/UpdaterPlugin/StatsClient.swift +66 -0
  42. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.m +15 -0
  43. package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +913 -0
  44. package/ios/Sources/UpdaterPlugin/ZipUtils.swift +90 -0
  45. package/package.json +85 -0
@@ -0,0 +1,120 @@
1
+ import Foundation
2
+
3
+ final class Downloader {
4
+ private let allowInsecureUrls: Bool
5
+
6
+ init(allowInsecureUrls: Bool = false) {
7
+ self.allowInsecureUrls = allowInsecureUrls
8
+ }
9
+
10
+ func download(
11
+ from url: URL,
12
+ progress: @escaping (Double, Int64, Int64) -> Void = { _, _, _ in }
13
+ ) async throws -> URL {
14
+ try ManifestClient.requireHTTPS(url: url, allowInsecure: allowInsecureUrls)
15
+ let delegate = DownloadDelegate(progressHandler: progress)
16
+ return try await delegate.start(from: url)
17
+ }
18
+ }
19
+
20
+ private final class DownloadDelegate: NSObject, URLSessionDownloadDelegate {
21
+ private var continuation: CheckedContinuation<URL, Error>?
22
+ private var session: URLSession?
23
+ private let progressHandler: (Double, Int64, Int64) -> Void
24
+ private let stateLock = NSLock()
25
+ private var isResolved = false
26
+
27
+ init(progressHandler: @escaping (Double, Int64, Int64) -> Void) {
28
+ self.progressHandler = progressHandler
29
+ super.init()
30
+ }
31
+
32
+ func start(from url: URL) async throws -> URL {
33
+ try await withCheckedThrowingContinuation { continuation in
34
+ self.continuation = continuation
35
+
36
+ let configuration = URLSessionConfiguration.default
37
+ let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
38
+ self.session = session
39
+ let task = session.downloadTask(with: url)
40
+ task.resume()
41
+ }
42
+ }
43
+
44
+ private func resolve(_ result: Result<URL, Error>) {
45
+ stateLock.lock()
46
+ guard !isResolved, let continuation else {
47
+ stateLock.unlock()
48
+ return
49
+ }
50
+ isResolved = true
51
+ self.continuation = nil
52
+ stateLock.unlock()
53
+
54
+ switch result {
55
+ case let .success(url):
56
+ continuation.resume(returning: url)
57
+ case let .failure(error):
58
+ continuation.resume(throwing: error)
59
+ }
60
+ }
61
+
62
+ func urlSession(
63
+ _ session: URLSession,
64
+ downloadTask: URLSessionDownloadTask,
65
+ didFinishDownloadingTo location: URL
66
+ ) {
67
+ // Check HTTP status before treating the file as a valid download
68
+ if let httpResponse = downloadTask.response as? HTTPURLResponse,
69
+ !(200..<300).contains(httpResponse.statusCode) {
70
+ resolve(.failure(NSError(
71
+ domain: "Downloader",
72
+ code: httpResponse.statusCode,
73
+ userInfo: [NSLocalizedDescriptionKey: "Download failed with HTTP \(httpResponse.statusCode)"]
74
+ )))
75
+ session.finishTasksAndInvalidate()
76
+ self.session = nil
77
+ return
78
+ }
79
+
80
+ let temporaryZip = FileManager.default.temporaryDirectory
81
+ .appendingPathComponent("updatekit-\(UUID().uuidString).zip")
82
+
83
+ do {
84
+ try FileManager.default.moveItem(at: location, to: temporaryZip)
85
+ resolve(.success(temporaryZip))
86
+ } catch {
87
+ resolve(.failure(error))
88
+ }
89
+
90
+ session.finishTasksAndInvalidate()
91
+ self.session = nil
92
+ }
93
+
94
+ func urlSession(
95
+ _ session: URLSession,
96
+ downloadTask: URLSessionDownloadTask,
97
+ didWriteData bytesWritten: Int64,
98
+ totalBytesWritten: Int64,
99
+ totalBytesExpectedToWrite: Int64
100
+ ) {
101
+ guard totalBytesExpectedToWrite > 0 else {
102
+ return
103
+ }
104
+ let percent = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100
105
+ progressHandler(percent, totalBytesWritten, totalBytesExpectedToWrite)
106
+ }
107
+
108
+ func urlSession(
109
+ _ session: URLSession,
110
+ task: URLSessionTask,
111
+ didCompleteWithError error: Error?
112
+ ) {
113
+ if let error {
114
+ resolve(.failure(error))
115
+ }
116
+
117
+ session.finishTasksAndInvalidate()
118
+ self.session = nil
119
+ }
120
+ }
@@ -0,0 +1,33 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ enum HashUtilsError: Error {
5
+ case couldNotOpenFile
6
+ }
7
+
8
+ enum HashUtils {
9
+ static func sha256(fileURL: URL) throws -> String {
10
+ guard let handle = try? FileHandle(forReadingFrom: fileURL) else {
11
+ throw HashUtilsError.couldNotOpenFile
12
+ }
13
+ defer { try? handle.close() }
14
+
15
+ var hasher = SHA256()
16
+
17
+ while autoreleasepool(invoking: {
18
+ let data = handle.readData(ofLength: 1024 * 1024)
19
+ if data.isEmpty {
20
+ return false
21
+ }
22
+ hasher.update(data: data)
23
+ return true
24
+ }) {}
25
+
26
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
27
+ }
28
+
29
+ static func verify(fileURL: URL, expectedSha256: String) throws -> Bool {
30
+ let actual = try sha256(fileURL: fileURL)
31
+ return actual.lowercased() == expectedSha256.lowercased()
32
+ }
33
+ }
@@ -0,0 +1,23 @@
1
+ import Foundation
2
+
3
+ enum HostedManifestKeys {
4
+ static let managedServerURL = "https://www.otakit.app/api/v1"
5
+
6
+ static let defaults: [(kid: String, key: Data)] = [
7
+ (
8
+ kid: "hosted-2026-04-02-ce611e6d",
9
+ key: Data(
10
+ base64Encoded: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAELg6eAj2+7aZ1FJnYUNMjOtWuQLJMomXkPvmeTQ3gXyabpLTDX0m3iWYO3cEOXqIR6NphGC6csS2T5bCtXwIBFw=="
11
+ )!
12
+ )
13
+ ]
14
+
15
+ static func matchesManagedServer(_ updateUrl: String) -> Bool {
16
+ let normalized = updateUrl
17
+ .trimmingCharacters(in: .whitespacesAndNewlines)
18
+ .replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
19
+ .lowercased()
20
+ return normalized == managedServerURL
21
+ || normalized == "https://otakit.app/api/v1"
22
+ }
23
+ }
@@ -0,0 +1,161 @@
1
+ import Foundation
2
+
3
+ struct LatestManifest {
4
+ let version: String
5
+ let url: String
6
+ let sha256: String
7
+ let size: Int
8
+ let minNativeBuild: Int?
9
+ let releaseId: String?
10
+ let signature: ManifestSignature?
11
+ }
12
+
13
+ struct ManifestSignature {
14
+ let kid: String
15
+ let sig: String
16
+ let iat: Int
17
+ let exp: Int
18
+ }
19
+
20
+ enum ManifestClientError: Error {
21
+ case invalidURL
22
+ case invalidResponse
23
+ case requestFailed(String)
24
+ case insecureURL(String)
25
+ }
26
+
27
+ enum ManifestClient {
28
+
29
+ static func requireHTTPS(url: URL, allowInsecure: Bool) throws {
30
+ let scheme = url.scheme?.lowercased() ?? ""
31
+ if scheme == "https" { return }
32
+ if allowInsecure {
33
+ let host = url.host?.lowercased() ?? ""
34
+ if host == "localhost" || host == "127.0.0.1" { return }
35
+ }
36
+ throw ManifestClientError.insecureURL(
37
+ "URL must use HTTPS: \(url.absoluteString)"
38
+ )
39
+ }
40
+
41
+ static func fetchLatest(
42
+ updateUrl: String,
43
+ appId: String,
44
+ channel: String?,
45
+ currentVersion: String,
46
+ currentReleaseId: String?,
47
+ nativeBuild: String,
48
+ platform: String,
49
+ allowInsecureUrls: Bool = false,
50
+ manifestKeys: [ManifestKey] = []
51
+ ) async throws -> LatestManifest? {
52
+ let sanitizedBase = updateUrl.replacingOccurrences(
53
+ of: "/+$",
54
+ with: "",
55
+ options: .regularExpression
56
+ )
57
+
58
+ guard let baseURL = URL(string: sanitizedBase) else {
59
+ throw ManifestClientError.invalidURL
60
+ }
61
+ let url = baseURL.appendingPathComponent("manifest")
62
+
63
+ try requireHTTPS(url: url, allowInsecure: allowInsecureUrls)
64
+
65
+ var request = URLRequest(url: url)
66
+ request.httpMethod = "GET"
67
+ request.setValue(appId, forHTTPHeaderField: "X-App-Id")
68
+ request.setValue(platform, forHTTPHeaderField: "X-Platform")
69
+ if let channel, !channel.isEmpty {
70
+ request.setValue(channel, forHTTPHeaderField: "X-Channel")
71
+ }
72
+ request.setValue(currentVersion, forHTTPHeaderField: "X-Current-Version")
73
+ if let currentReleaseId, !currentReleaseId.isEmpty {
74
+ request.setValue(currentReleaseId, forHTTPHeaderField: "X-Release-Id")
75
+ }
76
+ request.setValue(nativeBuild, forHTTPHeaderField: "X-Native-Build")
77
+ request.timeoutInterval = 30
78
+
79
+ let (data, response) = try await URLSession.shared.data(for: request)
80
+ guard let httpResponse = response as? HTTPURLResponse else {
81
+ throw ManifestClientError.invalidResponse
82
+ }
83
+
84
+ if httpResponse.statusCode == 204 {
85
+ return nil
86
+ }
87
+
88
+ guard httpResponse.statusCode == 200 else {
89
+ let body = String(data: data, encoding: .utf8) ?? "unknown"
90
+ throw ManifestClientError.requestFailed(
91
+ "HTTP \(httpResponse.statusCode): \(body)"
92
+ )
93
+ }
94
+
95
+ guard
96
+ let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
97
+ let version = object["version"] as? String,
98
+ let downloadUrl = object["url"] as? String,
99
+ let sha256 = object["sha256"] as? String,
100
+ let size = object["size"] as? Int
101
+ else {
102
+ throw ManifestClientError.invalidResponse
103
+ }
104
+
105
+ var minNativeBuild: Int?
106
+ if let numeric = object["minNativeBuild"] as? NSNumber {
107
+ minNativeBuild = numeric.intValue
108
+ } else if let stringValue = object["minNativeBuild"] as? String {
109
+ minNativeBuild = Int(stringValue)
110
+ }
111
+
112
+ var signature: ManifestSignature?
113
+ if let sigObj = object["signature"] as? [String: Any],
114
+ let kid = sigObj["kid"] as? String,
115
+ let sig = sigObj["sig"] as? String,
116
+ let iat = sigObj["iat"] as? Int,
117
+ let exp = sigObj["exp"] as? Int {
118
+ signature = ManifestSignature(kid: kid, sig: sig, iat: iat, exp: exp)
119
+ }
120
+
121
+ let releaseId = object["releaseId"] as? String
122
+
123
+ // Validate download URL scheme
124
+ guard let dlURL = URL(string: downloadUrl) else {
125
+ throw ManifestClientError.invalidURL
126
+ }
127
+ try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
128
+
129
+ if manifestKeys.isEmpty {
130
+ print("[UpdateKit] WARNING: No manifest signing keys configured — signature verification is disabled for this request.")
131
+ }
132
+
133
+ // Verify manifest signature if signing keys are configured
134
+ if !manifestKeys.isEmpty {
135
+ guard let sig = signature else {
136
+ throw ManifestVerifierError.missingSignature
137
+ }
138
+ try ManifestVerifier.verify(
139
+ appId: appId,
140
+ channel: channel,
141
+ platform: platform,
142
+ version: version,
143
+ sha256: sha256,
144
+ size: size,
145
+ minNativeBuild: minNativeBuild,
146
+ signature: sig,
147
+ trustedKeys: manifestKeys
148
+ )
149
+ }
150
+
151
+ return LatestManifest(
152
+ version: version,
153
+ url: downloadUrl,
154
+ sha256: sha256,
155
+ size: size,
156
+ minNativeBuild: minNativeBuild,
157
+ releaseId: releaseId,
158
+ signature: signature
159
+ )
160
+ }
161
+ }
@@ -0,0 +1,115 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ struct ManifestKey {
5
+ let kid: String
6
+ let derData: Data
7
+ }
8
+
9
+ enum ManifestVerifierError: Error {
10
+ case unknownKid(String)
11
+ case expired
12
+ case invalidSignature
13
+ case missingSignature
14
+ }
15
+
16
+ enum ManifestVerifier {
17
+
18
+ /// Verify a manifest signature using ES256 (ECDSA P-256 + SHA-256).
19
+ ///
20
+ /// - Parameters:
21
+ /// - appId, channel, platform: Request context (known by plugin).
22
+ /// - version, sha256, size, minNativeBuild: Response fields.
23
+ /// - signature: The signature object from the manifest response.
24
+ /// - trustedKeys: Array of verification keys configured in the plugin.
25
+ ///
26
+ /// - Throws: `ManifestVerifierError` on failure.
27
+ static func verify(
28
+ appId: String,
29
+ channel: String?,
30
+ platform: String,
31
+ version: String,
32
+ sha256: String,
33
+ size: Int,
34
+ minNativeBuild: Int?,
35
+ signature: ManifestSignature,
36
+ trustedKeys: [ManifestKey]
37
+ ) throws {
38
+ // Check expiry
39
+ let now = Int(Date().timeIntervalSince1970)
40
+ guard signature.exp > now else {
41
+ throw ManifestVerifierError.expired
42
+ }
43
+
44
+ // Find matching key
45
+ guard let keyEntry = trustedKeys.first(where: { $0.kid == signature.kid }) else {
46
+ throw ManifestVerifierError.unknownKid(signature.kid)
47
+ }
48
+
49
+ // Build canonical payload (must match server exactly)
50
+ let payload = buildCanonicalPayload(
51
+ appId: appId,
52
+ channel: channel,
53
+ platform: platform,
54
+ version: version,
55
+ sha256: sha256,
56
+ size: size,
57
+ minNativeBuild: minNativeBuild,
58
+ kid: signature.kid,
59
+ iat: signature.iat,
60
+ exp: signature.exp
61
+ )
62
+
63
+ // Decode base64url signature
64
+ guard let sigData = base64UrlDecode(signature.sig) else {
65
+ throw ManifestVerifierError.invalidSignature
66
+ }
67
+
68
+ // Verify with CryptoKit
69
+ let verificationKey = try P256.Signing.PublicKey(derRepresentation: keyEntry.derData)
70
+ let payloadData = Data(payload.utf8)
71
+ let ecdsaSignature = try P256.Signing.ECDSASignature(derRepresentation: sigData)
72
+ guard verificationKey.isValidSignature(ecdsaSignature, for: payloadData) else {
73
+ throw ManifestVerifierError.invalidSignature
74
+ }
75
+ }
76
+
77
+ private static func buildCanonicalPayload(
78
+ appId: String,
79
+ channel: String?,
80
+ platform: String,
81
+ version: String,
82
+ sha256: String,
83
+ size: Int,
84
+ minNativeBuild: Int?,
85
+ kid: String,
86
+ iat: Int,
87
+ exp: Int
88
+ ) -> String {
89
+ let minBuildStr = minNativeBuild.map { String($0) } ?? "null"
90
+ return [
91
+ "MANIFEST_V1",
92
+ "appId:\(appId)",
93
+ "channel:\(channel ?? "null")",
94
+ "platform:\(platform)",
95
+ "version:\(version)",
96
+ "sha256:\(sha256)",
97
+ "size:\(size)",
98
+ "minNativeBuild:\(minBuildStr)",
99
+ "kid:\(kid)",
100
+ "iat:\(iat)",
101
+ "exp:\(exp)",
102
+ ].joined(separator: "\n")
103
+ }
104
+
105
+ private static func base64UrlDecode(_ string: String) -> Data? {
106
+ var base64 = string
107
+ .replacingOccurrences(of: "-", with: "+")
108
+ .replacingOccurrences(of: "_", with: "/")
109
+ let remainder = base64.count % 4
110
+ if remainder > 0 {
111
+ base64 += String(repeating: "=", count: 4 - remainder)
112
+ }
113
+ return Data(base64Encoded: base64)
114
+ }
115
+ }
@@ -0,0 +1,66 @@
1
+ import Foundation
2
+
3
+ enum StatsAction: String {
4
+ case downloaded
5
+ case applied
6
+ case downloadError = "download_error"
7
+ case rollback
8
+ }
9
+
10
+ enum StatsClient {
11
+ static func send(
12
+ updateUrl: String,
13
+ appId: String,
14
+ platform: String,
15
+ action: StatsAction,
16
+ bundleVersion: String?,
17
+ channel: String?,
18
+ releaseId: String?,
19
+ nativeBuild: String?,
20
+ errorMessage: String?
21
+ ) {
22
+ let sanitizedBase = updateUrl.replacingOccurrences(
23
+ of: "/+$",
24
+ with: "",
25
+ options: .regularExpression
26
+ )
27
+
28
+ guard let url = URL(string: "\(sanitizedBase)/stats") else {
29
+ return
30
+ }
31
+
32
+ var payload: [String: Any] = [
33
+ "platform": platform,
34
+ "action": action.rawValue,
35
+ ]
36
+ if let bundleVersion {
37
+ payload["bundleVersion"] = bundleVersion
38
+ }
39
+ if let channel, !channel.isEmpty {
40
+ payload["channel"] = channel
41
+ }
42
+ if let releaseId, !releaseId.isEmpty {
43
+ payload["releaseId"] = releaseId
44
+ }
45
+ if let nativeBuild {
46
+ payload["nativeBuild"] = nativeBuild
47
+ }
48
+ if let errorMessage {
49
+ payload["errorMessage"] = String(errorMessage.prefix(500))
50
+ }
51
+
52
+ guard let body = try? JSONSerialization.data(withJSONObject: payload) else {
53
+ return
54
+ }
55
+
56
+ var request = URLRequest(url: url)
57
+ request.httpMethod = "POST"
58
+ request.setValue(appId, forHTTPHeaderField: "X-App-Id")
59
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
60
+ request.httpBody = body
61
+ request.timeoutInterval = 10
62
+
63
+ // Fire and forget - don't block on response
64
+ URLSession.shared.dataTask(with: request).resume()
65
+ }
66
+ }
@@ -0,0 +1,15 @@
1
+ #import <Capacitor/Capacitor.h>
2
+
3
+ CAP_PLUGIN(UpdaterPlugin, "OtaKit",
4
+ CAP_PLUGIN_METHOD(check, CAPPluginReturnPromise);
5
+ CAP_PLUGIN_METHOD(download, CAPPluginReturnPromise);
6
+ CAP_PLUGIN_METHOD(apply, CAPPluginReturnPromise);
7
+ CAP_PLUGIN_METHOD(debugGetState, CAPPluginReturnPromise);
8
+ CAP_PLUGIN_METHOD(debugCheck, CAPPluginReturnPromise);
9
+ CAP_PLUGIN_METHOD(debugDownload, CAPPluginReturnPromise);
10
+ CAP_PLUGIN_METHOD(notifyAppReady, CAPPluginReturnPromise);
11
+ CAP_PLUGIN_METHOD(debugReset, CAPPluginReturnPromise);
12
+ CAP_PLUGIN_METHOD(debugListBundles, CAPPluginReturnPromise);
13
+ CAP_PLUGIN_METHOD(debugDeleteBundle, CAPPluginReturnPromise);
14
+ CAP_PLUGIN_METHOD(debugGetLastFailure, CAPPluginReturnPromise);
15
+ )