mirror of
https://github.com/leonboe1/QuickDrop.git
synced 2026-11-11 02:51:11 +00:00
1159 lines
44 KiB
Swift
1159 lines
44 KiB
Swift
//
|
|
// NearbyConnectionManager.swift
|
|
// QuickDrop
|
|
//
|
|
// Created by Grishka on 08.04.2023.
|
|
//
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
import DeviceKit
|
|
#endif
|
|
|
|
import Foundation
|
|
import Network
|
|
import System
|
|
import SwiftECC
|
|
import CryptoKit
|
|
import SwiftUI
|
|
import QRCode
|
|
import LUI
|
|
|
|
public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject {
|
|
|
|
// MARK: Private Properties
|
|
|
|
#if !EXTENSION
|
|
private let sleepManager = SleepManager.shared
|
|
#endif
|
|
private var tcpListener: NWListener
|
|
private var mdnsServices: [NetService] = []
|
|
private var incomingConnections: [String: InboundNearbyConnection] = [:]
|
|
private var foundServices: [String: FoundServiceInfo] = [:]
|
|
private var outboundAppDelegates: [OutboundAppDelegate] = []
|
|
private var inboundAppDelegates: [InboundAppDelegate] = []
|
|
private var outgoingTransfers: [String: OutgoingTransferInfo] = [:]
|
|
private var pendingOutgoingTransferPreparations: [String: UUID] = [:]
|
|
private var outgoingServiceResolvers: [String: NearbyBonjourServiceResolver] = [:]
|
|
private let pendingNotificationSyncTrustQueue = DispatchQueue(label: "NearbyConnectionManager.pendingNotificationSyncTrust")
|
|
private var pendingNotificationSyncTrust: [String: PendingReceiverTrust] = [:]
|
|
private var startedDeviceDiscovery = false
|
|
private var startedAdvertising = false { didSet { informAboutStatus() }}
|
|
private var browsers: [NWBrowser] = []
|
|
private let deviceNameResolver = NearbyDeviceNameResolver()
|
|
private let serviceTypes = ["_FC9F5ED42C8A._tcp."]
|
|
private var qrCodePrivateKey: ECPrivateKey?
|
|
private var qrCodeAdvertisingToken: Data?
|
|
private var qrCodeNameEncryptionKey: SymmetricKey?
|
|
private let hasConnectionMonitor = NWPathMonitor()
|
|
private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue")
|
|
private var securityScopeUrl: URL?
|
|
private static let customDeviceNameKey = "com.leonboettger.quickdrop.deviceName"
|
|
private var defaultPort: NWEndpoint.Port = 50362
|
|
private let txtKeyEndpointInfo = "n"
|
|
private let txtQuickDropCapabilities = "qd_caps"
|
|
private let txtQuickDropDeviceKeyFingerprint = "qd_kid"
|
|
private let notificationSyncCapabilityToken = "nsync1"
|
|
private let clipboardReceiveCapabilityToken = "cliprecv1"
|
|
private let unnamedAndroidFallbackDeviceName = "AndroidDevice".localized()
|
|
|
|
|
|
// MARK: Shared Instance
|
|
|
|
public static let shared = NearbyConnectionManager()
|
|
|
|
|
|
// MARK: Published State
|
|
|
|
@Published var attachments: AttachmentDetails? = nil
|
|
@Published var hasLocalNetworkPermission = true
|
|
@Published var isConnectedToLocalNetwork = true { didSet { informAboutStatus() }}
|
|
@Published var deviceInfo: EndpointInfo
|
|
|
|
|
|
// MARK: Public Properties
|
|
|
|
public var endpointID: [UInt8] = getEndpointID(forceRegeneration: false)
|
|
public var connectionUpdateCallback: (Bool) -> Void = { _ in } { didSet { informAboutStatus() }}
|
|
public var changedDeviceNameCallback: () -> Void = { }
|
|
|
|
|
|
// MARK: Initializers
|
|
|
|
override init() {
|
|
self.deviceInfo = Self.getEndpointInfo()
|
|
tcpListener = try! NWListener(using: NWParameters(tls: .none))
|
|
|
|
super.init()
|
|
|
|
hasConnectionMonitor.pathUpdateHandler = { path in
|
|
|
|
#if os(macOS)
|
|
// On Mac, the local network is available as long as the path is satisfied, since Macs do not have cellular modems
|
|
let isConnected = path.status == .satisfied
|
|
#else
|
|
// On iPhone and iPad, the local network is only available using Wi-Fi or Ethernet
|
|
let isConnected = path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet)
|
|
#endif
|
|
|
|
DispatchQueue.main.async {
|
|
if isConnected {
|
|
log("[NearbyConnectionManager] Connected to local network.")
|
|
} else {
|
|
log("[NearbyConnectionManager] Local network lost.")
|
|
}
|
|
self.isConnectedToLocalNetwork = isConnected
|
|
}
|
|
}
|
|
|
|
hasConnectionMonitor.start(queue: connectionMonitorQueue)
|
|
|
|
|
|
// remove old temp directory
|
|
let tempPath = FileManager.default.temporaryDirectory
|
|
let fileManager = FileManager.default
|
|
|
|
do {
|
|
let contents = try fileManager.contentsOfDirectory(at: tempPath, includingPropertiesForKeys: nil)
|
|
|
|
var didSomething = false
|
|
|
|
for item in contents {
|
|
didSomething = true
|
|
try fileManager.removeItem(at: item)
|
|
}
|
|
|
|
if didSomething {
|
|
log("[SaveFilesManager] Temporary directory cleared.")
|
|
}
|
|
} catch {
|
|
log("[SaveFilesManager] Failed to list contents of temp directory: \(error)")
|
|
}
|
|
}
|
|
|
|
|
|
// MARK: Deinitializer
|
|
|
|
deinit {
|
|
self.stopAccessingSaveDirectory()
|
|
}
|
|
|
|
|
|
public static func getCustomDeviceName() -> String? {
|
|
let storedName = AppGroup.appGroupUD.string(forKey: Self.customDeviceNameKey)
|
|
|
|
guard let name = storedName, !name.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
|
|
public func setCustomDeviceName(to newName: String) {
|
|
AppGroup.appGroupUD.setValue(newName, forKey: Self.customDeviceNameKey)
|
|
|
|
self.deviceInfo = Self.getEndpointInfo()
|
|
changedDeviceNameCallback()
|
|
|
|
if startedAdvertising {
|
|
self.becomeInvisible()
|
|
|
|
runAfter(seconds: 0.5) {
|
|
self.becomeVisible()
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private static func getEndpointInfo() -> EndpointInfo {
|
|
|
|
let deviceType = isMac() ? RemoteDeviceInfo.DeviceType.computer : (isiPadOrMac() ? .tablet : .phone)
|
|
|
|
if let customName = getCustomDeviceName() {
|
|
return EndpointInfo(name: String(customName), deviceType: deviceType)
|
|
}
|
|
|
|
#if os(macOS)
|
|
return EndpointInfo(name: String((Host.current().localizedName ?? "Mac")), deviceType: deviceType)
|
|
#else
|
|
return EndpointInfo(name: String(Device.current.description.withoutBracketedContent), deviceType: deviceType)
|
|
#endif
|
|
}
|
|
|
|
|
|
public func becomeVisible(randomPort: Bool = false) {
|
|
if startedAdvertising {
|
|
log("[NearbyConnectionManager] Already advertising, skipping")
|
|
return
|
|
}
|
|
|
|
log("[NearbyConnectionManager] Becoming visible")
|
|
let parameters = NWParameters(tls: .none)
|
|
|
|
do {
|
|
if randomPort {
|
|
self.tcpListener = try NWListener(using: parameters)
|
|
} else {
|
|
self.tcpListener = try NWListener(using: parameters, on: defaultPort)
|
|
}
|
|
}
|
|
catch {
|
|
log("[NearbyConnectionManager] Error starting TCP listener: \(error). Trying with random port.")
|
|
|
|
runAfter(seconds: 1) {
|
|
self.becomeVisible(randomPort: true)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
startedAdvertising = true
|
|
|
|
tcpListener.stateUpdateHandler = { state in
|
|
switch state {
|
|
case .ready:
|
|
log("[NearbyConnectionManager] Listener ready")
|
|
self.initMDNS(forceIDRegeneration: randomPort)
|
|
|
|
if let port = self.tcpListener.port, port != self.defaultPort {
|
|
log("[NearbyConnectionManager] Updated default port to \(port)")
|
|
self.defaultPort = port
|
|
}
|
|
|
|
case .failed(let error):
|
|
log("[NearbyConnectionManager] Listener failed: \(error)")
|
|
self.becomeInvisible()
|
|
|
|
runAfter(seconds: 1) {
|
|
if case .posix(let posixError) = error,
|
|
posixError == .EADDRINUSE {
|
|
self.becomeVisible(randomPort: true)
|
|
}
|
|
else {
|
|
self.becomeVisible()
|
|
}
|
|
}
|
|
|
|
case .cancelled:
|
|
log("[NearbyConnectionManager] Listener cancelled")
|
|
|
|
case .setup:
|
|
log("[NearbyConnectionManager] Listener setup")
|
|
|
|
case .waiting(let state):
|
|
log("[NearbyConnectionManager] Listener waiting: \(state)")
|
|
|
|
@unknown default:
|
|
log("[NearbyConnectionManager] Listener unknown state")
|
|
}
|
|
}
|
|
|
|
tcpListener.newConnectionHandler = { (connection: NWConnection) in
|
|
let id = UUID().uuidString
|
|
let conn = InboundNearbyConnection(connection: connection, id: id)
|
|
self.incomingConnections[id] = conn
|
|
conn.delegate = self
|
|
conn.start()
|
|
}
|
|
|
|
tcpListener.start(queue: .global(qos: .utility))
|
|
}
|
|
|
|
|
|
public func becomeInvisible() {
|
|
if !startedAdvertising {
|
|
log("[NearbyConnectionManager] Already invisible, ignoring becomeInvisible()")
|
|
return
|
|
}
|
|
|
|
log("[NearbyConnectionManager] Becoming invisible")
|
|
|
|
startedAdvertising = false
|
|
self.stopMDNS()
|
|
tcpListener.cancel()
|
|
}
|
|
|
|
|
|
private static func getEndpointID(forceRegeneration: Bool) -> [UInt8] {
|
|
|
|
// Try to retrieve from UserDefaults
|
|
if !forceRegeneration,
|
|
let savedString = Settings.sharedInstance.endpointID,
|
|
let savedData = savedString.data(using: .utf8) {
|
|
log("[NearbyConnectionManager] Using cached endpoint ID: \(savedString)")
|
|
return [UInt8](savedData)
|
|
}
|
|
|
|
// Generate a new random ID
|
|
var id: [UInt8] = []
|
|
let alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".compactMap { UInt8($0.asciiValue!) }
|
|
for _ in 0 ..< 4 {
|
|
id.append(alphabet[Int.random(in: 0 ..< alphabet.count)])
|
|
}
|
|
|
|
// Save to UserDefaults as String
|
|
let idString = String(bytes: id, encoding: .utf8) ?? ""
|
|
Settings.sharedInstance.endpointID = idString
|
|
log("[NearbyConnectionManager] Storing new endpoint ID: \(idString) ======================================")
|
|
|
|
return id
|
|
}
|
|
|
|
|
|
private func initMDNS(forceIDRegeneration: Bool) {
|
|
|
|
self.endpointID = Self.getEndpointID(forceRegeneration: forceIDRegeneration)
|
|
|
|
let nameBytes: [UInt8] = [
|
|
0x23, // PCP
|
|
endpointID[0], endpointID[1], endpointID[2], endpointID[3],
|
|
0xFC, 0x9F, 0x5E, // Service ID hash
|
|
0, 0,
|
|
]
|
|
|
|
let name = Data(nameBytes).urlSafeBase64EncodedString()
|
|
let port = Int32(tcpListener.port!.rawValue)
|
|
|
|
mdnsServices = serviceTypes.map { serviceType in
|
|
let service = NetService(domain: "", type: serviceType, name: name, port: port)
|
|
service.delegate = self
|
|
var txtRecord: [String: Data] = [
|
|
txtKeyEndpointInfo: deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!,
|
|
]
|
|
if let keyFingerprint = notificationSyncKeyFingerprintHex() {
|
|
txtRecord[txtQuickDropDeviceKeyFingerprint] = Data(keyFingerprint.utf8)
|
|
}
|
|
#if os(macOS)
|
|
let capabilityTokens = [
|
|
notificationSyncCapabilityToken,
|
|
clipboardReceiveCapabilityToken,
|
|
]
|
|
txtRecord[txtQuickDropCapabilities] = Data(capabilityTokens.joined(separator: ",").utf8)
|
|
#endif
|
|
service.setTXTRecord(NetService.data(fromTXTRecord: txtRecord))
|
|
service.publish()
|
|
return service
|
|
}
|
|
}
|
|
|
|
|
|
private func notificationSyncKeyFingerprintHex() -> String? {
|
|
guard let keyID = IdentityManager.shared.getPublicKey()?.toGenericPublicKey().id() else {
|
|
return nil
|
|
}
|
|
return keyID.hex.lowercased()
|
|
}
|
|
|
|
|
|
private func stopMDNS() {
|
|
// Gracefully stop each published service
|
|
for service in mdnsServices {
|
|
service.stop()
|
|
}
|
|
mdnsServices.removeAll()
|
|
}
|
|
|
|
|
|
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection _: InboundNearbyConnection) {
|
|
inboundAppDelegates.forEach { delegate in
|
|
delegate.obtainUserConsent(transfer: transfer, device: device)
|
|
}
|
|
}
|
|
|
|
|
|
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection) {
|
|
inboundAppDelegates.forEach { delegate in
|
|
delegate.obtainedUserConsentAutomatically(transfer: transfer, device: device)
|
|
}
|
|
}
|
|
|
|
|
|
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) {
|
|
incomingConnections.removeValue(forKey: connection.id)
|
|
|
|
// Control transfers suppress normal success callbacks, but
|
|
// still forward failures so pending consent/error UI can be cleaned up.
|
|
if connection.isControlTransfer, error == nil {
|
|
return
|
|
}
|
|
|
|
if !connection.wasUserRejected {
|
|
inboundAppDelegates.forEach { delegate in
|
|
delegate.connectionWasTerminated(
|
|
connectionID: connection.id,
|
|
from: connection.remoteDeviceInfo,
|
|
savedFiles: savedFiles,
|
|
wasPlainTextTransfer: connection.isPlainTextTransfer,
|
|
wasClipboardSync: connection.isClipboardSyncTransfer,
|
|
error: error
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func pairingSetupConfirmed(connection _: InboundNearbyConnection) {
|
|
inboundAppDelegates.forEach { delegate in
|
|
delegate.pairingSetupConfirmed()
|
|
}
|
|
}
|
|
|
|
|
|
func showPlusScreen() {
|
|
inboundAppDelegates.forEach { delegate in
|
|
delegate.showPlusScreen()
|
|
}
|
|
}
|
|
|
|
|
|
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double) {
|
|
inboundAppDelegates.forEach { delegate in
|
|
delegate.transferProgress(connectionID: connection.id, progress: progress)
|
|
}
|
|
}
|
|
|
|
|
|
public func submitUserConsent(
|
|
transferID: String,
|
|
accept: Bool,
|
|
trustDevice: Bool,
|
|
pairingToken: String? = nil
|
|
) {
|
|
guard let conn = incomingConnections[transferID] else { return }
|
|
|
|
log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept)")
|
|
conn.submitUserConsent(accepted: accept, trustDevice: trustDevice, pairingToken: pairingToken)
|
|
}
|
|
|
|
|
|
public func startDeviceDiscovery() {
|
|
|
|
if !startedDeviceDiscovery {
|
|
startedDeviceDiscovery = true
|
|
foundServices.removeAll()
|
|
|
|
log("[NearbyConnectionManager] Starting device discovery")
|
|
|
|
if browsers.isEmpty {
|
|
for type in serviceTypes {
|
|
log("[NearbyConnectionManager] Starting browser for type \(type)")
|
|
|
|
let browser = NWBrowser(for: .bonjourWithTXTRecord(type: type, domain: nil), using: .tcp)
|
|
browser.browseResultsChangedHandler = { _, changes in
|
|
for change in changes {
|
|
switch change {
|
|
case let .added(res):
|
|
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)")
|
|
}
|
|
}
|
|
}
|
|
|
|
browser.stateUpdateHandler = { newState in
|
|
switch newState {
|
|
case .failed(let error):
|
|
log("[NearbyConnectionManager] Browser failed: \(error)")
|
|
case .ready:
|
|
log("[NearbyConnectionManager] Browser ready")
|
|
self.hasLocalNetworkPermission = true
|
|
case .waiting(let error):
|
|
log("[NearbyConnectionManager] Browser waiting: \(error)")
|
|
|
|
if error == NWError.dns(DNSServiceErrorType(kDNSServiceErr_PolicyDenied)) {
|
|
log("[NearbyConnectionManager] Local network access not granted.")
|
|
self.hasLocalNetworkPermission = false
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
browser.start(queue: .main)
|
|
browsers.append(browser)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
public func stopDeviceDiscovery() {
|
|
if startedDeviceDiscovery {
|
|
|
|
log("[NearbyConnectionManager] Stopping device discovery")
|
|
|
|
for browser in browsers {
|
|
browser.cancel()
|
|
}
|
|
|
|
browsers.removeAll()
|
|
startedDeviceDiscovery = false
|
|
deviceNameResolver.cancelAll()
|
|
|
|
for delegate in outboundAppDelegates {
|
|
|
|
foundServices.values.forEach { service in
|
|
guard let device = service.device, let id = device.id else { return }
|
|
delegate.removeDevice(id: id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
var isDeviceDiscoveryActive: Bool {
|
|
startedDeviceDiscovery
|
|
}
|
|
|
|
|
|
func discoveredDevices() -> [RemoteDeviceInfo] {
|
|
foundServices.values.compactMap(\.device)
|
|
}
|
|
|
|
|
|
public func addOutboundAppDelegate(_ delegate: OutboundAppDelegate) {
|
|
outboundAppDelegates.append(delegate)
|
|
for service in foundServices.values {
|
|
guard let device = service.device else { continue }
|
|
delegate.addDevice(device: device)
|
|
}
|
|
}
|
|
|
|
|
|
public func addInboundAppDelegate(_ delegate: InboundAppDelegate) {
|
|
inboundAppDelegates.append(delegate)
|
|
}
|
|
|
|
|
|
public func removeOutboundAppDelegate(_ delegate: OutboundAppDelegate) {
|
|
outboundAppDelegates.removeAll(where: { $0 === delegate })
|
|
}
|
|
|
|
public func removeInboundAppDelegate(_ delegate: InboundAppDelegate) {
|
|
inboundAppDelegates.removeAll(where: { $0 === delegate })
|
|
}
|
|
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
public func cancelAllIncomingConnections() {
|
|
let incomingTransfers = Array(incomingConnections.values)
|
|
for transfer in incomingTransfers {
|
|
transfer.cancel()
|
|
}
|
|
}
|
|
|
|
private func endpointID(for service: NWBrowser.Result) -> String? {
|
|
guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _) = service.endpoint else { return nil }
|
|
guard let nameData = Data.dataFromUrlSafeBase64(serviceName) else { return nil }
|
|
guard nameData.count >= 10 else { return nil }
|
|
let pcp = nameData[0]
|
|
guard pcp == 0x23 else { return nil }
|
|
let endpointID = String(data: nameData.subdata(in: 1 ..< 5), encoding: .ascii)!
|
|
let serviceIDHash = nameData.subdata(in: 5 ..< 8)
|
|
guard serviceIDHash == Data([0xFC, 0x9F, 0x5E]) else { return nil }
|
|
return endpointID
|
|
}
|
|
|
|
|
|
private func addFoundDevice(service: NWBrowser.Result) {
|
|
log("[NearbyConnectionManager] Found Service \(service)")
|
|
for interface in service.interfaces {
|
|
if case .loopback = interface.type {
|
|
return
|
|
}
|
|
}
|
|
guard let endpointID = endpointID(for: service) else { return }
|
|
var foundService = FoundServiceInfo(service: service)
|
|
|
|
guard case let NWBrowser.Result.Metadata.bonjour(txtRecord) = service.metadata else { return }
|
|
guard let endpointInfoEncoded = txtRecord.dictionary[txtKeyEndpointInfo] else { return }
|
|
guard let endpointInfoData = Data.dataFromUrlSafeBase64(endpointInfoEncoded) else { return }
|
|
let parsedEndpointInfo = EndpointInfo(data: endpointInfoData)
|
|
let keyFingerprint = normalizedKeyFingerprint(
|
|
txtRecord.dictionary[txtQuickDropDeviceKeyFingerprint]
|
|
)
|
|
let capabilityTokens = Set(
|
|
txtRecord.dictionary[txtQuickDropCapabilities]?
|
|
.split(separator: ",")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty } ?? []
|
|
)
|
|
let supportsNotificationSync = capabilityTokens.contains(notificationSyncCapabilityToken)
|
|
let supportsClipboardReceive = capabilityTokens.contains(clipboardReceiveCapabilityToken)
|
|
var endpointInfo = parsedEndpointInfo
|
|
?? EndpointInfo(name: unnamedAndroidFallbackDeviceName, deviceType: .unknown)
|
|
let isQuickDropPeer = keyFingerprint != nil
|
|
let needsDeviceNameLookup = deviceNameResolver.needsLookup(for: parsedEndpointInfo)
|
|
if needsDeviceNameLookup {
|
|
endpointInfo.name = unnamedAndroidFallbackDeviceName
|
|
}
|
|
let deviceInfo = addFoundDevice(
|
|
foundService: &foundService,
|
|
endpointInfo: endpointInfo,
|
|
endpointID: endpointID,
|
|
isQuickDropPeer: isQuickDropPeer,
|
|
keyFingerprint: keyFingerprint,
|
|
supportsNotificationSync: supportsNotificationSync,
|
|
supportsClipboardReceive: supportsClipboardReceive
|
|
)
|
|
|
|
if needsDeviceNameLookup {
|
|
deviceNameResolver.resolveName(for: service, endpointID: endpointID) { [weak self] deviceName in
|
|
guard let self else { return }
|
|
guard self.foundServices[endpointID]?.device?.name == self.unnamedAndroidFallbackDeviceName else { return }
|
|
|
|
var resolvedEndpointInfo = endpointInfo
|
|
resolvedEndpointInfo.name = deviceName
|
|
var resolvedFoundService = FoundServiceInfo(service: service)
|
|
_ = self.addFoundDevice(
|
|
foundService: &resolvedFoundService,
|
|
endpointInfo: resolvedEndpointInfo,
|
|
endpointID: endpointID,
|
|
isQuickDropPeer: isQuickDropPeer,
|
|
keyFingerprint: keyFingerprint,
|
|
supportsNotificationSync: supportsNotificationSync,
|
|
supportsClipboardReceive: supportsClipboardReceive
|
|
)
|
|
}
|
|
}
|
|
else {
|
|
deviceNameResolver.cancelLookup(for: endpointID)
|
|
}
|
|
|
|
if let qrData = endpointInfo.qrCodeData, let qrCodeAdvertisingToken = qrCodeAdvertisingToken, let qrCodeNameEncryptionKey = qrCodeNameEncryptionKey {
|
|
|
|
log("[NearbyConnectionManager] Device has QR data: \(qrData.base64EncodedString()), advertising token is \(qrCodeAdvertisingToken.base64EncodedString())")
|
|
|
|
if qrData == qrCodeAdvertisingToken {
|
|
for delegate in outboundAppDelegates {
|
|
delegate.startTransferWithQrCode(device: deviceInfo)
|
|
}
|
|
}
|
|
else if qrData.count > 28 {
|
|
do {
|
|
let box = try AES.GCM.SealedBox(combined: qrData)
|
|
let decryptedName = try AES.GCM.open(box, using: qrCodeNameEncryptionKey, authenticating: qrCodeAdvertisingToken)
|
|
guard let name = String.init(data: decryptedName, encoding: .utf8) else { return }
|
|
endpointInfo.name = name
|
|
let deviceInfo = addFoundDevice(
|
|
foundService: &foundService,
|
|
endpointInfo: endpointInfo,
|
|
endpointID: endpointID,
|
|
isQuickDropPeer: isQuickDropPeer,
|
|
keyFingerprint: keyFingerprint,
|
|
supportsNotificationSync: supportsNotificationSync,
|
|
supportsClipboardReceive: supportsClipboardReceive
|
|
)
|
|
for delegate in outboundAppDelegates {
|
|
delegate.startTransferWithQrCode(device: deviceInfo)
|
|
}
|
|
} catch {
|
|
log("[NearbyConnectionManager] Error decrypting QR code data of an invisible device: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private func addFoundDevice(
|
|
foundService: inout FoundServiceInfo,
|
|
endpointInfo: EndpointInfo,
|
|
endpointID: String,
|
|
isQuickDropPeer: Bool = false,
|
|
keyFingerprint: String? = nil,
|
|
supportsNotificationSync: Bool = false,
|
|
supportsClipboardReceive: Bool = false
|
|
) -> RemoteDeviceInfo {
|
|
let hadPreviousDevice = foundServices[endpointID]?.device != nil
|
|
let deviceInfo = RemoteDeviceInfo(
|
|
info: endpointInfo,
|
|
id: endpointID,
|
|
isQuickDropPeer: isQuickDropPeer,
|
|
keyFingerprint: keyFingerprint,
|
|
supportsNotificationSync: supportsNotificationSync,
|
|
supportsClipboardReceive: supportsClipboardReceive
|
|
)
|
|
foundService.device = deviceInfo
|
|
foundServices[endpointID] = foundService
|
|
|
|
if hadPreviousDevice {
|
|
for delegate in outboundAppDelegates {
|
|
delegate.removeDevice(id: endpointID)
|
|
}
|
|
}
|
|
|
|
for delegate in outboundAppDelegates {
|
|
delegate.addDevice(device: deviceInfo)
|
|
}
|
|
|
|
return deviceInfo
|
|
}
|
|
|
|
|
|
public func resolveDiscoveredDeviceIdForKeyFingerprint(_ keyFingerprint: String) -> String? {
|
|
guard let normalizedFingerprint = normalizedKeyFingerprint(keyFingerprint) else {
|
|
return nil
|
|
}
|
|
|
|
return foundServices.values
|
|
.first(where: { $0.device?.keyFingerprint == normalizedFingerprint })?
|
|
.device?
|
|
.id
|
|
}
|
|
|
|
|
|
private func removeFoundDevice(service: NWBrowser.Result) {
|
|
guard let endpointID = endpointID(for: service) else { return }
|
|
deviceNameResolver.cancelLookup(for: endpointID)
|
|
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)
|
|
}
|
|
}
|
|
|
|
|
|
public func generateQrCodeKey() -> Image? {
|
|
|
|
log("[NearbyConnectionManager] Generating new QR code key")
|
|
|
|
let domain = Domain.instance(curve: .EC256r1)
|
|
let (pubKey, privKey) = domain.makeKeyPair()
|
|
|
|
qrCodePrivateKey = privKey
|
|
|
|
var keyData = Data()
|
|
keyData.append(contentsOf: [0, 0, 2])
|
|
keyData.append(Data(pubKey.w.x.asSignedBytes()).ensureLength(length: 32))
|
|
|
|
let ikm = SymmetricKey(data: keyData)
|
|
|
|
qrCodeAdvertisingToken = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputLength: 16).data()
|
|
qrCodeNameEncryptionKey = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputLength: 16)
|
|
|
|
do {
|
|
let qrKey = keyData.urlSafeBase64EncodedString()
|
|
let qrCodeImage = try QRCode.build
|
|
.text("https://quickshare.google/qrcode#key=\(qrKey)")
|
|
.backgroundColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0))
|
|
.quietZonePixelCount(3)
|
|
.onPixels.shape(.circle())
|
|
.eye.shape(.squircle())
|
|
.errorCorrection(.low)
|
|
.generate
|
|
.image(dimension: 1000)
|
|
|
|
return Image(decorative: qrCodeImage, scale: 1.0, orientation: .up)
|
|
}
|
|
catch {
|
|
log("Error generating QR code: \(error)")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
|
|
@discardableResult
|
|
public func startOutgoingTransfer(
|
|
deviceID: String,
|
|
delegate: OutboundAppDelegate,
|
|
urls: [URL],
|
|
textToSend: String?,
|
|
preparationFinished: (() -> Void)? = nil,
|
|
isClipboardSyncTransfer: Bool = false,
|
|
receiverAuthenticationPolicy: ReceiverAuthenticationPolicy
|
|
) -> Bool {
|
|
log("Starting outgoing transfer to \(deviceID)")
|
|
guard foundServices[deviceID] != nil else {
|
|
let error = NearbyError.protocolError("TargetDeviceNoLongerNearby".localized())
|
|
delegate.connectionFailed(error: error)
|
|
return false
|
|
}
|
|
|
|
let preparationID = UUID()
|
|
pendingOutgoingTransferPreparations[deviceID] = preparationID
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self, weak delegate] in
|
|
guard let self else { return }
|
|
|
|
do {
|
|
let localUrls = try self.saveFilesToTemp(urls: urls)
|
|
|
|
DispatchQueue.main.async {
|
|
guard let info = self.foundServices[deviceID],
|
|
self.pendingOutgoingTransferPreparations[deviceID] == preparationID else {
|
|
return
|
|
}
|
|
|
|
guard let delegate else {
|
|
self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID)
|
|
return
|
|
}
|
|
|
|
preparationFinished?()
|
|
|
|
self.resolveOutgoingEndpoint(for: info.service, deviceID: deviceID) { endpoint, requiredInterface in
|
|
guard let info = self.foundServices[deviceID],
|
|
self.pendingOutgoingTransferPreparations[deviceID] == preparationID else {
|
|
return
|
|
}
|
|
|
|
self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID)
|
|
|
|
let tcp = NWProtocolTCP.Options()
|
|
tcp.noDelay = true
|
|
let parameters = NWParameters(tls: .none, tcp: tcp)
|
|
parameters.requiredInterface = requiredInterface
|
|
let nwconn = NWConnection(to: endpoint, using: parameters)
|
|
|
|
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)")
|
|
DispatchQueue.main.async {
|
|
guard self.pendingOutgoingTransferPreparations[deviceID] == preparationID else {
|
|
return
|
|
}
|
|
|
|
self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID)
|
|
delegate?.connectionFailed(error: error)
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
|
|
private func resolveOutgoingEndpoint(
|
|
for service: NWBrowser.Result,
|
|
deviceID: String,
|
|
completion: @escaping (NWEndpoint, NWInterface?) -> Void
|
|
) {
|
|
guard let resolver = NearbyBonjourServiceResolver(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, endpoint.interface)
|
|
} else {
|
|
log("[NearbyConnectionManager] Could not resolve outgoing service for \(deviceID); falling back to Bonjour endpoint \(service.endpoint)")
|
|
completion(service.endpoint, nil)
|
|
}
|
|
}) else {
|
|
log("[NearbyConnectionManager] Cannot create resolver for outgoing service; falling back to Bonjour endpoint")
|
|
completion(service.endpoint, nil)
|
|
return
|
|
}
|
|
|
|
outgoingServiceResolvers.removeValue(forKey: deviceID)?.cancel()
|
|
outgoingServiceResolvers[deviceID] = resolver
|
|
resolver.start()
|
|
}
|
|
|
|
|
|
private func normalizedKeyFingerprint(_ keyFingerprint: String?) -> String? {
|
|
let normalized = keyFingerprint?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.lowercased()
|
|
return normalized?.isEmpty == false ? normalized : nil
|
|
}
|
|
|
|
|
|
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 {
|
|
transfer.delegate.connectionWasEstablished(pinCode: connection.pinCode!)
|
|
}
|
|
}
|
|
|
|
|
|
func transferAccepted(connection: OutboundNearbyConnection) {
|
|
guard let transfer = outgoingTransfers[connection.id] else { return }
|
|
DispatchQueue.main.async {
|
|
transfer.delegate.transferAccepted()
|
|
}
|
|
}
|
|
|
|
|
|
func updatedTransferProgress(connection: OutboundNearbyConnection, progress: Double) {
|
|
guard let transfer = outgoingTransfers[connection.id] else { return }
|
|
DispatchQueue.main.async {
|
|
transfer.delegate.transferProgress(progress: progress)
|
|
}
|
|
}
|
|
|
|
|
|
func failedWithError(connection: OutboundNearbyConnection, error: Error) {
|
|
guard let transfer = outgoingTransfers[connection.id] else { return }
|
|
DispatchQueue.main.async {
|
|
transfer.delegate.connectionFailed(error: error)
|
|
}
|
|
outgoingTransfers.removeValue(forKey: connection.id)
|
|
}
|
|
|
|
|
|
func transferFinished(connection: OutboundNearbyConnection) {
|
|
guard let transfer = outgoingTransfers[connection.id] else { return }
|
|
DispatchQueue.main.async {
|
|
transfer.delegate.transferFinished()
|
|
}
|
|
outgoingTransfers.removeValue(forKey: connection.id)
|
|
}
|
|
|
|
|
|
func receiverAuthenticationPending(
|
|
connection _: OutboundNearbyConnection,
|
|
certificateData: Data,
|
|
device: RemoteDeviceInfo,
|
|
fingerprint: String
|
|
) {
|
|
guard let normalizedFingerprint = normalizedKeyFingerprint(fingerprint) else { return }
|
|
log("[NearbyConnectionManager] Receiver auth pending: fingerprint=\(normalizedFingerprint) device=\(device.name ?? "unknown") (\(device.id ?? "no-id"))")
|
|
pendingNotificationSyncTrustQueue.sync {
|
|
pendingNotificationSyncTrust[normalizedFingerprint] = PendingReceiverTrust(
|
|
certificateData: certificateData,
|
|
device: device
|
|
)
|
|
}
|
|
}
|
|
|
|
|
|
public func commitNotificationSyncReceiverTrust(_ fingerprint: String) -> Bool {
|
|
guard let normalizedFingerprint = normalizedKeyFingerprint(fingerprint) else {
|
|
return false
|
|
}
|
|
|
|
let pending = pendingNotificationSyncTrustQueue.sync {
|
|
pendingNotificationSyncTrust.removeValue(forKey: normalizedFingerprint)
|
|
}
|
|
|
|
if pending == nil {
|
|
let alreadyTrusted = TrustStore.shared.trustedCertificates.keys.contains(normalizedFingerprint)
|
|
if alreadyTrusted {
|
|
log("[NearbyConnectionManager] Commit trust skipped: already trusted for \(normalizedFingerprint)")
|
|
return true
|
|
}
|
|
|
|
log("[NearbyConnectionManager] Commit trust failed: no pending entry for \(normalizedFingerprint)")
|
|
return false
|
|
}
|
|
|
|
guard let pending,
|
|
let certificate = try? Sharing_Nearby_PublicCertificate(serializedBytes: pending.certificateData) else {
|
|
log("[NearbyConnectionManager] Commit trust failed: could not parse certificate for \(normalizedFingerprint)")
|
|
return false
|
|
}
|
|
|
|
let derivedFingerprint = certificate.secretID.hex.lowercased()
|
|
guard derivedFingerprint == normalizedFingerprint else {
|
|
log("[NearbyConnectionManager] Commit trust failed: fingerprint mismatch pending=\(normalizedFingerprint) derived=\(derivedFingerprint)")
|
|
return false
|
|
}
|
|
|
|
log("[NearbyConnectionManager] Commit trust succeeded for fingerprint=\(normalizedFingerprint)")
|
|
TrustStore.shared.addTrusted(certificate: certificate, device: pending.device)
|
|
return true
|
|
}
|
|
|
|
|
|
public func clearPendingNotificationSyncReceiverTrust(_ fingerprint: String? = nil) {
|
|
if fingerprint == nil {
|
|
pendingNotificationSyncTrustQueue.sync {
|
|
pendingNotificationSyncTrust.removeAll()
|
|
}
|
|
log("[NearbyConnectionManager] Cleared all pending receiver trust entries")
|
|
return
|
|
}
|
|
|
|
guard let normalizedFingerprint = normalizedKeyFingerprint(fingerprint) else { return }
|
|
_ = pendingNotificationSyncTrustQueue.sync {
|
|
pendingNotificationSyncTrust.removeValue(forKey: normalizedFingerprint)
|
|
}
|
|
log("[NearbyConnectionManager] Cleared pending receiver trust for \(normalizedFingerprint)")
|
|
}
|
|
|
|
|
|
public func getActiveConnectionsCount() -> Int {
|
|
return incomingConnections.count + outgoingTransfers.count
|
|
}
|
|
|
|
|
|
private func saveFilesToTemp(urls: [URL]) throws -> [URL] {
|
|
|
|
var modifiedUrls = [URL]()
|
|
|
|
for url in urls {
|
|
|
|
// if url is no file url or already in inside temp directory, do nothing
|
|
if url.isFileURL && !url.standardizedFileURL.path.contains(FileManager.default.temporaryDirectory.standardizedFileURL.path) {
|
|
|
|
let accessSuccess = url.startAccessingSecurityScopedResource()
|
|
|
|
if !accessSuccess {
|
|
log("[NearbyConnectionManager] Could not access security scoped resource at \(url)")
|
|
}
|
|
|
|
var isDirectory: ObjCBool = false
|
|
if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue {
|
|
let zipUrl = try Zip.createAtTemporaryDirectory(zipFilename: url.lastPathComponent, fromDirectory: url)
|
|
|
|
modifiedUrls.append(zipUrl)
|
|
}
|
|
else {
|
|
// Copy single file to temp directory
|
|
let tempUrl = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(url.lastPathComponent)
|
|
// Remove if already exists
|
|
if FileManager.default.fileExists(atPath: tempUrl.path) {
|
|
try FileManager.default.removeItem(at: tempUrl)
|
|
}
|
|
try FileManager.default.copyItem(at: url, to: tempUrl)
|
|
modifiedUrls.append(tempUrl)
|
|
}
|
|
|
|
if accessSuccess {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
else {
|
|
log("[NearbyConnectionManager] File URL \(url) does not point to a security scoped resource")
|
|
modifiedUrls.append(url)
|
|
}
|
|
}
|
|
|
|
return modifiedUrls
|
|
}
|
|
|
|
|
|
// -- MARK: - Inbound Connection Data Store
|
|
|
|
public func getSaveDirectory() -> URL {
|
|
|
|
// Not supported on iOS
|
|
#if os(macOS)
|
|
if let securityScopeUrl = securityScopeUrl {
|
|
log("[SaveFilesManager] Using existing security scope URL: \(securityScopeUrl)")
|
|
return securityScopeUrl
|
|
}
|
|
|
|
if let bookmarkData = Settings.sharedInstance.saveFolderBookmark {
|
|
var isStale = false
|
|
|
|
do {
|
|
let url = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
|
|
|
if !isStale {
|
|
if url.startAccessingSecurityScopedResource() {
|
|
log("[SaveFilesManager] Successfully accessed security scoped resource: \(url)")
|
|
|
|
securityScopeUrl = url
|
|
return url
|
|
}
|
|
} else {
|
|
log("[SaveFilesManager] Bookmark is stale, using default downloads folder.")
|
|
}
|
|
|
|
} catch {
|
|
log("[SaveFilesManager] Failed to resolve bookmark: \(error), using default downloads folder.")
|
|
}
|
|
}
|
|
|
|
do {
|
|
return try FileManager.default.url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: true).resolvingSymlinksInPath()
|
|
} catch {
|
|
fatalError("[SaveFilesManager] Failed to get downloads directory: \(error)")
|
|
}
|
|
#else
|
|
// Return the documents directory for iOS
|
|
do {
|
|
return try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).resolvingSymlinksInPath()
|
|
} catch {
|
|
fatalError("Failed to get documents directory: \(error)")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
|
|
private func stopAccessingSaveDirectory() {
|
|
// Clean up security scoped resource access
|
|
guard let url = securityScopeUrl else {
|
|
return
|
|
}
|
|
|
|
log("[SaveFilesManager] Stopping access to security scoped resource: \(url)")
|
|
url.stopAccessingSecurityScopedResource()
|
|
securityScopeUrl = nil
|
|
}
|
|
|
|
|
|
// MARK: Callbacks
|
|
|
|
func informAboutStatus() {
|
|
DispatchQueue.main.async {
|
|
self.connectionUpdateCallback(self.isConnectedToLocalNetwork && self.startedAdvertising)
|
|
}
|
|
}
|
|
|
|
|
|
// -- MARK: - Internal Data Model
|
|
|
|
private struct FoundServiceInfo {
|
|
let service: NWBrowser.Result
|
|
var device: RemoteDeviceInfo?
|
|
}
|
|
|
|
|
|
private struct OutgoingTransferInfo {
|
|
let service: NWBrowser.Result
|
|
let device: RemoteDeviceInfo
|
|
let connection: OutboundNearbyConnection
|
|
let delegate: OutboundAppDelegate
|
|
}
|
|
|
|
|
|
private struct PendingReceiverTrust {
|
|
let certificateData: Data
|
|
let device: RemoteDeviceInfo
|
|
}
|
|
}
|