import Combine import Foundation import Path import AppleAPI import Version import LegibleError import os.log import DockProgress /// Downloads and installs Xcodes extension AppState { public func install(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher { install(installationType, downloader: downloader, attemptNumber: 0) .map { _ in Void() } .eraseToAnyPublisher() } private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher { getXcodeArchive(installationType, downloader: downloader) .flatMap { xcode, url -> AnyPublisher in self.installArchivedXcode(xcode, at: url) } .catch { error -> AnyPublisher in switch error { case InstallationError.damagedXIP(let damagedXIPURL): guard attemptNumber < 1 else { return Fail(error: error).eraseToAnyPublisher() } switch installationType { case .version: // If the XIP was just downloaded, remove it and try to recover. do { Logger.appState.error("\(error.legibleLocalizedDescription)") Logger.appState.info("Removing damaged XIP and re-attempting installation.") try Current.files.removeItem(at: damagedXIPURL) return self.install(installationType, downloader: downloader, attemptNumber: attemptNumber + 1) .eraseToAnyPublisher() } catch { return Fail(error: error) .eraseToAnyPublisher() } } default: return Fail(error: error) .eraseToAnyPublisher() } } .handleEvents(receiveOutput: { installedXcode in DispatchQueue.main.async { guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: installedXcode.version) }) else { return } self.allXcodes[index].installState = .installed(installedXcode.path) } }) .eraseToAnyPublisher() } 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.isEquivalent(to: availableXcode.version) }) { return Fail(error: InstallationError.versionAlreadyInstalled(installedXcode)) .eraseToAnyPublisher() } return downloadXcode(availableXcode: availableXcode, downloader: downloader) } } private func downloadXcode(availableXcode: AvailableXcode, downloader: Downloader) -> AnyPublisher<(AvailableXcode, URL), Error> { downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in DispatchQueue.main.async { self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress)) } }) .map { return (availableXcode, $0) } .eraseToAnyPublisher() } public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { // Check to see if the archive is in the expected path in case it was downloaded but failed to install let expectedArchivePath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" // aria2 downloads directly to the destination (instead of into /tmp first) so we need to make sure that the download isn't incomplete let aria2DownloadMetadataPath = expectedArchivePath.parent/(expectedArchivePath.basename() + ".aria2") var aria2DownloadIsIncomplete = false if case .aria2 = downloader, aria2DownloadMetadataPath.exists { aria2DownloadIsIncomplete = true } if Current.files.fileExistsAtPath(expectedArchivePath.string), aria2DownloadIsIncomplete == false { Logger.appState.info("Found existing archive that will be used for installation at \(expectedArchivePath).") return Just(expectedArchivePath.url) .setFailureType(to: Error.self) .eraseToAnyPublisher() } else { let destination = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).\(availableXcode.filename.suffix(fromLast: "."))" switch downloader { case .aria2: let aria2Path = Path(url: Bundle.main.url(forAuxiliaryExecutable: "aria2c")!)! return downloadXcodeWithAria2( availableXcode, to: destination, aria2Path: aria2Path, progressChanged: progressChanged ) case .urlSession: return downloadXcodeWithURLSession( availableXcode, to: destination, progressChanged: progressChanged ) } } } public func downloadXcodeWithAria2(_ availableXcode: AvailableXcode, to destination: Path, aria2Path: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: availableXcode.url) ?? [] let (progress, publisher) = Current.shell.downloadWithAria2( aria2Path, availableXcode.url, destination, cookies ) progressChanged(progress) updateDockIcon(withProgress: progress) return publisher .map { _ in destination.url } .eraseToAnyPublisher() } public func downloadXcodeWithURLSession(_ availableXcode: AvailableXcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher { let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).resumedata" let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string) return attemptResumableTask(maximumRetryCount: 3) { [weak self] resumeData -> AnyPublisher in let (progress, publisher) = Current.network.downloadTask(with: availableXcode.url, to: destination.url, resumingWith: resumeData ?? persistedResumeData) progressChanged(progress) self?.updateDockIcon(withProgress: progress) return publisher .map { $0.saveLocation } .eraseToAnyPublisher() } .handleEvents(receiveCompletion: { completion in self.persistOrCleanUpResumeData(at: resumeDataPath, for: completion) }) .eraseToAnyPublisher() } private func updateDockIcon(withProgress progress: Progress) { DockProgress.style = .bar DockProgress.progressInstance = progress } public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher { do { let destinationURL = Path.root.join("Applications").join("Xcode-\(availableXcode.version.descriptionWithoutBuildMetadata).app").url switch archiveURL.pathExtension { case "xip": return unarchiveAndMoveXIP(availableXcode: availableXcode, at: archiveURL, to: destinationURL) .tryMap { xcodeURL throws -> InstalledXcode in guard let path = Path(url: xcodeURL), Current.files.fileExists(atPath: path.string), let installedXcode = InstalledXcode(path: path) else { throw InstallationError.failedToMoveXcodeToApplications } return installedXcode } .flatMap { installedXcode -> AnyPublisher in do { self.setInstallationStep(of: availableXcode.version, to: .trashingArchive) try Current.files.trashItem(at: archiveURL) self.setInstallationStep(of: availableXcode.version, to: .checkingSecurity) return self.verifySecurityAssessment(of: installedXcode) .combineLatest(self.verifySigningCertificate(of: installedXcode.path.url)) .map { _ in installedXcode } .eraseToAnyPublisher() } catch { return Fail(error: error) .eraseToAnyPublisher() } } .flatMap { installedXcode -> AnyPublisher in self.setInstallationStep(of: availableXcode.version, to: .finishing) return self.enableDeveloperMode() .map { installedXcode } .eraseToAnyPublisher() } .flatMap { installedXcode -> AnyPublisher in self.approveLicense(for: installedXcode) .map { installedXcode } .eraseToAnyPublisher() } .flatMap { installedXcode -> AnyPublisher in self.installComponents(for: installedXcode) .map { installedXcode } .eraseToAnyPublisher() } .eraseToAnyPublisher() case "dmg": throw InstallationError.unsupportedFileFormat(extension: "dmg") default: throw InstallationError.unsupportedFileFormat(extension: archiveURL.pathExtension) } } catch { return Fail(error: error) .eraseToAnyPublisher() } } func unarchiveAndMoveXIP(availableXcode: AvailableXcode, at source: URL, to destination: URL) -> AnyPublisher { self.setInstallationStep(of: availableXcode.version, to: .unarchiving) return Current.shell.unxip(source) .catch { error -> AnyPublisher in if let executionError = error as? ProcessExecutionError { if executionError.standardError.contains("damaged and can’t be expanded") { return Fail(error: InstallationError.damagedXIP(url: source)) .eraseToAnyPublisher() } else if executionError.standardError.contains("can’t be expanded because the selected volume doesn’t have enough free space.") { return Fail(error: InstallationError.notEnoughFreeSpaceToExpandArchive(archivePath: Path(url: source)!, version: availableXcode.version)) .eraseToAnyPublisher() } } return Fail(error: error) .eraseToAnyPublisher() } .tryMap { output -> URL in self.setInstallationStep(of: availableXcode.version, to: .moving(destination: destination.path)) let xcodeURL = source.deletingLastPathComponent().appendingPathComponent("Xcode.app") let xcodeBetaURL = source.deletingLastPathComponent().appendingPathComponent("Xcode-beta.app") if Current.files.fileExists(atPath: xcodeURL.path) { try Current.files.moveItem(at: xcodeURL, to: destination) } else if Current.files.fileExists(atPath: xcodeBetaURL.path) { try Current.files.moveItem(at: xcodeBetaURL, to: destination) } return destination } .handleEvents(receiveCancel: { if Current.files.fileExists(atPath: source.path) { try? Current.files.removeItem(source) } if Current.files.fileExists(atPath: destination.path) { try? Current.files.removeItem(destination) } }) .eraseToAnyPublisher() } public func verifySecurityAssessment(of xcode: InstalledXcode) -> AnyPublisher { return Current.shell.spctlAssess(xcode.path.url) .catch { (error: Swift.Error) -> AnyPublisher in var output = "" if let executionError = error as? ProcessExecutionError { output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n") } return Fail(error: InstallationError.failedSecurityAssessment(xcode: xcode, output: output)) .eraseToAnyPublisher() } .map { _ in Void() } .eraseToAnyPublisher() } func verifySigningCertificate(of url: URL) -> AnyPublisher { return Current.shell.codesignVerify(url) .catch { error -> AnyPublisher in var output = "" if let executionError = error as? ProcessExecutionError { output = [executionError.standardOutput, executionError.standardError].joined(separator: "\n") } return Fail(error: InstallationError.codesignVerifyFailed(output: output)) .eraseToAnyPublisher() } .map { output -> CertificateInfo in // codesign prints to stderr return self.parseCertificateInfo(output.err) } .tryMap { cert in guard cert.teamIdentifier == XcodeTeamIdentifier, cert.authority == XcodeCertificateAuthority else { throw InstallationError.unexpectedCodeSigningIdentity(identifier: cert.teamIdentifier, certificateAuthority: cert.authority) } return Void() } .eraseToAnyPublisher() } public struct CertificateInfo { public var authority: [String] public var teamIdentifier: String public var bundleIdentifier: String } public func parseCertificateInfo(_ rawInfo: String) -> CertificateInfo { var info = CertificateInfo(authority: [], teamIdentifier: "", bundleIdentifier: "") for part in rawInfo.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) { if part.hasPrefix("Authority") { info.authority.append(part.components(separatedBy: "=")[1]) } if part.hasPrefix("TeamIdentifier") { info.teamIdentifier = part.components(separatedBy: "=")[1] } if part.hasPrefix("Identifier") { info.bundleIdentifier = part.components(separatedBy: "=")[1] } } return info } func enableDeveloperMode() -> AnyPublisher { installHelperIfNecessary() .flatMap { Current.helper.devToolsSecurityEnable() } .flatMap { Current.helper.addStaffToDevelopersGroup() } .eraseToAnyPublisher() } func approveLicense(for xcode: InstalledXcode) -> AnyPublisher { installHelperIfNecessary() .flatMap { Current.helper.acceptXcodeLicense(xcode.path.string) } .eraseToAnyPublisher() } func installComponents(for xcode: InstalledXcode) -> AnyPublisher { installHelperIfNecessary() .flatMap { Current.helper.runFirstLaunch(xcode.path.string) } .flatMap { Current.shell.getUserCacheDir().map { $0.out } .combineLatest( Current.shell.buildVersion().map { $0.out }, Current.shell.xcodeBuildVersion(xcode).map { $0.out } ) } .flatMap { cacheDirectory, macOSBuildVersion, toolsVersion in Current.shell.touchInstallCheck(cacheDirectory, macOSBuildVersion, toolsVersion) } .map { _ in Void() } .eraseToAnyPublisher() } func setInstallationStep(of version: Version, to step: InstallationStep) { DispatchQueue.main.async { guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return } self.allXcodes[index].installState = .installing(step) } } } extension AppState { func persistOrCleanUpResumeData(at path: Path, for completion: Subscribers.Completion) { switch completion { case .finished: try? Current.files.removeItem(at: path.url) case .failure(let error): guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return } Current.files.createFile(atPath: path.string, contents: resumeData) } } } public enum InstallationError: LocalizedError, Equatable { case damagedXIP(url: URL) case notEnoughFreeSpaceToExpandArchive(archivePath: Path, version: Version) case failedToMoveXcodeToApplications case failedSecurityAssessment(xcode: InstalledXcode, output: String) case codesignVerifyFailed(output: String) case unexpectedCodeSigningIdentity(identifier: String, certificateAuthority: [String]) case unsupportedFileFormat(extension: String) case missingSudoerPassword case unavailableVersion(Version) case noNonPrereleaseVersionAvailable case noPrereleaseVersionAvailable case missingUsernameOrPassword case versionAlreadyInstalled(InstalledXcode) case invalidVersion(String) case versionNotInstalled(Version) public var errorDescription: String? { switch self { case .damagedXIP(let url): return "The archive \"\(url.lastPathComponent)\" is damaged and can't be expanded." case let .notEnoughFreeSpaceToExpandArchive(archivePath, version): return """ The archive “\(archivePath.basename())” can’t be expanded because the current volume doesn’t have enough free space. Make more space available to expand the archive and then install Xcode \(version.appleDescription) again to start installation from where it left off. """ case .failedToMoveXcodeToApplications: return "Failed to move Xcode to the /Applications directory." case .failedSecurityAssessment(let xcode, let output): return """ Xcode \(xcode.version) failed its security assessment with the following output: \(output) It remains installed at \(xcode.path) if you wish to use it anyways. """ case .codesignVerifyFailed(let output): return """ The downloaded Xcode failed code signing verification with the following output: \(output) """ case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority): return """ The downloaded Xcode doesn't have the expected code signing identity. Got: \(identity) \(certificateAuthority) Expected: \(XcodeTeamIdentifier) \(XcodeCertificateAuthority) """ case .unsupportedFileFormat(let fileExtension): return "xcodes doesn't (yet) support installing Xcode from the \(fileExtension) file format." case .missingSudoerPassword: return "Missing password. Please try again." case let .unavailableVersion(version): return "Could not find version \(version.appleDescription)." case .noNonPrereleaseVersionAvailable: return "No non-prerelease versions available." case .noPrereleaseVersionAvailable: return "No prerelease versions available." case .missingUsernameOrPassword: return "Missing username or a password. Please try again." case let .versionAlreadyInstalled(installedXcode): return "\(installedXcode.version.appleDescription) is already installed at \(installedXcode.path)" case let .invalidVersion(version): return "\(version) is not a valid version number." case let .versionNotInstalled(version): return "\(version.appleDescription) is not installed." } } } public enum InstallationType { case version(AvailableXcode) } let XcodeTeamIdentifier = "59GAB85EFG" let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]