From 57767f6920675e4efe785bd096e7092bc80eecdf Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 18:34:13 -0700 Subject: [PATCH 01/10] Fix RC capitalization --- Xcodes/Backend/Version+Xcode.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Xcodes/Backend/Version+Xcode.swift b/Xcodes/Backend/Version+Xcode.swift index 77879a5..5166fba 100644 --- a/Xcodes/Backend/Version+Xcode.swift +++ b/Xcodes/Backend/Version+Xcode.swift @@ -50,7 +50,13 @@ public extension Version { } if !prereleaseIdentifiers.isEmpty { base += " " + prereleaseIdentifiers - .map { $0.replacingOccurrences(of: "-", with: " ").capitalized.replacingOccurrences(of: "Gm", with: "GM") } + .map { identifiers in + identifiers + .replacingOccurrences(of: "-", with: " ") + .capitalized + .replacingOccurrences(of: "Gm", with: "GM") + .replacingOccurrences(of: "Rc", with: "RC") + } .joined(separator: " ") } return base From 7ac4814420267a8c21297c8efbcc882121870105 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 19:11:27 -0700 Subject: [PATCH 02/10] Treat GM versions as release versions --- Xcodes/Backend/Version+XcodeReleases.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Xcodes/Backend/Version+XcodeReleases.swift b/Xcodes/Backend/Version+XcodeReleases.swift index 6139d63..4ddd4f3 100644 --- a/Xcodes/Backend/Version+XcodeReleases.swift +++ b/Xcodes/Backend/Version+XcodeReleases.swift @@ -25,7 +25,7 @@ extension Version { versionString += ".\(dp)" } case .gm: - versionString += "-GM" + break case let .gmSeed(gmSeed): versionString += "-GM.Seed" if gmSeed > 1 { From bc45daeb74c8fa6e23f39e146bb41c495b716b73 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 19:12:52 -0700 Subject: [PATCH 03/10] Only adjust available versions for Apple data source --- Xcodes/Backend/AppState+Update.swift | 29 -------- Xcodes/Backend/AppState.swift | 63 ++++++++++++---- XcodesTests/AppStateUpdateTests.swift | 100 ++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 44 deletions(-) diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index dd5b7d7..c8124f2 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -6,10 +6,6 @@ import SwiftSoup import struct XCModel.Xcode extension AppState { - private var dataSource: DataSource { - Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default - } - func updateIfNeeded() { guard let lastUpdated = Current.defaults.date(forKey: "lastUpdated"), @@ -200,31 +196,6 @@ extension AppState { } return xcodes } - .map(filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers) .eraseToAnyPublisher() } - - /// Xcode Releases may have multiple releases with the same build metadata when a build doesn't change between candidate and final releases. - /// For example, 12.3 RC and 12.3 are both build 12C33 - /// We don't care about that difference, so only keep the final release (GM or Release, in XCModel terms). - /// The downside of this is that a user could technically have both releases installed, and so they won't both be shown in the list, but I think most users wouldn't do this. - func filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(_ availableXcodes: [AvailableXcode]) -> [AvailableXcode] { - var filteredAvailableXcodes: [AvailableXcode] = [] - for availableXcode in availableXcodes { - if availableXcode.version.buildMetadataIdentifiers.isEmpty { - filteredAvailableXcodes.append(availableXcode) - continue - } - - let availableXcodesWithSameBuildMetadataIdentifiers = availableXcodes - .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) - if availableXcodesWithSameBuildMetadataIdentifiers.count > 1, - availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.version.prereleaseIdentifiers == ["GM"] { - filteredAvailableXcodes.append(availableXcode) - } else if availableXcodesWithSameBuildMetadataIdentifiers.count == 1 { - filteredAvailableXcodes.append(availableXcode) - } - } - return filteredAvailableXcodes - } } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index ed465f8..1a83b01 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -57,6 +57,12 @@ class AppState: ObservableObject { private var selectPublisher: AnyCancellable? private var uninstallPublisher: AnyCancellable? + // MARK: - + + var dataSource: DataSource { + Current.defaults.string(forKey: "dataSource").flatMap(DataSource.init(rawValue:)) ?? .default + } + // MARK: - Init init() { @@ -419,23 +425,25 @@ class AppState: ObservableObject { } func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) { - var adjustedAvailableXcodes = availableXcodes + var adjustedAvailableXcodes = filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(availableXcodes) // First, adjust all of the available Xcodes so that available and installed versions line up and the second part of this function works properly. - for installedXcode in installedXcodes { - // We can trust that build metadata identifiers are unique for each version of Xcode, so if we have it then it's all we need. - // If build metadata matches exactly, replace the available version with the installed version. - // This should handle both Xcode Releases versions which can have different prerelease identifiers and Apple versions which rarely have build metadata identifiers. - if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) { - adjustedAvailableXcodes[index].version = installedXcode.version - } - // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version - // Not all prerelease Apple versions available online include build metadata - else if let index = adjustedAvailableXcodes.firstIndex(where: { availableXcode in - availableXcode.version.isEquivalent(to: installedXcode.version) && - availableXcode.version.buildMetadataIdentifiers.isEmpty - }) { - adjustedAvailableXcodes[index].version = installedXcode.version + if dataSource == .apple { + for installedXcode in installedXcodes { + // We can trust that build metadata identifiers are unique for each version of Xcode, so if we have it then it's all we need. + // If build metadata matches exactly, replace the available version with the installed version. + // This should handle Apple versions from /downloads/more which don't have build metadata identifiers. + if let index = adjustedAvailableXcodes.map(\.version).firstIndex(where: { $0.buildMetadataIdentifiers == installedXcode.version.buildMetadataIdentifiers }) { + adjustedAvailableXcodes[index].version = installedXcode.version + } + // If an installed version is the same as one that's listed online which doesn't have build metadata, replace it with the installed version + // Not all prerelease Apple versions available online include build metadata + else if let index = adjustedAvailableXcodes.firstIndex(where: { availableXcode in + availableXcode.version.isEquivalent(to: installedXcode.version) && + availableXcode.version.buildMetadataIdentifiers.isEmpty + }) { + adjustedAvailableXcodes[index].version = installedXcode.version + } } } @@ -453,6 +461,7 @@ class AppState: ObservableObject { return Xcode( version: availableXcode.version, + identicalBuilds: [], installState: existingXcodeInstallState ?? defaultXcodeInstallState, selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), @@ -483,6 +492,30 @@ class AppState: ObservableObject { self.allXcodes = newAllXcodes.sorted { $0.version > $1.version } } + /// Xcode Releases may have multiple releases with the same build metadata when a build doesn't change between candidate and final releases. + /// For example, 12.3 RC and 12.3 are both build 12C33 + /// We don't care about that difference, so only keep the final release (GM or Release, in XCModel terms). + /// The downside of this is that a user could technically have both releases installed, and so they won't both be shown in the list, but I think most users wouldn't do this. + func filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(_ availableXcodes: [AvailableXcode]) -> [AvailableXcode] { + var filteredAvailableXcodes: [AvailableXcode] = [] + for availableXcode in availableXcodes { + if availableXcode.version.buildMetadataIdentifiers.isEmpty { + filteredAvailableXcodes.append(availableXcode) + continue + } + + let availableXcodesWithSameBuildMetadataIdentifiers = availableXcodes + .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) + if availableXcodesWithSameBuildMetadataIdentifiers.count > 1, + availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.version.prereleaseIdentifiers == ["GM"] { + filteredAvailableXcodes.append(availableXcode) + } else if availableXcodesWithSameBuildMetadataIdentifiers.count == 1 { + filteredAvailableXcodes.append(availableXcode) + } + } + return filteredAvailableXcodes + } + // MARK: - Private private func uninstallXcode(path: Path) -> AnyPublisher { diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 997108e..bcb9b35 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -46,6 +46,14 @@ class AppStateUpdateTests: XCTestCase { } func testDeterminesIfInstalledByBuildMetadataAlone() throws { + Current.defaults.string = { key in + if key == "dataSource" { + return "apple" + } else { + return nil + } + } + subject.allXcodes = [ ] @@ -66,6 +74,14 @@ class AppStateUpdateTests: XCTestCase { } func testAdjustedVersionsAreUsedToLookupAvailableXcode() throws { + Current.defaults.string = { key in + if key == "dataSource" { + return "apple" + } else { + return nil + } + } + subject.allXcodes = [ ] @@ -105,6 +121,90 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(subject.allXcodes.map(\.version), [Version("1.2.3")!, Version("0.0.0+ABC123")!]) } + + func testIdenticalBuilds_KeepsReleaseVersion_WithNeitherInstalled() { + Current.defaults.string = { key in + if key == "dataSource" { + return "xcodeReleases" + } else { + return nil + } + } + + subject.allXcodes = [ + ] + + subject.updateAllXcodes( + availableXcodes: [ + AvailableXcode(version: Version("12.4.0+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + AvailableXcode(version: Version("12.4.0-RC+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + ], + installedXcodes: [ + ], + selectedXcodePath: nil + ) + + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) + } + + func testIdenticalBuilds_KeepsReleaseVersion_WithPrereleaseInstalled() { + Current.defaults.string = { key in + if key == "dataSource" { + return "xcodeReleases" + } else { + return nil + } + } + + subject.allXcodes = [ + ] + + Current.files.contentsAtPath = { path in + if path.contains("Info.plist") { + return """ + + + + + CFBundleIdentifier + com.apple.dt.Xcode + CFBundleShortVersionString + 12.4.0 + + + """.data(using: .utf8) + } + else if path.contains("version.plist") { + return """ + + + + + ProductBuildVersion + 12D4e + + + """.data(using: .utf8) + } + else { + return nil + } + } + + subject.updateAllXcodes( + availableXcodes: [ + AvailableXcode(version: Version("12.4.0+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + AvailableXcode(version: Version("12.4.0-RC+12D4e")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + ], + installedXcodes: [ + InstalledXcode(path: Path("/Applications/Xcode-12.4.0-RC.app")!)! + ], + selectedXcodePath: nil + ) + + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) + } + func testFilterReleasesThatMatchPrereleases() { let result = subject.filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers( [ From dcd4001548e6ddbe4b526d803eff0a2c7cebbce0 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 19:53:33 -0700 Subject: [PATCH 04/10] Merge identical builds into the release version instead of filtering --- Xcodes/Backend/AppState.swift | 46 ++++++++++++--------------- Xcodes/Backend/Xcode.swift | 4 +++ XcodesTests/AppStateUpdateTests.swift | 12 ++----- 3 files changed, 26 insertions(+), 36 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 1a83b01..e302307 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -425,7 +425,7 @@ class AppState: ObservableObject { } func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) { - var adjustedAvailableXcodes = filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(availableXcodes) + var adjustedAvailableXcodes = availableXcodes // First, adjust all of the available Xcodes so that available and installed versions line up and the second part of this function works properly. if dataSource == .apple { @@ -449,10 +449,28 @@ class AppState: ObservableObject { // Map all of the available versions into Xcode values that join available and installed Xcode data for display. var newAllXcodes = adjustedAvailableXcodes + .filter { availableXcode in + let availableXcodesWithIdenticalBuildIdentifiers = availableXcodes + .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) + + // Include this version if there's only one with this build identifier + return availableXcodesWithIdenticalBuildIdentifiers.count == 1 || + // Or if there's more than one with this build identifier and this is the release version + availableXcodesWithIdenticalBuildIdentifiers.count > 1 && availableXcode.version.prereleaseIdentifiers.isEmpty + } .map { availableXcode -> Xcode in let installedXcode = installedXcodes.first(where: { installedXcode in availableXcode.version.isEquivalent(to: installedXcode.version) }) + + let identicalBuilds: [Version] + let availableXcodesWithIdenticalBuildIdentifiers = availableXcodes + .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) + if availableXcodesWithIdenticalBuildIdentifiers.count > 1, availableXcode.version.prereleaseIdentifiers.isEmpty { + identicalBuilds = availableXcodesWithIdenticalBuildIdentifiers.map(\.version) + } else { + identicalBuilds = [] + } // If the existing install state is "installing", keep it let existingXcodeInstallState = allXcodes.first { $0.version == availableXcode.version && $0.installState.installing }?.installState @@ -461,7 +479,7 @@ class AppState: ObservableObject { return Xcode( version: availableXcode.version, - identicalBuilds: [], + identicalBuilds: identicalBuilds, installState: existingXcodeInstallState ?? defaultXcodeInstallState, selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true, icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)), @@ -492,30 +510,6 @@ class AppState: ObservableObject { self.allXcodes = newAllXcodes.sorted { $0.version > $1.version } } - /// Xcode Releases may have multiple releases with the same build metadata when a build doesn't change between candidate and final releases. - /// For example, 12.3 RC and 12.3 are both build 12C33 - /// We don't care about that difference, so only keep the final release (GM or Release, in XCModel terms). - /// The downside of this is that a user could technically have both releases installed, and so they won't both be shown in the list, but I think most users wouldn't do this. - func filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers(_ availableXcodes: [AvailableXcode]) -> [AvailableXcode] { - var filteredAvailableXcodes: [AvailableXcode] = [] - for availableXcode in availableXcodes { - if availableXcode.version.buildMetadataIdentifiers.isEmpty { - filteredAvailableXcodes.append(availableXcode) - continue - } - - let availableXcodesWithSameBuildMetadataIdentifiers = availableXcodes - .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) - if availableXcodesWithSameBuildMetadataIdentifiers.count > 1, - availableXcode.version.prereleaseIdentifiers.isEmpty || availableXcode.version.prereleaseIdentifiers == ["GM"] { - filteredAvailableXcodes.append(availableXcode) - } else if availableXcodesWithSameBuildMetadataIdentifiers.count == 1 { - filteredAvailableXcodes.append(availableXcode) - } - } - return filteredAvailableXcodes - } - // MARK: - Private private func uninstallXcode(path: Path) -> AnyPublisher { diff --git a/Xcodes/Backend/Xcode.swift b/Xcodes/Backend/Xcode.swift index a5da1bc..ebf1f23 100644 --- a/Xcodes/Backend/Xcode.swift +++ b/Xcodes/Backend/Xcode.swift @@ -6,6 +6,8 @@ import struct XCModel.Compilers struct Xcode: Identifiable, CustomStringConvertible { let version: Version + /// Other Xcode versions that have the same build identifier + let identicalBuilds: [Version] var installState: XcodeInstallState let selected: Bool let icon: NSImage? @@ -17,6 +19,7 @@ struct Xcode: Identifiable, CustomStringConvertible { init( version: Version, + identicalBuilds: [Version] = [], installState: XcodeInstallState, selected: Bool, icon: NSImage?, @@ -27,6 +30,7 @@ struct Xcode: Identifiable, CustomStringConvertible { downloadFileSize: Int64? = nil ) { self.version = version + self.identicalBuilds = identicalBuilds self.installState = installState self.selected = selected self.icon = icon diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index bcb9b35..0d95edb 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -145,6 +145,7 @@ class AppStateUpdateTests: XCTestCase { ) XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) + XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[Version("12.4.0+12D4e")!, Version("12.4.0-RC+12D4e")!]]) } func testIdenticalBuilds_KeepsReleaseVersion_WithPrereleaseInstalled() { @@ -203,15 +204,6 @@ class AppStateUpdateTests: XCTestCase { ) XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) - } - - func testFilterReleasesThatMatchPrereleases() { - let result = subject.filterPrereleasesThatMatchReleaseBuildMetadataIdentifiers( - [ - AvailableXcode(version: Version("12.3.0+12C33")!, url: URL(string: "https://apple.com")!, filename: "Xcode_12.3.xip", releaseDate: nil), - AvailableXcode(version: Version("12.3.0-RC+12C33")!, url: URL(string: "https://apple.com")!, filename: "Xcode_12.3_RC_1.xip", releaseDate: nil), - ] - ) - XCTAssertEqual(result.map(\.version), [Version("12.3.0+12C33")]) + XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[Version("12.4.0+12D4e")!, Version("12.4.0-RC+12D4e")!]]) } } From 760c9f300ec69af640e0cfc1f52f624897c8c4e4 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 20:03:53 -0700 Subject: [PATCH 05/10] Handle duplicate 3.2.3 release builds :/ --- Xcodes/Backend/AppState.swift | 11 +++++++---- XcodesTests/AppStateUpdateTests.swift | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index e302307..da38798 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -464,10 +464,13 @@ class AppState: ObservableObject { }) let identicalBuilds: [Version] - let availableXcodesWithIdenticalBuildIdentifiers = availableXcodes - .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) - if availableXcodesWithIdenticalBuildIdentifiers.count > 1, availableXcode.version.prereleaseIdentifiers.isEmpty { - identicalBuilds = availableXcodesWithIdenticalBuildIdentifiers.map(\.version) + let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes + .filter { + $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers && !$0.version.prereleaseIdentifiers.isEmpty + } + // If this is the release version, add the identical builds to it + if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty { + identicalBuilds = [availableXcode.version] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.map(\.version) } else { identicalBuilds = [] } diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 0d95edb..616e460 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -148,6 +148,32 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[Version("12.4.0+12D4e")!, Version("12.4.0-RC+12D4e")!]]) } + func testIdenticalBuilds_DoNotMergeReleaseVersions() { + Current.defaults.string = { key in + if key == "dataSource" { + return "xcodeReleases" + } else { + return nil + } + } + + subject.allXcodes = [ + ] + + subject.updateAllXcodes( + availableXcodes: [ + AvailableXcode(version: Version("3.2.3+10M2262")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + AvailableXcode(version: Version("3.2.3+10M2262")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + ], + installedXcodes: [ + ], + selectedXcodePath: nil + ) + + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("3.2.3+10M2262")!, Version("3.2.3+10M2262")!]) + XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[], []]) + } + func testIdenticalBuilds_KeepsReleaseVersion_WithPrereleaseInstalled() { Current.defaults.string = { key in if key == "dataSource" { From 70ca8c37bfacd22456e986068e253e82af51c3a4 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 20:30:52 -0700 Subject: [PATCH 06/10] Add identical builds to the info pane --- Xcodes/Frontend/XcodeList/InfoPane.swift | 29 ++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Xcodes/Frontend/XcodeList/InfoPane.swift b/Xcodes/Frontend/XcodeList/InfoPane.swift index 382138b..4cbabf6 100644 --- a/Xcodes/Frontend/XcodeList/InfoPane.swift +++ b/Xcodes/Frontend/XcodeList/InfoPane.swift @@ -53,6 +53,7 @@ struct InfoPane: View { Divider() releaseNotes(for: xcode) + identicalBuilds(for: xcode) compatibility(for: xcode) sdks(for: xcode) compilers(for: xcode) @@ -80,6 +81,34 @@ struct InfoPane: View { } } + @ViewBuilder + private func identicalBuilds(for xcode: Xcode) -> some View { + if !xcode.identicalBuilds.isEmpty { + VStack(alignment: .leading) { + HStack { + Text("Identical Builds") + Image(systemName: "square.fill.on.square.fill") + .foregroundColor(.secondary) + .accessibility(hidden: true) + .help("Sometimes a prerelease and release version are the exact same build. Xcodes will automatically display these versions together.") + } + .font(.headline) + + ForEach(xcode.identicalBuilds, id: \.description) { version in + Text("• \(version.appleDescription)") + .font(.subheadline) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement() + .accessibility(label: Text("Identical Builds")) + .accessibility(value: Text(xcode.identicalBuilds.map(\.appleDescription).joined(separator: ", "))) + .accessibility(hint: Text("Sometimes a prerelease and release version are the exact same build. Xcodes will automatically display these versions together.")) + } else { + EmptyView() + } + } + @ViewBuilder private func releaseNotes(for xcode: Xcode) -> some View { if let releaseNotesURL = xcode.releaseNotesURL { From 70872f441a768c5b7bc5abffa05a9fafe08d491f Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 21:03:47 -0700 Subject: [PATCH 07/10] Make XcodeListViewRow.appState a normal property It doesn't need to be a DynamicProperty because it only invokes its methods. --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 2 +- .../Frontend/XcodeList/XcodeListViewRow.swift | 24 +++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index f88d71c..b790988 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -32,7 +32,7 @@ struct XcodeListView: View { var body: some View { List(visibleXcodes, selection: $selectedXcodeID) { xcode in - XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id) + XcodeListViewRow(xcode: xcode, selected: selectedXcodeID == xcode.id, appState: appState) } } } diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 1d7f099..68c4c3d 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -3,9 +3,9 @@ import SwiftUI import Version struct XcodeListViewRow: View { - @EnvironmentObject var appState: AppState let xcode: Xcode let selected: Bool + let appState: AppState var body: some View { HStack { @@ -112,29 +112,39 @@ struct XcodeListViewRow_Previews: PreviewProvider { Group { XcodeListViewRow( xcode: Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil), - selected: false + selected: false, + appState: AppState() ) XcodeListViewRow( xcode: Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil), - selected: false + selected: false, + appState: AppState() ) XcodeListViewRow( xcode: Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil), - selected: false + selected: false, + appState: AppState() ) XcodeListViewRow( xcode: Xcode(version: Version("12.0.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil), - selected: false + selected: false, + appState: AppState() ) XcodeListViewRow( xcode: Xcode(version: Version("12.0.0+1234A")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil), - selected: false + selected: false, + appState: AppState() + ) + + XcodeListViewRow( + xcode: Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [Version("12.0.0-RC+1234A")!], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil), + selected: false, + appState: AppState() ) } - .environmentObject(AppState()) } } From ef646b7b88e2df967d9c115fb686d85c960c1e6f Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Thu, 4 Feb 2021 21:15:41 -0700 Subject: [PATCH 08/10] Add identical builds indicator to XcodeListViewRow --- Xcodes/Frontend/XcodeList/XcodeListView.swift | 1 + Xcodes/Frontend/XcodeList/XcodeListViewRow.swift | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/XcodeListView.swift b/Xcodes/Frontend/XcodeList/XcodeListView.swift index b790988..4905e78 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListView.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListView.swift @@ -44,6 +44,7 @@ struct XcodeListView_Previews: PreviewProvider { .environmentObject({ () -> AppState in let a = AppState() a.allXcodes = [ + Xcode(version: Version("12.0.0+1234A")!, identicalBuilds: [Version("12.0.0+1234A")!, Version("12.0.0-RC+1234A")!], installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: false, icon: nil), Xcode(version: Version("12.3.0")!, installState: .installed(Path("/Applications/Xcode-12.3.0.app")!), selected: true, icon: nil), Xcode(version: Version("12.2.0")!, installState: .notInstalled, selected: false, icon: nil), Xcode(version: Version("12.1.0")!, installState: .installing(.downloading(progress: configure(Progress(totalUnitCount: 100)) { $0.completedUnitCount = 40 })), selected: false, icon: nil), diff --git a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift index 68c4c3d..723d690 100644 --- a/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift +++ b/Xcodes/Frontend/XcodeList/XcodeListViewRow.swift @@ -12,8 +12,19 @@ struct XcodeListViewRow: View { appIconView(for: xcode) VStack(alignment: .leading) { - Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") - .font(.body) + HStack { + Text(verbatim: "\(xcode.description) \(xcode.version.buildMetadataIdentifiersDisplay)") + .font(.body) + + if !xcode.identicalBuilds.isEmpty { + Image(systemName: "square.fill.on.square.fill") + .font(.subheadline) + .foregroundColor(.secondary) + .accessibility(label: Text("Identical Builds")) + .accessibility(value: Text(xcode.identicalBuilds.map(\.appleDescription).joined(separator: ", "))) + .help("Sometimes a prerelease and release version are the exact same build. Xcodes will automatically display these versions together.") + } + } if case let .installed(path) = xcode.installState { Text(verbatim: path.string) From b8bae4f32ca7d7ef257422aee2f6524051ec79d7 Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sat, 6 Feb 2021 14:56:14 -0700 Subject: [PATCH 09/10] Don't consider versions without build identifiers for identical builds --- Xcodes/Backend/AppState.swift | 8 +++++++- XcodesTests/AppStateUpdateTests.swift | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index da38798..ff163a8 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -450,6 +450,9 @@ class AppState: ObservableObject { // Map all of the available versions into Xcode values that join available and installed Xcode data for display. var newAllXcodes = adjustedAvailableXcodes .filter { availableXcode in + // If we don't have the build identifier, don't attempt to filter prerelease versions with identical build identifiers + guard !availableXcode.version.buildMetadataIdentifiers.isEmpty else { return true } + let availableXcodesWithIdenticalBuildIdentifiers = availableXcodes .filter({ $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers }) @@ -466,7 +469,10 @@ class AppState: ObservableObject { let identicalBuilds: [Version] let prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes .filter { - $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers && !$0.version.prereleaseIdentifiers.isEmpty + return $0.version.buildMetadataIdentifiers == availableXcode.version.buildMetadataIdentifiers && + !$0.version.prereleaseIdentifiers.isEmpty && + // If we don't have the build identifier, don't consider this as a potential identical build + !$0.version.buildMetadataIdentifiers.isEmpty } // If this is the release version, add the identical builds to it if !prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.isEmpty, availableXcode.version.prereleaseIdentifiers.isEmpty { diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index 616e460..39d0d21 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -232,4 +232,30 @@ class AppStateUpdateTests: XCTestCase { XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0+12D4e")!]) XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[Version("12.4.0+12D4e")!, Version("12.4.0-RC+12D4e")!]]) } + + func testIdenticalBuilds_AppleDataSource_DoNotMergeVersionsWithoutBuildIdentifiers() { + Current.defaults.string = { key in + if key == "dataSource" { + return "apple" + } else { + return nil + } + } + + subject.allXcodes = [ + ] + + subject.updateAllXcodes( + availableXcodes: [ + AvailableXcode(version: Version("12.4.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + AvailableXcode(version: Version("12.3.0-RC")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil), + ], + installedXcodes: [ + ], + selectedXcodePath: nil + ) + + XCTAssertEqual(subject.allXcodes.map(\.version), [Version("12.4.0")!, Version("12.3.0-RC")!]) + XCTAssertEqual(subject.allXcodes.map(\.identicalBuilds), [[], []]) + } } From 3208a4d8d25a190f65334ff875bd516919ddcaef Mon Sep 17 00:00:00 2001 From: Brandon Evans Date: Sat, 6 Feb 2021 15:28:41 -0700 Subject: [PATCH 10/10] Put InfoPane content in ScrollView This issue was revealed when I broke the identical builds functionality with the Apple data source. https://github.com/RobotsAndPencils/XcodesApp/pull/100#pullrequestreview-584917125 --- Xcodes/Frontend/XcodeList/InfoPane.swift | 25 ++++++++++++------------ 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/Xcodes/Frontend/XcodeList/InfoPane.swift b/Xcodes/Frontend/XcodeList/InfoPane.swift index 4cbabf6..53d5088 100644 --- a/Xcodes/Frontend/XcodeList/InfoPane.swift +++ b/Xcodes/Frontend/XcodeList/InfoPane.swift @@ -11,8 +11,8 @@ struct InfoPane: View { @SwiftUI.Environment(\.openURL) var openURL: OpenURLAction var body: some View { - Group { - if let xcode = appState.allXcodes.first(where: { $0.id == selectedXcodeID }) { + if let xcode = appState.allXcodes.first(where: { $0.id == selectedXcodeID }) { + ScrollView { VStack(spacing: 16) { icon(for: xcode) @@ -61,12 +61,13 @@ struct InfoPane: View { Spacer() } - } else { - empty + .padding() } + .frame(minWidth: 200, maxWidth: .infinity) + } else { + empty + .frame(minWidth: 200, maxWidth: .infinity) } - .padding() - .frame(minWidth: 200, maxWidth: .infinity) } @ViewBuilder @@ -212,13 +213,11 @@ struct InfoPane: View { @ViewBuilder private var empty: some View { - VStack { - Spacer() - Text("No Xcode Selected") - .font(.title) - .foregroundColor(.secondary) - Spacer() - } + Text("No Xcode Selected") + .font(.title) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() } }