@capgo/capacitor-updater 7.50.2 → 7.51.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CapgoCapacitorUpdater.podspec +0 -2
  2. package/Package.swift +4 -5
  3. package/README.md +53 -48
  4. package/android/build.gradle +6 -6
  5. package/android/src/main/java/ee/forgr/capacitor_updater/AppLifecycleObserver.java +29 -2
  6. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +7 -3
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +1 -0
  8. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +427 -116
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1108 -182
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +102 -31
  11. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +23 -7
  12. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +2 -2
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +11 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +513 -201
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  16. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  17. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +115 -133
  18. package/dist/docs.json +32 -8
  19. package/dist/esm/definitions.d.ts +41 -17
  20. package/dist/esm/definitions.js.map +1 -1
  21. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  23. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +806 -92
  25. package/ios/Sources/CapacitorUpdaterPlugin/CapgoRawRsa.swift +36 -0
  26. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1020 -270
  27. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  28. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +73 -251
  29. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  30. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  31. package/package.json +13 -8
  32. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +0 -39
@@ -141,8 +141,24 @@ public struct CryptoCipher {
141
141
  }
142
142
  }
143
143
 
144
+ /// 256 KiB: one size for checksum, copy, and decode.
145
+ /// 64 workers * 256 KiB = 16 MiB for one buffer; AES/Brotli hold two (~32 MiB).
146
+ static let ioBufferBytesValue = 256 * 1024
147
+
148
+ static func ioBufferBytes() -> Int {
149
+ return ioBufferBytesValue
150
+ }
151
+
152
+ static func checksumBufferBytes() -> Int {
153
+ return ioBufferBytesValue
154
+ }
155
+
156
+ static func copyBufferBytes() -> Int {
157
+ return ioBufferBytesValue
158
+ }
159
+
144
160
  public static func calcChecksum(filePath: URL) -> String {
145
- let bufferSize = 1024 * 1024 * 5 // 5 MB
161
+ let bufferSize = checksumBufferBytes()
146
162
  var sha256 = SHA256()
147
163
 
148
164
  do {
@@ -182,8 +198,7 @@ public struct CryptoCipher {
182
198
  }
183
199
  }) {}
184
200
 
185
- let digest = sha256.finalize()
186
- return digest.compactMap { String(format: "%02x", $0) }.joined()
201
+ return hexString(from: sha256)
187
202
  } catch {
188
203
  logger.error("Cannot calculate checksum")
189
204
  logger.debug("Path: \(filePath.path), Error: \(error)")
@@ -191,6 +206,32 @@ public struct CryptoCipher {
191
206
  }
192
207
  }
193
208
 
209
+ final class RunningChecksum {
210
+ private var sha256 = SHA256()
211
+
212
+ func update(_ data: Data) {
213
+ guard !data.isEmpty else {
214
+ return
215
+ }
216
+ sha256.update(data: data)
217
+ }
218
+
219
+ func hex() -> String {
220
+ CryptoCipher.hexString(from: sha256)
221
+ }
222
+ }
223
+
224
+ static func hexString(from sha256: SHA256) -> String {
225
+ var copy = sha256
226
+ return copy.finalize().compactMap { String(format: "%02x", $0) }.joined()
227
+ }
228
+
229
+ static func shortPathKey(_ fileName: String) -> String {
230
+ var sha256 = SHA256()
231
+ sha256.update(data: Data(fileName.utf8))
232
+ return String(hexString(from: sha256).prefix(16))
233
+ }
234
+
194
235
  public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
195
236
  if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
196
237
  logger.info("Encryption not set, no public key or session, ignored")
