Files
xcodesApp-mirror/Xcodes/Backend/AppState+Install.swift
Matt Kiazyk cf85e2fc5a Merge pull request #569 from abiligiri/feature/managed_preferences
Disallow changes to managed preferences
2024-10-15 21:38:49 -05:00

624 lines
30 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Combine
import Foundation
import Path
import AppleAPI
import Version
import LegibleError
import os.log
import DockProgress
import XcodesKit
/// Downloads and installs Xcodes
extension AppState {
// check to see if we should auto install for the user
public func autoInstallIfNeeded() {
guard let storageValue = Current.defaults.get(forKey: "autoInstallation") as? Int, let autoInstallType = AutoInstallationType(rawValue: storageValue) else { return }
if autoInstallType == .none { return }
// get newest xcode version
guard let newestXcode = allXcodes.first, newestXcode.installState == .notInstalled else {
Logger.appState.info("User has latest Xcode already installed")
return
}
if autoInstallType == .newestBeta {
Logger.appState.info("Auto installing newest Xcode Beta")
// install it, as user doesn't have it installed and it's either latest beta or latest release
checkMinVersionAndInstall(id: newestXcode.id)
} else if autoInstallType == .newestVersion && newestXcode.version.isNotPrerelease {
Logger.appState.info("Auto installing newest Xcode")
checkMinVersionAndInstall(id: newestXcode.id)
} else {
Logger.appState.info("No new Xcodes version found to auto install")
}
}
public func install(_ installationType: InstallationType, downloader: Downloader) -> AnyPublisher<Void, Error> {
install(installationType, downloader: downloader, attemptNumber: 0)
.map { _ in Void() }
.eraseToAnyPublisher()
}
private func install(_ installationType: InstallationType, downloader: Downloader, attemptNumber: Int) -> AnyPublisher<InstalledXcode, Error> {
Logger.appState.info("Using \(downloader) downloader")
setupDockProgress()
return validateSession()
.flatMap { _ in
self.getXcodeArchive(installationType, downloader: downloader)
}
.flatMap { xcode, url -> AnyPublisher<InstalledXcode, Swift.Error> in
self.installArchivedXcode(xcode, at: url)
}
.catch { error -> AnyPublisher<InstalledXcode, Swift.Error> in
self.resetDockProgressTracking()
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.installDirectory).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> {
self.downloadOrUseExistingArchive(for: availableXcode, downloader: downloader, progressChanged: { [unowned self] progress in
DispatchQueue.main.async {
self.setInstallationStep(of: availableXcode.version, to: .downloading(progress: progress))
self.overallProgress.addChild(progress, withPendingUnitCount: AppState.totalProgressUnits - AppState.unxipProgressWeight)
}
})
.map { return (availableXcode, $0) }
.eraseToAnyPublisher()
}
public func downloadOrUseExistingArchive(for availableXcode: AvailableXcode, downloader: Downloader, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
// 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<URL, Error> {
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)
return publisher
.map { _ in destination.url }
.eraseToAnyPublisher()
}
public func downloadXcodeWithURLSession(_ availableXcode: AvailableXcode, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> AnyPublisher<URL, Error> {
let resumeDataPath = Path.xcodesApplicationSupport/"Xcode-\(availableXcode.version).resumedata"
let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string)
return attemptResumableTask(maximumRetryCount: 3) { resumeData -> AnyPublisher<URL, Error> in
let (progress, publisher) = Current.network.downloadTask(with: availableXcode.url,
to: destination.url,
resumingWith: resumeData ?? persistedResumeData)
progressChanged(progress)
return publisher
.map { $0.saveLocation }
.eraseToAnyPublisher()
}
.handleEvents(receiveCompletion: { completion in
self.persistOrCleanUpResumeData(at: resumeDataPath, for: completion)
})
.eraseToAnyPublisher()
}
public func installArchivedXcode(_ availableXcode: AvailableXcode, at archiveURL: URL) -> AnyPublisher<InstalledXcode, Error> {
unxipProgress.completedUnitCount = 0
overallProgress.addChild(unxipProgress, withPendingUnitCount: AppState.unxipProgressWeight)
do {
let destinationURL = Path.installDirectory.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<InstalledXcode, Error> 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<InstalledXcode, Error> in
self.setInstallationStep(of: availableXcode.version, to: .finishing)
return self.performPostInstallSteps(for: installedXcode)
.map { installedXcode }
// Show post-install errors but don't fail because of them
.handleEvents(receiveCompletion: { [unowned self] completion in
if case let .failure(error) = completion {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.InstallArchive.Error.Title"), message: error.legibleLocalizedDescription)
}
resetDockProgressTracking()
})
.catch { _ in
Just(installedXcode)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
.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<URL, Swift.Error> {
self.setInstallationStep(of: availableXcode.version, to: .unarchiving)
return unxipOrUnxipExperiment(source)
.catch { error -> AnyPublisher<ProcessOutput, Swift.Error> in
if let executionError = error as? ProcessExecutionError {
if executionError.standardError.contains("damaged and cant be expanded") {
return Fail(error: InstallationError.damagedXIP(url: source))
.eraseToAnyPublisher()
} else if executionError.standardError.contains("cant be expanded because the selected volume doesnt 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()
}
func unxipOrUnxipExperiment(_ source: URL) -> AnyPublisher<ProcessOutput, Error> {
if unxipExperiment {
// All hard work done by https://github.com/saagarjha/unxip
// Compiled to binary with `swiftc -parse-as-library -O unxip.swift`
return Current.shell.unxipExperiment(source)
} else {
return Current.shell.unxip(source)
}
}
public func verifySecurityAssessment(of xcode: InstalledXcode) -> AnyPublisher<Void, Error> {
return Current.shell.spctlAssess(xcode.path.url)
.catch { (error: Swift.Error) -> AnyPublisher<ProcessOutput, Error> 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<Void, Error> {
return Current.shell.codesignVerify(url)
.catch { error -> AnyPublisher<ProcessOutput, Error> 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
}
// MARK: - Post-Install
/// Attemps to install the helper once, then performs all post-install steps
public func performPostInstallSteps(for xcode: InstalledXcode) {
performPostInstallSteps(for: xcode)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
self.error = error
self.presentedAlert = .generic(title: localizeString("Alert.PostInstall.Title"), message: error.legibleLocalizedDescription)
}
},
receiveValue: {}
)
.store(in: &cancellables)
}
/// Attemps to install the helper once, then performs all post-install steps
public func performPostInstallSteps(for xcode: InstalledXcode) -> AnyPublisher<Void, Error> {
let postInstallPublisher: AnyPublisher<Void, Error> =
Deferred { [unowned self] in
self.installHelperIfNecessary()
}
.flatMap { [unowned self] in
self.enableDeveloperMode()
}
.flatMap { [unowned self] in
self.approveLicense(for: xcode)
}
.flatMap { [unowned self] in
self.installComponents(for: xcode)
}
.mapError { [unowned self] error in
Logger.appState.error("Performing post-install steps failed: \(error.legibleLocalizedDescription)")
return InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState)
}
.eraseToAnyPublisher()
guard helperInstallState == .installed else {
// If the helper isn't installed yet then we need to prepare the user for the install prompt,
// and then actually perform the installation,
// and the post-install steps need to wait until that is complete.
// This subject, which completes upon isPreparingUserForActionRequiringHelper being invoked, is used to achieve that.
// This is not the most straightforward code I've ever written...
let helperInstallConsentSubject = PassthroughSubject<Void, Error>()
// Need to dispatch this to avoid duplicate alerts,
// the second of which will crash when force-unwrapping isPreparingUserForActionRequiringHelper
DispatchQueue.main.async {
self.isPreparingUserForActionRequiringHelper = { [unowned self] userConsented in
if userConsented {
helperInstallConsentSubject.send()
} else {
Logger.appState.info("User did not consent to installing helper during post-install steps.")
helperInstallConsentSubject.send(
completion: .failure(
InstallationError.postInstallStepsNotPerformed(version: xcode.version, helperInstallState: self.helperInstallState)
)
)
}
}
self.presentedAlert = .privilegedHelper
}
unxipProgress.completedUnitCount = AppState.totalProgressUnits
resetDockProgressTracking()
return helperInstallConsentSubject
.flatMap {
postInstallPublisher
}
.eraseToAnyPublisher()
}
return postInstallPublisher
}
private func enableDeveloperMode() -> AnyPublisher<Void, Error> {
Current.helper.devToolsSecurityEnable()
.flatMap {
Current.helper.addStaffToDevelopersGroup()
}
.eraseToAnyPublisher()
}
private func approveLicense(for xcode: InstalledXcode) -> AnyPublisher<Void, Error> {
Current.helper.acceptXcodeLicense(xcode.path.string)
.eraseToAnyPublisher()
}
private func installComponents(for xcode: InstalledXcode) -> AnyPublisher<Void, Swift.Error> {
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()
}
// MARK: - Dock Progress Tracking
private func setupDockProgress() {
Task { @MainActor in
DockProgress.progressInstance = nil
DockProgress.style = .bar
let progress = Progress(totalUnitCount: AppState.totalProgressUnits)
progress.kind = .file
progress.fileOperationKind = .downloading
overallProgress = progress
DockProgress.progressInstance = overallProgress
}
}
func resetDockProgressTracking() {
Task { @MainActor in
DockProgress.progress = 1 // Only way to completely remove overlay with DockProgress is setting progress to complete
}
}
// MARK: -
func setInstallationStep(of version: Version, to step: XcodeInstallationStep) {
DispatchQueue.main.async {
guard let index = self.allXcodes.firstIndex(where: { $0.version.isEquivalent(to: version) }) else { return }
self.allXcodes[index].installState = .installing(step)
let xcode = self.allXcodes[index]
Current.notificationManager.scheduleNotification(title: xcode.id.appleDescription, body: step.description, category: .normal)
}
}
func setInstallationStep(of runtime: DownloadableRuntime, to step: RuntimeInstallationStep, postNotification: Bool = true) {
DispatchQueue.main.async {
guard let index = self.downloadableRuntimes.firstIndex(where: { $0.identifier == runtime.identifier }) else { return }
self.downloadableRuntimes[index].installState = .installing(step)
if postNotification {
Current.notificationManager.scheduleNotification(title: runtime.name, body: step.description, category: .normal)
}
}
}
}
extension AppState {
func persistOrCleanUpResumeData<T>(at path: Path, for completion: Subscribers.Completion<T>) {
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)
case postInstallStepsNotPerformed(version: Version, helperInstallState: HelperInstallState)
public var errorDescription: String? {
switch self {
case .damagedXIP(let url):
return String(format: localizeString("InstallationError.DamagedXIP"), url.lastPathComponent)
case let .notEnoughFreeSpaceToExpandArchive(archivePath, version):
return String(format: localizeString("InstallationError.NotEnoughFreeSpaceToExpandArchive"), archivePath.basename(), version.appleDescription)
case .failedToMoveXcodeToApplications:
return String(format: localizeString("InstallationError.FailedToMoveXcodeToApplications"), Path.installDirectory.string)
case .failedSecurityAssessment(let xcode, let output):
return String(format: localizeString("InstallationError.FailedSecurityAssessment"), String(xcode.version), output, xcode.path.string)
case .codesignVerifyFailed(let output):
return String(format: localizeString("InstallationError.CodesignVerifyFailed"), output)
case .unexpectedCodeSigningIdentity(let identity, let certificateAuthority):
return String(format: localizeString("InstallationError.UnexpectedCodeSigningIdentity"), identity, certificateAuthority, XcodeTeamIdentifier, XcodeCertificateAuthority)
case .unsupportedFileFormat(let fileExtension):
return String(format: localizeString("InstallationError.UnsupportedFileFormat"), fileExtension)
case .missingSudoerPassword:
return localizeString("InstallationError.MissingSudoerPassword")
case let .unavailableVersion(version):
return String(format: localizeString("InstallationError.UnavailableVersion"), version.appleDescription)
case .noNonPrereleaseVersionAvailable:
return localizeString("InstallationError.NoNonPrereleaseVersionAvailable")
case .noPrereleaseVersionAvailable:
return localizeString("InstallationError.NoPrereleaseVersionAvailable")
case .missingUsernameOrPassword:
return localizeString("InstallationError.MissingUsernameOrPassword")
case let .versionAlreadyInstalled(installedXcode):
return String(format: localizeString("InstallationError.VersionAlreadyInstalled"), installedXcode.version.appleDescription, installedXcode.path.string)
case let .invalidVersion(version):
return String(format: localizeString("InstallationError.InvalidVersion"), version)
case let .versionNotInstalled(version):
return String(format: localizeString("InstallationError.VersionNotInstalled"), version.appleDescription)
case let .postInstallStepsNotPerformed(version, helperInstallState):
switch helperInstallState {
case .installed:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.Installed"), version.appleDescription)
case .notInstalled, .unknown:
return String(format: localizeString("InstallationError.PostInstallStepsNotPerformed.NotInstalled"), version.appleDescription)
}
}
}
}
public enum InstallationType {
case version(AvailableXcode)
}
public enum AutoInstallationType: Int, Identifiable {
case none = 0
case newestVersion
case newestBeta
public var id: Self { self }
public var isAutoInstalling: Bool {
get {
return self != .none
}
set {
self = newValue ? .newestVersion : .none
}
}
public var isAutoInstallingBeta: Bool {
get {
return self == .newestBeta
}
set {
self = newValue ? .newestBeta : (isAutoInstalling ? .newestVersion : .none)
}
}
}
let XcodeTeamIdentifier = "59GAB85EFG"
let XcodeCertificateAuthority = ["Software Signing", "Apple Code Signing Certification Authority", "Apple Root CA"]