Skip to content
145 changes: 145 additions & 0 deletions Sources/XcodesKit/AppleSessionService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import PromiseKit
import Foundation
import AppleAPI

public class AppleSessionService {

private let xcodesUsername = "XCODES_USERNAME"
private let xcodesPassword = "XCODES_PASSWORD"

var configuration: Configuration

public init(configuration: Configuration) {
self.configuration = configuration
}

private func findUsername() -> String? {
if let username = Current.shell.env(xcodesUsername) {
return username
}
else if let username = configuration.defaultUsername {
return username
}
return nil
}

private func findPassword(withUsername username: String) -> String? {
if let password = Current.shell.env(xcodesPassword) {
return password
}
else if let password = try? Current.keychain.getString(username){
return password
}
return nil
}

func validateADCSession(path: String) -> Promise<Void> {
return Current.network.dataTask(with: URLRequest.downloadADCAuth(path: path)).asVoid()
}

func loginIfNeeded(withUsername providedUsername: String? = nil, shouldPromptForPassword: Bool = false) -> Promise<Void> {
return firstly { () -> Promise<Void> in
return Current.network.validateSession()
}
// Don't have a valid session, so we'll need to log in
.recover { error -> Promise<Void> in
var possibleUsername = providedUsername ?? self.findUsername()
var hasPromptedForUsername = false
if possibleUsername == nil {
possibleUsername = Current.shell.readLine(prompt: "Apple ID: ")
hasPromptedForUsername = true
}
guard let username = possibleUsername else { throw Error.missingUsernameOrPassword }

let passwordPrompt: String
if hasPromptedForUsername {
passwordPrompt = "Apple ID Password: "
} else {
// If the user wasn't prompted for their username, also explain which Apple ID password they need to enter
passwordPrompt = "Apple ID Password (\(username)): "
}
var possiblePassword = self.findPassword(withUsername: username)
if possiblePassword == nil || shouldPromptForPassword {
possiblePassword = Current.shell.readSecureLine(prompt: passwordPrompt)
}
guard let password = possiblePassword else { throw Error.missingUsernameOrPassword }

return firstly { () -> Promise<Void> in
self.login(username, password: password)
}
.recover { error -> Promise<Void> in
Current.logging.log(error.legibleLocalizedDescription.red)

if case Client.Error.invalidUsernameOrPassword = error {
Current.logging.log("Try entering your password again")
// Prompt for the password next time to avoid being stuck in a loop of using an incorrect XCODES_PASSWORD environment variable
return self.loginIfNeeded(withUsername: username, shouldPromptForPassword: true)
}
else {
return Promise(error: error)
}
}
}
}

func login(_ username: String, password: String) -> Promise<Void> {
return firstly { () -> Promise<Void> in
Current.network.login(accountName: username, password: password)
}
.recover { error -> Promise<Void> in

if let error = error as? Client.Error {
switch error {
case .invalidUsernameOrPassword(_):
// remove any keychain password if we fail to log with an invalid username or password so it doesn't try again.
try? Current.keychain.remove(username)
default:
break
}
}

return Promise(error: error)
}
.done { _ in
try? Current.keychain.set(password, key: username)

if self.configuration.defaultUsername != username {
self.configuration.defaultUsername = username
try? self.configuration.save()
}
}
}

public func logout() -> Promise<Void> {
guard let username = findUsername() else { return Promise<Void>(error: Client.Error.notAuthenticated) }

return Promise { seal in
// Remove cookies in the shared URLSession
AppleAPI.Current.network.session.reset {
seal.fulfill(())
}
}
.done {
// Remove all keychain items
try Current.keychain.remove(username)

// Set `defaultUsername` in Configuration to nil
self.configuration.defaultUsername = nil
try self.configuration.save()
}
}
}

extension AppleSessionService {
enum Error: LocalizedError, Equatable {
case missingUsernameOrPassword

public var errorDescription: String? {
switch self {
case .missingUsernameOrPassword:
return "Missing username or a password. Please try again."
}
}

}
}
68 changes: 68 additions & 0 deletions Sources/XcodesKit/Downloader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import PromiseKit
import Foundation
import Path
import AppleAPI

