diff --git a/NearbyShare/Connection/NearbyConnectionManager.swift b/NearbyShare/Connection/NearbyConnectionManager.swift index 1abccc3..6fa71bc 100644 --- a/NearbyShare/Connection/NearbyConnectionManager.swift +++ b/NearbyShare/Connection/NearbyConnectionManager.swift @@ -10,6 +10,7 @@ import UIKit import DeviceKit #endif +import Darwin import Foundation import Network import System @@ -34,6 +35,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb private var inboundAppDelegates: [InboundAppDelegate] = [] private var outgoingTransfers: [String: OutgoingTransferInfo] = [:] private var pendingOutgoingTransferPreparations: [String: UUID] = [:] + private var outgoingServiceResolvers: [String: NearbyOutgoingServiceResolver] = [:] private let pendingNotificationSyncTrustQueue = DispatchQueue(label: "NearbyConnectionManager.pendingNotificationSyncTrust") private var pendingNotificationSyncTrust: [String: PendingReceiverTrust] = [:] private var startedDeviceDiscovery = false @@ -445,6 +447,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb self.addFoundDevice(service: res) case let .removed(res): self.removeFoundDevice(service: res) + case let .changed(_, new, _): + self.addFoundDevice(service: new) default: log("[NearbyConnectionManager] Ignoring change \(change)") } @@ -536,11 +540,11 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb public func cancelTransfer(id: String) { - if let transfer = incomingConnections[id] { transfer.cancel() } pendingOutgoingTransferPreparations.removeValue(forKey: id) + outgoingServiceResolvers.removeValue(forKey: id)?.cancel() if let info = outgoingTransfers[id] { info.connection.cancel() } @@ -716,7 +720,13 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb private func removeFoundDevice(service: NWBrowser.Result) { guard let endpointID = endpointID(for: service) else { return } deviceNameResolver.cancelLookup(for: endpointID) - guard let _ = foundServices.removeValue(forKey: endpointID) else { return } + guard let currentService = foundServices[endpointID] else { return } + // Ignore delayed removals for an older Bonjour result after a changed result + // has already replaced the service for this endpoint ID. + guard serviceIdentity(currentService.service) == serviceIdentity(service) else { + return + } + foundServices.removeValue(forKey: endpointID) for delegate in outboundAppDelegates { delegate.removeDevice(id: endpointID) } @@ -795,30 +805,41 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb return } - self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID) - guard let delegate else { return } + guard let delegate else { + self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID) + return + } preparationFinished?() - let tcp = NWProtocolTCP.Options() - tcp.noDelay = true - let nwconn = NWConnection(to: info.service.endpoint, using: NWParameters(tls: .none, tcp: tcp)) + self.resolveOutgoingEndpoint(for: info.service, deviceID: deviceID) { endpoint in + guard let info = self.foundServices[deviceID], + self.pendingOutgoingTransferPreparations[deviceID] == preparationID else { + return + } - let conn = OutboundNearbyConnection( - connection: nwconn, - id: deviceID, - urlsToSend: localUrls, - textToSend: textToSend, - isClipboardSyncTransfer: isClipboardSyncTransfer, - receiverAuthenticationPolicy: receiverAuthenticationPolicy - ) - conn.remoteDeviceInfo = info.device - conn.delegate = self - conn.qrCodePrivateKey = self.qrCodePrivateKey + self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID) - let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate) - self.outgoingTransfers[deviceID] = transfer - conn.start() + let tcp = NWProtocolTCP.Options() + tcp.noDelay = true + let nwconn = NWConnection(to: endpoint, using: NWParameters(tls: .none, tcp: tcp)) + + let conn = OutboundNearbyConnection( + connection: nwconn, + id: deviceID, + urlsToSend: localUrls, + textToSend: textToSend, + isClipboardSyncTransfer: isClipboardSyncTransfer, + receiverAuthenticationPolicy: receiverAuthenticationPolicy + ) + conn.remoteDeviceInfo = info.device + conn.delegate = self + conn.qrCodePrivateKey = self.qrCodePrivateKey + + let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate) + self.outgoingTransfers[deviceID] = transfer + conn.start() + } } } catch { log("[NearbyConnectionManager] Error storing URLs: \(error)") @@ -837,6 +858,33 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } + private func resolveOutgoingEndpoint( + for service: NWBrowser.Result, + deviceID: String, + completion: @escaping (NWEndpoint) -> Void + ) { + guard let resolver = NearbyOutgoingServiceResolver(result: service, resolveTimeout: 2.5, completion: { [weak self] endpoints in + guard let self else { return } + self.outgoingServiceResolvers.removeValue(forKey: deviceID) + + if let endpoint = endpoints.first { + completion(endpoint.endpoint) + } else { + log("[NearbyConnectionManager] Could not resolve outgoing service for \(deviceID); falling back to Bonjour endpoint \(service.endpoint)") + completion(service.endpoint) + } + }) else { + log("[NearbyConnectionManager] Cannot create resolver for outgoing service; falling back to Bonjour endpoint") + completion(service.endpoint) + return + } + + outgoingServiceResolvers.removeValue(forKey: deviceID)?.cancel() + outgoingServiceResolvers[deviceID] = resolver + resolver.start() + } + + private func normalizedKeyFingerprint(_ keyFingerprint: String?) -> String? { let normalized = keyFingerprint? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -845,6 +893,11 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } + private func serviceIdentity(_ service: NWBrowser.Result) -> String { + String(describing: service.endpoint) + } + + func connectionWasEstablished(connection: OutboundNearbyConnection) { guard let transfer = outgoingTransfers[connection.id] else { return } DispatchQueue.main.async { diff --git a/NearbyShare/Connection/NearbyOutgoingServiceResolver.swift b/NearbyShare/Connection/NearbyOutgoingServiceResolver.swift new file mode 100644 index 0000000..ad64e72 --- /dev/null +++ b/NearbyShare/Connection/NearbyOutgoingServiceResolver.swift @@ -0,0 +1,167 @@ +// +// NearbyOutgoingServiceResolver.swift +// QuickDrop +// +// Created by Leon Böttger on 19.05.26. +// + +import Foundation +import Network + +final class NearbyOutgoingServiceResolver: NSObject, NetServiceDelegate { + private let service: NetService + private let resolveTimeout: TimeInterval + private var timeoutWorkItem: DispatchWorkItem? + private var completion: (([NearbyResolvedServiceEndpoint]) -> Void)? + private var finished = false + + init?( + result: NWBrowser.Result, + resolveTimeout: TimeInterval, + completion: @escaping ([NearbyResolvedServiceEndpoint]) -> Void + ) { + guard case let NWEndpoint.service(name: name, type: type, domain: domain, interface: _) = result.endpoint else { + return nil + } + + self.service = NetService( + domain: Self.normalizedServiceDomain(domain), + type: Self.normalizedServiceType(type), + name: name + ) + self.resolveTimeout = resolveTimeout + self.completion = completion + super.init() + } + + + func start() { + service.delegate = self + service.resolve(withTimeout: resolveTimeout) + + let timeoutWorkItem = DispatchWorkItem { [weak self] in + self?.finish(endpoints: []) + } + self.timeoutWorkItem = timeoutWorkItem + DispatchQueue.main.asyncAfter(deadline: .now() + resolveTimeout + 0.5, execute: timeoutWorkItem) + } + + + func cancel() { + finish(endpoints: [], notify: false) + } + + + func netServiceDidResolveAddress(_ sender: NetService) { + finish(endpoints: Self.resolvedEndpoints(from: sender)) + } + + + func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) { + finish(endpoints: []) + } + + + private func finish(endpoints: [NearbyResolvedServiceEndpoint], notify: Bool = true) { + guard !finished else { return } + finished = true + timeoutWorkItem?.cancel() + service.stop() + service.delegate = nil + + let completion = completion + self.completion = nil + + if notify { + DispatchQueue.main.async { + completion?(endpoints) + } + } + } + + + private static func resolvedEndpoints(from service: NetService) -> [NearbyResolvedServiceEndpoint] { + let endpoints = (service.addresses ?? []) + .compactMap(resolvedEndpoint) + + var seenDescriptions = Set() + let uniqueEndpoints = endpoints.filter { endpoint in + seenDescriptions.insert(endpoint.description).inserted + } + + return uniqueEndpoints.sorted { lhs, rhs in + if lhs.isIPv4 != rhs.isIPv4 { + return lhs.isIPv4 + } + return lhs.description < rhs.description + } + } + + + private static func resolvedEndpoint(from addressData: Data) -> NearbyResolvedServiceEndpoint? { + addressData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return nil } + + let sockaddrPointer = baseAddress.assumingMemoryBound(to: sockaddr.self) + let addressFamily = Int32(sockaddrPointer.pointee.sa_family) + + guard addressFamily == AF_INET || addressFamily == AF_INET6 else { + return nil + } + + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + var portBuffer = [CChar](repeating: 0, count: Int(NI_MAXSERV)) + let result = getnameinfo( + sockaddrPointer, + socklen_t(addressData.count), + &hostBuffer, + socklen_t(hostBuffer.count), + &portBuffer, + socklen_t(portBuffer.count), + NI_NUMERICHOST | NI_NUMERICSERV + ) + + guard result == 0 else { return nil } + + let host = String(cString: hostBuffer) + guard let portRawValue = UInt16(String(cString: portBuffer)), + let port = NWEndpoint.Port(rawValue: portRawValue) else { + return nil + } + + let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(host), port: port) + let description: String + if addressFamily == AF_INET6 { + description = "[\(host)]:\(portRawValue)" + } else { + description = "\(host):\(portRawValue)" + } + + return NearbyResolvedServiceEndpoint( + endpoint: endpoint, + description: description, + isIPv4: addressFamily == AF_INET + ) + } + } + + + private static func normalizedServiceDomain(_ domain: String) -> String { + let trimmedDomain = domain.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedDomain.isEmpty else { return "local." } + return trimmedDomain.hasSuffix(".") ? trimmedDomain : "\(trimmedDomain)." + } + + + private static func normalizedServiceType(_ type: String) -> String { + let trimmedType = type.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmedType.hasSuffix(".") ? trimmedType : "\(trimmedType)." + } +} + + +struct NearbyResolvedServiceEndpoint { + let endpoint: NWEndpoint + let description: String + let isIPv4: Bool +} diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index bade4de..1c12ee6 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -31,6 +31,12 @@ EB05DFBE2E59FC8900EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; }; EB05DFBF2E59FD0A00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; }; EB05DFC02E5A2EAA00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; }; + EB0A22E32FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */; }; + EB0A22E42FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */; }; + EB0A22E52FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */; }; + EB0A22E62FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */; }; + EB0A22E72FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */; }; + EB0A22E82FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */; }; EB1325D32E701D8E00FD749C /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1325D22E701D8900FD749C /* Settings.swift */; }; EB1325D42E701D8E00FD749C /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1325D22E701D8900FD749C /* Settings.swift */; }; EB1325D52E701D8E00FD749C /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1325D22E701D8900FD749C /* Settings.swift */; }; @@ -518,6 +524,7 @@ A1F5B3D82F3A1A0100A1B2C3 /* PairingToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingToken.swift; sourceTree = ""; }; A1F5B3D92F3A1A0100A1B2C4 /* PairingQrSheetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingQrSheetManager.swift; sourceTree = ""; }; EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedDevicesView.swift; sourceTree = ""; }; + EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyOutgoingServiceResolver.swift; sourceTree = ""; }; EB1325D22E701D8900FD749C /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; EB1325E72E7025D900FD749C /* ActiveNetworkInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveNetworkInterface.swift; sourceTree = ""; }; EB1325E82E7025D900FD749C /* ApIsolation6Tester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApIsolation6Tester.swift; sourceTree = ""; }; @@ -801,6 +808,7 @@ isa = PBXGroup; children = ( 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */, + EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */, EB3B9F192EF7150400D67050 /* NearbyDeviceNameResolver.swift */, 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */, A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */, @@ -1312,6 +1320,7 @@ EB5C37C52E377A780052EFD7 /* securemessage.pb.swift in Sources */, EB5C37C62E377A780052EFD7 /* securegcm.pb.swift in Sources */, EB5C37C72E377A780052EFD7 /* wire_format.pb.swift in Sources */, + EB0A22E42FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */, EB5C37C82E377A780052EFD7 /* ukey.pb.swift in Sources */, EB5C37C92E377A780052EFD7 /* device_to_device_messages.pb.swift in Sources */, EB950B742E520957002D68FA /* Attachments.swift in Sources */, @@ -1390,6 +1399,7 @@ EBC05C822E33B58300EECC18 /* ApIsolationIssueView.swift in Sources */, EBC05C832E33B58300EECC18 /* FirewallIssueView.swift in Sources */, EB1325FA2E70288300FD749C /* SendToExtension.swift in Sources */, + EB0A22E72FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */, EB1325F42E7025D900FD749C /* ApIsolationTester.swift in Sources */, EB1325F52E7025D900FD749C /* ActiveNetworkInterface.swift in Sources */, EB1325F62E7025D900FD749C /* ApIsolation6Tester.swift in Sources */, @@ -1450,6 +1460,7 @@ EB3B9F272EF7150400D6704E /* ApIsolationIssueView.swift in Sources */, EB3B9F282EF7150400D6704E /* FirewallIssueView.swift in Sources */, EB3B9F292EF7150400D6704E /* SendToExtension.swift in Sources */, + EB0A22E82FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */, EB3B9F2A2EF7150400D6704E /* ApIsolationTester.swift in Sources */, EB3B9F2B2EF7150400D6704E /* ActiveNetworkInterface.swift in Sources */, EB3B9F2C2EF7150400D6704E /* ApIsolation6Tester.swift in Sources */, @@ -1480,6 +1491,7 @@ EB3B9F592EF7151900D6704E /* securemessage.pb.swift in Sources */, EB3B9F5A2EF7151900D6704E /* securegcm.pb.swift in Sources */, EB3B9F5B2EF7151900D6704E /* wire_format.pb.swift in Sources */, + EB0A22E52FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */, EB3B9F5C2EF7151900D6704E /* ukey.pb.swift in Sources */, EB3B9F5D2EF7151900D6704E /* device_to_device_messages.pb.swift in Sources */, EB3B9F5F2EF7151900D6704E /* Attachments.swift in Sources */, @@ -1528,6 +1540,7 @@ EB5C37F92E377CE70052EFD7 /* securegcm.pb.swift in Sources */, EB5C37FA2E377CE70052EFD7 /* Helpers.swift in Sources */, EB5C37FB2E377CE70052EFD7 /* InboundNearbyConnection.swift in Sources */, + EB0A22E62FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */, A1B2C3D82F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, EB9925802E45D53E00357055 /* sharing_enums.pb.swift in Sources */, EB5C37FC2E377CE70052EFD7 /* OutboundNearbyConnection.swift in Sources */, @@ -1572,6 +1585,7 @@ EB16B6132E58B4B9007D9792 /* IdentityManager.swift in Sources */, EB950B4D2E520436002D68FA /* NearbyConnection.swift in Sources */, EB950B502E520436002D68FA /* wire_format.pb.swift in Sources */, + EB0A22E32FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift in Sources */, EB950B582E5205B4002D68FA /* Zip.swift in Sources */, EB950B512E520436002D68FA /* securemessage.pb.swift in Sources */, EBA1BB1F2EF1F6030068E46E /* ChangeDeviceNameModifier.swift in Sources */,