diff --git a/NearbyShare/Connection/InboundNearbyConnection.swift b/NearbyShare/Connection/InboundNearbyConnection.swift index 6b2426b..6d7732e 100644 --- a/NearbyShare/Connection/InboundNearbyConnection.swift +++ b/NearbyShare/Connection/InboundNearbyConnection.swift @@ -279,6 +279,9 @@ class InboundNearbyConnection: NearbyConnection { if let urlStr = String(data: payload, encoding: .utf8) { if isPlainTextTransfer { + #if os(macOS) && !EXTENSION + ClipboardSyncCoordinator.shared.suppressNextClipboardSync(for: urlStr) + #endif #if os(macOS) // macOS clipboard let pasteboard = NSPasteboard.general diff --git a/NearbyShare/Connection/NearbyConnectionManager.swift b/NearbyShare/Connection/NearbyConnectionManager.swift index 8c5ecfd..b4b84f7 100644 --- a/NearbyShare/Connection/NearbyConnectionManager.swift +++ b/NearbyShare/Connection/NearbyConnectionManager.swift @@ -34,6 +34,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb private var inboundAppDelegates: [InboundAppDelegate] = [] private var outgoingTransfers: [String: OutgoingTransferInfo] = [:] private var pendingOutgoingTransferPreparations: [String: UUID] = [:] + 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] = [] @@ -51,6 +53,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb private let txtQuickDropCapabilities = "qd_caps" private let txtQuickDropDeviceKeyFingerprint = "qd_kid" private let notificationSyncCapabilityToken = "nsync1" + private let clipboardReceiveCapabilityToken = "cliprecv1" private let unnamedAndroidFallbackDeviceName = "AndroidDevice".localized() @@ -321,7 +324,11 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb txtRecord[txtQuickDropDeviceKeyFingerprint] = Data(keyFingerprint.utf8) } #if os(macOS) - txtRecord[txtQuickDropCapabilities] = Data(notificationSyncCapabilityToken.utf8) + let capabilityTokens = [ + notificationSyncCapabilityToken, + clipboardReceiveCapabilityToken, + ] + txtRecord[txtQuickDropCapabilities] = Data(capabilityTokens.joined(separator: ",").utf8) #endif service.setTXTRecord(NetService.data(fromTXTRecord: txtRecord)) service.publish() @@ -486,6 +493,16 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } } } + + + var isDeviceDiscoveryActive: Bool { + startedDeviceDiscovery + } + + + func discoveredDevices() -> [RemoteDeviceInfo] { + foundServices.values.compactMap(\.device) + } public func addOutboundAppDelegate(_ delegate: OutboundAppDelegate) { @@ -556,9 +573,20 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb 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 = txtRecord.dictionary[txtQuickDropDeviceKeyFingerprint] != nil + let isQuickDropPeer = keyFingerprint != nil let needsDeviceNameLookup = deviceNameResolver.needsLookup(for: parsedEndpointInfo) if needsDeviceNameLookup { endpointInfo.name = unnamedAndroidFallbackDeviceName @@ -567,7 +595,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID, - isQuickDropPeer: isQuickDropPeer + isQuickDropPeer: isQuickDropPeer, + keyFingerprint: keyFingerprint, + supportsNotificationSync: supportsNotificationSync, + supportsClipboardReceive: supportsClipboardReceive ) if needsDeviceNameLookup { @@ -582,7 +613,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb foundService: &resolvedFoundService, endpointInfo: resolvedEndpointInfo, endpointID: endpointID, - isQuickDropPeer: isQuickDropPeer + isQuickDropPeer: isQuickDropPeer, + keyFingerprint: keyFingerprint, + supportsNotificationSync: supportsNotificationSync, + supportsClipboardReceive: supportsClipboardReceive ) } } @@ -609,7 +643,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID, - isQuickDropPeer: isQuickDropPeer + isQuickDropPeer: isQuickDropPeer, + keyFingerprint: keyFingerprint, + supportsNotificationSync: supportsNotificationSync, + supportsClipboardReceive: supportsClipboardReceive ) for delegate in outboundAppDelegates { delegate.startTransferWithQrCode(device: deviceInfo) @@ -626,13 +663,19 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb foundService: inout FoundServiceInfo, endpointInfo: EndpointInfo, endpointID: String, - isQuickDropPeer: Bool = false + 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 + isQuickDropPeer: isQuickDropPeer, + keyFingerprint: keyFingerprint, + supportsNotificationSync: supportsNotificationSync, + supportsClipboardReceive: supportsClipboardReceive ) foundService.device = deviceInfo foundServices[endpointID] = foundService @@ -649,6 +692,18 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb 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) { @@ -701,15 +756,21 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } + @discardableResult public func startOutgoingTransfer( deviceID: String, delegate: OutboundAppDelegate, urls: [URL], textToSend: String?, - preparationFinished: (() -> Void)? = nil - ) { + preparationFinished: (() -> Void)? = nil, + receiverAuthenticationPolicy: ReceiverAuthenticationPolicy + ) -> Bool { log("Starting outgoing transfer to \(deviceID)") - guard foundServices[deviceID] != nil else { return } + guard foundServices[deviceID] != nil else { + let error = NearbyError.protocolError("TargetDeviceNoLongerNearby".localized()) + delegate.connectionFailed(error: error) + return false + } let preparationID = UUID() pendingOutgoingTransferPreparations[deviceID] = preparationID @@ -735,7 +796,14 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb tcp.noDelay = true let nwconn = NWConnection(to: info.service.endpoint, using: NWParameters(tls: .none, tcp: tcp)) - let conn = OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: localUrls, textToSend: textToSend) + let conn = OutboundNearbyConnection( + connection: nwconn, + id: deviceID, + urlsToSend: localUrls, + textToSend: textToSend, + receiverAuthenticationPolicy: receiverAuthenticationPolicy + ) + conn.remoteDeviceInfo = info.device conn.delegate = self conn.qrCodePrivateKey = self.qrCodePrivateKey @@ -751,12 +819,20 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } self.pendingOutgoingTransferPreparations.removeValue(forKey: deviceID) - self.outboundAppDelegates.forEach { delegate in - delegate.connectionFailed(error: error) - } + delegate?.connectionFailed(error: error) } } } + + return true + } + + + private func normalizedKeyFingerprint(_ keyFingerprint: String?) -> String? { + let normalized = keyFingerprint? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized?.isEmpty == false ? normalized : nil } @@ -800,6 +876,78 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } 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 { @@ -938,4 +1086,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb let connection: OutboundNearbyConnection let delegate: OutboundAppDelegate } + + + private struct PendingReceiverTrust { + let certificateData: Data + let device: RemoteDeviceInfo + } } diff --git a/NearbyShare/Connection/OutboundNearbyConnection.swift b/NearbyShare/Connection/OutboundNearbyConnection.swift index df4d865..90b6218 100644 --- a/NearbyShare/Connection/OutboundNearbyConnection.swift +++ b/NearbyShare/Connection/OutboundNearbyConnection.swift @@ -22,11 +22,13 @@ class OutboundNearbyConnection: NearbyConnection { private var currentState: State = .initial private let urlsToSend: [URL] private let textToSend: String? + private let receiverAuthenticationPolicy: ReceiverAuthenticationPolicy private var ukeyClientFinishMsgData: Data? private var queue: [OutgoingFileTransfer] = [] private var currentTransfer: OutgoingFileTransfer? private var totalBytesToSend: Int64 = 0 private var textPayloadID: Int64 = 0 + private var receiverAuthenticationVerified = false public var qrCodePrivateKey: ECPrivateKey? public var delegate: OutboundNearbyConnectionDelegate? @@ -35,10 +37,17 @@ class OutboundNearbyConnection: NearbyConnection { case initial, sentUkeyClientInit, sentUkeyClientFinish, sentPairedKeyEncryption, sentPairedKeyResult, sentIntroduction, sendingFiles } - init(connection: NWConnection, id: String, urlsToSend: [URL], textToSend: String?) { + init( + connection: NWConnection, + id: String, + urlsToSend: [URL], + textToSend: String?, + receiverAuthenticationPolicy: ReceiverAuthenticationPolicy + ) { self.urlsToSend = urlsToSend self.textToSend = textToSend + self.receiverAuthenticationPolicy = receiverAuthenticationPolicy super.init(connection: connection, id: id) @@ -368,7 +377,12 @@ class OutboundNearbyConnection: NearbyConnection { pairedResult.v1 = Sharing_Nearby_V1Frame() pairedResult.v1.type = .pairedKeyResult pairedResult.v1.pairedKeyResult = Sharing_Nearby_PairedKeyResultFrame() - pairedResult.v1.pairedKeyResult.status = .unable + if receiverAuthenticationPolicy.requiresReceiverAuthentication { + try verifyReceiverAuthentication(frame: frame) + pairedResult.v1.pairedKeyResult.status = .success + } else { + pairedResult.v1.pairedKeyResult.status = .unable + } try sendTransferSetupFrame(pairedResult) currentState = .sentPairedKeyResult @@ -377,6 +391,9 @@ class OutboundNearbyConnection: NearbyConnection { private func processPairedKeyResult(frame: Sharing_Nearby_Frame) throws { guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyResult") } + if receiverAuthenticationPolicy.requiresReceiverAuthentication, !receiverAuthenticationVerified { + throw NearbyError.protocolError("Receiver authentication incomplete") + } var introduction = Sharing_Nearby_Frame() introduction.version = .v1 @@ -471,8 +488,86 @@ class OutboundNearbyConnection: NearbyConnection { try sendDisconnectionAndDisconnect() } } - - + + + private func verifyReceiverAuthentication(frame: Sharing_Nearby_Frame) throws { + guard let expectedFingerprint = receiverAuthenticationPolicy.expectedReceiverKeyFingerprint, !expectedFingerprint.isEmpty else { + throw NearbyError.protocolError("Missing expected receiver fingerprint") + } + + guard let certificate = frame.v1.certificateInfo.publicCertificate.first else { + throw NearbyError.protocolError("Missing receiver certificate") + } + + let certificateData = try certificate.serializedData() + let secretID = certificate.secretID + guard !secretID.isEmpty else { + throw NearbyError.protocolError("Missing receiver key ID") + } + + let peerGenericKey = try Securemessage_GenericPublicKey(serializedBytes: certificate.publicKey) + guard let derivedKeyID = peerGenericKey.id() else { + throw NearbyError.protocolError("Receiver key ID derivation failed") + } + + guard secretID == derivedKeyID else { + throw NearbyError.protocolError("Receiver key ID mismatch") + } + + let actualFingerprint = derivedKeyID.hex.lowercased() + guard actualFingerprint == expectedFingerprint else { + throw NearbyError.protocolError("Receiver fingerprint mismatch") + } + + let pinnedCertificateData = TrustStore.shared.findTrustedKey(for: secretID) + if pinnedCertificateData == nil, !receiverAuthenticationPolicy.allowsReceiverTrustBootstrap { + throw NearbyError.protocolError("Receiver certificate is not trusted") + } + + if let pinnedCertificateData, pinnedCertificateData != certificateData { + throw NearbyError.protocolError("Receiver certificate changed") + } + + let pairedKeyEncryption = frame.v1.pairedKeyEncryption + guard pairedKeyEncryption.secretIDHash == secretID else { + throw NearbyError.protocolError("Receiver sent unexpected key ID") + } + + guard let authKeyData = authKey?.data() else { + throw NearbyError.protocolError("Missing auth key for receiver verification") + } + + let domain = Domain.instance(curve: .EC256r1) + let ecKey = peerGenericKey.ecP256PublicKey + let point = Point( + BInt(magnitude: [UInt8](ecKey.x.ensureLength(length: 32))), + BInt(magnitude: [UInt8](ecKey.y.ensureLength(length: 32))) + ) + + guard let peerPublicKey = try? ECPublicKey(domain: domain, w: point) else { + throw NearbyError.protocolError("Invalid receiver public key") + } + + let signature = try ECSignature(asn1: .build(pairedKeyEncryption.signedData), domain: domain) + guard peerPublicKey.verify(signature: signature, msg: authKeyData) else { + throw NearbyError.protocolError("Receiver signature verification failed") + } + + if pinnedCertificateData == nil, receiverAuthenticationPolicy.allowsReceiverTrustBootstrap { + guard let device = remoteDeviceInfo else { + throw NearbyError.protocolError("Missing remote device info for receiver trust") + } + delegate?.receiverAuthenticationPending( + connection: self, + certificateData: certificateData, + device: device, + fingerprint: actualFingerprint + ) + } + + receiverAuthenticationVerified = true + } + private func hasURL() -> Bool { urlsToSend.count == 1 && !urlsToSend[0].isFileURL } @@ -593,3 +688,32 @@ class OutboundNearbyConnection: NearbyConnection { var currentOffset: Int64 } } + +private extension ReceiverAuthenticationPolicy { + var expectedReceiverKeyFingerprint: String? { + switch self { + case .none: + return nil + case .trustedReceiver(let fingerprint), .bootstrapReceiverTrust(let fingerprint): + return Self.normalizedFingerprint(fingerprint) + } + } + + var allowsReceiverTrustBootstrap: Bool { + if case .bootstrapReceiverTrust = self { + return true + } + return false + } + + var requiresReceiverAuthentication: Bool { + if case .none = self { + return false + } + return true + } + + static func normalizedFingerprint(_ fingerprint: String) -> String { + fingerprint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} diff --git a/NearbyShare/Model/DataModel.swift b/NearbyShare/Model/DataModel.swift index 48a25ff..1cda349 100644 --- a/NearbyShare/Model/DataModel.swift +++ b/NearbyShare/Model/DataModel.swift @@ -29,6 +29,19 @@ protocol OutboundNearbyConnectionDelegate { func transferAccepted(connection: OutboundNearbyConnection) func failedWithError(connection: OutboundNearbyConnection, error: Error) func transferFinished(connection: OutboundNearbyConnection) + func receiverAuthenticationPending( + connection: OutboundNearbyConnection, + certificateData: Data, + device: RemoteDeviceInfo, + fingerprint: String + ) +} + + +public enum ReceiverAuthenticationPolicy: Equatable { + case none + case trustedReceiver(fingerprint: String) + case bootstrapReceiverTrust(fingerprint: String) } @@ -64,21 +77,45 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable { public let qrCodeData: Data? public let isQuickDropPeer: Bool public var id: String? + public let keyFingerprint: String? + public let supportsNotificationSync: Bool + public let supportsClipboardReceive: Bool - init(name: String?, type: DeviceType?, id: String? = nil, isQuickDropPeer: Bool = false) { + init( + name: String?, + type: DeviceType?, + id: String? = nil, + isQuickDropPeer: Bool = false, + keyFingerprint: String? = nil, + supportsNotificationSync: Bool = false, + supportsClipboardReceive: Bool = false + ) { self.name = name self.type = type ?? .phone self.id = id self.qrCodeData = nil self.isQuickDropPeer = isQuickDropPeer + self.keyFingerprint = Self.normalizedKeyFingerprint(keyFingerprint) + self.supportsNotificationSync = supportsNotificationSync + self.supportsClipboardReceive = supportsClipboardReceive } - init(info: EndpointInfo, id: String? = nil, isQuickDropPeer: Bool = false) { + init( + info: EndpointInfo, + id: String? = nil, + isQuickDropPeer: Bool = false, + keyFingerprint: String? = nil, + supportsNotificationSync: Bool = false, + supportsClipboardReceive: Bool = false + ) { self.name = info.name self.type = info.deviceType self.qrCodeData = info.qrCodeData self.id = id self.isQuickDropPeer = isQuickDropPeer + self.keyFingerprint = Self.normalizedKeyFingerprint(keyFingerprint) + self.supportsNotificationSync = supportsNotificationSync + self.supportsClipboardReceive = supportsClipboardReceive } private enum CodingKeys: String, CodingKey { @@ -88,6 +125,9 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable { case isQuickDropPeer case isVerifiedQuickDropPeer case id + case keyFingerprint + case supportsNotificationSync + case supportsClipboardReceive } public init(from decoder: Decoder) throws { @@ -99,6 +139,11 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable { ?? container.decodeIfPresent(Bool.self, forKey: .isVerifiedQuickDropPeer) ?? false id = try container.decodeIfPresent(String.self, forKey: .id) + keyFingerprint = Self.normalizedKeyFingerprint( + try container.decodeIfPresent(String.self, forKey: .keyFingerprint) + ) + supportsNotificationSync = try container.decodeIfPresent(Bool.self, forKey: .supportsNotificationSync) ?? false + supportsClipboardReceive = try container.decodeIfPresent(Bool.self, forKey: .supportsClipboardReceive) ?? false } public func encode(to encoder: Encoder) throws { @@ -108,6 +153,9 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable { try container.encodeIfPresent(qrCodeData, forKey: .qrCodeData) try container.encode(isQuickDropPeer, forKey: .isQuickDropPeer) try container.encodeIfPresent(id, forKey: .id) + try container.encodeIfPresent(keyFingerprint, forKey: .keyFingerprint) + try container.encode(supportsNotificationSync, forKey: .supportsNotificationSync) + try container.encode(supportsClipboardReceive, forKey: .supportsClipboardReceive) } @@ -152,6 +200,13 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable { } return "iphone" } + + private static func normalizedKeyFingerprint(_ keyFingerprint: String?) -> String? { + let normalized = keyFingerprint? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return normalized?.isEmpty == false ? normalized : nil + } } diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index f105f2c..a2152a0 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ 699DEBAE2AB0573200115D22 /* QuickDropShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 699DEBA12AB0573200115D22 /* QuickDropShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 69DA9A1229E0BF5100A442DA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1129E0BF5100A442DA /* AppDelegate.swift */; }; 69DA9A1429E0BF5200A442DA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69DA9A1329E0BF5200A442DA /* Assets.xcassets */; }; + A1C0DE112F4C000100ABC001 /* ClipboardSyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE102F4C000100ABC001 /* ClipboardSyncCoordinator.swift */; }; + A1C0DE122F4C000100ABC001 /* ClipboardSyncCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE102F4C000100ABC001 /* ClipboardSyncCoordinator.swift */; }; A1B2C3D42F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; A1B2C3D52F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; A1B2C3D62F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; @@ -508,6 +510,7 @@ 69DA9A2B29E18CB500A442DA /* offline_wire_formats.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = offline_wire_formats.pb.swift; sourceTree = ""; }; 69DA9A2C29E18CB500A442DA /* securegcm.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = securegcm.pb.swift; sourceTree = ""; }; 69DA9A2D29E18CB500A442DA /* securemessage.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = securemessage.pb.swift; sourceTree = ""; }; + A1C0DE102F4C000100ABC001 /* ClipboardSyncCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardSyncCoordinator.swift; sourceTree = ""; }; A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirroredNotificationSupport.swift; sourceTree = ""; }; A1F5B3D82F3A1A0100A1B2C3 /* NotificationSyncPairingToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSyncPairingToken.swift; sourceTree = ""; }; A1F5B3D92F3A1A0100A1B2C4 /* NotificationSyncQrSheetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSyncQrSheetManager.swift; sourceTree = ""; }; @@ -930,6 +933,7 @@ EBC05C6B2E33B57100EECC18 /* Util */ = { isa = PBXGroup; children = ( + A1C0DE102F4C000100ABC001 /* ClipboardSyncCoordinator.swift */, EB1325F72E70287C00FD749C /* SendToExtension.swift */, EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */, EB886E662E4F2F7C0052B7D9 /* License.swift */, @@ -1387,6 +1391,7 @@ EBC05C842E33B58300EECC18 /* IssueView.swift in Sources */, EBC05C852E33B58300EECC18 /* NetworkFilterIssueView.swift in Sources */, EB7A95F42EF6E1BE0021111B /* IntroductionView.swift in Sources */, + A1C0DE112F4C000100ABC001 /* ClipboardSyncCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1445,6 +1450,7 @@ EB3B9F2D2EF7150400D6704E /* IssueView.swift in Sources */, EB3B9F2E2EF7150400D6704E /* NetworkFilterIssueView.swift in Sources */, EB3B9F2F2EF7150400D6704E /* IntroductionView.swift in Sources */, + A1C0DE122F4C000100ABC001 /* ClipboardSyncCoordinator.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/QuickDrop/AppDelegate.swift b/QuickDrop/AppDelegate.swift index 690cae2..662d56b 100644 --- a/QuickDrop/AppDelegate.swift +++ b/QuickDrop/AppDelegate.swift @@ -131,6 +131,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele func startReceiving() { guard receiveModel == nil else { log("[AppDelegate] startReceiving called while receiver is already active. Ignoring duplicate call.") + ClipboardSyncCoordinator.shared.start() return } @@ -146,6 +147,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele } } }) + + ClipboardSyncCoordinator.shared.start() } @@ -174,6 +177,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele func applicationWillTerminate(_: Notification) { UNUserNotificationCenter.current().removeAllDeliveredNotifications() + ClipboardSyncCoordinator.shared.stop() NearbyConnectionManager.shared.stopDeviceDiscovery() NearbyConnectionManager.shared.becomeInvisible() } diff --git a/QuickDrop/Localizable.xcstrings b/QuickDrop/Localizable.xcstrings index 78ef519..1edfa36 100644 --- a/QuickDrop/Localizable.xcstrings +++ b/QuickDrop/Localizable.xcstrings @@ -35624,6 +35624,239 @@ } } }, + "TargetDeviceNoLongerNearby" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "الجهاز المستهدف لم يعد بالقرب." + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "El dispositiu de destinació ja no és a prop." + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cílové zařízení už není v dosahu." + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målenheden er ikke længere i nærheden." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Zielgerät ist nicht mehr in der Nähe." + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Η συσκευή προορισμού δεν είναι πια κοντά." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The target device is no longer nearby." + } + }, + "en-AU" : { + "stringUnit" : { + "state" : "translated", + "value" : "The target device is no longer nearby." + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "The target device is no longer nearby." + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "The target device is no longer nearby." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El dispositivo de destino ya no está cerca." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "El dispositivo de destino ya no está cerca." + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kohdelaite ei ole enää lähistöllä." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’appareil cible n’est plus à proximité." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "L’appareil cible n’est plus à proximité." + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "המכשיר היעד כבר לא נמצא בקרבת מקום." + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लक्ष्य डिवाइस अब पास में नहीं है।" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciljni uređaj više nije u blizini." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A céleszköz már nincs a közelben." + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Perangkat tujuan sudah tidak terdekat lagi." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il dispositivo di destinazione non è più nelle vicinanze." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ターゲットデバイスが近くにありません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "대상 기기가 더 이상 근처에 없습니다." + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Peranti sasaran tidak lagi berdekatan." + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målenheten er ikke lenger i nærheten." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Het doeldapparaat is niet meer in de buurt." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Docelowe urządzenie nie jest już w pobliżu." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "O dispositivo de destino não está mais próximo." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O dispositivo de destino já não está por perto." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dispozitivul țintă nu mai este în apropiere." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Целевое устройство больше не находится рядом." + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Målenheten är inte längre i närheten." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "อุปกรณ์เป้าหมายไม่ได้อยู่ใกล้แล้ว" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hedef cihaz artık yakında değil." + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Цільовий пристрій більше не поруч." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiết bị đích không còn ở gần nữa." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标设备已不在附近。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "目標裝置已不在附近。" + } + } + } + }, "Text" : { "extractionState" : "manual", "localizations" : { diff --git a/QuickDrop/Util/ClipboardSyncCoordinator.swift b/QuickDrop/Util/ClipboardSyncCoordinator.swift new file mode 100644 index 0000000..f96ca25 --- /dev/null +++ b/QuickDrop/Util/ClipboardSyncCoordinator.swift @@ -0,0 +1,264 @@ +// +// ClipboardSyncCoordinator.swift +// QuickDrop +// + +#if os(macOS) && !EXTENSION + +import AppKit +import Foundation +import LUI + +final class ClipboardSyncCoordinator { + static let shared = ClipboardSyncCoordinator() + + private let pollInterval: TimeInterval = 1 + private let transferTimeout: TimeInterval = 75 + + private var pollTimer: Timer? + private var lastObservedChangeCount: Int + private var suppressedClipboardText: String? + private var startedOwnDiscovery = false + private var activeTransfersByFingerprint: [String: ClipboardSyncTransferDelegate] = [:] + private var queuedTextByFingerprint: [String: String] = [:] + + private init() { + lastObservedChangeCount = NSPasteboard.general.changeCount + } + + func start() { + guard pollTimer == nil else { return } + + let manager = NearbyConnectionManager.shared + if !manager.isDeviceDiscoveryActive { + startedOwnDiscovery = true + manager.startDeviceDiscovery() + } + + lastObservedChangeCount = NSPasteboard.general.changeCount + log("[ClipboardSyncCoordinator] Starting clipboard watcher at changeCount=\(lastObservedChangeCount)") + pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + self?.pollClipboard() + } + } + + func stop() { + guard pollTimer != nil else { return } + + log("[ClipboardSyncCoordinator] Stopping clipboard watcher") + pollTimer?.invalidate() + pollTimer = nil + suppressedClipboardText = nil + activeTransfersByFingerprint.values.forEach { $0.cancelTimeout() } + activeTransfersByFingerprint.removeAll() + queuedTextByFingerprint.removeAll() + + guard startedOwnDiscovery else { return } + startedOwnDiscovery = false + NearbyConnectionManager.shared.stopDeviceDiscovery() + } + + func suppressNextClipboardSync(for text: String) { + if Thread.isMainThread { + setSuppressedClipboardText(text) + } else { + DispatchQueue.main.sync { + setSuppressedClipboardText(text) + } + } + } + + private func setSuppressedClipboardText(_ text: String) { + log("[ClipboardSyncCoordinator] Suppressing next clipboard sync for incoming text with \(text.utf8.count) bytes") + suppressedClipboardText = text + } + + private func pollClipboard() { + let pasteboard = NSPasteboard.general + let currentChangeCount = pasteboard.changeCount + guard currentChangeCount != lastObservedChangeCount else { return } + + log("[ClipboardSyncCoordinator] Clipboard change detected: \(lastObservedChangeCount) -> \(currentChangeCount)") + lastObservedChangeCount = currentChangeCount + + guard let clipboardText = pasteboard.string(forType: .string) else { + log("[ClipboardSyncCoordinator] Clipboard change ignored because no plain text content is available") + return + } + guard !clipboardText.isEmpty else { + log("[ClipboardSyncCoordinator] Clipboard change ignored because text content is empty") + return + } + + if suppressedClipboardText == clipboardText { + log("[ClipboardSyncCoordinator] Ignoring clipboard change because it matches an incoming clipboard transfer") + suppressedClipboardText = nil + return + } + suppressedClipboardText = nil + + sendClipboardText(clipboardText) + } + + private func sendClipboardText(_ clipboardText: String) { + let targets = nearbyTrustedClipboardTargets() + guard !targets.isEmpty else { + log("[ClipboardSyncCoordinator] Clipboard change will not be sent because no trusted clipboard-capable devices are nearby") + return + } + + log("[ClipboardSyncCoordinator] Sending clipboard contents to \(targets.count) trusted clipboard-capable device(s)") + + for target in targets { + startTransfer(to: target, clipboardText: clipboardText) + } + } + + private func startTransfer(to target: ClipboardSyncTarget, clipboardText: String) { + guard activeTransfersByFingerprint[target.fingerprint] == nil else { + queuedTextByFingerprint[target.fingerprint] = clipboardText + log("[ClipboardSyncCoordinator] Queued latest clipboard text for \(target.logName) because a transfer is already active") + return + } + + let delegate = ClipboardSyncTransferDelegate(target: target) { [weak self] finishedTarget, finishedDelegate in + DispatchQueue.main.async { + self?.handleTransferCompleted(for: finishedTarget, delegate: finishedDelegate) + } + } + activeTransfersByFingerprint[target.fingerprint] = delegate + delegate.startTimeout(after: transferTimeout) + + NearbyConnectionManager.shared.startOutgoingTransfer( + deviceID: target.deviceID, + delegate: delegate, + urls: [], + textToSend: clipboardText, + receiverAuthenticationPolicy: .trustedReceiver(fingerprint: target.fingerprint) + ) + } + + private func handleTransferCompleted(for target: ClipboardSyncTarget, delegate: ClipboardSyncTransferDelegate) { + guard activeTransfersByFingerprint[target.fingerprint] === delegate else { + log("[ClipboardSyncCoordinator] Ignoring stale clipboard sync completion for \(target.logName)") + return + } + + activeTransfersByFingerprint.removeValue(forKey: target.fingerprint) + + guard let queuedText = queuedTextByFingerprint.removeValue(forKey: target.fingerprint) else { + return + } + + guard let refreshedTarget = nearbyTrustedClipboardTargets() + .first(where: { $0.fingerprint == target.fingerprint }) else { + log("[ClipboardSyncCoordinator] Dropping queued clipboard text for \(target.logName) because the device is no longer an eligible target") + return + } + + log("[ClipboardSyncCoordinator] Sending queued latest clipboard text to \(refreshedTarget.logName)") + startTransfer(to: refreshedTarget, clipboardText: queuedText) + } + + private func nearbyTrustedClipboardTargets() -> [ClipboardSyncTarget] { + let trustedFingerprints = Set( + TrustStore.shared.trustedCertificates.keys.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + ) + var seenFingerprints = Set() + + return NearbyConnectionManager.shared.discoveredDevices() + .compactMap { device -> ClipboardSyncTarget? in + guard let deviceID = device.id, + !deviceID.isEmpty, + let fingerprint = device.keyFingerprint, + trustedFingerprints.contains(fingerprint), + device.supportsClipboardReceive, + device.type != .computer, + seenFingerprints.insert(fingerprint).inserted else { + return nil + } + + return ClipboardSyncTarget( + device: device, + deviceID: deviceID, + fingerprint: fingerprint + ) + } + .sorted { lhs, rhs in + lhs.logName < rhs.logName + } + } +} + +private struct ClipboardSyncTarget { + let device: RemoteDeviceInfo + let deviceID: String + let fingerprint: String + + var logName: String { + device.name ?? deviceID + } +} + +private final class ClipboardSyncTransferDelegate: OutboundAppDelegate { + private let target: ClipboardSyncTarget + private let onComplete: (ClipboardSyncTarget, ClipboardSyncTransferDelegate) -> Void + private var isCompleted = false + private var timeoutWorkItem: DispatchWorkItem? + + init(target: ClipboardSyncTarget, onComplete: @escaping (ClipboardSyncTarget, ClipboardSyncTransferDelegate) -> Void) { + self.target = target + self.onComplete = onComplete + } + + func startTimeout(after timeout: TimeInterval) { + let timeoutWorkItem = DispatchWorkItem { [weak self] in + guard let self, !self.isCompleted else { return } + + log("[ClipboardSyncCoordinator] Clipboard sync to \(self.target.logName) timed out") + NearbyConnectionManager.shared.cancelTransfer(id: self.target.deviceID) + self.complete() + } + + self.timeoutWorkItem = timeoutWorkItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeout, execute: timeoutWorkItem) + } + + func cancelTimeout() { + timeoutWorkItem?.cancel() + timeoutWorkItem = nil + } + + func addDevice(device _: RemoteDeviceInfo) {} + + func removeDevice(id _: String) {} + + func startTransferWithQrCode(device _: RemoteDeviceInfo) {} + + func connectionWasEstablished(pinCode _: String) {} + + func transferAccepted() {} + + func transferProgress(progress _: Double) {} + + func connectionFailed(error: Error) { + log("[ClipboardSyncCoordinator] Clipboard sync to \(target.logName) failed: \(error.localizedDescription)") + complete() + } + + func transferFinished() { + log("[ClipboardSyncCoordinator] Clipboard sync to \(target.logName) finished") + complete() + } + + private func complete() { + guard !isCompleted else { return } + isCompleted = true + cancelTimeout() + onComplete(target, self) + } +} + +#endif diff --git a/QuickDrop/ViewModel/SendModel.swift b/QuickDrop/ViewModel/SendModel.swift index 5d2218b..7c8c375 100644 --- a/QuickDrop/ViewModel/SendModel.swift +++ b/QuickDrop/ViewModel/SendModel.swift @@ -129,7 +129,13 @@ class SendModel: ObservableObject, OutboundAppDelegate { selectedDevice = device runAfter(seconds: 0.3) { - NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: self.urls, textToSend: self.textToSend) + NearbyConnectionManager.shared.startOutgoingTransfer( + deviceID: device.id!, + delegate: self, + urls: self.urls, + textToSend: self.textToSend, + receiverAuthenticationPolicy: .none + ) self.progressState = "Connecting".localized() } } diff --git a/QuickDropiOS/ShareViewModel.swift b/QuickDropiOS/ShareViewModel.swift index dfd7ecc..23a685d 100644 --- a/QuickDropiOS/ShareViewModel.swift +++ b/QuickDropiOS/ShareViewModel.swift @@ -84,7 +84,13 @@ class SendModel: ObservableObject, OutboundAppDelegate { progressValue = 0 progressState = "Connecting".localized() selectedDevice = device - NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: urls, textToSend: textToSend) + NearbyConnectionManager.shared.startOutgoingTransfer( + deviceID: device.id!, + delegate: self, + urls: urls, + textToSend: textToSend, + receiverAuthenticationPolicy: .none + ) } } } diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 1061cd9..b7729e4 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -294,7 +294,8 @@ class ShareViewController: NSViewController, OutboundAppDelegate { self.timeoutDispatchWorkItem = timeoutAlert DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert) - } + }, + receiverAuthenticationPolicy: .none ) } }