@otakit/capacitor-updater 1.2.0 → 2.0.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.
@@ -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),
@@ -40,7 +38,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
40
38
  private var appReadyTimeoutMs = 10_000
41
39
  private var allowInsecureUrls = false
42
40
  private var updateMode: UpdateMode = .nextLaunch
43
- private var updateUrl = UpdaterPlugin.defaultUpdateURL
41
+ private var ingestUrl = UpdaterPlugin.defaultIngestURL
42
+ private var cdnUrl = UpdaterPlugin.defaultCdnURL
44
43
  private var appId: String?
45
44
  private var channel: String?
46
45
  private var runtimeVersion: String?
@@ -50,15 +49,21 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
50
49
  private let isCheckInProgress = NSLock()
51
50
  private var checkInProgress = false
52
51
  private var foregroundObserver: NSObjectProtocol?
53
- private static let defaultUpdateURL = "https://www.otakit.app/api/v1"
54
- private static let apiPathSuffix = "/api/v1"
52
+ private static let defaultIngestURL = "https://ingest.otakit.app/v1"
53
+ private static let defaultCdnURL = "https://cdn.otakit.app"
54
+ private static let ingestPathSuffix = "/v1"
55
55
  private static let lastCheckTimestampKey = "otakit_last_check_timestamp"
56
56
 
57
57
  public override func load() {
58
- let envUpdateUrl = ProcessInfo.processInfo.environment["OTAKIT_SERVER_URL"]
59
- updateUrl = resolveUpdateUrl(
60
- configured: getConfig().getString("serverUrl"),
61
- env: envUpdateUrl
58
+ let envIngestUrl = ProcessInfo.processInfo.environment["OTAKIT_INGEST_URL"]
59
+ let envCdnUrl = ProcessInfo.processInfo.environment["OTAKIT_CDN_URL"]
60
+ ingestUrl = resolveIngestUrl(
61
+ configured: getConfig().getString("ingestUrl"),
62
+ env: envIngestUrl
63
+ )
64
+ cdnUrl = resolveCdnUrl(
65
+ configured: getConfig().getString("cdnUrl"),
66
+ env: envCdnUrl
62
67
  )
63
68
  appId = getConfig().getString("appId")
64
69
  channel = trimToNil(getConfig().getString("channel"))
@@ -88,7 +93,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
88
93
  manifestKeys = [(kid: "_invalid_", key: Data())]
89
94
  }
90
95
 
91
- if manifestKeys.isEmpty && HostedManifestKeys.matchesManagedServer(updateUrl) {
96
+ if manifestKeys.isEmpty && HostedManifestKeys.matchesManagedManifestURL(cdnUrl) {
92
97
  manifestKeys = HostedManifestKeys.defaults
93
98
  }
94
99
 
@@ -154,7 +159,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
154
159
  return
155
160
  }
156
161
 
157
- if shouldThrottleCheck() { return }
162
+ if updateMode != .immediate && shouldThrottleCheck() { return }
163
+ }
164
+
165
+ if trigger == .launch && updateMode != .immediate && shouldThrottleCheck() {
166
+ return
158
167
  }
159
168
 
160
169
  // Acquire in-flight guard (submit-time)
@@ -166,7 +175,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
166
175
  defer { releaseCheckInProgress() }
