mirror of
https://github.com/leonboe1/QuickDrop.git
synced 2026-11-11 02:51:11 +00:00
Add Clipboard Listener
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,8 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
|
||||
|
||||
self.timeoutDispatchWorkItem = timeoutAlert
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert)
|
||||
}
|
||||
},
|
||||
receiverAuthenticationPolicy: .none
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user