@capgo/capacitor-updater 5.50.1 → 5.51.15

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 (28) hide show
  1. package/CapgoCapacitorUpdater.podspec +1 -1
  2. package/Package.swift +3 -2
  3. package/README.md +53 -48
  4. package/android/build.gradle +1 -0
  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 +476 -151
  9. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1354 -412
  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/DownloadService.java +540 -224
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +103 -3
  15. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +1 -1
  16. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +131 -145
  17. package/dist/docs.json +32 -8
  18. package/dist/esm/definitions.d.ts +41 -17
  19. package/dist/esm/definitions.js.map +1 -1
  20. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +124 -0
  21. package/ios/Sources/CapacitorUpdaterPlugin/BundleInfo.swift +9 -1
  22. package/ios/Sources/CapacitorUpdaterPlugin/BundleStatus.swift +3 -0
  23. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +787 -92
  24. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1014 -268
  25. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +49 -31
  26. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +44 -20
  27. package/ios/Sources/CapacitorUpdaterPlugin/WebViewStatsReporter.swift +28 -0
  28. package/package.json +12 -7
@@ -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 {
@@ -175,8 +191,7 @@ public struct CryptoCipher {
175
191
  }
176
192
  }) {}
177
193
 
178
- let digest = sha256.finalize()
179
- return digest.compactMap { String(format: "%02x", $0) }.joined()
194
+ return hexString(from: sha256)
180
195
  } catch {
181
196
  logger.error("Cannot calculate checksum")
182
197
  logger.debug("Path: \(filePath.path), Error: \(error)")
@@ -184,6 +199,32 @@ public struct CryptoCipher {
184
199
  }
185
200
  }
186
201
 
202
+ final class RunningChecksum {
203
+ private var sha256 = SHA256()
204
+
205
+ func update(_ data: Data) {
206
+ guard !data.isEmpty else {
207
+ return
208
+ }
209
+ sha256.update(data: data)
210
+ }
211
+
212
+ func hex() -> String {
213
+ CryptoCipher.hexString(from: sha256)
214
+ }
215
+ }
216
+
217
+ static func hexString(from sha256: SHA256) -> String {
218
+ var copy = sha256
219
+ return copy.finalize().compactMap { String(format: "%02x", $0) }.joined()
220
+ }
221
+
222
+ static func shortPathKey(_ fileName: String) -> String {
223
+ var sha256 = SHA256()
224
+ sha256.update(data: Data(fileName.utf8))
225
+ return String(hexString(from: sha256).prefix(16))
226
+ }
227
+
187
228
  public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
188
229
  if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
189
230
  logger.info("Encryption not set, no public key or session, ignored")
@@ -236,42 +277,19 @@ public struct CryptoCipher {
236
277
 
237
278
  let aesPrivateKey = AES128Key(iv: ivData, aes128Key: sessionKeyDataDecrypted, logger: logger)
238
279
 
239
- let encryptedData: Data
240
- do {
241
- encryptedData = try Data(contentsOf: filePath)
242
- } catch {
243
- logger.error("Failed to read encrypted data")
244
- logger.debug("Error: \(error)")
245
- throw NSError(domain: "Failed to read encrypted data", code: 3, userInfo: nil)
246
- }
247
-
248
- if encryptedData.isEmpty {
280
+ let encryptedSize = (try FileManager.default.attributesOfItem(atPath: filePath.path)[.size] as? NSNumber)?.uint64Value ?? 0
281
+ if encryptedSize == 0 {
249
282
  logger.error("Encrypted file data is empty")
250
283
  throw NSError(domain: "Empty encrypted data", code: 6, userInfo: nil)
251
284
  }
252
285
 
253
- guard let decryptedData = aesPrivateKey.decrypt(data: encryptedData) else {
254
- logger.error("Failed to decrypt data")
255
- throw NSError(domain: "Failed to decrypt data", code: 4, userInfo: nil)
256
- }
257
-
258
- if decryptedData.isEmpty {
286
+ try aesPrivateKey.decrypt(from: filePath, to: filePath)
287
+ let decryptedSize = (try FileManager.default.attributesOfItem(atPath: filePath.path)[.size] as? NSNumber)?.uint64Value ?? 0
288
+ if decryptedSize == 0 {
259
289
  logger.error("Decrypted data is empty")
260
290
  throw NSError(domain: "Empty decrypted data", code: 7, userInfo: nil)
261
291
  }
262
292
 
263
- do {
264
- try decryptedData.write(to: filePath, options: .atomic)
265
- if !FileManager.default.fileExists(atPath: filePath.path) {
266
- logger.error("File was not created after write")
267
- throw NSError(domain: "File write failed", code: 8, userInfo: nil)
268
- }
269
- } catch {
270
- logger.error("Error writing decrypted file")
271
- logger.debug("Error: \(error)")
272
- throw error
273
- }
274
-
275
293
  } catch {
276
294
  logger.error("File decryption failed")
277
295
  throw CustomError.cannotDecode
@@ -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
  }
@@ -86,6 +86,10 @@ final class WebViewStatsReporter {
86
86
  }
87
87
  writeSession(true);
88
88
  setInterval(function(){writeSession(true);},15000);
89
+ function pageDuration(){
90
+ var started=Number(window.__capgoWebViewSessionStartedAt||Date.now());
91
+ return String(Math.max(0,Date.now()-started));
92
+ }
89
93
  function markClean(){writeSession(false);}
90
94
  window.addEventListener('pagehide',markClean,true);
91
95
  window.addEventListener('beforeunload',markClean,true);
@@ -120,6 +124,22 @@ final class WebViewStatsReporter {
120
124
  source:s(event&&event.blockedURI)
121
125
  });
122
126
  },true);
