import Combine import Foundation import os.log import ServiceManagement final class HelperClient { private var connection: NSXPCConnection? private func currentConnection() -> NSXPCConnection? { guard self.connection == nil else { return self.connection } let connection = NSXPCConnection(machServiceName: machServiceName, options: .privileged) connection.remoteObjectInterface = NSXPCInterface(with: HelperXPCProtocol.self) connection.invalidationHandler = { self.connection?.invalidationHandler = nil DispatchQueue.main.async { self.connection = nil } } self.connection = connection connection.resume() return self.connection } private func helper(errorSubject: PassthroughSubject) -> HelperXPCProtocol? { guard let helper = self.currentConnection()?.remoteObjectProxyWithErrorHandler({ error in errorSubject.send(completion: .failure(error)) }) as? HelperXPCProtocol else { return nil } return helper } func checkIfLatestHelperIsInstalled() -> AnyPublisher { Logger.helperClient.info(#function) let helperURL = Bundle.main.bundleURL.appendingPathComponent("Contents/Library/LaunchServices/" + machServiceName) guard let helperBundleInfo = CFBundleCopyInfoDictionaryForURL(helperURL as CFURL) as? [String: Any], let bundledHelperVersion = helperBundleInfo["CFBundleShortVersionString"] as? String else { return Just(false) .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }) .eraseToAnyPublisher() } return getVersion() .map { installedHelperVersion in installedHelperVersion == bundledHelperVersion } .catch { _ in Just(false) } // Failure is Never, so don't bother logging completion .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0), privacy: .public)") }) .eraseToAnyPublisher() } func getVersion() -> AnyPublisher { Logger.helperClient.info(#function) let connectionErrorSubject = PassthroughSubject() guard let helper = self.helper(errorSubject: connectionErrorSubject) else { return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) .eraseToAnyPublisher() } return Deferred { Future { promise in helper.getVersion { version in promise(.success(version)) } } } // Take values, but fail when connectionErrorSubject fails .zip( connectionErrorSubject .prepend("") .map { _ in Void() } ) .map { $0.0 } .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0), privacy: .public)") }, receiveCompletion: { completion in switch completion { case .finished: Logger.helperClient.info("\(#function): finished") case let .failure(error): Logger.helperClient.error("\(#function): \(String(describing: error))") } }) .eraseToAnyPublisher() } func switchXcodePath(_ absolutePath: String) -> AnyPublisher { Logger.helperClient.info("\(#function): \(absolutePath, privacy: .private(mask: .hash))") let connectionErrorSubject = PassthroughSubject() guard let helper = self.helper(errorSubject: connectionErrorSubject) else { return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) .eraseToAnyPublisher() } return Deferred { Future { promise in helper.xcodeSelect(absolutePath: absolutePath, completion: { (possibleError) in if let error = possibleError { promise(.failure(error)) } else { promise(.success(())) } }) } } // Take values, but fail when connectionErrorSubject fails .zip( connectionErrorSubject .prepend("") .map { _ in Void() } ) .map { $0.0 } .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, receiveCompletion: { completion in switch completion { case .finished: Logger.helperClient.info("\(#function): finished") case let .failure(error): Logger.helperClient.error("\(#function): \(String(describing: error))") } }) .eraseToAnyPublisher() } func devToolsSecurityEnable() -> AnyPublisher { Logger.helperClient.info(#function) let connectionErrorSubject = PassthroughSubject() guard let helper = self.helper(errorSubject: connectionErrorSubject) else { return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) .eraseToAnyPublisher() } return Deferred { Future { promise in helper.devToolsSecurityEnable(completion: { (possibleError) in if let error = possibleError { promise(.failure(error)) } else { promise(.success(())) } }) } } // Take values, but fail when connectionErrorSubject fails .zip( connectionErrorSubject .prepend("") .map { _ in Void() } ) .map { $0.0 } .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, receiveCompletion: { completion in switch completion { case .finished: Logger.helperClient.info("\(#function): finished") case let .failure(error): Logger.helperClient.error("\(#function): \(String(describing: error))") } }) .eraseToAnyPublisher() } func addStaffToDevelopersGroup() -> AnyPublisher { Logger.helperClient.info(#function) let connectionErrorSubject = PassthroughSubject() guard let helper = self.helper(errorSubject: connectionErrorSubject) else { return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) .eraseToAnyPublisher() } return Deferred { Future { promise in helper.addStaffToDevelopersGroup(completion: { (possibleError) in if let error = possibleError { promise(.failure(error)) } else { promise(.success(())) } }) } } // Take values, but fail when connectionErrorSubject fails .zip( connectionErrorSubject .prepend("") .map { _ in Void() } ) .map { $0.0 } .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, receiveCompletion: { completion in switch completion { case .finished: Logger.helperClient.info("\(#function): finished") case let .failure(error): Logger.helperClient.error("\(#function): \(String(describing: error))") } }) .eraseToAnyPublisher() } func acceptXcodeLicense(absoluteXcodePath: String) -> AnyPublisher { Logger.helperClient.info("\(#function): \(absoluteXcodePath, privacy: .private(mask: .hash))") let connectionErrorSubject = PassthroughSubject() guard let helper = self.helper(errorSubject: connectionErrorSubject) else { return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) .eraseToAnyPublisher() } return Deferred { Future { promise in helper.acceptXcodeLicense(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in if let error = possibleError { promise(.failure(error)) } else { promise(.success(())) } }) } } // Take values, but fail when connectionErrorSubject fails .zip( connectionErrorSubject .prepend("") .map { _ in Void() } ) .map { $0.0 } .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, receiveCompletion: { completion in switch completion { case .finished: Logger.helperClient.info("\(#function): finished") case let .failure(error): Logger.helperClient.error("\(#function): \(String(describing: error))") } }) .eraseToAnyPublisher() } func runFirstLaunch(absoluteXcodePath: String) -> AnyPublisher { Logger.helperClient.info("\(#function): \(absoluteXcodePath, privacy: .private(mask: .hash))") let connectionErrorSubject = PassthroughSubject() guard let helper = self.helper(errorSubject: connectionErrorSubject) else { return Fail(error: HelperClientError.failedToCreateRemoteObjectProxy) .handleEvents(receiveCompletion: { Logger.helperClient.error("\(#function): \(String(describing: $0))") }) .eraseToAnyPublisher() } return Deferred { Future { promise in helper.runFirstLaunch(absoluteXcodePath: absoluteXcodePath, completion: { (possibleError) in if let error = possibleError { promise(.failure(error)) } else { promise(.success(())) } }) } } // Take values, but fail when connectionErrorSubject fails .zip( connectionErrorSubject .prepend("") .map { _ in Void() } ) .map { $0.0 } .handleEvents(receiveOutput: { Logger.helperClient.info("\(#function): \(String(describing: $0))") }, receiveCompletion: { completion in switch completion { case .finished: Logger.helperClient.info("\(#function): finished") case let .failure(error): Logger.helperClient.error("\(#function): \(String(describing: error))") } }) .eraseToAnyPublisher() } // MARK: - Install // From https://github.com/securing/SimpleXPCApp/ func install() throws { Logger.helperClient.info(#function) var authItem = kSMRightBlessPrivilegedHelper.withCString { name in AuthorizationItem(name: name, valueLength: 0, value:UnsafeMutableRawPointer(bitPattern: 0), flags: 0) } var authRights = withUnsafeMutablePointer(to: &authItem) { authItem in AuthorizationRights(count: 1, items: authItem) } do { let authRef = try authorizationRef(&authRights, nil, [.interactionAllowed, .extendRights, .preAuthorize]) var cfError: Unmanaged? SMJobBless(kSMDomainSystemLaunchd, machServiceName as CFString, authRef, &cfError) if let error = cfError?.takeRetainedValue() { throw error } self.connection?.invalidate() self.connection = nil Logger.helperClient.info("\(#function): Finished installation") } catch { Logger.helperClient.error("\(#function): \(error.localizedDescription)") throw error } } private func executeAuthorizationFunction(_ authorizationFunction: () -> (OSStatus) ) throws { let osStatus = authorizationFunction() guard osStatus == errAuthorizationSuccess else { if let message = SecCopyErrorMessageString(osStatus, nil) { throw HelperClientError.message(String(message as NSString)) } else { throw HelperClientError.message("Unknown error") } } } func authorizationRef(_ rights: UnsafePointer?, _ environment: UnsafePointer?, _ flags: AuthorizationFlags) throws -> AuthorizationRef? { var authRef: AuthorizationRef? try executeAuthorizationFunction { AuthorizationCreate(rights, environment, flags, &authRef) } return authRef } } enum HelperClientError: LocalizedError { case failedToCreateRemoteObjectProxy case message(String) var errorDescription: String? { switch self { case .failedToCreateRemoteObjectProxy: return localizeString("HelperClient.error") case let .message(message): return message } } }