diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 54a1f30..579d372 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -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 = ""; }; CABFA9A32592ED5700380FEE /* Apple.paw */ = {isa = PBXFileReference; lastKnownFileType = file; name = Apple.paw; path = ../xcodes/Apple.paw; sourceTree = ""; }; CABFA9A62592EEE900380FEE /* Version+Xcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+Xcode.swift"; sourceTree = ""; }; - CABFA9A72592EEE900380FEE /* XcodeList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XcodeList.swift; sourceTree = ""; }; + CABFA9A72592EEE900380FEE /* AppState+Update.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppState+Update.swift"; sourceTree = ""; }; CABFA9A82592EEE900380FEE /* Version+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+.swift"; sourceTree = ""; }; CABFA9A92592EEE900380FEE /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+Apple.swift"; sourceTree = ""; }; @@ -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 = ""; @@ -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 */, diff --git a/Xcodes/Backend/XcodeList.swift b/Xcodes/Backend/AppState+Update.swift similarity index 64% rename from Xcodes/Backend/XcodeList.swift rename to Xcodes/Backend/AppState+Update.swift index 317b5c1..a6e41aa 100644 --- a/Xcodes/Backend/XcodeList.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -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: "/")), diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index e9e36bd..e0ec6e2 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -7,12 +7,16 @@ import KeychainAccess import SwiftUI class AppState: ObservableObject { - private let list = XcodeList() private let client = AppleAPI.Client() private var cancellables = Set() @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