@otakit/capacitor-updater 1.1.0 → 2.0.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.
@@ -23,8 +23,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
23
23
  CAPPluginMethod(name: "download", returnType: CAPPluginReturnPromise),
24
24
  CAPPluginMethod(name: "apply", returnType: CAPPluginReturnPromise),
25
25
  CAPPluginMethod(name: "debugGetState", returnType: CAPPluginReturnPromise),
26
- CAPPluginMethod(name: "debugCheck", returnType: CAPPluginReturnPromise),
27
- CAPPluginMethod(name: "debugDownload", returnType: CAPPluginReturnPromise),
28
26
  CAPPluginMethod(name: "notifyAppReady", returnType: CAPPluginReturnPromise),
29
27
  CAPPluginMethod(name: "debugReset", returnType: CAPPluginReturnPromise),
30
28
  CAPPluginMethod(name: "debugListBundles", returnType: CAPPluginReturnPromise),
@@ -41,8 +39,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
41
39
  private var allowInsecureUrls = false
42
40
  private var updateMode: UpdateMode = .nextLaunch
43
41
  private var updateUrl = UpdaterPlugin.defaultUpdateURL
42
+ private var cdnUrl = UpdaterPlugin.defaultCdnURL
44
43
  private var appId: String?
45
44
  private var channel: String?
45
+ private var runtimeVersion: String?
46
46
  private var manifestKeys: [(kid: String, key: Data)] = []
47
47
  private var trialTimeoutWorkItem: DispatchWorkItem?
48
48
  private var checkIntervalMs: Int = 600_000
@@ -50,17 +50,26 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
50
50
  private var checkInProgress = false
51
51
  private var foregroundObserver: NSObjectProtocol?
52
52
  private static let defaultUpdateURL = "https://www.otakit.app/api/v1"
53
+ private static let defaultCdnURL = "https://cdn.otakit.app"
53
54
  private static let apiPathSuffix = "/api/v1"
54
55
  private static let lastCheckTimestampKey = "otakit_last_check_timestamp"
55
56
 
56
57
  public override func load() {
57
58
  let envUpdateUrl = ProcessInfo.processInfo.environment["OTAKIT_SERVER_URL"]
59
+ let envCdnUrl = ProcessInfo.processInfo.environment["OTAKIT_CDN_URL"]
58
60
  updateUrl = resolveUpdateUrl(
59
61
  configured: getConfig().getString("serverUrl"),
60
62
  env: envUpdateUrl
61
63
  )
64
+ cdnUrl = resolveCdnUrl(
65
+ configured: getConfig().getString("cdnUrl"),
66
+ env: envCdnUrl,
67
+ resolvedUpdateUrl: updateUrl
68
+ )
62
69
  appId = getConfig().getString("appId")
63
70
  channel = trimToNil(getConfig().getString("channel"))
71
+ runtimeVersion = trimToNil(getConfig().getString("runtimeVersion"))
72
+ store.appRuntimeVersion = runtimeVersion
64
73
  allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false)
65
74
  let updateModeRaw = getConfig().getString("updateMode", UpdateMode.nextLaunch.rawValue)
66
75
  updateMode = resolveUpdateMode(configured: updateModeRaw)
@@ -85,10 +94,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
85
94
  manifestKeys = [(kid: "_invalid_", key: Data())]
86
95
  }
87
96
 
88
- if manifestKeys.isEmpty && HostedManifestKeys.matchesManagedServer(updateUrl) {
97
+ if manifestKeys.isEmpty && HostedManifestKeys.matchesManagedManifestURL(cdnUrl) {
89
98
  manifestKeys = HostedManifestKeys.defaults
90
99
  }
91
100
 
101
+ pruneIncompatibleBundles()
102
+
92
103
  var current = store.getCurrentBundle()
93
104
  if current.status == .trial {
94
105
  rollbackCurrentBundle(reason: "app_restarted_before_notify")
@@ -149,7 +160,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
149
160
  return
150
161
  }
151
162
 
152
- if shouldThrottleCheck() { return }
163
+ if updateMode != .immediate && shouldThrottleCheck() { return }
164
+ }
165
+
166
+ if trigger == .launch && updateMode != .immediate && shouldThrottleCheck() {
167
+ return
153
168
  }
154
169
 
155
170
  // Acquire in-flight guard (submit-time)
@@ -161,7 +176,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
161
176
  defer { releaseCheckInProgress() }
