More robust outgoing connection

This commit is contained in:
Leon Böttger committed 2026-05-19 21:48:26 +02:00
1 parent db70d40e43
commit 6b0e14de3e
3 files changed
+255 -21

No files matched your search

@@ -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 {
@@ -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<String>()
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
}
+14
View File
@@ -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 = "<group>"; };
A1F5B3D92F3A1A0100A1B2C4 /* PairingQrSheetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingQrSheetManager.swift; sourceTree = "<group>"; };
EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedDevicesView.swift; sourceTree = "<group>"; };
EB0A22E22FBCF59300B78FF5 /* NearbyOutgoingServiceResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyOutgoingServiceResolver.swift; sourceTree = "<group>"; };
EB1325D22E701D8900FD749C /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
EB1325E72E7025D900FD749C /* ActiveNetworkInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveNetworkInterface.swift; sourceTree = "<group>"; };
EB1325E82E7025D900FD749C /* ApIsolation6Tester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApIsolation6Tester.swift; sourceTree = "<group>"; };
@@ -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 */,