167
176
  do {
168
177
  let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
169
- recordCheckTimestamp()
170
178
  if result != nil {
171
179
  activateStagedBundleForReload()
172
180
  reloadWebView()
@@ -184,7 +192,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
184
192
  defer { releaseCheckInProgress() }
185
193
  do {
186
194
  let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
187
- recordCheckTimestamp()
188
195
  if result != nil {
189
196
  activateStagedBundleForReload()
190
197
  reloadWebView()
@@ -282,27 +289,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
282
289
  call.resolve(payload)
283
290
  }
284
291
 
285
- @objc func debugCheck(_ call: CAPPluginCall) {
286
- let requestedChannel = call.getString("channel")
287
- let targetChannel = resolveTargetChannel(requestedChannel)
288
- Task {
289
- do {
290
- let latest = try await fetchLatest(channel: targetChannel)
291
- if let latest {
292
- let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
293
- call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
294
- } else {
295
- call.resolve()
296
- }
297
- } catch {
298
- call.reject("debugCheck failed: \(error.localizedDescription)")
299
- }
300
- }
301
- }
302
-
303
292
  @objc func check(_ call: CAPPluginCall) {
304
- // Respect throttle — return staged bundle as a LatestVersion if available, else null
305
- if shouldThrottleCheck() || !claimCheckInProgress() {
293
+ if !claimCheckInProgress() {
306
294
  if let stagedId = store.getStagedBundleId(),
307
295
  let staged = store.getBundle(id: stagedId) {
308
296
  var payload: [String: Any] = [
@@ -329,9 +317,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
329
317
  defer { releaseCheckInProgress() }
330
318
  do {
331
319
  let latest = try await fetchLatest(channel: targetChannel)
332
- // check() does NOT record timestamp — only download() and automatic paths do.
333
- // This keeps check() -> download() working without the throttle blocking download().
334
320
  if let latest {
321
+ if isCurrentBundleLatest(latest: latest, targetChannel: targetChannel) {
322
+ call.resolve()
323
+ return
324
+ }
335
325
  let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
336
326
  call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
337
327
  } else {
@@ -343,28 +333,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
343
333
  }
344
334
  }
345
335
 
346
- @objc func debugDownload(_ call: CAPPluginCall) {
347
- let requestedChannel = call.getString("channel")
348
- Task {
349
- do {
350
- let bundle = try await performCheckAndDownload(
351
- channel: requestedChannel,
352
- emitEvents: true
353
- )
354
- if let bundle {
355
- call.resolve(bundle.toDictionary())
356
- } else {
357
- call.resolve()
358
- }
359
- } catch {
360
- call.reject("debugDownload failed: \(error.localizedDescription)")
361
- }
362
- }
363
- }
364
-
365
336
  @objc func download(_ call: CAPPluginCall) {
366
- // Respect throttle — return staged info if available, else null
367
- if shouldThrottleCheck() || !claimCheckInProgress() {
337
+ if !claimCheckInProgress() {
368
338
  if let stagedId = store.getStagedBundleId(),
369
339
  let staged = store.getBundle(id: stagedId) {
370
340
  call.resolve(staged.toDictionary())
@@ -377,7 +347,6 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
377
347
  defer { releaseCheckInProgress() }
378
348
  do {
379
349
  let bundle = try await performCheckAndDownload(channel: nil, emitEvents: true)
380
- recordCheckTimestamp()
381
350
  if let bundle {
382
351
  call.resolve(bundle.toDictionary())
383
352
  } else {
@@ -424,9 +393,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
424
393
  notifyListeners("appReady", data: current.toDictionary())
425
394
  }
426
395
 
427
- sendStats(
396
+ sendDeviceEvent(
428
397
  action: .applied,
429
398
  bundleVersion: current.version,
399
+ runtimeVersion: current.runtimeVersion,
430
400
  channel: current.channel,
431
401
  releaseId: current.releaseId
432
402
  )
@@ -504,15 +474,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
504
474
  throw NSError(domain: "OtaKit", code: 1, userInfo: [NSLocalizedDescriptionKey: "Missing appId in plugin config"])
505
475
  }
506
476
 
507
- let current = store.getCurrentBundle()
508
477
  return try await ManifestClient.fetchLatest(
509
- updateUrl: updateUrl,
478
+ cdnUrl: cdnUrl,
510
479
  appId: appId,
511
480
  channel: channel,
512
- currentVersion: current.version,
513
- currentReleaseId: current.releaseId,
514
481
  runtimeVersion: runtimeVersion,
515
- platform: "ios",
516
482
  allowInsecureUrls: allowInsecureUrls,
517
483
  manifestKeys: manifestKeys.map {
518
484
  ManifestKey(kid: $0.kid, derData: $0.key)
@@ -542,6 +508,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
542
508
  )
543
509
  }
544
510
 
511
+ if isCurrentBundleLatest(latest: manifest, targetChannel: targetChannel) {
512
+ if emitEvents {
513
+ notifyListeners("noUpdateAvailable", data: [:])
514
+ }
515
+ return nil
516
+ }
517
+
545
518
  let staged = findMatchingStagedBundle(latest: manifest, targetChannel: targetChannel)
546
519
  if emitEvents {
547
520
  notifyListeners("updateAvailable", data: manifestToDictionary(manifest, downloaded: staged != nil))
@@ -620,12 +593,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
620
593
  code: 1,
621
594
  userInfo: [NSLocalizedDescriptionKey: "Insufficient disk space"]
622
595
  )
623
- sendStats(
596
+ sendDeviceEvent(
624
597
  action: .downloadError,
625
598
  bundleVersion: version,
599
+ runtimeVersion: runtimeVersion,
626
600
  channel: channel,
627
601
  releaseId: releaseId,
628
- errorMessage: "insufficient_disk_space"
602
+ detail: "insufficient_disk_space"
629
603
  )
630
604
  throw error
631
605
  }
@@ -686,9 +660,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
686
660
  cleanupSupersededStagedBundle(previousStagedId: previousStagedId, replacementId: bundleId)
687
661
 
688
662
  notifyListeners("downloadComplete", data: info.toDictionary())
689
- sendStats(
663
+ sendDeviceEvent(
690
664
  action: .downloaded,
691
665
  bundleVersion: version,
666
+ runtimeVersion: runtimeVersion,
692
667
  channel: channel,
693
668
  releaseId: releaseId
694
669
  )
@@ -698,12 +673,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
698
673
  "version": version,
699
674
  "error": error.localizedDescription,
700
675
  ])
701
- sendStats(
676
+ sendDeviceEvent(
702
677
  action: .downloadError,
703
678
  bundleVersion: version,
679
+ runtimeVersion: runtimeVersion,
704
680
  channel: channel,
705
681
  releaseId: releaseId,
706
- errorMessage: error.localizedDescription
682
+ detail: error.localizedDescription
707
683
  )
708
684
  throw error
709
685
  }
@@ -835,12 +811,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
835
811
  store.setFailedBundle(failed)
836
812
  store.setStagedBundleId(nil)
837
813
 
838
- sendStats(
814
+ sendDeviceEvent(
839
815
  action: .rollback,
840
816
  bundleVersion: current.version,
817
+ runtimeVersion: current.runtimeVersion,
841
818
  channel: current.channel,
842
819
  releaseId: current.releaseId,
843
- errorMessage: reason
820
+ detail: reason
844
821
  )
845
822
 
846
823
  let fallback = store.getFallbackBundle()
@@ -933,12 +910,45 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
933
910
  if let runtimeVersion = latest.runtimeVersion {
934
911
  payload["runtimeVersion"] = runtimeVersion
935
912
  }
936
- if let releaseId = latest.releaseId {
937
- payload["releaseId"] = releaseId
938
- }
913
+ payload["releaseId"] = latest.releaseId
939
914
  return payload
940
915
  }
941
916
 
917
+ private func isCurrentBundleLatest(
918
+ latest: LatestManifest,
919
+ targetChannel: String?
920
+ ) -> Bool {
921
+ doesBundleMatchLatest(store.getCurrentBundle(), latest: latest, targetChannel: targetChannel)
922
+ }
923
+
924
+ private func doesBundleMatchLatest(
925
+ _ bundle: BundleInfo,
926
+ latest: LatestManifest,
927
+ targetChannel: String?
928
+ ) -> Bool {
929
+ if trimToNil(bundle.channel) != targetChannel {
930
+ return false
931
+ }
932
+
933
+ if trimToNil(bundle.runtimeVersion) != trimToNil(latest.runtimeVersion) {
934
+ return false
935
+ }
936
+
937
+ if let bundleReleaseId = bundle.releaseId,
938
+ latest.releaseId == bundleReleaseId {
939
+ return true
940
+ }
941
+
942
+ if !latest.sha256.isEmpty,
943
+ let bundleSha = bundle.sha256,
944
+ !bundleSha.isEmpty,
945
+ latest.sha256 == bundleSha {
946
+ return true
947
+ }
948
+
949
+ return latest.version == bundle.version
950
+ }
951
+
942
952
  private func findMatchingStagedBundle(
943
953
  latest: LatestManifest,
944
954
  targetChannel: String?
@@ -959,18 +969,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
959
969
  return nil
960
970
  }
961
971
 
962
- if let latestReleaseId = latest.releaseId,
963
- let stagedReleaseId = staged.releaseId,
964
- latestReleaseId == stagedReleaseId {
965
- return staged
966
- }
967
-
968
- if latest.version == staged.version,
969
- latest.sha256 == staged.sha256 {
970
- return staged
971
- }
972
-
973
- return nil
972
+ return doesBundleMatchLatest(staged, latest: latest, targetChannel: targetChannel) ? staged : nil
974
973
  }
975
974
 
976
975
  private func pruneIncompatibleBundles() {
@@ -1025,26 +1024,41 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1025
1024
  return "\(normalized)-\(suffix)"
1026
1025
  }
1027
1026
 
1028
- private func sendStats(
1029
- action: StatsAction,
1027
+ private func sendDeviceEvent(
1028
+ action: DeviceEventAction,
1030
1029
  bundleVersion: String? = nil,
1030
+ runtimeVersion: String? = nil,
1031
1031
  channel: String? = nil,
1032
1032
  releaseId: String? = nil,
1033
- errorMessage: String? = nil
1033
+ detail: String? = nil
1034
1034
  ) {
1035
1035
  guard let appId else {
1036
1036
  return
1037
1037
  }
1038
- StatsClient.send(
1039
- updateUrl: updateUrl,
1038
+ guard let bundleVersion = trimToNil(bundleVersion) else {
1039
+ print("[UpdateKit] Skipping device event without bundleVersion")
1040
+ return
1041
+ }
1042
+ guard let releaseId = trimToNil(releaseId) else {
1043
+ print("[UpdateKit] Skipping device event without releaseId")
1044
+ return
1045
+ }
1046
+ let nativeBuild = store.nativeBuild.trimmingCharacters(in: .whitespacesAndNewlines)
1047
+ guard !nativeBuild.isEmpty else {
1048
+ print("[UpdateKit] Skipping device event without nativeBuild")
1049
+ return
1050
+ }
1051
+ DeviceEventClient.send(
1052
+ ingestUrl: ingestUrl,
1040
1053
  appId: appId,
1041
1054
  platform: "ios",
1042
1055
  action: action,
1043
1056
  bundleVersion: bundleVersion,
1044
1057
  channel: channel,
1058
+ runtimeVersion: trimToNil(runtimeVersion),
1045
1059
  releaseId: releaseId,
1046
- nativeBuild: store.nativeBuild,
1047
- errorMessage: errorMessage
1060
+ nativeBuild: nativeBuild,
1061
+ detail: detail
1048
1062
  )
1049
1063
  }
1050
1064
 
@@ -1063,21 +1077,41 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1063
1077
  return trimmed.isEmpty ? nil : trimmed
1064
1078
  }
1065
1079
 
1066
- private func resolveUpdateUrl(configured: String?, env: String?) -> String {
1080
+ private func resolveIngestUrl(
1081
+ configured: String?,
1082
+ env: String?
1083
+ ) -> String {
1084
+ let configuredValue = configured?.trimmingCharacters(in: .whitespacesAndNewlines)
1085
+ if let configuredValue, !configuredValue.isEmpty {
1086
+ return normalizeIngestUrl(configuredValue)
1087
+ }
1088
+
1089
+ let envValue = env?.trimmingCharacters(in: .whitespacesAndNewlines)
1090
+ if let envValue, !envValue.isEmpty {
1091
+ return normalizeIngestUrl(envValue)
1092
+ }
1093
+
1094
+ return UpdaterPlugin.defaultIngestURL
1095
+ }
1096
+
1097
+ private func resolveCdnUrl(
1098
+ configured: String?,
1099
+ env: String?
1100
+ ) -> String {
1067
1101
  let configuredValue = configured?.trimmingCharacters(in: .whitespacesAndNewlines)
1068
1102
  if let configuredValue, !configuredValue.isEmpty {
1069
- return normalizeUpdateUrl(configuredValue)
1103
+ return normalizeCdnUrl(configuredValue)
1070
1104
  }
1071
1105
 
1072
1106
  let envValue = env?.trimmingCharacters(in: .whitespacesAndNewlines)
1073
1107
  if let envValue, !envValue.isEmpty {
1074
- return normalizeUpdateUrl(envValue)
1108
+ return normalizeCdnUrl(envValue)
1075
1109
  }
1076
1110
 
1077
- return UpdaterPlugin.defaultUpdateURL
1111
+ return UpdaterPlugin.defaultCdnURL
1078
1112
  }
1079
1113
 
1080
- private func normalizeUpdateUrl(_ raw: String) -> String {
1114
+ private func normalizeIngestUrl(_ raw: String) -> String {
1081
1115
  let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
1082
1116
  let withoutTrailingSlash = trimmed.replacingOccurrences(
1083
1117
  of: "/+$",
@@ -1085,11 +1119,17 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1085
1119
  options: .regularExpression
1086
1120
  )
1087
1121
 
1088
- if withoutTrailingSlash.lowercased().hasSuffix(UpdaterPlugin.apiPathSuffix) {
1122
+ if withoutTrailingSlash.lowercased().hasSuffix(UpdaterPlugin.ingestPathSuffix) {
1089
1123
  return withoutTrailingSlash
1090
1124
  }
1091
1125
 
1092
- return withoutTrailingSlash + UpdaterPlugin.apiPathSuffix
1126
+ return withoutTrailingSlash + UpdaterPlugin.ingestPathSuffix
1127
+ }
1128
+
1129
+ private func normalizeCdnUrl(_ raw: String) -> String {
1130
+ raw
1131
+ .trimmingCharacters(in: .whitespacesAndNewlines)
1132
+ .replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
1093
1133
  }
1094
1134
 
1095
1135
  private func getFreeDiskSpace() -> Int64 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otakit/capacitor-updater",
3
- "version": "1.2.0",
3
+ "version": "2.0.1",
4
4
  "description": "Capacitor plugin for OTA updates",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",