From 48eb5be4a5526a095fae9a973b9c99dec655fef9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6ttger?= Date: Sun, 8 Mar 2026 13:49:07 +0100 Subject: [PATCH] Add notification sync support --- .../Connection/InboundNearbyConnection.swift | 53 +- .../MirroredNotificationSupport.swift | 183 +++++ .../Connection/NearbyConnectionManager.swift | 33 +- .../Connection/OutboundNearbyConnection.swift | 9 + NearbyShare/Model/DataModel.swift | 7 + QuickDrop.xcodeproj/project.pbxproj | 14 + QuickDrop/Localizable.xcstrings | 699 ++++++++++++++++++ QuickDrop/ViewModel/ReceiveModel.swift | 25 + 8 files changed, 1014 insertions(+), 9 deletions(-) create mode 100644 NearbyShare/Connection/MirroredNotificationSupport.swift diff --git a/NearbyShare/Connection/InboundNearbyConnection.swift b/NearbyShare/Connection/InboundNearbyConnection.swift index 0a8ba3f..332343b 100644 --- a/NearbyShare/Connection/InboundNearbyConnection.swift +++ b/NearbyShare/Connection/InboundNearbyConnection.swift @@ -31,8 +31,10 @@ class InboundNearbyConnection: NearbyConnection { private var isAuthenticated = false private var peerCertificate: Sharing_Nearby_PublicCertificate? = nil var isPlainTextTransfer = false + private var isMirroredNotificationTransfer = false var wasUserRejected = false + var shouldSuppressAppDelegates = false var delegate: InboundNearbyConnectionDelegate? enum State { @@ -187,6 +189,20 @@ class InboundNearbyConnection: NearbyConnection { override func processBytesPayload(payload: Data, id: Int64) throws -> Bool { if id == textPayloadID { + #if os(macOS) + if isMirroredNotificationTransfer { + if let payloadString = String(data: payload, encoding: .utf8) { + log("[InboundNearbyConnection \(self.id)] Received mirrored notification payload (\(payload.count) bytes)") + MirroredNotificationPresenter.shared.present(rawPayload: payloadString, senderDeviceName: remoteDeviceInfo?.name) + } else { + log("[InboundNearbyConnection \(self.id)] Failed to decode mirrored notification payload as UTF-8") + } + + try sendDisconnectionAndDisconnect() + return true + } + #endif + if let urlStr = String(data: payload, encoding: .utf8) { if isPlainTextTransfer { @@ -507,14 +523,43 @@ class InboundNearbyConnection: NearbyConnection { } else if let textMetadata = frame.v1.introduction.textMetadata.first { let isURL = textMetadata.type == .url - - let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isURL ? .url : .text, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil) textPayloadID = textMetadata.payloadID - + + #if os(macOS) + if textMetadata.type == .text && textMetadata.textTitle == MirroredNotificationProtocol.introductionTitle { + isMirroredNotificationTransfer = true + shouldSuppressAppDelegates = true + + if !isAuthenticated { + guard let device = self.remoteDeviceInfo else { + self.rejectTransfer(with: .reject) + return + } + let metadata = TransferMetadata( + files: [], + id: id, + pinCode: pinCode, + transferType: .notificationSync, + allowsToBeAddedAsTrustedDevice: false + ) + + DispatchQueue.main.async { + self.delegate?.obtainUserConsent(transfer: metadata, device: device, connection: self) + } + return + } + + submitUserConsent(accepted: true, trustDevice: false) + return + } + #endif + + let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isURL ? .url : .text, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil) + if !isURL { isPlainTextTransfer = true } - + checkIfCanProceed(metadata: metadata) } else if let wifiMetadata = frame.v1.introduction.wifiCredentialsMetadata.first { diff --git a/NearbyShare/Connection/MirroredNotificationSupport.swift b/NearbyShare/Connection/MirroredNotificationSupport.swift new file mode 100644 index 0000000..e12c4a7 --- /dev/null +++ b/NearbyShare/Connection/MirroredNotificationSupport.swift @@ -0,0 +1,183 @@ +// +// MirroredNotificationSupport.swift +// QuickDrop +// + +import Foundation +import CryptoKit +import UserNotifications +import LUI + +enum MirroredNotificationProtocol { + static let introductionTitle = "__quickdrop_mirror_notification_v1__" +} + +final class MirroredNotificationPresenter { + + static let shared = MirroredNotificationPresenter() + + private let center = UNUserNotificationCenter.current() + private let queue = DispatchQueue(label: "MirroredNotificationPresenter") + private var pendingNotifications: [(MirroredNotificationEnvelope, String?)] = [] + private var authorizationRequestInFlight = false + + private init() {} + + func present(rawPayload: String, senderDeviceName: String?) { + guard let envelope = decodeEnvelope(from: rawPayload) else { + log("[MirroredNotificationPresenter] Failed to decode mirrored notification payload") + return + } + + queue.async { + self.postNotificationIfAuthorized(envelope: envelope, senderDeviceName: senderDeviceName) + } + } + + private func decodeEnvelope(from rawPayload: String) -> MirroredNotificationEnvelope? { + guard let data = rawPayload.data(using: .utf8) else { + return nil + } + do { + return try JSONDecoder().decode(MirroredNotificationEnvelope.self, from: data) + } catch { + log("[MirroredNotificationPresenter] Failed to decode envelope JSON: \(error.localizedDescription)") + return nil + } + } + + private func postNotificationIfAuthorized(envelope: MirroredNotificationEnvelope, senderDeviceName: String?) { + center.getNotificationSettings { settings in + self.queue.async { + self.handleAuthorizationStatus( + settings.authorizationStatus, + envelope: envelope, + senderDeviceName: senderDeviceName + ) + } + } + } + + private func handleAuthorizationStatus( + _ status: UNAuthorizationStatus, + envelope: MirroredNotificationEnvelope, + senderDeviceName: String? + ) { + switch status { + case .authorized, .provisional, .ephemeral: + post(envelope: envelope, senderDeviceName: senderDeviceName) + case .notDetermined: + pendingNotifications.append((envelope, senderDeviceName)) + requestAuthorizationIfNeeded() + case .denied: + log("[MirroredNotificationPresenter] Notifications are denied in system settings; cannot mirror.") + @unknown default: + log("[MirroredNotificationPresenter] Unknown notification authorization status; skipping mirrored notification.") + } + } + + private func requestAuthorizationIfNeeded() { + if authorizationRequestInFlight { + return + } + + authorizationRequestInFlight = true + + DispatchQueue.main.async { + self.center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + self.queue.async { + self.authorizationRequestInFlight = false + + if let error = error { + log("[MirroredNotificationPresenter] Authorization request failed: \(error.localizedDescription)") + } + + guard granted else { + log("[MirroredNotificationPresenter] Authorization request not granted; dropping queued mirrored notifications.") + self.pendingNotifications.removeAll() + return + } + + let queued = self.pendingNotifications + self.pendingNotifications.removeAll() + for (queuedEnvelope, queuedSender) in queued { + self.post(envelope: queuedEnvelope, senderDeviceName: queuedSender) + } + } + } + } + } + + private func post(envelope: MirroredNotificationEnvelope, senderDeviceName: String?) { + let senderName = senderDeviceName?.trimmingCharacters(in: .whitespacesAndNewlines) + let hasSenderName = senderName?.isEmpty == false + let displaySender = hasSenderName ? senderName! : "NotificationSyncFallbackSenderAndroid".localized() + let identifierSender = hasSenderName ? senderName! : "android" + let cleanAppName = envelope.appName.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanTitle = envelope.title.trimmingCharacters(in: .whitespacesAndNewlines) + let cleanBody = envelope.body.trimmingCharacters(in: .whitespacesAndNewlines) + + let content = UNMutableNotificationContent() + content.title = cleanTitle.isEmpty ? (cleanAppName.isEmpty ? "NotificationSyncFallbackTitle".localized() : cleanAppName) : cleanTitle + content.body = cleanBody + content.subtitle = cleanAppName.isEmpty ? displaySender : "\(cleanAppName) · \(displaySender)" + content.sound = .default + content.threadIdentifier = "quickdrop-mirror-\(sanitizeIdentifierComponent(identifierSender))" + + let identifier = makeIdentifier(sender: identifierSender, notificationKey: envelope.notificationKey) + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + + center.add(request) { error in + if let error = error { + log("[MirroredNotificationPresenter] Could not post notification: \(error.localizedDescription)") + } else { + log("[MirroredNotificationPresenter] Posted mirrored notification (\(identifier))") + } + } + } + + private func makeIdentifier(sender: String, notificationKey: String) -> String { + let raw = "\(sender)|\(notificationKey)" + let digest = SHA256.hash(data: Data(raw.utf8)) + let prefix = digest.prefix(12).map { String(format: "%02x", $0) }.joined() + return "quickdrop-mirror-\(prefix)" + } + + private func sanitizeIdentifierComponent(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + let scalarView = value.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" } + let sanitized = String(scalarView) + return sanitized.isEmpty ? "android" : sanitized + } +} + +private struct MirroredNotificationEnvelope: Codable { + let version: Int + let notificationKey: String + let packageName: String + let appName: String + let title: String + let body: String + let postedAtEpochMs: Int64 + + enum CodingKeys: String, CodingKey { + case version + case notificationKey + case packageName + case appName + case title + case body + case postedAtEpochMs + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1 + notificationKey = try container.decode(String.self, forKey: .notificationKey) + packageName = try container.decode(String.self, forKey: .packageName) + appName = try container.decode(String.self, forKey: .appName) + title = try container.decode(String.self, forKey: .title) + body = try container.decode(String.self, forKey: .body) + postedAtEpochMs = try container.decode(Int64.self, forKey: .postedAtEpochMs) + } +} diff --git a/NearbyShare/Connection/NearbyConnectionManager.swift b/NearbyShare/Connection/NearbyConnectionManager.swift index 29cad70..4203623 100644 --- a/NearbyShare/Connection/NearbyConnectionManager.swift +++ b/NearbyShare/Connection/NearbyConnectionManager.swift @@ -45,6 +45,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb private var securityScopeUrl: URL? private static let customDeviceNameKey = "com.leonboettger.quickdrop.deviceName" private var defaultPort: NWEndpoint.Port = 50362 + private let txtKeyEndpointInfo = "n" + private let txtKeyNotificationSyncCapabilities = "qd_caps" + private let txtKeyNotificationSyncKeyFingerprint = "qd_kid" + private let notificationSyncCapabilityToken = "nsync1" // MARK: Shared Instance @@ -307,14 +311,29 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb mdnsServices = serviceTypes.map { serviceType in let service = NetService(domain: "", type: serviceType, name: name, port: port) service.delegate = self - service.setTXTRecord(NetService.data(fromTXTRecord: [ - "n": deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!, - ])) + var txtRecord: [String: Data] = [ + txtKeyEndpointInfo: deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!, + ] + #if os(macOS) + txtRecord[txtKeyNotificationSyncCapabilities] = Data(notificationSyncCapabilityToken.utf8) + if let keyFingerprint = notificationSyncKeyFingerprintHex() { + txtRecord[txtKeyNotificationSyncKeyFingerprint] = Data(keyFingerprint.utf8) + } + #endif + service.setTXTRecord(NetService.data(fromTXTRecord: txtRecord)) service.publish() return service } } + + private func notificationSyncKeyFingerprintHex() -> String? { + guard let keyID = IdentityManager.shared.getPublicKey()?.toGenericPublicKey().id() else { + return nil + } + return keyID.hex.lowercased() + } + private func stopMDNS() { // Gracefully stop each published service @@ -341,7 +360,11 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) { incomingConnections.removeValue(forKey: connection.id) - + + if connection.shouldSuppressAppDelegates { + return + } + if !connection.wasUserRejected { inboundAppDelegates.forEach { delegate in delegate.connectionWasTerminated(connectionID: connection.id, from: connection.remoteDeviceInfo, savedFiles: savedFiles, wasPlainTextTransfer: connection.isPlainTextTransfer, error: error) @@ -513,7 +536,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb var foundService = FoundServiceInfo(service: service) guard case let NWBrowser.Result.Metadata.bonjour(txtRecord) = service.metadata else { return } - guard let endpointInfoEncoded = txtRecord.dictionary["n"] else { return } + guard let endpointInfoEncoded = txtRecord.dictionary[txtKeyEndpointInfo] else { return } guard let endpointInfoData = Data.dataFromUrlSafeBase64(endpointInfoEncoded) else { return } guard var endpointInfo = EndpointInfo(data: endpointInfoData) else { return } var deviceInfo: RemoteDeviceInfo? diff --git a/NearbyShare/Connection/OutboundNearbyConnection.swift b/NearbyShare/Connection/OutboundNearbyConnection.swift index 9a14c06..6fb76fc 100644 --- a/NearbyShare/Connection/OutboundNearbyConnection.swift +++ b/NearbyShare/Connection/OutboundNearbyConnection.swift @@ -269,6 +269,15 @@ class OutboundNearbyConnection: NearbyConnection { private func processConnectionResponse(frame: Location_Nearby_Connections_OfflineFrame) throws { guard frame.version == .v1 else { throw NearbyError.protocolError("Unexpected offline frame version \(frame.version)") } + guard frame.hasV1, frame.v1.hasType else { throw NearbyError.requiredFieldMissing("offlineFrame.v1.type") } + + if frame.v1.type == .keepAlive { + log("[OutboundNearbyConnection \(self.id)] Received keep-alive while waiting for connection response") + if frame.v1.hasKeepAlive, !frame.v1.keepAlive.ack { + sendKeepAlive(ack: true) + } + return + } guard frame.v1.type == .connectionResponse else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") } diff --git a/NearbyShare/Model/DataModel.swift b/NearbyShare/Model/DataModel.swift index 4052f95..74d0299 100644 --- a/NearbyShare/Model/DataModel.swift +++ b/NearbyShare/Model/DataModel.swift @@ -139,6 +139,7 @@ public struct TransferMetadata { case text case url case wifiPassword + case notificationSync } private func getSummary() -> String { @@ -196,6 +197,12 @@ public struct TransferMetadata { case .wifiPassword: return "DeviceSendingWifiPassword".localized(with: deviceName) + + case .notificationSync: + if let textDescription, !textDescription.isEmpty { + return textDescription + } + return "NotificationSyncConsentPromptFromDevice".localized(with: deviceName) } } diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index f212f49..42f6a9e 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -68,6 +68,7 @@ EB3B9F152EF7150400D6704E /* DataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C36E82E3546100052EFD7 /* DataModel.swift */; }; EB3B9F162EF7150400D6704E /* OutboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */; }; EB3B9F172EF7150400D6704E /* InboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */; }; + A1B2C3D62F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; EB3B9F182EF7150400D6704E /* NearbyConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */; }; EB3B9F192EF7150400D6704E /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8D2E50CA23008B8C5C /* Configuration.swift */; }; EB3B9F1A2EF7150400D6704E /* AttachmentDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB950B762E521712002D68FA /* AttachmentDetails.swift */; }; @@ -136,6 +137,7 @@ EB3B9F6B2EF7151900D6704E /* DataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C36E82E3546100052EFD7 /* DataModel.swift */; }; EB3B9F6C2EF7151900D6704E /* NearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698DFAE529E2F91A0064F247 /* NearbyConnection.swift */; }; EB3B9F6D2EF7151900D6704E /* InboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */; }; + A1B2C3D72F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; EB3B9F6E2EF7151900D6704E /* NearbyConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */; }; EB3B9F6F2EF7151900D6704E /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2F9BFA2DA04B67004010A2 /* Helpers.swift */; }; EB3B9F702EF7151900D6704E /* ApIsolationTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1325E92E7025D900FD749C /* ApIsolationTester.swift */; }; @@ -188,11 +190,13 @@ EB5C37CC2E377A7F0052EFD7 /* DataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C36E82E3546100052EFD7 /* DataModel.swift */; }; EB5C37CD2E377A7F0052EFD7 /* OutboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */; }; EB5C37CE2E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */; }; + A1B2C3D52F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; EB5C37CF2E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */; }; EB5C37D02E377A7F0052EFD7 /* OutboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */; }; EB5C37D12E377A7F0052EFD7 /* DataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C36E82E3546100052EFD7 /* DataModel.swift */; }; EB5C37D22E377A7F0052EFD7 /* NearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698DFAE529E2F91A0064F247 /* NearbyConnection.swift */; }; EB5C37D32E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */; }; + A1B2C3D42F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; EB5C37D42E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */; }; EB5C37D62E377ACF0052EFD7 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = EB5C37D52E377ACF0052EFD7 /* SwiftProtobuf */; }; EB5C37D82E377ACF0052EFD7 /* SwiftECC in Frameworks */ = {isa = PBXBuildFile; productRef = EB5C37D72E377ACF0052EFD7 /* SwiftECC */; }; @@ -209,6 +213,7 @@ EB5C37F92E377CE70052EFD7 /* securegcm.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2C29E18CB500A442DA /* securegcm.pb.swift */; }; EB5C37FA2E377CE70052EFD7 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2F9BFA2DA04B67004010A2 /* Helpers.swift */; }; EB5C37FB2E377CE70052EFD7 /* InboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */; }; + A1B2C3D82F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; EB5C37FC2E377CE70052EFD7 /* OutboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */; }; EB5C37FE2E377CE70052EFD7 /* offline_wire_formats.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2B29E18CB500A442DA /* offline_wire_formats.pb.swift */; }; EB5C37FF2E377CE70052EFD7 /* wire_format.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2929E18CB500A442DA /* wire_format.pb.swift */; }; @@ -271,6 +276,7 @@ EB950B4A2E520436002D68FA /* securegcm.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2C29E18CB500A442DA /* securegcm.pb.swift */; }; EB950B4B2E520436002D68FA /* offline_wire_formats.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2B29E18CB500A442DA /* offline_wire_formats.pb.swift */; }; EB950B4C2E520436002D68FA /* InboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */; }; + A1B2C3D92F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; }; EB950B4D2E520436002D68FA /* NearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698DFAE529E2F91A0064F247 /* NearbyConnection.swift */; }; EB950B502E520436002D68FA /* wire_format.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2929E18CB500A442DA /* wire_format.pb.swift */; }; EB950B512E520436002D68FA /* securemessage.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2D29E18CB500A442DA /* securemessage.pb.swift */; }; @@ -486,6 +492,7 @@ 69DA9A1829E0BF5200A442DA /* QuickDrop.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QuickDrop.entitlements; sourceTree = ""; }; 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyConnectionManager.swift; sourceTree = ""; }; 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundNearbyConnection.swift; sourceTree = ""; }; + A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirroredNotificationSupport.swift; sourceTree = ""; }; 69DA9A2829E18CB500A442DA /* ukey.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ukey.pb.swift; sourceTree = ""; }; 69DA9A2929E18CB500A442DA /* wire_format.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = wire_format.pb.swift; sourceTree = ""; }; 69DA9A2A29E18CB500A442DA /* device_to_device_messages.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = device_to_device_messages.pb.swift; sourceTree = ""; }; @@ -767,6 +774,7 @@ children = ( 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */, 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */, + A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */, 691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */, 698DFAE529E2F91A0064F247 /* NearbyConnection.swift */, ); @@ -1289,6 +1297,7 @@ EB5C37D12E377A7F0052EFD7 /* DataModel.swift in Sources */, EB5C37D22E377A7F0052EFD7 /* NearbyConnection.swift in Sources */, EB5C37D32E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */, + A1B2C3D42F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, EB5C37D42E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */, EB5C37BD2E377A700052EFD7 /* Helpers.swift in Sources */, EB1325EB2E7025D900FD749C /* ApIsolationTester.swift in Sources */, @@ -1325,6 +1334,7 @@ EB5C37CC2E377A7F0052EFD7 /* DataModel.swift in Sources */, EB5C37CD2E377A7F0052EFD7 /* OutboundNearbyConnection.swift in Sources */, EB5C37CE2E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */, + A1B2C3D52F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, EB5C37CF2E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */, EBE5FF422EDB6F6F0019B380 /* Configuration.swift in Sources */, EB7654A12E53252D006B4AA3 /* AttachmentDetails.swift in Sources */, @@ -1379,6 +1389,7 @@ EB3B9F152EF7150400D6704E /* DataModel.swift in Sources */, EB3B9F162EF7150400D6704E /* OutboundNearbyConnection.swift in Sources */, EB3B9F172EF7150400D6704E /* InboundNearbyConnection.swift in Sources */, + A1B2C3D62F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, EB3B9F182EF7150400D6704E /* NearbyConnectionManager.swift in Sources */, EB3B9F192EF7150400D6704E /* Configuration.swift in Sources */, EB3B9F1A2EF7150400D6704E /* AttachmentDetails.swift in Sources */, @@ -1443,6 +1454,7 @@ EB3B9F6B2EF7151900D6704E /* DataModel.swift in Sources */, EB3B9F6C2EF7151900D6704E /* NearbyConnection.swift in Sources */, EB3B9F6D2EF7151900D6704E /* InboundNearbyConnection.swift in Sources */, + A1B2C3D72F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, EB3B9F6E2EF7151900D6704E /* NearbyConnectionManager.swift in Sources */, EB3B9F6F2EF7151900D6704E /* Helpers.swift in Sources */, EB3B9F702EF7151900D6704E /* ApIsolationTester.swift in Sources */, @@ -1471,6 +1483,7 @@ EB5C37F92E377CE70052EFD7 /* securegcm.pb.swift in Sources */, EB5C37FA2E377CE70052EFD7 /* Helpers.swift in Sources */, EB5C37FB2E377CE70052EFD7 /* InboundNearbyConnection.swift in Sources */, + A1B2C3D82F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, EB9925802E45D53E00357055 /* sharing_enums.pb.swift in Sources */, EB5C37FC2E377CE70052EFD7 /* OutboundNearbyConnection.swift in Sources */, EB1325D62E701D8E00FD749C /* Settings.swift in Sources */, @@ -1510,6 +1523,7 @@ EB950B4A2E520436002D68FA /* securegcm.pb.swift in Sources */, EB950B4B2E520436002D68FA /* offline_wire_formats.pb.swift in Sources */, EB950B4C2E520436002D68FA /* InboundNearbyConnection.swift in Sources */, + A1B2C3D92F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */, EB16B6132E58B4B9007D9792 /* IdentityManager.swift in Sources */, EB950B4D2E520436002D68FA /* NearbyConnection.swift in Sources */, EB950B502E520436002D68FA /* wire_format.pb.swift in Sources */, diff --git a/QuickDrop/Localizable.xcstrings b/QuickDrop/Localizable.xcstrings index 4617a87..c540c41 100644 --- a/QuickDrop/Localizable.xcstrings +++ b/QuickDrop/Localizable.xcstrings @@ -21890,6 +21890,705 @@ } } }, + "NotificationSyncConsentPromptFromDevice" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "هل تريد تفعيل مزامنة الإشعارات من %1$@؟" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voleu activar la sincronització de notificacions des de %1$@?" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chcete povolit synchronizaci oznámení z %1$@?" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vil du aktivere synkronisering af notifikationer fra %1$@?" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Möchtest du die Benachrichtigungssynchronisierung von %1$@ aktivieren?" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Θέλετε να ενεργοποιήσετε τον συγχρονισμό ειδοποιήσεων από το %1$@;" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you want to enable notification sync from %1$@?" + } + }, + "en-AU" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you want to enable notification sync from %1$@?" + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you want to enable notification sync from %1$@?" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do you want to enable notification sync from %1$@?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Quieres activar la sincronización de notificaciones desde %1$@?" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Quieres activar la sincronización de notificaciones desde %1$@?" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haluatko ottaa käyttöön ilmoitusten synkronoinnin laitteesta %1$@?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autoriser la synchronisation des notifications depuis %1$@ ?" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voulez-vous activer la synchronisation des notifications à partir de %1$@ ?" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "האם להפעיל סנכרון התראות מ-%1$@?" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "क्या आप %1$@ से नोटिफिकेशन सिंक चालू करना चाहते हैं?" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Želite li omogućiti sinkronizaciju obavijesti s %1$@?" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Szeretnéd engedélyezni az értesítések szinkronizálását innen: %1$@?" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apakah Anda ingin mengaktifkan sinkronisasi notifikasi dari %1$@?" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vuoi attivare la sincronizzazione delle notifiche da %1$@?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ からの通知同期を有効にしますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@에서 알림 동기화를 활성화할까요?" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adakah anda ingin mengaktifkan penyegerakan notifikasi dari %1$@?" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vil du aktivere varselsynkronisering fra %1$@?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wil je meldingssynchronisatie van %1$@ inschakelen?" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chcesz włączyć synchronizację powiadomień z %1$@?" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deseja ativar a sincronização de notificações de %1$@?" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pretende ativar a sincronização de notificações de %1$@?" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dorești să activezi sincronizarea notificărilor de la %1$@?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Включить синхронизацию уведомлений с устройства %1$@?" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vill du aktivera synkronisering av aviseringar från %1$@?" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ต้องการเปิดใช้งานการซิงค์การแจ้งเตือนจาก %1$@ หรือไม่?" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ cihazından bildirim senkronizasyonunu etkinleştirmek istiyor musunuz?" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Увімкнути синхронізацію сповіщень з %1$@?" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bạn có muốn bật đồng bộ thông báo từ %1$@ không?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "要启用来自 %1$@ 的通知同步吗?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你想啟用從 %1$@ 同步通知嗎?" + } + } + } + }, + "NotificationSyncFallbackSenderAndroid" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "en-AU" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אנדרואיד" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "एंड्रॉइड" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Android" + } + } + } + }, + "NotificationSyncFallbackTitle" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إشعار" + } + }, + "ca" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificació" + } + }, + "cs" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oznámení" + } + }, + "da" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifikation" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benachrichtigung" + } + }, + "el" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ειδοποίηση" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification" + } + }, + "en-AU" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification" + } + }, + "en-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification" + } + }, + "en-GB" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificación" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificación" + } + }, + "fi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ilmoitus" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notification" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "התראה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "सूचना" + } + }, + "hr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Obavijest" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Értesítés" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifikasi" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifica" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "알림" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notifikasi" + } + }, + "nb" : { + "stringUnit" : { + "state" : "translated", + "value" : "Varsel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Melding" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powiadomienie" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificação" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificação" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Notificare" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Уведомление" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Avisering" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "การแจ้งเตือน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildirim" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сповіщення" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thông báo" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "通知" + } + } + } + }, "OpenFinderAfterReceiving" : { "localizations" : { "ar" : { diff --git a/QuickDrop/ViewModel/ReceiveModel.swift b/QuickDrop/ViewModel/ReceiveModel.swift index 04a8a72..c6c4c45 100644 --- a/QuickDrop/ViewModel/ReceiveModel.swift +++ b/QuickDrop/ViewModel/ReceiveModel.swift @@ -81,6 +81,31 @@ class ReceiveModel: ObservableObject, InboundAppDelegate { #if os(macOS) DispatchQueue.main.async { + if transfer.type == .notificationSync { + self.hideQuickDropToast() + self.toastActions = nil + self.progress = nil + self.consentState = nil + let senderName = device.name?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedSenderName = (senderName?.isEmpty == false) ? senderName! : "UnknownDevice".localized() + self.activeDeviceName = resolvedSenderName + + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "NotificationSyncConsentPromptFromDevice".localized(with: resolvedSenderName) + alert.informativeText = pinCodeMessage + alert.addButton(withTitle: "Accept".localized()) + alert.addButton(withTitle: "Decline".localized()) + + let result = alert.runModal() + if result == .alertFirstButtonReturn { + primaryButtonAction(true) + } else { + secondaryButtonAction() + } + return + } + self.toastActions = nil self.progress = nil self.activeDeviceName = device.name ?? "UnknownDevice".localized()