Add Clipboard Listener

This commit is contained in:
Leon Böttger committed 2026-05-14 14:44:17 +02:00
1 parent ee091f60cd
commit 5fdc5f8a1b
11 files changed
+879 -23

No files matched your search

@@ -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
@@ -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
}
}
@@ -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()
}
}
+57 -2
View File
@@ -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
}
}
+6
View File
@@ -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 = "<group>"; };
69DA9A2C29E18CB500A442DA /* securegcm.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = securegcm.pb.swift; sourceTree = "<group>"; };
69DA9A2D29E18CB500A442DA /* securemessage.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = securemessage.pb.swift; sourceTree = "<group>"; };
A1C0DE102F4C000100ABC001 /* ClipboardSyncCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardSyncCoordinator.swift; sourceTree = "<group>"; };
A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirroredNotificationSupport.swift; sourceTree = "<group>"; };
A1F5B3D82F3A1A0100A1B2C3 /* NotificationSyncPairingToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSyncPairingToken.swift; sourceTree = "<group>"; };
A1F5B3D92F3A1A0100A1B2C4 /* NotificationSyncQrSheetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSyncQrSheetManager.swift; sourceTree = "<group>"; };
@@ -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;
};
+4
View File
@@ -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()
}
+233
View File
@@ -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" : {
@@ -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<String>()
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
+7 -1
View File
@@ -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()
}
}
+7 -1
View File
@@ -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
)
}
}
}
+2 -1
View File
@@ -294,7 +294,8 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
self.timeoutDispatchWorkItem = timeoutAlert
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert)
}
},
receiverAuthenticationPolicy: .none
)
}
}