mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
commit
23db486ac6
17 changed files with 508 additions and 207 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
180
Xcodes/Backend/AppState+Update.swift
Normal file
180
Xcodes/Backend/AppState+Update.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
17
Xcodes/Backend/AvailableXcode.swift
Normal file
17
Xcodes/Backend/AvailableXcode.swift
Normal 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
|
||||
}
|
||||
}
|
||||
17
Xcodes/Backend/DataSource.swift
Normal file
17
Xcodes/Backend/DataSource.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
Xcodes/Backend/Downloads.swift
Normal file
17
Xcodes/Backend/Downloads.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
Xcodes/Backend/Version+XcodeReleases.swift
Normal file
51
Xcodes/Backend/Version+XcodeReleases.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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))]
|
||||
}
|
||||
}
|
||||
24
Xcodes/Frontend/View+IsHidden.swift
Normal file
24
Xcodes/Frontend/View+IsHidden.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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\
|
||||
\
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue