Merge pull request #100 from RobotsAndPencils/identical-builds

Improve how identical builds are represented in the UI
This commit is contained in:
Brandon Evans 2021-02-08 08:16:32 -07:00 committed by GitHub
commit 9eda0984ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 286 additions and 75 deletions

View file

@ -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
}
}

View file

@ -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() {
@ -422,29 +428,58 @@ class AppState: ObservableObject {
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.
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
}
}
}
// 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 })
// 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 prereleaseAvailableXcodesWithIdenticalBuildIdentifiers = availableXcodes
.filter {
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 {
identicalBuilds = [availableXcode.version] + prereleaseAvailableXcodesWithIdenticalBuildIdentifiers.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
@ -453,6 +488,7 @@ class AppState: ObservableObject {
return Xcode(
version: availableXcode.version,
identicalBuilds: identicalBuilds,
installState: existingXcodeInstallState ?? defaultXcodeInstallState,
selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true,
icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)),

View file

@ -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

View file

@ -25,7 +25,7 @@ extension Version {
versionString += ".\(dp)"
}
case .gm:
versionString += "-GM"
break
case let .gmSeed(gmSeed):
versionString += "-GM.Seed"
if gmSeed > 1 {

View file

@ -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

View file

@ -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)
@ -53,6 +53,7 @@ struct InfoPane: View {
Divider()
releaseNotes(for: xcode)
identicalBuilds(for: xcode)
compatibility(for: xcode)
sdks(for: xcode)
compilers(for: xcode)
@ -60,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
@ -80,6 +82,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 {
@ -183,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()
}
}

View file

@ -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)
}
}
}
@ -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),

View file

@ -3,17 +3,28 @@ 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 {
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)
@ -112,29 +123,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())
}
}

View file

@ -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,13 +121,141 @@ class AppStateUpdateTests: XCTestCase {
XCTAssertEqual(subject.allXcodes.map(\.version), [Version("1.2.3")!, Version("0.0.0+ABC123")!])
}
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),
]
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(result.map(\.version), [Version("12.3.0+12C33")])
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_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" {
return "xcodeReleases"
} else {
return nil
}
}
subject.allXcodes = [
]
Current.files.contentsAtPath = { path in
if path.contains("Info.plist") {
return """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>com.apple.dt.Xcode</string>
<key>CFBundleShortVersionString</key>
<string>12.4.0</string>
</dict>
</plist>
""".data(using: .utf8)
}
else if path.contains("version.plist") {
return """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ProductBuildVersion</key>
<string>12D4e</string>
</dict>
</plist>
""".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")!])
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), [[], []])
}
}