Merge pull request #49 from RobotsAndPencils/version-comparison-and-updating

Version comparison and updating
This commit is contained in:
Brandon Evans 2021-01-16 13:04:38 -07:00 committed by GitHub
commit 0161e6b43d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 186 additions and 63 deletions

View file

@ -44,7 +44,7 @@ extension AppState {
}
.handleEvents(receiveOutput: { installedXcode in
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version == installedXcode.version || $0.version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) }) else { return }
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { return }
self.allXcodes[index].installState = .installed
}
})
@ -54,7 +54,7 @@ extension AppState {
private func getXcodeArchive(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> {
switch installationType {
case .version(let availableXcode):
if let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.isEqualWithoutBuildMetadataIdentifiers(to: availableXcode.version) }) {
if let installedXcode = Current.files.installedXcodes(Path.root/"Applications").first(where: { $0.version.isEquivalent(to: availableXcode.version) }) {
return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode))
.eraseToAnyPublisher()
}
@ -344,7 +344,7 @@ extension AppState {
func setInstallationStep(of version: Version, to step: InstallationStep) {
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.buildMetadataIdentifiers == version.buildMetadataIdentifiers || $0.version.isEquivalentForDeterminingIfInstalled(toInstalled: version) }) else { return }
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
self.allXcodes[index].installState = .installing(step)
}
}

View file

@ -70,7 +70,7 @@ extension AppState {
// /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.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false
} + prereleaseXcodes
return xcodes
}
@ -200,6 +200,31 @@ 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

@ -258,6 +258,7 @@ class AppState: ObservableObject {
.flatMap { [unowned self] in
self.install(.version(availableXcode), downloader: .aria2)
}
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [unowned self] completion in
self.installationPublishers[id] = nil
@ -356,44 +357,69 @@ class AppState: ObservableObject {
}
func updateAllXcodes(availableXcodes: [AvailableXcode], installedXcodes: [InstalledXcode], selectedXcodePath: String?) {
var allXcodeVersions = availableXcodes.map { $0.version }
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 {
// If an installed version isn't listed online, add the installed version
if !allXcodeVersions.contains(where: { version in
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version)
}) {
allXcodeVersions.append(installedXcode.version)
// 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 with build metadata
else if let index = allXcodeVersions.firstIndex(where: { version in
version.isEquivalentForDeterminingIfInstalled(toInstalled: installedXcode.version) &&
version.buildMetadataIdentifiers.isEmpty
// 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
}) {
allXcodeVersions[index] = installedXcode.version
adjustedAvailableXcodes[index].version = installedXcode.version
}
}
allXcodes = allXcodeVersions
.sorted(by: >)
.map { xcodeVersion in
let installedXcode = installedXcodes.first(where: { xcodeVersion.isEquivalentForDeterminingIfInstalled(toInstalled: $0.version) })
let availableXcode = availableXcodes.first { $0.version == xcodeVersion }
let existingXcode = allXcodes.first { $0.version == xcodeVersion }
// Map all of the available versions into Xcode values that join available and installed Xcode data for display.
var newAllXcodes = adjustedAvailableXcodes
.map { availableXcode -> Xcode in
let installedXcode = installedXcodes.first(where: { installedXcode in
availableXcode.version.isEquivalent(to: installedXcode.version)
})
let defaultInstallState: XcodeInstallState = installedXcode != nil ? .installed : .notInstalled
// If the existing install state is "installing", keep it
let existingXcodeInstallState = allXcodes.first { $0.version == availableXcode.version && $0.installing }?.installState
// Otherwise, determine it from whether there's an installed Xcode
let defaultXcodeInstallState: XcodeInstallState = installedXcode != nil ? .installed : .notInstalled
return Xcode(
version: xcodeVersion,
installState: existingXcode?.installState ?? defaultInstallState,
version: availableXcode.version,
installState: existingXcodeInstallState ?? defaultXcodeInstallState,
selected: installedXcode != nil && selectedXcodePath?.hasPrefix(installedXcode!.path.string) == true,
path: installedXcode?.path.string,
icon: (installedXcode?.path.string).map(NSWorkspace.shared.icon(forFile:)),
requiredMacOSVersion: availableXcode?.requiredMacOSVersion,
releaseNotesURL: availableXcode?.releaseNotesURL,
sdks: availableXcode?.sdks,
compilers: availableXcode?.compilers
requiredMacOSVersion: availableXcode.requiredMacOSVersion,
releaseNotesURL: availableXcode.releaseNotesURL,
sdks: availableXcode.sdks,
compilers: availableXcode.compilers
)
}
// If an installed version isn't listed in the available versions, add the installed version
// Xcode Releases should have all versions
// Apple didn't used to keep all prerelease versions around but has started to recently
for installedXcode in installedXcodes {
if !newAllXcodes.contains(where: { xcode in xcode.version.isEquivalent(to: installedXcode.version) }) {
newAllXcodes.append(
Xcode(
version: installedXcode.version,
installState: .installed,
selected: selectedXcodePath?.hasPrefix(installedXcode.path.string) == true,
path: installedXcode.path.string,
icon: NSWorkspace.shared.icon(forFile: installedXcode.path.string)
)
)
}
}
self.allXcodes = newAllXcodes.sorted { $0.version > $1.version }
}
// MARK: - Private

View file

@ -5,7 +5,7 @@ import struct XCModel.Compilers
/// A version of Xcode that's available for installation
public struct AvailableXcode: Codable {
public let version: Version
public var version: Version
public let url: URL
public let filename: String
public let releaseDate: Date?

View file

@ -1,41 +1,24 @@
import Version
public extension Version {
func isEqualWithoutBuildMetadataIdentifiers(to other: Version) -> Bool {
return major == other.major &&
minor == other.minor &&
patch == other.patch &&
prereleaseIdentifiers == other.prereleaseIdentifiers
}
/// If release versions, don't compare build metadata because that's not provided in the /downloads/more list
/// if beta versions, compare build metadata because it's available in versions.plist
func isEquivalentForDeterminingIfInstalled(toInstalled installed: Version) -> Bool {
let isBeta = !prereleaseIdentifiers.isEmpty
let otherIsBeta = !installed.prereleaseIdentifiers.isEmpty
if isBeta && otherIsBeta {
if buildMetadataIdentifiers.isEmpty {
return major == installed.major &&
minor == installed.minor &&
patch == installed.patch &&
prereleaseIdentifiers.map { $0.lowercased() } == installed.prereleaseIdentifiers.map { $0.lowercased() }
}
else {
return major == installed.major &&
minor == installed.minor &&
patch == installed.patch &&
prereleaseIdentifiers.map { $0.lowercased() } == installed.prereleaseIdentifiers.map { $0.lowercased() } &&
buildMetadataIdentifiers.map { $0.lowercased() } == installed.buildMetadataIdentifiers.map { $0.lowercased() }
}
/// Determines if two Xcode versions should be treated equivalently. This is not the same as equality.
///
/// We need a way to determine if two Xcode versions are the same without always having full information, and supporting different data sources.
/// For example, the Apple data source often doesn't have build metadata identifiers.
func isEquivalent(to other: Version) -> Bool {
// If we don't have build metadata identifiers for both Versions, compare major, minor, patch and prerelease identifiers.
if buildMetadataIdentifiers.isEmpty || other.buildMetadataIdentifiers.isEmpty {
return major == other.major &&
minor == other.minor &&
patch == other.patch &&
prereleaseIdentifiers.map { $0.lowercased() } == other.prereleaseIdentifiers.map { $0.lowercased() }
// If we have build metadata identifiers for both, we can ignore the prerelease identifiers.
} else {
return major == other.major &&
minor == other.minor &&
patch == other.patch &&
buildMetadataIdentifiers.map { $0.lowercased() } == other.buildMetadataIdentifiers.map { $0.lowercased() }
}
else if !isBeta && !otherIsBeta {
return major == installed.major &&
minor == installed.minor &&
patch == installed.patch
}
return false
}
var descriptionWithoutBuildMetadata: String {

View file

@ -27,4 +27,93 @@ class AppStateUpdateTests: XCTestCase {
XCTAssertEqual(subject.allXcodes[0].installState, .installing(.unarchiving))
}
func testRemovesUninstalledVersion() throws {
subject.allXcodes = [
Xcode(version: Version("0.0.0")!, installState: .installed, selected: true, path: "/Applications/Xcode-0.0.0.app", icon: NSImage(systemSymbolName: "app.fill", accessibilityDescription: nil))
]
subject.updateAllXcodes(
availableXcodes: [
AvailableXcode(version: Version("0.0.0")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)
],
installedXcodes: [
],
selectedXcodePath: nil
)
XCTAssertEqual(subject.allXcodes[0].installState, .notInstalled)
}
func testDeterminesIfInstalledByBuildMetadataAlone() throws {
subject.allXcodes = [
]
subject.updateAllXcodes(
availableXcodes: [
// Note "GM" prerelease identifier
AvailableXcode(version: Version("0.0.0-GM+ABC123")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil)
],
installedXcodes: [
InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!
],
selectedXcodePath: nil
)
XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!)
XCTAssertEqual(subject.allXcodes[0].installState, .installed)
XCTAssertEqual(subject.allXcodes[0].selected, false)
XCTAssertEqual(subject.allXcodes[0].path, "/Applications/Xcode-0.0.0.app")
}
func testAdjustedVersionsAreUsedToLookupAvailableXcode() throws {
subject.allXcodes = [
]
subject.updateAllXcodes(
availableXcodes: [
// Note "GM" prerelease identifier
AvailableXcode(version: Version("0.0.0-GM+ABC123")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil, sdks: .init(iOS: .init("14.3")))
],
installedXcodes: [
InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!
],
selectedXcodePath: nil
)
XCTAssertEqual(subject.allXcodes[0].version, Version("0.0.0+ABC123")!)
XCTAssertEqual(subject.allXcodes[0].installState, .installed)
XCTAssertEqual(subject.allXcodes[0].selected, false)
XCTAssertEqual(subject.allXcodes[0].path, "/Applications/Xcode-0.0.0.app")
// XCModel types aren't equatable, so just check for non-nil for now
XCTAssertNotNil(subject.allXcodes[0].sdks)
}
func testAppendingInstalledVersionThatIsNotAvailable() {
subject.allXcodes = [
]
subject.updateAllXcodes(
availableXcodes: [
AvailableXcode(version: Version("1.2.3")!, url: URL(string: "https://apple.com/xcode.xip")!, filename: "mock.xip", releaseDate: nil, sdks: .init(iOS: .init("14.3")))
],
installedXcodes: [
// There's a version installed which for some reason isn't listed online
InstalledXcode(path: Path("/Applications/Xcode-0.0.0.app")!)!
],
selectedXcodePath: nil
)
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),
]
)
XCTAssertEqual(result.map(\.version), [Version("12.3.0+12C33")])
}
}