@capgo/capacitor-updater 8.51.25 → 8.52.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.
@@ -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.25"
98
+ private let pluginVersion: String = "8.52.1"
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")
@@ -3325,7 +3323,65 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3325
3323
  )
3326
3324
  }
3327
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
+
3328
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
+ }
3329
3385
  self.semaphoreDown()
3330
3386
  let bundle = self.implementation.getCurrentBundle()
3331
3387
  self.implementation.setSuccess(bundle: bundle, autoDeletePrevious: self.autoDeletePrevious)
@@ -3433,7 +3489,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3433
3489
  if BundleStatus.SUCCESS.storedValue != current.getStatus() {
3434
3490
  logger.error("notifyAppReady was not called, roll back current bundle: \(current.toString())")
3435
3491
  logger.error("Did you forget to call 'notifyAppReady()' in your Capacitor App code?")
3436
- self.notifyListeners("updateFailed", data: [
3492
+ self.notifyListenersOnMain("updateFailed", data: [
3437
3493
  "bundle": current.toJSON()
3438
3494
  ])
3439
3495
  self.persistLastFailedBundle(current)
@@ -3475,7 +3531,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3475
3531
  }
3476
3532
 
3477
3533
  private func notifyBundleSet(_ bundle: BundleInfo) {
3478
- self.notifyListeners("set", data: ["bundle": bundle.toJSON()], retainUntilConsumed: true)
3534
+ self.notifyListenersOnMain("set", data: ["bundle": bundle.toJSON()], retainUntilConsumed: true)
3479
3535
  }
3480
3536
 
3481
3537
  func sendReadyToJs(current: BundleInfo, msg: String) {
@@ -3487,7 +3543,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
3487
3543
  if self.consumePendingNotifyAppReady() {
3488
3544
  self.semaphoreWait(waitTime: self.appReadyTimeout)
3489
3545
  }
3490
- self.notifyListeners("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
3546
+ self.notifyListenersOnMain("appReady", data: ["bundle": current.toJSON(), "status": msg], retainUntilConsumed: true)
3491
3547
 
3492
3548
  // Auto hide splashscreen if enabled
3493
3549
  // We show it on background when conditions are met, so we should hide it on foreground regardless of update outcome
@@ -4263,8 +4319,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4263
4319
  return
4264
4320
  }
4265
4321
  let payload: [String: Any] = ["version": version]
4266
- self.notifyListeners("breakingAvailable", data: payload)
4267
- self.notifyListeners("majorAvailable", data: payload)
4322
+ self.notifyListenersOnMain("breakingAvailable", data: payload)
4323
+ self.notifyListenersOnMain("majorAvailable", data: payload)
4268
4324
  }
4269
4325
 
4270
4326
  private func shouldNotifyBreakingEvents(response: AppVersion) -> Bool {
@@ -4304,7 +4360,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4304
4360
  let responseMessage = res.message?.isEmpty == false ? res.message : nil
4305
4361
  let message = responseMessage ?? (backendError.isEmpty ? "server did not provide a message" : backendError)
4306
4362
  let latestVersionName = res.version.isEmpty ? current.getVersionName() : res.version
4307
- self.notifyListeners("updateCheckResult", data: [
4363
+ self.notifyListenersOnMain("updateCheckResult", data: [
4308
4364
  "kind": responseKind,
4309
4365
  "error": backendError,
4310
4366
  "message": message,
@@ -4357,10 +4413,10 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4357
4413
  if sendStats {
4358
4414
  self.implementation.sendStats(action: failureAction, versionName: current.getVersionName())
4359
4415
  }
4360
- self.notifyListeners(failureEvent, data: ["version": latestVersionName])
4416
+ self.notifyListenersOnMain(failureEvent, data: ["version": latestVersionName])
4361
4417
  }
4362
4418
  if notifyNoNeedUpdate {
4363
- self.notifyListeners("noNeedUpdate", data: ["bundle": current.toJSON()])
4419
+ self.notifyListenersOnMain("noNeedUpdate", data: ["bundle": current.toJSON()])
4364
4420
  }
4365
4421
  self.sendReadyToJs(current: current, msg: msg)
4366
4422
  logger.info("endBackGroundTaskWithNotif \(msg) current: \(current.getVersionName()) latestVersionName: \(latestVersionName)")
@@ -4517,7 +4573,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4517
4573
  let builtinUpdateAvailable = !current.isBuiltin()
4518
4574
  if builtinUpdateAvailable {
4519
4575
  let builtinBundle = self.implementation.getBundleInfo(id: BundleInfo.ID_BUILTIN)
4520
- self.notifyListeners("updateAvailable", data: ["bundle": builtinBundle.toJSON()], retainUntilConsumed: true)
4576
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": builtinBundle.toJSON()], retainUntilConsumed: true)
4521
4577
  }
4522
4578
  self.endBackGroundTaskWithNotif(
4523
4579
  msg: "Latest version is builtin, autoUpdate onlyDownload",
@@ -4668,7 +4724,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4668
4724
  plannedDirectUpdate: plannedDirectUpdate
4669
4725
  )
4670
4726
  } else if self.queueBundleForNextBackgroundInstall(next) {
4671
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4727
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": next.toJSON()])
4672
4728
  self.endBackGroundTaskWithNotif(
4673
4729
  msg: "Direct update reload failed, update will install next background",
4674
4730
  latestVersionName: latestVersionName,
@@ -4689,7 +4745,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4689
4745
  self.logger.info("Direct update skipped because splashscreen timeout occurred. Update will install on next app background.")
4690
4746
  }
4691
4747
  if self.queueBundleForNextBackgroundInstall(next) {
4692
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()])
4748
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": next.toJSON()])
4693
4749
  self.endBackGroundTaskWithNotif(
4694
4750
  msg: "update downloaded, will install next background",
4695
4751
  latestVersionName: latestVersionName,
@@ -4707,7 +4763,7 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
4707
4763
  }
4708
4764
  } else {
4709
4765
  self.logger.info("autoUpdate is set to onlyDownload, downloaded update will not be set as next bundle")
4710
- self.notifyListeners("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
4766
+ self.notifyListenersOnMain("updateAvailable", data: ["bundle": next.toJSON()], retainUntilConsumed: true)
4711
4767
  self.endBackGroundTaskWithNotif(
4712
4768
  msg: "update downloaded, autoUpdate onlyDownload",
4713
4769
  latestVersionName: latestVersionName,
@@ -5052,8 +5108,8 @@ public class CapacitorUpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
5052
5108
  // Determine update availability by comparing versions
5053
5109
  if let availableVersion = availableVersion {
5054
5110
  do {
5055
- let currentVer = try Version(currentVersionName)
5056
- let availableVer = try Version(availableVersion)
5111
+ let currentVer = try CapgoSemanticVersion(currentVersionName)
5112
+ let availableVer = try CapgoSemanticVersion(availableVersion)
5057
5113
  if availableVer > currentVer {
5058
5114
  result["updateAvailability"] = AppUpdateAvailability.updateAvailable.rawValue
5059
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
+ }