import Foundation import Network import SystemConfiguration @MainActor protocol MirrorTransport: AnyObject { var onReady: (() -> Void)? { get set } var onMessage: ((MirrorMessage) -> Void)? { get set } var onClose: ((String?) -> Void)? { get set } var verifiedConfiguration: MirrorSavedConnection? { get } func start() func send(_ message: MirrorMessage, closeAfterSending: Bool) func close(_ reason: String?) } extension MirrorTransport { var verifiedConfiguration: MirrorSavedConnection? { nil } func send(_ message: MirrorMessage) { send(message, closeAfterSending: false) } func close() { close(nil) } } extension MirrorConnection: MirrorTransport {} /// Authentication completes before discovery callbacks are exposed to the UI. @MainActor final class MirrorRemoteConnection: MirrorTransport { var onEnrolled: ((MirrorSavedConnection) -> Void)? var onReady: (() -> Void)? var onMessage: ((MirrorMessage) -> Void)? var onClose: ((String?) -> Void)? private(set) var verifiedConfiguration: MirrorSavedConnection? /// Set when the transport ended before authentication completed. private(set) var failure: MirrorConnectionFailure? private var configuration: MirrorSavedConnection private var connection: MirrorConnection? private var challenge: MirrorMessage.Challenge? private var closed = false private var authenticated = false private let restore: (MirrorSavedConnection) throws -> MirrorSavedConnection? private let persist: (MirrorSavedConnection) throws -> Void init( configuration: MirrorSavedConnection, restore: @escaping (MirrorSavedConnection) throws -> MirrorSavedConnection? = { try MirrorSavedConnection.load(account: "host:" + $0.address + ":" + String($0.port)) }, persist: @escaping (MirrorSavedConnection) throws -> Void = { try $0.save(account: "host:" + $0.address + ":" + String($0.port)) try $0.save() } ) { self.configuration = configuration self.restore = restore self.persist = persist } func start() { guard connection == nil, !closed else { return } do { guard configuration.port > 0 else { throw MirrorProtocolError.invalidMessage } if configuration.credential == nil, configuration.pairingKey.isEmpty, let saved = try restore(configuration) { configuration.credential = saved.credential } let parameters: NWParameters if let credential = configuration.credential { guard credential.key.count == 32 else { throw MirrorProtocolError.invalidMessage } parameters = try MirrorConnection.parameters(keys: [ (credential.deviceID.uuidString, credential.key) ]) } else { parameters = try MirrorConnection.parameters(pairingKey: configuration.pairingKey) } // A refused or rejected connection is reported at once; ten seconds covers a slow network. let peer = MirrorConnection( NWConnection( host: .init(configuration.address), port: .init(rawValue: configuration.port)!, using: parameters), handshakeTimeout: .seconds(10)) connection = peer let pairing = configuration.credential == nil let endpoint = configuration.endpointID peer.onMessage = { [weak self] in self?.receive($0) } peer.onClose = { [weak self, weak peer] reason in guard let self, !self.closed else { return } self.closed = true if let failure = peer?.failure { self.failure = failure self.onClose?(failure.clientMessage(endpoint: endpoint, pairing: pairing)) } else { self.onClose?(reason) } } peer.start() } catch { SupaLogger("RemoteMirror").warning("Host connection failed: \(error)") close(error.localizedDescription) } } func send(_ message: MirrorMessage, closeAfterSending: Bool = false) { guard authenticated, !closed else { return } connection?.send(message, closeAfterSending: closeAfterSending) } func close(_ reason: String? = nil) { guard !closed else { return } closed = true connection?.onClose = nil connection?.close(reason) connection = nil onClose?(reason) } private func receive(_ message: MirrorMessage) { if authenticated { onMessage?(message) return } do { switch message { case .challenge(let value): guard challenge == nil, value.nonce.count == 32 else { throw MirrorProtocolError.invalidMessage } challenge = value if let credential = configuration.credential { guard credential.hostID == value.hostID else { close(String(localized: "Host identity changed. Pair with this Host again.")) return } connection?.send( .authenticate( .init( deviceID: credential.deviceID, proof: MirrorAuthentication.proof( key: credential.key, host: value.hostID, nonce: value.nonce, purpose: "device", identity: credential.deviceID.uuidString))) ) } else { let code = try MirrorPairingCode.normalized(configuration.pairingKey) #if DEBUG let lookupStarted = ProcessInfo.processInfo.systemUptime SupaLogger("MirrorPairing").notice("Device name lookup started") #endif // Read local configuration; hostname resolution can block the authentication deadline. let name = String(((SCDynamicStoreCopyLocalHostName(nil) as String?) ?? "Mac").prefix(80)) #if DEBUG let elapsed = ProcessInfo.processInfo.systemUptime - lookupStarted SupaLogger("MirrorPairing").notice( "Device name lookup completed after \(elapsed) seconds") #endif connection?.send( .pair( .init( name: name, proof: MirrorAuthentication.proof( key: Data(code.utf8), host: value.hostID, nonce: value.nonce, purpose: "pair", identity: name)))) } case .paired(let credential): guard configuration.credential == nil, credential.hostID == challenge?.hostID, credential.key.count == 32 else { throw MirrorProtocolError.invalidMessage } configuration.credential = credential configuration.pairingKey = "" // Save before reconnect: a successful pairing response must never depend on UI lifetime. try persist(configuration) onEnrolled?(configuration) connection?.onClose = nil connection?.close() connection = nil challenge = nil start() case .authenticated(let hostID): guard let credential = configuration.credential, credential.hostID == hostID, challenge?.hostID == hostID else { throw MirrorProtocolError.invalidMessage } try persist(configuration) verifiedConfiguration = configuration authenticated = true onReady?() case .failure(let failure): close(failure.error) default: throw MirrorProtocolError.invalidMessage } } catch { SupaLogger("RemoteMirror").warning("Host connection failed: \(error)") close(error.localizedDescription) } } }