@capgo/capacitor-updater 8.51.24 → 8.52.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.
@@ -8,7 +8,6 @@ import Foundation
8
8
  import Capacitor
9
9
  import UIKit
10
10
  import WebKit
11
- import Version
12
11
 
13
12
  /**
14
13
  * Please read the Capacitor iOS Plugin Development Guide
@@ -96,7 +95,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
96
95
  deinit {
97
96
  implementation.shutdown()
98
97
  }
99
- private let pluginVersion: String = "8.51.24"
98
+ private let pluginVersion: String = "8.52.0"
100
99
  private let launchStartedAtMs = Int64(Date().timeIntervalSince1970 * 1000)
101
100
  static let updateUrlDefault = "https://plugin.capgo.app/updates"
102
101
  static let statsUrlDefault = "https://plugin.capgo.app/stats"
@@ -147,7 +146,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
147
146
  // Note: DELAY_CONDITION_PREFERENCES is now defined in DelayUpdateUtils.DELAY_CONDITION_PREFERENCES
148
147
  private var updateUrl = ""
149
148
  private var backgroundTaskID: UIBackgroundTaskIdentifier = UIBackgroundTaskIdentifier.invalid
150
- private var currentVersionNative: Version = "0.0.0"
149
+ private var currentVersionNative = CapgoSemanticVersion(major: 0, minor: 0, patch: 0)
151
150
  private var currentBuildVersion: String = "0"
152
151
  private var autoUpdate = false
153
152
  private var autoUpdateMode = CapacitorUpdaterPlugin.autoUpdateModeOff
@@ -219,6 +218,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
219
218
  // Best-effort flag: set before we expect notifyAppReady (load/reload).
220
219
  // No lock — never held across waits; a rare race only mis-times one wait.
221
220
  private var pendingNotifyAppReady = false
221
+ // Armed only after a reload. The next document stamps this generation into notifyAppReady.
222
+ // A call from the previous page has no matching generation and must not mark the new bundle successful.
223
+ private var readyGuardArmed = false
224
+ private var readyGeneration = 0
222
225
  private let semaphoreWaitTestingLock = NSLock()
223
226
  private var didEnterSemaphoreWaitForTesting = false
224
227
 
@@ -264,7 +267,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
264
267
  fatalError("Cannot get version name")
265
268
  }
266
269
  do {
267
- currentVersionNative = try Version(versionName)
270
+ currentVersionNative = try CapgoSemanticVersion(versionName)
268
271
  } catch {
269
272
  logger.error("Cannot parse versionName \(versionName)")
270
273
  }
@@ -288,6 +291,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
288
291
  configureAutoUpdateModeFromConfig()
289
292
  appReadyTimeout = max(1000, getConfig().getInt("appReadyTimeout", 10000)) // Minimum 1 second
290
293
  implementation.timeout = Double(getConfig().getInt("responseTimeout", 20))
294
+ implementation.allowHttpsToHttpRedirect = getConfig().getBoolean("allowHttpsToHttpRedirect", false)
291
295
  resetWhenUpdate = getConfig().getBoolean("resetWhenUpdate", true)
292
296
  shakeMenuEnabled = getConfig().getBoolean("shakeMenu", false)
293
297
  shakeChannelSelectorEnabled = getConfig().getBoolean("allowShakeChannelSelector", false)
@@ -308,16 +312,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
308
312
  implementation.setPublicKey(getConfig().getString("publicKey") ?? "")
309
313
  implementation.notifyDownloadRaw = notifyDownload
310
314
  implementation.notifyListeners = { [weak self] eventName, data in
311
- let emit = {
312
- self?.notifyListeners(eventName, data: data)
313
- }
314
- if Thread.isMainThread {
315
- emit()
316
- } else {
317
- DispatchQueue.main.async {
318
- emit()
319
- }
320
- }
315
+ self?.notifyListenersOnMain(eventName, data: data)
321
316
  }
322
317
  implementation.pluginVersion = self.pluginVersion
323
318
 
@@ -1244,9 +1239,11 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1244
1239
  call.keepAlive = true
1245
1240
  }
1246
1241
 
1247
- private func notifyListenersOnMain(_ eventName: String, data: JSObject) {
1242
+ /// CAPPlugin's listener storage is not thread-safe (ionic-team/capacitor#8157), so every
1243
+ /// event this plugin emits goes through the main thread. Calls already on main stay synchronous.
1244
+ private func notifyListenersOnMain(_ eventName: String, data: [String: Any]?, retainUntilConsumed: Bool = false) {
1248
1245
  let notify = {
1249
- self.notifyListeners(eventName, data: data)
1246
+ self.notifyListeners(eventName, data: data, retainUntilConsumed: retainUntilConsumed)
1250
1247
  }
1251
1248
 
1252
1249
  if Thread.isMainThread {
@@ -1716,6 +1713,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1716
1713
  self.logger.error("Cannot get capBridge")
1717
1714
  return false
1718
1715
  }
1716
+ self.armReadyGuard(webView: vc.webView)
1719
1717
  if self.keepUrlPathAfterReload {
1720
1718
  if let currentURL = vc.webView?.url {
1721
1719
  capBridge.setServerBasePath(dest.path)
@@ -1767,7 +1765,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1767
1765
  return false
1768
1766
  }
1769
1767
  self.checkAppReady()
1770
- self.notifyListeners("appReloaded", data: [:])
1768
+ self.notifyListenersOnMain("appReloaded", data: [:])
1771
1769
  return true
1772
1770
  }
1773
1771
 
@@ -1790,7 +1788,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
1790
1788
  return false
1791
1789
  }
1792
1790
  self.checkAppReady()
1793
- self.notifyListeners("appReloaded", data: [:])
1791
+ self.notifyListenersOnMain("appReloaded", data: [:])
1794
1792
  return true
1795
1793
  }
1796
1794
 
@@ -2705,12 +2703,12 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
2705
2703
  var response: URLResponse?
2706
2704
  var responseError: Error?
2707
2705
 
2708
- URLSession.shared.dataTask(with: request) { data, urlResponse, error in
2706
+ self.implementation.startRawDataTask(request) { data, urlResponse, error in
2709
2707
  responseData = data
2710
2708
  response = urlResponse
2711
2709
  responseError = error
2712
2710
  semaphore.signal()
2713
- }.resume()
2711
+ }
2714
2712
 
2715
2713
  if semaphore.wait(timeout: .now() + 60) == .timedOut {
2716
2714
  throw makePreviewError("Preview payload request timed out")
@@ -3025,11 +3023,16 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3025
3023
  self.saveCallForAsyncHandling(call)
3026
3024
  DispatchQueue.global(qos: .utility).async {
3027
3025
  let configDefaultChannel = self.getConfig().getString("defaultChannel", "")!
3028
- let res = self.implementation.unsetChannel(defaultChannelKey: self.defaultChannelDefaultsKey, configDefaultChannel: configDefaultChannel)
3026
+ let res = self.implementation.unsetChannel(
3027
+ defaultChannelKey: self.defaultChannelDefaultsKey,
3028
+ configDefaultChannel: configDefaultChannel,
3029
+ allowSetDefaultChannel: self.allowSetDefaultChannel
3030
+ )
3029
3031
  if res.error != "" {
3030
- self.rejectCall(call, message: res.error, code: "UNSETCHANNEL_FAILED", data: [
3031
- "message": res.error,
3032
- "error": res.error.contains("Channel URL") ? "missing_config" : "request_failed"
3032
+ let message = res.message.isEmpty ? res.error : res.message
3033
+ self.rejectCall(call, message: message, code: "UNSETCHANNEL_FAILED", data: [
3034
+ "message": message,
3035
+ "error": res.error
3033
3036
  ])
3034
3037
  } else {
3035
3038
  guard self.persistDefaultChannelStateFromDefaults() else {
@@ -3077,9 +3080,20 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3077
3080
  "message": res.error
3078
3081
  ])
3079
3082
  }
3080
- self.rejectCall(call, message: res.error, code: "SETCHANNEL_FAILED", data: [
3081
- "message": res.error,
3082
- "error": res.error.contains("Channel URL") ? "missing_config" : (res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed")) ? "channel_private" : "request_failed"
3083
+ let message = res.message.isEmpty ? res.error : res.message
3084
+ let errorCode: String
3085
+ if res.error == "disabled_by_config" {
3086
+ errorCode = res.error
3087
+ } else if res.error.contains("cannot_update_via_private_channel") || res.error.contains("channel_self_set_not_allowed") {
3088
+ errorCode = "channel_private"
3089
+ } else if res.error.contains("Channel URL") {
3090
+ errorCode = "missing_config"
3091
+ } else {
3092
+ errorCode = "request_failed"
3093
+ }
3094
+ self.rejectCall(call, message: message, code: "SETCHANNEL_FAILED", data: [
3095
+ "message": message,
3096
+ "error": errorCode
3083
3097
  ])
3084
3098
  } else {
3085
3099
  guard self.persistDefaultChannelStateFromDefaults() else {
@@ -3309,7 +3323,65 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3309
3323
  )
3310
3324
  }
3311
3325
 
3326
+ static func shouldAcceptReadyCall(guardArmed: Bool, expectedGeneration: Int, reportedGeneration: Int?) -> Bool {
3327
+ if !guardArmed {
3328
+ return true
3329
+ }
3330
+ guard let reportedGeneration else {
3331
+ return false
3332
+ }
3333
+ return reportedGeneration == expectedGeneration
3334
+ }
3335
+
3336
+ static func readyGenerationScript(_ generation: Int) -> String {
3337
+ // Wrap Capacitor.nativePromise, not the plugin proxy. registerPlugin's get trap
3338
+ // ignores assignments to notifyAppReady. Each document keeps its own generation.
3339
+ return "(function(){window.__CAPGO_READY_GEN=\(generation);if(window.__capgoReadyBridge)return;function arm(){var cap=window.Capacitor;if(!cap||typeof cap.nativePromise!=='function'||cap.__capgoNativePromise)return false;var orig=cap.nativePromise.bind(cap);cap.nativePromise=function(pluginName,methodName,options){if(pluginName==='CapacitorUpdater'&&methodName==='notifyAppReady'){var next={};if(options&&typeof options==='object'){for(var k in options){if(Object.prototype.hasOwnProperty.call(options,k))next[k]=options[k];}}next.loadGeneration=window.__CAPGO_READY_GEN;options=next;}return orig(pluginName,methodName,options);};cap.__capgoNativePromise=true;window.__capgoReadyBridge=true;return true;}if(!arm()){var n=0;var t=setInterval(function(){if(arm()||++n>100)clearInterval(t);},20);}})();"
3340
+ }
3341
+
3342
+ private func armReadyGuard(webView: WKWebView?) {
3343
+ let generation = self.readyGeneration + 1
3344
+ guard let webView else {
3345
+ logger.warn("Cannot stamp notifyAppReady generation without a webview")
3346
+ self.readyGuardArmed = false
3347
+ return
3348
+ }
3349
+ let userScript = WKUserScript(
3350
+ source: CapacitorUpdaterPlugin.readyGenerationScript(generation),
3351
+ injectionTime: .atDocumentStart,
3352
+ forMainFrameOnly: true
3353
+ )
3354
+ webView.configuration.userContentController.addUserScript(userScript)
3355
+ self.readyGeneration = generation
3356
+ self.readyGuardArmed = true
3357
+ }
3358
+
3359
+ private func reportedReadyGeneration(_ call: CAPPluginCall) -> Int? {
3360
+ if let generation = call.getInt("loadGeneration") {
3361
+ return generation
3362
+ }
3363
+ // The bridge often boxes JS numbers as NSNumber, which getInt does not cast.
3364
+ if let number = call.getValue("loadGeneration") as? NSNumber {
3365
+ return number.intValue
3366
+ }
3367
+ return nil
3368
+ }
3369
+
3370
+ private func acceptsReadyCall(_ call: CAPPluginCall) -> Bool {
3371
+ let reported = self.readyGuardArmed ? self.reportedReadyGeneration(call) : nil
3372
+ return CapacitorUpdaterPlugin.shouldAcceptReadyCall(
3373
+ guardArmed: self.readyGuardArmed,
3374
+ expectedGeneration: self.readyGeneration,
3375
+ reportedGeneration: reported
3376
+ )
3377
+ }
3378
+
3312
3379
  @objc func notifyAppReady(_ call: CAPPluginCall) {
3380
+ if !self.acceptsReadyCall(call) {
3381
+ logger.info("Ignoring notifyAppReady from a page that is no longer current")
3382
+ call.resolve(["bundle": self.implementation.getCurrentBundle().toJSON()])
3383
+ return
3384
+ }
3313
3385
  self.semaphoreDown()
3314
3386
  let bundle = self.implementation.getCurrentBundle()
3315
3387
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
@@ -3417,7 +3489,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3417
3489
  if BundleStatus.SUCCESS.storedValue != current.getStatus() {
3418
3490
  logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
3419
3491
  logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
3420
- self.notifyListeners("updateFailed", data: [
3492
+ self.notifyListenersOnMain("updateFailed", data: [
3421
3493
  "bundle": current.toJSON()
3422
3494
  ])
3423
3495
  self.persistLastFailedBundle(current)
@@ -3459,7 +3531,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3459
3531
  }
3460
3532
 
3461
3533
  private func notifyBundleSet(_ bundle: BundleInfo) {
3462
- self.notifyListeners("set", data: ["bundle": bundle.toJSON()], retainUntilConsumed: true)
3534
+ self.notifyListenersOnMain("set", data: ["bundle": bundle.toJSON()], retainUntilConsumed: true)
3463
3535
  }
3464
3536
 
3465
3537
  func sendReadyToJs(current: BundleInfo, msg: String) {
@@ -3471,7 +3543,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3471
3543
  if self.consumePendingNotifyAppReady() {
3472
3544
  self.semaphoreWait(waitTime: self.appReadyTimeout)
3473
3545
  }
3474
- self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
3546
+ self.notifyListenersOnMain("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
3475
3547
 
3476
3548
  // Auto hide splashscreen if enabled
3477
3549
  // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
@@ -4247,8 +4319,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4247
4319
  return
4248
4320
  }
4249
4321
  let payload: [String: Any] = ["version": version]
4250
- self.notifyListeners("breakingAvailable", data: payload)
4251
- self.notifyListeners("majorAvailable", data: payload)
4322
+ self.notifyListenersOnMain("breakingAvailable", data: payload)
4323
+ self.notifyListenersOnMain("majorAvailable", data: payload)
4252
4324
  }
4253
4325
 
4254
4326
  private func shouldNotifyBreakingEvents(response: AppVersion) -> Bool {
@@ -4288,7 +4360,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4288
4360
  let responseMessage = res.message?.isEmpty == false ? res.message : nil
4289
4361
  let message = responseMessage ?? (backendError.isEmpty ? "server did not provide a message" : backendError)
4290
4362
  let latestVersionName = res.version.isEmpty ? current.getVersionName() : res.version
4291
- self.notifyListeners("updateCheckResult", data: [
4363
+ self.notifyListenersOnMain("updateCheckResult", data: [
4292
4364
  "kind": responseKind,
4293
4365
  "error": backendError,
4294
4366
  "message": message,
@@ -4341,10 +4413,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4341
4413
  if sendStats {
4342
4414
  self.implementation.sendStats(action: failureAction, versionName: current.getVersionName())
4343
4415
  }
4344
- self.notifyListeners(failureEvent, data: ["version": latestVersionName])
4416
+ self.notifyListenersOnMain(failureEvent, data: ["version": latestVersionName])
4345
4417
  }
4346
4418
  if notifyNoNeedUpdate {
4347
- self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
4419
+ self.notifyListenersOnMain("noNeedUpdate", data: ["bundle": current.toJSON()])
4348
4420
  }
4349
4421
  self.sendReadyToJs(current: current, msg: msg)
4350
4422
  logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
@@ -4501,7 +4573,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4501
4573
  let builtinUpdateAvailable = !current.isBuiltin()
4502
4574
  if builtinUpdateAvailable {
4503
4575
  let builtinBundle = self.implementation.getBundleInfo(id: BundleInfo.ID_BUILTIN)
4504
- self.notifyListeners("updateAvailable", data: ["bundle": builtinBundle.toJSON()], retainUntilConsumed: true)
4576
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": builtinBundle.toJSON()], retainUntilConsumed: true)
4505
4577
  }
4506
4578
  self.endBackGroundTaskWithNotif(
4507
4579
  msg: "Latest version is builtin, autoUpdate onlyDownload",
@@ -4652,7 +4724,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4652
4724
  plannedDirectUpdate: plannedDirectUpdate
4653
4725
  )
4654
4726
  } else if self.queueBundleForNextBackgroundInstall(next) {
4655
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4727
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": next.toJSON()])
4656
4728
  self.endBackGroundTaskWithNotif(
4657
4729
  msg: "Direct update reload failed, update will install next background",
4658
4730
  latestVersionName: latestVersionName,
@@ -4673,7 +4745,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4673
4745
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
4674
4746
  }
4675
4747
  if self.queueBundleForNextBackgroundInstall(next) {
4676
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4748
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": next.toJSON()])
4677
4749
  self.endBackGroundTaskWithNotif(
4678
4750
  msg: "update downloaded, will install next background",
4679
4751
  latestVersionName: latestVersionName,
@@ -4691,7 +4763,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4691
4763
  }
4692
4764
  } else {
4693
4765
  self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
4694
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
4766
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
4695
4767
  self.endBackGroundTaskWithNotif(
4696
4768
  msg: "update downloaded, autoUpdate onlyDownload",
4697
4769
  latestVersionName: latestVersionName,
@@ -5036,8 +5108,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
5036
5108
  // Determine update availability by comparing versions
5037
5109
  if let availableVersion = availableVersion {
5038
5110
  do {
5039
- let currentVer = try Version(currentVersionName)
5040
- let availableVer = try Version(availableVersion)
5111
+ let currentVer = try CapgoSemanticVersion(currentVersionName)
5112
+ let availableVer = try CapgoSemanticVersion(availableVersion)
5041
5113
  if availableVer > currentVer {
5042
5114
  result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
5043
5115
  } else {
@@ -0,0 +1,162 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+
9
+ /// Minimal version value used for native version comparisons.
10
+ ///
11
+ /// Parsing and ordering intentionally mirror the lenient parser of the `Version` package
12
+ /// (mrackwitz/Version 0.8.0) that this type replaces, so existing comparisons keep the same results:
13
+ /// - `MAJOR[.MINOR[.PATCH]][-PRERELEASE][+BUILD]`, numeric components are ASCII digits (leading zeros allowed).
14
+ /// - Missing minor/patch compare as `0`.
15
+ /// - A version with a prerelease is lower than the same version without one.
16
+ /// - Prerelease identifiers are compared dot by dot: numerically when both are digits, lexically otherwise.
17
+ /// - Build metadata is ignored for equality and ordering.
18
+ public struct CapgoSemanticVersion: Comparable, Hashable, CustomStringConvertible {
19
+ public enum ParseError: Error {
20
+ case invalidComponents
21
+ case invalidMajorComponent
22
+ case invalidMinorComponent
23
+ case invalidPatchComponent
24
+ }
25
+
26
+ public let major: Int
27
+ public let minor: Int?
28
+ public let patch: Int?
29
+ public let prerelease: String?
30
+ public let build: String?
31
+
32
+ public var canonicalMinor: Int { minor ?? 0 }
33
+ public var canonicalPatch: Int { patch ?? 0 }
34
+
35
+ // Same pattern as the lenient `Version` parser (including its build group quirk: no dots in build metadata).
36
+ private static let versionPattern =
37
+ "\\A([0-9]+)(?:\\.([0-9]+))?(?:\\.([0-9]+))?(?:-([0-9A-Za-z-.]+))?(?:\\+([0-9A-Za-z-]+))??\\z"
38
+ private static let versionRegex = try? NSRegularExpression(pattern: versionPattern)
39
+
40
+ public init(major: Int, minor: Int? = nil, patch: Int? = nil, prerelease: String? = nil, build: String? = nil) {
41
+ self.major = max(0, major)
42
+ self.minor = minor.map { max(0, $0) }
43
+ self.patch = patch.map { max(0, $0) }
44
+ self.prerelease = prerelease
45
+ self.build = build
46
+ }
47
+
48
+ public init(_ string: String) throws {
49
+ guard let regex = Self.versionRegex else {
50
+ throw ParseError.invalidComponents
51
+ }
52
+ let nsString = string as NSString
53
+ guard let match = regex.firstMatch(in: string, options: [], range: NSRange(location: 0, length: nsString.length)),
54
+ match.numberOfRanges == 6 else {
55
+ throw ParseError.invalidComponents
56
+ }
57
+
58
+ func group(_ index: Int) -> String? {
59
+ let range = match.range(at: index)
60
+ return range.location == NSNotFound ? nil : nsString.substring(with: range)
61
+ }
62
+
63
+ guard let major = group(1).flatMap({ Int($0) }) else {
64
+ throw ParseError.invalidMajorComponent
65
+ }
66
+ let minorComponent = group(2)
67
+ let minor = minorComponent.flatMap { Int($0) }
68
+ if minorComponent != nil && minor == nil {
69
+ throw ParseError.invalidMinorComponent
70
+ }
71
+ let patchComponent = group(3)
72
+ let patch = patchComponent.flatMap { Int($0) }
73
+ if patchComponent != nil && patch == nil {
74
+ throw ParseError.invalidPatchComponent
75
+ }
76
+
77
+ self.major = major
78
+ self.minor = minor
79
+ self.patch = patch
80
+ self.prerelease = group(4)
81
+ self.build = group(5)
82
+ }
83
+
84
+ public var description: String {
85
+ var result = "\(major)"
86
+ if let minor {
87
+ result += ".\(minor)"
88
+ }
89
+ if let patch {
90
+ result += ".\(patch)"
91
+ }
92
+ if let prerelease {
93
+ result += "-\(prerelease)"
94
+ }
95
+ if let build {
96
+ result += "+\(build)"
97
+ }
98
+ return result
99
+ }
100
+
101
+ public static func == (lhs: CapgoSemanticVersion, rhs: CapgoSemanticVersion) -> Bool {
102
+ return lhs.major == rhs.major
103
+ && lhs.canonicalMinor == rhs.canonicalMinor
104
+ && lhs.canonicalPatch == rhs.canonicalPatch
105
+ && lhs.prerelease == rhs.prerelease
106
+ }
107
+
108
+ public func hash(into hasher: inout Hasher) {
109
+ hasher.combine(major)
110
+ hasher.combine(canonicalMinor)
111
+ hasher.combine(canonicalPatch)
112
+ hasher.combine(prerelease)
113
+ }
114
+
115
+ public static func < (lhs: CapgoSemanticVersion, rhs: CapgoSemanticVersion) -> Bool {
116
+ if lhs.major != rhs.major {
117
+ return lhs.major < rhs.major
118
+ }
119
+ if lhs.canonicalMinor != rhs.canonicalMinor {
120
+ return lhs.canonicalMinor < rhs.canonicalMinor
121
+ }
122
+ if lhs.canonicalPatch != rhs.canonicalPatch {
123
+ return lhs.canonicalPatch < rhs.canonicalPatch
124
+ }
125
+
126
+ switch (lhs.prerelease, rhs.prerelease) {
127
+ case (.some, .none):
128
+ return true
129
+ case (.none, .some), (.none, .none):
130
+ return false
131
+ case let (.some(lpre), .some(rpre)):
132
+ return comparePrerelease(lpre, rpre) == .orderedAscending
133
+ }
134
+ }
135
+
136
+ private static func isNumericIdentifier(_ value: String) -> Bool {
137
+ return !value.isEmpty && value.utf8.allSatisfy { $0 >= 0x30 && $0 <= 0x39 }
138
+ }
139
+
140
+ private static func comparePrerelease(_ lhs: String, _ rhs: String) -> ComparisonResult {
141
+ let lhsComponents = lhs.components(separatedBy: ".")
142
+ let rhsComponents = rhs.components(separatedBy: ".")
143
+ if let (left, right) = zip(lhsComponents, rhsComponents).first(where: { $0.0 != $0.1 }) {
144
+ if isNumericIdentifier(left) && isNumericIdentifier(right) {
145
+ let leftValue = Int(left) ?? 0
146
+ let rightValue = Int(right) ?? 0
147
+ if leftValue == rightValue {
148
+ return .orderedSame
149
+ }
150
+ return leftValue < rightValue ? .orderedAscending : .orderedDescending
151
+ }
152
+ if left == right {
153
+ return .orderedSame
154
+ }
155
+ return left < right ? .orderedAscending : .orderedDescending
156
+ }
157
+ if lhsComponents.count != rhsComponents.count {
158
+ return lhsComponents.count < rhsComponents.count ? .orderedAscending : .orderedDescending
159
+ }
160
+ return .orderedSame
161
+ }
162
+ }