Merge XcodeList into AppState

This commit is contained in:
Brandon Evans 2020-12-24 16:51:57 -07:00
parent f06d72f3eb
commit 33b5f96ed2
No known key found for this signature in database
GPG key ID: D58A4B8DB64F8E93
3 changed files with 95 additions and 92 deletions

View file

@ -37,7 +37,7 @@
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 */; };
@ -99,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>"; };
@ -209,6 +209,7 @@
isa = PBXGroup;
children = (
CA378F982466567600A58CE0 /* AppState.swift */,
CABFA9A72592EEE900380FEE /* AppState+Update.swift */,
CA9FF88025955C7000E47BAF /* AvailableXcode.swift */,
CABFAA2B2592FBFC00380FEE /* Configure.swift */,
CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */,
@ -228,7 +229,6 @@
CABFA9A82592EEE900380FEE /* Version+.swift */,
CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */,
CABFA9A62592EEE900380FEE /* Version+Xcode.swift */,
CABFA9A72592EEE900380FEE /* XcodeList.swift */,
);
path = Backend;
sourceTree = "<group>";
@ -461,7 +461,7 @@
CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */,
CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */,
CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */,
CABFA9CA2592EEEA00380FEE /* XcodeList.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 */,

View file

@ -5,15 +5,47 @@ import Version
import SwiftSoup
import struct XCModel.Xcode
/// Provides lists of available Xcodes
public final class XcodeList {
public init() {
try? loadCachedAvailableXcodes()
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
}
public private(set) var availableXcodes: [Xcode] = []
func update() {
guard !isUpdating else { return }
updatePublisher = update()
.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")
}
public func update(dataSource: DataSource) -> AnyPublisher<[Xcode], Error> {
self.updatePublisher = nil
},
receiveValue: { _ in }
)
}
private func update() -> AnyPublisher<[AvailableXcode], Error> {
signInIfNeeded()
.flatMap { [unowned self] in
self.updateAvailableXcodes(from: self.dataSource)
}
.eraseToAnyPublisher()
}
private func updateAvailableXcodes(from dataSource: DataSource) -> AnyPublisher<[AvailableXcode], Error> {
switch dataSource {
case .apple:
return releasedXcodes().combineLatest(prereleaseXcodes())
@ -26,16 +58,21 @@ public final class XcodeList {
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
}
.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)
}
)
@ -44,14 +81,16 @@ public final class XcodeList {
}
}
extension XcodeList {
private func loadCachedAvailableXcodes() throws {
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([Xcode].self, from: data)
let xcodes = try JSONDecoder().decode([AvailableXcode].self, from: data)
self.availableXcodes = xcodes
}
private func cacheAvailableXcodes(_ xcodes: [Xcode]) throws {
func cacheAvailableXcodes(_ xcodes: [AvailableXcode]) throws {
let data = try JSONEncoder().encode(xcodes)
try FileManager.default.createDirectory(at: Path.cacheFile.url.deletingLastPathComponent(),
withIntermediateDirectories: true)
@ -59,20 +98,20 @@ extension XcodeList {
}
}
extension XcodeList {
// MARK: Apple
extension AppState {
// MARK: - Apple
private func releasedXcodes() -> AnyPublisher<[Xcode], Error> {
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 -> [Xcode] in
.map { downloads -> [AvailableXcode] in
let xcodes = downloads
.downloads
.filter { $0.name.range(of: "^Xcode [0-9]", options: .regularExpression) != nil }
.compactMap { download -> Xcode? in
.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") }),
@ -80,22 +119,22 @@ extension XcodeList {
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 AvailableXcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified)
}
return xcodes
}
.eraseToAnyPublisher()
}
private func prereleaseXcodes() -> AnyPublisher<[Xcode], Error> {
private func prereleaseXcodes() -> AnyPublisher<[AvailableXcode], Error> {
Current.network.dataTask(with: URLRequest.download)
.tryMap { (data, _) -> [Xcode] in
.tryMap { (data, _) -> [AvailableXcode] in
try self.parsePrereleaseXcodes(from: data)
}
.eraseToAnyPublisher()
}
private func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] {
private func parsePrereleaseXcodes(from data: Data) throws -> [AvailableXcode] {
let body = String(data: data, encoding: .utf8)!
let document = try SwiftSoup.parse(body)
@ -110,19 +149,19 @@ extension XcodeList {
let filename = String(path.suffix(fromLast: "/"))
return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))]
return [AvailableXcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))]
}
}
extension XcodeList {
// MARK: XcodeReleases
extension AppState {
// MARK: - XcodeReleases
private func xcodeReleases() -> AnyPublisher<[Xcode], Error> {
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 -> Xcode? in
let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> AvailableXcode? in
guard
let downloadURL = xcReleasesXcode.links?.download?.url,
let version = Version(xcReleasesXcode: xcReleasesXcode)
@ -134,7 +173,7 @@ extension XcodeList {
day: xcReleasesXcode.date.day
))
return Xcode(
return AvailableXcode(
version: version,
url: downloadURL,
filename: String(downloadURL.path.suffix(fromLast: "/")),

View file

@ -7,12 +7,16 @@ 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?
@ -21,8 +25,8 @@ 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
init() {
try? loadCachedAvailableXcodes()
}
// MARK: - Authentication
@ -165,53 +169,29 @@ class AppState: ObservableObject {
authenticationState = .unauthenticated
}
// MARK: - Load Xcode Versions
// MARK: -
func update() {
guard !isUpdating else { return }
updatePublisher = update()
.sink(
receiveCompletion: { [unowned self] _ in
Current.defaults.setDate(Current.date(), forKey: "lastUpdated")
self.updatePublisher = nil
},
receiveValue: { _ in }
)
func install(id: String) {
// TODO:
}
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 uninstall(id: String) {
// TODO:
}
private func update() -> AnyPublisher<[Xcode], Never> {
signInIfNeeded()
.flatMap { [unowned self] in
self.list.update(dataSource: self.dataSource)
}
.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 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:
}
private func updateAllVersions(_ xcodes: [Xcode]) {
// 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 {
@ -243,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