@capgo/capacitor-updater 5.34.0 → 5.38.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/CapgoCapacitorUpdater.podspec +1 -1
- package/Package.swift +1 -1
- 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 +84 -28
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +88 -4
- 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 +81 -23
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +137 -25
- 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 = "5.
|
|
57
|
+
private let pluginVersion: String = "5.38.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)
|
|
@@ -196,7 +201,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
196
201
|
periodCheckDelay = periodCheckDelayValue
|
|
197
202
|
}
|
|
198
203
|
|
|
199
|
-
implementation.
|
|
204
|
+
implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
|
|
200
205
|
implementation.notifyDownloadRaw = notifyDownload
|
|
201
206
|
implementation.pluginVersion = self.pluginVersion
|
|
202
207
|
|
|
@@ -204,6 +209,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
204
209
|
implementation.setLogger(logger)
|
|
205
210
|
CryptoCipher.setLogger(logger)
|
|
206
211
|
|
|
212
|
+
// Log public key prefix if encryption is enabled
|
|
213
|
+
if let keyId = implementation.getKeyId(), !keyId.isEmpty {
|
|
214
|
+
logger.info("Public key prefix: \(keyId)")
|
|
215
|
+
}
|
|
216
|
+
|
|
207
217
|
// Initialize DelayUpdateUtils
|
|
208
218
|
self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
|
|
209
219
|
let config = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor().legacyConfig
|
|
@@ -358,28 +368,74 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
358
368
|
}
|
|
359
369
|
|
|
360
370
|
private func cleanupObsoleteVersions() {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
logger.info("
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
371
|
+
cleanupThread = Thread {
|
|
372
|
+
self.cleanupLock.lock()
|
|
373
|
+
defer {
|
|
374
|
+
self.cleanupComplete = true
|
|
375
|
+
self.cleanupLock.unlock()
|
|
376
|
+
self.logger.info("Cleanup complete")
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
|
|
380
|
+
if previous != "0" && self.currentBuildVersion != previous {
|
|
381
|
+
_ = self._reset(toLastSuccessful: false)
|
|
382
|
+
let res = self.implementation.list()
|
|
383
|
+
for version in res {
|
|
384
|
+
// Check if thread was cancelled
|
|
385
|
+
if Thread.current.isCancelled {
|
|
386
|
+
self.logger.warn("Cleanup was cancelled, stopping")
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
self.logger.info("Deleting obsolete bundle: \(version.getId())")
|
|
390
|
+
let res = self.implementation.delete(id: version.getId())
|
|
391
|
+
if !res {
|
|
392
|
+
self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let storedBundles = self.implementation.list(raw: true)
|
|
397
|
+
let allowedIds = Set(storedBundles.compactMap { info -> String? in
|
|
398
|
+
let id = info.getId()
|
|
399
|
+
return id.isEmpty ? nil : id
|
|
400
|
+
})
|
|
401
|
+
self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
|
|
402
|
+
self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
|
|
403
|
+
|
|
404
|
+
// Check again before the expensive delta cache cleanup
|
|
405
|
+
if Thread.current.isCancelled {
|
|
406
|
+
self.logger.warn("Cleanup was cancelled before delta cache cleanup")
|
|
407
|
+
return
|
|
370
408
|
}
|
|
409
|
+
self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
|
|
371
410
|
}
|
|
411
|
+
UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
|
|
412
|
+
UserDefaults.standard.synchronize()
|
|
413
|
+
}
|
|
414
|
+
cleanupThread?.start()
|
|
372
415
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
416
|
+
// Start a timeout watchdog thread to cancel cleanup if it takes too long
|
|
417
|
+
let timeout = Double(self.appReadyTimeout / 2) / 1000.0
|
|
418
|
+
Thread.detachNewThread {
|
|
419
|
+
Thread.sleep(forTimeInterval: timeout)
|
|
420
|
+
if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
|
|
421
|
+
self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
|
|
422
|
+
thread.cancel()
|
|
423
|
+
}
|
|
380
424
|
}
|
|
381
|
-
|
|
382
|
-
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private func waitForCleanupIfNeeded() {
|
|
428
|
+
if cleanupComplete {
|
|
429
|
+
return // Already done, no need to wait
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
logger.info("Waiting for cleanup to complete before starting download...")
|
|
433
|
+
|
|
434
|
+
// Wait for cleanup to complete - blocks until lock is released
|
|
435
|
+
cleanupLock.lock()
|
|
436
|
+
cleanupLock.unlock()
|
|
437
|
+
|
|
438
|
+
logger.info("Cleanup finished, proceeding with download")
|
|
383
439
|
}
|
|
384
440
|
|
|
385
441
|
@objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
|
|
@@ -1272,6 +1328,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1272
1328
|
return
|
|
1273
1329
|
}
|
|
1274
1330
|
DispatchQueue.global(qos: .background).async {
|
|
1331
|
+
// Wait for cleanup to complete before starting download
|
|
1332
|
+
self.waitForCleanupIfNeeded()
|
|
1275
1333
|
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
|
|
1276
1334
|
// End the task if time expires.
|
|
1277
1335
|
self.endBackGroundTask()
|
|
@@ -1337,9 +1395,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1337
1395
|
}
|
|
1338
1396
|
}
|
|
1339
1397
|
if res.manifest != nil {
|
|
1340
|
-
nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey)
|
|
1398
|
+
nextImpl = try self.implementation.downloadManifest(manifest: res.manifest!, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
|
|
1341
1399
|
} else {
|
|
1342
|
-
nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey)
|
|
1400
|
+
nextImpl = try self.implementation.download(url: downloadUrl, version: latestVersionName, sessionKey: sessionKey, link: res.link, comment: res.comment)
|
|
1343
1401
|
}
|
|
1344
1402
|
}
|
|
1345
1403
|
guard let next = nextImpl else {
|
|
@@ -26,6 +26,7 @@ import UIKit
|
|
|
26
26
|
private let FALLBACK_VERSION: String = "pastVersion"
|
|
27
27
|
private let NEXT_VERSION: String = "nextVersion"
|
|
28
28
|
private var unzipPercent = 0
|
|
29
|
+
private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
|
|
29
30
|
|
|
30
31
|
// Add this line to declare cacheFolder
|
|
31
32
|
private let cacheFolder: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!.appendingPathComponent("capgo_downloads")
|
|
@@ -42,6 +43,9 @@ import UIKit
|
|
|
42
43
|
public var deviceID = ""
|
|
43
44
|
public var publicKey: String = ""
|
|
44
45
|
|
|
46
|
+
// Cached key ID calculated once from publicKey
|
|
47
|
+
private var cachedKeyId: String?
|
|
48
|
+
|
|
45
49
|
// Flag to track if we received a 429 response - stops requests until app restart
|
|
46
50
|
private static var rateLimitExceeded = false
|
|
47
51
|
|
|
@@ -79,6 +83,19 @@ import UIKit
|
|
|
79
83
|
return String((0..<length).map { _ in letters.randomElement()! })
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
public func setPublicKey(_ publicKey: String) {
|
|
87
|
+
self.publicKey = publicKey
|
|
88
|
+
if !publicKey.isEmpty {
|
|
89
|
+
self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
|
|
90
|
+
} else {
|
|
91
|
+
self.cachedKeyId = nil
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
public func getKeyId() -> String? {
|
|
96
|
+
return self.cachedKeyId
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
private var isDevEnvironment: Bool {
|
|
83
100
|
#if DEBUG
|
|
84
101
|
return true
|
|
@@ -258,7 +275,7 @@ import UIKit
|
|
|
258
275
|
private func saveDownloaded(sourceZip: URL, id: String, base: URL, notify: Bool) throws {
|
|
259
276
|
try prepareFolder(source: base)
|
|
260
277
|
let destPersist: URL = base.appendingPathComponent(id)
|
|
261
|
-
let destUnZip: URL = libraryDir.appendingPathComponent(randomString(length: 10))
|
|
278
|
+
let destUnZip: URL = libraryDir.appendingPathComponent(TEMP_UNZIP_PREFIX + randomString(length: 10))
|
|
262
279
|
|
|
263
280
|
self.unzipPercent = 0
|
|
264
281
|
self.notifyDownload(id: id, percent: 75)
|
|
@@ -324,7 +341,8 @@ import UIKit
|
|
|
324
341
|
is_prod: self.isProd(),
|
|
325
342
|
action: nil,
|
|
326
343
|
channel: nil,
|
|
327
|
-
defaultChannel: self.defaultChannel
|
|
344
|
+
defaultChannel: self.defaultChannel,
|
|
345
|
+
key_id: self.cachedKeyId
|
|
328
346
|
)
|
|
329
347
|
}
|
|
330
348
|
|
|
@@ -372,6 +390,12 @@ import UIKit
|
|
|
372
390
|
if let manifest = response.value?.manifest {
|
|
373
391
|
latest.manifest = manifest
|
|
374
392
|
}
|
|
393
|
+
if let link = response.value?.link {
|
|
394
|
+
latest.link = link
|
|
395
|
+
}
|
|
396
|
+
if let comment = response.value?.comment {
|
|
397
|
+
latest.comment = comment
|
|
398
|
+
}
|
|
375
399
|
case let .failure(error):
|
|
376
400
|
self.logger.error("Error getting Latest \(response.value.debugDescription) \(error)")
|
|
377
401
|
latest.message = "Error getting Latest \(String(describing: response.value))"
|
|
@@ -400,11 +424,11 @@ import UIKit
|
|
|
400
424
|
private var tempData = Data()
|
|
401
425
|
|
|
402
426
|
private func verifyChecksum(file: URL, expectedHash: String) -> Bool {
|
|
403
|
-
let actualHash =
|
|
427
|
+
let actualHash = CryptoCipher.calcChecksum(filePath: file)
|
|
404
428
|
return actualHash == expectedHash
|
|
405
429
|
}
|
|
406
430
|
|
|
407
|
-
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String) throws -> BundleInfo {
|
|
431
|
+
public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
408
432
|
let id = self.randomString(length: 10)
|
|
409
433
|
logger.info("downloadManifest start \(id)")
|
|
410
434
|
let destFolder = self.getBundleDirectory(id: id)
|
|
@@ -414,7 +438,7 @@ import UIKit
|
|
|
414
438
|
try FileManager.default.createDirectory(at: destFolder, withIntermediateDirectories: true, attributes: nil)
|
|
415
439
|
|
|
416
440
|
// Create and save BundleInfo before starting the download process
|
|
417
|
-
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "")
|
|
441
|
+
let bundleInfo = BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: "", link: link, comment: comment)
|
|
418
442
|
self.saveBundleInfo(id: id, bundle: bundleInfo)
|
|
419
443
|
|
|
420
444
|
// Send stats for manifest download start
|
|
@@ -445,10 +469,16 @@ import UIKit
|
|
|
445
469
|
}
|
|
446
470
|
}
|
|
447
471
|
|
|
472
|
+
// Check if file has .br extension for Brotli decompression
|
|
448
473
|
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
449
474
|
let cacheFileName = "\(fileHash)_\(fileNameWithoutPath)"
|
|
450
475
|
let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
|
|
451
|
-
|
|
476
|
+
|
|
477
|
+
// Check if file is Brotli compressed and remove .br extension from destination
|
|
478
|
+
let isBrotli = fileName.hasSuffix(".br")
|
|
479
|
+
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
480
|
+
|
|
481
|
+
let destFilePath = destFolder.appendingPathComponent(destFileName)
|
|
452
482
|
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
453
483
|
|
|
454
484
|
// Create necessary subdirectories in the destination folder
|
|
@@ -457,13 +487,18 @@ import UIKit
|
|
|
457
487
|
dispatchGroup.enter()
|
|
458
488
|
|
|
459
489
|
if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
490
|
+
do {
|
|
491
|
+
try FileManager.default.copyItem(at: builtinFilePath, to: destFilePath)
|
|
492
|
+
logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
493
|
+
completedFiles += 1
|
|
494
|
+
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
495
|
+
} catch {
|
|
496
|
+
downloadError = error
|
|
497
|
+
logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
|
|
498
|
+
}
|
|
464
499
|
dispatchGroup.leave()
|
|
465
|
-
} else if
|
|
466
|
-
|
|
500
|
+
} else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: fileHash) {
|
|
501
|
+
// Successfully copied from cache
|
|
467
502
|
logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
468
503
|
completedFiles += 1
|
|
469
504
|
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
@@ -502,15 +537,11 @@ import UIKit
|
|
|
502
537
|
try FileManager.default.removeItem(at: tempFile)
|
|
503
538
|
}
|
|
504
539
|
|
|
505
|
-
//
|
|
506
|
-
let isBrotli = fileName.hasSuffix(".br")
|
|
507
|
-
let finalFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
508
|
-
let destFilePath = destFolder.appendingPathComponent(finalFileName)
|
|
509
|
-
|
|
540
|
+
// Use the isBrotli and destFilePath already computed above
|
|
510
541
|
if isBrotli {
|
|
511
542
|
// Decompress the Brotli data
|
|
512
543
|
guard let decompressedData = self.decompressBrotli(data: finalData, fileName: fileName) else {
|
|
513
|
-
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(
|
|
544
|
+
self.sendStats(action: "download_manifest_brotli_fail", versionName: "\(version):\(destFileName)")
|
|
514
545
|
throw NSError(domain: "BrotliDecompressionError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to decompress Brotli data for file \(fileName) at url \(downloadUrl)"])
|
|
515
546
|
}
|
|
516
547
|
finalData = decompressedData
|
|
@@ -525,7 +556,7 @@ import UIKit
|
|
|
525
556
|
if calculatedChecksum != fileHash {
|
|
526
557
|
// Delete the corrupt file before throwing error
|
|
527
558
|
try? FileManager.default.removeItem(at: destFilePath)
|
|
528
|
-
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(
|
|
559
|
+
self.sendStats(action: "download_manifest_checksum_fail", versionName: "\(version):\(destFileName)")
|
|
529
560
|
throw NSError(domain: "ChecksumError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Computed checksum is not equal to required checksum (\(calculatedChecksum) != \(fileHash)) for file \(fileName) at url \(downloadUrl)"])
|
|
530
561
|
}
|
|
531
562
|
}
|
|
@@ -570,6 +601,30 @@ import UIKit
|
|
|
570
601
|
return updatedBundle
|
|
571
602
|
}
|
|
572
603
|
|
|
604
|
+
/// Atomically try to copy a file from cache - returns true if successful, false if file doesn't exist or copy failed
|
|
605
|
+
/// This handles the race condition where OS can delete cache files between exists() check and copy
|
|
606
|
+
private func tryCopyFromCache(from source: URL, to destination: URL, expectedHash: String) -> Bool {
|
|
607
|
+
// First quick check - if file doesn't exist, don't bother
|
|
608
|
+
guard FileManager.default.fileExists(atPath: source.path) else {
|
|
609
|
+
return false
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Verify checksum before copy
|
|
613
|
+
guard verifyChecksum(file: source, expectedHash: expectedHash) else {
|
|
614
|
+
return false
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Try to copy - if it fails (file deleted by OS between check and copy), return false
|
|
618
|
+
do {
|
|
619
|
+
try FileManager.default.copyItem(at: source, to: destination)
|
|
620
|
+
return true
|
|
621
|
+
} catch {
|
|
622
|
+
// File was deleted between check and copy, or other IO error - caller should download instead
|
|
623
|
+
logger.debug("Cache copy failed (likely OS eviction): \(error.localizedDescription)")
|
|
624
|
+
return false
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
573
628
|
private func decompressBrotli(data: Data, fileName: String) -> Data? {
|
|
574
629
|
// Handle empty files
|
|
575
630
|
if data.count == 0 {
|
|
@@ -683,7 +738,7 @@ import UIKit
|
|
|
683
738
|
return status == COMPRESSION_STATUS_END ? decompressedData : nil
|
|
684
739
|
}
|
|
685
740
|
|
|
686
|
-
public func download(url: URL, version: String, sessionKey: String) throws -> BundleInfo {
|
|
741
|
+
public func download(url: URL, version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
|
|
687
742
|
let id: String = self.randomString(length: 10)
|
|
688
743
|
let semaphore = DispatchSemaphore(value: 0)
|
|
689
744
|
if version != getLocalUpdateVersion() {
|
|
@@ -750,7 +805,7 @@ import UIKit
|
|
|
750
805
|
semaphore.signal()
|
|
751
806
|
}
|
|
752
807
|
}
|
|
753
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum))
|
|
808
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.DOWNLOADING, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
754
809
|
let reachabilityManager = NetworkReachabilityManager()
|
|
755
810
|
reachabilityManager?.startListening { status in
|
|
756
811
|
switch status {
|
|
@@ -768,7 +823,7 @@ import UIKit
|
|
|
768
823
|
|
|
769
824
|
if mainError != nil {
|
|
770
825
|
logger.error("Failed to download: \(String(describing: mainError))")
|
|
771
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
826
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
772
827
|
throw mainError!
|
|
773
828
|
}
|
|
774
829
|
|
|
@@ -778,7 +833,7 @@ import UIKit
|
|
|
778
833
|
try FileManager.default.moveItem(at: tempDataPath, to: finalPath)
|
|
779
834
|
} catch {
|
|
780
835
|
logger.error("Failed decrypt file : \(error)")
|
|
781
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
836
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
782
837
|
cleanDownloadData()
|
|
783
838
|
throw error
|
|
784
839
|
}
|
|
@@ -791,7 +846,7 @@ import UIKit
|
|
|
791
846
|
|
|
792
847
|
} catch {
|
|
793
848
|
logger.error("Failed to unzip file: \(error)")
|
|
794
|
-
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum))
|
|
849
|
+
self.saveBundleInfo(id: id, bundle: BundleInfo(id: id, version: version, status: BundleStatus.ERROR, downloaded: Date(), checksum: checksum, link: link, comment: comment))
|
|
795
850
|
// Best-effort cleanup of the decrypted zip file when unzip fails
|
|
796
851
|
do {
|
|
797
852
|
if FileManager.default.fileExists(atPath: finalPath.path) {
|
|
@@ -806,7 +861,7 @@ import UIKit
|
|
|
806
861
|
|
|
807
862
|
self.notifyDownload(id: id, percent: 90)
|
|
808
863
|
logger.info("Downloading: 90% (wrapping up)")
|
|
809
|
-
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum)
|
|
864
|
+
let info = BundleInfo(id: id, version: version, status: BundleStatus.PENDING, downloaded: Date(), checksum: checksum, link: link, comment: comment)
|
|
810
865
|
self.saveBundleInfo(id: id, bundle: info)
|
|
811
866
|
self.cleanDownloadData()
|
|
812
867
|
|
|
@@ -981,6 +1036,16 @@ import UIKit
|
|
|
981
1036
|
}
|
|
982
1037
|
|
|
983
1038
|
public func cleanupDeltaCache() {
|
|
1039
|
+
cleanupDeltaCache(threadToCheck: nil)
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
public func cleanupDeltaCache(threadToCheck: Thread?) {
|
|
1043
|
+
// Check if thread was cancelled
|
|
1044
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
1045
|
+
logger.warn("cleanupDeltaCache was cancelled before starting")
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
|
|
984
1049
|
let fileManager = FileManager.default
|
|
985
1050
|
guard fileManager.fileExists(atPath: cacheFolder.path) else {
|
|
986
1051
|
return
|
|
@@ -994,6 +1059,10 @@ import UIKit
|
|
|
994
1059
|
}
|
|
995
1060
|
|
|
996
1061
|
public func cleanupDownloadDirectories(allowedIds: Set<String>) {
|
|
1062
|
+
cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
|
|
997
1066
|
let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
|
|
998
1067
|
let fileManager = FileManager.default
|
|
999
1068
|
|
|
@@ -1005,6 +1074,12 @@ import UIKit
|
|
|
1005
1074
|
let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
1006
1075
|
|
|
1007
1076
|
for url in contents {
|
|
1077
|
+
// Check if thread was cancelled
|
|
1078
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
1079
|
+
logger.warn("cleanupDownloadDirectories was cancelled")
|
|
1080
|
+
return
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1008
1083
|
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
1009
1084
|
if resourceValues.isDirectory != true {
|
|
1010
1085
|
continue
|
|
@@ -1029,6 +1104,43 @@ import UIKit
|
|
|
1029
1104
|
}
|
|
1030
1105
|
}
|
|
1031
1106
|
|
|
1107
|
+
public func cleanupOrphanedTempFolders(threadToCheck: Thread?) {
|
|
1108
|
+
let fileManager = FileManager.default
|
|
1109
|
+
|
|
1110
|
+
do {
|
|
1111
|
+
let contents = try fileManager.contentsOfDirectory(at: libraryDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
1112
|
+
|
|
1113
|
+
for url in contents {
|
|
1114
|
+
// Check if thread was cancelled
|
|
1115
|
+
if let thread = threadToCheck, thread.isCancelled {
|
|
1116
|
+
logger.warn("cleanupOrphanedTempFolders was cancelled")
|
|
1117
|
+
return
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
1121
|
+
if resourceValues.isDirectory != true {
|
|
1122
|
+
continue
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
let folderName = url.lastPathComponent
|
|
1126
|
+
|
|
1127
|
+
// Only delete folders with the temp unzip prefix
|
|
1128
|
+
if !folderName.hasPrefix(TEMP_UNZIP_PREFIX) {
|
|
1129
|
+
continue
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
do {
|
|
1133
|
+
try fileManager.removeItem(at: url)
|
|
1134
|
+
logger.info("Deleted orphaned temp unzip folder: \(folderName)")
|
|
1135
|
+
} catch {
|
|
1136
|
+
logger.error("Failed to delete orphaned temp folder: \(folderName) \(error.localizedDescription)")
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
} catch {
|
|
1140
|
+
logger.error("Failed to enumerate library directory for temp folder cleanup: \(error.localizedDescription)")
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1032
1144
|
public func getBundleDirectory(id: String) -> URL {
|
|
1033
1145
|
return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
|
|
1034
1146
|
}
|
|
@@ -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": "5.
|
|
3
|
+
"version": "5.38.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": {
|