@capgo/capacitor-updater 6.34.0 → 6.37.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.
- package/README.md +11 -5
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +60 -8
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +82 -31
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +48 -3
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +19 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +111 -79
- package/dist/docs.json +26 -2
- package/dist/esm/definitions.d.ts +12 -2
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +37 -10
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +79 -22
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +100 -31
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +19 -0
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +5 -0
- package/package.json +2 -1
|
@@ -16,21 +16,25 @@ import Foundation
|
|
|
16
16
|
private let version: String
|
|
17
17
|
private let checksum: String
|
|
18
18
|
private let status: BundleStatus
|
|
19
|
+
private let link: String?
|
|
20
|
+
private let comment: String?
|
|
19
21
|
|
|
20
|
-
convenience init(id: String, version: String, status: BundleStatus, downloaded: Date, checksum: String) {
|
|
21
|
-
self.init(id: id, version: version, status: status, downloaded: downloaded.iso8601withFractionalSeconds, checksum: checksum)
|
|
22
|
+
convenience init(id: String, version: String, status: BundleStatus, downloaded: Date, checksum: String, link: String? = nil, comment: String? = nil) {
|
|
23
|
+
self.init(id: id, version: version, status: status, downloaded: downloaded.iso8601withFractionalSeconds, checksum: checksum, link: link, comment: comment)
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
init(id: String, version: String, status: BundleStatus, downloaded: String = BundleInfo.DOWNLOADED_BUILTIN, checksum: String) {
|
|
26
|
+
init(id: String, version: String, status: BundleStatus, downloaded: String = BundleInfo.DOWNLOADED_BUILTIN, checksum: String, link: String? = nil, comment: String? = nil) {
|
|
25
27
|
self.downloaded = downloaded.trim()
|
|
26
28
|
self.id = id
|
|
27
29
|
self.version = version
|
|
28
30
|
self.checksum = checksum
|
|
29
31
|
self.status = status
|
|
32
|
+
self.link = link
|
|
33
|
+
self.comment = comment
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
enum CodingKeys: String, CodingKey {
|
|
33
|
-
case downloaded, id, version, status, checksum
|
|
37
|
+
case downloaded, id, version, status, checksum, link, comment
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
public func isBuiltin() -> Bool {
|
|
@@ -62,11 +66,11 @@ import Foundation
|
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
public func setChecksum(checksum: String) -> BundleInfo {
|
|
65
|
-
return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: checksum)
|
|
69
|
+
return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: checksum, link: self.link, comment: self.comment)
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
public func setDownloaded(downloaded: Date) -> BundleInfo {
|
|
69
|
-
return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: downloaded, checksum: self.checksum)
|
|
73
|
+
return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
public func getId() -> String {
|
|
@@ -74,7 +78,7 @@ import Foundation
|
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
public func setId(id: String) -> BundleInfo {
|
|
77
|
-
return BundleInfo(id: id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum)
|
|
81
|
+
return BundleInfo(id: id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
|
|
78
82
|
}
|
|
79
83
|
|
|
80
84
|
public func getVersionName() -> String {
|
|
@@ -82,7 +86,7 @@ import Foundation
|
|
|
82
86
|
}
|
|
83
87
|
|
|
84
88
|
public func setVersionName(version: String) -> BundleInfo {
|
|
85
|
-
return BundleInfo(id: self.id, version: version, status: self.status, downloaded: self.downloaded, checksum: self.checksum)
|
|
89
|
+
return BundleInfo(id: self.id, version: version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
|
|
86
90
|
}
|
|
87
91
|
|
|
88
92
|
public func getStatus() -> String {
|
|
@@ -90,17 +94,40 @@ import Foundation
|
|
|
90
94
|
}
|
|
91
95
|
|
|
92
96
|
public func setStatus(status: String) -> BundleInfo {
|
|
93
|
-
return BundleInfo(id: self.id, version: self.version, status: BundleStatus(localizedString: status)!, downloaded: self.downloaded, checksum: self.checksum)
|
|
97
|
+
return BundleInfo(id: self.id, version: self.version, status: BundleStatus(localizedString: status)!, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: self.comment)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public func getLink() -> String? {
|
|
101
|
+
return self.link
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public func setLink(link: String?) -> BundleInfo {
|
|
105
|
+
return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: link, comment: self.comment)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public func getComment() -> String? {
|
|
109
|
+
return self.comment
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public func setComment(comment: String?) -> BundleInfo {
|
|
113
|
+
return BundleInfo(id: self.id, version: self.version, status: self.status, downloaded: self.downloaded, checksum: self.checksum, link: self.link, comment: comment)
|
|
94
114
|
}
|
|
95
115
|
|
|
96
116
|
public func toJSON() -> [String: String] {
|
|
97
|
-
|
|
117
|
+
var result: [String: String] = [
|
|
98
118
|
"id": self.getId(),
|
|
99
119
|
"version": self.getVersionName(),
|
|
100
120
|
"downloaded": self.getDownloaded(),
|
|
101
121
|
"checksum": self.getChecksum(),
|
|
102
122
|
"status": self.getStatus()
|
|
103
123
|
]
|
|
124
|
+
if let link = self.link {
|
|
125
|
+
result["link"] = link
|
|
126
|
+
}
|
|
127
|
+
if let comment = self.comment {
|
|
128
|
+
result["comment"] = comment
|
|
129
|
+
}
|
|
130
|
+
return result
|
|
104
131
|
}
|
|
105
132
|
|
|
106
133
|
public static func == (lhs: BundleInfo, rhs: BundleInfo) -> Bool {
|
|
@@ -54,7 +54,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
54
54
|
CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
|
|
55
55
|
]
|
|
56
56
|
public var implementation = CapgoUpdater()
|
|
57
|
-
private let pluginVersion: String = "6.
|
|
57
|
+
private let pluginVersion: String = "6.37.0"
|
|
58
58
|
static let updateUrlDefault = "https://plugin.capgo.app/updates"
|
|
59
59
|
static let statsUrlDefault = "https://plugin.capgo.app/stats"
|
|
60
60
|
static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
|
|
@@ -92,6 +92,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
92
92
|
private var backgroundWork: DispatchWorkItem?
|
|
93
93
|
private var taskRunning = false
|
|
94
94
|
private var periodCheckDelay = 0
|
|
95
|
+
|
|
96
|
+
// Lock to ensure cleanup completes before downloads start
|
|
97
|
+
private let cleanupLock = NSLock()
|
|
98
|
+
private var cleanupComplete = false
|
|
99
|
+
private var cleanupThread: Thread?
|
|
95
100
|
private var persistCustomId = false
|
|
96
101
|
private var persistModifyUrl = false
|
|
97
102
|
private var allowManualBundleError = false
|
|
@@ -185,7 +190,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
185
190
|
logger.info("Loaded persisted updateUrl")
|
|
186
191
|
}
|
|
187
192
|
autoUpdate = getConfig().getBoolean("autoUpdate", true)
|
|
188
|
-
appReadyTimeout = getConfig().getInt("appReadyTimeout", 10000)
|
|
193
|
+
appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
|
|
189
194
|
implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
|
|
190
195
|
resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
|
|
191
196
|
shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
|
|
@@ -208,6 +213,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
208
213
|
implementation.setLogger(logger)
|
|
209
214
|
CryptoCipher.setLogger(logger)
|
|
210
215
|
|
|
216
|
+
// Log public key prefix if encryption is enabled
|
|
217
|
+
if let keyId = implementation.getKeyId(), !keyId.isEmpty {
|
|
218
|
+
logger.info("Public key prefix: \(keyId)")
|
|
219
|
+
}
|
|
220
|
+
|
|
211
221
|
// Initialize DelayUpdateUtils
|
|
212
222
|
self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
|
|
213
223
|
let config = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor().legacyConfig
|
|
@@ -362,28 +372,73 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
362
372
|
}
|
|
363
373
|
|
|
364
374
|
private func cleanupObsoleteVersions() {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
logger.info("
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
375
|
+
cleanupThread = Thread {
|
|
376
|
+
self.cleanupLock.lock()
|
|
377
|
+
defer {
|
|
378
|
+
self.cleanupComplete = true
|
|
379
|
+
self.cleanupLock.unlock()
|
|
380
|
+
self.logger.info("Cleanup complete")
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
|
|
384
|
+
if previous != "0" && self.currentBuildVersion != previous {
|
|
385
|
+
_ = self._reset(toLastSuccessful: false)
|
|
386
|
+
let res = self.implementation.list()
|
|
387
|
+
for version in res {
|
|
388
|
+
// Check if thread was cancelled
|
|
389
|
+
if Thread.current.isCancelled {
|
|
390
|
+
self.logger.warn("Cleanup was cancelled, stopping")
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
self.logger.info("Deleting obsolete bundle: \(version.getId())")
|
|
394
|
+
let res = self.implementation.delete(id: version.getId())
|
|
395
|
+
if !res {
|
|
396
|
+
self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
|
|
397
|
+
}
|
|
374
398
|
}
|
|
399
|
+
|
|
400
|
+
let storedBundles = self.implementation.list(raw: true)
|
|
401
|
+
let allowedIds = Set(storedBundles.compactMap { info -> String? in
|
|
402
|
+
let id = info.getId()
|
|
403
|
+
return id.isEmpty ? nil : id
|
|
404
|
+
})
|
|
405
|
+
self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
|
|
406
|
+
|
|
407
|
+
// Check again before the expensive delta cache cleanup
|
|
408
|
+
if Thread.current.isCancelled {
|
|
409
|
+
self.logger.warn("Cleanup was cancelled before delta cache cleanup")
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
|
|
375
413
|
}
|
|
414
|
+
UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
|
|
415
|
+
UserDefaults.standard.synchronize()
|
|
416
|
+
}
|
|
417
|
+
cleanupThread?.start()
|
|
376
418
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
419
|
+
// Start a timeout watchdog thread to cancel cleanup if it takes too long
|
|
420
|
+
let timeout = Double(self.appReadyTimeout / 2) / 1000.0
|
|
421
|
+
Thread.detachNewThread {
|
|
422
|
+
Thread.sleep(forTimeInterval: timeout)
|
|
423
|
+
if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
|
|
424
|
+
self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
|
|
425
|
+
thread.cancel()
|
|
426
|
+
}
|
|
384
427
|
}
|
|
385
|
-
|
|
386
|
-
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private func waitForCleanupIfNeeded() {
|
|
431
|
+
if cleanupComplete {
|
|
432
|
+
return // Already done, no need to wait
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
logger.info("Waiting for cleanup to complete before starting download...")
|
|
436
|
+
|
|
437
|
+
// Wait for cleanup to complete - blocks until lock is released
|
|
438
|
+
cleanupLock.lock()
|
|
439
|
+
cleanupLock.unlock()
|
|
440
|
+
|
|
441
|
+
logger.info("Cleanup finished, proceeding with download")
|
|
387
442
|
}
|
|
388
443
|
|
|
389
444
|
@objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
|
|
@@ -1276,6 +1331,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1276
1331
|
return
|
|
1277
1332
|
}
|
|
1278
1333
|
DispatchQueue.global(qos: .background).async {
|
|
1334
|
+
// Wait for cleanup to complete before starting download
|
|
1335
|
+
self.waitForCleanupIfNeeded()
|
|
1279
1336
|
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
|
|
1280
1337
|
// End the task if time expires.
|
|
1281
1338
|
self.endBackGroundTask()
|
|
@@ -1341,9 +1398,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1341
1398
|
}
|
|
1342
1399
|
}
|
|
1343
1400
|
if res.manifest != nil {
|
|
1344
|
-
nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey)
|
|
1401
|
+
nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
|
|
1345
1402
|
} else {
|
|
1346
|
-
nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
|
|
1403
|
+
nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
|
|
1347
1404
|
}
|
|
1348
1405
|
}
|
|
1349
1406
|
guard let next = nextImpl else {
|
|
@@ -44,6 +44,9 @@ import UIKit
|
|
|
44
44
|
public var publicKey: String = ""
|
|
45
45
|
public var hasOldPrivateKeyPropertyInConfig: Bool = false
|
|
46
46
|
|
|
47
|
+
// Cached key ID calculated once from publicKey
|
|
48
|
+
private var cachedKeyId: String?
|
|
49
|
+
|
|
47
50
|
// Flag to track if we received a 429 response - stops requests until app restart
|
|
48
51
|
private static var rateLimitExceeded = false
|
|
49
52
|
|
|
@@ -81,6 +84,19 @@ import UIKit
|
|
|
81
84
|
return String((0..<length).map { _ in letters.randomElement()! })
|
|
82
85
|
}
|
|
83
86
|
|
|
87
|
+
public func setPublicKey(_ publicKey: String) {
|
|
88
|
+
self.publicKey = publicKey
|
|
89
|
+
if !publicKey.isEmpty {
|
|
90
|
+
self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
|
|
91
|
+
} else {
|
|
92
|
+
self.cachedKeyId = nil
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public func getKeyId() -> String? {
|
|
97
|
+
return self.cachedKeyId
|
|
98
|
+
}
|
|
99
|
+
|
|
84
100
|
private var isDevEnvironment: Bool {
|
|
85
101
|
#if DEBUG
|
|
86
102
|
return true
|
|
@@ -326,7 +342,8 @@ import UIKit
|
|
|
326
342
|
is_prod: self.isProd(),
|
|
327
343
|
action: nil,
|
|
328
344
|
channel: nil,
|
|
329
|
-
defaultChannel: self.defaultChannel
|
|
345
|
+
defaultChannel: self.defaultChannel,
|
|
346
|
+
key_id: self.cachedKeyId
|
|
330
347
|
)
|
|
331
348
|
}
|
|
332
349
|
|
|
@@ -374,6 +391,12 @@ import UIKit
|
|
|
374
391
|
if let manifest = response.value?.manifest {
|
|
375
392
|
latest.manifest = manifest
|
|
376
393
|
}
|
|
394
|
+
if let link = response.value?.link {
|
|
395
|
+
latest.link = link
|
|
396
|
+
}
|
|
397
|
+
if let comment = response.value?.comment {
|
|
398
|
+
latest.comment = comment
|
|
399
|
+
}
|
|
377
400
|
case let .failure(error):
|
|
378
401
|
self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
|
|
379
402
|
latest.message = "Error getting Latest \(String(describing: response.value))"
|
|
@@ -402,11 +425,11 @@ import UIKit
|
|
|
402
425
|
private var tempData = Data()
|
|
403
426
|
|
|
404
427
|
private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
|
|
405
|
-
let actualHash =
|
|
428
|
+
let actualHash = CryptoCipher.calcChecksum(filePath: file)
|
|
406
429
|
return actualHash == expectedHash
|
|
407
430
|
}
|
|
408
431
|
|
|
409
|
-
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
|
|
432
|
+
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
410
433
|
let id = self.randomString(length: 10)
|
|
411
434
|
logger.info("downloadManifest start \(id)")
|
|
412
435
|
let destFolder = self.getBundleDirectory(id: id)
|
|
@@ -416,7 +439,7 @@ import UIKit
|
|
|
416
439
|
try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
|
|
417
440
|
|
|
418
441
|
// Create and save BundleInfo before starting the download process
|
|
419
|
-
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
|
|
442
|
+
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
|
|
420
443
|
self.saveBundleInfo(id: id, bundle: bundleInfo)
|
|
421
444
|
|
|
422
445
|
// Send stats for manifest download start
|
|
@@ -451,10 +474,16 @@ import UIKit
|
|
|
451
474
|
// V1 doesn't decrypt checksum, uses different method
|
|
452
475
|
}
|
|
453
476
|
|
|
477
|
+
// Check if file has .br extension for Brotli decompression
|
|
454
478
|
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
455
479
|
let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
|
|
456
480
|
let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
|
|
457
|
-
|
|
481
|
+
|
|
482
|
+
// Check if file is Brotli compressed and remove .br extension from destination
|
|
483
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
484
|
+
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
485
|
+
|
|
486
|
+
let destFilePath = destFolder.appendingPathComponent(destFileName)
|
|
458
487
|
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
459
488
|
|
|
460
489
|
// Create necessary subdirectories in the destination folder
|
|
@@ -463,13 +492,18 @@ import UIKit
|
|
|
463
492
|
dispatchGroup.enter()
|
|
464
493
|
|
|
465
494
|
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
495
|
+
do {
|
|
496
|
+
try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
|
|
497
|
+
logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
498
|
+
completedFiles += 1
|
|
499
|
+
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
500
|
+
} catch {
|
|
501
|
+
downloadError = error
|
|
502
|
+
logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
|
|
503
|
+
}
|
|
470
504
|
dispatchGroup.leave()
|
|
471
|
-
} else if
|
|
472
|
-
|
|
505
|
+
} else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: fileHash) {
|
|
506
|
+
// Successfully copied from cache
|
|
473
507
|
logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
474
508
|
completedFiles += 1
|
|
475
509
|
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
@@ -510,15 +544,11 @@ import UIKit
|
|
|
510
544
|
throw NSError(domain: "DeprecatedEncryptionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "V1 Encryption with privateKey is deprecated and not supported in this download method for file \(fileName) at url \(downloadUrl)"])
|
|
511
545
|
}
|
|
512
546
|
|
|
513
|
-
//
|
|
514
|
-
let isBrotli = fileName.hasSuffix(".br")
|
|
515
|
-
let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
516
|
-
let destFilePath = destFolder.appendingPathComponent(finalFileName)
|
|
517
|
-
|
|
547
|
+
// Use the isBrotli and destFilePath already computed above
|
|
518
548
|
if isBrotli {
|
|
519
549
|
// Decompress the Brotli data
|
|
520
550
|
guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
|
|
521
|
-
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(
|
|
551
|
+
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
|
|
522
552
|
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
|
|
523
553
|
}
|
|
524
554
|
finalData = decompressedData
|
|
@@ -533,7 +563,7 @@ import UIKit
|
|
|
533
563
|
if calculatedChecksum != fileHash {
|
|
534
564
|
// Delete the corrupt file before throwing error
|
|
535
565
|
try? FileManager.default.removeItem(at: destFilePath)
|
|
536
|
-
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(
|
|
566
|
+
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
537
567
|
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
538
568
|
}
|
|
539
569
|
}
|
|
@@ -578,6 +608,30 @@ import UIKit
|
|
|
578
608
|
return updatedBundle
|
|
579
609
|
}
|
|
580
610
|
|
|
611
|
+
/// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
|
|
612
|
+
/// This handles the race condition where OS can delete cache files between exists() check and copy
|
|
613
|
+
private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
|
|
614
|
+
// First quick check - if file doesn't exist, don't bother
|
|
615
|
+
guard FileManager.default.fileExists(atPath: source.path) else {
|
|
616
|
+
return false
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Verify checksum before copy
|
|
620
|
+
guard verifyChecksum(file: source, expectedHash: expectedHash) else {
|
|
621
|
+
return false
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Try to copy - if it fails (file deleted by OS between check and copy), return false
|
|
625
|
+
do {
|
|
626
|
+
try FileManager.default.copyItem(at: source, to: destination)
|
|
627
|
+
return true
|
|
628
|
+
} catch {
|
|
629
|
+
// File was deleted between check and copy, or other IO error - caller should download instead
|
|
630
|
+
logger.debug("Cache copy failed (likely OS eviction): \(error.localizedDescription)")
|
|
631
|
+
return false
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
581
635
|
private func decompressBrotli(data: Data, fileName: String) -> Data? {
|
|
582
636
|
// Handle empty files
|
|
583
637
|
if data.count == 0 {
|
|
@@ -691,7 +745,7 @@ import UIKit
|
|
|
691
745
|
return status == COMPRESSION_STATUS_END ? decompressedData : nil
|
|
692
746
|
}
|
|
693
747
|
|
|
694
|
-
public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
|
|
748
|
+
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
695
749
|
let id: String = self.randomString(length: 10)
|
|
696
750
|
let semaphore = DispatchSemaphore(value: 0)
|
|
697
751
|
if version != getLocalUpdateVersion() {
|
|
@@ -758,7 +812,7 @@ import UIKit
|
|
|
758
812
|
semaphore.signal()
|
|
759
813
|
}
|
|
760
814
|
}
|
|
761
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
|
|
815
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
762
816
|
let reachabilityManager = NetworkReachabilityManager()
|
|
763
817
|
reachabilityManager?.startListening { status in
|
|
764
818
|
switch status {
|
|
@@ -776,23 +830,18 @@ import UIKit
|
|
|
776
830
|
|
|
777
831
|
if mainError != nil {
|
|
778
832
|
logger.error("Failed to download: \(String(describing: mainError))")
|
|
779
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
833
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
780
834
|
throw mainError!
|
|
781
835
|
}
|
|
782
836
|
|
|
783
837
|
let finalPath = tempDataPath.deletingLastPathComponent().appendingPathComponent("\(id)")
|
|
784
838
|
do {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
788
|
-
} else {
|
|
789
|
-
// V1 Encryption (privateKey) - deprecated but supported
|
|
790
|
-
try CryptoCipher.decryptFile(filePath: tempDataPath, privateKey: self.privateKey, sessionKey: sessionKey, version: version)
|
|
791
|
-
}
|
|
839
|
+
// Decrypt file using public key (V1 encryption with privateKey is deprecated)
|
|
840
|
+
try CryptoCipher.decryptFile(filePath: tempDataPath, publicKey: self.publicKey, sessionKey: sessionKey, version: version)
|
|
792
841
|
try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
|
|
793
842
|
} catch {
|
|
794
843
|
logger.error("Failed decrypt file : \(error)")
|
|
795
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
844
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
796
845
|
cleanDownloadData()
|
|
797
846
|
throw error
|
|
798
847
|
}
|
|
@@ -805,7 +854,7 @@ import UIKit
|
|
|
805
854
|
|
|
806
855
|
} catch {
|
|
807
856
|
logger.error("Failed to unzip file: \(error)")
|
|
808
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
857
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
809
858
|
// Best-effort cleanup of the decrypted zip file when unzip fails
|
|
810
859
|
do {
|
|
811
860
|
if FileManager.default.fileExists(atPath: finalPath.path) {
|
|
@@ -820,7 +869,7 @@ import UIKit
|
|
|
820
869
|
|
|
821
870
|
self.notifyDownload(id: id, percent: 90)
|
|
822
871
|
logger.info("Downloading: 90% (wrapping up)")
|
|
823
|
-
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
|
|
872
|
+
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
|
|
824
873
|
self.saveBundleInfo(id: id, bundle: info)
|
|
825
874
|
self.cleanDownloadData()
|
|
826
875
|
|
|
@@ -995,6 +1044,16 @@ import UIKit
|
|
|
995
1044
|
}
|
|
996
1045
|
|
|
997
1046
|
public func cleanupDeltaCache() {
|
|
1047
|
+
cleanupDeltaCache(threadToCheck: nil)
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
public func cleanupDeltaCache(threadToCheck: Thread?) {
|
|
1051
|
+
// Check if thread was cancelled
|
|
1052
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
1053
|
+
logger.warn("cleanupDeltaCache was cancelled before starting")
|
|
1054
|
+
return
|
|
1055
|
+
}
|
|
1056
|
+
|
|
998
1057
|
let fileManager = FileManager.default
|
|
999
1058
|
guard fileManager.fileExists(atPath: cacheFolder.path) else {
|
|
1000
1059
|
return
|
|
@@ -1008,6 +1067,10 @@ import UIKit
|
|
|
1008
1067
|
}
|
|
1009
1068
|
|
|
1010
1069
|
public func cleanupDownloadDirectories(allowedIds: Set<String>) {
|
|
1070
|
+
cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
|
|
1011
1074
|
let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
|
|
1012
1075
|
let fileManager = FileManager.default
|
|
1013
1076
|
|
|
@@ -1019,6 +1082,12 @@ import UIKit
|
|
|
1019
1082
|
let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
1020
1083
|
|
|
1021
1084
|
for url in contents {
|
|
1085
|
+
// Check if thread was cancelled
|
|
1086
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
1087
|
+
logger.warn("cleanupDownloadDirectories was cancelled")
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1022
1091
|
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
1023
1092
|
if resourceValues.isDirectory != true {
|
|
1024
1093
|
continue
|
|
@@ -264,4 +264,23 @@ public struct CryptoCipher {
|
|
|
264
264
|
throw CustomError.cannotDecode
|
|
265
265
|
}
|
|
266
266
|
}
|
|
267
|
+
|
|
268
|
+
/// Get first 4 characters of the public key for identification
|
|
269
|
+
/// Returns 4-character string or empty string if key is invalid/empty
|
|
270
|
+
public static func calcKeyId(publicKey: String) -> String {
|
|
271
|
+
if publicKey.isEmpty {
|
|
272
|
+
return ""
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Remove PEM headers and whitespace to get the raw key data
|
|
276
|
+
let cleanedKey = publicKey
|
|
277
|
+
.replacingOccurrences(of: "-----BEGIN RSA PUBLIC KEY-----", with: "")
|
|
278
|
+
.replacingOccurrences(of: "-----END RSA PUBLIC KEY-----", with: "")
|
|
279
|
+
.replacingOccurrences(of: "\n", with: "")
|
|
280
|
+
.replacingOccurrences(of: "\r", with: "")
|
|
281
|
+
.replacingOccurrences(of: " ", with: "")
|
|
282
|
+
|
|
283
|
+
// Return first 4 characters of the base64-encoded key
|
|
284
|
+
return String(cleanedKey.prefix(4))
|
|
285
|
+
}
|
|
267
286
|
}
|
|
@@ -128,6 +128,7 @@ struct InfoObject: Codable {
|
|
|
128
128
|
var action: String?
|
|
129
129
|
var channel: String?
|
|
130
130
|
var defaultChannel: String?
|
|
131
|
+
var key_id: String?
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
public struct ManifestEntry: Codable {
|
|
@@ -160,6 +161,8 @@ struct AppVersionDec: Decodable {
|
|
|
160
161
|
let breaking: Bool?
|
|
161
162
|
let data: [String: String]?
|
|
162
163
|
let manifest: [ManifestEntry]?
|
|
164
|
+
let link: String?
|
|
165
|
+
let comment: String?
|
|
163
166
|
// The HTTP status code is captured separately in CapgoUpdater; this struct only mirrors JSON.
|
|
164
167
|
}
|
|
165
168
|
|
|
@@ -174,6 +177,8 @@ public class AppVersion: NSObject {
|
|
|
174
177
|
var breaking: Bool?
|
|
175
178
|
var data: [String: String]?
|
|
176
179
|
var manifest: [ManifestEntry]?
|
|
180
|
+
var link: String?
|
|
181
|
+
var comment: String?
|
|
177
182
|
var statusCode: Int = 0
|
|
178
183
|
}
|
|
179
184
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capgo/capacitor-updater",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.37.0",
|
|
4
4
|
"license": "MPL-2.0",
|
|
5
5
|
"description": "Live update for capacitor apps",
|
|
6
6
|
"main": "dist/plugin.cjs.js",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"ionic",
|
|
38
38
|
"appflow alternative",
|
|
39
39
|
"capawesome alternative",
|
|
40
|
+
"@capawesome/capacitor-live-update",
|
|
40
41
|
"native"
|
|
41
42
|
],
|
|
42
43
|
"scripts": {
|