@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.
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 +123 -53
  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/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 +66 -39
  18. package/ios/Plugin/LiveUpdatePlugin.swift +2 -2
  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
@@ -6,17 +6,39 @@ import com.getcapacitor.PluginCall;
6
6
 
7
7
  public class FetchLatestBundleOptions {
8
8
 
9
+ @Nullable
10
+ private final String appId;
11
+
12
+ @Nullable
13
+ private final String bundleId;
14
+
9
15
  @Nullable
10
16
  private final String channel;
11
17
 
12
18
  public FetchLatestBundleOptions(@NonNull PluginCall call) {
13
- this.channel = call.getString("channel", null);
19
+ this(null, null, call.getString("channel", null));
14
20
  }
15
21
 
16
22
  public FetchLatestBundleOptions(@Nullable String channel) {
23
+ this(null, null, channel);
24
+ }
25
+
26
+ public FetchLatestBundleOptions(@Nullable String appId, @Nullable String bundleId, @Nullable String channel) {
27
+ this.appId = appId;
28
+ this.bundleId = bundleId;
17
29
  this.channel = channel;
18
30
  }
19
31
 
32
+ @Nullable
33
+ public String getAppId() {
34
+ return appId;
35
+ }
36
+
37
+ @Nullable
38
+ public String getBundleId() {
39
+ return bundleId;
40
+ }
41
+
20
42
  @Nullable
21
43
  public String getChannel() {
22
44
  return channel;
@@ -48,6 +48,36 @@ public class FetchLatestBundleResult implements Result {
48
48
  this.signature = signature;
49
49
  }
50
50
 
51
+ @Nullable
52
+ public ArtifactType getArtifactType() {
53
+ return artifactType;
54
+ }
55
+
56
+ @Nullable
57
+ public String getBundleId() {
58
+ return bundleId;
59
+ }
60
+
61
+ @Nullable
62
+ public String getChecksum() {
63
+ return checksum;
64
+ }
65
+
66
+ @Nullable
67
+ public JSONObject getCustomProperties() {
68
+ return customProperties;
69
+ }
70
+
71
+ @Nullable
72
+ public String getDownloadUrl() {
73
+ return downloadUrl;
74
+ }
75
+
76
+ @Nullable
77
+ public String getSignature() {
78
+ return signature;
79
+ }
80
+
51
81
  @NonNull
52
82
  public JSObject toJSObject() {
53
83
  JSObject result = new JSObject();
@@ -0,0 +1,229 @@
1
+ package io.capawesome.capacitorjs.plugins.liveupdate.providers.ionic
2
+
3
+ import android.content.Context
4
+ import com.getcapacitor.Logger
5
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdate
6
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdateConfig
7
+ import io.capawesome.capacitorjs.plugins.liveupdate.LiveUpdatePlugin
8
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.DownloadBundleOptions
9
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.options.FetchLatestBundleOptions
10
+ import io.capawesome.capacitorjs.plugins.liveupdate.classes.results.FetchLatestBundleResult
11
+ import io.capawesome.capacitorjs.plugins.liveupdate.enums.ArtifactType
12
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.EmptyCallback
13
+ import io.capawesome.capacitorjs.plugins.liveupdate.interfaces.NonEmptyCallback
14
+ import io.ionic.liveupdateprovider.MetadataSyncResult
15
+ import io.ionic.liveupdateprovider.ProviderError
16
+ import io.ionic.liveupdateprovider.ProviderManager
17
+ import io.ionic.liveupdateprovider.ProviderSyncResult
18
+ import java.io.File
19
+ import java.util.concurrent.ConcurrentHashMap
20
+ import kotlin.coroutines.resume
21
+ import kotlin.coroutines.resumeWithException
22
+ import kotlin.coroutines.suspendCoroutine
23
+ import kotlinx.coroutines.CoroutineScope
24
+ import kotlinx.coroutines.Deferred
25
+ import kotlinx.coroutines.SupervisorJob
26
+ import kotlinx.coroutines.async
27
+ import org.json.JSONObject
28
+
29
+ /**
30
+ * Ionic Live Update Provider manager backed by the Capawesome live update plugin.
31
+ *
32
+ * Each manager persists its active bundle scoped by its resolved app ID and channel, so that
33
+ * multiple shells (Portals or Federated Capacitor apps) do not collide with each other or with
34
+ * the standalone plugin's `currentBundleId` / `nextBundleId` state.
35
+ *
36
+ * Provider configuration keys (V1):
37
+ * - `appId` (optional) — Capawesome Cloud app UUID; falls back to plugin config, required in either
38
+ * - `channel` (optional) — channel to sync; falls back to plugin config
39
+ * - `publicKey`, `serverDomain`, `httpTimeout` (optional) — same as the plugin config; only
40
+ * applied by the headless constructor, otherwise the running plugin's config is used
41
+ */
42
+ class LiveUpdateIonicManager
43
+ @Throws(ProviderError.InvalidConfiguration::class)
44
+ constructor(context: Context, configuration: Map<String, *>, private val liveUpdate: LiveUpdate) : ProviderManager {
45
+
46
+ /**
47
+ * Creates a manager without a running Capacitor plugin, e.g. for Ionic Portals hosts
48
+ * that construct the manager directly and attach it to a Portal via
49
+ * `setLiveUpdateProviderManager(...)`. Uses a headless [LiveUpdate] instance configured
50
+ * via the configuration map.
51
+ */
52
+ @Throws(ProviderError.InvalidConfiguration::class)
53
+ constructor(
54
+ context: Context,
55
+ configuration: Map<String, *>
56
+ ) : this(context, configuration, LiveUpdate(createLiveUpdateConfig(configuration), context.applicationContext))
57
+
58
+ companion object {
59
+ /**
60
+ * Downloads in flight per bundle ID, shared by all managers, so that concurrent syncs
61
+ * download the same bundle only once.
62
+ */
63
+ private val bundleDownloads = ConcurrentHashMap<String, Deferred<Unit>>()
64
+ private val bundleDownloadScope = CoroutineScope(SupervisorJob())
65
+
66
+ @Throws(ProviderError.InvalidConfiguration::class)
67
+ private fun createLiveUpdateConfig(configuration: Map<String, *>): LiveUpdateConfig {
68
+ val config = LiveUpdateConfig()
69
+ getOptionalValue<String>(configuration, "publicKey")?.let { config.publicKey = it }
70
+ getOptionalValue<String>(configuration, "serverDomain")?.let { config.serverDomain = it }
71
+ getOptionalValue<Number>(configuration, "httpTimeout")?.let { config.httpTimeout = it.toInt() }
72
+ return config
73
+ }
74
+
75
+ @Throws(ProviderError.InvalidConfiguration::class)
76
+ private inline fun <reified T> getOptionalValue(configuration: Map<String, *>, key: String): T? {
77
+ val value = configuration[key] ?: return null
78
+ return value as? T ?: throw ProviderError.InvalidConfiguration("$key has an invalid type.")
79
+ }
80
+ }
81
+
82
+ override var latestAppDirectory: File? = null
83
+ private set
84
+
85
+ private val appId: String
86
+ private val channel: String?
87
+ private val preferences: LiveUpdateIonicPreferences
88
+ private val versionCode: String = liveUpdate.versionCodeAsString
89
+ private var syncedBundleId: String? = null
90
+
91
+ init {
92
+ val resolvedAppId = configuration["appId"] as? String ?: liveUpdate.appId
93
+ if (resolvedAppId.isNullOrEmpty()) {
94
+ throw ProviderError.InvalidConfiguration(LiveUpdatePlugin.ERROR_APP_ID_MISSING)
95
+ }
96
+ appId = resolvedAppId
97
+ channel = configuration["channel"] as? String ?: liveUpdate.channel
98
+ val stateKey = if (channel.isNullOrEmpty()) appId else "$appId.$channel"
99
+ val sharedPreferences =
100
+ context.applicationContext.getSharedPreferences(LiveUpdateIonicPreferences.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
101
+ preferences = LiveUpdateIonicPreferences(sharedPreferences, stateKey)
102
+ preferences.resetLastSyncedBundleIdIfVersionChanged(versionCode)
103
+ restoreSyncedBundle()
104
+ deleteSupersededBundles()
105
+ }
106
+
107
+ override suspend fun sync(): ProviderSyncResult? {
108
+ try {
109
+ val result = fetchLatestBundle()
110
+
111
+ // No update available; nothing to report.
112
+ val bundleId = result.bundleId
113
+ if (bundleId == null || bundleId == syncedBundleId) {
114
+ return null
115
+ }
116
+
117
+ // Use the bundle on disk, or download it (or wait for a running download of the same bundle).
118
+ val directory = liveUpdate.getBundleDirectory(bundleId) ?: run {
119
+ downloadBundleOnce(bundleId, result)
120
+ liveUpdate.getBundleDirectory(bundleId) ?: throw Exception(LiveUpdatePlugin.ERROR_BUNDLE_DIRECTORY_NOT_FOUND)
121
+ }
122
+ applySyncedBundle(bundleId, directory)
123
+ return MetadataSyncResult(buildMetadata(bundleId, result.customProperties))
124
+ } catch (exception: Exception) {
125
+ Logger.error(LiveUpdatePlugin.TAG, "Ionic provider sync failed: ${exception.message}", exception)
126
+ throw exception
127
+ }
128
+ }
129
+
130
+ private fun applySyncedBundle(bundleId: String, directory: File) {
131
+ syncedBundleId = bundleId
132
+ latestAppDirectory = directory
133
+ preferences.setLastSyncedBundleId(bundleId, versionCode)
134
+ }
135
+
136
+ private fun buildMetadata(bundleId: String, customProperties: JSONObject?): Map<String, Any> {
137
+ val metadata = mutableMapOf<String, Any>("bundleId" to bundleId)
138
+ channel?.let { metadata["channel"] = it }
139
+ customProperties?.let {
140
+ val customPropertiesMap = jsonObjectToMap(it)
141
+ if (customPropertiesMap.isNotEmpty()) {
142
+ metadata["customProperties"] = customPropertiesMap
143
+ }
144
+ }
145
+ return metadata
146
+ }
147
+
148
+ /**
149
+ * Deletes the bundles this manager no longer points at, unless another manager or the
150
+ * plugin still uses them. Never runs right after a sync, because a visible portal may
151
+ * still serve the previous bundle.
152
+ */
153
+ private fun deleteSupersededBundles() {
154
+ val supersededBundleIds = preferences.getSupersededBundleIds()
155
+ if (supersededBundleIds.isEmpty()) {
156
+ return
157
+ }
158
+ val referencedBundleIds = preferences.getLastSyncedBundleIdsOfAllManagers()
159
+ val deletedBundleIds = supersededBundleIds
160
+ .filter { it !in referencedBundleIds && liveUpdate.tryDeleteUnusedBundle(it) }
161
+ .toSet()
162
+ preferences.removeSupersededBundleIds(deletedBundleIds)
163
+ }
164
+
165
+ private suspend fun downloadBundle(bundleId: String, result: FetchLatestBundleResult) {
166
+ val downloadUrl = result.downloadUrl
167
+ if (downloadUrl.isNullOrEmpty()) {
168
+ throw Exception(LiveUpdatePlugin.ERROR_DOWNLOAD_URL_MISSING)
169
+ }
170
+ val artifactType = result.artifactType ?: ArtifactType.ZIP
171
+ val options = DownloadBundleOptions(artifactType, bundleId, result.checksum, result.signature, downloadUrl)
172
+ suspendCoroutine { continuation ->
173
+ liveUpdate.downloadBundle(
174
+ options,
175
+ object : EmptyCallback {
176
+ override fun success() = continuation.resume(Unit)
177
+
178
+ override fun error(exception: Exception) = continuation.resumeWithException(exception)
179
+ }
180
+ )
181
+ }
182
+ }
183
+
184
+ private suspend fun downloadBundleOnce(bundleId: String, result: FetchLatestBundleResult) {
185
+ val download = bundleDownloads.computeIfAbsent(bundleId) {
186
+ bundleDownloadScope.async { downloadBundle(bundleId, result) }
187
+ }
188
+ try {
189
+ download.await()
190
+ } finally {
191
+ bundleDownloads.remove(bundleId, download)
192
+ }
193
+ }
194
+
195
+ private suspend fun fetchLatestBundle(): FetchLatestBundleResult = suspendCoroutine { continuation ->
196
+ liveUpdate.fetchLatestBundle(
197
+ FetchLatestBundleOptions(appId, syncedBundleId, channel),
198
+ object : NonEmptyCallback<FetchLatestBundleResult> {
199
+ override fun success(result: FetchLatestBundleResult) = continuation.resume(result)
200
+
201
+ override fun error(exception: Exception) = continuation.resumeWithException(exception)
202
+ }
203
+ )
204
+ }
205
+
206
+ private fun jsonObjectToMap(jsonObject: JSONObject): Map<String, Any> {
207
+ val map = mutableMapOf<String, Any>()
208
+ val keys = jsonObject.keys()
209
+ while (keys.hasNext()) {
210
+ val key = keys.next()
211
+ val value = jsonObject.opt(key)
212
+ if (value != null && value != JSONObject.NULL) {
213
+ map[key] = value
214
+ }
215
+ }
216
+ return map
217
+ }
218
+
219
+ /**
220
+ * Restores the last synced bundle, if it is still on disk, so the host can load it offline
221
+ * and the next sync reports it as the bundle in use.
222
+ */
223
+ private fun restoreSyncedBundle() {
224
+ val bundleId = preferences.getLastSyncedBundleId() ?: return
225
+ val directory = liveUpdate.getBundleDirectory(bundleId) ?: return
226
+ syncedBundleId = bundleId
227
+ latestAppDirectory = directory
228
+ }
229
+ }
@@ -0,0 +1,85 @@
1
+ package io.capawesome.capacitorjs.plugins.liveupdate.providers.ionic
2
+
3
+ import android.content.SharedPreferences
4
+
5
+ /**
6
+ * Persisted state of one provider manager, scoped by its state key (`<appId>` or `<appId>.<channel>`).
7
+ *
8
+ * All managers share the same [SharedPreferences] file, so a manager can see which bundles
9
+ * the other managers still point at.
10
+ */
11
+ class LiveUpdateIonicPreferences(private val sharedPreferences: SharedPreferences, stateKey: String) {
12
+
13
+ companion object {
14
+ const val SHARED_PREFERENCES_NAME = "CapawesomeLiveUpdateIonicProvider"
15
+ private const val LAST_SYNCED_BUNDLE_ID_PREFIX = "lastSyncedBundleId."
16
+ private const val LAST_VERSION_CODE_PREFIX = "lastVersionCode."
17
+ private const val SUPERSEDED_BUNDLE_IDS_PREFIX = "supersededBundleIds."
18
+ }
19
+
20
+ private val lastSyncedBundleIdKey = LAST_SYNCED_BUNDLE_ID_PREFIX + stateKey
21
+ private val lastVersionCodeKey = LAST_VERSION_CODE_PREFIX + stateKey
22
+ private val supersededBundleIdsKey = SUPERSEDED_BUNDLE_IDS_PREFIX + stateKey
23
+
24
+ fun getLastSyncedBundleId(): String? = sharedPreferences.getString(lastSyncedBundleIdKey, null)
25
+
26
+ /**
27
+ * @return The last synced bundle IDs of all managers.
28
+ */
29
+ fun getLastSyncedBundleIdsOfAllManagers(): Set<String> = sharedPreferences.all
30
+ .filterKeys { it.startsWith(LAST_SYNCED_BUNDLE_ID_PREFIX) }
31
+ .values
32
+ .filterIsInstance<String>()
33
+ .toSet()
34
+
35
+ /**
36
+ * @return The bundles this manager no longer points at and that may be deleted once unused.
37
+ */
38
+ fun getSupersededBundleIds(): Set<String> = sharedPreferences.getStringSet(supersededBundleIdsKey, null)?.toSet() ?: emptySet()
39
+
40
+ fun removeSupersededBundleIds(bundleIds: Set<String>) {
41
+ if (bundleIds.isEmpty()) {
42
+ return
43
+ }
44
+ val remainingBundleIds = getSupersededBundleIds() - bundleIds
45
+ val editor = sharedPreferences.edit()
46
+ if (remainingBundleIds.isEmpty()) {
47
+ editor.remove(supersededBundleIdsKey)
48
+ } else {
49
+ editor.putStringSet(supersededBundleIdsKey, remainingBundleIds)
50
+ }
51
+ editor.apply()
52
+ }
53
+
54
+ /**
55
+ * Forgets the last synced bundle if it was synced on another app version code,
56
+ * because the bundle was built for that native version.
57
+ */
58
+ fun resetLastSyncedBundleIdIfVersionChanged(versionCode: String) {
59
+ val lastSyncedBundleId = getLastSyncedBundleId() ?: return
60
+ val lastVersionCode = sharedPreferences.getString(lastVersionCodeKey, null) ?: return
61
+ if (lastVersionCode == versionCode) {
62
+ return
63
+ }
64
+ sharedPreferences.edit()
65
+ .putStringSet(supersededBundleIdsKey, getSupersededBundleIds() + lastSyncedBundleId)
66
+ .remove(lastSyncedBundleIdKey)
67
+ .remove(lastVersionCodeKey)
68
+ .apply()
69
+ }
70
+
71
+ /**
72
+ * Points this manager at the given bundle, synced on the given app version code,
73
+ * and records the previous bundle as superseded.
74
+ */
75
+ fun setLastSyncedBundleId(bundleId: String, versionCode: String) {
76
+ val editor = sharedPreferences.edit()
77
+ val previousBundleId = getLastSyncedBundleId()
78
+ if (previousBundleId != null && previousBundleId != bundleId) {
79
+ editor.putStringSet(supersededBundleIdsKey, getSupersededBundleIds() + previousBundleId)
80
+ }
81
+ editor.putString(lastSyncedBundleIdKey, bundleId)
82
+ .putString(lastVersionCodeKey, versionCode)
83
+ .apply()
84
+ }
85
+ }
@@ -8,12 +8,13 @@ import Capacitor
8
8
  private var signature: String?
9
9
  private var url: String
10
10
 
11
- init(artifactType: String, bundleId: String, checksum: String?, signature: String?, url: String) {
12
- if artifactType == "manifest" {
13
- self.artifactType = .manifest
14
- } else {
15
- self.artifactType = .zip
16
- }
11
+ convenience init(artifactType: String, bundleId: String, checksum: String?, signature: String?, url: String) {
12
+ let artifactType: ArtifactType = artifactType == "manifest" ? .manifest : .zip
13
+ self.init(artifactType: artifactType, bundleId: bundleId, checksum: checksum, signature: signature, url: url)
14
+ }
15
+
16
+ init(artifactType: ArtifactType, bundleId: String, checksum: String?, signature: String?, url: String) {
17
+ self.artifactType = artifactType
17
18
  self.bundleId = bundleId
18
19
  self.checksum = checksum
19
20
  self.signature = signature
@@ -2,16 +2,32 @@ import Foundation
2
2
  import Capacitor
3
3
 
4
4
  @objc public class FetchLatestBundleOptions: NSObject {
5
+ private var appId: String?
6
+ private var bundleId: String?
5
7
  private var channel: String?
6
8
 
7
- init(_ call: CAPPluginCall) {
8
- self.channel = call.getString("channel")
9
+ convenience init(_ call: CAPPluginCall) {
10
+ self.init(appId: nil, bundleId: nil, channel: call.getString("channel"))
9
11
  }
10
12
 
11
- init(channel: String?) {
13
+ convenience init(channel: String?) {
14
+ self.init(appId: nil, bundleId: nil, channel: channel)
15
+ }
16
+
17
+ init(appId: String?, bundleId: String?, channel: String?) {
18
+ self.appId = appId
19
+ self.bundleId = bundleId
12
20
  self.channel = channel
13
21
  }
14
22
 
23
+ func getAppId() -> String? {
24
+ return appId
25
+ }
26
+
27
+ func getBundleId() -> String? {
28
+ return bundleId
29
+ }
30
+
15
31
  func getChannel() -> String? {
16
32
  return channel
17
33
  }
@@ -20,6 +20,30 @@ import Capacitor
20
20
  self.signature = signature
21
21
  }
22
22
 
23
+ func getArtifactType() -> ArtifactType? {
24
+ return artifactType
25
+ }
26
+
27
+ public func getBundleId() -> String? {
28
+ return bundleId
29
+ }
30
+
31
+ public func getChecksum() -> String? {
32
+ return checksum
33
+ }
34
+
35
+ public func getCustomProperties() -> [String: Any]? {
36
+ return customProperties
37
+ }
38
+
39
+ public func getDownloadUrl() -> String? {
40
+ return downloadUrl
41
+ }
42
+
43
+ public func getSignature() -> String? {
44
+ return signature
45
+ }
46
+
23
47
  public func toJSObject() -> AnyObject {
24
48
  var result = JSObject()
25
49
  if artifactType == .manifest {
@@ -3,6 +3,7 @@ import Foundation
3
3
  public enum CustomError: Error {
4
4
  case appIdMissing
5
5
  case bundleAlreadyExists
6
+ case bundleDirectoryNotFound
6
7
  case bundleIdMissing
7
8
  case bundleIndexHtmlMissing
8
9
  case bundleNotFound
@@ -10,9 +11,12 @@ public enum CustomError: Error {
10
11
  case channelMissing
11
12
  case checksumCalculationFailed
12
13
  case checksumMismatch
14
+ case configurationValueInvalid(key: String)
13
15
  case customIdMissing
14
16
  case downloadFailed
17
+ case downloadUrlMissing
15
18
  case httpTimeout
19
+ case pluginNotInitialized
16
20
  case signatureMissing
17
21
  case signatureVerificationFailed
18
22
  case syncInProgress
@@ -27,6 +31,8 @@ extension CustomError: LocalizedError {
27
31
  return NSLocalizedString("appId must be configured.", comment: "appIdMissing")
28
32
  case .bundleAlreadyExists:
29
33
  return NSLocalizedString("bundle already exists.", comment: "bundleAlreadyExists")
34
+ case .bundleDirectoryNotFound:
35
+ return NSLocalizedString("Bundle directory could not be resolved.", comment: "bundleDirectoryNotFound")
30
36
  case .bundleIdMissing:
31
37
  return NSLocalizedString("bundleId must be provided.", comment: "bundleIdMissing")
32
38
  case .bundleIndexHtmlMissing:
@@ -41,12 +47,19 @@ extension CustomError: LocalizedError {
41
47
  return NSLocalizedString("Failed to calculate checksum.", comment: "checksumCalculationFailed")
42
48
  case .checksumMismatch:
43
49
  return NSLocalizedString("Checksum mismatch.", comment: "checksumMismatch")
50
+ case .configurationValueInvalid(let key):
51
+ let format = NSLocalizedString("%@ has an invalid type.", comment: "configurationValueInvalid")
52
+ return String(format: format, key)
44
53
  case .customIdMissing:
45
54
  return NSLocalizedString("customId must be provided.", comment: "customIdMissing")
46
55
  case .downloadFailed:
47
56
  return NSLocalizedString("Bundle could not be downloaded.", comment: "downloadFailed")
57
+ case .downloadUrlMissing:
58
+ return NSLocalizedString("Bundle does not have a valid download URL.", comment: "downloadUrlMissing")
48
59
  case .httpTimeout:
49
60
  return NSLocalizedString("Request timed out.", comment: "httpTimeout")
61
+ case .pluginNotInitialized:
62
+ return NSLocalizedString("Plugin is not initialized.", comment: "pluginNotInitialized")
50
63
  case .signatureMissing:
51
64
  return NSLocalizedString("Bundle does not contain a signature.", comment: "signatureMissing")
52
65
  case .signatureVerificationFailed: