@capgo/capacitor-updater 7.41.1 → 7.42.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Package.swift +2 -2
- package/README.md +27 -0
- package/android/build.gradle +4 -2
- package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +88 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +82 -16
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +181 -11
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +13 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +9 -4
- package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +36 -14
- package/dist/docs.json +16 -0
- package/dist/esm/definitions.d.ts +14 -0
- package/dist/esm/definitions.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +42 -2
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +145 -20
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +9 -1
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +22 -0
- package/package.json +1 -1
|
@@ -16,7 +16,18 @@ import Version
|
|
|
16
16
|
*/
|
|
17
17
|
@objc(CapacitorUpdaterPlugin)
|
|
18
18
|
public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
19
|
-
|
|
19
|
+
lazy var logger: Logger = {
|
|
20
|
+
// Default to true for OS logging. In test environments without a bridge,
|
|
21
|
+
// this will default to true. In production, it reads from config.
|
|
22
|
+
let osLogging: Bool
|
|
23
|
+
if self.bridge != nil {
|
|
24
|
+
osLogging = getConfig().getBoolean("osLogging", true)
|
|
25
|
+
} else {
|
|
26
|
+
osLogging = true
|
|
27
|
+
}
|
|
28
|
+
let options = Logger.Options(useSyslog: osLogging)
|
|
29
|
+
return Logger(withTag: "✨ CapgoUpdater", options: options)
|
|
30
|
+
}()
|
|
20
31
|
|
|
21
32
|
public let identifier = "CapacitorUpdaterPlugin"
|
|
22
33
|
public let jsName = "CapacitorUpdater"
|
|
@@ -60,7 +71,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
60
71
|
CAPPluginMethod(name: "completeFlexibleUpdate", returnType: CAPPluginReturnPromise)
|
|
61
72
|
]
|
|
62
73
|
public var implementation = CapgoUpdater()
|
|
63
|
-
private let pluginVersion: String = "7.
|
|
74
|
+
private let pluginVersion: String = "7.42.3"
|
|
64
75
|
static let updateUrlDefault = "https://plugin.capgo.app/updates"
|
|
65
76
|
static let statsUrlDefault = "https://plugin.capgo.app/stats"
|
|
66
77
|
static let channelUrlDefault = "https://plugin.capgo.app/channel_self"
|
|
@@ -382,6 +393,32 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
382
393
|
self.logger.info("Cleanup complete")
|
|
383
394
|
}
|
|
384
395
|
|
|
396
|
+
// Michael (WcaleNieWolny) at 04.01.2026
|
|
397
|
+
// The following line of code contains a bug. After having evaluated it, I have decided not to fix it.
|
|
398
|
+
// The initial report: https://discord.com/channels/912707985829163099/1456985639345061969
|
|
399
|
+
// The bug happens in a very specific scenario. Here is the reproduction steps, followed by the lackof busniess impact
|
|
400
|
+
// Reproduction steps:
|
|
401
|
+
// 1. Install iOS app via app store. Version: 10.13.0. Version v10 of the app uses Capacitor 6 (6.3.13) - a version where the key was still "LatestVersionNative"
|
|
402
|
+
// 2. The plugin writes "10.13.0" to the key "LatestVersionNative"
|
|
403
|
+
// 3. Update the app to version 10.17.0 via Capgo.
|
|
404
|
+
// 4. Update the app via testflight to version 11.0.0. This version uses Capacitor 8 (8.41.3) - a version where the key was changed to "LatestNativeBuildVersion"
|
|
405
|
+
// 5. During the initial load of then new native version, the plugin will read "LatestNativeBuildVersion", not find it, read "LatestVersionNative", find it and revert to builtin version sucessfully.
|
|
406
|
+
// 6. The plugin writes "11.0.0" to the key "LatestNativeBuildVersion"
|
|
407
|
+
// 7. The app is now in a state where it is using the builtin version, but the key "LatestNativeBuildVersion" is still set to "11.0.0" and "LatestVersionNative" is still set to "10.13.0".
|
|
408
|
+
// 8. The user downgrades using app store back to version 10.13.0.
|
|
409
|
+
// 9. The old plugin reads "LatestVersionNative", finds "10.13.0," so it doesn't revert to builtin version. // <--- THIS IS THE FIRST PART OF THE BUG
|
|
410
|
+
// 10. "LatestVersionNative" is written to "10.13.0" but "LatestNativeBuildVersion" is not touched, and stays at "11.0.0"
|
|
411
|
+
// 11. A capgo update happesn to version 10.17.0.
|
|
412
|
+
// 12. The user updates again to version 11.0.0 via Testflight.
|
|
413
|
+
// 13. The plugin reads "LatestNativeBuildVersion", finds "11.0.0", so it doesn't revert to builtin version. It is unaware of the native update that happended.
|
|
414
|
+
// 14. Capgo loads the 10.13.0 version, while it should have loaded the builtin 11.0.0 version. // <--- THIS IS THE SECOND PART OF THE BUG
|
|
415
|
+
// The business impact:
|
|
416
|
+
// None - no one will ever be affected by this bug as reverting via app store should in practice never happen. You are not SUPPOSE to go from Capacitor v8 to v6.
|
|
417
|
+
// Downgrading isn't supported.
|
|
418
|
+
// Possible fixes:
|
|
419
|
+
// 1. Write "LatestVersionNative" - this fixes the part 1 of this bug
|
|
420
|
+
// 2. Compare both keys. If any is not equal to "currentBuildVersion", then revert to builtin version. This fixes the part 2 of this bug
|
|
421
|
+
|
|
385
422
|
let previous = UserDefaults.standard.string(forKey: "LatestNativeBuildVersion") ?? UserDefaults.standard.string(forKey: "LatestVersionNative") ?? "0"
|
|
386
423
|
if previous != "0" && self.currentBuildVersion != previous {
|
|
387
424
|
_ = self._reset(toLastSuccessful: false)
|
|
@@ -1571,6 +1608,9 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1571
1608
|
}
|
|
1572
1609
|
|
|
1573
1610
|
@objc func appMovedToBackground() {
|
|
1611
|
+
// Reset timeout flag at start of each background cycle
|
|
1612
|
+
self.autoSplashscreenTimedOut = false
|
|
1613
|
+
|
|
1574
1614
|
let current: BundleInfo = self.implementation.getCurrentBundle()
|
|
1575
1615
|
self.implementation.sendStats(action: "app_moved_to_background", versionName: current.getVersionName())
|
|
1576
1616
|
logger.info("Check for pending update")
|
|
@@ -48,6 +48,12 @@ import UIKit
|
|
|
48
48
|
// Flag to track if we've already sent the rate limit statistic - prevents infinite loop
|
|
49
49
|
private static var rateLimitStatisticSent = false
|
|
50
50
|
|
|
51
|
+
// Stats batching - queue events and send max once per second
|
|
52
|
+
private var statsQueue: [StatsEvent] = []
|
|
53
|
+
private let statsQueueLock = NSLock()
|
|
54
|
+
private var statsFlushTimer: Timer?
|
|
55
|
+
private static let statsFlushInterval: TimeInterval = 1.0
|
|
56
|
+
|
|
51
57
|
private var userAgent: String {
|
|
52
58
|
let safePluginVersion = pluginVersion.isEmpty ? "unknown" : pluginVersion
|
|
53
59
|
let safeAppId = appId.isEmpty ? "unknown" : appId
|
|
@@ -70,6 +76,15 @@ import UIKit
|
|
|
70
76
|
self.logger = logger
|
|
71
77
|
}
|
|
72
78
|
|
|
79
|
+
deinit {
|
|
80
|
+
// Invalidate the stats timer to prevent memory leaks
|
|
81
|
+
statsFlushTimer?.invalidate()
|
|
82
|
+
statsFlushTimer = nil
|
|
83
|
+
|
|
84
|
+
// Flush any remaining stats before deallocation
|
|
85
|
+
flushStatsQueue()
|
|
86
|
+
}
|
|
87
|
+
|
|
73
88
|
private func calcTotalPercent(percent: Int, min: Int, max: Int) -> Int {
|
|
74
89
|
return (percent * (max - min)) / 100 + min
|
|
75
90
|
}
|
|
@@ -80,12 +95,20 @@ import UIKit
|
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
public func setPublicKey(_ publicKey: String) {
|
|
83
|
-
|
|
84
|
-
if
|
|
85
|
-
self.
|
|
86
|
-
} else {
|
|
98
|
+
// Empty string means no encryption - proceed normally
|
|
99
|
+
if publicKey.isEmpty {
|
|
100
|
+
self.publicKey = ""
|
|
87
101
|
self.cachedKeyId = nil
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Non-empty: must be a valid RSA key or crash
|
|
106
|
+
guard RSAPublicKey.load(rsaPublicKey: publicKey) != nil else {
|
|
107
|
+
fatalError("Invalid public key in capacitor.config.json: failed to parse RSA key. Remove the key or provide a valid PEM-formatted RSA public key.")
|
|
88
108
|
}
|
|
109
|
+
|
|
110
|
+
self.publicKey = publicKey
|
|
111
|
+
self.cachedKeyId = CryptoCipher.calcKeyId(publicKey: publicKey)
|
|
89
112
|
}
|
|
90
113
|
|
|
91
114
|
public func getKeyId() -> String? {
|
|
@@ -360,6 +383,56 @@ import UIKit
|
|
|
360
383
|
}
|
|
361
384
|
}
|
|
362
385
|
|
|
386
|
+
private func populateDeltaCacheAsync(for id: String) {
|
|
387
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
388
|
+
self?.populateDeltaCache(for: id)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private func populateDeltaCache(for id: String) {
|
|
393
|
+
let bundleDir = self.getBundleDirectory(id: id)
|
|
394
|
+
let fileManager = FileManager.default
|
|
395
|
+
|
|
396
|
+
guard fileManager.fileExists(atPath: bundleDir.path) else {
|
|
397
|
+
logger.debug("Skip delta cache population: bundle dir missing")
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
do {
|
|
402
|
+
try fileManager.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil)
|
|
403
|
+
} catch {
|
|
404
|
+
logger.debug("Skip delta cache population: failed to create cache dir")
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
guard let enumerator = fileManager.enumerator(at: bundleDir, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else {
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for case let fileURL as URL in enumerator {
|
|
413
|
+
let resourceValues = try? fileURL.resourceValues(forKeys: [.isDirectoryKey])
|
|
414
|
+
if resourceValues?.isDirectory == true {
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let checksum = CryptoCipher.calcChecksum(filePath: fileURL)
|
|
419
|
+
if checksum.isEmpty {
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
let cacheFile = cacheFolder.appendingPathComponent("\(checksum)_\(fileURL.lastPathComponent)")
|
|
424
|
+
if fileManager.fileExists(atPath: cacheFile.path) {
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
do {
|
|
429
|
+
try fileManager.copyItem(at: fileURL, to: cacheFile)
|
|
430
|
+
} catch {
|
|
431
|
+
logger.debug("Delta cache copy failed: \(fileURL.path)")
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
363
436
|
private func createInfoObject() -> InfoObject {
|
|
364
437
|
return InfoObject(
|
|
365
438
|
platform: "ios",
|
|
@@ -526,10 +599,11 @@ import UIKit
|
|
|
526
599
|
|
|
527
600
|
let finalFileHash = fileHash
|
|
528
601
|
let fileNameWithoutPath = (fileName as NSString).lastPathComponent
|
|
529
|
-
let cacheFileName = "\(finalFileHash)_\(fileNameWithoutPath)"
|
|
530
|
-
let cacheFilePath = cacheFolder.appendingPathComponent(cacheFileName)
|
|
531
|
-
|
|
532
602
|
let isBrotli = fileName.hasSuffix(".br")
|
|
603
|
+
let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
|
|
604
|
+
let cacheFilePath = cacheFolder.appendingPathComponent("\(finalFileHash)_\(cacheBaseName)")
|
|
605
|
+
let legacyCacheFilePath: URL? = isBrotli ? cacheFolder.appendingPathComponent("\(finalFileHash)_\(fileNameWithoutPath)") : nil
|
|
606
|
+
|
|
533
607
|
let destFileName = isBrotli ? String(fileName.dropLast(3)) : fileName
|
|
534
608
|
let destFilePath = destFolder.appendingPathComponent(destFileName)
|
|
535
609
|
let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
|
|
@@ -548,7 +622,9 @@ import UIKit
|
|
|
548
622
|
self.logger.info("downloadManifest \(fileName) using builtin file \(id)")
|
|
549
623
|
}
|
|
550
624
|
// Try cache
|
|
551
|
-
else if
|
|
625
|
+
else if
|
|
626
|
+
self.tryCopyFromCache(from: cacheFilePath, to: destFilePath, expectedHash: finalFileHash) ||
|
|
627
|
+
(legacyCacheFilePath != nil && self.tryCopyFromCache(from: legacyCacheFilePath!, to: destFilePath, expectedHash: finalFileHash)) {
|
|
552
628
|
self.logger.info("downloadManifest \(fileName) copy from cache \(id)")
|
|
553
629
|
}
|
|
554
630
|
// Download
|
|
@@ -962,6 +1038,7 @@ import UIKit
|
|
|
962
1038
|
CryptoCipher.logChecksumInfo(label: "Calculated bundle checksum", hexChecksum: checksum)
|
|
963
1039
|
logger.info("Downloading: 80% (unzipping)")
|
|
964
1040
|
try self.saveDownloaded(sourceZip: finalPath, id: id, base: self.libraryDir.appendingPathComponent(self.bundleDirectory), notify: true)
|
|
1041
|
+
self.populateDeltaCacheAsync(for: id)
|
|
965
1042
|
|
|
966
1043
|
} catch {
|
|
967
1044
|
logger.error("Failed to unzip file")
|
|
@@ -1306,7 +1383,7 @@ import UIKit
|
|
|
1306
1383
|
let fileName = url.lastPathComponent
|
|
1307
1384
|
// Only cleanup package_*.tmp and update_*.dat files
|
|
1308
1385
|
let isDownloadTemp = (fileName.hasPrefix("package_") && fileName.hasSuffix(".tmp")) ||
|
|
1309
|
-
|
|
1386
|
+
(fileName.hasPrefix("update_") && fileName.hasSuffix(".dat"))
|
|
1310
1387
|
if !isDownloadTemp {
|
|
1311
1388
|
continue
|
|
1312
1389
|
}
|
|
@@ -1667,21 +1744,70 @@ import UIKit
|
|
|
1667
1744
|
guard !statsUrl.isEmpty else {
|
|
1668
1745
|
return
|
|
1669
1746
|
}
|
|
1670
|
-
operationQueue.maxConcurrentOperationCount = 1
|
|
1671
1747
|
|
|
1672
|
-
let
|
|
1748
|
+
let resolvedVersionName = versionName ?? getCurrentBundle().getVersionName()
|
|
1749
|
+
let info = createInfoObject()
|
|
1750
|
+
|
|
1751
|
+
let event = StatsEvent(
|
|
1752
|
+
platform: info.platform,
|
|
1753
|
+
device_id: info.device_id,
|
|
1754
|
+
app_id: info.app_id,
|
|
1755
|
+
custom_id: info.custom_id,
|
|
1756
|
+
version_build: info.version_build,
|
|
1757
|
+
version_code: info.version_code,
|
|
1758
|
+
version_os: info.version_os,
|
|
1759
|
+
version_name: resolvedVersionName,
|
|
1760
|
+
old_version_name: oldVersionName ?? "",
|
|
1761
|
+
plugin_version: info.plugin_version,
|
|
1762
|
+
is_emulator: info.is_emulator,
|
|
1763
|
+
is_prod: info.is_prod,
|
|
1764
|
+
action: action,
|
|
1765
|
+
channel: info.channel,
|
|
1766
|
+
defaultChannel: info.defaultChannel,
|
|
1767
|
+
key_id: info.key_id,
|
|
1768
|
+
timestamp: Int64(Date().timeIntervalSince1970 * 1000)
|
|
1769
|
+
)
|
|
1770
|
+
|
|
1771
|
+
statsQueueLock.lock()
|
|
1772
|
+
statsQueue.append(event)
|
|
1773
|
+
statsQueueLock.unlock()
|
|
1673
1774
|
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1775
|
+
ensureStatsTimerStarted()
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
private func ensureStatsTimerStarted() {
|
|
1779
|
+
DispatchQueue.main.async { [weak self] in
|
|
1780
|
+
guard let self = self else { return }
|
|
1781
|
+
if self.statsFlushTimer == nil || !self.statsFlushTimer!.isValid {
|
|
1782
|
+
// Use closure-based timer to avoid strong reference cycle
|
|
1783
|
+
self.statsFlushTimer = Timer.scheduledTimer(
|
|
1784
|
+
withTimeInterval: CapgoUpdater.statsFlushInterval,
|
|
1785
|
+
repeats: true
|
|
1786
|
+
) { [weak self] _ in
|
|
1787
|
+
self?.flushStatsQueue()
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
private func flushStatsQueue() {
|
|
1794
|
+
statsQueueLock.lock()
|
|
1795
|
+
guard !statsQueue.isEmpty else {
|
|
1796
|
+
statsQueueLock.unlock()
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
let eventsToSend = statsQueue
|
|
1800
|
+
statsQueue.removeAll()
|
|
1801
|
+
statsQueueLock.unlock()
|
|
1802
|
+
|
|
1803
|
+
operationQueue.maxConcurrentOperationCount = 1
|
|
1678
1804
|
|
|
1679
1805
|
let operation = BlockOperation {
|
|
1680
1806
|
let semaphore = DispatchSemaphore(value: 0)
|
|
1681
1807
|
self.alamofireSession.request(
|
|
1682
1808
|
self.statsUrl,
|
|
1683
1809
|
method: .post,
|
|
1684
|
-
parameters:
|
|
1810
|
+
parameters: eventsToSend,
|
|
1685
1811
|
encoder: JSONParameterEncoder.default,
|
|
1686
1812
|
requestModifier: { $0.timeoutInterval = self.timeout }
|
|
1687
1813
|
).responseData { response in
|
|
@@ -1693,10 +1819,10 @@ import UIKit
|
|
|
1693
1819
|
|
|
1694
1820
|
switch response.result {
|
|
1695
1821
|
case .success:
|
|
1696
|
-
self.logger.info("Stats sent successfully")
|
|
1697
|
-
self.logger.debug("
|
|
1822
|
+
self.logger.info("Stats batch sent successfully")
|
|
1823
|
+
self.logger.debug("Sent \(eventsToSend.count) events")
|
|
1698
1824
|
case let .failure(error):
|
|
1699
|
-
self.logger.error("Error sending stats")
|
|
1825
|
+
self.logger.error("Error sending stats batch")
|
|
1700
1826
|
self.logger.debug("Response: \(response.value?.debugDescription ?? "nil"), Error: \(error.localizedDescription)")
|
|
1701
1827
|
}
|
|
1702
1828
|
semaphore.signal()
|
|
@@ -1704,7 +1830,6 @@ import UIKit
|
|
|
1704
1830
|
semaphore.wait()
|
|
1705
1831
|
}
|
|
1706
1832
|
operationQueue.addOperation(operation)
|
|
1707
|
-
|
|
1708
1833
|
}
|
|
1709
1834
|
|
|
1710
1835
|
public func getBundleInfo(id: String?) -> BundleInfo {
|
|
@@ -63,13 +63,21 @@ public struct CryptoCipher {
|
|
|
63
63
|
detectedFormat = "base64"
|
|
64
64
|
}
|
|
65
65
|
// swiftlint:disable:next line_length
|
|
66
|
-
logger.debug("Received
|
|
66
|
+
logger.debug("Received checksum format: \(detectedFormat) (length: \(checksum.count) chars, \(checksumBytes.count) bytes)")
|
|
67
67
|
|
|
68
68
|
if checksumBytes.isEmpty {
|
|
69
69
|
logger.error("Decoded checksum is empty")
|
|
70
70
|
throw CustomError.cannotDecode
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// RSA-2048 encrypted data must be exactly 256 bytes
|
|
74
|
+
// If the checksum is not 256 bytes, the bundle was not encrypted properly
|
|
75
|
+
if checksumBytes.count != 256 {
|
|
76
|
+
// swiftlint:disable:next line_length
|
|
77
|
+
logger.error("Checksum is not RSA encrypted (size: \(checksumBytes.count) bytes, expected 256 for RSA-2048). Bundle must be uploaded with encryption when public key is configured.")
|
|
78
|
+
throw CustomError.cannotDecode
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
|
|
74
82
|
logger.error("The public key is not a valid RSA Public key")
|
|
75
83
|
throw CustomError.cannotDecode
|
|
@@ -135,6 +135,28 @@ struct InfoObject: Codable {
|
|
|
135
135
|
}
|
|
136
136
|
// swiftlint:enable identifier_name
|
|
137
137
|
|
|
138
|
+
// swiftlint:disable identifier_name
|
|
139
|
+
struct StatsEvent: Codable {
|
|
140
|
+
let platform: String?
|
|
141
|
+
let device_id: String?
|
|
142
|
+
let app_id: String?
|
|
143
|
+
let custom_id: String?
|
|
144
|
+
let version_build: String?
|
|
145
|
+
let version_code: String?
|
|
146
|
+
let version_os: String?
|
|
147
|
+
let version_name: String?
|
|
148
|
+
let old_version_name: String?
|
|
149
|
+
let plugin_version: String?
|
|
150
|
+
let is_emulator: Bool?
|
|
151
|
+
let is_prod: Bool?
|
|
152
|
+
let action: String?
|
|
153
|
+
let channel: String?
|
|
154
|
+
let defaultChannel: String?
|
|
155
|
+
let key_id: String?
|
|
156
|
+
let timestamp: Int64
|
|
157
|
+
}
|
|
158
|
+
// swiftlint:enable identifier_name
|
|
159
|
+
|
|
138
160
|
// swiftlint:disable identifier_name
|
|
139
161
|
public struct ManifestEntry: Codable {
|
|
140
162
|
let file_name: String?
|