Merge pull request #19 from RobotsAndPencils/update

Update update
This commit is contained in:
Brandon Evans 2020-12-27 23:17:21 -07:00 committed by GitHub
commit 23db486ac6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 508 additions and 207 deletions

View file

@ -17,6 +17,12 @@
CA9FF83F2594FBC000E47BAF /* Licenses.rtf in Resources */ = {isa = PBXBuildFile; fileRef = CA9FF83E2594FBC000E47BAF /* Licenses.rtf */; };
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 */; };
CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF88025955C7000E47BAF /* AvailableXcode.swift */; };
CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8862595607900E47BAF /* InstalledXcode.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 */; };
@ -27,11 +33,11 @@
CABFA9BF2592EEEA00380FEE /* URLSession+Promise.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B32592EEEA00380FEE /* URLSession+Promise.swift */; };
CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A82592EEE900380FEE /* Version+.swift */; };
CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B02592EEEA00380FEE /* Promise+.swift */; };
CABFA9C32592EEEA00380FEE /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B92592EEEA00380FEE /* Models.swift */; };
CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B92592EEEA00380FEE /* Downloads.swift */; };
CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B82592EEEA00380FEE /* FileManager+.swift */; };
CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9B22592EEEA00380FEE /* Entry+.swift */; };
CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */; };
CABFA9CA2592EEEA00380FEE /* XcodeList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A72592EEE900380FEE /* XcodeList.swift */; };
CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A72592EEE900380FEE /* AppState+Update.swift */; };
CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AE2592EEE900380FEE /* Path+.swift */; };
CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9AC2592EEE900380FEE /* Foundation.swift */; };
CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = CABFA9A62592EEE900380FEE /* Version+Xcode.swift */; };
@ -80,6 +86,11 @@
CA9FF83E2594FBC000E47BAF /* Licenses.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Licenses.rtf; sourceTree = "<group>"; };
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>"; };
CA9FF88025955C7000E47BAF /* AvailableXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailableXcode.swift; sourceTree = "<group>"; };
CA9FF8862595607900E47BAF /* InstalledXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledXcode.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>"; };
@ -88,7 +99,7 @@
CABFA9A12592EAFB00380FEE /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = "<group>"; };
CABFA9A32592ED5700380FEE /* Apple.paw */ = {isa = PBXFileReference; lastKnownFileType = file; name = Apple.paw; path = ../xcodes/Apple.paw; sourceTree = "<group>"; };
CABFA9A62592EEE900380FEE /* Version+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+Xcode.swift"; sourceTree = "<group>"; };
CABFA9A72592EEE900380FEE /* XcodeList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeList.swift; sourceTree = "<group>"; };
CABFA9A72592EEE900380FEE /* AppState+Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Update.swift"; sourceTree = "<group>"; };
CABFA9A82592EEE900380FEE /* Version+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+.swift"; sourceTree = "<group>"; };
CABFA9A92592EEE900380FEE /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = "<group>"; };
CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+Apple.swift"; sourceTree = "<group>"; };
@ -99,7 +110,7 @@
CABFA9B32592EEEA00380FEE /* URLSession+Promise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Promise.swift"; sourceTree = "<group>"; };
CABFA9B42592EEEA00380FEE /* Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Process.swift; sourceTree = "<group>"; };
CABFA9B82592EEEA00380FEE /* FileManager+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+.swift"; sourceTree = "<group>"; };
CABFA9B92592EEEA00380FEE /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
CABFA9B92592EEEA00380FEE /* Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloads.swift; sourceTree = "<group>"; };
CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+.swift"; sourceTree = "<group>"; };
CABFA9D42592EF6300380FEE /* DECISIONS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DECISIONS.md; sourceTree = "<group>"; };
CABFAA2A2592FBFC00380FEE /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SettingsView.swift; path = Xcodes/SettingsView.swift; sourceTree = SOURCE_ROOT; };
@ -126,6 +137,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 */,
@ -197,22 +209,26 @@
isa = PBXGroup;
children = (
CA378F982466567600A58CE0 /* AppState.swift */,
CABFA9A72592EEE900380FEE /* AppState+Update.swift */,
CA9FF88025955C7000E47BAF /* AvailableXcode.swift */,
CABFAA2B2592FBFC00380FEE /* Configure.swift */,
CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */,
CA9FF87A2595293E00E47BAF /* DataSource.swift */,
CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */,
CABFA9B92592EEEA00380FEE /* Downloads.swift */,
CABFA9B22592EEEA00380FEE /* Entry+.swift */,
CABFA9A92592EEE900380FEE /* Environment.swift */,
CABFA9B82592EEEA00380FEE /* FileManager+.swift */,
CABFA9AC2592EEE900380FEE /* Foundation.swift */,
CABFA9B92592EEEA00380FEE /* Models.swift */,
CA9FF8862595607900E47BAF /* InstalledXcode.swift */,
CABFA9AE2592EEE900380FEE /* Path+.swift */,
CABFA9B42592EEEA00380FEE /* Process.swift */,
CABFA9B02592EEEA00380FEE /* Promise+.swift */,
CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */,
CABFA9B32592EEEA00380FEE /* URLSession+Promise.swift */,
CABFA9A82592EEE900380FEE /* Version+.swift */,
CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */,
CABFA9A62592EEE900380FEE /* Version+Xcode.swift */,
CABFA9A72592EEE900380FEE /* XcodeList.swift */,
);
path = Backend;
sourceTree = "<group>";
@ -225,6 +241,7 @@
CAA1CB50255A5D16003FD669 /* SignIn */,
CABFAA142592F73000380FEE /* XcodeList */,
CABFAA2A2592FBFC00380FEE /* SettingsView.swift */,
CA9FF8652595130600E47BAF /* View+IsHidden.swift */,
);
path = Frontend;
sourceTree = "<group>";
@ -323,6 +340,7 @@
CABFA9F22592F0E400380FEE /* PMKFoundation */,
CABFA9F72592F0F900380FEE /* KeychainAccess */,
CABFA9FC2592F13300380FEE /* LegibleError */,
CA9FF86C25951C6E00E47BAF /* XCModel */,
);
productName = XcodesMac;
productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */;
@ -382,6 +400,7 @@
CABFA9F12592F0E400380FEE /* XCRemoteSwiftPackageReference "Foundation" */,
CABFA9F62592F0F900380FEE /* XCRemoteSwiftPackageReference "KeychainAccess" */,
CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */,
CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */,
);
productRefGroup = CAD2E79F2449574E00113D76 /* Products */;
projectDirPath = "";
@ -441,23 +460,26 @@
files = (
CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */,
CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */,
CABFA9CA2592EEEA00380FEE /* XcodeList.swift in Sources */,
CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */,
CABFA9CA2592EEEA00380FEE /* AppState+Update.swift in Sources */,
CA44901F2463AD34003D8213 /* Tag.swift in Sources */,
CABFA9BF2592EEEA00380FEE /* URLSession+Promise.swift in Sources */,
CABFA9BB2592EEEA00380FEE /* DateFormatter+.swift in Sources */,
CABFA9BD2592EEEA00380FEE /* Environment.swift in Sources */,
CABFA9C32592EEEA00380FEE /* Models.swift in Sources */,
CABFA9C32592EEEA00380FEE /* Downloads.swift in Sources */,
CA378F992466567600A58CE0 /* AppState.swift in Sources */,
CAD2E7A42449574E00113D76 /* XcodeListView.swift in Sources */,
CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */,
CABFA9C52592EEEA00380FEE /* FileManager+.swift in Sources */,
CABFA9CD2592EEEA00380FEE /* Foundation.swift in Sources */,
CA9FF8872595607900E47BAF /* InstalledXcode.swift in Sources */,
CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */,
CABFA9C12592EEEA00380FEE /* Version+.swift in Sources */,
CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */,
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 */,
@ -465,6 +487,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 */,
@ -472,6 +495,7 @@
63EAA4EB259944450046AB8F /* ProgressButton.swift in Sources */,
CA5D781E257365D6008EDE9D /* PinCodeTextView.swift in Sources */,
CA39711924495F0E00AFFB77 /* AppStoreButtonStyle.swift in Sources */,
CA9FF88125955C7000E47BAF /* AvailableXcode.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -836,6 +860,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";
@ -895,6 +927,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

@ -0,0 +1,180 @@
import Combine
import Foundation
import Path
import Version
import SwiftSoup
import struct XCModel.Xcode
extension AppState {
private var dataSource: DataSource {
Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default
}
func updateIfNeeded() {
guard
let lastUpdated = Current.defaults.date(forKey: "lastUpdated"),
// This is bad date math but for this use case it doesn't need to be exact
lastUpdated < Current.date().addingTimeInterval(-60 * 60 * 24)
else { return }
update() as Void
}
func update() {
guard !isUpdating else { return }
updatePublisher = updateAvailableXcodes(from: self.dataSource)
.sink(
receiveCompletion: { [unowned self] completion in
switch completion {
case let .failure(error):
self.error = AlertContent(title: "Update Error", message: error.legibleLocalizedDescription)
case .finished:
Current.defaults.setDate(Current.date(), forKey: "lastUpdated")
}
self.updatePublisher = nil
},
receiveValue: { _ in }
)
}
private func updateAvailableXcodes(from dataSource: DataSource) -> AnyPublisher<[AvailableXcode], Error> {
switch dataSource {
case .apple:
return signInIfNeeded()
.flatMap { [unowned self] in self.releasedXcodes().combineLatest(self.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
return xcodes
}
.handleEvents(
receiveOutput: { xcodes in
self.availableXcodes = xcodes
try? self.cacheAvailableXcodes(xcodes)
}
)
.eraseToAnyPublisher()
case .xcodeReleases:
return xcodeReleases()
.receive(on: DispatchQueue.main)
.handleEvents(
receiveOutput: { xcodes in
self.availableXcodes = xcodes
try? self.cacheAvailableXcodes(xcodes)
}
)
.eraseToAnyPublisher()
}
}
}
extension AppState {
// MARK: - Available Xcode Cache
func loadCachedAvailableXcodes() throws {
guard let data = Current.files.contents(atPath: Path.cacheFile.string) else { return }
let xcodes = try JSONDecoder().decode([AvailableXcode].self, from: data)
self.availableXcodes = xcodes
}
func cacheAvailableXcodes(_ xcodes: [AvailableXcode]) throws {
let data = try JSONEncoder().encode(xcodes)
try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: Path.cacheFile.url)
}
}
extension AppState {
// MARK: - Apple
private func releasedXcodes() -> AnyPublisher<[AvailableXcode], Error> {
Current.network.dataTask(with: URLRequest.downloads)
.map(\.data)
.decode(type: Downloads.self, decoder: configure(JSONDecoder()) {
$0.dateDecodingStrategy = .formatted(.downloadsDateModified)
})
.map { downloads -> [AvailableXcode] in
let xcodes = downloads
.downloads
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
.compactMap { download -> AvailableXcode? in
let urlPrefix = URL(string: "https://download.developer.apple.com/")!
guard
let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }),
let version = Version(xcodeVersion: download.name)
else { return nil }
let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath)
return AvailableXcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified)
}
return xcodes
}
.eraseToAnyPublisher()
}
private func prereleaseXcodes() -> AnyPublisher<[AvailableXcode], Error> {
Current.network.dataTask(with: URLRequest.download)
.tryMap { (data, _) -> [AvailableXcode] in
try self.parsePrereleaseXcodes(from: data)
}
.eraseToAnyPublisher()
}
private func parsePrereleaseXcodes(from data: Data) throws -> [AvailableXcode] {
let body = String(data: data, encoding: .utf8)!
let document = try SwiftSoup.parse(body)
guard
let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(),
let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""),
let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""),
let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion),
let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"),
let url = URL(string: "https://developer.apple.com" + path)
else { return [] }
let filename = String(path.suffix(fromLast: "/"))
return [AvailableXcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))]
}
}
extension AppState {
// MARK: - XcodeReleases
private func xcodeReleases() -> AnyPublisher<[AvailableXcode], 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 -> AvailableXcode? 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 AvailableXcode(
version: version,
url: downloadURL,
filename: String(downloadURL.path.suffix(fromLast: "/")),
releaseDate: releaseDate
)
}
return xcodes
}
.eraseToAnyPublisher()
}
}

View file

@ -2,23 +2,33 @@ import AppKit
import AppleAPI
import Combine
import Path
import PromiseKit
import LegibleError
import KeychainAccess
import SwiftUI
class AppState: ObservableObject {
private let list = XcodeList()
private let client = AppleAPI.Client()
private var cancellables = Set<AnyCancellable>()
@Published var authenticationState: AuthenticationState = .unauthenticated
@Published var allVersions: [XcodeVersion] = []
@Published var availableXcodes: [AvailableXcode] = [] {
willSet {
updateAllVersions(newValue)
}
}
var allVersions: [XcodeVersion] = []
@Published var updatePublisher: AnyCancellable?
var isUpdating: Bool { updatePublisher != nil }
@Published var error: AlertContent?
@Published var authError: AlertContent?
@Published var presentingSignInAlert = false
@Published var isProcessingAuthRequest = false
@Published var secondFactorData: SecondFactorData?
init() {
try? loadCachedAvailableXcodes()
}
// MARK: - Authentication
func validateSession() -> AnyPublisher<Void, Error> {
@ -159,48 +169,29 @@ class AppState: ObservableObject {
authenticationState = .unauthenticated
}
// MARK: - Load Xcode Versions
// MARK: -
func update() {
update()
.sink(
receiveCompletion: { _ in },
receiveValue: { _ in }
)
.store(in: &cancellables)
func install(id: String) {
// TODO:
}
public func update() -> AnyPublisher<[Xcode], Never> {
signInIfNeeded()
.flatMap {
// Wrap the Promise API in a Publisher for now
Deferred {
Future { promise in
self.list.update()
.done { promise(.success($0)) }
.catch { promise(.failure($0)) }
}
}
.handleEvents(
receiveCompletion: { completion in
if case let .failure(error) = completion {
self.error = AlertContent(title: "Update Error", message: error.legibleLocalizedDescription)
}
}
)
}
.catch { _ in
Just(self.list.availableXcodes)
}
.handleEvents(
receiveOutput: { [unowned self] xcodes in
self.updateAllVersions(xcodes)
}
)
.eraseToAnyPublisher()
func uninstall(id: String) {
// TODO:
}
private func updateAllVersions(_ xcodes: [Xcode]) {
func reveal(id: String) {
// TODO: show error if not
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return }
NSWorkspace.shared.activateFileViewerSelecting([installedXcode.path.url])
}
func select(id: String) {
// TODO:
}
// MARK: - Private
private func updateAllVersions(_ xcodes: [AvailableXcode]) {
let installedXcodes = Current.files.installedXcodes(Path.root/"Applications")
var allXcodeVersions = xcodes.map { $0.version }
for installedXcode in installedXcodes {
@ -232,26 +223,10 @@ class AppState: ObservableObject {
}
}
func install(id: String) {
// TODO:
}
func uninstall(id: String) {
// TODO:
}
func reveal(id: String) {
// TODO: show error if not
guard let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.xcodeDescription == id }) else { return }
NSWorkspace.shared.activateFileViewerSelecting([installedXcode.path.url])
}
func select(id: String) {
// TODO:
}
// MARK: - Nested Types
/// A merging of AvailableXcode and InstalledXcode prepared for display
struct XcodeVersion: Identifiable {
let title: String
let installState: InstallState

View file

@ -0,0 +1,17 @@
import Foundation
import Version
/// A version of Xcode that's available for installation
public struct AvailableXcode: Codable {
public let version: Version
public let url: URL
public let filename: String
public let releaseDate: Date?
public init(version: Version, url: URL, filename: String, releaseDate: Date?) {
self.version = version
self.url = url
self.filename = filename
self.releaseDate = releaseDate
}
}

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

@ -0,0 +1,17 @@
import Foundation
import Path
import Version
struct Downloads: Codable {
let downloads: [Download]
}
public struct Download: Codable {
public let name: String
public let files: [File]
public let dateModified: Date
public struct File: Codable {
public let remotePath: String
}
}

View file

@ -19,6 +19,7 @@ public struct Environment {
public var logging = Logging()
public var keychain = Keychain()
public var defaults = Defaults()
public var date: () -> Date = Date.init
}
public var Current = Environment()
@ -111,10 +112,10 @@ private func _installedXcodes(destination: Path) -> [InstalledXcode] {
public struct Network {
private static let client = AppleAPI.Client()
public var dataTask: (URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> = { AppleAPI.Current.network.session.dataTask(.promise, with: $0) }
public func dataTask(with convertible: URLRequestConvertible) -> Promise<(data: Data, response: URLResponse)> {
dataTask(convertible)
public var dataTask: (URLRequest) -> URLSession.DataTaskPublisher = { AppleAPI.Current.network.session.dataTaskPublisher(for: $0) }
public func dataTask(with request: URLRequest) -> URLSession.DataTaskPublisher {
dataTask(request)
}
public var downloadTask: (URLRequestConvertible, URL, Data?) -> (Progress, Promise<(saveLocation: URL, response: URLResponse)>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) }
@ -153,6 +154,16 @@ public struct Defaults {
string(key)
}
public var date: (String) -> Date? = { Date(timeIntervalSince1970: UserDefaults.standard.double(forKey: $0)) }
public func date(forKey key: String) -> Date? {
date(key)
}
public var setDate: (Date?, String) -> Void = { UserDefaults.standard.set($0?.timeIntervalSince1970, forKey: $1) }
public func setDate(_ value: Date?, forKey key: String) {
setDate(value, key)
}
public var set: (Any?, String) -> Void = { UserDefaults.standard.set($0, forKey: $1) }
public func set(_ value: Any?, forKey key: String) {
set(value, key)

View file

@ -1,7 +1,8 @@
import Foundation
import Path
import Version
import Path
/// A version of Xcode that's already installed
public struct InstalledXcode: Equatable {
public let path: Path
/// Composed of the bundle short version from Info.plist and the product build version from version.plist
@ -39,34 +40,6 @@ public struct InstalledXcode: Equatable {
}
}
public struct Xcode: Codable {
public let version: Version
public let url: URL
public let filename: String
public let releaseDate: Date?
public init(version: Version, url: URL, filename: String, releaseDate: Date?) {
self.version = version
self.url = url
self.filename = filename
self.releaseDate = releaseDate
}
}
struct Downloads: Codable {
let downloads: [Download]
}
public struct Download: Codable {
public let name: String
public let files: [File]
public let dateModified: Date
public struct File: Codable {
public let remotePath: String
}
}
public struct InfoPlist: Decodable {
public let bundleID: String?
public let bundleShortVersion: String?
@ -86,4 +59,3 @@ public struct VersionPlist: Decodable {
case productBuildVersion = "ProductBuildVersion"
}
}

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

@ -1,103 +0,0 @@
import Foundation
import Path
import Version
import PromiseKit
import SwiftSoup
/// Provides lists of available and installed Xcodes
public final class XcodeList {
public init() {
try? loadCachedAvailableXcodes()
}
public private(set) var availableXcodes: [Xcode] = []
public var shouldUpdate: Bool {
return availableXcodes.isEmpty
}
public func update() -> Promise<[Xcode]> {
return when(fulfilled: releasedXcodes(), prereleaseXcodes())
.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
}
}
}
extension XcodeList {
private func loadCachedAvailableXcodes() throws {
guard let data = Current.files.contents(atPath: Path.cacheFile.string) else { return }
let xcodes = try JSONDecoder().decode([Xcode].self, from: data)
self.availableXcodes = xcodes
}
private func cacheAvailableXcodes(_ xcodes: [Xcode]) throws {
let data = try JSONEncoder().encode(xcodes)
try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: Path.cacheFile.url)
}
}
extension XcodeList {
private func releasedXcodes() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.downloads)
}
.map { (data, response) -> [Xcode] in
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(.downloadsDateModified)
let downloads = try decoder.decode(Downloads.self, from: data)
let xcodes = downloads
.downloads
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
.compactMap { download -> Xcode? in
let urlPrefix = URL(string: "https://download.developer.apple.com/")!
guard
let xcodeFile = download.files.first(where: { $0.remotePath.hasSuffix("dmg") || $0.remotePath.hasSuffix("xip") }),
let version = Version(xcodeVersion: download.name)
else { return nil }
let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath)
return Xcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified)
}
return xcodes
}
}
private func prereleaseXcodes() -> Promise<[Xcode]> {
return firstly { () -> Promise<(data: Data, response: URLResponse)> in
Current.network.dataTask(with: URLRequest.download)
}
.map { (data, _) -> [Xcode] in
try self.parsePrereleaseXcodes(from: data)
}
}
func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] {
let body = String(data: data, encoding: .utf8)!
let document = try SwiftSoup.parse(body)
guard
let xcodeHeader = try document.select("h2:containsOwn(Xcode)").first(),
let productBuildVersion = try xcodeHeader.parent()?.select("li:contains(Build)").text().replacingOccurrences(of: "Build", with: ""),
let releaseDateString = try xcodeHeader.parent()?.select("li:contains(Released)").text().replacingOccurrences(of: "Released", with: ""),
let version = Version(xcodeVersion: try xcodeHeader.text(), buildMetadataIdentifier: productBuildVersion),
let path = try document.select(".direct-download[href*=xip]").first()?.attr("href"),
let url = URL(string: "https://developer.apple.com" + path)
else { return [] }
let filename = String(path.suffix(fromLast: "/"))
return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))]
}
}

View file

@ -0,0 +1,24 @@
import SwiftUI
extension View {
@ViewBuilder
func isHidden(_ isHidden: Bool) -> some View {
if isHidden {
self.hidden()
} else {
self
}
}
}
struct View_IsHidden_Previews: PreviewProvider {
static var previews: some View {
Group {
Text("Not Hidden")
.isHidden(false)
Text("Hidden")
.isHidden(true)
}
}
}

View file

@ -7,6 +7,7 @@ struct XcodeListView: View {
@State private var selection = Set<String>()
@State private var rowBeingConfirmedForUninstallation: AppState.XcodeVersion?
@State private var searchText: String = ""
@AppStorage("lastUpdated") private var lastUpdated: Double?
@AppStorage("xcodeListCategory") private var category: Category = .all
@ -79,9 +80,16 @@ struct XcodeListView: View {
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: appState.update) {
Image(systemName: "arrow.clockwise")
Label("Refresh", systemImage: "arrow.clockwise")
}
.keyboardShortcut(KeyEquivalent("r"))
.disabled(appState.isUpdating)
.isHidden(appState.isUpdating)
.overlay(
ProgressView()
.scaleEffect(0.5, anchor: .center)
.isHidden(!appState.isUpdating)
)
}
ToolbarItem(placement: .principal) {
Picker("", selection: $category) {
@ -97,9 +105,8 @@ struct XcodeListView: View {
.frame(width: 200)
}
}
.navigationSubtitle(Text("Updated \(Date().addingTimeInterval(-600), style: .relative) ago"))
.navigationSubtitle(subtitleText)
.frame(minWidth: 200, maxWidth: .infinity, minHeight: 300, maxHeight: .infinity)
.onAppear(perform: appState.update)
.alert(item: $appState.error) { error in
Alert(title: Text(error.title),
message: Text(verbatim: error.message),
@ -131,6 +138,14 @@ struct XcodeListView: View {
SignInPhoneListView(isPresented: $appState.secondFactorData.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
}
}
private var subtitleText: Text {
if let lastUpdated = lastUpdated.map(Date.init(timeIntervalSince1970:)) {
return Text("Updated at \(lastUpdated, style: .date) \(lastUpdated, style: .time)")
} else {
return Text("")
}
}
}
struct XcodeListView_Previews: PreviewProvider {

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 {

View file

@ -4,12 +4,22 @@ import AppKit
@main
struct XcodesApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate: AppDelegate
@SwiftUI.Environment(\.scenePhase) private var scenePhase: ScenePhase
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup("Xcodes") {
XcodeListView()
.environmentObject(appState)
// This is intentionally used on a View, and not on a WindowGroup,
// so that it's triggered when an individual window's phase changes instead of all window phases.
// When used on a View it's also invoked on launch, which doesn't occur with a WindowGroup.
// FB8954581 ScenePhase read from App doesn't return a value on launch
.onChange(of: scenePhase) { newScenePhase in
if case .active = newScenePhase {
appState.updateIfNeeded()
}
}
}
.commands {
CommandGroup(replacing: .appInfo) {
@ -17,6 +27,13 @@ struct XcodesApp: App {
appDelegate.showAboutWindow()
}
}
CommandGroup(after: CommandGroupPlacement.newItem) {
Button("Refresh") {
appState.update()
}
.keyboardShortcut(KeyEquivalent("r"))
.disabled(appState.isUpdating)
}
}
Settings {
@ -53,6 +70,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
/// WindowGroup lets the user open more than one window right now, which is a little strange for an About window.
/// (It's also weird that the main Xcode list window can be opened more than once, there should only be one.)
/// To work around this, an AppDelegate holds onto a single instance of an NSWindow that is shown here.
/// FB8954588 Scene / WindowGroup is missing API to limit the number of windows that can be created
func showAboutWindow() {
aboutWindow.center()
aboutWindow.makeKeyAndOrderFront(nil)