@capgo/capacitor-updater 8.46.3 → 8.47.1

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.
@@ -21,6 +21,7 @@ import UIKit
21
21
  private let INFO_SUFFIX: String = "_info"
22
22
  private let FALLBACK_VERSION: String = "pastVersion"
23
23
  private let NEXT_VERSION: String = "nextVersion"
24
+ private let PREVIEW_FALLBACK_VERSION: String = "previewFallbackVersion"
24
25
  private var unzipPercent = 0
25
26
  private let TEMP_UNZIP_PREFIX: String = "capgo_unzip_"
26
27
 
@@ -37,6 +38,7 @@ import UIKit
37
38
  public var defaultChannel: String = ""
38
39
  public var appId: String = ""
39
40
  public var deviceID = ""
41
+ public var previewSession = false
40
42
  public var publicKey: String = ""
41
43
 
42
44
  // Cached key ID calculated once from publicKey
@@ -363,7 +365,7 @@ import UIKit
363
365
  if statusCode == 429 {
364
366
  // Send a statistic about the rate limit BEFORE setting the flag
365
367
  // Only send once to prevent infinite loop if the stat request itself gets rate limited
366
- if !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
368
+ if !previewSession && !CapgoUpdater.rateLimitExceeded && !CapgoUpdater.rateLimitStatisticSent {
367
369
  CapgoUpdater.rateLimitStatisticSent = true
368
370
 
369
371
  // Dispatch to background queue to avoid blocking the main thread
@@ -701,11 +703,11 @@ import UIKit
701
703
  }
702
704
  }
703
705
 
