@capawesome/capacitor-live-update 8.6.0 → 8.7.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.
Files changed (22) hide show
  1. package/CapawesomeCapacitorLiveUpdate.podspec +15 -0
  2. package/Package.swift +21 -5
  3. package/README.md +35 -2
  4. package/android/build.gradle +19 -0
  5. package/android/consumer-rules.pro +5 -0
  6. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +181 -56
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +47 -1
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -5
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +23 -1
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +30 -0
  11. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicManager.kt +229 -0
  12. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicPreferences.kt +85 -0
  13. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -6
  14. package/ios/Plugin/Classes/Options/FetchLatestBundleOptions.swift +19 -3
  15. package/ios/Plugin/Classes/Results/FetchLatestBundleResult.swift +24 -0
  16. package/ios/Plugin/Enums/CustomError.swift +13 -0
  17. package/ios/Plugin/LiveUpdate.swift +140 -45
  18. package/ios/Plugin/LiveUpdatePlugin.swift +16 -3
  19. package/ios/Plugin/Providers/Ionic/LiveUpdateIonicManager.swift +187 -0
  20. package/ios/Plugin/Providers/Ionic/LiveUpdateIonicPreferences.swift +81 -0
  21. package/ios/Plugin/Providers/Ionic/LiveUpdatePlugin+LiveUpdateProvider.swift +15 -0
  22. package/package.json +2 -1
@@ -7,25 +7,41 @@ import CommonCrypto
7
7
 
8
8
  // swiftlint:disable type_body_length
