diff --git a/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift b/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift index 5ecabf4..6320935 100644 --- a/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift +++ b/Sources/XcodesKit/Models/Xcodes/XcodeListGrouping.swift @@ -167,7 +167,9 @@ public extension Array { /// Groups arbitrary elements by the major version of a derived Xcode list item. func groupedByMajorVersion(item: (Element) -> XcodeListItem) -> [XcodeListElementMajorVersionGroup] { - Dictionary(grouping: self, by: { item($0).version.major }) + let visibleElements = hidingOlderIdenticalBuilds(item: item) + + return Dictionary(grouping: visibleElements, by: { item($0).version.major }) .map { majorVersion, elements in let minorVersionGroups = Dictionary(grouping: elements, by: { item($0).version.minor }) .map { minorVersion, minorElements in @@ -223,7 +225,9 @@ public extension Array where Element == XcodeListItem { /// Groups Xcode list items by major version, then minor version. func groupedByMajorVersion() -> [XcodeMajorVersionGroup] { - Dictionary(grouping: self, by: { $0.version.major }) + let visibleItems = hidingOlderIdenticalBuilds(item: { $0 }) + + return Dictionary(grouping: visibleItems, by: { $0.version.major }) .map { majorVersion, xcodes in let minorVersionGroups = Dictionary(grouping: xcodes, by: { $0.version.minor }) .map { minorVersion, minorXcodes in @@ -275,6 +279,30 @@ private struct XcodeListFilteredElement { let item: XcodeListItem } +private extension Array { + func hidingOlderIdenticalBuilds(item: (Element) -> XcodeListItem) -> [Element] { + let items = map(item) + let visibleIDs = Set(items.map(\.id)) + let identicalBuildGroups = items + .map(\.identicalBuilds) + .filter { $0.isEmpty == false } + + return filter { element in + let currentID = item(element).id + + return identicalBuildGroups.contains { identicalBuilds in + guard identicalBuilds.contains(currentID) else { return false } + + return identicalBuilds.contains { identicalID in + identicalID.architectures == currentID.architectures && + identicalID.version > currentID.version && + visibleIDs.contains(identicalID) + } + } == false + } + } +} + private extension Array { func applying(_ filters: XcodeListFilters) -> [Element] where Element: XcodeListFilterable { var elements = self diff --git a/Sources/XcodesKit/Services/Aria2DownloadService.swift b/Sources/XcodesKit/Services/Aria2DownloadService.swift index 2b2f307..11ea699 100644 --- a/Sources/XcodesKit/Services/Aria2DownloadService.swift +++ b/Sources/XcodesKit/Services/Aria2DownloadService.swift @@ -38,11 +38,16 @@ public struct Aria2DownloadService: Sendable { progress.updateFromAria2(string: string) }, - failureHandler: { process in + failureHandler: { process, stdout, stderr in if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { return aria2cError } else { - return ProcessExecutionError(process: process, standardOutput: "", standardError: "") + return ProcessExecutionError( + process: process, + terminationStatus: process.terminationStatus, + standardOutput: stdout, + standardError: stderr + ) } }, successHandler: { diff --git a/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift b/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift index f8faf41..9db33bc 100644 --- a/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift +++ b/Sources/XcodesKit/Services/XcodebuildRuntimeDownloadService.swift @@ -34,8 +34,13 @@ public struct XcodebuildRuntimeDownloadService: Sendable { outputHandler: { string, progress in progress.updateFromXcodebuild(text: string) }, - failureHandler: { process in - ProcessExecutionError(process: process, standardOutput: "", standardError: "") + failureHandler: { process, stdout, stderr in + ProcessExecutionError( + process: process, + terminationStatus: process.terminationStatus, + standardOutput: stdout, + standardError: stderr + ) } ).stream() } diff --git a/Sources/XcodesKit/Shell/ProcessProgressStream.swift b/Sources/XcodesKit/Shell/ProcessProgressStream.swift index e499ba7..edc716e 100644 --- a/Sources/XcodesKit/Shell/ProcessProgressStream.swift +++ b/Sources/XcodesKit/Shell/ProcessProgressStream.swift @@ -3,7 +3,7 @@ import os final class ProcessProgressStreamRunner: Sendable { typealias OutputHandler = @Sendable (String, Progress) -> Void - typealias FailureHandler = @Sendable (Process) -> Error + typealias FailureHandler = @Sendable (Process, String, String) -> Error typealias SuccessHandler = @Sendable () -> Error? private let process: Process @@ -54,20 +54,21 @@ final class ProcessProgressStreamRunner: Sendable { let stdErrPipe = Pipe() process.standardError = stdErrPipe - let handleData: @Sendable (FileHandle) -> Void = { [weak self] handle in + let handleData: @Sendable (FileHandle, OutputStream) -> Void = { [weak self] handle, stream in guard let self else { return } let data = handle.availableData guard data.isEmpty == false else { return } let string = String(decoding: data, as: UTF8.self) self.continuation.withLock { + self.append(data, to: stream) self.outputHandler(string, self.progress) _ = $0?.yield(self.progress) } } - stdOutPipe.fileHandleForReading.readabilityHandler = handleData - stdErrPipe.fileHandleForReading.readabilityHandler = handleData + stdOutPipe.fileHandleForReading.readabilityHandler = { handleData($0, .stdout) } + stdErrPipe.fileHandleForReading.readabilityHandler = { handleData($0, .stderr) } process.terminationHandler = { [weak self] process in self?.finish(process: process) @@ -95,7 +96,8 @@ final class ProcessProgressStreamRunner: Sendable { consumeRemainingOutput() guard process.terminationReason == .exit, process.terminationStatus == 0 else { - finish(throwing: failureHandler(process)) + let output = output() + finish(throwing: failureHandler(process, output.stdout, output.stderr)) return } @@ -126,11 +128,11 @@ final class ProcessProgressStreamRunner: Sendable { } private func consumeRemainingOutput() { - consumeRemainingOutput(from: process.standardOutput as? Pipe) - consumeRemainingOutput(from: process.standardError as? Pipe) + consumeRemainingOutput(from: process.standardOutput as? Pipe, stream: .stdout) + consumeRemainingOutput(from: process.standardError as? Pipe, stream: .stderr) } - private func consumeRemainingOutput(from pipe: Pipe?) { + private func consumeRemainingOutput(from pipe: Pipe?, stream: OutputStream) { guard let pipe else { return } let data = pipe.fileHandleForReading.readDataToEndOfFile() @@ -138,8 +140,41 @@ final class ProcessProgressStreamRunner: Sendable { let string = String(decoding: data, as: UTF8.self) continuation.withLock { + append(data, to: stream) outputHandler(string, progress) _ = $0?.yield(progress) } } + + private let capturedOutput = OSAllocatedUnfairLock(initialState: OutputStorage()) + + private func append(_ data: Data, to stream: OutputStream) { + capturedOutput.withLock { + switch stream { + case .stdout: + $0.stdout.append(data) + case .stderr: + $0.stderr.append(data) + } + } + } + + private func output() -> (stdout: String, stderr: String) { + capturedOutput.withLock { + ( + String(data: $0.stdout, encoding: .utf8) ?? "", + String(data: $0.stderr, encoding: .utf8) ?? "" + ) + } + } + + private enum OutputStream { + case stdout + case stderr + } + + private struct OutputStorage: Sendable { + var stdout = Data() + var stderr = Data() + } } diff --git a/Tests/XcodesKitTests/XcodeListGroupingTests.swift b/Tests/XcodesKitTests/XcodeListGroupingTests.swift index 001324d..9b8b265 100644 --- a/Tests/XcodesKitTests/XcodeListGroupingTests.swift +++ b/Tests/XcodesKitTests/XcodeListGroupingTests.swift @@ -57,6 +57,44 @@ final class XcodeListGroupingTests: XCTestCase { XCTAssertTrue(minorGroup.hasInstalling) } + func testGroupedVersionsHideOlderIdenticalBuildsWhenNewerBuildIsVisible() throws { + let releaseID = XcodeID(version: try XCTUnwrap(Version("26.5.0+17F76"))) + let releaseCandidateID = XcodeID(version: try XCTUnwrap(Version("26.5.0-Release.Candidate+17F76"))) + let items = try [ + item("26.5.0+17F76", identicalBuilds: [releaseID, releaseCandidateID]), + item("26.5.0-Release.Candidate+17F76") + ] + + let versions = try XCTUnwrap(items.groupedByMajorVersion().first?.versions.map(\.version)) + + XCTAssertEqual(versions, [try XCTUnwrap(Version("26.5.0+17F76"))]) + } + + func testGroupedVersionsKeepOlderIdenticalBuildsWhenNewerBuildIsFilteredOut() throws { + let releaseID = XcodeID(version: try XCTUnwrap(Version("26.5.0+17F76"))) + let releaseCandidateID = XcodeID(version: try XCTUnwrap(Version("26.5.0-Release.Candidate+17F76"))) + let items = try [ + item("26.5.0-Release.Candidate+17F76", identicalBuilds: [releaseID, releaseCandidateID]) + ] + + let versions = try XCTUnwrap(items.groupedByMajorVersion().first?.versions.map(\.version)) + + XCTAssertEqual(versions, [try XCTUnwrap(Version("26.5.0-Release.Candidate+17F76"))]) + } + + func testGenericGroupedVersionsHideOlderIdenticalBuildsWhenNewerBuildIsVisible() throws { + let releaseID = XcodeID(version: try XCTUnwrap(Version("26.5.0+17F76"))) + let releaseCandidateID = XcodeID(version: try XCTUnwrap(Version("26.5.0-Release.Candidate+17F76"))) + let items = try [ + PositionedXcodeListItem(position: 0, item: item("26.5.0+17F76", identicalBuilds: [releaseID, releaseCandidateID])), + PositionedXcodeListItem(position: 1, item: item("26.5.0-Release.Candidate+17F76")) + ] + + let groups = items.groupedByMajorVersion(item: \.item) + + XCTAssertEqual(groups.first?.versions.map(\.position), [0]) + } + func testAppliesVersionArchitectureSearchAndInstalledFilters() throws { let installedPath = try XCTUnwrap(Path("/Applications/Xcode-16.0.app")) let items = try [ @@ -109,12 +147,14 @@ final class XcodeListGroupingTests: XCTestCase { private func item( _ version: String, + identicalBuilds: [XcodeID] = [], installState: XcodeInstallState = .notInstalled, selected: Bool = false, architectures: [Architecture]? = nil ) throws -> XcodeListItem { XcodeListItem( version: try XCTUnwrap(Version(version)), + identicalBuilds: identicalBuilds, installState: installState, selected: selected, architectures: architectures diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 8ef13e5..946679a 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -2041,7 +2041,7 @@ final class XcodesKitTests: XCTestCase { collector.append(string) progress.updateFromXcodebuild(text: string) }, - failureHandler: { process in + failureHandler: { process, _, _ in ProcessExecutionError(process: process, standardOutput: "", standardError: "") } ).stream() @@ -2074,7 +2074,7 @@ final class XcodesKitTests: XCTestCase { outputHandler: { string, _ in collector.append(string) }, - failureHandler: { process in + failureHandler: { process, _, _ in ProcessExecutionError(process: process, standardOutput: "", standardError: "") } ).stream() @@ -2087,19 +2087,19 @@ final class XcodesKitTests: XCTestCase { func testProcessProgressStreamRunnerThrowsFailureHandlerError() async { enum TestError: Error, Equatable { - case failed(Int32) + case failed(Int32, String, String) } let process = Process() process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", "exit 12"] + process.arguments = ["-c", "printf 'stdout text'; printf 'stderr text' >&2; exit 12"] let stream = ProcessProgressStreamRunner( process: process, progress: Progress(), outputHandler: { _, _ in }, - failureHandler: { process in - TestError.failed(process.terminationStatus) + failureHandler: { process, stdout, stderr in + TestError.failed(process.terminationStatus, stdout, stderr) } ).stream() @@ -2107,7 +2107,7 @@ final class XcodesKitTests: XCTestCase { for try await _ in stream {} XCTFail("Expected process failure to throw") } catch { - XCTAssertEqual(error as? TestError, .failed(12)) + XCTAssertEqual(error as? TestError, .failed(12, "stdout text", "stderr text")) } }