@otakit/capacitor-updater 1.0.0 → 1.2.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.
@@ -19,7 +19,7 @@ enum ManifestVerifier {
19
19
  ///
20
20
  /// - Parameters:
21
21
  /// - appId, channel, platform: Request context (known by plugin).
22
- /// - version, sha256, size, minNativeBuild: Response fields.
22
+ /// - version, sha256, size, runtimeVersion: Response fields.
23
23
  /// - signature: The signature object from the manifest response.
24
24
  /// - trustedKeys: Array of verification keys configured in the plugin.
25
25
  ///
@@ -31,22 +31,10 @@ enum ManifestVerifier {
31
31
  version: String,
32
32
  sha256: String,
33
33
  size: Int,
34
- minNativeBuild: Int?,
34
+ runtimeVersion: String?,
35
35
  signature: ManifestSignature,
36
36
  trustedKeys: [ManifestKey]
37
37
  ) throws {
38
- // Check expiry
39
- let now = Int(Date().timeIntervalSince1970)
40
- guard signature.exp > now else {
41
- throw ManifestVerifierError.expired
42
- }
43
-
44
- // Find matching key
45
- guard let keyEntry = trustedKeys.first(where: { $0.kid == signature.kid }) else {
46
- throw ManifestVerifierError.unknownKid(signature.kid)
47
- }
48
-
49
- // Build canonical payload (must match server exactly)
50
38
  let payload = buildCanonicalPayload(
51
39
  appId: appId,
52
40
  channel: channel,
@@ -54,11 +42,53 @@ enum ManifestVerifier {
54
42
  version: version,
55
43
  sha256: sha256,
56
44
  size: size,
57
- minNativeBuild: minNativeBuild,
45
+ runtimeVersion: runtimeVersion,
58
46
  kid: signature.kid,
59
47
  iat: signature.iat,
60
48
  exp: signature.exp
61
49
  )
50
+ try verifyPayload(payload, signature: signature, trustedKeys: trustedKeys)
51
+ }
52
+
53
+ static func verifyLegacy(
54
+ appId: String,
55
+ channel: String?,
56
+ platform: String,
57
+ version: String,
58
+ sha256: String,
59
+ size: Int,
60
+ signature: ManifestSignature,
61
+ trustedKeys: [ManifestKey]
62
+ ) throws {
63
+ let payload = buildLegacyCanonicalPayload(
64
+ appId: appId,
65
+ channel: channel,
66
+ platform: platform,
67
+ version: version,
68
+ sha256: sha256,
69
+ size: size,
70
+ kid: signature.kid,
71
+ iat: signature.iat,
72
+ exp: signature.exp
73
+ )
74
+ try verifyPayload(payload, signature: signature, trustedKeys: trustedKeys)
75
+ }
76
+
77
+ private static func verifyPayload(
78
+ _ payload: String,
79
+ signature: ManifestSignature,
80
+ trustedKeys: [ManifestKey]
81
+ ) throws {
82
+ // Check expiry
83
+ let now = Int(Date().timeIntervalSince1970)
84
+ guard signature.exp > now else {
85
+ throw ManifestVerifierError.expired
86
+ }
87
+
88
+ // Find matching key
89
+ guard let keyEntry = trustedKeys.first(where: { $0.kid == signature.kid }) else {
90
+ throw ManifestVerifierError.unknownKid(signature.kid)
91
+ }
62
92
 
63
93
  // Decode base64url signature
64
94
  guard let sigData = base64UrlDecode(signature.sig) else {
@@ -81,12 +111,37 @@ enum ManifestVerifier {
81
111
  version: String,
82
112
  sha256: String,
83
113
  size: Int,
84
- minNativeBuild: Int?,
114
+ runtimeVersion: String?,
115
+ kid: String,
116
+ iat: Int,
117
+ exp: Int
118
+ ) -> String {
119
+ return [
120
+ "MANIFEST_V2",
121
+ "appId:\(appId)",
122
+ "channel:\(channel ?? "null")",
123
+ "platform:\(platform)",
124
+ "version:\(version)",
125
+ "sha256:\(sha256)",
126
+ "size:\(size)",
127
+ "runtimeVersion:\(runtimeVersion ?? "null")",
128
+ "kid:\(kid)",
129
+ "iat:\(iat)",
130
+ "exp:\(exp)",
131
+ ].joined(separator: "\n")
132
+ }
133
+
134
+ private static func buildLegacyCanonicalPayload(
135
+ appId: String,
136
+ channel: String?,
137
+ platform: String,
138
+ version: String,
139
+ sha256: String,
140
+ size: Int,
85
141
  kid: String,
86
142
  iat: Int,
87
143
  exp: Int
88
144
  ) -> String {
89
- let minBuildStr = minNativeBuild.map { String($0) } ?? "null"
90
145
  return [
91
146
  "MANIFEST_V1",
92
147
  "appId:\(appId)",
@@ -95,7 +150,7 @@ enum ManifestVerifier {
95
150
  "version:\(version)",
96
151
  "sha256:\(sha256)",
97
152
  "size:\(size)",
98
- "minNativeBuild:\(minBuildStr)",
153
+ "minNativeBuild:null",
99
154
  "kid:\(kid)",
100
155
  "iat:\(iat)",
101
156
  "exp:\(exp)",
@@ -7,9 +7,15 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
7
7
  private enum UpdateMode: String {
8
8
  case manual
9
9
  case nextLaunch = "next-launch"
10
+ case nextResume = "next-resume"
10
11
  case immediate
11
12
  }
12
13
 
14
+ private enum Trigger {
15
+ case launch
16
+ case resume
17
+ }
18
+
13
19
  public let identifier = "UpdaterPlugin"
14
20
  public let jsName = "OtaKit"
15
21
  public let pluginMethods: [CAPPluginMethod] = [
@@ -37,10 +43,16 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
37
43
  private var updateUrl = UpdaterPlugin.defaultUpdateURL
38
44
  private var appId: String?
39
45
  private var channel: String?
46
+ private var runtimeVersion: String?
40
47
  private var manifestKeys: [(kid: String, key: Data)] = []
41
48
  private var trialTimeoutWorkItem: DispatchWorkItem?
49
+ private var checkIntervalMs: Int = 600_000
50
+ private let isCheckInProgress = NSLock()
51
+ private var checkInProgress = false
52
+ private var foregroundObserver: NSObjectProtocol?
42
53
  private static let defaultUpdateURL = "https://www.otakit.app/api/v1"
43
54
  private static let apiPathSuffix = "/api/v1"
55
+ private static let lastCheckTimestampKey = "otakit_last_check_timestamp"
44
56
 
45
57
  public override func load() {
46
58
  let envUpdateUrl = ProcessInfo.processInfo.environment["OTAKIT_SERVER_URL"]
@@ -50,11 +62,14 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
50
62
  )
51
63
  appId = getConfig().getString("appId")
52
64
  channel = trimToNil(getConfig().getString("channel"))
65
+ runtimeVersion = trimToNil(getConfig().getString("runtimeVersion"))
66
+ store.appRuntimeVersion = runtimeVersion
53
67
  allowInsecureUrls = getConfig().getBoolean("allowInsecureUrls", false)
54
68
  let updateModeRaw = getConfig().getString("updateMode", UpdateMode.nextLaunch.rawValue)
55
69
  updateMode = resolveUpdateMode(configured: updateModeRaw)
56
70
  downloader = Downloader(allowInsecureUrls: allowInsecureUrls)
57
71
  appReadyTimeoutMs = max(1000, getConfig().getInt("appReadyTimeout", 10_000))
72
+ checkIntervalMs = max(600_000, getConfig().getInt("checkInterval", 600_000))
58
73
 
59
74
  let rawKeysValue = getConfig().getArray("manifestKeys")
60
75
  if let rawKeys = rawKeysValue as? [[String: String]] {
@@ -77,6 +92,8 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
77
92
  manifestKeys = HostedManifestKeys.defaults
78
93
  }
79
94
 
95
+ pruneIncompatibleBundles()
96
+
80
97
  var current = store.getCurrentBundle()
81
98
  if current.status == .trial {
82
99
  rollbackCurrentBundle(reason: "app_restarted_before_notify")
@@ -99,33 +116,126 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
99
116
  }
100
117
 
101
118
  if isAutomaticUpdateMode() {
102
- startAutoUpdate()
119
+ runAutomaticUpdate(trigger: .launch)
120
+ }
121
+
122
+ if isAutomaticUpdateMode() {
123
+ foregroundObserver = NotificationCenter.default.addObserver(
124
+ forName: UIApplication.willEnterForegroundNotification,
125
+ object: nil,
126
+ queue: .main
127
+ ) { [weak self] _ in
128
+ self?.handleAppWillEnterForeground()
129
+ }
103
130
  }
104
131
  }
105
132
 
106
- private func startAutoUpdate() {
107
- switch updateMode {
108
- case .manual:
109
- return
110
- case .nextLaunch:
133
+ deinit {
134
+ if let observer = foregroundObserver {
135
+ NotificationCenter.default.removeObserver(observer)
136
+ }
137
+ }
138
+
139
+ private func handleAppWillEnterForeground() {
140
+ runAutomaticUpdate(trigger: .resume)
141
+ }
142
+
143
+ private func runAutomaticUpdate(trigger: Trigger) {
144
+ // Resume-only guards
145
+ if trigger == .resume {
146
+ if updateMode == .manual { return }
147
+
148
+ // next-resume and immediate: activate staged bundle on resume without server check
149
+ if (updateMode == .nextResume || updateMode == .immediate),
150
+ let stagedId = store.getStagedBundleId(),
151
+ store.getBundle(id: stagedId) != nil {
152
+ activateStagedBundleForReload()
153
+ reloadWebView()
154
+ return
155
+ }
156
+
157
+ if shouldThrottleCheck() { return }
158
+ }
159
+
160
+ // Acquire in-flight guard (submit-time)
161
+ guard claimCheckInProgress() else { return }
162
+
163
+ // Immediate mode on cold start: block until check+download+activate completes
164
+ if updateMode == .immediate && trigger == .launch {
111
165
  Task {
112
- _ = try? await performCheckAndDownload(channel: nil, emitEvents: true)
166
+ defer { releaseCheckInProgress() }
167
+ do {
168
+ let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
169
+ recordCheckTimestamp()
170
+ if result != nil {
171
+ activateStagedBundleForReload()
172
+ reloadWebView()
173
+ }
174
+ } catch {
175
+ print("[OtaKit] immediate startup update failed: \(error.localizedDescription)")
176
+ }
113
177
  }
114
- case .immediate:
178
+ return
179
+ }
180
+
181
+ // Immediate mode on resume: background check+download, activate when done
182
+ if updateMode == .immediate && trigger == .resume {
115
183
  Task {
184
+ defer { releaseCheckInProgress() }
116
185
  do {
117
- let downloaded = try await performCheckAndDownload(channel: nil, emitEvents: true)
118
- if downloaded != nil {
186
+ let result = try await performCheckAndDownload(channel: nil, emitEvents: true)
187
+ recordCheckTimestamp()
188
+ if result != nil {
119
189
  activateStagedBundleForReload()
120
190
  reloadWebView()
121
191
  }
122
192
  } catch {
123
- print("[UpdateKit] immediate startup update failed: \(error.localizedDescription)")
193
+ print("[OtaKit] immediate resume update failed: \(error.localizedDescription)")
124
194
  }
125
195
  }
196
+ return
197
+ }
198
+
199
+ // next-launch / next-resume: background check+download (fire-and-forget)
200
+ Task {
201
+ defer { releaseCheckInProgress() }
202
+ do {
203
+ _ = try await performCheckAndDownload(channel: nil, emitEvents: true)
204
+ recordCheckTimestamp()
205
+ } catch {
206
+ // Check failed — timestamp not recorded, will retry on next trigger
207
+ }
126
208
  }
127
209
  }
128
210
 
211
+ private func shouldThrottleCheck() -> Bool {
212
+ let lastCheck = UserDefaults.standard.double(forKey: UpdaterPlugin.lastCheckTimestampKey)
213
+ guard lastCheck > 0 else { return false }
214
+ let elapsed = Date().timeIntervalSince1970 * 1000 - lastCheck
215
+ return elapsed < Double(checkIntervalMs)
216
+ }
217
+
218
+ private func recordCheckTimestamp() {
219
+ UserDefaults.standard.set(
220
+ Date().timeIntervalSince1970 * 1000,
221
+ forKey: UpdaterPlugin.lastCheckTimestampKey
222
+ )
223
+ }
224
+
225
+ private func claimCheckInProgress() -> Bool {
226
+ isCheckInProgress.lock()
227
+ defer { isCheckInProgress.unlock() }
228
+ if checkInProgress { return false }
229
+ checkInProgress = true
230
+ return true
231
+ }
232
+
233
+ private func releaseCheckInProgress() {
234
+ isCheckInProgress.lock()
235
+ defer { isCheckInProgress.unlock() }
236
+ checkInProgress = false
237
+ }
238
+
129
239
  private func shouldActivateStagedOnLaunch() -> Bool {
130
240
  updateMode != .manual
131
241
  }
@@ -141,10 +251,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
141
251
  return .nextLaunch
142
252
  case UpdateMode.manual.rawValue:
143
253
  return .manual
254
+ case UpdateMode.nextResume.rawValue:
255
+ return .nextResume
144
256
  case UpdateMode.immediate.rawValue:
145
257
  return .immediate
146
258
  default:
147
- print("[UpdateKit] Unknown updateMode '\(raw)', defaulting to 'next-launch'")
259
+ print("[OtaKit] Unknown updateMode '\(raw)', defaulting to 'next-launch'")
148
260
  return .nextLaunch
149
261
  }
150
262
  }
@@ -189,10 +301,36 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
189
301
  }
190
302
 
191
303
  @objc func check(_ call: CAPPluginCall) {
304
+ // Respect throttle — return staged bundle as a LatestVersion if available, else null
305
+ if shouldThrottleCheck() || !claimCheckInProgress() {
306
+ if let stagedId = store.getStagedBundleId(),
307
+ let staged = store.getBundle(id: stagedId) {
308
+ var payload: [String: Any] = [
309
+ "version": staged.version,
310
+ "url": "",
311
+ "sha256": staged.sha256 ?? "",
312
+ "size": 0,
313
+ "downloaded": true,
314
+ ]
315
+ if let runtimeVersion = staged.runtimeVersion {
316
+ payload["runtimeVersion"] = runtimeVersion
317
+ }
318
+ if let releaseId = staged.releaseId {
319
+ payload["releaseId"] = releaseId
320
+ }
321
+ call.resolve(payload)
322
+ } else {
323
+ call.resolve()
324
+ }
325
+ return
326
+ }
192
327
  let targetChannel = resolveTargetChannel(nil)
193
328
  Task {
329
+ defer { releaseCheckInProgress() }
194
330
  do {
195
331
  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().
196
334
  if let latest {
197
335
  let staged = findMatchingStagedBundle(latest: latest, targetChannel: targetChannel)
198
336
  call.resolve(manifestToDictionary(latest, downloaded: staged != nil))
@@ -225,9 +363,21 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
225
363
  }
226
364
 
227
365
  @objc func download(_ call: CAPPluginCall) {
366
+ // Respect throttle — return staged info if available, else null
367
+ if shouldThrottleCheck() || !claimCheckInProgress() {
368
+ if let stagedId = store.getStagedBundleId(),
369
+ let staged = store.getBundle(id: stagedId) {
370
+ call.resolve(staged.toDictionary())
371
+ } else {
372
+ call.resolve()
373
+ }
374
+ return
375
+ }
228
376
  Task {
377
+ defer { releaseCheckInProgress() }
229
378
  do {
230
379
  let bundle = try await performCheckAndDownload(channel: nil, emitEvents: true)
380
+ recordCheckTimestamp()
231
381
  if let bundle {
232
382
  call.resolve(bundle.toDictionary())
233
383
  } else {
@@ -361,7 +511,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
361
511
  channel: channel,
362
512
  currentVersion: current.version,
363
513
  currentReleaseId: current.releaseId,
364
- nativeBuild: store.nativeBuild,
514
+ runtimeVersion: runtimeVersion,
365
515
  platform: "ios",
366
516
  allowInsecureUrls: allowInsecureUrls,
367
517
  manifestKeys: manifestKeys.map {
@@ -384,6 +534,14 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
384
534
  return nil
385
535
  }
386
536
 
537
+ guard isCompatibleRuntime(manifest.runtimeVersion) else {
538
+ throw NSError(
539
+ domain: "OtaKit",
540
+ code: 1,
541
+ userInfo: [NSLocalizedDescriptionKey: "Manifest runtimeVersion does not match the installed app runtime"]
542
+ )
543
+ }
544
+
387
545
  let staged = findMatchingStagedBundle(latest: manifest, targetChannel: targetChannel)
388
546
  if emitEvents {
389
547
  notifyListeners("updateAvailable", data: manifestToDictionary(manifest, downloaded: staged != nil))
@@ -407,6 +565,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
407
565
  version: manifest.version,
408
566
  expectedSha256: manifest.sha256,
409
567
  expectedSize: manifest.size,
568
+ runtimeVersion: manifest.runtimeVersion,
410
569
  channel: targetChannel,
411
570
  releaseId: manifest.releaseId
412
571
  )
@@ -426,6 +585,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
426
585
  version: manifest.version,
427
586
  expectedSha256: manifest.sha256,
428
587
  expectedSize: manifest.size,
588
+ runtimeVersion: manifest.runtimeVersion,
429
589
  channel: targetChannel,
430
590
  releaseId: manifest.releaseId
431
591
  )
@@ -446,6 +606,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
446
606
  version: String,
447
607
  expectedSha256: String,
448
608
  expectedSize: Int? = nil,
609
+ runtimeVersion: String? = nil,
449
610
  channel: String? = nil,
450
611
  releaseId: String? = nil
451
612
  ) async throws -> BundleInfo {
@@ -510,6 +671,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
510
671
  let info = BundleInfo(
511
672
  id: bundleId,
512
673
  version: version,
674
+ runtimeVersion: runtimeVersion,
513
675
  status: .pending,
514
676
  downloadedAt: Date(),
515
677
  sha256: expectedSha256,
@@ -581,6 +743,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
581
743
  store.setStagedBundleId(nil)
582
744
  return store.getCurrentBundle()
583
745
  }
746
+ guard isCompatibleRuntime(staged) else {
747
+ try? store.deleteBundle(id: staged.id)
748
+ return store.getCurrentBundle()
749
+ }
584
750
 
585
751
  store.setCurrentBundleId(staged.id)
586
752
  store.setStagedBundleId(nil)
@@ -595,6 +761,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
595
761
  store.setStagedBundleId(nil)
596
762
  return
597
763
  }
764
+ guard isCompatibleRuntime(staged) else {
765
+ try? store.deleteBundle(id: staged.id)
766
+ return
767
+ }
598
768
 
599
769
  store.setCurrentBundleId(staged.id)
600
770
  store.setStagedBundleId(nil)
@@ -653,6 +823,7 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
653
823
  let failed = BundleInfo(
654
824
  id: current.id,
655
825
  version: current.version,
826
+ runtimeVersion: current.runtimeVersion,
656
827
  status: .error,
657
828
  downloadedAt: current.downloadedAt,
658
829
  sha256: current.sha256,
@@ -759,12 +930,12 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
759
930
  "size": latest.size,
760
931
  "downloaded": downloaded,
761
932
  ]
933
+ if let runtimeVersion = latest.runtimeVersion {
934
+ payload["runtimeVersion"] = runtimeVersion
935
+ }
762
936
  if let releaseId = latest.releaseId {
763
937
  payload["releaseId"] = releaseId
764
938
  }
765
- if let minNativeBuild = latest.minNativeBuild {
766
- payload["minNativeBuild"] = minNativeBuild
767
- }
768
939
  return payload
769
940
  }
770
941
 
@@ -784,6 +955,10 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
784
955
  return nil
785
956
  }
786
957
 
958
+ if trimToNil(staged.runtimeVersion) != trimToNil(latest.runtimeVersion) {
959
+ return nil
960
+ }
961
+
787
962
  if let latestReleaseId = latest.releaseId,
788
963
  let stagedReleaseId = staged.releaseId,
789
964
  latestReleaseId == stagedReleaseId {
@@ -798,6 +973,24 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
798
973
  return nil
799
974
  }
800
975
 
976
+ private func pruneIncompatibleBundles() {
977
+ for bundle in store.listDownloadedBundles() where !isCompatibleRuntime(bundle) {
978
+ try? store.deleteBundle(id: bundle.id)
979
+ }
980
+
981
+ if let failed = store.getFailedBundle(), !isCompatibleRuntime(failed) {
982
+ store.setFailedBundle(nil)
983
+ }
984
+ }
985
+
986
+ private func isCompatibleRuntime(_ runtimeVersion: String?) -> Bool {
987
+ trimToNil(runtimeVersion) == self.runtimeVersion
988
+ }
989
+
990
+ private func isCompatibleRuntime(_ bundle: BundleInfo) -> Bool {
991
+ isCompatibleRuntime(bundle.runtimeVersion)
992
+ }
993
+
801
994
  private func buildBundleId(from version: String) -> String {
802
995
  let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
803
996
  let allowed = CharacterSet(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otakit/capacitor-updater",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Capacitor plugin for OTA updates",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",