public enum Downloader {
case urlSession
case aria2(Path)

func download(url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
switch self {
case .urlSession:
if Current.shell.isatty() {
Current.logging.log("Downloading with urlSession - for faster downloads install aria2 (`brew install aria2`)".black.onYellow)
// Add 1 extra line as we are overwriting with download progress
Current.logging.log("")
}
return withUrlSession(url: url, to: destination, progressChanged: progressChanged)
case .aria2(let aria2Path):
if Current.shell.isatty() {
Current.logging.log("Downloading with aria2 (\(aria2Path))".green)
// Add 1 extra line as we are overwriting with download progress
Current.logging.log("")
}
return withAria(aria2Path: aria2Path, url: url, to: destination, progressChanged: progressChanged)
}
}

private func withAria(aria2Path: Path, url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
let cookies = AppleAPI.Current.network.session.configuration.httpCookieStorage?.cookies(for: url) ?? []
return attemptRetryableTask(maximumRetryCount: 3) {
let (progress, promise) = Current.shell.downloadWithAria2(
aria2Path,
url,
destination,
cookies
)
progressChanged(progress)
return promise.map { _ in destination.url }
}
}

private func withUrlSession(url: URL, to destination: Path, progressChanged: @escaping (Progress) -> Void) -> Promise<URL> {
let resumeDataPath = destination.parent/(destination.basename() + ".resumedata")
let persistedResumeData = Current.files.contents(atPath: resumeDataPath.string)

return attemptResumableTask(maximumRetryCount: 3) { resumeData in
let (progress, promise) = Current.network.downloadTask(with: url,
to: destination.url,
resumingWith: resumeData ?? persistedResumeData)
progressChanged(progress)
return promise.map { $0.saveLocation }
}
.tap { result in
self.persistOrCleanUpResumeData(at: resumeDataPath, for: result)
}
}

private func persistOrCleanUpResumeData<T>(at path: Path, for result: Result<T>) {
switch result {
case .fulfilled:
try? Current.files.removeItem(at: path.url)
case .rejected(let error):
guard let resumeData = (error as NSError).userInfo[NSURLSessionDownloadTaskResumeData] as? Data else { return }
Current.files.createFile(atPath: path.string, contents: resumeData)
}
}
}
49 changes: 29 additions & 20 deletions Sources/XcodesKit/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ public var Current = Environment()

public struct Shell {
public var unxip: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") }
public var mountDmg: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.join("hdiutil"), "attach", "-nobrowse", "-plist", $0.path) }
public var unmountDmg: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.join("hdiutil"), "detach", $0.path) }
public var expandPkg: (URL, URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--expand", $0.path, $1.path) }
public var createPkg: (URL, URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.sbin.join("pkgutil"), "--flatten", $0.path, $1.path) }
public var installPkg: (URL, String) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.sbin.join("installer"), "-pkg", $0.path, "-target", $1) }
public var installRuntimeImage: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.join("xcrun"), "simctl", "runtime", "add", $0.path) }
public var spctlAssess: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") }
public var codesignVerify: (URL) -> Promise<ProcessOutput> = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") }
public var devToolsSecurityEnable: (String?) -> Promise<ProcessOutput> = { Process.sudo(password: $0, Path.root.usr.sbin.DevToolsSecurity, "-enable") }
Expand Down Expand Up @@ -54,7 +60,8 @@ public struct Shell {
public func xcodeSelectSwitch(password: String?, path: String) -> Promise<ProcessOutput> {
xcodeSelectSwitch(password, path)
}

public var isRoot: () -> Bool = { NSUserName() == "root" }

/// Returns the path of an executable within the directories in the PATH environment variable.
public var findExecutable: (_ executableName: String) -> Path? = { executableName in
guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil }
Expand All @@ -64,15 +71,16 @@ public struct Shell {
return executable
}
}

return nil
}

