import Combine import Foundation import Path import AppleAPI import KeychainAccess /** Lightweight dependency injection using global mutable state :P - SeeAlso: https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy - SeeAlso: https://www.pointfree.co/episodes/ep18-dependency-injection-made-comfortable - SeeAlso: https://vimeo.com/291588126 */ public struct Environment { public var shell = Shell() public var files = Files() public var network = Network() public var keychain = Keychain() public var defaults = Defaults() public var date: () -> Date = Date.init public var helper = Helper() } public var Current = Environment() public struct Shell { public var unxip: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.xip, workingDirectory: $0.deletingLastPathComponent(), "--expand", "\($0.path)") } public var spctlAssess: (URL) -> AnyPublisher = { Process.run(Path.root.usr.sbin.spctl, "--assess", "--verbose", "--type", "execute", "\($0.path)") } public var codesignVerify: (URL) -> AnyPublisher = { Process.run(Path.root.usr.bin.codesign, "-vv", "-d", "\($0.path)") } public var buildVersion: () -> AnyPublisher = { Process.run(Path.root.usr.bin.sw_vers, "-buildVersion") } public var xcodeBuildVersion: (InstalledXcode) -> AnyPublisher = { Process.run(Path.root.usr.libexec.PlistBuddy, "-c", "Print :ProductBuildVersion", "\($0.path.string)/Contents/version.plist") } public var getUserCacheDir: () -> AnyPublisher = { Process.run(Path.root.usr.bin.getconf, "DARWIN_USER_CACHE_DIR") } public var touchInstallCheck: (String, String, String) -> AnyPublisher = { Process.run(Path.root.usr.bin/"touch", "\($0)com.apple.dt.Xcode.InstallCheckCache_\($1)_\($2)") } public var xcodeSelectPrintPath: () -> AnyPublisher = { Process.run(Path.root.usr.bin.join("xcode-select"), "-p") } public var downloadWithAria2: (Path, URL, Path, [HTTPCookie]) -> (Progress, AnyPublisher) = { aria2Path, url, destination, cookies in let process = Process() process.executableURL = aria2Path.url process.arguments = [ "--header=Cookie: \(cookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; "))", "--max-connection-per-server=16", "--split=16", "--summary-interval=1", "--stop-with-process=\(ProcessInfo.processInfo.processIdentifier)", "--dir=\(destination.parent.string)", "--out=\(destination.basename())", "--human-readable=false", // sets the output to use bytes instead of formatting url.absoluteString, ] let stdOutPipe = Pipe() process.standardOutput = stdOutPipe let stdErrPipe = Pipe() process.standardError = stdErrPipe var progress = Progress() progress.kind = .file progress.fileOperationKind = .downloading let observer = NotificationCenter.default.addObserver( forName: .NSFileHandleDataAvailable, object: nil, queue: OperationQueue.main ) { note in guard // This should always be the case for Notification.Name.NSFileHandleDataAvailable let handle = note.object as? FileHandle, handle === stdOutPipe.fileHandleForReading || handle === stdErrPipe.fileHandleForReading else { return } defer { handle.waitForDataInBackgroundAndNotify() } let string = String(decoding: handle.availableData, as: UTF8.self) progress.updateFromAria2(string: string) } stdOutPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() stdErrPipe.fileHandleForReading.waitForDataInBackgroundAndNotify() do { try process.run() } catch { return (progress, Fail(error: error).eraseToAnyPublisher()) } let publisher = Deferred { Future { promise in DispatchQueue.global(qos: .default).async { process.waitUntilExit() NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) guard process.terminationReason == .exit, process.terminationStatus == 0 else { if let aria2cError = Aria2CError(exitStatus: process.terminationStatus) { return promise(.failure(aria2cError)) } else { return promise(.failure(ProcessExecutionError(process: process, standardOutput: "", standardError: ""))) } } promise(.success(())) } } } .handleEvents(receiveCancel: { process.terminate() NotificationCenter.default.removeObserver(observer, name: .NSFileHandleDataAvailable, object: nil) }) .eraseToAnyPublisher() return (progress, publisher) } } public struct Files { public var fileExistsAtPath: (String) -> Bool = { FileManager.default.fileExists(atPath: $0) } public func fileExists(atPath path: String) -> Bool { return fileExistsAtPath(path) } public var moveItem: (URL, URL) throws -> Void = { try FileManager.default.moveItem(at: $0, to: $1) } public func moveItem(at srcURL: URL, to dstURL: URL) throws { try moveItem(srcURL, dstURL) } public var contentsAtPath: (String) -> Data? = { FileManager.default.contents(atPath: $0) } public func contents(atPath path: String) -> Data? { return contentsAtPath(path) } public var removeItem: (URL) throws -> Void = { try FileManager.default.removeItem(at: $0) } public func removeItem(at URL: URL) throws { try removeItem(URL) } public var trashItem: (URL) throws -> URL = { try FileManager.default.trashItem(at: $0) } @discardableResult 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) } public var createDirectory: (URL, Bool, [FileAttributeKey : Any]?) throws -> Void = FileManager.default.createDirectory(at:withIntermediateDirectories:attributes:) public func createDirectory(at url: URL, withIntermediateDirectories createIntermediates: Bool, attributes: [FileAttributeKey : Any]? = nil) throws { try createDirectory(url, createIntermediates, attributes) } public var installedXcodes = _installedXcodes } private func _installedXcodes(destination: Path) -> [InstalledXcode] { ((try? destination.ls()) ?? []) .filter { $0.isAppBundle && $0.infoPlist?.bundleID == "com.apple.dt.Xcode" } .map { $0.path } .compactMap(InstalledXcode.init) } public struct Network { private static let client = AppleAPI.Client() public var dataTask: (URLRequest) -> AnyPublisher = { AppleAPI.Current.network.session.dataTaskPublisher(for: $0) .mapError { $0 as Error } .eraseToAnyPublisher() } public func dataTask(with request: URLRequest) -> AnyPublisher { dataTask(request) } public var downloadTask: (URL, URL, Data?) -> (Progress, AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) = { AppleAPI.Current.network.session.downloadTask(with: $0, to: $1, resumingWith: $2) } public func downloadTask(with url: URL, to saveLocation: URL, resumingWith resumeData: Data?) -> (progress: Progress, publisher: AnyPublisher<(saveLocation: URL, response: URLResponse), Error>) { return downloadTask(url, saveLocation, resumeData) } } public struct Keychain { private static let keychain = KeychainAccess.Keychain(service: "com.robotsandpencils.XcodesApp") public var getString: (String) throws -> String? = keychain.getString(_:) public func getString(_ key: String) throws -> String? { try getString(key) } public var set: (String, String) throws -> Void = keychain.set(_:key:) public func set(_ value: String, key: String) throws { try set(value, key) } public var remove: (String) throws -> Void = keychain.remove(_:) public func remove(_ key: String) throws -> Void { try remove(key) } } public struct Defaults { public var string: (String) -> String? = { UserDefaults.standard.string(forKey: $0) } public func string(forKey key: String) -> String? { string(key) } public var date: (String) -> Date? = { Date(timeIntervalSince1970: UserDefaults.standard.double(forKey: $0)) } public func date(forKey key: String) -> Date? { date(key) } public var setDate: (Date?, String) -> Void = { UserDefaults.standard.set($0?.timeIntervalSince1970, forKey: $1) } public func setDate(_ value: Date?, forKey key: String) { setDate(value, key) } public var set: (Any?, String) -> Void = { UserDefaults.standard.set($0, forKey: $1) } public func set(_ value: Any?, forKey key: String) { set(value, key) } public var removeObject: (String) -> Void = { UserDefaults.standard.removeObject(forKey: $0) } public func removeObject(forKey key: String) { removeObject(key) } } private let helperClient = HelperClient() public struct Helper { var install: () throws -> Void = helperClient.install var checkIfLatestHelperIsInstalled: () -> AnyPublisher = helperClient.checkIfLatestHelperIsInstalled var getVersion: () -> AnyPublisher = helperClient.getVersion var switchXcodePath: (_ absolutePath: String) -> AnyPublisher = helperClient.switchXcodePath var devToolsSecurityEnable: () -> AnyPublisher = helperClient.devToolsSecurityEnable var addStaffToDevelopersGroup: () -> AnyPublisher = helperClient.addStaffToDevelopersGroup var acceptXcodeLicense: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.acceptXcodeLicense var runFirstLaunch: (_ absoluteXcodePath: String) -> AnyPublisher = helperClient.runFirstLaunch }