diff --git a/Sources/XcodesKit/RuntimeInstaller.swift b/Sources/XcodesKit/RuntimeInstaller.swift index 614aff1f..75c89196 100644 --- a/Sources/XcodesKit/RuntimeInstaller.swift +++ b/Sources/XcodesKit/RuntimeInstaller.swift @@ -91,9 +91,14 @@ public final class RuntimeInstaller: Sendable { public func downloadAndInstallRuntime(identifier: String, to destinationDirectory: Path, with downloader: Downloader, shouldDelete: Bool, architectures: [ArchitectureFilter] = []) async throws { let matchedRuntime = try await getMatchingRuntime(identifier: identifier, architectures: architectures) + let runtimeName = downloadName(for: matchedRuntime, architectures: architectures) + + if await runtimeIsAlreadyInstalled(matchedRuntime) { + Current.logging.log("Runtime \(runtimeName) is already installed") + return + } let method = try await installMethod(for: matchedRuntime) - let runtimeName = downloadName(for: matchedRuntime, architectures: architectures) switch method { case .archive: @@ -342,6 +347,17 @@ public final class RuntimeInstaller: Sendable { ) != nil } + private func runtimeIsAlreadyInstalled(_ runtime: DownloadableRuntime) async -> Bool { + guard let installedRuntimes = try? await runtimeService.localInstalledRuntimes() else { + return false + } + + return RuntimeInstallationLookupService().coreSimulatorImage( + for: runtime, + in: installedRuntimes + ) != nil + } + private func isDuplicateRuntimeInstallError(_ error: Swift.Error) -> Bool { guard let error = error as? ProcessExecutionError else { return false } let output = error.standardOutput + "\n" + error.standardError diff --git a/Tests/XcodesKitTests/RuntimeTests.swift b/Tests/XcodesKitTests/RuntimeTests.swift index c6b9b4db..7d660d12 100644 --- a/Tests/XcodesKitTests/RuntimeTests.swift +++ b/Tests/XcodesKitTests/RuntimeTests.swift @@ -293,7 +293,7 @@ final class RuntimeTests: XCTestCase { XCTAssertFalse(log.value.contains("Apple Silicon (arm64)")) } - func test_downloadAndInstallRuntimeTreatsDuplicateXcodebuildRuntimeAsAlreadyInstalled() async throws { + func test_downloadAndInstallRuntimeSkipsAlreadyInstalledXcodebuildRuntime() async throws { let log = LockedBox("") let attempts = LockedBox(0) XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } @@ -328,10 +328,37 @@ final class RuntimeTests: XCTestCase { shouldDelete: true ) - XCTAssertEqual(attempts.value, 1) + XCTAssertEqual(attempts.value, 0) XCTAssertTrue(log.value.contains("Runtime iOS 16.0 - Apple Silicon (arm64) is already installed")) } + func test_downloadAndInstallRuntimeSkipsAlreadyInstalledDiskImage() async throws { + let log = LockedBox("") + let didInstall = LockedBox(false) + XcodesCLIKit.Current.logging.log = { log.append($0 + "\n") } + Current.shell.isatty = { false } + Current.files.contentsAtPath = { path in + guard path == "/Library/Developer/CoreSimulator/images/images.plist" else { return nil } + return Self.installedRuntimeImagesPlistData() + } + Current.shell.installRuntimeImage = { _ in + didInstall.set(true) + return Shell.processOutputMock + } + mockDownloadables(data: Self.duplicateArchitectureRuntimePlistData()) + + try await runtimeInstaller.downloadAndInstallRuntime( + identifier: "iOS 16.0", + to: .xcodesCaches, + with: .urlSession, + shouldDelete: true, + architectures: [.variant(.appleSilicon)] + ) + + XCTAssertFalse(didInstall.value) + XCTAssertTrue(log.value.contains("Runtime iOS 16.0 is already installed")) + } + func test_installStepsForPackage() async throws { mockDownloadables() let expectedSteps = [