9
9
  @objc public class LiveUpdate: NSObject {
10
+ private static let downloadsDirectoryUrl = FileManager.default
11
+ .urls(for: .cachesDirectory, in: .userDomainMask).first!
12
+ .appendingPathComponent("capawesome_capacitor_live_update_downloads")
13
+ /// Deletes the leftovers of downloads that a process kill interrupted. Runs once per process and
14
+ /// blocks concurrent initializers until it is done, so no other instance can have a download in flight.
15
+ private static let interruptedDownloadsDeleted: Void = {
16
+ try? FileManager.default.removeItem(at: downloadsDirectoryUrl)
17
+ }()
18
+
10
19
  private let autoUpdateIntervalMs: Int64 = 15 * 60 * 1000 // 15 minutes
11
20
  private let bundlesDirectory = "NoCloud/ionic_built_snapshots" // DO NOT CHANGE! (See https://dub.sh/BLluidt)
12
- private let cachesDirectoryUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
13
21
  private let config: LiveUpdateConfig
14
22
  private let defaultWebAssetDir = "public" // DO NOT CHANGE! (See https://dub.sh/Buvz4yj)
15
23
  private let defaultServerPathKey = "serverBasePath" // DO NOT CHANGE! (See https://dub.sh/ceDl0zT)
16
- private let downloadsDirectory = "capawesome_capacitor_live_update_downloads"
17
24
  private let httpClient: LiveUpdateHttpClient
18
25
  private let libraryDirectoryUrl = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
19
26
  private let manifestFileName = "capawesome-live-update-manifest.json" // DO NOT CHANGE!
20
- private let plugin: LiveUpdatePlugin
27
+ private let plugin: LiveUpdatePlugin?
21
28
  private let preferences: LiveUpdatePreferences
22
29
 
30
+ private var isAppInBackground = false
31
+ private var lastAutoUpdateCheckTimestamp: Int64 = 0
32
+ /// Milliseconds left on the rollback timer while it is paused in the background.
33
+ private var remainingRollbackTimeout: Int?
34
+ private var rollbackDeadline: DispatchTime?
23
35
  private var rollbackDispatchWorkItem: DispatchWorkItem?
24
36
  private var rollbackPerformed = false
25
- private var lastAutoUpdateCheckTimestamp: Int64 = 0
37
+ /// Guards the rollback timer state: plugin methods run on the bridge queue, lifecycle callbacks on main.
38
+ private let rollbackTimerLock = NSLock()
26
39
  private var syncInProgress = false
27
40
 
28
- init(config: LiveUpdateConfig, plugin: LiveUpdatePlugin) {
41
+ /// - Parameter plugin: The Capacitor plugin, or `nil` for a headless instance
42
+ /// (e.g. for Ionic Portals hosts that construct a provider manager directly).
43
+ /// In headless mode, WebView-related operations and plugin events are skipped.
44
+ init(config: LiveUpdateConfig, plugin: LiveUpdatePlugin? = nil) {
29
45
  self.config = config
30
46
  self.httpClient = LiveUpdateHttpClient(config: config)
31
47
  self.plugin = plugin
@@ -38,8 +54,7 @@ import CommonCrypto
38
54
  // Set the device ID on the HTTP client (after any potential config reset)
39
55
  self.httpClient.setDeviceId(getDeviceId())
40
56
 
41
- // Delete the leftovers of downloads that were interrupted by a process kill
42
- tryDeleteDownloadsDirectory()
57
+ _ = Self.interruptedDownloadsDeleted
43
58
 
44
59
  // Start the rollback timer to rollback to the default bundle
45
60
  // if the app is not ready after a certain time
@@ -166,6 +181,14 @@ import CommonCrypto
166
181
  completion(result, nil)
167
182
  }
168
183
 
184
+ /// - Returns: The on-disk URL of a downloaded bundle, or `nil` if the bundle does not exist.
185
+ public func getBundleDirectory(bundleId: String) -> URL? {
186
+ guard hasBundleById(bundleId) else {
187
+ return nil
188
+ }
189
+ return buildBundleURLFor(bundleId: bundleId)
190
+ }
191
+
169
192
  @objc public func getCustomId(completion: @escaping (Result?, Error?) -> Void) {
170
193
  let customId = preferences.getCustomId()
171
194
 
@@ -208,7 +231,18 @@ import CommonCrypto
208
231
  }
209
232
  }
210
233
 
234
+ @objc public func handleAppDidEnterBackground() {
235
+ rollbackTimerLock.lock()
236
+ defer { rollbackTimerLock.unlock() }
237
+ isAppInBackground = true
238
+ pauseRollbackTimer()
239
+ }
240
+
211
241
  @objc public func handleAppWillEnterForeground() {
242
+ rollbackTimerLock.lock()
243
+ defer { rollbackTimerLock.unlock() }
244
+ isAppInBackground = false
245
+ resumeRollbackTimer()
212
246
  if config.autoUpdateStrategy == "background" {
213
247
  performAutoUpdate()
214
248
  }
@@ -351,7 +385,12 @@ import CommonCrypto
351
385
 
352
386
  // Move the unzipped files to the bundles directory
353
387
  let bundlePath = self.buildBundlePathFor(bundleId: bundleId)
354
- try FileManager.default.moveItem(atPath: indexHtmlFile.deletingLastPathComponent().path, toPath: bundlePath)
388
+ do {
389
+ try FileManager.default.moveItem(atPath: indexHtmlFile.deletingLastPathComponent().path, toPath: bundlePath)
390
+ } catch where hasBundleById(bundleId) {
391
+ // A concurrent download of the same bundle was installed first
392
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Bundle already installed: \(bundleId)")
393
+ }
355
394
  }
356
395
 
357
396
  private func addBundleOfTypeManifest(bundleId: String, directory: URL) async throws {
@@ -385,7 +424,15 @@ import CommonCrypto
385
424
  }
386
425
 
387
426
  private func buildDownloadsDirectoryUrl() -> URL {
388
- return cachesDirectoryUrl.appendingPathComponent(downloadsDirectory)
427
+ return Self.downloadsDirectoryUrl
428
+ }
429
+
430
+ /// Must be called with `rollbackTimerLock` held.
431
+ private func clearRollbackTimer() {
432
+ rollbackDispatchWorkItem?.cancel()
433
+ rollbackDispatchWorkItem = nil
434
+ rollbackDeadline = nil
435
+ remainingRollbackTimeout = nil
389
436
  }
390
437
 
391
438
  private func copyCurrentBundleFile(fileToCopy: ManifestItem, toDirectory: URL) throws {
@@ -455,18 +502,8 @@ import CommonCrypto
455
502
  }
456
503
 
457
504
  private func deleteUnusedBundles() {
458
- let bundleIds = getDownloadedBundleIds()
459
- let currentBundleId = getCurrentBundleId()
460
- let nextBundleId = getNextBundleId()
461
-
462
- for bundleId in bundleIds {
463
- if bundleId != currentBundleId && bundleId != nextBundleId {
464
- do {
465
- try deleteBundleById(bundleId)
466
- } catch {
467
- CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to delete bundle with id: \(bundleId)")
468
- }
469
- }
505
+ for bundleId in getDownloadedBundleIds() {
506
+ tryDeleteUnusedBundle(bundleId: bundleId)
470
507
  }
471
508
  }
472
509
 
@@ -593,18 +630,18 @@ import CommonCrypto
593
630
  }
594
631
 
595
632
  private func fetchLatestBundle(_ options: FetchLatestBundleOptions) async throws -> GetLatestBundleResponse? {
633
+ let appId = try requireAppId(options.getAppId())
596
634
  let channel = options.getChannel() ?? getChannel()
597
635
  var parameters = [String: String]()
598
636
  parameters["appVersionCode"] = getVersionCode()
599
637
  parameters["appVersionName"] = getVersionName()
600
- parameters["bundleId"] = getCurrentBundleId()
638
+ parameters["bundleId"] = options.getBundleId() ?? getCurrentBundleId()
601
639
  parameters["channelName"] = channel
602
640
  parameters["customId"] = preferences.getCustomId()
603
641
  parameters["deviceId"] = getDeviceId()
604
642
  parameters["osVersion"] = await UIDevice.current.systemVersion
605
643
  parameters["platform"] = "1"
606
644
  parameters["pluginVersion"] = LiveUpdatePlugin.version
607
- let appId = try requireAppId()
608
645
  var urlComponents = URLComponents(string: "https://\(config.serverDomain)/v1/apps/\(appId)/bundles/latest")!
609
646
  urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
610
647
  let url = try urlComponents.asURL()
@@ -614,12 +651,12 @@ import CommonCrypto
614
651
  CAPLog.print("[", LiveUpdatePlugin.tag, "] Latest bundle response: ", String(decoding: data, as: UTF8.self))
615
652
  }
616
653
  if let error = response.error {
617
- if let urlError = error.underlyingError as? URLError {
618
- if urlError.code == .timedOut {
619
- throw urlError
620
- }
654
+ // The server answered with an error status, so no update is available
655
+ if let statusCode = response.response?.statusCode, !(200..<300).contains(statusCode) {
656
+ return nil
621
657
  }
622
- return nil
658
+ // The server could not be reached (e.g. offline or timed out) or sent an invalid body
659
+ throw error.underlyingError ?? error
623
660
  }
624
661
  if let value = response.value {
625
662
  return value
@@ -642,14 +679,14 @@ import CommonCrypto
642
679
  }
643
680
  }
644
681
 
645
- private func getAppId() -> String? {
682
+ func getAppId() -> String? {
646
683
  if let appId = preferences.getAppId() {
647
684
  return appId
648
685
  }
649
686
  return config.appId
650
687
  }
651
688
 
652
- private func getChannel() -> String? {
689
+ func getChannel() -> String? {
653
690
  var channel: String?
654
691
  if let _ = config.defaultChannel {
655
692
  channel = config.defaultChannel
@@ -695,7 +732,7 @@ import CommonCrypto
695
732
 
696
733
  /// - Returns: The path to the current bundle directory or `nil` if no view controller was found.
697
734
  private func getCurrentCapacitorServerPath() -> String? {
698
- guard let viewController = self.plugin.bridge?.viewController as? CAPBridgeViewController else {
735
+ guard let viewController = self.plugin?.bridge?.viewController as? CAPBridgeViewController else {
699
736
  return nil
700
737
  }
701
738
  return viewController.getServerBasePath()
@@ -730,7 +767,7 @@ import CommonCrypto
730
767
  return preferences.getPreviousBundleId()
731
768
  }
732
769
 
733
- private func getVersionCode() -> String {
770
+ func getVersionCode() -> String {
734
771
  guard let appVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
735
772
  return ""
736
773
  }
@@ -777,7 +814,7 @@ import CommonCrypto
777
814
  }
778
815
 
779
816
  private func notifyDownloadBundleProgressListeners(_ event: DownloadBundleProgressEvent) {
780
- plugin.notifyDownloadBundleProgressListeners(event)
817
+ plugin?.notifyDownloadBundleProgressListeners(event)
781
818
  }
782
819
 
783
820
  private func performAutoUpdate() {
@@ -804,8 +841,29 @@ import CommonCrypto
804
841
  }
805
842
  }
806
843
 
807
- private func requireAppId() throws -> String {
808
- guard let appId = getAppId(), !appId.isEmpty else {
844
+ /// Must be called with `rollbackTimerLock` held.
845
+ private func pauseRollbackTimer() {
846
+ guard let deadline = rollbackDeadline else {
847
+ return
848
+ }
849
+ rollbackDispatchWorkItem?.cancel()
850
+ rollbackDispatchWorkItem = nil
851
+ rollbackDeadline = nil
852
+ let now = DispatchTime.now()
853
+ remainingRollbackTimeout = deadline > now ? Int((deadline.uptimeNanoseconds - now.uptimeNanoseconds) / 1_000_000) : 0
854
+ }
855
+
856
+ /// Must be called with `rollbackTimerLock` held.
857
+ private func resumeRollbackTimer() {
858
+ guard let remainingRollbackTimeout = remainingRollbackTimeout else {
859
+ return
860
+ }
861
+ self.remainingRollbackTimeout = nil
862
+ scheduleRollback(afterMilliseconds: remainingRollbackTimeout)
863
+ }
864
+
865
+ private func requireAppId(_ appIdOverride: String? = nil) throws -> String {
866
+ guard let appId = appIdOverride ?? getAppId(), !appId.isEmpty else {
809
867
  throw CustomError.appIdMissing
810
868
  }
811
869
  return appId
@@ -861,7 +919,7 @@ import CommonCrypto
861
919
  }
862
920
 
863
921
  private func setCurrentCapacitorServerPath(path: String) {
864
- guard let viewController = self.plugin.bridge?.viewController as? CAPBridgeViewController else {
922
+ guard let viewController = self.plugin?.bridge?.viewController as? CAPBridgeViewController else {
865
923
  return
866
924
  }
867
925
  viewController.setServerBasePath(path: path)
@@ -880,11 +938,11 @@ import CommonCrypto
880
938
 
881
939
  private func notifyNextBundleSetListeners(_ bundleId: String?) {
882
940
  let event = NextBundleSetEvent(bundleId: bundleId)
883
- plugin.notifyNextBundleSetListeners(event)
941
+ plugin?.notifyNextBundleSetListeners(event)
884
942
  }
885
943
 
886
944
  private func notifyReloadedListeners() {
887
- plugin.notifyReloadedListeners()
945
+ plugin?.notifyReloadedListeners()
888
946
  }
889
947
 
890
948
  private func addBlockedBundleId(_ bundleId: String) {
@@ -956,19 +1014,41 @@ import CommonCrypto
956
1014
  preferences.setPreviousBundleId(bundleId)
957
1015
  }
958
1016
 
1017
+ private func scheduleRollback(afterMilliseconds milliseconds: Int) {
1018
+ let deadline = DispatchTime.now() + DispatchTimeInterval.milliseconds(milliseconds)
1019
+ let workItem = DispatchWorkItem { [weak self] in
1020
+ guard let self = self else {
1021
+ return
1022
+ }
1023
+ self.rollbackTimerLock.lock()
1024
+ self.rollbackDeadline = nil
1025
+ self.rollbackTimerLock.unlock()
1026
+ self.rollback()
1027
+ }
1028
+ rollbackDeadline = deadline
1029
+ rollbackDispatchWorkItem = workItem
1030
+ DispatchQueue.main.asyncAfter(deadline: deadline, execute: workItem)
1031
+ }
1032
+
1033
+ /// The timer only counts time in the foreground, because a suspended app cannot call `ready()`.
959
1034
  private func startRollbackTimer() {
960
1035
  guard config.readyTimeout > 0 else {
961
1036
  return
962
1037
  }
963
- stopRollbackTimer()
964
- rollbackDispatchWorkItem = DispatchWorkItem { [weak self] in
965
- self?.rollback()
1038
+ rollbackTimerLock.lock()
1039
+ defer { rollbackTimerLock.unlock() }
1040
+ clearRollbackTimer()
1041
+ if isAppInBackground {
1042
+ remainingRollbackTimeout = config.readyTimeout
1043
+ } else {
1044
+ scheduleRollback(afterMilliseconds: config.readyTimeout)
966
1045
  }
967
- DispatchQueue.main.asyncAfter(deadline: .now() + DispatchTimeInterval.milliseconds(config.readyTimeout), execute: rollbackDispatchWorkItem!)
968
1046
  }
969
1047
 
970
1048
  private func stopRollbackTimer() {
971
- rollbackDispatchWorkItem?.cancel()
1049
+ rollbackTimerLock.lock()
1050
+ defer { rollbackTimerLock.unlock() }
1051
+ clearRollbackTimer()
972
1052
  }
973
1053
 
974
1054
  private func tryCopyCurrentBundleFile(
@@ -994,8 +1074,23 @@ import CommonCrypto
994
1074
  }
995
1075
  }
996
1076
 
997
- private func tryDeleteDownloadsDirectory() {
998
- tryDeleteDirectory(buildDownloadsDirectoryUrl())
1077
+ /// Deletes the bundle unless it is the current or next bundle.
1078
+ /// - Returns: `true` if the bundle does not exist anymore.
1079
+ @discardableResult
1080
+ func tryDeleteUnusedBundle(bundleId: String) -> Bool {
1081
+ guard hasBundleById(bundleId) else {
1082
+ return true
1083
+ }
1084
+ guard bundleId != getCurrentBundleId() && bundleId != getNextBundleId() else {
1085
+ return false
1086
+ }
1087
+ do {
1088
+ try deleteBundleById(bundleId)
1089
+ return true
1090
+ } catch {
1091
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to delete bundle with id: \(bundleId)")
1092
+ return false
1093
+ }
999
1094
  }
1000
1095
 
1001
1096
  private func unzipDownloadedBundle(zipFile: URL) throws -> URL {
@@ -8,7 +8,7 @@ import Capacitor
8
8
  @objc(LiveUpdatePlugin)
9
9
  public class LiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
10
10
  public static let tag = "LiveUpdate"
11
- public static let version = "8.6.0"
11
+ public static let version = "8.7.1"
12
12
  public static let userDefaultsPrefix = "CapawesomeLiveUpdate" // DO NOT CHANGE
13
13
 
14
14
  public let identifier = "LiveUpdatePlugin"
@@ -46,7 +46,7 @@ public class LiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
46
46
  private let eventNextBundleSet = "nextBundleSet"
47
47
  private let eventReloaded = "reloaded"
48
48
 
49
- private var implementation: LiveUpdate?
49
+ var implementation: LiveUpdate?
50
50
 
51
51
  override public func load() {
52
52
  self.implementation = LiveUpdate(config: liveUpdateConfig(), plugin: self)
@@ -54,13 +54,22 @@ public class LiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
54
54
  // Notify implementation about load
55
55
  implementation?.handleLoad()
56
56
 
57
- // Register for app resume notifications
57
+ // Register for app lifecycle notifications
58
+ NotificationCenter.default.addObserver(
59
+ self,
60
+ selector: #selector(handleAppDidEnterBackground),
61
+ name: UIApplication.didEnterBackgroundNotification,
62
+ object: nil
63
+ )
58
64
  NotificationCenter.default.addObserver(
59
65
  self,
60
66
  selector: #selector(handleAppWillEnterForeground),
61
67
  name: UIApplication.willEnterForegroundNotification,
62
68
  object: nil
63
69
  )
70
+ if UIApplication.shared.applicationState == .background {
71
+ implementation?.handleAppDidEnterBackground()
72
+ }
64
73
  }
65
74
 
66
75
  @objc func clearBlockedBundles(_ call: CAPPluginCall) {
@@ -387,6 +396,10 @@ public class LiveUpdatePlugin: CAPPlugin, CAPBridgedPlugin {
387
396
  notifyListeners(eventReloaded, data: event, retainUntilConsumed: true)
388
397
  }
389
398
 
399
+ @objc private func handleAppDidEnterBackground() {
400
+ implementation?.handleAppDidEnterBackground()
401
+ }
402
+
390
403
  @objc private func handleAppWillEnterForeground() {
391
404
  implementation?.handleAppWillEnterForeground()
392
405
  }
@@ -0,0 +1,187 @@
1
+ #if CAPAWESOME_INCLUDE_IONIC_PROVIDER
2
+ import Foundation
3
+ import Capacitor
4
+ import LiveUpdateProvider
5
+
6
+ /// Ionic Live Update Provider manager backed by the Capawesome live update plugin.
7
+ ///
8
+ /// Each manager instance is scoped by its resolved app ID and channel so that multiple shells
9
+ /// (Portals or Federated Capacitor apps) can persist their own active bundle without colliding
10
+ /// with each other or with the standalone plugin's `currentBundleId` / `nextBundleId` state.
11
+ ///
12
+ /// Provider configuration keys (V1):
13
+ /// - `appId` (optional) — Capawesome Cloud app UUID; falls back to plugin config, required in either
14
+ /// - `channel` (optional) — channel to sync; falls back to plugin config
15
+ /// - `publicKey`, `serverDomain`, `httpTimeout` (optional) — same as the plugin config;
16
+ /// only applied to the headless instance created by `init(configuration:)`
17
+ public final class LiveUpdateIonicManager: ProviderManager {
18
+ public private(set) var latestAppDirectory: URL?
19
+
20
+ private let appId: String
21
+ private let channel: String?
22
+ private let liveUpdate: LiveUpdate
23
+ private let preferences: LiveUpdateIonicPreferences
24
+ private var syncedBundleId: String?
25
+
26
+ /// Creates a manager without a running Capacitor plugin, e.g. for Ionic Portals hosts
27
+ /// that construct the manager directly and attach it to a Portal via
28
+ /// `liveUpdateSource: .provider(manager:)`. Uses a headless `LiveUpdate` instance with
29
+ /// the default configuration, overridden by the configuration dictionary.
30
+ public convenience init(configuration: [String: Any]) throws {
31
+ let config = try LiveUpdateIonicManager.buildConfig(configuration: configuration)
32
+ try self.init(configuration: configuration, liveUpdate: LiveUpdate(config: config))
33
+ }
34
+
35
+ init(configuration: [String: Any], liveUpdate: LiveUpdate) throws {
36
+ guard let appId = configuration["appId"] as? String ?? liveUpdate.getAppId(), !appId.isEmpty else {
37
+ throw ProviderError.invalidConfiguration(message: CustomError.appIdMissing.localizedDescription)
38
+ }
39
+ let channel = configuration["channel"] as? String ?? liveUpdate.getChannel()
40
+ self.appId = appId
41
+ self.channel = channel
42
+ self.liveUpdate = liveUpdate
43
+ self.preferences = LiveUpdateIonicPreferences(stateKey: Self.buildStateKey(appId: appId, channel: channel))
44
+ preferences.resetLastSyncedBundleIdIfVersionChanged(versionCode: liveUpdate.getVersionCode())
45
+ restoreSyncedBundle()
46
+ deleteSupersededBundles()
47
+ }
48
+
49
+ private static func buildStateKey(appId: String, channel: String?) -> String {
50
+ guard let channel = channel, !channel.isEmpty else {
51
+ return appId
52
+ }
53
+ return appId + "." + channel
54
+ }
55
+
56
+ private static func buildConfig(configuration: [String: Any]) throws -> LiveUpdateConfig {
57
+ var config = LiveUpdateConfig()
58
+ if let publicKey = try optionalValue(configuration, key: "publicKey", type: String.self) {
59
+ config.publicKey = publicKey
60
+ }
61
+ if let serverDomain = try optionalValue(configuration, key: "serverDomain", type: String.self) {
62
+ config.serverDomain = serverDomain
63
+ }
64
+ if let httpTimeout = try optionalValue(configuration, key: "httpTimeout", type: NSNumber.self) {
65
+ config.httpTimeout = httpTimeout.intValue
66
+ }
67
+ return config
68
+ }
69
+
70
+ private static func optionalValue<T>(_ configuration: [String: Any], key: String, type: T.Type) throws -> T? {
71
+ guard let value = configuration[key] else {
72
+ return nil
73
+ }
74
+ guard let typedValue = value as? T else {
75
+ let message = CustomError.configurationValueInvalid(key: key).localizedDescription
76
+ throw ProviderError.invalidConfiguration(message: message)
77
+ }
78
+ return typedValue
79
+ }
80
+
81
+ public func sync() async throws -> (any ProviderSyncResult)? {
82
+ do {
83
+ let result = try await liveUpdate.fetchLatestBundle(FetchLatestBundleOptions(appId: appId, bundleId: syncedBundleId, channel: channel))
84
+
85
+ // No update available; nothing to report.
86
+ guard let bundleId = result.getBundleId(), bundleId != syncedBundleId else {
87
+ return nil
88
+ }
89
+
90
+ let directory: URL
91
+ if let existingDirectory = liveUpdate.getBundleDirectory(bundleId: bundleId) {
92
+ directory = existingDirectory
93
+ } else {
94
+ directory = try await downloadBundle(bundleId: bundleId, result: result)
95
+ }
96
+ applySyncedBundle(bundleId: bundleId, directory: directory)
97
+ return MetadataSyncResult(metadata: buildMetadata(bundleId: bundleId, customProperties: result.getCustomProperties()))
98
+ } catch {
99
+ CAPLog.print("[", LiveUpdatePlugin.tag, "] Ionic provider sync failed: ", error.localizedDescription)
100
+ throw error
101
+ }
102
+ }
103
+
104
+ /// Does not delete the previous bundle, because a visible portal may still serve it.
105
+ /// It is recorded as superseded and deleted on the next launch instead.
106
+ private func applySyncedBundle(bundleId: String, directory: URL) {
107
+ syncedBundleId = bundleId
108
+ latestAppDirectory = directory
109
+ preferences.setLastSyncedBundleId(bundleId, versionCode: liveUpdate.getVersionCode())
110
+ }
111
+
112
+ private func buildMetadata(bundleId: String, customProperties: [String: Any]?) -> [String: Any] {
113
+ var metadata: [String: Any] = ["bundleId": bundleId]
114
+ if let channel = channel {
115
+ metadata["channel"] = channel
116
+ }
117
+ if let customProperties = customProperties, !customProperties.isEmpty {
118
+ metadata["customProperties"] = customProperties
119
+ }
120
+ return metadata
121
+ }
122
+
123
+ /// Deletes the superseded bundles that no manager points to anymore. Bundles that were not
124
+ /// superseded by a manager are never deleted, because they may belong to the plugin's JS API.
125
+ private func deleteSupersededBundles() {
126
+ let supersededBundleIds = preferences.getSupersededBundleIds()
127
+ if supersededBundleIds.isEmpty {
128
+ return
129
+ }
130
+ let lastSyncedBundleIds = preferences.getLastSyncedBundleIdsOfAllManagers()
131
+ let deletedBundleIds = supersededBundleIds.filter { bundleId in
132
+ !lastSyncedBundleIds.contains(bundleId) && liveUpdate.tryDeleteUnusedBundle(bundleId: bundleId)
133
+ }
134
+ preferences.removeSupersededBundleIds(deletedBundleIds)
135
+ }
136
+
137
+ /// Downloads the bundle, or awaits a running download of the same bundle, and returns its directory.
138
+ private func downloadBundle(bundleId: String, result: FetchLatestBundleResult) async throws -> URL {
139
+ guard let downloadUrl = result.getDownloadUrl(), !downloadUrl.isEmpty else {
140
+ throw CustomError.downloadUrlMissing
141
+ }
142
+ let downloadOptions = DownloadBundleOptions(
143
+ artifactType: result.getArtifactType() ?? .zip,
144
+ bundleId: bundleId,
145
+ checksum: result.getChecksum(),
146
+ signature: result.getSignature(),
147
+ url: downloadUrl
148
+ )
149
+ try await BundleDownloads.shared.run(bundleId: bundleId) { [liveUpdate] in
150
+ try await liveUpdate.downloadBundle(downloadOptions)
151
+ }
152
+ guard let directory = liveUpdate.getBundleDirectory(bundleId: bundleId) else {
153
+ throw CustomError.bundleDirectoryNotFound
154
+ }
155
+ return directory
156
+ }
157
+
158
+ /// Restores the last synced bundle, if it is still on disk, so the host can load it offline
159
+ /// and the next sync reports it as the bundle in use.
160
+ private func restoreSyncedBundle() {
161
+ guard let bundleId = preferences.getLastSyncedBundleId(),
162
+ let directory = liveUpdate.getBundleDirectory(bundleId: bundleId) else {
163
+ return
164
+ }
165
+ syncedBundleId = bundleId
166
+ latestAppDirectory = directory
167
+ }
168
+ }
169
+
170
+ /// De-duplicates concurrent downloads of the same bundle across all managers in the process.
171
+ /// A second caller for a bundle that is already downloading awaits the running download.
172
+ private actor BundleDownloads {
173
+ static let shared = BundleDownloads()
174
+
175
+ private var tasks: [String: Task<Void, Error>] = [:]
176
+
177
+ func run(bundleId: String, operation: @escaping @Sendable () async throws -> Void) async throws {
178
+ if let task = tasks[bundleId] {
179
+ return try await task.value
180
+ }
181
+ let task = Task { try await operation() }
182
+ tasks[bundleId] = task
183
+ defer { tasks[bundleId] = nil }
184
+ try await task.value
185
+ }
186
+ }
187
+ #endif
@@ -0,0 +1,81 @@
1
+ #if CAPAWESOME_INCLUDE_IONIC_PROVIDER
2
+ import Foundation
3
+
4
+ /// Persisted state of one provider manager, scoped by its state key (`<appId>` or `<appId>.<channel>`).
5
+ ///
6
+ /// All managers share the same `UserDefaults`, so a manager can see which bundles
7
+ /// the other managers still point at.
8
+ final class LiveUpdateIonicPreferences {
9
+ private static let keyPrefix = "CapawesomeLiveUpdateIonicProvider."
10
+ private static let lastSyncedBundleIdPrefix = keyPrefix + "lastSyncedBundleId."
11
+ private static let lastVersionCodePrefix = keyPrefix + "lastVersionCode."
12
+ private static let supersededBundleIdsPrefix = keyPrefix + "supersededBundleIds."
13
+
14
+ private let userDefaults: UserDefaults
15
+ private let lastSyncedBundleIdKey: String
16
+ private let lastVersionCodeKey: String
17
+ private let supersededBundleIdsKey: String
18
+
19
+ init(userDefaults: UserDefaults = .standard, stateKey: String) {
20
+ self.userDefaults = userDefaults
21
+ self.lastSyncedBundleIdKey = Self.lastSyncedBundleIdPrefix + stateKey
22
+ self.lastVersionCodeKey = Self.lastVersionCodePrefix + stateKey
23
+ self.supersededBundleIdsKey = Self.supersededBundleIdsPrefix + stateKey
24
+ }
25
+
26
+ func getLastSyncedBundleId() -> String? {
27
+ return userDefaults.string(forKey: lastSyncedBundleIdKey)
28
+ }
29
+
30
+ /// - Returns: The last synced bundle IDs of all managers.
31
+ func getLastSyncedBundleIdsOfAllManagers() -> Set<String> {
32
+ let bundleIds = userDefaults.dictionaryRepresentation()
33
+ .filter { $0.key.hasPrefix(Self.lastSyncedBundleIdPrefix) }
34
+ .compactMap { $0.value as? String }
35
+ return Set(bundleIds)
36
+ }
37
+
38
+ /// - Returns: The bundles this manager no longer points at and that may be deleted once unused.
39
+ func getSupersededBundleIds() -> Set<String> {
40
+ return Set(userDefaults.stringArray(forKey: supersededBundleIdsKey) ?? [])
41
+ }
42
+
43
+ func removeSupersededBundleIds(_ bundleIds: Set<String>) {
44
+ if bundleIds.isEmpty {
45
+ return
46
+ }
47
+ setSupersededBundleIds(getSupersededBundleIds().subtracting(bundleIds))
48
+ }
49
+
50
+ /// Forgets the last synced bundle if it was synced on another app version code,
51
+ /// because the bundle was built for that native version.
52
+ func resetLastSyncedBundleIdIfVersionChanged(versionCode: String) {
53
+ guard let lastSyncedBundleId = getLastSyncedBundleId(),
54
+ let lastVersionCode = userDefaults.string(forKey: lastVersionCodeKey),
55
+ lastVersionCode != versionCode else {
56
+ return
57
+ }
58
+ setSupersededBundleIds(getSupersededBundleIds().union([lastSyncedBundleId]))
59
+ userDefaults.removeObject(forKey: lastSyncedBundleIdKey)
60
+ userDefaults.removeObject(forKey: lastVersionCodeKey)
61
+ }
62
+
63
+ /// Points this manager at the given bundle, synced on the given app version code,
64
+ /// and records the previous bundle as superseded.
65
+ func setLastSyncedBundleId(_ bundleId: String, versionCode: String) {
66
+ if let previousBundleId = getLastSyncedBundleId(), previousBundleId != bundleId {
67
+ setSupersededBundleIds(getSupersededBundleIds().union([previousBundleId]))
68
+ }
69
+ userDefaults.set(bundleId, forKey: lastSyncedBundleIdKey)
70
+ userDefaults.set(versionCode, forKey: lastVersionCodeKey)
71
+ }
72
+
73
+ private func setSupersededBundleIds(_ bundleIds: Set<String>) {
74
+ if bundleIds.isEmpty {
75
+ userDefaults.removeObject(forKey: supersededBundleIdsKey)
76
+ } else {
77
+ userDefaults.set(Array(bundleIds), forKey: supersededBundleIdsKey)
78
+ }
79
+ }
80
+ }
81
+ #endif