diff --git a/Xcodes/Backend/AppState+Install.swift b/Xcodes/Backend/AppState+Install.swift index 17e4c3a..6ef2ff2 100644 --- a/Xcodes/Backend/AppState+Install.swift +++ b/Xcodes/Backend/AppState+Install.swift @@ -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) } } diff --git a/Xcodes/Backend/AppState+Update.swift b/Xcodes/Backend/AppState+Update.swift index 9400bc7..660d2f0 100644 --- a/Xcodes/Backend/AppState+Update.swift +++ b/Xcodes/Backend/AppState+Update.swift @@ -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 + } } diff --git a/Xcodes/Backend/AppState.swift b/Xcodes/Backend/AppState.swift index 7a1e7de..8be93b1 100644 --- a/Xcodes/Backend/AppState.swift +++ b/Xcodes/Backend/AppState.swift @@ -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 diff --git a/Xcodes/Backend/AvailableXcode.swift b/Xcodes/Backend/AvailableXcode.swift index 8ef4e18..c1810ba 100644 --- a/Xcodes/Backend/AvailableXcode.swift +++ b/Xcodes/Backend/AvailableXcode.swift @@ -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? diff --git a/Xcodes/Backend/Version+.swift b/Xcodes/Backend/Version+.swift index 5fed036..e86d4a5 100644 --- a/Xcodes/Backend/Version+.swift +++ b/Xcodes/Backend/Version+.swift @@ -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 { diff --git a/XcodesTests/AppStateUpdateTests.swift b/XcodesTests/AppStateUpdateTests.swift index ee850bc..b0c668c 100644 --- a/XcodesTests/AppStateUpdateTests.swift +++ b/XcodesTests/AppStateUpdateTests.swift @@ -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")]) + } }