mirror of
https://github.com/XcodesOrg/XcodesApp.git
synced 2026-03-25 08:55:46 +00:00
Merge pull request #49 from RobotsAndPencils/version-comparison-and-updating
Version comparison and updating
This commit is contained in:
commit
0161e6b43d
6 changed files with 186 additions and 63 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue