Add Xcode Releases data source

This commit is contained in:
Brandon Evans 2020-12-24 16:09:00 -07:00
parent b342baff02
commit 293aef80e3
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
9 changed files with 237 additions and 25 deletions

View file

@ -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 = "<group>"; };
CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = "<group>"; };
CA9FF8652595130600E47BAF /* View+IsHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsHidden.swift"; sourceTree = "<group>"; };
CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+XcodeReleases.swift"; sourceTree = "<group>"; };
CA9FF87A2595293E00E47BAF /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = "<group>"; };
CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = "<group>"; };
CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = "<group>"; };
CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = "<group>"; };
@ -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;

View file

@ -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",

View file

@ -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<Void, Error> {
@ -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

View file

@ -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"
}
}
}

View file

@ -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() }
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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\
\

View file

@ -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 {