@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.
- package/CapawesomeCapacitorLiveUpdate.podspec +15 -0
- package/Package.swift +21 -5
- package/README.md +35 -2
- package/android/build.gradle +19 -0
- package/android/consumer-rules.pro +5 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdate.java +181 -56
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +47 -1
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -5
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +23 -1
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +30 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicManager.kt +229 -0
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicPreferences.kt +85 -0
- package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -6
- package/ios/Plugin/Classes/Options/FetchLatestBundleOptions.swift +19 -3
- package/ios/Plugin/Classes/Results/FetchLatestBundleResult.swift +24 -0
- package/ios/Plugin/Enums/CustomError.swift +13 -0
- package/ios/Plugin/LiveUpdate.swift +140 -45
- package/ios/Plugin/LiveUpdatePlugin.swift +16 -3
- package/ios/Plugin/Providers/Ionic/LiveUpdateIonicManager.swift +187 -0
- package/ios/Plugin/Providers/Ionic/LiveUpdateIonicPreferences.swift +81 -0
- package/ios/Plugin/Providers/Ionic/LiveUpdatePlugin+LiveUpdateProvider.swift +15 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
808
|
-
|
|
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
|
|
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
|
|
941
|
+
plugin?.notifyNextBundleSetListeners(event)
|
|
884
942
|
}
|
|
885
943
|
|
|
886
944
|
private func notifyReloadedListeners() {
|
|
887
|
-
plugin
|
|
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
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
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
|
-
|
|
998
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|