704
- private func createInfoObject() -> InfoObject {
706
+ private func createInfoObject(appIdOverride: String? = nil) -> InfoObject {
705
707
  return InfoObject(
706
708
  platform: "ios",
707
709
  device_id: self.deviceID,
708
- app_id: self.appId,
710
+ app_id: appIdOverride ?? self.appId,
709
711
  custom_id: self.customId,
710
712
  version_build: self.versionBuild,
711
713
  version_code: self.versionCode,
@@ -721,7 +723,7 @@ import UIKit
721
723
  )
722
724
  }
723
725
 
724
- public func getLatest(url: URL, channel: String?) -> AppVersion {
726
+ public func getLatest(url: URL, channel: String?, appIdOverride: String? = nil) -> AppVersion {
725
727
  let latest: AppVersion = AppVersion()
726
728
  func applyLatestResponse(_ value: AppVersionDec?) {
727
729
  if let url = value?.url {
@@ -765,7 +767,7 @@ import UIKit
765
767
  }
766
768
  }
767
769
 
768
- var parameters: InfoObject = self.createInfoObject()
770
+ var parameters: InfoObject = self.createInfoObject(appIdOverride: appIdOverride)
769
771
  if let channel = channel {
770
772
  parameters.defaultChannel = channel
771
773
  }
@@ -874,6 +876,132 @@ import UIKit
874
876
  return actualHash == expectedHash
875
877
  }
876
878
 
879
+ private func resolveManifestFileHash(entry: ManifestEntry, sessionKey: String) -> String? {
880
+ guard var fileHash = entry.file_hash, !fileHash.isEmpty else {
881
+ return nil
882
+ }
883
+ if !self.publicKey.isEmpty && !sessionKey.isEmpty {
884
+ do {
885
+ fileHash = try CryptoCipher.decryptChecksum(checksum: fileHash, publicKey: self.publicKey)
886
+ } catch {
887
+ logger.error("Checksum decryption failed while checking missing manifest files")
888
+ logger.debug("File: \(entry.file_name ?? "unknown"), Error: \(error.localizedDescription)")
889
+ return nil
890
+ }
891
+ }
892
+ return fileHash
893
+ }
894
+
895
+ private func isManifestEntryAvailableLocally(entry: ManifestEntry, sessionKey: String) -> Bool {
896
+ guard let fileName = entry.file_name,
897
+ let fileHash = resolveManifestFileHash(entry: entry, sessionKey: sessionKey) else {
898
+ return false
899
+ }
900
+
901
+ let builtinFolder = Bundle.main.bundleURL.appendingPathComponent("public")
902
+ let builtinFilePath = builtinFolder.appendingPathComponent(fileName)
903
+ if FileManager.default.fileExists(atPath: builtinFilePath.path) && verifyChecksum(file: builtinFilePath, expectedHash: fileHash) {
904
+ return true
905
+ }
906
+
907
+ let fileNameWithoutPath = (fileName as NSString).lastPathComponent
908
+ let isBrotli = fileName.hasSuffix(".br")
909
+ let cacheBaseName = isBrotli ? String(fileNameWithoutPath.dropLast(3)) : fileNameWithoutPath
910
+ let cacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(cacheBaseName)")
911
+ if FileManager.default.fileExists(atPath: cacheFilePath.path) && verifyChecksum(file: cacheFilePath, expectedHash: fileHash) {
912
+ return true
913
+ }
914
+
915
+ if isBrotli {
916
+ let legacyCacheFilePath = cacheFolder.appendingPathComponent("\(fileHash)_\(fileNameWithoutPath)")
917
+ if FileManager.default.fileExists(atPath: legacyCacheFilePath.path) && verifyChecksum(file: legacyCacheFilePath, expectedHash: fileHash) {
918
+ return true
919
+ }
920
+ }
921
+
922
+ return false
923
+ }
924
+
925
+ public func getMissingBundleFiles(manifest: [ManifestEntry], sessionKey: String) -> [ManifestEntry] {
926
+ return manifest.filter { entry in
927
+ !isManifestEntryAvailableLocally(entry: entry, sessionKey: sessionKey)
928
+ }
929
+ }
930
+
931
+ public func missingBundleFilesResult(manifest: [ManifestEntry], sessionKey: String) -> [String: Any] {
932
+ let missing = getMissingBundleFiles(manifest: manifest, sessionKey: sessionKey)
933
+ return [
934
+ "missing": missing.map { $0.toDict() },
935
+ "total": manifest.count,
936
+ "missingCount": missing.count,
937
+ "reusableCount": manifest.count - missing.count
938
+ ]
939
+ }
940
+
941
+ private func manifestSizeUrl(from updateUrl: URL) -> URL {
942
+ var components = URLComponents(url: updateUrl, resolvingAgainstBaseURL: false)
943
+ let path = components?.path ?? updateUrl.path
944
+ let trimmedPath = path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
945
+ components?.path = trimmedPath == ""
946
+ ? "/manifest_size"
947
+ : "/\(trimmedPath)/manifest_size"
948
+ components?.query = nil
949
+ return components?.url ?? updateUrl.appendingPathComponent("manifest_size")
950
+ }
951
+
952
+ private func unavailableBundleSizeResult(manifest: [ManifestEntry], error: String) -> [String: Any] {
953
+ return [
954
+ "totalSize": 0,
955
+ "knownFiles": 0,
956
+ "unknownFiles": manifest.count,
957
+ "files": manifest.map {
958
+ var dict = $0.toDict()
959
+ dict["error"] = error
960
+ return dict
961
+ }
962
+ ]
963
+ }
964
+
965
+ public func getBundleDownloadSize(updateUrl: URL, version: String?, manifest: [ManifestEntry]) -> [String: Any] {
966
+ if manifest.isEmpty {
967
+ return [
968
+ "totalSize": 0,
969
+ "knownFiles": 0,
970
+ "unknownFiles": 0,
971
+ "files": []
972
+ ]
973
+ }
974
+
975
+ var parameters = self.createInfoObject().toParameters()
976
+ parameters["version"] = version ?? ""
977
+ parameters["manifest"] = manifest.map { $0.toDict() }
978
+
979
+ guard let request = createRequest(url: manifestSizeUrl(from: updateUrl), method: "POST", parameters: parameters) else {
980
+ return unavailableBundleSizeResult(manifest: manifest, error: "request_error")
981
+ }
982
+
983
+ let result = performRequest(request, label: "getBundleDownloadSize")
984
+ if result.timedOut {
985
+ return unavailableBundleSizeResult(manifest: manifest, error: "timeout_error")
986
+ }
987
+ if let error = result.error {
988
+ logger.error("Error getting bundle download size")
989
+ logger.debug("Error: \(error.localizedDescription)")
990
+ return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
991
+ }
992
+ guard let data = result.data,
993
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
994
+ return unavailableBundleSizeResult(manifest: manifest, error: "parse_error")
995
+ }
996
+
997
+ let statusCode = result.response?.statusCode ?? 0
998
+ if statusCode < 200 || statusCode >= 300 {
999
+ return unavailableBundleSizeResult(manifest: manifest, error: "response_error")
1000
+ }
1001
+
1002
+ return json
1003
+ }
1004
+
877
1005
  public func downloadManifest(manifest: [ManifestEntry], version: String, sessionKey: String, link: String? = nil, comment: String? = nil) throws -> BundleInfo {
878
1006
  let id = self.randomString(length: 10)
879
1007
  logger.info("downloadManifest start \(id)")
@@ -899,8 +1027,8 @@ import UIKit
899
1027
 
900
1028
  let totalFiles = manifest.count
901
1029
 
902
- // Configure concurrent operation count similar to Android: min(64, max(32, totalFiles))
903
- manifestDownloadQueue.maxConcurrentOperationCount = min(64, max(32, totalFiles))
1030
+ // Keep this bounded because each manifest operation waits on a URLSession callback.
1031
+ manifestDownloadQueue.maxConcurrentOperationCount = min(8, max(1, totalFiles))
904
1032
 
905
1033
  // Thread-safe counters for concurrent operations
906
1034
  let completedFiles = AtomicCounter()
@@ -1599,6 +1727,15 @@ import UIKit
1599
1727
  return false
1600
1728
  }
1601
1729
 
1730
+ if let previewFallback = self.getPreviewFallbackBundle(),
1731
+ !previewFallback.isDeleted(),
1732
+ !previewFallback.isErrorStatus(),
1733
+ previewFallback.getId() == id {
1734
+ logger.info("Cannot delete the preview fallback bundle")
1735
+ logger.debug("Bundle ID: \(id)")
1736
+ return false
1737
+ }
1738
+
1602
1739
  // Check if this is the next bundle and prevent deletion if it is
1603
1740
  if let next = self.getNextBundle(),
1604
1741
  !next.isDeleted() &&
@@ -1883,6 +2020,21 @@ import UIKit
1883
2020
  return true
1884
2021
  }
