@capawesome/capacitor-live-update 8.5.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.
Files changed (26) hide show
  1. package/CapawesomeCapacitorLiveUpdate.podspec +15 -0
  2. package/Package.swift +21 -5
  3. package/README.md +38 -5
  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 +129 -55
  7. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePlugin.java +31 -1
  8. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/LiveUpdatePreferences.java +12 -14
  9. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/DownloadBundleOptions.java +11 -5
  10. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/options/FetchLatestBundleOptions.java +23 -1
  11. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/classes/results/FetchLatestBundleResult.java +30 -0
  12. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicManager.kt +229 -0
  13. package/android/src/main/java/io/capawesome/capacitorjs/plugins/liveupdate/providers/ionic/LiveUpdateIonicPreferences.kt +85 -0
  14. package/dist/docs.json +1 -1
  15. package/dist/esm/definitions.d.ts +3 -0
  16. package/dist/esm/definitions.js.map +1 -1
  17. package/ios/Plugin/Classes/Options/DownloadBundleOptions.swift +7 -6
  18. package/ios/Plugin/Classes/Options/FetchLatestBundleOptions.swift +19 -3
  19. package/ios/Plugin/Classes/Results/FetchLatestBundleResult.swift +24 -0
  20. package/ios/Plugin/Enums/CustomError.swift +13 -0
  21. package/ios/Plugin/LiveUpdate.swift +66 -39
  22. package/ios/Plugin/LiveUpdatePlugin.swift +2 -2
  23. package/ios/Plugin/Providers/Ionic/LiveUpdateIonicManager.swift +187 -0
  24. package/ios/Plugin/Providers/Ionic/LiveUpdateIonicPreferences.swift +81 -0
  25. package/ios/Plugin/Providers/Ionic/LiveUpdatePlugin+LiveUpdateProvider.swift +15 -0
  26. package/package.json +2 -1
@@ -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.5.0",
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/",