From 293aef80e3303078a20a6a7b6f05c00bb2675c83 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 24 Dec 2020 16:09:00 -0700 Subject: [PATCH] Add Xcode Releases data source --- Xcodes.xcodeproj/project.pbxproj | 25 ++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ Xcodes/Backend/AppState.swift | 9 +- Xcodes/Backend/DataSource.swift | 17 ++++ Xcodes/Backend/Version+.swift | 4 +- Xcodes/Backend/Version+XcodeReleases.swift | 51 +++++++++++ Xcodes/Backend/XcodeList.swift | 86 ++++++++++++++----- Xcodes/Resources/Licenses.rtf | 27 ++++++ Xcodes/SettingsView.swift | 34 ++++++++ 9 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 Xcodes/Backend/DataSource.swift create mode 100644 Xcodes/Backend/Version+XcodeReleases.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index cdada1e..1dae143 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */; }; CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */; }; CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8652595130600E47BAF /* View+IsHidden.swift */; }; + CA9FF86D25951C6E00E47BAF /* XCModel in Frameworks */ = {isa = PBXBuildFile; productRef = CA9FF86C25951C6E00E47BAF /* XCModel */; }; + CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */; }; + CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF87A2595293E00E47BAF /* DataSource.swift */; }; CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; }; @@ -82,6 +85,8 @@ CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingTextView.swift; sourceTree = ""; }; CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; CA9FF8652595130600E47BAF /* View+IsHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsHidden.swift"; sourceTree = ""; }; + CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+XcodeReleases.swift"; sourceTree = ""; }; + CA9FF87A2595293E00E47BAF /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = ""; }; CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = ""; }; CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = ""; }; @@ -128,6 +133,7 @@ CABFA9E42592F08E00380FEE /* Version in Frameworks */, CABFA9E92592F0B400380FEE /* PromiseKit in Frameworks */, CABFA9FD2592F13300380FEE /* LegibleError in Frameworks */, + CA9FF86D25951C6E00E47BAF /* XCModel in Frameworks */, CABFA9F82592F0F900380FEE /* KeychainAccess in Frameworks */, CABFA9F32592F0E400380FEE /* PMKFoundation in Frameworks */, CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */, @@ -201,6 +207,7 @@ CA378F982466567600A58CE0 /* AppState.swift */, CABFAA2B2592FBFC00380FEE /* Configure.swift */, CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */, + CA9FF87A2595293E00E47BAF /* DataSource.swift */, CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */, CABFA9B22592EEEA00380FEE /* Entry+.swift */, CABFA9A92592EEE900380FEE /* Environment.swift */, @@ -213,6 +220,7 @@ CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */, CABFA9B32592EEEA00380FEE /* URLSession+Promise.swift */, CABFA9A82592EEE900380FEE /* Version+.swift */, + CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */, CABFA9A62592EEE900380FEE /* Version+Xcode.swift */, CABFA9A72592EEE900380FEE /* XcodeList.swift */, ); @@ -326,6 +334,7 @@ CABFA9F22592F0E400380FEE /* PMKFoundation */, CABFA9F72592F0F900380FEE /* KeychainAccess */, CABFA9FC2592F13300380FEE /* LegibleError */, + CA9FF86C25951C6E00E47BAF /* XCModel */, ); productName = XcodesMac; productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */; @@ -385,6 +394,7 @@ CABFA9F12592F0E400380FEE /* XCRemoteSwiftPackageReference "Foundation" */, CABFA9F62592F0F900380FEE /* XCRemoteSwiftPackageReference "KeychainAccess" */, CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */, + CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */, ); productRefGroup = CAD2E79F2449574E00113D76 /* Products */; projectDirPath = ""; @@ -462,6 +472,7 @@ CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, + CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */, @@ -469,6 +480,7 @@ CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */, + CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */, CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */, CABFAA432593104F00380FEE /* AboutView.swift in Sources */, CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */, @@ -840,6 +852,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/xcodereleases/data"; + requirement = { + kind = revision; + revision = b47228c688b608e34b3b84079ab6052a24c7a981; + }; + }; CABFA9DD2592F07A00380FEE /* XCRemoteSwiftPackageReference "Path" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mxcl/Path.swift"; @@ -899,6 +919,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + CA9FF86C25951C6E00E47BAF /* XCModel */ = { + isa = XCSwiftPackageProductDependency; + package = CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */; + productName = XCModel; + }; CAA1CB2C255A5262003FD669 /* AppleAPI */ = { isa = XCSwiftPackageProductDependency; productName = AppleAPI; diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ee46f5..fba9785 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "XcodeReleases", + "repositoryURL": "https://github.com/xcodereleases/data", + "state": { + "branch": null, + "revision": "b47228c688b608e34b3b84079ab6052a24c7a981", + "version": null + } + }, { "package": "PMKFoundation", "repositoryURL": "https://github.com/PromiseKit/Foundation", diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 0f62038..e9e36bd 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -4,6 +4,7 @@ import Combine import Path import LegibleError import KeychainAccess +import SwiftUI class AppState: ObservableObject { private let list = XcodeList() @@ -20,6 +21,10 @@ class AppState: ObservableObject { @Published var isProcessingAuthRequest = false @Published var secondFactorData: SecondFactorData? + private var dataSource: DataSource { + Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default + } + // MARK: - Authentication func validateSession() -> AnyPublisher { @@ -185,8 +190,8 @@ class AppState: ObservableObject { private func update() -> AnyPublisher<[Xcode], Never> { signInIfNeeded() - .flatMap { - self.list.update() + .flatMap { [unowned self] in + self.list.update(dataSource: self.dataSource) } .handleEvents( receiveCompletion: { completion in diff --git a/Xcodes/Backend/DataSource.swift b/Xcodes/Backend/DataSource.swift new file mode 100644 index 0000000..69363ae --- /dev/null +++ b/Xcodes/Backend/DataSource.swift @@ -0,0 +1,17 @@ +import Foundation + +public enum DataSource: String, CaseIterable, Identifiable, CustomStringConvertible { + case apple + case xcodeReleases + + public var id: Self { self } + + public static var `default` = DataSource.xcodeReleases + + public var description: String { + switch self { + case .apple: return "Apple" + case .xcodeReleases: return "Xcode Releases" + } + } +} diff --git a/Xcodes/Backend/Version+.swift b/Xcodes/Backend/Version+.swift index c4ecec3..5fed036 100644 --- a/Xcodes/Backend/Version+.swift +++ b/Xcodes/Backend/Version+.swift @@ -19,13 +19,13 @@ public extension Version { return major == installed.major && minor == installed.minor && patch == installed.patch && - prereleaseIdentifiers == installed.prereleaseIdentifiers + prereleaseIdentifiers.map { $0.lowercased() } == installed.prereleaseIdentifiers.map { $0.lowercased() } } else { return major == installed.major && minor == installed.minor && patch == installed.patch && - prereleaseIdentifiers == installed.prereleaseIdentifiers && + prereleaseIdentifiers.map { $0.lowercased() } == installed.prereleaseIdentifiers.map { $0.lowercased() } && buildMetadataIdentifiers.map { $0.lowercased() } == installed.buildMetadataIdentifiers.map { $0.lowercased() } } } diff --git a/Xcodes/Backend/Version+XcodeReleases.swift b/Xcodes/Backend/Version+XcodeReleases.swift new file mode 100644 index 0000000..e13661f --- /dev/null +++ b/Xcodes/Backend/Version+XcodeReleases.swift @@ -0,0 +1,51 @@ +import Version +import struct XCModel.Xcode + +extension Version { + /// Initialize a Version from an XcodeReleases' XCModel.Xcode + /// + /// This is kinda quick-and-dirty, and it would probably be better for us to adopt something closer to XCModel.Xcode under the hood and map the scraped data to it instead. + init?(xcReleasesXcode: XCModel.Xcode) { + var versionString = xcReleasesXcode.version.number ?? "" + + // Append trailing ".0" in order to get a fully-specified version string + let components = versionString.components(separatedBy: ".") + versionString += Array(repeating: ".0", count: 3 - components.count).joined() + + // Append prerelease identifier + switch xcReleasesXcode.version.release { + case let .beta(beta): + versionString += "-Beta" + if beta > 1 { + versionString += ".\(beta)" + } + case let .dp(dp): + versionString += "-DP" + if dp > 1 { + versionString += ".\(dp)" + } + case .gm: + versionString += "-GM" + case let .gmSeed(gmSeed): + versionString += "-GM.Seed" + if gmSeed > 1 { + versionString += ".\(gmSeed)" + } + case let .rc(rc): + versionString += "-Release.Candidate" + if rc > 1 { + versionString += ".\(rc)" + } + case .release: + break + } + + // Append build identifier + if let buildNumber = xcReleasesXcode.version.build { + versionString += "+\(buildNumber)" + } + + self.init(versionString) + } +} + diff --git a/Xcodes/Backend/XcodeList.swift b/Xcodes/Backend/XcodeList.swift index 25967d6..317b5c1 100644 --- a/Xcodes/Backend/XcodeList.swift +++ b/Xcodes/Backend/XcodeList.swift @@ -3,6 +3,7 @@ import Foundation import Path import Version import SwiftSoup +import struct XCModel.Xcode /// Provides lists of available Xcodes public final class XcodeList { @@ -12,26 +13,34 @@ public final class XcodeList { public private(set) var availableXcodes: [Xcode] = [] - public var shouldUpdate: Bool { - return availableXcodes.isEmpty - } - - public func update() -> AnyPublisher<[Xcode], Error> { - releasedXcodes().combineLatest(prereleaseXcodes()) - .receive(on: DispatchQueue.main) - .map { releasedXcodes, prereleaseXcodes in - // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. - // Previously pre-release versions only appeared on developer.apple.com/download. - // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. - // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. - let xcodes = releasedXcodes.filter { releasedXcode in - prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false - } + prereleaseXcodes - self.availableXcodes = xcodes - try? self.cacheAvailableXcodes(xcodes) - return xcodes - } - .eraseToAnyPublisher() + public func update(dataSource: DataSource) -> AnyPublisher<[Xcode], Error> { + switch dataSource { + case .apple: + return releasedXcodes().combineLatest(prereleaseXcodes()) + .receive(on: DispatchQueue.main) + .map { releasedXcodes, prereleaseXcodes in + // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. + // Previously pre-release versions only appeared on developer.apple.com/download. + // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. + // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. + let xcodes = releasedXcodes.filter { releasedXcode in + prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false + } + prereleaseXcodes + self.availableXcodes = xcodes + try? self.cacheAvailableXcodes(xcodes) + return xcodes + } + .eraseToAnyPublisher() + case .xcodeReleases: + return xcodeReleases() + .receive(on: DispatchQueue.main) + .handleEvents( + receiveOutput: { xcodes in + try? self.cacheAvailableXcodes(xcodes) + } + ) + .eraseToAnyPublisher() + } } } @@ -51,6 +60,8 @@ extension XcodeList { } extension XcodeList { + // MARK: Apple + private func releasedXcodes() -> AnyPublisher<[Xcode], Error> { Current.network.dataTask(with: URLRequest.downloads) .map(\.data) @@ -84,7 +95,7 @@ extension XcodeList { .eraseToAnyPublisher() } - func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] { + private func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] { let body = String(data: data, encoding: .utf8)! let document = try SwiftSoup.parse(body) @@ -102,3 +113,36 @@ extension XcodeList { return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))] } } + +extension XcodeList { + // MARK: XcodeReleases + + private func xcodeReleases() -> AnyPublisher<[Xcode], Error> { + Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!)) + .map(\.data) + .decode(type: [XCModel.Xcode].self, decoder: JSONDecoder()) + .map { xcReleasesXcodes in + let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> Xcode? in + guard + let downloadURL = xcReleasesXcode.links?.download?.url, + let version = Version(xcReleasesXcode: xcReleasesXcode) + else { return nil } + + let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( + year: xcReleasesXcode.date.year, + month: xcReleasesXcode.date.month, + day: xcReleasesXcode.date.day + )) + + return Xcode( + version: version, + url: downloadURL, + filename: String(downloadURL.path.suffix(fromLast: "/")), + releaseDate: releaseDate + ) + } + return xcodes + } + .eraseToAnyPublisher() + } +} diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index f9dca46..f140dc0 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -360,6 +360,33 @@ SOFTWARE.\ \ \ +\fs34 data\ +\ + +\fs26 MIT License\ +\ +Copyright (c) 2018 \ +\ +Permission is hereby granted, free of charge, to any person obtaining a copy\ +of this software and associated documentation files (the "Software"), to deal\ +in the Software without restriction, including without limitation the rights\ +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ +copies of the Software, and to permit persons to whom the Software is\ +furnished to do so, subject to the following conditions:\ +\ +The above copyright notice and this permission notice shall be included in all\ +copies or substantial portions of the Software.\ +\ +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\ +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\ +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\ +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\ +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\ +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\ +SOFTWARE.\ +\ +\ + \fs34 LegibleError\ \ diff --git a/Xcodes/SettingsView.swift b/Xcodes/SettingsView.swift index 373561f..5ff2646 100644 --- a/Xcodes/SettingsView.swift +++ b/Xcodes/SettingsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct SettingsView: View { @EnvironmentObject var appState: AppState + @AppStorage("dataSource") var dataSource: DataSource = .xcodeReleases var body: some View { VStack(alignment: .leading) { @@ -21,6 +22,22 @@ struct SettingsView: View { } .frame(maxWidth: .infinity, alignment: .leading) } + + GroupBox(label: Text("Data Source")) { + VStack(alignment: .leading) { + Picker("Data Source", selection: $dataSource) { + ForEach(DataSource.allCases) { dataSource in + Text(dataSource.description) + .tag(dataSource) + } + } + .labelsHidden() + + AttributedText(dataSourceFootnote) + .font(.footnote) + } + .frame(maxWidth: .infinity, alignment: .leading) + } Spacer() } .padding() @@ -28,6 +45,23 @@ struct SettingsView: View { .frame(width: 300) .frame(minHeight: 300) } + + private var dataSourceFootnote: NSAttributedString { + let string = """ + The Apple data source scrapes the Apple Developer website. It will always show the latest releases that are available, but is more fragile. + + Xcode Releases is an unofficial list of Xcode releases. It's provided as well-formed data, contains extra information that is not readily available from Apple, and is less likely to break if Apple redesigns their developer website. + """ + let attributedString = NSMutableAttributedString( + string: string, + attributes: [ + .font: NSFont.preferredFont(forTextStyle: .footnote, options: [:]), + .foregroundColor: NSColor.labelColor + ] + ) + attributedString.addAttribute(.link, value: URL(string: "https://xcodereleases.com")!, range: NSRange(string.range(of: "Xcode Releases")!, in: string)) + return attributedString + } } struct SettingsView_Previews: PreviewProvider {