public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, Promise<Void>) = { aria2Path, url, destination, cookies in
precondition(Thread.isMainThread, "Aria must be called on the main queue")
let process = Process()
process.executableURL = aria2Path.url
process.arguments = [
"--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))",
"--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))",
"--max-connection-per-server=16",
"--split=16",
"--summary-interval=1",
Expand All @@ -85,12 +93,12 @@ public struct Shell {
process.standardOutput = stdOutPipe
let stdErrPipe = Pipe()
process.standardError = stdErrPipe

var progress = Progress(totalUnitCount: 100)

let observer = NotificationCenter.default.addObserver(
forName: .NSFileHandleDataAvailable,
object: nil,
forName: .NSFileHandleDataAvailable,
object: nil,
queue: OperationQueue.main
) { note in
guard
Expand All @@ -116,7 +124,7 @@ public struct Shell {

stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()
stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify()

do {
try process.run()
} catch {
Expand All @@ -126,7 +134,7 @@ public struct Shell {
let promise = Promise<Void> { seal in
DispatchQueue.global(qos: .default).async {
process.waitUntilExit()

NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil)

guard process.terminationReason == .exit, process.terminationStatus == 0 else {
Expand All @@ -139,7 +147,7 @@ public struct Shell {
seal.fulfill(())
}
}

return (progress, promise)
}

Expand Down Expand Up @@ -191,7 +199,7 @@ public struct Shell {
}

public var exit: (Int32) -> Void = { Darwin.exit($0) }

public var isatty: () -> Bool = { Foundation.isatty(fileno(stdout)) != 0 }
}

Expand Down Expand Up @@ -238,9 +246,9 @@ public struct Files {
public func trashItem(at URL: URL) throws -> URL {
return try trashItem(URL)
}

public var createFile: (String, Data?, [FileAttributeKey: Any]?) -> Bool = { FileManager.default.createFile(atPath: $0, contents: $1, attributes: $2) }

@discardableResult
public func createFile(atPath path: String, contents data: Data?, attributes attr: [FileAttributeKey : Any]? = nil) -> Bool {
return createFile(path, data, attr)
Expand All @@ -251,13 +259,14 @@ public struct Files {
try createDirectory(url, createIntermediates, attributes)
}

public var installedXcodes = XcodesKit.installedXcodes
}
private func installedXcodes(directory: Path) -> [InstalledXcode] {
((try? directory.ls()) ?? [])
.filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" }
.map { $0.path }
.compactMap(InstalledXcode.init)
public var contentsOfDirectory: (URL) throws -> [URL] = { try FileManager.default.contentsOfDirectory(at: $0, includingPropertiesForKeys: nil, options: []) }

public var installedXcodes: (Path) -> [InstalledXcode] = { directory in
return ((try? directory.ls()) ?? [])
.filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" }
.map { $0.path }
.compactMap(InstalledXcode.init)
}
}

public struct Network {
Expand Down
24 changes: 20 additions & 4 deletions Sources/XcodesKit/Models+Runtimes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,25 @@ public struct DownloadableRuntime: Decodable {
let name: String
let authentication: Authentication?

var betaVersion: Int? {
var betaNumber: Int? {
enum Regex { static let shared = try! NSRegularExpression(pattern: "b[0-9]+$") }
guard var foundString = Regex.shared.firstString(in: identifier) else { return nil }
foundString.removeFirst()
return Int(foundString)!
}

var visibleName: String {
let betaSuffix = betaVersion.flatMap { "-beta\($0)" } ?? ""
return platform.shortName + " " + simulatorVersion.version + betaSuffix
var completeVersion: String {
makeVersion(for: simulatorVersion.version, betaNumber: betaNumber)
}

var visibleIdentifier: String {
return platform.shortName + " " + completeVersion
}
}

func makeVersion(for osVersion: String, betaNumber: Int?) -> String {
let betaSuffix = betaNumber.flatMap { "-beta\($0)" } ?? ""
return osVersion + betaSuffix
}

struct SDKToSeedMapping: Decodable {
Expand Down Expand Up @@ -126,5 +134,13 @@ extension InstalledRuntime {
case tvOS = "com.apple.platform.appletvsimulator"
case iOS = "com.apple.platform.iphonesimulator"
case watchOS = "com.apple.platform.watchsimulator"

var asPlatformOS: DownloadableRuntime.Platform {
switch self {
case .watchOS: return .watchOS
case .iOS: return .iOS
case .tvOS: return .tvOS
}
}
}
}
Loading