162
177
  do {
163
178
  let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
164
- recordCheckTimestamp()
165
179
  if result != nil {
166
180
  activateStagedBundleForReload()
167
181
  reloadWebView()
@@ -179,7 +193,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
179
193
  defer { releaseCheckInProgress() }
180
194
  do {
181
195
  let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
182
- recordCheckTimestamp()
183
196
  if result != nil {
184
197
  activateStagedBundleForReload()
185
198
  reloadWebView()
@@ -277,27 +290,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
277
290
  call.resolve(payload)
278
291
  }
279
292
 
280
- @objc func debugCheck(_ call: CAPPluginCall) {
281
- let requestedChannel = call.getString("channel")
282
- let targetChannel = resolveTargetChannel(requestedChannel)
283
- Task {
284
- do {
285
- let latest = try await fetchLatest(channel: targetChannel)
286
- if let latest {
287
- let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
288
- call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
289
- } else {
290
- call.resolve()
291
- }
292
- } catch {
293
- call.reject("debugCheck failed: \(error.localizedDescription)")
294
- }
295
- }
296
- }
297
-
298
293
  @objc func check(_ call: CAPPluginCall) {
299
- // Respect throttle — return staged bundle as a LatestVersion if available, else null
300
- if shouldThrottleCheck() || !claimCheckInProgress() {
294
+ if !claimCheckInProgress() {
301
295
  if let stagedId = store.getStagedBundleId(),
302
296
  let staged = store.getBundle(id: stagedId) {
303
297
  var payload: [String: Any] = [
@@ -307,6 +301,9 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
307
301
  "size": 0,
308
302
  "downloaded": true,
309
303
  ]
304
+ if let runtimeVersion = staged.runtimeVersion {
305
+ payload["runtimeVersion"] = runtimeVersion
306
+ }
310
307
  if let releaseId = staged.releaseId {
311
308
  payload["releaseId"] = releaseId
312
309
  }
@@ -321,9 +318,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
321
318
  defer { releaseCheckInProgress() }
322
319
  do {
323
320
  let latest = try await fetchLatest(channel: targetChannel)
324
- // check() does NOT record timestamp — only download() and automatic paths do.
325
- // This keeps check() -> download() working without the throttle blocking download().
326
321
  if let latest {
322
+ if isCurrentBundleLatest(latest: latest, targetChannel: targetChannel) {
323
+ call.resolve()
324
+ return
325
+ }
327
326
  let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
328
327
  call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
329
328
  } else {
@@ -335,28 +334,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
335
334
  }
336
335
  }
337
336
 
338
- @objc func debugDownload(_ call: CAPPluginCall) {
339
- let requestedChannel = call.getString("channel")
340
- Task {
341
- do {
342
- let bundle = try await performCheckAndDownload(
343
- channel: requestedChannel,
344
- emitEvents: true
345
- )
346
- if let bundle {
347
- call.resolve(bundle.toDictionary())
348
- } else {
349
- call.resolve()
350
- }
351
- } catch {
352
- call.reject("debugDownload failed: \(error.localizedDescription)")
353
- }
354
- }
355
- }
356
-
357
337
  @objc func download(_ call: CAPPluginCall) {
358
- // Respect throttle — return staged info if available, else null
359
- if shouldThrottleCheck() || !claimCheckInProgress() {
338
+ if !claimCheckInProgress() {
360
339
  if let stagedId = store.getStagedBundleId(),
361
340
  let staged = store.getBundle(id: stagedId) {
362
341
  call.resolve(staged.toDictionary())
@@ -369,7 +348,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
369
348
  defer { releaseCheckInProgress() }
370
349
  do {
371
350
  let bundle = try await performCheckAndDownload(channel: nil, emitEvents: true)
372
- recordCheckTimestamp()
373
351
  if let bundle {
374
352
  call.resolve(bundle.toDictionary())
375
353
  } else {
@@ -496,15 +474,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
496
474
  throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing appId in plugin config"])
497
475
  }
498
476
 
499
- let current = store.getCurrentBundle()
500
477
  return try await ManifestClient.fetchLatest(
501
- updateUrl: updateUrl,
478
+ cdnUrl: cdnUrl,
502
479
  appId: appId,
503
480
  channel: channel,
504
- currentVersion: current.version,
505
- currentReleaseId: current.releaseId,
506
- nativeBuild: store.nativeBuild,
507
- platform: "ios",
481
+ runtimeVersion: runtimeVersion,
508
482
  allowInsecureUrls: allowInsecureUrls,
509
483
  manifestKeys: manifestKeys.map {
510
484
  ManifestKey(kid: $0.kid, derData: $0.key)
@@ -526,6 +500,21 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
526
500
  return nil
527
501
  }
528
502
 
503
+ guard isCompatibleRuntime(manifest.runtimeVersion) else {
504
+ throw NSError(
505
+ domain: "OtaKit",
506
+ code: 1,
507
+ userInfo: [NSLocalizedDescriptionKey: "Manifest runtimeVersion does not match the installed app runtime"]
508
+ )
509
+ }
510
+
511
+ if isCurrentBundleLatest(latest: manifest, targetChannel: targetChannel) {
512
+ if emitEvents {
513
+ notifyListeners("noUpdateAvailable", data: [:])
514
+ }
515
+ return nil
516
+ }
517
+
529
518
  let staged = findMatchingStagedBundle(latest: manifest, targetChannel: targetChannel)
530
519
  if emitEvents {
531
520
  notifyListeners("updateAvailable", data: manifestToDictionary(manifest, downloaded: staged != nil))
@@ -549,6 +538,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
549
538
  version: manifest.version,
550
539
  expectedSha256: manifest.sha256,
551
540
  expectedSize: manifest.size,
541
+ runtimeVersion: manifest.runtimeVersion,
552
542
  channel: targetChannel,
553
543
  releaseId: manifest.releaseId
554
544
  )
@@ -568,6 +558,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
568
558
  version: manifest.version,
569
559
  expectedSha256: manifest.sha256,
570
560
  expectedSize: manifest.size,
561
+ runtimeVersion: manifest.runtimeVersion,
571
562
  channel: targetChannel,
572
563
  releaseId: manifest.releaseId
573
564
  )
@@ -588,6 +579,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
588
579
  version: String,
589
580
  expectedSha256: String,
590
581
  expectedSize: Int? = nil,
582
+ runtimeVersion: String? = nil,
591
583
  channel: String? = nil,
592
584
  releaseId: String? = nil
593
585
  ) async throws -> BundleInfo {
@@ -652,6 +644,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
652
644
  let info = BundleInfo(
653
645
  id: bundleId,
654
646
  version: version,
647
+ runtimeVersion: runtimeVersion,
655
648
  status: .pending,
656
649
  downloadedAt: Date(),
657
650
  sha256: expectedSha256,
@@ -723,6 +716,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
723
716
  store.setStagedBundleId(nil)
724
717
  return store.getCurrentBundle()
725
718
  }
719
+ guard isCompatibleRuntime(staged) else {
720
+ try? store.deleteBundle(id: staged.id)
721
+ return store.getCurrentBundle()
722
+ }
726
723
 
727
724
  store.setCurrentBundleId(staged.id)
728
725
  store.setStagedBundleId(nil)
@@ -737,6 +734,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
737
734
  store.setStagedBundleId(nil)
738
735
  return
739
736
  }
737
+ guard isCompatibleRuntime(staged) else {
738
+ try? store.deleteBundle(id: staged.id)
739
+ return
740
+ }
740
741
 
741
742
  store.setCurrentBundleId(staged.id)
742
743
  store.setStagedBundleId(nil)
@@ -795,6 +796,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
795
796
  let failed = BundleInfo(
796
797
  id: current.id,
797
798
  version: current.version,
799
+ runtimeVersion: current.runtimeVersion,
798
800
  status: .error,
799
801
  downloadedAt: current.downloadedAt,
800
802
  sha256: current.sha256,
@@ -901,15 +903,51 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
901
903
  "size": latest.size,
902
904
  "downloaded": downloaded,
903
905
  ]
906
+ if let runtimeVersion = latest.runtimeVersion {
907
+ payload["runtimeVersion"] = runtimeVersion
908
+ }
904
909
  if let releaseId = latest.releaseId {
905
910
  payload["releaseId"] = releaseId
906
911
  }
907
- if let minNativeBuild = latest.minNativeBuild {
908
- payload["minNativeBuild"] = minNativeBuild
909
- }
910
912
  return payload
911
913
  }
912
914
 
915
+ private func isCurrentBundleLatest(
916
+ latest: LatestManifest,
917
+ targetChannel: String?
918
+ ) -> Bool {
919
+ doesBundleMatchLatest(store.getCurrentBundle(), latest: latest, targetChannel: targetChannel)
920
+ }
921
+
922
+ private func doesBundleMatchLatest(
923
+ _ bundle: BundleInfo,
924
+ latest: LatestManifest,
925
+ targetChannel: String?
926
+ ) -> Bool {
927
+ if trimToNil(bundle.channel) != targetChannel {
928
+ return false
929
+ }
930
+
931
+ if trimToNil(bundle.runtimeVersion) != trimToNil(latest.runtimeVersion) {
932
+ return false
933
+ }
934
+
935
+ if let latestReleaseId = latest.releaseId,
936
+ let bundleReleaseId = bundle.releaseId,
937
+ latestReleaseId == bundleReleaseId {
938
+ return true
939
+ }
940
+
941
+ if !latest.sha256.isEmpty,
942
+ let bundleSha = bundle.sha256,
943
+ !bundleSha.isEmpty,
944
+ latest.sha256 == bundleSha {
945
+ return true
946
+ }
947
+
948
+ return latest.version == bundle.version
949
+ }
950
+
913
951
  private func findMatchingStagedBundle(
914
952
  latest: LatestManifest,
915
953
  targetChannel: String?
@@ -926,18 +964,29 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
926
964
  return nil
927
965
  }
928
966
 
929
- if let latestReleaseId = latest.releaseId,
930
- let stagedReleaseId = staged.releaseId,
931
- latestReleaseId == stagedReleaseId {
932
- return staged
967
+ if trimToNil(staged.runtimeVersion) != trimToNil(latest.runtimeVersion) {
968
+ return nil
933
969
  }
934
970
 
935
- if latest.version == staged.version,
936
- latest.sha256 == staged.sha256 {
937
- return staged
971
+ return doesBundleMatchLatest(staged, latest: latest, targetChannel: targetChannel) ? staged : nil
972
+ }
973
+
974
+ private func pruneIncompatibleBundles() {
975
+ for bundle in store.listDownloadedBundles() where !isCompatibleRuntime(bundle) {
976
+ try? store.deleteBundle(id: bundle.id)
938
977
  }
939
978
 
940
- return nil
979
+ if let failed = store.getFailedBundle(), !isCompatibleRuntime(failed) {
980
+ store.setFailedBundle(nil)
981
+ }
982
+ }
983
+
984
+ private func isCompatibleRuntime(_ runtimeVersion: String?) -> Bool {
985
+ trimToNil(runtimeVersion) == self.runtimeVersion
986
+ }
987
+
988
+ private func isCompatibleRuntime(_ bundle: BundleInfo) -> Bool {
989
+ isCompatibleRuntime(bundle.runtimeVersion)
941
990
  }
942
991
 
943
992
  private func buildBundleId(from version: String) -> String {
@@ -1026,6 +1075,25 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1026
1075
  return UpdaterPlugin.defaultUpdateURL
1027
1076
  }
1028
1077
 
1078
+ private func resolveCdnUrl(configured: String?, env: String?, resolvedUpdateUrl: String) -> String {
1079
+ let configuredValue = configured?.trimmingCharacters(in: .whitespacesAndNewlines)
1080
+ if let configuredValue, !configuredValue.isEmpty {
1081
+ return normalizeCdnUrl(configuredValue)
1082
+ }
1083
+
1084
+ let envValue = env?.trimmingCharacters(in: .whitespacesAndNewlines)
1085
+ if let envValue, !envValue.isEmpty {
1086
+ return normalizeCdnUrl(envValue)
1087
+ }
1088
+
1089
+ if resolvedUpdateUrl.lowercased() != UpdaterPlugin.defaultUpdateURL.lowercased(),
1090
+ resolvedUpdateUrl.lowercased().hasSuffix(UpdaterPlugin.apiPathSuffix) {
1091
+ return String(resolvedUpdateUrl.dropLast(UpdaterPlugin.apiPathSuffix.count))
1092
+ }
1093
+
1094
+ return UpdaterPlugin.defaultCdnURL
1095
+ }
1096
+
1029
1097
  private func normalizeUpdateUrl(_ raw: String) -> String {
1030
1098
  let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
1031
1099
  let withoutTrailingSlash = trimmed.replacingOccurrences(
@@ -1041,6 +1109,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1041
1109
  return withoutTrailingSlash + UpdaterPlugin.apiPathSuffix
1042
1110
  }
1043
1111
 
1112
+ private func normalizeCdnUrl(_ raw: String) -> String {
1113
+ raw
1114
+ .trimmingCharacters(in: .whitespacesAndNewlines)
1115
+ .replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
1116
+ }
1117
+
1044
1118
  private func getFreeDiskSpace() -> Int64 {
1045
1119
  do {
1046
1120
  let attributes = try fileManager.attributesOfFileSystem(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otakit/capacitor-updater",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Capacitor plugin for OTA updates",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",