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( [