From 4ef8428151bdf28a8f96c63e3c2353520b91bf79 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 24 Dec 2020 11:08:13 -0700 Subject: [PATCH 1/9] Convert XcodeList to Combine --- Xcodes/Backend/AppState.swift | 22 ++++------- Xcodes/Backend/Environment.swift | 8 ++-- Xcodes/Backend/XcodeList.swift | 67 ++++++++++++++++---------------- 3 files changed, 45 insertions(+), 52 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index a554e15..3aac23c 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -2,7 +2,6 @@ import AppKit import AppleAPI import Combine import Path -import PromiseKit import LegibleError import KeychainAccess @@ -173,22 +172,15 @@ class AppState: ObservableObject { 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)) } + self.list.update() + } + .handleEvents( + receiveCompletion: { completion in + if case let .failure(error) = completion { + self.error = AlertContent(title: "Update Error", message: error.legibleLocalizedDescription) } } - .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) } diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index 166cbab..c0c6c73 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -111,10 +111,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) } diff --git a/Xcodes/Backend/XcodeList.swift b/Xcodes/Backend/XcodeList.swift index dae4046..25967d6 100644 --- a/Xcodes/Backend/XcodeList.swift +++ b/Xcodes/Backend/XcodeList.swift @@ -1,10 +1,10 @@ +import Combine import Foundation import Path import Version -import PromiseKit import SwiftSoup -/// Provides lists of available and installed Xcodes +/// Provides lists of available Xcodes public final class XcodeList { public init() { try? loadCachedAvailableXcodes() @@ -16,8 +16,9 @@ public final class XcodeList { return availableXcodes.isEmpty } - public func update() -> Promise<[Xcode]> { - return when(fulfilled: releasedXcodes(), prereleaseXcodes()) + public func update() -> AnyPublisher<[Xcode], Error> { + releasedXcodes().combineLatest(prereleaseXcodes()) + .receive(on: DispatchQueue.main) .map { releasedXcodes, prereleaseXcodes in // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. // Previously pre-release versions only appeared on developer.apple.com/download. @@ -30,6 +31,7 @@ public final class XcodeList { try? self.cacheAvailableXcodes(xcodes) return xcodes } + .eraseToAnyPublisher() } } @@ -49,38 +51,37 @@ extension XcodeList { } 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 } + private func releasedXcodes() -> AnyPublisher<[Xcode], Error> { + Current.network.dataTask(with: URLRequest.downloads) + .map(\.data) + .decode(type: Downloads.self, decoder: configure(JSONDecoder()) { + $0.dateDecodingStrategy = .formatted(.downloadsDateModified) + }) + .map { downloads -> [Xcode] in + 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 - } + let url = urlPrefix.appendingPathComponent(xcodeFile.remotePath) + return Xcode(version: version, url: url, filename: String(xcodeFile.remotePath.suffix(fromLast: "/")), releaseDate: download.dateModified) + } + return xcodes + } + .eraseToAnyPublisher() } - 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) - } + private func prereleaseXcodes() -> AnyPublisher<[Xcode], Error> { + Current.network.dataTask(with: URLRequest.download) + .tryMap { (data, _) -> [Xcode] in + try self.parsePrereleaseXcodes(from: data) + } + .eraseToAnyPublisher() } func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] { From 9b26688255e0566cc383ca66a357382c5b617b0a Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 24 Dec 2020 11:30:59 -0700 Subject: [PATCH 2/9] Add refresh menu item and progress view --- Xcodes.xcodeproj/project.pbxproj | 4 ++++ Xcodes/Backend/AppState.swift | 12 ++++++---- Xcodes/Frontend/View+IsHidden.swift | 24 +++++++++++++++++++ Xcodes/Frontend/XcodeList/XcodeListView.swift | 9 ++++++- Xcodes/XcodesApp.swift | 7 ++++++ 5 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 Xcodes/Frontend/View+IsHidden.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 85fea2d..cdada1e 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 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 */; }; 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 */; }; @@ -80,6 +81,7 @@ CA9FF83E2594FBC000E47BAF /* Licenses.rtf */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.rtf; path = Licenses.rtf; sourceTree = ""; }; CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingTextView.swift; sourceTree = ""; }; CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; + CA9FF8652595130600E47BAF /* View+IsHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsHidden.swift"; sourceTree = ""; }; CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = ""; }; CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = ""; }; CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = ""; }; @@ -225,6 +227,7 @@ CAA1CB50255A5D16003FD669 /* SignIn */, CABFAA142592F73000380FEE /* XcodeList */, CABFAA2A2592FBFC00380FEE /* SettingsView.swift */, + CA9FF8652595130600E47BAF /* View+IsHidden.swift */, ); path = Frontend; sourceTree = ""; @@ -441,6 +444,7 @@ files = ( CA735109257BF96D00EA9CF8 /* AttributedText.swift in Sources */, CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */, + CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */, CABFA9CA2592EEEA00380FEE /* XcodeList.swift in Sources */, CA44901F2463AD34003D8213 /* Tag.swift in Sources */, CABFA9BF2592EEEA00380FEE /* URLSession+Promise.swift in Sources */, diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 3aac23c..68dec29 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -12,6 +12,8 @@ class AppState: ObservableObject { @Published var authenticationState: AuthenticationState = .unauthenticated @Published var allVersions: [XcodeVersion] = [] + @Published var updatePublisher: AnyCancellable? + var isUpdating: Bool { updatePublisher != nil } @Published var error: AlertContent? @Published var authError: AlertContent? @Published var presentingSignInAlert = false @@ -161,15 +163,17 @@ class AppState: ObservableObject { // MARK: - Load Xcode Versions func update() { - update() + guard !isUpdating else { return } + updatePublisher = update() .sink( - receiveCompletion: { _ in }, + receiveCompletion: { [unowned self] _ in + self.updatePublisher = nil + }, receiveValue: { _ in } ) - .store(in: &cancellables) } - public func update() -> AnyPublisher<[Xcode], Never> { + private func update() -> AnyPublisher<[Xcode], Never> { signInIfNeeded() .flatMap { self.list.update() diff --git a/Xcodes/Frontend/View+IsHidden.swift b/Xcodes/Frontend/View+IsHidden.swift new file mode 100644 index 0000000..4da6da0 --- /dev/null +++ b/Xcodes/Frontend/View+IsHidden.swift @@ -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) + } + } +} diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 7a2d0e5..7b70958 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -79,9 +79,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) { diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 88645fa..0a46137 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -17,6 +17,13 @@ struct XcodesApp: App { appDelegate.showAboutWindow() } } + CommandGroup(after: CommandGroupPlacement.newItem) { + Button("Refresh") { + appState.update() + } + .keyboardShortcut(KeyEquivalent("r")) + .disabled(appState.isUpdating) + } } Settings { From b342baff02a699a300a09609de1c697fe3b6daf5 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 24 Dec 2020 11:54:55 -0700 Subject: [PATCH 3/9] Update when the app launches or becomes active and it's been a day since last update --- Xcodes/Backend/AppState.swift | 10 ++++++++++ Xcodes/Backend/Environment.swift | 11 +++++++++++ Xcodes/Frontend/XcodeList/XcodeListView.swift | 1 - Xcodes/XcodesApp.swift | 9 +++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 68dec29..0f62038 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -167,12 +167,22 @@ class AppState: ObservableObject { updatePublisher = update() .sink( receiveCompletion: { [unowned self] _ in + Current.defaults.setDate(Current.date(), forKey: "lastUpdated") self.updatePublisher = nil }, receiveValue: { _ in } ) } + 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 + } + private func update() -> AnyPublisher<[Xcode], Never> { signInIfNeeded() .flatMap { diff --git a/Xcodes/Backend/Environment.swift b/Xcodes/Backend/Environment.swift index c0c6c73..fa3be9d 100644 --- a/Xcodes/Backend/Environment.swift +++ b/Xcodes/Backend/Environment.swift @@ -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() @@ -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) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index 7b70958..cb5523a 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -106,7 +106,6 @@ struct XcodeListView: View { } .navigationSubtitle(Text("Updated \(Date().addingTimeInterval(-600), style: .relative) ago")) .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), diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 0a46137..18cfdea 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -4,12 +4,21 @@ 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. + .onChange(of: scenePhase) { newScenePhase in + if case .active = newScenePhase { + appState.updateIfNeeded() + } + } } .commands { CommandGroup(replacing: .appInfo) { From 293aef80e3303078a20a6a7b6f05c00bb2675c83 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 24 Dec 2020 16:09:00 -0700 Subject: [PATCH 4/9] Add Xcode Releases data source --- Xcodes.xcodeproj/project.pbxproj | 25 ++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ Xcodes/Backend/AppState.swift | 9 +- Xcodes/Backend/DataSource.swift | 17 ++++ Xcodes/Backend/Version+.swift | 4 +- Xcodes/Backend/Version+XcodeReleases.swift | 51 +++++++++++ Xcodes/Backend/XcodeList.swift | 86 ++++++++++++++----- Xcodes/Resources/Licenses.rtf | 27 ++++++ Xcodes/SettingsView.swift | 34 ++++++++ 9 files changed, 237 insertions(+), 25 deletions(-) create mode 100644 Xcodes/Backend/DataSource.swift create mode 100644 Xcodes/Backend/Version+XcodeReleases.swift diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index cdada1e..1dae143 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ CA9FF84E2595079F00E47BAF /* ScrollingTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */; }; CA9FF8522595080100E47BAF /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */; }; CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF8652595130600E47BAF /* View+IsHidden.swift */; }; + CA9FF86D25951C6E00E47BAF /* XCModel in Frameworks */ = {isa = PBXBuildFile; productRef = CA9FF86C25951C6E00E47BAF /* XCModel */; }; + CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */; }; + CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9FF87A2595293E00E47BAF /* DataSource.swift */; }; CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */ = {isa = PBXBuildFile; productRef = CAA1CB2C255A5262003FD669 /* AppleAPI */; }; CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */; }; CAA1CB45255A5B60003FD669 /* SignIn2FAView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */; }; @@ -82,6 +85,8 @@ CA9FF84D2595079F00E47BAF /* ScrollingTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingTextView.swift; sourceTree = ""; }; CA9FF8512595080100E47BAF /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; CA9FF8652595130600E47BAF /* View+IsHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsHidden.swift"; sourceTree = ""; }; + CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+XcodeReleases.swift"; sourceTree = ""; }; + CA9FF87A2595293E00E47BAF /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = ""; }; CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = ""; }; CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = ""; }; @@ -128,6 +133,7 @@ CABFA9E42592F08E00380FEE /* Version in Frameworks */, CABFA9E92592F0B400380FEE /* PromiseKit in Frameworks */, CABFA9FD2592F13300380FEE /* LegibleError in Frameworks */, + CA9FF86D25951C6E00E47BAF /* XCModel in Frameworks */, CABFA9F82592F0F900380FEE /* KeychainAccess in Frameworks */, CABFA9F32592F0E400380FEE /* PMKFoundation in Frameworks */, CAA1CB2D255A5262003FD669 /* AppleAPI in Frameworks */, @@ -201,6 +207,7 @@ CA378F982466567600A58CE0 /* AppState.swift */, CABFAA2B2592FBFC00380FEE /* Configure.swift */, CABFAA482593162500380FEE /* Bundle+InfoPlistValues.swift */, + CA9FF87A2595293E00E47BAF /* DataSource.swift */, CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */, CABFA9B22592EEEA00380FEE /* Entry+.swift */, CABFA9A92592EEE900380FEE /* Environment.swift */, @@ -213,6 +220,7 @@ CABFA9AB2592EEE900380FEE /* URLRequest+Apple.swift */, CABFA9B32592EEEA00380FEE /* URLSession+Promise.swift */, CABFA9A82592EEE900380FEE /* Version+.swift */, + CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */, CABFA9A62592EEE900380FEE /* Version+Xcode.swift */, CABFA9A72592EEE900380FEE /* XcodeList.swift */, ); @@ -326,6 +334,7 @@ CABFA9F22592F0E400380FEE /* PMKFoundation */, CABFA9F72592F0F900380FEE /* KeychainAccess */, CABFA9FC2592F13300380FEE /* LegibleError */, + CA9FF86C25951C6E00E47BAF /* XCModel */, ); productName = XcodesMac; productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */; @@ -385,6 +394,7 @@ CABFA9F12592F0E400380FEE /* XCRemoteSwiftPackageReference "Foundation" */, CABFA9F62592F0F900380FEE /* XCRemoteSwiftPackageReference "KeychainAccess" */, CABFA9FB2592F13300380FEE /* XCRemoteSwiftPackageReference "LegibleError" */, + CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */, ); productRefGroup = CAD2E79F2449574E00113D76 /* Products */; projectDirPath = ""; @@ -462,6 +472,7 @@ CABFA9CE2592EEEA00380FEE /* Version+Xcode.swift in Sources */, CAA1CB49255A5C97003FD669 /* SignInSMSView.swift in Sources */, CAA1CB35255A5AD5003FD669 /* SignInCredentialsView.swift in Sources */, + CA9FF877259528CC00E47BAF /* Version+XcodeReleases.swift in Sources */, CABFAA2D2592FBFC00380FEE /* Configure.swift in Sources */, CA73510D257BFCEF00EA9CF8 /* NSAttributedString+.swift in Sources */, CABFA9C22592EEEA00380FEE /* Promise+.swift in Sources */, @@ -469,6 +480,7 @@ CABFA9CF2592EEEA00380FEE /* Process.swift in Sources */, CABFA9C72592EEEA00380FEE /* Entry+.swift in Sources */, CABFAA2C2592FBFC00380FEE /* SettingsView.swift in Sources */, + CA9FF87B2595293E00E47BAF /* DataSource.swift in Sources */, CABFA9C92592EEEA00380FEE /* URLRequest+Apple.swift in Sources */, CABFAA432593104F00380FEE /* AboutView.swift in Sources */, CABFA9CC2592EEEA00380FEE /* Path+.swift in Sources */, @@ -840,6 +852,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/xcodereleases/data"; + requirement = { + kind = revision; + revision = b47228c688b608e34b3b84079ab6052a24c7a981; + }; + }; CABFA9DD2592F07A00380FEE /* XCRemoteSwiftPackageReference "Path" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mxcl/Path.swift"; @@ -899,6 +919,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + CA9FF86C25951C6E00E47BAF /* XCModel */ = { + isa = XCSwiftPackageProductDependency; + package = CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */; + productName = XCModel; + }; CAA1CB2C255A5262003FD669 /* AppleAPI */ = { isa = XCSwiftPackageProductDependency; productName = AppleAPI; diff --git a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ee46f5..fba9785 100644 --- a/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Xcodes.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 0f62038..e9e36bd 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -4,6 +4,7 @@ import Combine import Path import LegibleError import KeychainAccess +import SwiftUI class AppState: ObservableObject { private let list = XcodeList() @@ -20,6 +21,10 @@ class AppState: ObservableObject { @Published var isProcessingAuthRequest = false @Published var secondFactorData: SecondFactorData? + private var dataSource: DataSource { + Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default + } + // MARK: - Authentication func validateSession() -> AnyPublisher { @@ -185,8 +190,8 @@ class AppState: ObservableObject { private func update() -> AnyPublisher<[Xcode], Never> { signInIfNeeded() - .flatMap { - self.list.update() + .flatMap { [unowned self] in + self.list.update(dataSource: self.dataSource) } .handleEvents( receiveCompletion: { completion in diff --git a/Xcodes/Backend/DataSource.swift b/Xcodes/Backend/DataSource.swift new file mode 100644 index 0000000..69363ae --- /dev/null +++ b/Xcodes/Backend/DataSource.swift @@ -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" + } + } +} diff --git a/Xcodes/Backend/Version+.swift b/Xcodes/Backend/Version+.swift index c4ecec3..5fed036 100644 --- a/Xcodes/Backend/Version+.swift +++ b/Xcodes/Backend/Version+.swift @@ -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() } } } diff --git a/Xcodes/Backend/Version+XcodeReleases.swift b/Xcodes/Backend/Version+XcodeReleases.swift new file mode 100644 index 0000000..e13661f --- /dev/null +++ b/Xcodes/Backend/Version+XcodeReleases.swift @@ -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) + } +} + diff --git a/Xcodes/Backend/XcodeList.swift b/Xcodes/Backend/XcodeList.swift index 25967d6..317b5c1 100644 --- a/Xcodes/Backend/XcodeList.swift +++ b/Xcodes/Backend/XcodeList.swift @@ -3,6 +3,7 @@ import Foundation import Path import Version import SwiftSoup +import struct XCModel.Xcode /// Provides lists of available Xcodes public final class XcodeList { @@ -12,26 +13,34 @@ public final class XcodeList { public private(set) var availableXcodes: [Xcode] = [] - public var shouldUpdate: Bool { - return availableXcodes.isEmpty - } - - public func update() -> AnyPublisher<[Xcode], Error> { - releasedXcodes().combineLatest(prereleaseXcodes()) - .receive(on: DispatchQueue.main) - .map { releasedXcodes, prereleaseXcodes in - // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. - // Previously pre-release versions only appeared on developer.apple.com/download. - // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. - // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. - let xcodes = releasedXcodes.filter { releasedXcode in - prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false - } + prereleaseXcodes - self.availableXcodes = xcodes - try? self.cacheAvailableXcodes(xcodes) - return xcodes - } - .eraseToAnyPublisher() + public func update(dataSource: DataSource) -> AnyPublisher<[Xcode], Error> { + switch dataSource { + case .apple: + return releasedXcodes().combineLatest(prereleaseXcodes()) + .receive(on: DispatchQueue.main) + .map { releasedXcodes, prereleaseXcodes in + // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. + // Previously pre-release versions only appeared on developer.apple.com/download. + // /download/more doesn't include build numbers, so we trust that if the version number and prerelease identifiers are the same that they're the same build. + // If an Xcode version is listed on both sites then prefer the one on /download because the build metadata is used to compare against installed Xcodes. + let xcodes = releasedXcodes.filter { releasedXcode in + prereleaseXcodes.contains { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: releasedXcode.version) } == false + } + prereleaseXcodes + self.availableXcodes = xcodes + try? self.cacheAvailableXcodes(xcodes) + return xcodes + } + .eraseToAnyPublisher() + case .xcodeReleases: + return xcodeReleases() + .receive(on: DispatchQueue.main) + .handleEvents( + receiveOutput: { xcodes in + try? self.cacheAvailableXcodes(xcodes) + } + ) + .eraseToAnyPublisher() + } } } @@ -51,6 +60,8 @@ extension XcodeList { } extension XcodeList { + // MARK: Apple + private func releasedXcodes() -> AnyPublisher<[Xcode], Error> { Current.network.dataTask(with: URLRequest.downloads) .map(\.data) @@ -84,7 +95,7 @@ extension XcodeList { .eraseToAnyPublisher() } - func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] { + private func parsePrereleaseXcodes(from data: Data) throws -> [Xcode] { let body = String(data: data, encoding: .utf8)! let document = try SwiftSoup.parse(body) @@ -102,3 +113,36 @@ extension XcodeList { return [Xcode(version: version, url: url, filename: filename, releaseDate: DateFormatter.downloadsReleaseDate.date(from: releaseDateString))] } } + +extension XcodeList { + // MARK: XcodeReleases + + private func xcodeReleases() -> AnyPublisher<[Xcode], Error> { + Current.network.dataTask(with: URLRequest(url: URL(string: "https://xcodereleases.com/data.json")!)) + .map(\.data) + .decode(type: [XCModel.Xcode].self, decoder: JSONDecoder()) + .map { xcReleasesXcodes in + let xcodes = xcReleasesXcodes.compactMap { xcReleasesXcode -> Xcode? in + guard + let downloadURL = xcReleasesXcode.links?.download?.url, + let version = Version(xcReleasesXcode: xcReleasesXcode) + else { return nil } + + let releaseDate = Calendar(identifier: .gregorian).date(from: DateComponents( + year: xcReleasesXcode.date.year, + month: xcReleasesXcode.date.month, + day: xcReleasesXcode.date.day + )) + + return Xcode( + version: version, + url: downloadURL, + filename: String(downloadURL.path.suffix(fromLast: "/")), + releaseDate: releaseDate + ) + } + return xcodes + } + .eraseToAnyPublisher() + } +} diff --git a/Xcodes/Resources/Licenses.rtf b/Xcodes/Resources/Licenses.rtf index f9dca46..f140dc0 100644 --- a/Xcodes/Resources/Licenses.rtf +++ b/Xcodes/Resources/Licenses.rtf @@ -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\ \ diff --git a/Xcodes/SettingsView.swift b/Xcodes/SettingsView.swift index 373561f..5ff2646 100644 --- a/Xcodes/SettingsView.swift +++ b/Xcodes/SettingsView.swift @@ -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 { From f06d72f3eb372b427591d09c3963094e7ea2e0fc Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 24 Dec 2020 16:51:20 -0700 Subject: [PATCH 5/9] Split up Models.swift --- Xcodes.xcodeproj/project.pbxproj | 16 +++++++--- Xcodes/Backend/AvailableXcode.swift | 17 ++++++++++ Xcodes/Backend/Downloads.swift | 17 ++++++++++ .../{Models.swift => InstalledXcode.swift} | 32 ++----------------- 4 files changed, 48 insertions(+), 34 deletions(-) create mode 100644 Xcodes/Backend/AvailableXcode.swift create mode 100644 Xcodes/Backend/Downloads.swift rename Xcodes/Backend/{Models.swift => InstalledXcode.swift} (79%) diff --git a/Xcodes.xcodeproj/project.pbxproj b/Xcodes.xcodeproj/project.pbxproj index 1dae143..54a1f30 100644 --- a/Xcodes.xcodeproj/project.pbxproj +++ b/Xcodes.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ 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 */; }; @@ -31,7 +33,7 @@ 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 */; }; @@ -87,6 +89,8 @@ CA9FF8652595130600E47BAF /* View+IsHidden.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+IsHidden.swift"; sourceTree = ""; }; CA9FF876259528CC00E47BAF /* Version+XcodeReleases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Version+XcodeReleases.swift"; sourceTree = ""; }; CA9FF87A2595293E00E47BAF /* DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSource.swift; sourceTree = ""; }; + CA9FF88025955C7000E47BAF /* AvailableXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailableXcode.swift; sourceTree = ""; }; + CA9FF8862595607900E47BAF /* InstalledXcode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstalledXcode.swift; sourceTree = ""; }; CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInCredentialsView.swift; sourceTree = ""; }; CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn2FAView.swift; sourceTree = ""; }; CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSMSView.swift; sourceTree = ""; }; @@ -106,7 +110,7 @@ CABFA9B32592EEEA00380FEE /* URLSession+Promise.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSession+Promise.swift"; sourceTree = ""; }; CABFA9B42592EEEA00380FEE /* Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Process.swift; sourceTree = ""; }; CABFA9B82592EEEA00380FEE /* FileManager+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+.swift"; sourceTree = ""; }; - CABFA9B92592EEEA00380FEE /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + CABFA9B92592EEEA00380FEE /* Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Downloads.swift; sourceTree = ""; }; CABFA9BA2592EEEA00380FEE /* DateFormatter+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+.swift"; sourceTree = ""; }; CABFA9D42592EF6300380FEE /* DECISIONS.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DECISIONS.md; sourceTree = ""; }; CABFAA2A2592FBFC00380FEE /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SettingsView.swift; path = Xcodes/SettingsView.swift; sourceTree = SOURCE_ROOT; }; @@ -205,15 +209,17 @@ isa = PBXGroup; children = ( CA378F982466567600A58CE0 /* AppState.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 */, @@ -460,12 +466,13 @@ 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 */, @@ -488,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; }; diff --git a/Xcodes/Backend/AvailableXcode.swift b/Xcodes/Backend/AvailableXcode.swift new file mode 100644 index 0000000..f1c0c0a --- /dev/null +++ b/Xcodes/Backend/AvailableXcode.swift @@ -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 + } +} diff --git a/Xcodes/Backend/Downloads.swift b/Xcodes/Backend/Downloads.swift new file mode 100644 index 0000000..45e2bb7 --- /dev/null +++ b/Xcodes/Backend/Downloads.swift @@ -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 + } +} diff --git a/Xcodes/Backend/Models.swift b/Xcodes/Backend/InstalledXcode.swift similarity index 79% rename from Xcodes/Backend/Models.swift rename to Xcodes/Backend/InstalledXcode.swift index fe0fbb8..aa3d716 100644 --- a/Xcodes/Backend/Models.swift +++ b/Xcodes/Backend/InstalledXcode.swift @@ -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" } } - From 33b5f96ed2be27fc948ea007f4255e877db56f91 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 24 Dec 2020 16:51:57 -0700 Subject: [PATCH 6/9] Merge XcodeList into AppState --- Xcodes.xcodeproj/project.pbxproj | 8 +- ...{XcodeList.swift => AppState+Update.swift} | 93 +++++++++++++------ Xcodes/Backend/AppState.swift | 86 +++++------------ 3 files changed, 95 insertions(+), 92 deletions(-) rename Xcodes/Backend/{XcodeList.swift => AppState+Update.swift} (64%) 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 From 115de2d3a2bf958ca77257dee2b90001213a986f Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sat, 26 Dec 2020 19:30:28 -0700 Subject: [PATCH 7/9] Show last updated date in toolbar --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index cb5523a..c97b3b6 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -7,6 +7,7 @@ struct XcodeListView: View { @State private var selection = Set() @State private var rowBeingConfirmedForUninstallation: AppState.XcodeVersion? @State private var searchText: String = "" + @AppStorage("lastUpdated") private var lastUpdated: Double? @AppStorage("xcodeListCategory") private var category: Category = .all @@ -104,7 +105,7 @@ 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) .alert(item: $appState.error) { error in Alert(title: Text(error.title), @@ -137,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 { From 8594996b871ed79b3152d5aaeaa6bd84aa76c25a Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sun, 27 Dec 2020 16:49:08 -0700 Subject: [PATCH 8/9] Add feedback IDs in comments --- Xcodes/XcodesApp.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Xcodes/XcodesApp.swift b/Xcodes/XcodesApp.swift index 18cfdea..ea66958 100644 --- a/Xcodes/XcodesApp.swift +++ b/Xcodes/XcodesApp.swift @@ -14,6 +14,7 @@ struct XcodesApp: App { // 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() @@ -69,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) From de54c3d784498542177a9b4e3f45b8b0efad0276 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sun, 27 Dec 2020 18:33:29 -0700 Subject: [PATCH 9/9] Only sign in before refreshing when using Apple data source --- Xcodes/Backend/AppState+Update.swift | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index a6e41aa..856d49f 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -21,7 +21,7 @@ extension AppState { func update() { guard !isUpdating else { return } - updatePublisher = update() + updatePublisher = updateAvailableXcodes(from: self.dataSource) .sink( receiveCompletion: { [unowned self] completion in switch completion { @@ -36,19 +36,12 @@ extension AppState { 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()) + 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.