diff --git a/Brief/Updater.swift b/Brief/Updater.swift index 010cb14..cea0bbb 100644 --- a/Brief/Updater.swift +++ b/Brief/Updater.swift @@ -11,7 +11,10 @@ public class Updater: ObservableObject, UpdaterProtocol { @Published public var update: Release? - public init(checkOnLaunch: Bool) { + private let osVersion: SemVer + + public init(checkOnLaunch: Bool, osVersion: SemVer = SemVer(ProcessInfo.processInfo.operatingSystemVersion)) { + self.osVersion = osVersion if checkOnLaunch { // Don't do a launch check if the user hasn't seen the setup prompt explaining updater yet. checkForUpdates() @@ -25,8 +28,8 @@ public class Updater: ObservableObject, UpdaterProtocol { public func checkForUpdates() { URLSession.shared.dataTask(with: Constants.updateURL) { data, _, _ in guard let data = data else { return } - guard let release = try? JSONDecoder().decode(Release.self, from: data) else { return } - self.evaluate(release: release) + guard let releases = try? JSONDecoder().decode([Release].self, from: data) else { return } + self.evaluate(releases: releases) }.resume() } @@ -42,11 +45,16 @@ public class Updater: ObservableObject, UpdaterProtocol { extension Updater { - func evaluate(release: Release) { + func evaluate(releases: [Release]) { + guard let release = releases + .sorted() + .reversed() + .filter({ !$0.prerelease }) + .first(where: { $0.minimumOSVersion <= osVersion }) else { return } guard !userIgnored(release: release) else { return } guard !release.prerelease else { return } let latestVersion = SemVer(release.name) - let currentVersion = SemVer(Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String) + let currentVersion = SemVer(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0") if latestVersion > currentVersion { DispatchQueue.main.async { self.update = release @@ -64,11 +72,11 @@ extension Updater { } } -struct SemVer { +public struct SemVer { let versionNumbers: [Int] - init(_ version: String) { + public init(_ version: String) { // Betas have the format 1.2.3_beta1 let strippedBeta = version.split(separator: "_").first! var split = strippedBeta.split(separator: ".").compactMap { Int($0) } @@ -78,11 +86,15 @@ struct SemVer { versionNumbers = split } + public init(_ version: OperatingSystemVersion) { + versionNumbers = [version.majorVersion, version.minorVersion, version.patchVersion] + } + } extension SemVer: Comparable { - static func < (lhs: SemVer, rhs: SemVer) -> Bool { + public static func < (lhs: SemVer, rhs: SemVer) -> Bool { for (latest, current) in zip(lhs.versionNumbers, rhs.versionNumbers) { if latest < current { return true @@ -99,7 +111,7 @@ extension SemVer: Comparable { extension Updater { enum Constants { - static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases/latest")! + static let updateURL = URL(string: "https://api.github.com/repos/maxgoedjen/secretive/releases")! } } @@ -128,12 +140,32 @@ extension Release: Identifiable { } +extension Release: Comparable { + + public static func < (lhs: Release, rhs: Release) -> Bool { + lhs.version < rhs.version + } + +} + extension Release { public var critical: Bool { body.contains(Constants.securityContent) } + public var version: SemVer { + SemVer(name) + } + + public var minimumOSVersion: SemVer { + guard let range = body.range(of: "Minimum macOS Version"), + let numberStart = body.rangeOfCharacter(from: CharacterSet.decimalDigits, options: [], range: range.upperBound.. SemVer("1.0.0")) + } + + func testGreatestSelectedIfOldPatchIsPublishedLater() { + // If 2.x.x series has been published, and a patch for 1.x.x is issued + // 2.x.x should still be selected if user can run it. + let updater = Updater(checkOnLaunch: false, osVersion: SemVer("2.2.3")) + let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3") + let releases = [ + Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release Minimum macOS Version: 1.2.3"), + Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes Minimum macOS Version: 1.2.3"), + two, + Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), + ] + + let expectation = XCTestExpectation() + updater.evaluate(releases: releases) + DispatchQueue.main.async { + XCTAssert(updater.update == two) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + } + + func testLatestVersionIsRunnable() { + // If the 2.x.x series has been published but the user can't run it + // the last version the user can run should be selected. + let updater = Updater(checkOnLaunch: false, osVersion: SemVer("1.2.3")) + let oneOhTwo = Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3") + let releases = [ + Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release Minimum macOS Version: 1.2.3"), + Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes Minimum macOS Version: 1.2.3"), + Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available! Minimum macOS Version: 2.2.3"), + Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch! Minimum macOS Version: 1.2.3"), + ] + let expectation = XCTestExpectation() + updater.evaluate(releases: releases) + DispatchQueue.main.async { + XCTAssert(updater.update == oneOhTwo) + expectation.fulfill() + } + wait(for: [expectation], timeout: 1) + } + + func testSorting() { + let two = Release(name: "2.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "2.0 available!") + let releases = [ + Release(name: "1.0.0", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Initial release"), + Release(name: "1.0.1", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Bug fixes"), + two, + Release(name: "1.0.2", prerelease: false, html_url: URL(string: "https://example.com")!, body: "Emergency patch!"), + ] + let sorted = releases.sorted().reversed().first + XCTAssert(sorted == two) + } + +} diff --git a/BriefTests/BriefTests.swift b/BriefTests/SemVerTests.swift similarity index 68% rename from BriefTests/BriefTests.swift rename to BriefTests/SemVerTests.swift index 02729fa..f7ee332 100644 --- a/BriefTests/BriefTests.swift +++ b/BriefTests/SemVerTests.swift @@ -27,6 +27,21 @@ class SemVerTests: XCTestCase { XCTAssert(current < new) } + func testRegularParsing() { + let current = SemVer("1.0.2") + XCTAssert(current.versionNumbers == [1, 0, 2]) + } + + func testNoPatch() { + let current = SemVer("1.1") + XCTAssert(current.versionNumbers == [1, 1, 0]) + } + + func testGarbage() { + let current = SemVer("Test") + XCTAssert(current.versionNumbers == [0, 0, 0]) + } + func testBeta() { let current = SemVer("1.0.2") let new = SemVer("1.1.0_beta1") diff --git a/Secretive.xcodeproj/project.pbxproj b/Secretive.xcodeproj/project.pbxproj index 81fd55d..b07e940 100644 --- a/Secretive.xcodeproj/project.pbxproj +++ b/Secretive.xcodeproj/project.pbxproj @@ -64,7 +64,7 @@ 508BF28E25B4F005009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF28D25B4F005009EFB7E /* InternetAccessPolicy.plist */; }; 508BF2AA25B4F1CB009EFB7E /* InternetAccessPolicy.plist in Resources */ = {isa = PBXBuildFile; fileRef = 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */; }; 5091D2BC25183B830049FD9B /* ApplicationDirectoryController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */; }; - 5091D3222519D56D0049FD9B /* BriefTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D3212519D56D0049FD9B /* BriefTests.swift */; }; + 5091D3222519D56D0049FD9B /* SemVerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5091D3212519D56D0049FD9B /* SemVerTests.swift */; }; 5091D3242519D56D0049FD9B /* Brief.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 506772FB2426F3F400034DED /* Brief.framework */; }; 5099A02423FD2AAA0062B6F2 /* CreateSecretView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */; }; 5099A02723FE34FA0062B6F2 /* SmartCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A02623FE34FA0062B6F2 /* SmartCard.swift */; }; @@ -75,6 +75,7 @@ 5099A07C240242BA0062B6F2 /* AgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A07B240242BA0062B6F2 /* AgentTests.swift */; }; 5099A07E240242BA0062B6F2 /* SecretAgentKit.h in Headers */ = {isa = PBXBuildFile; fileRef = 5099A06E240242BA0062B6F2 /* SecretAgentKit.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5099A08A240242C20062B6F2 /* SSHAgentProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5099A089240242C20062B6F2 /* SSHAgentProtocol.swift */; }; + 509FA3B625B53C49005E2535 /* ReleaseParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 509FA3B525B53C49005E2535 /* ReleaseParsingTests.swift */; }; 50A3B79124026B7600D209EA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79024026B7600D209EA /* Assets.xcassets */; }; 50A3B79424026B7600D209EA /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79324026B7600D209EA /* Preview Assets.xcassets */; }; 50A3B79724026B7600D209EA /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 50A3B79524026B7600D209EA /* Main.storyboard */; }; @@ -280,7 +281,7 @@ 508BF29425B4F140009EFB7E /* InternetAccessPolicy.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; name = InternetAccessPolicy.plist; path = SecretAgent/InternetAccessPolicy.plist; sourceTree = SOURCE_ROOT; }; 5091D2BB25183B830049FD9B /* ApplicationDirectoryController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationDirectoryController.swift; sourceTree = ""; }; 5091D31F2519D56D0049FD9B /* BriefTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BriefTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 5091D3212519D56D0049FD9B /* BriefTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BriefTests.swift; sourceTree = ""; }; + 5091D3212519D56D0049FD9B /* SemVerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SemVerTests.swift; sourceTree = ""; }; 5091D3232519D56D0049FD9B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5099A02323FD2AAA0062B6F2 /* CreateSecretView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateSecretView.swift; sourceTree = ""; }; 5099A02623FE34FA0062B6F2 /* SmartCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmartCard.swift; sourceTree = ""; }; @@ -294,6 +295,7 @@ 5099A07B240242BA0062B6F2 /* AgentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentTests.swift; sourceTree = ""; }; 5099A07D240242BA0062B6F2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 5099A089240242C20062B6F2 /* SSHAgentProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSHAgentProtocol.swift; sourceTree = ""; }; + 509FA3B525B53C49005E2535 /* ReleaseParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReleaseParsingTests.swift; sourceTree = ""; }; 50A3B78A24026B7500D209EA /* SecretAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SecretAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; 50A3B79024026B7600D209EA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 50A3B79324026B7600D209EA /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -565,7 +567,8 @@ 5091D3202519D56D0049FD9B /* BriefTests */ = { isa = PBXGroup; children = ( - 5091D3212519D56D0049FD9B /* BriefTests.swift */, + 5091D3212519D56D0049FD9B /* SemVerTests.swift */, + 509FA3B525B53C49005E2535 /* ReleaseParsingTests.swift */, 5091D3232519D56D0049FD9B /* Info.plist */, ); path = BriefTests; @@ -1073,7 +1076,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5091D3222519D56D0049FD9B /* BriefTests.swift in Sources */, + 509FA3B625B53C49005E2535 /* ReleaseParsingTests.swift in Sources */, + 5091D3222519D56D0049FD9B /* SemVerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };