From 2f6b0ff5baf0cce89c5685c84b7db92af60c4919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6ttger?= <49094811+leonboe1@users.noreply.github.com> Date: Thu, 14 May 2026 15:49:50 +0200 Subject: [PATCH] Add clipboard sync notification --- .../Connection/InboundNearbyConnection.swift | 22 ++- .../Connection/NearbyConnectionManager.swift | 9 +- NearbyShare/Model/DataModel.swift | 6 +- QuickDrop.xcodeproj/project.pbxproj | 6 + QuickDrop/AppDelegate.swift | 6 +- .../ClipboardSyncNotificationPresenter.swift | 125 ++++++++++++++++++ QuickDrop/ViewModel/ReceiveModel.swift | 10 +- 7 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 QuickDrop/Util/ClipboardSyncNotificationPresenter.swift diff --git a/NearbyShare/Connection/InboundNearbyConnection.swift b/NearbyShare/Connection/InboundNearbyConnection.swift index 6d7732e..e185744 100644 --- a/NearbyShare/Connection/InboundNearbyConnection.swift +++ b/NearbyShare/Connection/InboundNearbyConnection.swift @@ -30,11 +30,13 @@ class InboundNearbyConnection: NearbyConnection { private var textPayloadID: Int64 = 0 private var bytesToBeTransferred: Int64 = 0 private var isAuthenticated = false + private var receiverAuthenticationAccepted = false private var peerCertificate: Sharing_Nearby_PublicCertificate? = nil private var mirroredNotificationMetadata: Sharing_Nearby_MirroredNotificationMetadata? private var pairingMetadata: Sharing_Nearby_PairingMetadata? private var isPairingSetupRequest = false var isPlainTextTransfer = false + var isClipboardSyncTransfer = false var isControlTransfer = false var wasUserRejected = false @@ -287,6 +289,14 @@ class InboundNearbyConnection: NearbyConnection { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(urlStr, forType: .string) + #if !EXTENSION + if isClipboardSyncTransfer { + ClipboardSyncNotificationPresenter.shared.present( + clipboardText: urlStr, + senderDeviceName: remoteDeviceInfo?.name + ) + } + #endif #elseif os(iOS) // iOS clipboard UIPasteboard.general.string = urlStr @@ -570,6 +580,7 @@ class InboundNearbyConnection: NearbyConnection { private func processPairedKeyResultFrame(_ frame: Sharing_Nearby_Frame) throws { guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyResult") } + receiverAuthenticationAccepted = frame.v1.pairedKeyResult.status == .success currentState = .receivedPairedKeyResult } @@ -727,8 +738,17 @@ class InboundNearbyConnection: NearbyConnection { if let textMetadata = frame.v1.introduction.textMetadata.first { let isURL = textMetadata.type == .url textPayloadID = textMetadata.payloadID + isClipboardSyncTransfer = !isURL && isAuthenticated && receiverAuthenticationAccepted - let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isURL ? .url : .text, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil) + let metadata = TransferMetadata( + files: [], + id: id, + pinCode: pinCode, + textDescription: textMetadata.textTitle, + transferType: isURL ? .url : .text, + allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil, + isClipboardSync: isClipboardSyncTransfer + ) if !isURL { isPlainTextTransfer = true diff --git a/NearbyShare/Connection/NearbyConnectionManager.swift b/NearbyShare/Connection/NearbyConnectionManager.swift index b4b84f7..7ba3a2b 100644 --- a/NearbyShare/Connection/NearbyConnectionManager.swift +++ b/NearbyShare/Connection/NearbyConnectionManager.swift @@ -379,7 +379,14 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb if !connection.wasUserRejected { inboundAppDelegates.forEach { delegate in - delegate.connectionWasTerminated(connectionID: connection.id, from: connection.remoteDeviceInfo, savedFiles: savedFiles, wasPlainTextTransfer: connection.isPlainTextTransfer, error: error) + delegate.connectionWasTerminated( + connectionID: connection.id, + from: connection.remoteDeviceInfo, + savedFiles: savedFiles, + wasPlainTextTransfer: connection.isPlainTextTransfer, + wasClipboardSync: connection.isClipboardSyncTransfer, + error: error + ) } } } diff --git a/NearbyShare/Model/DataModel.swift b/NearbyShare/Model/DataModel.swift index 1cda349..5bdfdc8 100644 --- a/NearbyShare/Model/DataModel.swift +++ b/NearbyShare/Model/DataModel.swift @@ -48,7 +48,7 @@ public enum ReceiverAuthenticationPolicy: Equatable { public protocol InboundAppDelegate: AnyObject { func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo) func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) - func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: Error?) + func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, wasClipboardSync: Bool, error: Error?) func transferProgress(connectionID: String, progress: Double) func showPlusScreen() func notificationSyncSetupConfirmed() @@ -217,14 +217,16 @@ public struct TransferMetadata { public let textDescription: String? public let type: TransferType public let allowsToBeAddedAsTrustedDevice: Bool + public let isClipboardSync: Bool - init(files: [FileMetadata], id: String, pinCode: String?, textDescription: String? = nil, transferType: TransferType = .file, allowsToBeAddedAsTrustedDevice: Bool) { + init(files: [FileMetadata], id: String, pinCode: String?, textDescription: String? = nil, transferType: TransferType = .file, allowsToBeAddedAsTrustedDevice: Bool, isClipboardSync: Bool = false) { self.files = files self.id = id self.pinCode = pinCode self.textDescription = textDescription self.type = transferType self.allowsToBeAddedAsTrustedDevice = allowsToBeAddedAsTrustedDevice + self.isClipboardSync = isClipboardSync } public enum TransferType { diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index a2152a0..681b086 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -16,6 +16,8 @@ 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 */; }; + A1C0DE132F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE152F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift */; }; + A1C0DE142F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C0DE152F4C000100ABC001 /* ClipboardSyncNotificationPresenter.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 */; }; @@ -511,6 +513,7 @@ 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 = ""; }; + A1C0DE152F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardSyncNotificationPresenter.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 = ""; }; @@ -934,6 +937,7 @@ isa = PBXGroup; children = ( A1C0DE102F4C000100ABC001 /* ClipboardSyncCoordinator.swift */, + A1C0DE152F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift */, EB1325F72E70287C00FD749C /* SendToExtension.swift */, EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */, EB886E662E4F2F7C0052B7D9 /* License.swift */, @@ -1366,6 +1370,7 @@ EB5C37CD2E377A7F0052EFD7 /* OutboundNearbyConnection.swift in Sources */, EB5C37CE2E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */, A1B2C3D52F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, + A1C0DE132F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift in Sources */, EB5C37CF2E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */, EB5C37CF2E377A7F0052EFD8 /* NearbyDeviceNameResolver.swift in Sources */, EBE5FF422EDB6F6F0019B380 /* Configuration.swift in Sources */, @@ -1425,6 +1430,7 @@ EB3B9F162EF7150400D6704E /* OutboundNearbyConnection.swift in Sources */, EB3B9F172EF7150400D6704E /* InboundNearbyConnection.swift in Sources */, A1B2C3D62F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, + A1C0DE142F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift in Sources */, EB3B9F182EF7150400D6704E /* NearbyConnectionManager.swift in Sources */, EB3B9F192EF7150400D6704F /* NearbyDeviceNameResolver.swift in Sources */, EB3B9F192EF7150400D6704E /* Configuration.swift in Sources */, diff --git a/QuickDrop/AppDelegate.swift b/QuickDrop/AppDelegate.swift index 662d56b..191e19e 100644 --- a/QuickDrop/AppDelegate.swift +++ b/QuickDrop/AppDelegate.swift @@ -169,8 +169,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele public func userNotificationCenter(_: UNUserNotificationCenter, - willPresent _: UNNotification, + willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + if notification.request.identifier == ClipboardSyncNotificationPresenter.notificationIdentifier { + completionHandler([.banner]) + return + } completionHandler([.sound, .banner]) } diff --git a/QuickDrop/Util/ClipboardSyncNotificationPresenter.swift b/QuickDrop/Util/ClipboardSyncNotificationPresenter.swift new file mode 100644 index 0000000..ad48906 --- /dev/null +++ b/QuickDrop/Util/ClipboardSyncNotificationPresenter.swift @@ -0,0 +1,125 @@ +// +// ClipboardSyncNotificationPresenter.swift +// QuickDrop +// + +#if os(macOS) && !EXTENSION + +import Foundation +import UserNotifications +import LUI + +final class ClipboardSyncNotificationPresenter { + + static let shared = ClipboardSyncNotificationPresenter() + static let notificationIdentifier = "quickdrop-clipboard-sync" + + private let center = UNUserNotificationCenter.current() + private let queue = DispatchQueue(label: "ClipboardSyncNotificationPresenter") + private var pendingNotifications: [(String, String?)] = [] + private var authorizationRequestInFlight = false + + private init() {} + + func present(clipboardText: String, senderDeviceName: String?) { + queue.async { + self.postNotificationIfAuthorized( + clipboardText: clipboardText, + senderDeviceName: senderDeviceName + ) + } + } + + private func postNotificationIfAuthorized(clipboardText: String, senderDeviceName: String?) { + center.getNotificationSettings { settings in + self.queue.async { + self.handleAuthorizationStatus( + settings.authorizationStatus, + clipboardText: clipboardText, + senderDeviceName: senderDeviceName + ) + } + } + } + + private func handleAuthorizationStatus( + _ status: UNAuthorizationStatus, + clipboardText: String, + senderDeviceName: String? + ) { + switch status { + case .authorized, .provisional, .ephemeral: + post(clipboardText: clipboardText, senderDeviceName: senderDeviceName) + case .notDetermined: + pendingNotifications.append((clipboardText, senderDeviceName)) + requestAuthorizationIfNeeded() + case .denied: + log("[ClipboardSyncNotificationPresenter] Notifications are denied in system settings; cannot show clipboard sync notification.") + @unknown default: + log("[ClipboardSyncNotificationPresenter] Unknown notification authorization status; skipping clipboard sync notification.") + } + } + + private func requestAuthorizationIfNeeded() { + if authorizationRequestInFlight { + return + } + + authorizationRequestInFlight = true + + DispatchQueue.main.async { + self.center.requestAuthorization(options: [.alert, .badge]) { granted, error in + self.queue.async { + self.authorizationRequestInFlight = false + + if let error = error { + log("[ClipboardSyncNotificationPresenter] Authorization request failed: \(error.localizedDescription)") + } + + guard granted else { + log("[ClipboardSyncNotificationPresenter] Authorization request not granted; dropping queued clipboard sync notifications.") + self.pendingNotifications.removeAll() + return + } + + let queued = self.pendingNotifications + self.pendingNotifications.removeAll() + for (queuedText, queuedSender) in queued { + self.post(clipboardText: queuedText, senderDeviceName: queuedSender) + } + } + } + } + } + + private func post(clipboardText: String, senderDeviceName: String?) { + let senderName = senderDeviceName?.trimmingCharacters(in: .whitespacesAndNewlines) + let content = UNMutableNotificationContent() + content.title = "CopiedToClipboard".localized() + content.body = preview(for: clipboardText) + content.subtitle = senderName?.isEmpty == false ? senderName! : "QuickDrop" + content.threadIdentifier = Self.notificationIdentifier + content.interruptionLevel = .active + + let identifier = Self.notificationIdentifier + center.removePendingNotificationRequests(withIdentifiers: [identifier]) + center.removeDeliveredNotifications(withIdentifiers: [identifier]) + + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + center.add(request) { error in + if let error = error { + log("[ClipboardSyncNotificationPresenter] Could not post notification: \(error.localizedDescription)") + } else { + log("[ClipboardSyncNotificationPresenter] Posted clipboard sync notification (\(identifier)).") + } + } + } + + private func preview(for clipboardText: String) -> String { + clipboardText + .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +#endif diff --git a/QuickDrop/ViewModel/ReceiveModel.swift b/QuickDrop/ViewModel/ReceiveModel.swift index dc12a74..d56df9c 100644 --- a/QuickDrop/ViewModel/ReceiveModel.swift +++ b/QuickDrop/ViewModel/ReceiveModel.swift @@ -185,6 +185,11 @@ class ReceiveModel: ObservableObject, InboundAppDelegate { func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) { + #if os(macOS) + if transfer.isClipboardSync { + return + } + #endif let mainMessage = transfer.getDescription(deviceName: device.name ?? "AndroidDevice".localized()) @@ -244,7 +249,7 @@ class ReceiveModel: ObservableObject, InboundAppDelegate { } - func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: (any Error)?) { + func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, wasClipboardSync: Bool, error: (any Error)?) { processes.removeValue(forKey: connectionID) @@ -388,6 +393,9 @@ class ReceiveModel: ObservableObject, InboundAppDelegate { if wasPendingConsent { return } + if wasClipboardSync && error == nil { + return + } if let error = error { if let name = device?.name {