@@ -243,42 +284,19 @@ public struct CryptoCipher {
243
284
 
244
285
  let aesPrivateKey = AES128Key(iv: ivData, aes128Key: sessionKeyDataDecrypted, logger: logger)
245
286
 
246
- let encryptedData: Data
247
- do {
248
- encryptedData = try Data(contentsOf: filePath)
249
- } catch {
250
- logger.error("Failed to read encrypted data")
251
- logger.debug("Error: \(error)")
252
- throw NSError(domain: "Failed to read encrypted data", code: 3, userInfo: nil)
253
- }
254
-
255
- if encryptedData.isEmpty {
287
+ let encryptedSize = (try FileManager.default.attributesOfItem(atPath: filePath.path)[.size] as? NSNumber)?.uint64Value ?? 0
288
+ if encryptedSize == 0 {
256
289
  logger.error("Encrypted file data is empty")
257
290
  throw NSError(domain: "Empty encrypted data", code: 6, userInfo: nil)
258
291
  }
259
292
 
260
- guard let decryptedData = aesPrivateKey.decrypt(data: encryptedData) else {
261
- logger.error("Failed to decrypt data")
262
- throw NSError(domain: "Failed to decrypt data", code: 4, userInfo: nil)
263
- }
264
-
265
- if decryptedData.isEmpty {
293
+ try aesPrivateKey.decrypt(from: filePath, to: filePath)
294
+ let decryptedSize = (try FileManager.default.attributesOfItem(atPath: filePath.path)[.size] as? NSNumber)?.uint64Value ?? 0
295
+ if decryptedSize == 0 {
266
296
  logger.error("Decrypted data is empty")
267
297
  throw NSError(domain: "Empty decrypted data", code: 7, userInfo: nil)
268
298
  }
269
299
 
270
- do {
271
- try decryptedData.write(to: filePath, options: .atomic)
272
- if !FileManager.default.fileExists(atPath: filePath.path) {
273
- logger.error("File was not created after write")
274
- throw NSError(domain: "File write failed", code: 8, userInfo: nil)
275
- }
276
- } catch {
277
- logger.error("Error writing decrypted file")
278
- logger.debug("Error: \(error)")
279
- throw error
280
- }
281
-
282
300
  } catch {
283
301
  logger.error("File decryption failed")
284
302
  throw CustomError.cannotDecode
@@ -1,274 +1,96 @@
1
- import BigInt
2
- import Foundation
3
- import CommonCrypto
4
- import CryptoKit
5
-
6
- ///
7
- /// Constants
8
- ///
9
- private enum RSAConstants {
10
- static let rsaKeySizeInBits: NSNumber = 2048
11
- static let rsaAlgorithm: SecKeyAlgorithm = .rsaEncryptionOAEPSHA256
12
- }
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
13
6
 
14
- // We do all this stuff because ios is shit and open source libraries allow to do decryption with public key
15
- // So we have to do it manually, while in nodejs or Java it's ok and done at language level.
7
+ import Foundation
8
+ import Security
9
+ import os.log
16
10
 
17
- ///
18
- /// The RSA public key.
19
- ///
11
+ /// Capgo v2 uses Node's privateEncrypt with PKCS#1 v1.5 type-1 padding for
12
+ /// checksums and session keys. Recover the signed payload using Apple's RSA
13
+ /// implementation instead of custom modular exponentiation.
20
14
  public struct RSAPublicKey {
21
- private let manualKey: ManualRSAPublicKey
22
-
23
- fileprivate init(manualKey: ManualRSAPublicKey) {
24
- self.manualKey = manualKey
25
- }
26
-
27
- ///
28
- /// Takes the data and uses the public key to decrypt it.
29
- /// Returns the decrypted data.
30
- ///
31
- public func decrypt(data: Data) -> Data? {
32
- return manualKey.decrypt(data)
33
- }
15
+ private let key: SecKey
16
+ private static let keyLock = NSLock()
17
+ private static let log = OSLog(subsystem: "ee.forgr.capacitor_updater", category: "RSA")
18
+ // One public key only: bounded storage, invalid/rotated keys never fall back
19
+ // to a cached key with different material. SecKey is immutable after import.
20
+ private static var cachedKey: (der: Data, key: SecKey)?
34
21
 
35
- ///
36
- /// Allows you to load an RSA public key (i.e. one downloaded from the net).
37
- ///
38
22
  public static func load(rsaPublicKey: String) -> RSAPublicKey? {
39
- // Clean up the key string
40
- var pubKey: String = rsaPublicKey
41
- pubKey = pubKey.replacingOccurrences(of: "-----BEGIN RSA PUBLIC KEY-----", with: "")
42
- pubKey = pubKey.replacingOccurrences(of: "-----END RSA PUBLIC KEY-----", with: "")
43
- pubKey = pubKey.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
44
- pubKey = pubKey.replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
45
- pubKey = pubKey.replacingOccurrences(of: "\\n+", with: "", options: .regularExpression)
46
- pubKey = pubKey.replacingOccurrences(of: "\n", with: "")
47
- pubKey = pubKey.trimmingCharacters(in: .whitespacesAndNewlines)
48
-
49
- do {
50
- guard let rsaPublicKeyData: Data = Data(base64Encoded: String(pubKey)) else {
51
- throw CustomError.cannotDecode
52
- }
53
-
54
- // Try parsing as PKCS#1
55
- if let manualKey = ManualRSAPublicKey.fromPKCS1(rsaPublicKeyData) {
56
- return RSAPublicKey(manualKey: manualKey)
57
- }
58
-
59
- // Most common public exponent is 65537 (0x010001)
60
- let commonExponent = Data([0x01, 0x00, 0x01]) // 65537 in big-endian
61
-
62
- // Assume the entire key data is the modulus
63
- let lastResortKey = ManualRSAPublicKey(modulus: rsaPublicKeyData, exponent: commonExponent)
64
- return RSAPublicKey(manualKey: lastResortKey)
65
- } catch {
66
- return nil
67
- }
68
- }
69
- }
70
-
71
- // Manual RSA Public Key Implementation using the BigInt library
72
- struct ManualRSAPublicKey {
73
- let modulus: BigInt
74
- let exponent: BigInt
75
-
76
- init(modulus: Data, exponent: Data) {
77
- // Create positive BigInts from Data
78
- let modulusBytes = [UInt8](modulus)
79
- var modulusValue = BigUInt(0)
80
- for byte in modulusBytes {
81
- modulusValue = (modulusValue << 8) | BigUInt(byte)
82
- }
83
- self.modulus = BigInt(modulusValue)
84
- let exponentBytes = [UInt8](exponent)
85
- var exponentValue = BigUInt(0)
86
- for byte in exponentBytes {
87
- exponentValue = (exponentValue << 8) | BigUInt(byte)
88
- }
89
- self.exponent = BigInt(exponentValue)
90
- }
91
-
92
- // Parse PKCS#1 format public key
93
- static func fromPKCS1(_ publicKeyData: Data) -> ManualRSAPublicKey? {
94
- // Parse ASN.1 DER encoded RSA public key
95
- // Format: RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }
96
-
97
- guard publicKeyData.count > 0 else {
98
- return nil
99
- }
100
-
101
- let bytes = [UInt8](publicKeyData)
102
-
103
- // Check for sequence tag (0x30)
104
- guard bytes[0] == 0x30 else {
105
- // Try direct modulus/exponent approach as fallback
106
- if publicKeyData.count >= 3 {
107
- // Assume this is a raw RSA public key with modulus + exponent
108
- // Most common: modulus is 256 bytes (2048 bits), exponent is 3 bytes (0x010001 = 65537)
109
- let modulusSize = publicKeyData.count - 3
110
- if modulusSize > 0 {
111
- let modulusData = publicKeyData.prefix(modulusSize)
112
- let exponentData = publicKeyData.suffix(3)
113
- return ManualRSAPublicKey(modulus: modulusData, exponent: exponentData)
114
- }
115
- }
116
-
117
- return nil
118
- }
119
-
120
- var index = 1
121
-
122
- // Skip length
123
- if bytes[index] & 0x80 != 0 {
124
- let lenBytes = Int(bytes[index] & 0x7F)
125
- if (index + 1 + lenBytes) >= bytes.count {
126
- return nil
127
- }
128
- index += 1 + lenBytes
129
- } else {
130
- index += 1
131
- }
132
-
133
- // Check for INTEGER tag for modulus (0x02)
134
- if index >= bytes.count {
135
- return nil
136
- }
137
-
138
- guard bytes[index] == 0x02 else {
139
- return nil
140
- }
141
- index += 1
142
-
143
- // Get modulus length
144
- if index >= bytes.count {
145
- return nil
146
- }
147
-
148
- var modulusLength = 0
149
- if bytes[index] & 0x80 != 0 {
150
- let lenBytes = Int(bytes[index] & 0x7F)
151
- if (index + 1 + lenBytes) >= bytes.count {
152
- return nil
153
- }
154
- index += 1
155
- for i in 0..<lenBytes {
156
- modulusLength = (modulusLength << 8) | Int(bytes[index + i])
157
- }
158
- index += lenBytes
159
- } else {
160
- modulusLength = Int(bytes[index])
161
- index += 1
162
- }
163
-
164
- // Skip any leading zero in modulus (for unsigned integer)
165
- if index < bytes.count && bytes[index] == 0x00 {
166
- index += 1
167
- modulusLength -= 1
168
- }
169
-
170
- // Extract modulus
171
- if (index + modulusLength) > bytes.count {
172
- return nil
173
- }
174
-
175
- let modulusData = Data(bytes[index..<(index + modulusLength)])
176
- index += modulusLength
177
-
178
- // Check for INTEGER tag for exponent
179
- if index >= bytes.count {
23
+ guard let der = pemToDer(rsaPublicKey) else {
24
+ os_log("RSA key import failed: invalid PEM/base64", log: Self.log, type: .error)
180
25
  return nil
181
26
  }
182
27
 
183
- guard bytes[index] == 0x02 else {
28
+ keyLock.lock()
29
+ defer { keyLock.unlock() }
30
+ if let cached = cachedKey, cached.der == der {
31
+ return RSAPublicKey(key: cached.key)
32
+ }
33
+
34
+ let attributes: [CFString: Any] = [
35
+ kSecAttrKeyType: kSecAttrKeyTypeRSA,
36
+ kSecAttrKeyClass: kSecAttrKeyClassPublic
37
+ ]
38
+ var error: Unmanaged<CFError>?
39
+ guard let key = SecKeyCreateWithData(der as CFData, attributes as CFDictionary, &error) else {
40
+ os_log(
41
+ "RSA key import failed: %{public}@",
42
+ log: Self.log,
43
+ type: .error,
44
+ String(describing: error?.takeRetainedValue())
45
+ )
184
46
  return nil
185
47
  }
186
- index += 1
187
-
188
- // Get exponent length
189
- if index >= bytes.count {
48
+ guard CapgoRawRsa.supportsCapgoRecovery(key: key) else {
49
+ os_log(
50
+ "RSA key import failed: unsupported key (Capgo requires RSA-2048 with raw encryption)",
51
+ log: Self.log,
52
+ type: .error
53
+ )
190
54
  return nil
191
55
  }
56
+ cachedKey = (der, key)
57
+ return RSAPublicKey(key: key)
58
+ }
192
59
 
193
- var exponentLength = 0
194
- if bytes[index] & 0x80 != 0 {
195
- let lenBytes = Int(bytes[index] & 0x7F)
196
- if (index + 1 + lenBytes) >= bytes.count {
197
- return nil
198
- }
199
- index += 1
200
- for i in 0..<lenBytes {
201
- exponentLength = (exponentLength << 8) | Int(bytes[index + i])
202
- }
203
- index += lenBytes
204
- } else {
205
- exponentLength = Int(bytes[index])
206
- index += 1
207
- }
208
-
209
- // Extract exponent
210
- if (index + exponentLength) > bytes.count {
60
+ public func decrypt(data: Data) -> Data? {
61
+ let blockSize = SecKeyGetBlockSize(key)
62
+ guard data.count == blockSize else { return nil }
63
+ // Raw public-key encryption is the same c^e mod n operation as Node's
64
+ // publicDecrypt. Do NOT use encryption PKCS#1 padding here: the recovered
65
+ // block already contains signature (type-1) padding, checked below.
66
+ guard let recovered = CapgoRawRsa.recoverSignatureBlock(key: key, data: data) else {
211
67
  return nil
212
68
  }
213
-
214
- let exponentData = Data(bytes[index..<(index + exponentLength)])
215
- return ManualRSAPublicKey(modulus: modulusData, exponent: exponentData)
69
+ return Self.unpadSignature(recovered, blockSize: blockSize)
216
70
  }
217
71
 
218
- // Decrypt data using raw RSA operation (c^d mod n)
219
- func decrypt(_ encryptedData: Data) -> Data? {
220
- // Create positive BigInt from encrypted data
221
- let encryptedBytes = [UInt8](encryptedData)
222
- var encryptedValue = BigUInt(0)
223
- for byte in encryptedBytes {
224
- encryptedValue = (encryptedValue << 8) | BigUInt(byte)
72
+ static func unpadSignature(_ block: Data, blockSize: Int = 256) -> Data? {
73
+ let bytes = [UInt8](block)
74
+ // RFC 8017: 00 01, at least eight FF bytes, 00, then the payload.
75
+ guard bytes.count == blockSize, bytes[0] == 0, bytes[1] == 1 else { return nil }
76
+ var separator = 2
77
+ while separator < bytes.count && bytes[separator] == 0xff {
78
+ separator += 1
225
79
  }
226
- let encrypted = BigInt(encryptedValue)
227
-
228
- // In Node.js:
229
- // privateEncrypt uses the private key (d) to encrypt
230
- // publicDecrypt uses the public key (e) to decrypt
231
- // The operation we want is: ciphertext^e mod n
232
-
233
- // RSA operation: c^e mod n
234
- let decrypted = encrypted.manualPower(exponent, modulus: modulus)
235
-
236
- // Convert to bytes with proper padding
237
- guard let bigUIntValue = decrypted.magnitude as? BigUInt else {
80
+ guard separator >= 10, separator < bytes.count - 1, bytes[separator] == 0 else {
238
81
  return nil
239
82
  }
83
+ return Data(bytes[(separator + 1)...])
84
+ }
240
85
 
241
- // Convert BigUInt to bytes with padding
242
- var resultBytes = [UInt8]()
243
- var tempValue = bigUIntValue
244
- while tempValue > 0 {
245
- let byte = UInt8(tempValue & 0xFF)
246
- resultBytes.insert(byte, at: 0) // Prepend to get big-endian
247
- tempValue >>= 8
248
- }
249
-
250
- // Ensure we have at least 256 bytes (2048 bits) with leading zeros
251
- let paddedBytes = [UInt8](repeating: 0, count: max(0, 256 - resultBytes.count)) + resultBytes
252
-
253
- // For PKCS1 padding from Node.js privateEncrypt, the format is:
254
- // 0x00 || 0x01 || PS || 0x00 || actual data
255
- // where PS is a string of 0xFF bytes
256
-
257
- // Check for privateEncrypt padding format (0x00 || 0x01 || PS || 0x00)
258
- var startIndex = 0
259
- if paddedBytes.count > 2 && paddedBytes[0] == 0x00 && paddedBytes[1] == 0x01 {
260
- for i in 2..<paddedBytes.count {
261
- if paddedBytes[i] == 0x00 {
262
- startIndex = i + 1
263
- break
264
- }
265
- }
266
- }
267
- if startIndex < paddedBytes.count {
268
- let result = Data(paddedBytes[startIndex...])
269
- return result
270
- } else {
271
- return Data(paddedBytes)
272
- }
86
+ private static func pemToDer(_ pem: String) -> Data? {
87
+ let encoded = pem
88
+ .replacingOccurrences(of: "-----BEGIN RSA PUBLIC KEY-----", with: "")
89
+ .replacingOccurrences(of: "-----END RSA PUBLIC KEY-----", with: "")
90
+ .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
91
+ .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
92
+ .replacingOccurrences(of: "\\n", with: "")
93
+ .components(separatedBy: .whitespacesAndNewlines).joined()
94
+ return Data(base64Encoded: encoded)
273
95
  }
274
96
  }
@@ -92,18 +92,26 @@ final class ThreeFingerPinchGestureRecognizer: UIGestureRecognizer {
92
92
  }
93
93
 
94
94
  extension UIApplication {
95
- // swiftlint:disable:next line_length
96
- public class func topViewController(_ base: UIViewController? = UIApplication.shared.windows.first?.rootViewController) -> UIViewController? {
97
- if let nav = base as? UINavigationController {
95
+ public class func topViewController(_ base: UIViewController? = nil) -> UIViewController? {
96
+ let resolvedBase = base ?? keyWindowRootViewController
97
+ if let nav = resolvedBase as? UINavigationController {
98
98
  return topViewController(nav.visibleViewController)
99
99
  }
100
- if let tab = base as? UITabBarController, let selected = tab.selectedViewController {
100
+ if let tab = resolvedBase as? UITabBarController, let selected = tab.selectedViewController {
101
101
  return topViewController(selected)
102
102
  }
103
- if let presented = base?.presentedViewController {
103
+ if let presented = resolvedBase?.presentedViewController {
104
104
  return topViewController(presented)
105
105
  }
106
- return base
106
+ return resolvedBase
107
+ }
108
+
109
+ private static var keyWindowRootViewController: UIViewController? {
110
+ let scenes = shared.connectedScenes.compactMap { $0 as? UIWindowScene }
111
+ let foregroundScenes = scenes.filter { $0.activationState == .foregroundActive }
112
+ let candidates = foregroundScenes.isEmpty ? scenes : foregroundScenes
113
+ let window = candidates.flatMap(\.windows).first(where: \.isKeyWindow) ?? candidates.first?.windows.first
114
+ return window?.rootViewController
107
115
  }
108
116
  }
109
117
 
@@ -184,6 +192,10 @@ extension CapacitorUpdaterPlugin: UIGestureRecognizerDelegate {
184
192
  }
185
193
 
186
194
  extension UIWindow {
195
+ private var menuHostViewController: UIViewController? {
196
+ UIApplication.topViewController(rootViewController)
197
+ }
198
+
187
199
  override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
188
200
  if motion == .motionShake {
189
201
  guard let bridgeViewController = rootViewController as? CAPBridgeViewController,
@@ -230,7 +242,7 @@ extension UIWindow {
230
242
  @discardableResult
231
243
  private func showDefaultMenu(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) -> Bool {
232
244
  // Prevent multiple alerts from showing
233
- guard let topVC = UIApplication.topViewController() else {
245
+ guard let topVC = self.menuHostViewController else {
234
246
  return false
235
247
  }
236
248
  if topVC.isKind(of: UIAlertController.self) {
@@ -365,7 +377,7 @@ extension UIWindow {
365
377
  })
366
378
 
367
379
  DispatchQueue.main.async {
368
- if let topVC = UIApplication.topViewController() {
380
+ if let topVC = self.menuHostViewController {
369
381
  topVC.present(alertShake, animated: true)
370
382
  }
371
383
  }
@@ -388,7 +400,7 @@ extension UIWindow {
388
400
  }
389
401
 
390
402
  private func showPreviewSelector(plugin: CapacitorUpdaterPlugin) {
391
- guard let topVC = UIApplication.topViewController() else {
403
+ guard let topVC = self.menuHostViewController else {
392
404
  return
393
405
  }
394
406
  if topVC.isKind(of: UIAlertController.self) {
@@ -456,7 +468,7 @@ extension UIWindow {
456
468
  alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
457
469
 
458
470
  DispatchQueue.main.async {
459
- if let topVC = UIApplication.topViewController() {
471
+ if let topVC = self.menuHostViewController {
460
472
  topVC.present(alert, animated: true)
461
473
  }
462
474
  }
@@ -479,7 +491,7 @@ extension UIWindow {
479
491
  }
480
492
 
481
493
  DispatchQueue.main.async {
482
- if let topVC = UIApplication.topViewController() {
494
+ if let topVC = self.menuHostViewController {
483
495
  topVC.present(alert, animated: true)
484
496
  }
485
497
  }
@@ -498,7 +510,7 @@ extension UIWindow {
498
510
  @discardableResult
499
511
  private func showChannelSelector(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) -> Bool {
500
512
  // Prevent multiple alerts from showing
501
- guard let topVC = UIApplication.topViewController() else {
513
+ guard let topVC = self.menuHostViewController else {
502
514
  return false
503
515
  }
504
516
  if topVC.isKind(of: UIAlertController.self) {
@@ -582,7 +594,7 @@ extension UIWindow {
582
594
  }
583
595
 
584
596
  DispatchQueue.main.async {
585
- if let topVC = UIApplication.topViewController() {
597
+ if let topVC = self.menuHostViewController {
586
598
  topVC.present(alert, animated: true)
587
599
  }
588
600
  }
@@ -627,7 +639,7 @@ extension UIWindow {
627
639
  alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
628
640
 
629
641
  DispatchQueue.main.async {
630
- if let topVC = UIApplication.topViewController() {
642
+ if let topVC = self.menuHostViewController {
631
643
  topVC.present(alert, animated: true)
632
644
  }
633
645
  }
@@ -649,7 +661,7 @@ extension UIWindow {
649
661
  ])
650
662
 
651
663
  DispatchQueue.main.async {
652
- if let topVC = UIApplication.topViewController() {
664
+ if let topVC = self.menuHostViewController {
653
665
  topVC.present(progressAlert, animated: true) {
654
666
  DispatchQueue.global(qos: .userInitiated).async {
655
667
  // Set the channel - respect plugin's allowSetDefaultChannel config
@@ -668,6 +680,14 @@ extension UIWindow {
668
680
  }
669
681
  return
670
682
  }
683
+ guard plugin.persistDefaultChannelStateFromDefaults() else {
684
+ DispatchQueue.main.async {
685
+ progressAlert.dismiss(animated: true) {
686
+ self.showError(message: "Channel set to \(name), but local persistence failed.", plugin: plugin)
687
+ }
688
+ }
689
+ return
690
+ }
671
691
 
672
692
  // Update progress message
673
693
  DispatchQueue.main.async {
@@ -718,8 +738,12 @@ extension UIWindow {
718
738
  return
719
739
  }
720
740
 
721
- // Check if there's an actual update available
722
- if latestKind == "up_to_date" || latest.url.isEmpty {
741
+ // Check if there's an actual update available. A manifest-only
742
+ // response legitimately has no URL (the files come from the
743
+ // manifest, not a zip), so only report "already on latest" when
744
+ // the URL is empty AND there is no manifest to download from.
745
+ let hasManifest = !(latest.manifest?.isEmpty ?? true)
746
+ if latestKind == "up_to_date" || (latest.url.isEmpty && !hasManifest) {
723
747
  DispatchQueue.main.async {
724
748
  progressAlert.dismiss(animated: true) {
725
749
  self.showSuccess(message: "Channel set to \(name). Already on latest version.", plugin: plugin)
@@ -794,7 +818,7 @@ extension UIWindow {
794
818
  alert.addAction(UIAlertAction(title: "OK", style: .default))
795
819
 
796
820
  DispatchQueue.main.async {
797
- if let topVC = UIApplication.topViewController() {
821
+ if let topVC = self.menuHostViewController {
798
822
  topVC.present(alert, animated: true)
799
823
  }
800
824
  }
@@ -806,7 +830,7 @@ extension UIWindow {
806
830
  alert.addAction(UIAlertAction(title: "OK", style: .default))
807
831
 
808
832
  DispatchQueue.main.async {
809
- if let topVC = UIApplication.topViewController() {
833
+ if let topVC = self.menuHostViewController {
810
834
  topVC.present(alert, animated: true)
811
835
  }
812
836
  }
@@ -832,7 +856,7 @@ extension UIWindow {
832
856
  })
833
857
 
834
858
  DispatchQueue.main.async {
835
- if let topVC = UIApplication.topViewController() {
859
+ if let topVC = self.menuHostViewController {
836
860
  topVC.present(alert, animated: true)
837
861
  }
838
862
  }