Add clipboard sync notification

This commit is contained in:
Leon Böttger committed 2026-05-14 15:49:50 +02:00
1 parent 5fdc5f8a1b
commit 2f6b0ff5ba
7 files changed
+178 -6

No files matched your search

@@ -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
@@ -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
)
}
}
}
+4 -2
View File
@@ -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 {
+6
View File
@@ -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 = "<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>"; };
A1C0DE152F4C000100ABC001 /* ClipboardSyncNotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardSyncNotificationPresenter.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>"; };
@@ -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 */,
+5 -1
View File
@@ -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])
}
@@ -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
+9 -1
View File
@@ -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 {