@capawesome/capacitor-live-update 8.6.0 → 8.7.0
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 +123 -53
- package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +31 -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 +66 -39
- package/ios/Plugin/LiveUpdatePlugin.swift +2 -2
- 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,17 +7,24 @@ 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
|
|
|
23
30
|
private var rollbackDispatchWorkItem: DispatchWorkItem?
|
|
@@ -25,7 +32,10 @@ import CommonCrypto
|
|
|
25
32
|
private var lastAutoUpdateCheckTimestamp: Int64 = 0
|
|
26
33
|
private var syncInProgress = false
|
|
27
34
|
|
|
28
|
-
|
|
35
|
+
/// - Parameter plugin: The Capacitor plugin, or `nil` for a headless instance
|
|
36
|
+
/// (e.g. for Ionic Portals hosts that construct a provider manager directly).
|
|
37
|
+
/// In headless mode, WebView-related operations and plugin events are skipped.
|
|
38
|
+
init(config: LiveUpdateConfig, plugin: LiveUpdatePlugin? = nil) {
|
|
29
39
|
self.config = config
|
|
30
40
|
self.httpClient = LiveUpdateHttpClient(config: config)
|
|
31
41
|
self.plugin = plugin
|
|
@@ -38,8 +48,7 @@ import CommonCrypto
|
|
|
38
48
|
// Set the device ID on the HTTP client (after any potential config reset)
|
|
39
49
|
self.httpClient.setDeviceId(getDeviceId())
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
tryDeleteDownloadsDirectory()
|
|
51
|
+
_ = Self.interruptedDownloadsDeleted
|
|
43
52
|
|
|
44
53
|
// Start the rollback timer to rollback to the default bundle
|
|
45
54
|
// if the app is not ready after a certain time
|
|
@@ -166,6 +175,14 @@ import CommonCrypto
|
|
|
166
175
|
completion(result, nil)
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
/// - Returns: The on-disk URL of a downloaded bundle, or `nil` if the bundle does not exist.
|
|
179
|
+
public func getBundleDirectory(bundleId: String) -> URL? {
|
|
180
|
+
guard hasBundleById(bundleId) else {
|
|
181
|
+
return nil
|
|
182
|
+
}
|
|
183
|
+
return buildBundleURLFor(bundleId: bundleId)
|
|
184
|
+
}
|
|
185
|
+
|
|
169
186
|
@objc public func getCustomId(completion: @escaping (Result?, Error?) -> Void) {
|
|
170
187
|
let customId = preferences.getCustomId()
|
|
171
188
|
|
|
@@ -351,7 +368,12 @@ import CommonCrypto
|
|
|
351
368
|
|
|
352
369
|
// Move the unzipped files to the bundles directory
|
|
353
370
|
let bundlePath = self.buildBundlePathFor(bundleId: bundleId)
|
|
354
|
-
|
|
371
|
+
do {
|
|
372
|
+
try FileManager.default.moveItem(atPath: indexHtmlFile.deletingLastPathComponent().path, toPath: bundlePath)
|
|
373
|
+
} catch where hasBundleById(bundleId) {
|
|
374
|
+
// A concurrent download of the same bundle was installed first
|
|
375
|
+
CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Bundle already installed: \(bundleId)")
|
|
376
|
+
}
|
|
355
377
|
}
|
|
356
378
|
|
|
357
379
|
private func addBundleOfTypeManifest(bundleId: String, directory: URL) async throws {
|
|
@@ -385,7 +407,7 @@ import CommonCrypto
|
|
|
385
407
|
}
|
|
386
408
|
|
|
387
409
|
private func buildDownloadsDirectoryUrl() -> URL {
|
|
388
|
-
return
|
|
410
|
+
return Self.downloadsDirectoryUrl
|
|
389
411
|
}
|
|
390
412
|
|
|
391
413
|
private func copyCurrentBundleFile(fileToCopy: ManifestItem, toDirectory: URL) throws {
|
|
@@ -455,18 +477,8 @@ import CommonCrypto
|
|
|
455
477
|
}
|
|
456
478
|
|
|
457
479
|
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
|
-
}
|
|
480
|
+
for bundleId in getDownloadedBundleIds() {
|
|
481
|
+
tryDeleteUnusedBundle(bundleId: bundleId)
|
|
470
482
|
}
|
|
471
483
|
}
|
|
472
484
|
|
|
@@ -593,18 +605,18 @@ import CommonCrypto
|
|
|
593
605
|
}
|
|
594
606
|
|
|
595
607
|
private func fetchLatestBundle(_ options: FetchLatestBundleOptions) async throws -> GetLatestBundleResponse? {
|
|
608
|
+
let appId = try requireAppId(options.getAppId())
|
|
596
609
|
let channel = options.getChannel() ?? getChannel()
|
|
597
610
|
var parameters = [String: String]()
|
|
598
611
|
parameters["appVersionCode"] = getVersionCode()
|
|
599
612
|
parameters["appVersionName"] = getVersionName()
|
|
600
|
-
parameters["bundleId"] = getCurrentBundleId()
|
|
613
|
+
parameters["bundleId"] = options.getBundleId() ?? getCurrentBundleId()
|
|
601
614
|
parameters["channelName"] = channel
|
|
602
615
|
parameters["customId"] = preferences.getCustomId()
|
|
603
616
|
parameters["deviceId"] = getDeviceId()
|
|
604
617
|
parameters["osVersion"] = await UIDevice.current.systemVersion
|
|
605
618
|
parameters["platform"] = "1"
|
|
606
619
|
parameters["pluginVersion"] = LiveUpdatePlugin.version
|
|
607
|
-
let appId = try requireAppId()
|
|
608
620
|
var urlComponents = URLComponents(string: "https://\(config.serverDomain)/v1/apps/\(appId)/bundles/latest")!
|
|
609
621
|
urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) }
|
|
610
622
|
let url = try urlComponents.asURL()
|
|
@@ -614,12 +626,12 @@ import CommonCrypto
|
|
|
614
626
|
CAPLog.print("[", LiveUpdatePlugin.tag, "] Latest bundle response: ", String(decoding: data, as: UTF8.self))
|
|
615
627
|
}
|
|
616
628
|
if let error = response.error {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
}
|
|
629
|
+
// The server answered with an error status, so no update is available
|
|
630
|
+
if let statusCode = response.response?.statusCode, !(200..<300).contains(statusCode) {
|
|
631
|
+
return nil
|
|
621
632
|
}
|
|
622
|
-
|
|
633
|
+
// The server could not be reached (e.g. offline or timed out) or sent an invalid body
|
|
634
|
+
throw error.underlyingError ?? error
|
|
623
635
|
}
|
|
624
636
|
if let value = response.value {
|
|
625
637
|
return value
|
|
@@ -642,14 +654,14 @@ import CommonCrypto
|
|
|
642
654
|
}
|
|
643
655
|
}
|
|
644
656
|
|
|
645
|
-
|
|
657
|
+
func getAppId() -> String? {
|
|
646
658
|
if let appId = preferences.getAppId() {
|
|
647
659
|
return appId
|
|
648
660
|
}
|
|
649
661
|
return config.appId
|
|
650
662
|
}
|
|
651
663
|
|
|
652
|
-
|
|
664
|
+
func getChannel() -> String? {
|
|
653
665
|
var channel: String?
|
|
654
666
|
if let _ = config.defaultChannel {
|
|
655
667
|
channel = config.defaultChannel
|
|
@@ -695,7 +707,7 @@ import CommonCrypto
|
|
|
695
707
|
|
|
696
708
|
/// - Returns: The path to the current bundle directory or `nil` if no view controller was found.
|
|
697
709
|
private func getCurrentCapacitorServerPath() -> String? {
|
|
698
|
-
guard let viewController = self.plugin
|
|
710
|
+
guard let viewController = self.plugin?.bridge?.viewController as? CAPBridgeViewController else {
|
|
699
711
|
return nil
|
|
700
712
|
}
|
|
701
713
|
return viewController.getServerBasePath()
|
|
@@ -730,7 +742,7 @@ import CommonCrypto
|
|
|
730
742
|
return preferences.getPreviousBundleId()
|
|
731
743
|
}
|
|
732
744
|
|
|
733
|
-
|
|
745
|
+
func getVersionCode() -> String {
|
|
734
746
|
guard let appVersionCode = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else {
|
|
735
747
|
return ""
|
|
736
748
|
}
|
|
@@ -777,7 +789,7 @@ import CommonCrypto
|
|
|
777
789
|
}
|
|
778
790
|
|
|
779
791
|
private func notifyDownloadBundleProgressListeners(_ event: DownloadBundleProgressEvent) {
|
|
780
|
-
plugin
|
|
792
|
+
plugin?.notifyDownloadBundleProgressListeners(event)
|
|
781
793
|
}
|
|
782
794
|
|
|
783
795
|
private func performAutoUpdate() {
|
|
@@ -804,8 +816,8 @@ import CommonCrypto
|
|
|
804
816
|
}
|
|
805
817
|
}
|
|
806
818
|
|
|
807
|
-
private func requireAppId() throws -> String {
|
|
808
|
-
guard let appId = getAppId(), !appId.isEmpty else {
|
|
819
|
+
private func requireAppId(_ appIdOverride: String? = nil) throws -> String {
|
|
820
|
+
guard let appId = appIdOverride ?? getAppId(), !appId.isEmpty else {
|
|
809
821
|
throw CustomError.appIdMissing
|
|
810
822
|
}
|
|
811
823
|
return appId
|
|
@@ -861,7 +873,7 @@ import CommonCrypto
|
|
|
861
873
|
}
|
|
862
874
|
|
|
863
875
|
private func setCurrentCapacitorServerPath(path: String) {
|
|
864
|
-
guard let viewController = self.plugin
|
|
876
|
+
guard let viewController = self.plugin?.bridge?.viewController as? CAPBridgeViewController else {
|
|
865
877
|
return
|
|
866
878
|
}
|
|
867
879
|
viewController.setServerBasePath(path: path)
|
|
@@ -880,11 +892,11 @@ import CommonCrypto
|
|
|
880
892
|
|
|
881
893
|
private func notifyNextBundleSetListeners(_ bundleId: String?) {
|
|
882
894
|
let event = NextBundleSetEvent(bundleId: bundleId)
|
|
883
|
-
plugin
|
|
895
|
+
plugin?.notifyNextBundleSetListeners(event)
|
|
884
896
|
}
|
|
885
897
|
|
|
886
898
|
private func notifyReloadedListeners() {
|
|
887
|
-
plugin
|
|
899
|
+
plugin?.notifyReloadedListeners()
|
|
888
900
|
}
|
|
889
901
|
|
|
890
902
|
private func addBlockedBundleId(_ bundleId: String) {
|
|
@@ -994,8 +1006,23 @@ import CommonCrypto
|
|
|
994
1006
|
}
|
|
995
1007
|
}
|
|
996
1008
|
|
|
997
|
-
|
|
998
|
-
|
|
1009
|
+
/// Deletes the bundle unless it is the current or next bundle.
|
|
1010
|
+
/// - Returns: `true` if the bundle does not exist anymore.
|
|
1011
|
+
@discardableResult
|
|
1012
|
+
func tryDeleteUnusedBundle(bundleId: String) -> Bool {
|
|
1013
|
+
guard hasBundleById(bundleId) else {
|
|
1014
|
+
return true
|
|
1015
|
+
}
|
|
1016
|
+
guard bundleId != getCurrentBundleId() && bundleId != getNextBundleId() else {
|
|
1017
|
+
return false
|
|
1018
|
+
}
|
|
1019
|
+
do {
|
|
1020
|
+
try deleteBundleById(bundleId)
|
|
1021
|
+
return true
|
|
1022
|
+
} catch {
|
|
1023
|
+
CAPLog.print("[", LiveUpdatePlugin.tag, "] ", "Failed to delete bundle with id: \(bundleId)")
|
|
1024
|
+
return false
|
|
1025
|
+
}
|
|
999
1026
|
}
|
|
1000
1027
|
|
|
1001
1028
|
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.0"
|
|
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)
|
|
@@ -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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#if CAPAWESOME_INCLUDE_IONIC_PROVIDER
|
|
2
|
+
import Foundation
|
|
3
|
+
import LiveUpdateProvider
|
|
4
|
+
|
|
5
|
+
extension LiveUpdatePlugin: LiveUpdateProvider {
|
|
6
|
+
/// Creates a manager for the Ionic Live Update Provider SDK. Invoked natively by
|
|
7
|
+
/// Federated Capacitor after resolving this plugin by its Capacitor plugin name.
|
|
8
|
+
public func createManager(configuration: [String: Any]) throws -> any ProviderManager {
|
|
9
|
+
guard let implementation = implementation else {
|
|
10
|
+
throw ProviderError.invalidConfiguration(message: CustomError.pluginNotInitialized.localizedDescription)
|
|
11
|
+
}
|
|
12
|
+
return try LiveUpdateIonicManager(configuration: configuration, liveUpdate: implementation)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
#endif
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@capawesome/capacitor-live-update",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.7.0",
|
|
4
4
|
"description": "Capacitor plugin to update your app remotely in real-time on Android, iOS and Electron.",
|
|
5
5
|
"main": "dist/plugin.cjs.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"android/src/main/",
|
|
11
11
|
"android/build.gradle",
|
|
12
|
+
"android/consumer-rules.pro",
|
|
12
13
|
"dist/",
|
|
13
14
|
"electron/dist/",
|
|
14
15
|
"ios/Plugin/",
|