127
+ document.addEventListener('DOMContentLoaded',function(){
128
+ send({
129
+ type:'webview_dom_content_loaded',
130
+ message:'WebView DOM content loaded',
131
+ duration_ms:pageDuration(),
132
+ page_started_at:String(window.__capgoWebViewSessionStartedAt)
133
+ });
134
+ },true);
135
+ window.addEventListener('load',function(){
136
+ send({
137
+ type:'webview_page_loaded',
138
+ message:'WebView page loaded',
139
+ duration_ms:pageDuration(),
140
+ page_started_at:String(window.__capgoWebViewSessionStartedAt)
141
+ });
142
+ },true);
123
143
  document.addEventListener('deviceready',scheduleFlush,false);
124
144
  setTimeout(scheduleFlush,0);
125
145
  })();
@@ -164,6 +184,8 @@ final class WebViewStatsReporter {
164
184
  "href": call.getString("href"),
165
185
  "user_agent": call.getString("user_agent"),
166
186
  "session_id": call.getString("session_id"),
187
+ "duration_ms": call.getString("duration_ms"),
188
+ "page_started_at": call.getString("page_started_at"),
167
189
  "previous_session_id": call.getString("previous_session_id"),
168
190
  "previous_href": call.getString("previous_href"),
169
191
  "previous_started_at": call.getString("previous_started_at"),
@@ -187,6 +209,10 @@ final class WebViewStatsReporter {
187
209
  return "webview_render_process_gone"
188
210
  case "web_content_process_terminated":
189
211
  return "webview_content_process_terminated"
212
+ case "webview_dom_content_loaded":
213
+ return "webview_dom_content_loaded"
214
+ case "webview_page_loaded":
215
+ return "webview_page_loaded"
190
216
  case "javascript_error":
191
217
  return "webview_javascript_error"
192
218
  default:
@@ -206,6 +232,8 @@ final class WebViewStatsReporter {
206
232
  put(&metadata, key: "href", value: sanitizeUrl(payloadValue(values, "href")), maxLength: 512)
207
233
  put(&metadata, key: "user_agent", value: payloadValue(values, "user_agent"), maxLength: 256)
208
234
  put(&metadata, key: "session_id", value: payloadValue(values, "session_id"), maxLength: 128)
235
+ put(&metadata, key: "duration_ms", value: payloadValue(values, "duration_ms"), maxLength: 32)
236
+ put(&metadata, key: "page_started_at", value: payloadValue(values, "page_started_at"), maxLength: 64)
209
237
  put(&metadata, key: "previous_session_id", value: payloadValue(values, "previous_session_id"), maxLength: 128)
210
238
  put(&metadata, key: "previous_href", value: sanitizeUrl(payloadValue(values, "previous_href")), maxLength: 512)
211
239
  put(&metadata, key: "previous_started_at", value: payloadValue(values, "previous_started_at"), maxLength: 64)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "5.50.1",
3
+ "version": "5.51.15",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",
@@ -72,15 +72,20 @@
72
72
  "watch": "tsc --watch",
73
73
  "prepublishOnly": "bun run build",
74
74
  "check:wiring": "node scripts/check-capacitor-plugin-wiring.mjs",
75
+ "changelog:ai": "node scripts/generate-ai-changelog.mjs",
75
76
  "example:install": "cd example-app && bun install --frozen-lockfile",
76
- "example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build"
77
+ "example:build": "bun run build && cd example-app && bun install --frozen-lockfile && bun run build",
78
+ "native:contract:crypto": "bun run native:contract:crypto:ios && bun run native:contract:crypto:android",
79
+ "native:contract:crypto:ios": "./scripts/test-ios.sh -only-testing:CapacitorUpdaterPluginTests/RsaContractTests",
80
+ "native:contract:crypto:android": "cd android && ./gradlew testDebugUnitTest --tests ee.forgr.capacitor_updater.RsaContractTest && cd ..",
81
+ "generate:rsa-contract": "bun scripts/generate-rsa-contract-fixtures.mjs"
77
82
  },
78
83
  "devDependencies": {
79
- "@capacitor/android": "^5.5.0",
80
- "@capacitor/cli": "^5.5.0",
81
- "@capacitor/core": "^5.5.0",
82
- "@capacitor/docgen": "^0.2.1",
83
- "@capacitor/ios": "^5.5.0",
84
+ "@capacitor/android": "^5.0.0",
85
+ "@capacitor/cli": "^5.0.0",
86
+ "@capacitor/core": "^5.0.0",
87
+ "@capacitor/docgen": "^0.3.0",
88
+ "@capacitor/ios": "^5.0.0",
84
89
  "@ionic/eslint-config": "^0.4.0",
85
90
  "@ionic/prettier-config": "^4.0.0",
86
91
  "@ionic/swiftlint-config": "^2.0.0",