1885
2022
 
2023
+ func stagePreviewFallbackReload(bundle: BundleInfo) -> Bool {
2024
+ guard !bundle.isErrorStatus() else {
2025
+ return false
2026
+ }
2027
+ if bundle.isBuiltin() {
2028
+ self.setCurrentBundle(bundle: self.DEFAULT_FOLDER)
2029
+ return true
2030
+ }
2031
+ guard bundleExists(id: bundle.getId()) else {
2032
+ return false
2033
+ }
2034
+ self.setCurrentBundle(bundle: self.getBundleDirectory(id: bundle.getId()).path)
2035
+ return true
2036
+ }
2037
+
1886
2038
  func finalizePendingReload(bundle: BundleInfo, previousBundleName: String) {
1887
2039
  guard !bundle.isBuiltin() else {
1888
2040
  return
@@ -1922,9 +2074,11 @@ import UIKit
1922
2074
  public func setSuccess(bundle: BundleInfo, autoDeletePrevious: Bool) {
1923
2075
  self.setBundleStatus(id: bundle.getId(), status: BundleStatus.SUCCESS)
1924
2076
  let fallback: BundleInfo = self.getFallbackBundle()
2077
+ let previewFallback = self.getPreviewFallbackBundle()
2078
+ let fallbackIsPreviewFallback = previewFallback?.getId() == fallback.getId()
1925
2079
  logger.info("Fallback bundle is: \(fallback.toString())")
1926
2080
  logger.info("Version successfully loaded: \(bundle.toString())")
1927
- if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() {
2081
+ if autoDeletePrevious && !fallback.isBuiltin() && fallback.getId() != bundle.getId() && !fallbackIsPreviewFallback {
1928
2082
  let res = self.delete(id: fallback.getId())
1929
2083
  if res {
1930
2084
  logger.info("Deleted previous bundle")
@@ -2264,6 +2418,11 @@ import UIKit
2264
2418
  }
2265
2419
 
2266
2420
  private func sendStatsWithMetadata(action: String, versionName: String?, oldVersionName: String?, metadata: [String: String]?) {
2421
+ if previewSession {
2422
+ logger.debug("Skipping sendStats during preview session.")
2423
+ return
2424
+ }
2425
+
2267
2426
  // Check if rate limit was exceeded
2268
2427
  if CapgoUpdater.rateLimitExceeded {
2269
2428
  logger.debug("Skipping sendStats due to rate limit (429). Stats will resume after app restart.")
@@ -2458,6 +2617,33 @@ import UIKit
2458
2617
  return self.getBundleInfo(id: id)
2459
2618
  }
2460
2619
 
2620
+ public func getPreviewFallbackBundle() -> BundleInfo? {
2621
+ guard let id = UserDefaults.standard.string(forKey: self.PREVIEW_FALLBACK_VERSION) else {
2622
+ return nil
2623
+ }
2624
+ let bundle = self.getBundleInfo(id: id)
2625
+ if !bundle.isBuiltin() && !self.bundleExists(id: id) {
2626
+ _ = self.setPreviewFallbackBundle(fallback: nil)
2627
+ return nil
2628
+ }
2629
+ return bundle
2630
+ }
2631
+
2632
+ public func setPreviewFallbackBundle(fallback: String?) -> Bool {
2633
+ guard let fallbackId = fallback else {
2634
+ UserDefaults.standard.removeObject(forKey: self.PREVIEW_FALLBACK_VERSION)
2635
+ UserDefaults.standard.synchronize()
2636
+ return true
2637
+ }
2638
+ let newBundle: BundleInfo = self.getBundleInfo(id: fallbackId)
2639
+ if !newBundle.isBuiltin() && !self.bundleExists(id: fallbackId) {
2640
+ return false
2641
+ }
2642
+ UserDefaults.standard.set(fallbackId, forKey: self.PREVIEW_FALLBACK_VERSION)
2643
+ UserDefaults.standard.synchronize()
2644
+ return true
2645
+ }
2646
+
2461
2647
  public func setNextBundle(next: String?) -> Bool {
2462
2648
  guard let nextId: String = next else {
2463
2649
  UserDefaults.standard.removeObject(forKey: self.NEXT_VERSION)
@@ -240,6 +240,8 @@ public class AppVersion: NSObject {
240
240
  var breaking: Bool?
241
241
  var data: [String: String]?
242
242
  var manifest: [ManifestEntry]?
243
+ var missing: [String: Any]?
244
+ var downloadSize: [String: Any]?
243
245
  var link: String?
244
246
  var comment: String?
245
247
  var statusCode: Int = 0
@@ -27,7 +27,8 @@ extension UIWindow {
27
27
  override open func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) {
28
28
  if motion == .motionShake {
29
29
  // Find the CapacitorUpdaterPlugin instance
30
- guard let bridge = (rootViewController as? CAPBridgeProtocol),
30
+ guard let bridgeViewController = rootViewController as? CAPBridgeViewController,
31
+ let bridge = bridgeViewController.bridge,
31
32
  let plugin = bridge.plugin(withName: "CapacitorUpdaterPlugin") as? CapacitorUpdaterPlugin else {
32
33
  return
33
34
  }
@@ -37,8 +38,9 @@ extension UIWindow {
37
38
  return
38
39
  }
39
40
 
40
- // Check if channel selector mode is enabled
41
- if plugin.shakeChannelSelectorEnabled {
41
+ if plugin.hasActivePreviewSession() {
42
+ showDefaultMenu(plugin: plugin, bridge: bridge)
43
+ } else if plugin.shakeChannelSelectorEnabled {
42
44
  showChannelSelector(plugin: plugin, bridge: bridge)
43
45
  } else {
44
46
  showDefaultMenu(plugin: plugin, bridge: bridge)
@@ -54,6 +56,48 @@ extension UIWindow {
54
56
  return
55
57
  }
56
58
 
59
+ if !plugin.hasActivePreviewSession() {
60
+ showConfiguredDefaultMenu(plugin: plugin, bridge: bridge)
61
+ return
62
+ }
63
+
64
+ let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "App"
65
+ let title = "Preview \(appName) Menu"
66
+ let message = "Reload the current preview or leave the test app."
67
+ let okButtonTitle = "Leave test app"
68
+ let reloadButtonTitle = "Reload app"
69
+ let cancelButtonTitle = "Close menu"
70
+
71
+ let alertShake = UIAlertController(title: title, message: message, preferredStyle: .alert)
72
+
73
+ alertShake.addAction(UIAlertAction(title: okButtonTitle, style: .default) { _ in
74
+ DispatchQueue.global(qos: .userInitiated).async {
75
+ if !plugin.leavePreviewSessionFromShakeMenu() {
76
+ DispatchQueue.main.async {
77
+ self.showError(message: "Could not leave the test app.", plugin: plugin)
78
+ }
79
+ }
80
+ }
81
+ })
82
+
83
+ alertShake.addAction(UIAlertAction(title: reloadButtonTitle, style: .default) { _ in
84
+ DispatchQueue.main.async {
85
+ if !plugin.reloadPreviewSessionFromShakeMenu() {
86
+ self.showError(message: "Could not reload the test app.", plugin: plugin)
87
+ }
88
+ }
89
+ })
90
+
91
+ alertShake.addAction(UIAlertAction(title: cancelButtonTitle, style: .default))
92
+
93
+ DispatchQueue.main.async {
94
+ if let topVC = UIApplication.topViewController() {
95
+ topVC.present(alertShake, animated: true)
96
+ }
97
+ }
98
+ }
99
+
100
+ private func showConfiguredDefaultMenu(plugin: CapacitorUpdaterPlugin, bridge: CAPBridgeProtocol) {
57
101
  let appName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ?? "App"
58
102
  let title = "Preview \(appName) Menu"
59
103
  let message = "What would you like to do?"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/capacitor-updater",
3
- "version": "8.46.3",
3
+ "version": "8.47.1",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Live update for capacitor apps",
6
6
  "main": "dist/plugin.cjs.js",