@capgo/capacitor-updater 5.35.0 → 5.39.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 +309 -5
- package/android/build.gradle +3 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +430 -49
- 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 +27 -2
- package/dist/docs.json +609 -3
- package/dist/esm/definitions.d.ts +428 -2
- package/dist/esm/definitions.js +103 -1
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +6 -1
- package/dist/esm/web.js +24 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +132 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +132 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +290 -22
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +106 -12
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +19 -0
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +1 -0
- package/package.json +1 -1
|
@@ -51,10 +51,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
51
51
|
CAPPluginMethod(name: "getNextBundle", returnType: CAPPluginReturnPromise),
|
|
52
52
|
CAPPluginMethod(name: "getFailedUpdate", returnType: CAPPluginReturnPromise),
|
|
53
53
|
CAPPluginMethod(name: "setShakeMenu", returnType: CAPPluginReturnPromise),
|
|
54
|
-
CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise)
|
|
54
|
+
CAPPluginMethod(name: "isShakeMenuEnabled", returnType: CAPPluginReturnPromise),
|
|
55
|
+
// App Store update methods
|
|
56
|
+
CAPPluginMethod(name: "getAppUpdateInfo", returnType: CAPPluginReturnPromise),
|
|
57
|
+
CAPPluginMethod(name: "openAppStore", returnType: CAPPluginReturnPromise),
|
|
58
|
+
CAPPluginMethod(name: "performImmediateUpdate", returnType: CAPPluginReturnPromise),
|
|
59
|
+
CAPPluginMethod(name: "startFlexibleUpdate", returnType: CAPPluginReturnPromise),
|
|
60
|
+
CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
|
|
55
61
|
]
|
|
56
62
|
public var implementation = CapgoUpdater()
|
|
57
|
-
private let pluginVersion: String = "5.
|
|
63
|
+
private let pluginVersion: String = "5.39.0"
|
|
58
64
|
static let updateUrlDefault = "https://plugin.capgo.app/updates"
|
|
59
65
|
static let statsUrlDefault = "https://plugin.capgo.app/stats"
|
|
60
66
|
static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
|
|
@@ -92,6 +98,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
92
98
|
private var backgroundWork: DispatchWorkItem?
|
|
93
99
|
private var taskRunning = false
|
|
94
100
|
private var periodCheckDelay = 0
|
|
101
|
+
|
|
102
|
+
// Lock to ensure cleanup completes before downloads start
|
|
103
|
+
private let cleanupLock = NSLock()
|
|
104
|
+
private var cleanupComplete = false
|
|
105
|
+
private var cleanupThread: Thread?
|
|
95
106
|
private var persistCustomId = false
|
|
96
107
|
private var persistModifyUrl = false
|
|
97
108
|
private var allowManualBundleError = false
|
|
@@ -185,7 +196,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
185
196
|
logger.info("Loaded persisted updateUrl")
|
|
186
197
|
}
|
|
187
198
|
autoUpdate = getConfig().getBoolean("autoUpdate", true)
|
|
188
|
-
appReadyTimeout = getConfig().getInt("appReadyTimeout", 10000)
|
|
199
|
+
appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
|
|
189
200
|
implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
|
|
190
201
|
resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
|
|
191
202
|
shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
|
|
@@ -196,7 +207,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
196
207
|
periodCheckDelay = periodCheckDelayValue
|
|
197
208
|
}
|
|
198
209
|
|
|
199
|
-
implementation.
|
|
210
|
+
implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
|
|
200
211
|
implementation.notifyDownloadRaw = notifyDownload
|
|
201
212
|
implementation.pluginVersion = self.pluginVersion
|
|
202
213
|
|
|
@@ -204,6 +215,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
204
215
|
implementation.setLogger(logger)
|
|
205
216
|
CryptoCipher.setLogger(logger)
|
|
206
217
|
|
|
218
|
+
// Log public key prefix if encryption is enabled
|
|
219
|
+
if let keyId = implementation.getKeyId(), !keyId.isEmpty {
|
|
220
|
+
logger.info("Public key prefix: \(keyId)")
|
|
221
|
+
}
|
|
222
|
+
|
|
207
223
|
// Initialize DelayUpdateUtils
|
|
208
224
|
self.delayUpdateUtils = DelayUpdateUtils(currentVersionNative: currentVersionNative, logger: logger)
|
|
209
225
|
let config = (self.bridge?.viewController as? CAPBridgeViewController)?.instanceDescriptor().legacyConfig
|
|
@@ -358,28 +374,74 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
358
374
|
}
|
|
359
375
|
|
|
360
376
|
private func cleanupObsoleteVersions() {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
logger.info("
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
377
|
+
cleanupThread = Thread {
|
|
378
|
+
self.cleanupLock.lock()
|
|
379
|
+
defer {
|
|
380
|
+
self.cleanupComplete = true
|
|
381
|
+
self.cleanupLock.unlock()
|
|
382
|
+
self.logger.info("Cleanup complete")
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
|
|
386
|
+
if previous != "0" && self.currentBuildVersion != previous {
|
|
387
|
+
_ = self._reset(toLastSuccessful: false)
|
|
388
|
+
let res = self.implementation.list()
|
|
389
|
+
for version in res {
|
|
390
|
+
// Check if thread was cancelled
|
|
391
|
+
if Thread.current.isCancelled {
|
|
392
|
+
self.logger.warn("Cleanup was cancelled, stopping")
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
self.logger.info("Deleting obsolete bundle: \(version.getId())")
|
|
396
|
+
let res = self.implementation.delete(id: version.getId())
|
|
397
|
+
if !res {
|
|
398
|
+
self.logger.error("Delete failed, id \(version.getId()) doesn't exist")
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let storedBundles = self.implementation.list(raw: true)
|
|
403
|
+
let allowedIds = Set(storedBundles.compactMap { info -> String? in
|
|
404
|
+
let id = info.getId()
|
|
405
|
+
return id.isEmpty ? nil : id
|
|
406
|
+
})
|
|
407
|
+
self.implementation.cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: Thread.current)
|
|
408
|
+
self.implementation.cleanupOrphanedTempFolders(threadToCheck: Thread.current)
|
|
409
|
+
|
|
410
|
+
// Check again before the expensive delta cache cleanup
|
|
411
|
+
if Thread.current.isCancelled {
|
|
412
|
+
self.logger.warn("Cleanup was cancelled before delta cache cleanup")
|
|
413
|
+
return
|
|
370
414
|
}
|
|
415
|
+
self.implementation.cleanupDeltaCache(threadToCheck: Thread.current)
|
|
371
416
|
}
|
|
417
|
+
UserDefaults.standard.set(self.currentBuildVersion, forKey: "LatestNativeBuildVersion")
|
|
418
|
+
UserDefaults.standard.synchronize()
|
|
419
|
+
}
|
|
420
|
+
cleanupThread?.start()
|
|
372
421
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
422
|
+
// Start a timeout watchdog thread to cancel cleanup if it takes too long
|
|
423
|
+
let timeout = Double(self.appReadyTimeout / 2) / 1000.0
|
|
424
|
+
Thread.detachNewThread {
|
|
425
|
+
Thread.sleep(forTimeInterval: timeout)
|
|
426
|
+
if let thread = self.cleanupThread, !thread.isFinished && !self.cleanupComplete {
|
|
427
|
+
self.logger.warn("Cleanup timeout exceeded (\(timeout)s), cancelling cleanup thread")
|
|
428
|
+
thread.cancel()
|
|
429
|
+
}
|
|
380
430
|
}
|
|
381
|
-
|
|
382
|
-
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private func waitForCleanupIfNeeded() {
|
|
434
|
+
if cleanupComplete {
|
|
435
|
+
return // Already done, no need to wait
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
logger.info("Waiting for cleanup to complete before starting download...")
|
|
439
|
+
|
|
440
|
+
// Wait for cleanup to complete - blocks until lock is released
|
|
441
|
+
cleanupLock.lock()
|
|
442
|
+
cleanupLock.unlock()
|
|
443
|
+
|
|
444
|
+
logger.info("Cleanup finished, proceeding with download")
|
|
383
445
|
}
|
|
384
446
|
|
|
385
447
|
@objc func notifyDownload(id: String, percent: Int, ignoreMultipleOfTen: Bool = false, bundle: BundleInfo? = nil) {
|
|
@@ -1272,6 +1334,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1272
1334
|
return
|
|
1273
1335
|
}
|
|
1274
1336
|
DispatchQueue.global(qos: .background).async {
|
|
1337
|
+
// Wait for cleanup to complete before starting download
|
|
1338
|
+
self.waitForCleanupIfNeeded()
|
|
1275
1339
|
self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "Finish Download Tasks") {
|
|
1276
1340
|
// End the task if time expires.
|
|
1277
1341
|
self.endBackGroundTask()
|
|
@@ -1602,4 +1666,208 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1602
1666
|
implementation.appId = appId
|
|
1603
1667
|
call.resolve()
|
|
1604
1668
|
}
|
|
1669
|
+
|
|
1670
|
+
// MARK: - App Store Update Methods
|
|
1671
|
+
|
|
1672
|
+
/// AppUpdateAvailability enum values matching TypeScript definitions
|
|
1673
|
+
private enum AppUpdateAvailability: Int {
|
|
1674
|
+
case unknown = 0
|
|
1675
|
+
case updateNotAvailable = 1
|
|
1676
|
+
case updateAvailable = 2
|
|
1677
|
+
case updateInProgress = 3
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
@objc func getAppUpdateInfo(_ call: CAPPluginCall) {
|
|
1681
|
+
let country = call.getString("country", "US")
|
|
1682
|
+
let bundleId = implementation.appId
|
|
1683
|
+
|
|
1684
|
+
logger.info("Getting App Store update info for \(bundleId) in country \(country)")
|
|
1685
|
+
|
|
1686
|
+
DispatchQueue.global(qos: .background).async {
|
|
1687
|
+
let urlString = "https://itunes.apple.com/lookup?bundleId=\(bundleId)&country=\(country)"
|
|
1688
|
+
guard let url = URL(string: urlString) else {
|
|
1689
|
+
call.reject("Invalid URL for App Store lookup")
|
|
1690
|
+
return
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
let task = URLSession.shared.dataTask(with: url) { data, response, error in
|
|
1694
|
+
if let error = error {
|
|
1695
|
+
self.logger.error("App Store lookup failed: \(error.localizedDescription)")
|
|
1696
|
+
call.reject("App Store lookup failed: \(error.localizedDescription)")
|
|
1697
|
+
return
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
guard let data = data else {
|
|
1701
|
+
call.reject("No data received from App Store")
|
|
1702
|
+
return
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
do {
|
|
1706
|
+
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
1707
|
+
let resultCount = json["resultCount"] as? Int else {
|
|
1708
|
+
call.reject("Invalid response from App Store")
|
|
1709
|
+
return
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
let currentVersionName = Bundle.main.versionName ?? "0.0.0"
|
|
1713
|
+
let currentVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "0"
|
|
1714
|
+
|
|
1715
|
+
var result: [String: Any] = [
|
|
1716
|
+
"currentVersionName": currentVersionName,
|
|
1717
|
+
"currentVersionCode": currentVersionCode,
|
|
1718
|
+
"updateAvailability": AppUpdateAvailability.unknown.rawValue
|
|
1719
|
+
]
|
|
1720
|
+
|
|
1721
|
+
if resultCount > 0,
|
|
1722
|
+
let results = json["results"] as? [[String: Any]],
|
|
1723
|
+
let appInfo = results.first {
|
|
1724
|
+
|
|
1725
|
+
let availableVersion = appInfo["version"] as? String
|
|
1726
|
+
let releaseDate = appInfo["currentVersionReleaseDate"] as? String
|
|
1727
|
+
let minimumOsVersion = appInfo["minimumOsVersion"] as? String
|
|
1728
|
+
|
|
1729
|
+
result["availableVersionName"] = availableVersion
|
|
1730
|
+
result["availableVersionCode"] = availableVersion // iOS doesn't have separate version code
|
|
1731
|
+
result["availableVersionReleaseDate"] = releaseDate
|
|
1732
|
+
result["minimumOsVersion"] = minimumOsVersion
|
|
1733
|
+
|
|
1734
|
+
// Determine update availability by comparing versions
|
|
1735
|
+
if let availableVersion = availableVersion {
|
|
1736
|
+
do {
|
|
1737
|
+
let currentVer = try Version(currentVersionName)
|
|
1738
|
+
let availableVer = try Version(availableVersion)
|
|
1739
|
+
if availableVer > currentVer {
|
|
1740
|
+
result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
|
|
1741
|
+
} else {
|
|
1742
|
+
result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
|
|
1743
|
+
}
|
|
1744
|
+
} catch {
|
|
1745
|
+
// If version parsing fails, do string comparison
|
|
1746
|
+
if availableVersion != currentVersionName {
|
|
1747
|
+
result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
|
|
1748
|
+
} else {
|
|
1749
|
+
result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
} else {
|
|
1753
|
+
result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// iOS doesn't support in-app updates like Android
|
|
1757
|
+
result["immediateUpdateAllowed"] = false
|
|
1758
|
+
result["flexibleUpdateAllowed"] = false
|
|
1759
|
+
} else {
|
|
1760
|
+
// App not found in App Store (maybe not published yet)
|
|
1761
|
+
result["updateAvailability"] = AppUpdateAvailability.updateNotAvailable.rawValue
|
|
1762
|
+
self.logger.info("App not found in App Store for bundleId: \(bundleId)")
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
call.resolve(result)
|
|
1766
|
+
} catch {
|
|
1767
|
+
self.logger.error("Failed to parse App Store response: \(error.localizedDescription)")
|
|
1768
|
+
call.reject("Failed to parse App Store response: \(error.localizedDescription)")
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
task.resume()
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
@objc func openAppStore(_ call: CAPPluginCall) {
|
|
1776
|
+
let appId = call.getString("appId")
|
|
1777
|
+
|
|
1778
|
+
if let appId = appId {
|
|
1779
|
+
// Open App Store with provided app ID
|
|
1780
|
+
let urlString = "https://apps.apple.com/app/id\(appId)"
|
|
1781
|
+
guard let url = URL(string: urlString) else {
|
|
1782
|
+
call.reject("Invalid App Store URL")
|
|
1783
|
+
return
|
|
1784
|
+
}
|
|
1785
|
+
DispatchQueue.main.async {
|
|
1786
|
+
UIApplication.shared.open(url) { success in
|
|
1787
|
+
if success {
|
|
1788
|
+
call.resolve()
|
|
1789
|
+
} else {
|
|
1790
|
+
call.reject("Failed to open App Store")
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
} else {
|
|
1795
|
+
// Look up app ID using bundle identifier
|
|
1796
|
+
let bundleId = implementation.appId
|
|
1797
|
+
let lookupUrl = "https://itunes.apple.com/lookup?bundleId=\(bundleId)"
|
|
1798
|
+
|
|
1799
|
+
DispatchQueue.global(qos: .background).async {
|
|
1800
|
+
guard let url = URL(string: lookupUrl) else {
|
|
1801
|
+
call.reject("Invalid lookup URL")
|
|
1802
|
+
return
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
let task = URLSession.shared.dataTask(with: url) { data, _, error in
|
|
1806
|
+
if let error = error {
|
|
1807
|
+
call.reject("Failed to lookup app: \(error.localizedDescription)")
|
|
1808
|
+
return
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
guard let data = data,
|
|
1812
|
+
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
1813
|
+
let results = json["results"] as? [[String: Any]],
|
|
1814
|
+
let appInfo = results.first,
|
|
1815
|
+
let trackId = appInfo["trackId"] as? Int else {
|
|
1816
|
+
// If lookup fails, try opening the generic App Store app page using bundle ID
|
|
1817
|
+
let fallbackUrlString = "https://apps.apple.com/app/\(bundleId)"
|
|
1818
|
+
guard let fallbackUrl = URL(string: fallbackUrlString) else {
|
|
1819
|
+
call.reject("Failed to find app in App Store and fallback URL is invalid")
|
|
1820
|
+
return
|
|
1821
|
+
}
|
|
1822
|
+
DispatchQueue.main.async {
|
|
1823
|
+
UIApplication.shared.open(fallbackUrl) { success in
|
|
1824
|
+
if success {
|
|
1825
|
+
call.resolve()
|
|
1826
|
+
} else {
|
|
1827
|
+
call.reject("Failed to open App Store")
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
let appStoreUrl = "https://apps.apple.com/app/id\(trackId)"
|
|
1835
|
+
guard let url = URL(string: appStoreUrl) else {
|
|
1836
|
+
call.reject("Invalid App Store URL")
|
|
1837
|
+
return
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
DispatchQueue.main.async {
|
|
1841
|
+
UIApplication.shared.open(url) { success in
|
|
1842
|
+
if success {
|
|
1843
|
+
call.resolve()
|
|
1844
|
+
} else {
|
|
1845
|
+
call.reject("Failed to open App Store")
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
task.resume()
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
@objc func performImmediateUpdate(_ call: CAPPluginCall) {
|
|
1856
|
+
// iOS doesn't support in-app updates like Android's Play Store
|
|
1857
|
+
// Redirect users to the App Store instead
|
|
1858
|
+
logger.warn("performImmediateUpdate is not supported on iOS. Use openAppStore() instead.")
|
|
1859
|
+
call.reject("In-app updates are not supported on iOS. Use openAppStore() to direct users to the App Store.", "NOT_SUPPORTED")
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
@objc func startFlexibleUpdate(_ call: CAPPluginCall) {
|
|
1863
|
+
// iOS doesn't support flexible in-app updates
|
|
1864
|
+
logger.warn("startFlexibleUpdate is not supported on iOS. Use openAppStore() instead.")
|
|
1865
|
+
call.reject("Flexible updates are not supported on iOS. Use openAppStore() to direct users to the App Store.", "NOT_SUPPORTED")
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
@objc func completeFlexibleUpdate(_ call: CAPPluginCall) {
|
|
1869
|
+
// iOS doesn't support flexible in-app updates
|
|
1870
|
+
logger.warn("completeFlexibleUpdate is not supported on iOS.")
|
|
1871
|
+
call.reject("Flexible updates are not supported on iOS.", "NOT_SUPPORTED")
|
|
1872
|
+
}
|
|
1605
1873
|
}
|
|
@@ -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
|
|
|
@@ -479,16 +497,11 @@ import UIKit
|
|
|
479
497
|
logger.error("Failed to copy builtin file \(fileName): \(error.localizedDescription)")
|
|
480
498
|
}
|
|
481
499
|
dispatchGroup.leave()
|
|
482
|
-
} else if
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
488
|
-
} catch {
|
|
489
|
-
downloadError = error
|
|
490
|
-
logger.error("Failed to copy cached file \(fileName): \(error.localizedDescription)")
|
|
491
|
-
}
|
|
500
|
+
} else if self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: fileHash) {
|
|
501
|
+
// Successfully copied from cache
|
|
502
|
+
logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
503
|
+
completedFiles += 1
|
|
504
|
+
self.notifyDownload(id: id, percent: self.calcTotalPercent(percent: Int((Double(completedFiles) / Double(totalFiles)) * 100), min: 10, max: 70))
|
|
492
505
|
dispatchGroup.leave()
|
|
493
506
|
} else {
|
|
494
507
|
// File not in cache, download, decompress, and save to both cache and destination
|
|
@@ -588,6 +601,30 @@ import UIKit
|
|
|
588
601
|
return updatedBundle
|
|
589
602
|
}
|
|
590
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
|
+
|
|
591
628
|
private func decompressBrotli(data: Data, fileName: String) -> Data? {
|
|
592
629
|
// Handle empty files
|
|
593
630
|
if data.count == 0 {
|
|
@@ -999,6 +1036,16 @@ import UIKit
|
|
|
999
1036
|
}
|
|
1000
1037
|
|
|
1001
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
|
+
|
|
1002
1049
|
let fileManager = FileManager.default
|
|
1003
1050
|
guard fileManager.fileExists(atPath: cacheFolder.path) else {
|
|
1004
1051
|
return
|
|
@@ -1012,6 +1059,10 @@ import UIKit
|
|
|
1012
1059
|
}
|
|
1013
1060
|
|
|
1014
1061
|
public func cleanupDownloadDirectories(allowedIds: Set<String>) {
|
|
1062
|
+
cleanupDownloadDirectories(allowedIds: allowedIds, threadToCheck: nil)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
public func cleanupDownloadDirectories(allowedIds: Set<String>, threadToCheck: Thread?) {
|
|
1015
1066
|
let bundleRoot = libraryDir.appendingPathComponent(bundleDirectory)
|
|
1016
1067
|
let fileManager = FileManager.default
|
|
1017
1068
|
|
|
@@ -1023,6 +1074,12 @@ import UIKit
|
|
|
1023
1074
|
let contents = try fileManager.contentsOfDirectory(at: bundleRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])
|
|
1024
1075
|
|
|
1025
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
|
+
|
|
1026
1083
|
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
|
1027
1084
|
if resourceValues.isDirectory != true {
|
|
1028
1085
|
continue
|
|
@@ -1047,6 +1104,43 @@ import UIKit
|
|
|
1047
1104
|
}
|
|
1048
1105
|
}
|
|
1049
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
|
+
|
|
1050
1144
|
public func getBundleDirectory(id: String) -> URL {
|
|
1051
1145
|
return libraryDir.appendingPathComponent(self.bundleDirectory).appendingPathComponent(id)
|
|
1052
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
|
}
|