98 Commits
Author SHA1 Message Date
Leon Böttger 0d0b720114 Bump version number 2026-03-25 08:44:45 +01:00
Leon Böttger 13f3073077 Update Mac background colors 2026-03-25 07:19:17 +01:00
Leon Böttger ff39ba4c7d Bump version 2026-03-22 15:38:39 +01:00
Leon Böttger 245358ae96 Send keepalive ack during handshake 2026-03-21 21:51:19 +01:00
Leon Böttger 429069f372 Don't send keepalive too early 2026-03-21 21:16:26 +01:00
Leon Böttger 04e056da02 Ignore keepalive during handshake 2026-03-21 21:09:54 +01:00
Leon Böttger 50d4d4ee92 Change toast view close button position 2026-03-21 13:47:20 +01:00
Leon Böttger 86b7e4ca24 Safer dataTransferInactivityTimer handling 2026-03-21 11:56:05 +01:00
Leon Böttger d3a95c068f Improve logging 2026-03-21 11:32:41 +01:00
Leon Böttger e3cbc19f11 Re-add openLog 2026-03-21 11:15:15 +01:00
Leon Böttger c46f6eedbc Extract pairing proto 2026-03-18 20:05:07 +01:00
Leon Böttger ea213906d6 Fix cannot share screenshots 2026-03-17 21:05:01 +01:00
Leon Böttger 4700a9c56e Package updates 2026-03-16 21:27:25 +01:00
Leon Böttger 4fe30da52b Improve Strings 2026-03-16 21:27:02 +01:00
Leon Böttger bd93961c59 Ensure P256 derived field length 2026-03-16 21:26:20 +01:00
Leon Böttger f244572508 Improve Strings 2026-03-16 19:10:20 +01:00
Leon Böttger d4ea143d51 UI Improvements 2026-03-16 07:22:35 +01:00
Leon Böttger 2dbc46f792 Fix init order 2026-03-15 21:18:33 +01:00
Leon Böttger f61be69507 Add QR code based pairing 2026-03-15 21:00:12 +01:00
Leon Böttger 82f40d780f PIN check for pairing 2026-03-15 15:57:56 +01:00
Leon Böttger 606082d76d Improve toast view UI 2026-03-15 14:56:53 +01:00
Leon Böttger 2233699ece Toast View adjustment 2026-03-15 13:59:08 +01:00
Leon Böttger 130935c01d Use ToastView for notification setup 2026-03-14 21:20:45 +01:00
Leon Böttger 74226d2fc3 Order TrustedDevicesView by date 2026-03-14 19:35:53 +01:00
Leon Böttger e895e7a68e Better device name fallback 2026-03-14 11:36:44 +01:00
Leon Böttger 843ed9fe4f Use Keychain for trusted devices 2026-03-14 11:33:27 +01:00
Leon Böttger 2ef3bc3b44 Add auth for incoming transfers 2026-03-14 11:15:15 +01:00
Leon Böttger 216e2cf851 Add nync to protobuf 2026-03-14 08:26:00 +01:00
Leon Böttger d63665136b Remove liquid glass force refresh 2026-03-13 21:20:50 +01:00
Leon Böttger 42c101605f Show unknown devicrd 2026-03-13 19:48:36 +01:00
Leon Böttger fd1dfb4f4a Always allow incoming notifications 2026-03-13 19:09:03 +01:00
Leon Böttger 767a5c5706 Bump build number 2026-03-11 21:25:28 +01:00
Leon Böttger 8e78f58b5e Fix log sending from share extension 2026-03-11 21:08:56 +01:00
Leon Böttger 935bdc5a65 Fix liquid glass background refresh 2026-03-11 21:05:31 +01:00
Leon Böttger f559a6ac9d Improve logging 2026-03-11 20:48:19 +01:00
Leon Böttger e853ba2b36 Improve logging 2026-03-11 20:23:13 +01:00
Leon Böttger 4621395a87 Package updates 2026-03-11 19:58:26 +01:00
Leon Böttger 55e2a72a90 Fix notification sync sematics 2026-03-11 19:05:38 +01:00
Leon Böttger 159e83179b Improve Strings 2026-03-11 19:00:57 +01:00
Leon Böttger 6a77e4aba6 Bump version 2026-03-11 18:46:15 +01:00
Leon Böttger ffe3ac7a4a Bump version 2026-03-08 16:17:50 +01:00
Leon Böttger 2dbe945f3e Better out-of-frame detection 2026-03-08 16:15:21 +01:00
Leon Böttger e828edd6ae Better error handling 2026-03-08 15:44:25 +01:00
Leon Böttger 7fbe254118 Ignore duplicate sequence numbers 2026-03-08 15:42:12 +01:00
Leon Böttger cffaabc076 Fail fast on outbound rejection 2026-03-08 15:39:17 +01:00
Leon Böttger 3736605509 Better frame duplication logic 2026-03-08 15:30:22 +01:00
Leon Böttger e0407d9398 Safer connection handling 2026-03-08 15:18:07 +01:00
Leon Böttger 3ed93e6079 Add liquid glass for toast view 2026-03-08 15:12:27 +01:00
Leon Böttger e1924e1919 Improve ACK logic 2026-03-08 14:40:16 +01:00
Leon Böttger c10facbedd Disconnect only after sending last frame 2026-03-08 14:32:07 +01:00
Leon Böttger a1b4a075a6 Support body / EOF frame separately 2026-03-08 14:27:31 +01:00
Leon Böttger bf7ad37d15 Recover files on early disconnect 2026-03-08 14:13:39 +01:00
Leon Böttger e68167788a Process partial frame on end of connection 2026-03-08 14:07:48 +01:00
Leon Böttger 48eb5be4a5 Add notification sync support 2026-03-08 13:49:07 +01:00
Leon Böttger a352e9ed1c Fix 2026-03-07 10:32:07 +01:00
Leon Böttger 0f8c9d254e Remove unused code 2026-03-01 10:54:03 +01:00
Leon Böttger be4d342894 Fix toast view appearance 2026-03-01 10:38:30 +01:00
Leon Böttger 811b991fcd Fix startup sequence 2026-02-26 20:51:01 +01:00
Leon Böttger d46b62d3de Refactoring 2026-02-23 19:15:14 +01:00
Leon Böttger 093390084f Fix toast view stutter 2026-02-22 19:37:10 +01:00
Leon Böttger b16556dd1b Fix toast view slide in animation 2026-02-22 19:18:36 +01:00
Leon Böttger f5d895ed56 Fix macOS 12 not visible on first start 2026-02-22 18:53:20 +01:00
Leon Böttger c489ef018f Fix toast view disappears if new connection comes right after 2026-02-22 16:12:02 +01:00
Leon Böttger 7e0a2e2830 Toast view cancel all connections with xmark 2026-02-19 21:35:14 +01:00
Leon Böttger 0b94a888b5 Fix cancel not hiding toast view 2026-02-19 21:29:36 +01:00
Leon Böttger 15677b7843 Fix toast view race condition 2026-02-19 21:23:18 +01:00
Leon Böttger 6401c1969c Allow multiple incoming connections 2026-02-19 20:53:48 +01:00
Leon Böttger bb1f28af0b Don't create multiple ReceiveModels in AppDelegate 2026-02-16 19:43:42 +01:00
Leon Böttger d367e0ff34 Fix accept button on macOS 12 2026-02-12 21:24:17 +01:00
Leon Böttger ec093cf302 Formatting 2026-02-12 18:59:57 +01:00
Leon Böttger edc09b048c Fix rename alert 2026-02-11 21:13:23 +01:00
Leon Böttger 9f15ee85f6 Improve toast view 2026-02-11 21:04:07 +01:00
Leon Böttger 10ef0c5a21 Always use fade as hide animation for toast view 2026-02-11 19:18:50 +01:00
Leon Böttger 0aa4fd6894 Improve UI 2026-02-11 19:14:19 +01:00
Leon Böttger 71bddf6541 Fix glitch on macOS 12 2026-02-11 19:05:39 +01:00
Leon Böttger 3ac2280dc6 UI improvements 2026-02-08 11:14:45 +01:00
Leon Böttger 8b965a7eb2 Fix toast could appear twice 2026-02-08 10:11:10 +01:00
Leon Böttger f11943475c Slide in for tast view 2026-02-08 08:41:59 +01:00
Leon Böttger a5781e6e96 Fix toast view dark mode 2026-02-07 21:29:27 +01:00
Leon Böttger ed20c9155e Replace alert with toast view 2026-02-07 15:15:26 +01:00
Leon Böttger b55d6ff07b UI improvements 2026-02-06 18:42:39 +01:00
Leon Böttger c86ed97bc2 Improve support link & logging 2026-02-05 20:54:59 +01:00
Leon Böttger eb32603c5f Fix issue that prevented sharing files 2026-02-02 20:47:34 +01:00
Leon Böttger 044753efb1 Present introduction as sheet window 2026-02-01 15:12:29 +01:00
Leon Böttger e8fdd0e94a Improve introduction view 2026-02-01 14:54:41 +01:00
Leon Böttger 09ef75d249 Refactoring 2026-01-31 21:21:32 +01:00
Leon Böttger 3551c1a006 Fix: Cannot save photos without creation date 2026-01-25 21:00:53 +01:00
Leon Böttger d748167f0e Refactoring 2026-01-25 20:31:34 +01:00
Leon Böttger 02e8af22ab Cleanup TrustedDevicesView 2026-01-25 14:52:52 +01:00
Leon Böttger 0e1fab8faa Improve strings 2026-01-25 13:41:31 +01:00
Leon Böttger 0396c56a31 Add support for WiFi password transfer & add FAQ 2026-01-25 13:30:04 +01:00
Leon Böttger ffdf667cc6 Refactoring 2026-01-23 21:31:25 +01:00
Leon Böttger cce84e9460 Prefer fixed port if possible 2026-01-23 20:58:45 +01:00
Leon Böttger b546370e87 Clean up after closing app 2026-01-18 18:50:22 +01:00
Leon Böttger fbd14c2280 Fix toast view cutoff 2026-01-18 11:31:37 +01:00
Leon Böttger bf1a8164d9 Update metadata 2026-01-18 11:16:58 +01:00
Leon Böttger 0a53ad79cb Fix toast view offset when transfer done 2026-01-15 21:32:03 +01:00
Leon Böttger 800fd23be3 Package updates 2026-01-15 21:17:14 +01:00
52 changed files with 19111 additions and 9372 deletions

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
DerivedData/ DerivedData/
.DerivedData/
.DS_Store .DS_Store
xcuserdata/ xcuserdata/
@@ -12,6 +12,7 @@ import Network
import System import System
import BigInt import BigInt
import SwiftECC import SwiftECC
import ASN1
import LUI import LUI
#if os(macOS) #if os(macOS)
@@ -30,7 +31,11 @@ class InboundNearbyConnection: NearbyConnection {
private var bytesToBeTransferred: Int64 = 0 private var bytesToBeTransferred: Int64 = 0
private var isAuthenticated = false private var isAuthenticated = false
private var peerCertificate: Sharing_Nearby_PublicCertificate? = nil 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 isPlainTextTransfer = false
var isControlTransfer = false
var wasUserRejected = false var wasUserRejected = false
var delegate: InboundNearbyConnectionDelegate? var delegate: InboundNearbyConnectionDelegate?
@@ -46,6 +51,16 @@ class InboundNearbyConnection: NearbyConnection {
override func disconnect() { override func disconnect() {
let recoveredAllPendingFiles = recoverCompletedFilesOnDisconnect()
if recoveredAllPendingFiles {
if case let .protocolError(message) = self.lastError as? NearbyError,
message == "Error.ClosedByPeer".localized() {
log("[InboundNearbyConnection \(self.id)] Recovering completed transfer after connection closed by peer.")
self.lastError = nil
NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: 1)
}
}
super.disconnect() super.disconnect()
currentState = .disconnected currentState = .disconnected
deletePartiallyReceivedFiles() deletePartiallyReceivedFiles()
@@ -62,6 +77,19 @@ class InboundNearbyConnection: NearbyConnection {
log("[InboundNearbyConnection \(self.id)] Received frame in state \(currentState)...") log("[InboundNearbyConnection \(self.id)] Received frame in state \(currentState)...")
} }
if !encryptionDone,
currentState != .initial,
let frame = try? Location_Nearby_Connections_OfflineFrame(serializedBytes: frameData),
frame.hasV1,
frame.v1.hasType,
case .keepAlive = frame.v1.type {
log("[InboundNearbyConnection \(self.id)] Received keep-alive during handshake in state \(currentState)")
if !frame.v1.hasKeepAlive || !frame.v1.keepAlive.ack {
sendKeepAlive(ack: true)
}
return
}
do { do {
switch currentState { switch currentState {
case .initial: case .initial:
@@ -111,11 +139,64 @@ class InboundNearbyConnection: NearbyConnection {
} }
switch currentState { switch currentState {
case .sentConnectionResponse: case .sentConnectionResponse:
try processPairedKeyEncryptionFrame(frame) if frame.hasV1, frame.v1.hasPairedKeyEncryption {
try processPairedKeyEncryptionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasIntroduction {
log("[InboundNearbyConnection \(self.id)] Received introduction before paired-key exchange finished; continuing unauthenticated flow.")
try sendPairedKeyResult(status: .unable)
try processIntroductionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyResult || frame.v1.type == .progressUpdate {
log("[InboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyEncryption.")
return
}
if frame.v1.type == .response, case .accept = frame.v1.connectionResponse.status {
log("[InboundNearbyConnection \(self.id)] Ignoring accept response while waiting for pairedKeyEncryption.")
return
}
}
throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyEncryption")
case .sentPairedKeyResult: case .sentPairedKeyResult:
try processPairedKeyResultFrame(frame) if frame.hasV1, frame.v1.hasPairedKeyResult {
try processPairedKeyResultFrame(frame)
return
}
if frame.hasV1, frame.v1.hasIntroduction {
log("[InboundNearbyConnection \(self.id)] Received introduction without pairedKeyResult; continuing for legacy compatibility.")
try processIntroductionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyEncryption || frame.v1.type == .progressUpdate {
log("[InboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyResult.")
return
}
if frame.v1.type == .response, case .accept = frame.v1.connectionResponse.status {
log("[InboundNearbyConnection \(self.id)] Ignoring accept response while waiting for pairedKeyResult.")
return
}
}
throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyResult")
case .receivedPairedKeyResult: case .receivedPairedKeyResult:
try processIntroductionFrame(frame) if frame.hasV1, frame.v1.hasIntroduction {
try processIntroductionFrame(frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyResult || frame.v1.type == .pairedKeyEncryption || frame.v1.type == .progressUpdate {
log("[InboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for introduction.")
return
}
if frame.v1.type == .response, case .accept = frame.v1.connectionResponse.status {
log("[InboundNearbyConnection \(self.id)] Ignoring accept response while waiting for introduction.")
return
}
}
throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.introduction")
default: default:
if frame.hasV1, frame.v1.hasType, frame.v1.type == .progressUpdate { if frame.hasV1, frame.v1.hasType, frame.v1.type == .progressUpdate {
// ignore progress updates // ignore progress updates
@@ -140,7 +221,7 @@ class InboundNearbyConnection: NearbyConnection {
let id = frame.payloadHeader.id let id = frame.payloadHeader.id
guard let fileInfo = filesToBeReceived[id] else { throw NearbyError.protocolError("File payload ID \(id) is not known") } guard var fileInfo = filesToBeReceived[id] else { throw NearbyError.protocolError("File payload ID \(id) is not known") }
let currentOffset = fileInfo.bytesTransferred let currentOffset = fileInfo.bytesTransferred
@@ -148,11 +229,15 @@ class InboundNearbyConnection: NearbyConnection {
guard currentOffset + Int64(frame.payloadChunk.body.count) <= fileInfo.meta.size else { throw NearbyError.protocolError("Transferred file size exceeds previously specified value") } guard currentOffset + Int64(frame.payloadChunk.body.count) <= fileInfo.meta.size else { throw NearbyError.protocolError("Transferred file size exceeds previously specified value") }
if !frame.payloadChunk.body.isEmpty { let hasBody = !frame.payloadChunk.body.isEmpty
let isLastChunk = (frame.payloadChunk.flags & 1) == 1
if hasBody {
do { do {
try fileInfo.fileHandle?.write(contentsOf: frame.payloadChunk.body) try fileInfo.fileHandle?.write(contentsOf: frame.payloadChunk.body)
filesToBeReceived[id]!.bytesTransferred += Int64(frame.payloadChunk.body.count) fileInfo.bytesTransferred += Int64(frame.payloadChunk.body.count)
fileInfo.progress?.completedUnitCount = filesToBeReceived[id]!.bytesTransferred fileInfo.progress?.completedUnitCount = fileInfo.bytesTransferred
filesToBeReceived[id] = fileInfo
self.bytesTransferred += Int64(frame.payloadChunk.body.count) self.bytesTransferred += Int64(frame.payloadChunk.body.count)
NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: Double(self.bytesTransferred) / Double(self.bytesToBeTransferred)) NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: Double(self.bytesTransferred) / Double(self.bytesToBeTransferred))
@@ -162,15 +247,19 @@ class InboundNearbyConnection: NearbyConnection {
throw NearbyError.protocolError(error.localizedDescription) throw NearbyError.protocolError(error.localizedDescription)
} }
} }
else if (frame.payloadChunk.flags & 1) == 1 {
if isLastChunk {
guard fileInfo.bytesTransferred == fileInfo.meta.size else {
throw NearbyError.protocolError("Received EOF before file was fully transferred (\(fileInfo.bytesTransferred)/\(fileInfo.meta.size) bytes)")
}
try fileInfo.fileHandle?.close() try fileInfo.fileHandle?.close()
filesToBeReceived[id]!.fileHandle = nil fileInfo.fileHandle = nil
#if os(macOS) #if os(macOS)
fileInfo.progress?.unpublish() fileInfo.progress?.unpublish()
#endif #endif
downloadedFiles.append(fileInfo.destinationURL) downloadedFiles.append(fileInfo.destinationURL)
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL) EXIFUtils.applyTimestamps(at: fileInfo.destinationURL)
filesToBeReceived[id] = fileInfo
filesToBeReceived.removeValue(forKey: id) filesToBeReceived.removeValue(forKey: id)
if filesToBeReceived.isEmpty { if filesToBeReceived.isEmpty {
@@ -179,7 +268,7 @@ class InboundNearbyConnection: NearbyConnection {
try sendDisconnectionAndDisconnect() try sendDisconnectionAndDisconnect()
} }
} }
else { else if !hasBody {
log("[InboundNearbyConnection \(self.id)] Received file chunk with no body and no flags, ignoring it.") log("[InboundNearbyConnection \(self.id)] Received file chunk with no body and no flags, ignoring it.")
} }
} }
@@ -223,7 +312,7 @@ class InboundNearbyConnection: NearbyConnection {
#endif #endif
filesToBeReceived.removeValue(forKey: id) filesToBeReceived.removeValue(forKey: id)
downloadedFiles.append(fileInfo.destinationURL) downloadedFiles.append(fileInfo.destinationURL)
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL) EXIFUtils.applyTimestamps(at: fileInfo.destinationURL)
if filesToBeReceived.isEmpty { if filesToBeReceived.isEmpty {
log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...") log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...")
@@ -243,8 +332,11 @@ class InboundNearbyConnection: NearbyConnection {
guard case .connectionRequest = frame.v1.type else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") } guard case .connectionRequest = frame.v1.type else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
let endpointInfo = EndpointInfo(data: frame.v1.connectionRequest.endpointInfo) let endpointInfo = EndpointInfo(data: frame.v1.connectionRequest.endpointInfo)
let fallbackDeviceName = "AndroidDevice".localized()
let resolvedDeviceName = endpointInfo?.name?.trimmingCharacters(in: .whitespacesAndNewlines)
let deviceName = (resolvedDeviceName?.isEmpty == false) ? resolvedDeviceName : fallbackDeviceName
remoteDeviceInfo = RemoteDeviceInfo(name: endpointInfo?.name, type: endpointInfo?.deviceType) remoteDeviceInfo = RemoteDeviceInfo(name: deviceName, type: endpointInfo?.deviceType)
currentState = .receivedConnectionRequest currentState = .receivedConnectionRequest
} }
@@ -365,9 +457,28 @@ class InboundNearbyConnection: NearbyConnection {
pairedEncryption.v1.type = .pairedKeyEncryption pairedEncryption.v1.type = .pairedKeyEncryption
pairedEncryption.v1.pairedKeyEncryption = Sharing_Nearby_PairedKeyEncryptionFrame() pairedEncryption.v1.pairedKeyEncryption = Sharing_Nearby_PairedKeyEncryptionFrame()
// For inbound connections, we do not use provide secretIDHash and signedData if let signingPrivateKey = IdentityManager.shared.getPrivateKey(),
pairedEncryption.v1.pairedKeyEncryption.secretIDHash = Data.randomData(length: 6) let publicKey = IdentityManager.shared.getPublicKey()?.toGenericPublicKey(),
pairedEncryption.v1.pairedKeyEncryption.signedData = Data.randomData(length: 72) let publicKeyData = IdentityManager.shared.getPublicKey()?.toGenericPublicKeyData(),
let publicKeyID = publicKey.id(),
let authKeyData = self.authKey?.data() {
var cert = Sharing_Nearby_PublicCertificate()
cert.secretID = publicKeyID
cert.publicKey = publicKeyData
let signatureTuple = signingPrivateKey.sign(msg: authKeyData)
pairedEncryption.v1.certificateInfo.publicCertificate.append(cert)
pairedEncryption.v1.pairedKeyEncryption.secretIDHash = cert.secretID
pairedEncryption.v1.pairedKeyEncryption.signedData = Data(signatureTuple.asn1.encode())
pairedEncryption.v1.pairedKeyResult.status = .success
} else {
log("[InboundNearbyConnection \(self.id)] No private key available for receiver authentication.")
pairedEncryption.v1.pairedKeyEncryption.secretIDHash = Data.randomData(length: 6)
pairedEncryption.v1.pairedKeyEncryption.signedData = Data.randomData(length: 72)
}
try sendTransferSetupFrame(pairedEncryption) try sendTransferSetupFrame(pairedEncryption)
currentState = .sentConnectionResponse currentState = .sentConnectionResponse
@@ -414,6 +525,7 @@ class InboundNearbyConnection: NearbyConnection {
if peerPublicKey.verify(signature: signature, msg: authKeyData) { if peerPublicKey.verify(signature: signature, msg: authKeyData) {
log("[InboundNearbyConnection \(self.id)] Paired key authentication successful.") log("[InboundNearbyConnection \(self.id)] Paired key authentication successful.")
self.peerCertificate = trustedCert
try sendPairedKeyResult(status: .success) try sendPairedKeyResult(status: .success)
isAuthenticated = true isAuthenticated = true
} else { } else {
@@ -504,12 +616,116 @@ class InboundNearbyConnection: NearbyConnection {
} }
let metadata = TransferMetadata(files: filesToBeReceived.map { $0.value.meta }, id: id, pinCode: pinCode, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil) let metadata = TransferMetadata(files: filesToBeReceived.map { $0.value.meta }, id: id, pinCode: pinCode, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
checkIfCanProceed(metadata: metadata) checkIfCanProceed(metadata: metadata)
return
} }
else if let textMetadata = frame.v1.introduction.textMetadata.first {
#if os(macOS)
if frame.v1.introduction.hasMirroredNotificationMetadata || frame.v1.introduction.hasPairingMetadata {
isControlTransfer = true
mirroredNotificationMetadata = frame.v1.introduction.hasMirroredNotificationMetadata
? frame.v1.introduction.mirroredNotificationMetadata
: nil
pairingMetadata = frame.v1.introduction.hasPairingMetadata
? frame.v1.introduction.pairingMetadata
: nil
isPairingSetupRequest = pairingMetadata?.setupRequest == true
let hasPairingConfirmation = pairingMetadata?.hasSetupPinHash == true
let pairingUseCase = pairingMetadata.flatMap { PairingUseCase(protoValue: $0.useCase) }
guard let device = self.remoteDeviceInfo else {
self.rejectTransfer(with: .reject)
return
}
if pairingMetadata != nil && pairingUseCase == nil {
self.rejectTransfer(with: .reject)
return
}
if pairingMetadata != nil,
mirroredNotificationMetadata == nil,
!isPairingSetupRequest,
!hasPairingConfirmation {
self.rejectTransfer(with: .reject)
return
}
if !isAuthenticated && !isPairingSetupRequest && !hasPairingConfirmation {
self.lastError = NearbyError.notificationSyncNotTrusted
self.rejectTransfer(with: .reject, markAsUserRejected: false)
return
}
if hasPairingConfirmation {
guard let peerCertificate = peerCertificate,
let pairingMetadata,
let pairingUseCase else {
self.rejectTransfer(with: .reject)
return
}
let trustedData = TrustStore.shared.findTrustedKey(for: peerCertificate.secretID)
if let trustedData {
guard let currentData = try? peerCertificate.serializedData(),
currentData == trustedData else {
self.rejectTransfer(with: .reject)
return
}
DispatchQueue.main.async {
self.delegate?.notificationSyncSetupConfirmed(connection: self)
}
submitUserConsent(accepted: true, trustDevice: false)
return
}
let matches = TrustStore.shared.confirmPendingPairingTrust(
secretIdHex: peerCertificate.secretID.hex,
useCase: pairingUseCase,
pinHash: pairingMetadata.setupPinHash
)
guard matches else {
self.rejectTransfer(with: .reject)
return
}
TrustStore.shared.addTrusted(certificate: peerCertificate, device: device)
DispatchQueue.main.async {
self.delegate?.notificationSyncSetupConfirmed(connection: self)
}
submitUserConsent(accepted: true, trustDevice: false)
return
}
if !isAuthenticated || isPairingSetupRequest {
guard let transferType = pairingTransferType(for: pairingUseCase) else {
self.rejectTransfer(with: .reject)
return
}
let metadata = TransferMetadata(
files: [],
id: id,
pinCode: pinCode,
transferType: transferType,
allowsToBeAddedAsTrustedDevice: true
)
DispatchQueue.main.async {
self.delegate?.obtainUserConsent(transfer: metadata, device: device, connection: self)
}
return
}
submitUserConsent(accepted: true, trustDevice: false)
return
}
#endif
if let textMetadata = frame.v1.introduction.textMetadata.first {
let isURL = textMetadata.type == .url let isURL = textMetadata.type == .url
textPayloadID = textMetadata.payloadID
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)
textPayloadID = textMetadata.payloadID
if !isURL { if !isURL {
isPlainTextTransfer = true isPlainTextTransfer = true
@@ -517,6 +733,15 @@ class InboundNearbyConnection: NearbyConnection {
checkIfCanProceed(metadata: metadata) checkIfCanProceed(metadata: metadata)
} }
else if let wifiMetadata = frame.v1.introduction.wifiCredentialsMetadata.first {
let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: wifiMetadata.ssid, transferType: .wifiPassword, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
textPayloadID = wifiMetadata.payloadID
isPlainTextTransfer = true
checkIfCanProceed(metadata: metadata)
}
else { else {
log("[InboundNearbyConnection \(self.id)] Rejecting transfer due to unsupported file type. Frame is \(frame.debugDescription)") log("[InboundNearbyConnection \(self.id)] Rejecting transfer due to unsupported file type. Frame is \(frame.debugDescription)")
@@ -527,7 +752,7 @@ class InboundNearbyConnection: NearbyConnection {
func checkIfCanProceed(metadata: TransferMetadata) { func checkIfCanProceed(metadata: TransferMetadata) {
let acceptAutomatically = isAuthenticated || Settings.shared.automaticallyAcceptFiles let acceptAutomatically = isAuthenticated || Settings.sharedInstance.automaticallyAcceptFiles
DispatchQueue.main.async { DispatchQueue.main.async {
if acceptAutomatically { if acceptAutomatically {
@@ -542,7 +767,30 @@ class InboundNearbyConnection: NearbyConnection {
} }
func submitUserConsent(accepted: Bool, trustDevice: Bool) { func submitUserConsent(accepted: Bool, trustDevice: Bool, pairingToken: String? = nil) {
if isPairingSetupRequest,
let peerCertificate = peerCertificate,
let pairingMetadata,
let pairingUseCase = PairingUseCase(protoValue: pairingMetadata.useCase) {
let secretIdHex = peerCertificate.secretID.hex
if accepted {
if TrustStore.shared.findTrustedKey(for: peerCertificate.secretID) == nil {
if let token = pairingToken {
TrustStore.shared.registerPendingPairingTrust(
secretIdHex: secretIdHex,
pinCode: token,
useCase: pairingUseCase
)
} else {
log("[InboundNearbyConnection \(self.id)] Missing pairing token; pending trust not registered.")
TrustStore.shared.clearPendingPairingTrust(secretIdHex: secretIdHex)
}
}
} else {
TrustStore.shared.clearPendingPairingTrust(secretIdHex: secretIdHex)
}
}
if trustDevice, let peerCertificate = peerCertificate, let remoteDeviceInfo = remoteDeviceInfo { if trustDevice, let peerCertificate = peerCertificate, let remoteDeviceInfo = remoteDeviceInfo {
TrustStore.shared.addTrusted(certificate: peerCertificate, device: remoteDeviceInfo) TrustStore.shared.addTrusted(certificate: peerCertificate, device: remoteDeviceInfo)
} }
@@ -563,7 +811,7 @@ class InboundNearbyConnection: NearbyConnection {
return return
} }
if isFileTransferRestricted() { if !isControlTransfer && isFileTransferRestricted() {
log("[InboundNearbyConnection \(self.id)] File transfer restrictions detected.") log("[InboundNearbyConnection \(self.id)] File transfer restrictions detected.")
delegate?.showPlusScreen() delegate?.showPlusScreen()
rejectTransfer() rejectTransfer()
@@ -607,17 +855,34 @@ class InboundNearbyConnection: NearbyConnection {
currentState = .receivingFiles currentState = .receivingFiles
isTransferring = true isTransferring = true
try sendTransferSetupFrame(frame) try sendTransferSetupFrame(frame)
if let mirroredNotificationMetadata,
!isPairingSetupRequest {
MirroredNotificationPresenter.shared.present(metadata: mirroredNotificationMetadata, senderDeviceName: remoteDeviceInfo?.name)
}
if isControlTransfer {
try sendDisconnectionAndDisconnect()
}
} catch { } catch {
lastError = error lastError = error
protocolError() protocolError()
} }
} }
private func pairingTransferType(for useCase: PairingUseCase?) -> TransferMetadata.TransferType? {
switch useCase {
case .notificationSync:
return .notificationSync
case .clipboardSync, .none:
return nil
}
}
private func rejectTransfer(with reason: Sharing_Nearby_ConnectionResponseFrame.Status = .reject) {
private func rejectTransfer(with reason: Sharing_Nearby_ConnectionResponseFrame.Status = .reject, markAsUserRejected: Bool = true) {
// rejected by user // rejected by user
if reason == .reject { if reason == .reject && markAsUserRejected {
self.wasUserRejected = true self.wasUserRejected = true
} }
@@ -650,4 +915,41 @@ class InboundNearbyConnection: NearbyConnection {
try? FileManager.default.removeItem(at: file.destinationURL) try? FileManager.default.removeItem(at: file.destinationURL)
} }
} }
private func recoverCompletedFilesOnDisconnect() -> Bool {
guard !filesToBeReceived.isEmpty else { return false }
var recoveredCount = 0
let pendingFileIDs = Array(filesToBeReceived.keys)
for fileID in pendingFileIDs {
guard let file = filesToBeReceived[fileID] else { continue }
guard file.created, file.bytesTransferred == file.meta.size else { continue }
do {
try file.fileHandle?.close()
} catch {
log("[InboundNearbyConnection \(self.id)] Failed to close recovered file handle for \(file.destinationURL.lastPathComponent): \(error.localizedDescription)")
}
#if os(macOS)
file.progress?.unpublish()
#endif
if !downloadedFiles.contains(file.destinationURL) {
downloadedFiles.append(file.destinationURL)
}
EXIFUtils.applyTimestamps(at: file.destinationURL)
filesToBeReceived.removeValue(forKey: fileID)
recoveredCount += 1
}
if recoveredCount > 0 {
log("[InboundNearbyConnection \(self.id)] Recovered \(recoveredCount) completed file(s) during disconnect.")
}
return recoveredCount > 0 && filesToBeReceived.isEmpty
}
} }
@@ -0,0 +1,134 @@
//
// MirroredNotificationSupport.swift
// QuickDrop
//
import Foundation
import CryptoKit
import UserNotifications
import LUI
final class MirroredNotificationPresenter {
static let shared = MirroredNotificationPresenter()
private let center = UNUserNotificationCenter.current()
private let queue = DispatchQueue(label: "MirroredNotificationPresenter")
private var pendingNotifications: [(Sharing_Nearby_MirroredNotificationMetadata, String?)] = []
private var authorizationRequestInFlight = false
private init() {}
func present(metadata: Sharing_Nearby_MirroredNotificationMetadata, senderDeviceName: String?) {
queue.async {
self.postNotificationIfAuthorized(metadata: metadata, senderDeviceName: senderDeviceName)
}
}
private func postNotificationIfAuthorized(metadata: Sharing_Nearby_MirroredNotificationMetadata, senderDeviceName: String?) {
center.getNotificationSettings { settings in
self.queue.async {
self.handleAuthorizationStatus(
settings.authorizationStatus,
metadata: metadata,
senderDeviceName: senderDeviceName
)
}
}
}
private func handleAuthorizationStatus(
_ status: UNAuthorizationStatus,
metadata: Sharing_Nearby_MirroredNotificationMetadata,
senderDeviceName: String?
) {
switch status {
case .authorized, .provisional, .ephemeral:
post(metadata: metadata, senderDeviceName: senderDeviceName)
case .notDetermined:
pendingNotifications.append((metadata, 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 (queuedMetadata, queuedSender) in queued {
self.post(metadata: queuedMetadata, senderDeviceName: queuedSender)
}
}
}
}
}
private func post(metadata: Sharing_Nearby_MirroredNotificationMetadata, 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 = metadata.appName.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanTitle = metadata.title.trimmingCharacters(in: .whitespacesAndNewlines)
let cleanBody = metadata.body.trimmingCharacters(in: .whitespacesAndNewlines)
let notificationKey = metadata.notificationKey.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: 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 {
DispatchQueue.main.async {
Settings.sharedInstance.incomingNotificationCount += 1
let count = Settings.sharedInstance.incomingNotificationCount
log("[MirroredNotificationPresenter] Posted mirrored notification (\(identifier)). Current count: \(count)")
}
}
}
}
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
}
}
+96 -30
View File
@@ -29,8 +29,13 @@ class NearbyConnection {
var cancelled: Bool = false var cancelled: Bool = false
var isTransferring: Bool = false { var isTransferring: Bool = false {
didSet { didSet {
log("[NearbyConnection \(self.id)] Now transferring. Setting up data transfer inactivity timer.") dataTransferInactivityTimer?.cancel()
startDataTransferTimer(previousBytesTransferred: bytesTransferred) dataTransferInactivityTimer = nil
if isTransferring {
log("[NearbyConnection \(self.id)] Now transferring. Setting up data transfer inactivity timer.")
startDataTransferTimer(previousBytesTransferred: bytesTransferred)
}
} }
} }
@@ -38,6 +43,7 @@ class NearbyConnection {
private var connectionClosed: Bool = false private var connectionClosed: Bool = false
private var inactivityTimer: DispatchSourceTimer? private var inactivityTimer: DispatchSourceTimer?
private var dataTransferInactivityTimer: DispatchSourceTimer?
private let ackInterval: TimeInterval = 5 private let ackInterval: TimeInterval = 5
private let ackTimeoutInterval: TimeInterval = 30 private let ackTimeoutInterval: TimeInterval = 30
@@ -189,7 +195,14 @@ class NearbyConnection {
} }
guard let content = content else { guard let content = content else {
log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(). IsComplete: \(isComplete)") log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(). IsComplete: \(isComplete)")
assertionFailure() self.lastError = NearbyError.protocolError("Received empty frame header")
self.protocolError()
return
}
guard content.count == 4 else {
log("[NearbyConnection \(self.id)] Received invalid frame header size \(content.count), expected 4")
self.lastError = NearbyError.protocolError("Unexpected frame header length")
self.protocolError()
return return
} }
let frameLength = UInt32(content[0]) << 24 | UInt32(content[1]) << 16 | UInt32(content[2]) << 8 | UInt32(content[3]) let frameLength = UInt32(content[0]) << 24 | UInt32(content[1]) << 16 | UInt32(content[2]) << 8 | UInt32(content[3])
@@ -206,7 +219,7 @@ class NearbyConnection {
private func receiveFrameAsync(length: UInt32) { private func receiveFrameAsync(length: UInt32) {
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { [self] content, _, isComplete, _ in connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { [self] content, _, isComplete, error in
if self.connectionClosed { if self.connectionClosed {
log("[NearbyConnection \(self.id)] Received FIN from peer, connection closed.") log("[NearbyConnection \(self.id)] Received FIN from peer, connection closed.")
connection.cancel() connection.cancel()
@@ -217,6 +230,13 @@ class NearbyConnection {
if let content = content, !content.isEmpty { if let content = content, !content.isEmpty {
log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), and frame not empty. Frame length: \(content.count)") log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), and frame not empty. Frame length: \(content.count)")
if content.count == Int(length) {
self.processReceivedFrame(frameData: content)
}
else {
log("[NearbyConnection \(self.id)] Dropping trailing partial frame during connection close. Expected \(length) bytes, got \(content.count)")
}
} }
else { else {
log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), but no content received anymore (\(String(describing: content))).") log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), but no content received anymore (\(String(describing: content))).")
@@ -230,11 +250,23 @@ class NearbyConnection {
return return
} }
if let error = error {
log("[NearbyConnection \(self.id)] Error during receiveFrameAsync(length:): \(error)")
self.lastError = error
self.protocolError()
return
}
guard let content = content else { guard let content = content else {
log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(length:). IsComplete: \(isComplete)") log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(length:). IsComplete: \(isComplete)")
self.protocolError() self.protocolError()
return return
} }
guard content.count == Int(length) else {
log("[NearbyConnection \(self.id)] Dropping partial frame body. Expected \(length) bytes, got \(content.count)")
self.lastError = NearbyError.protocolError("Unexpected frame body length")
self.protocolError()
return
}
self.startAndResetHeartbeatTimer() self.startAndResetHeartbeatTimer()
self.processReceivedFrame(frameData: content) self.processReceivedFrame(frameData: content)
@@ -256,7 +288,10 @@ class NearbyConnection {
UInt8(truncatingIfNeeded: length), UInt8(truncatingIfNeeded: length),
]) ])
lengthPrefixedData.append(frame) lengthPrefixedData.append(frame)
connection.send(content: lengthPrefixedData, completion: .contentProcessed { _ in connection.send(content: lengthPrefixedData, completion: .contentProcessed { error in
if let error = error {
log("[NearbyConnection \(self.id)] Error while sending frame: \(error)")
}
if let completion = completion { if let completion = completion {
completion() completion()
} }
@@ -367,9 +402,17 @@ class NearbyConnection {
let d2dMsg = try Securegcm_DeviceToDeviceMessage(serializedBytes: decryptedData) let d2dMsg = try Securegcm_DeviceToDeviceMessage(serializedBytes: decryptedData)
guard d2dMsg.hasMessage, d2dMsg.hasSequenceNumber else { throw NearbyError.requiredFieldMissing("d2dMessage.message|sequenceNumber") } guard d2dMsg.hasMessage, d2dMsg.hasSequenceNumber else { throw NearbyError.requiredFieldMissing("d2dMessage.message|sequenceNumber") }
clientSeq += 1 let receivedSeq = d2dMsg.sequenceNumber
let expectedSeq = clientSeq + 1
guard d2dMsg.sequenceNumber == clientSeq else { throw NearbyError.protocolError("Wrong sequence number. Expected \(clientSeq), got \(d2dMsg.sequenceNumber)") } if receivedSeq == expectedSeq {
clientSeq = receivedSeq
} else if receivedSeq <= clientSeq {
log("[NearbyConnection \(self.id)] Ignoring duplicate/stale frame sequence \(receivedSeq). Current sequence is \(clientSeq)")
return
} else {
throw NearbyError.protocolError("Wrong sequence number. Expected \(expectedSeq), got \(receivedSeq)")
}
let offlineFrame = try Location_Nearby_Connections_OfflineFrame(serializedBytes: d2dMsg.message) let offlineFrame = try Location_Nearby_Connections_OfflineFrame(serializedBytes: d2dMsg.message)
if offlineFrame.hasV1, offlineFrame.v1.hasType, case .payloadTransfer = offlineFrame.v1.type { if offlineFrame.hasV1, offlineFrame.v1.hasType, case .payloadTransfer = offlineFrame.v1.type {
@@ -432,7 +475,15 @@ class NearbyConnection {
} }
} }
else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .keepAlive = offlineFrame.v1.type { else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .keepAlive = offlineFrame.v1.type {
sendKeepAlive(ack: true) if offlineFrame.v1.hasKeepAlive {
let keepAlive = offlineFrame.v1.keepAlive
if !keepAlive.hasAck || !keepAlive.ack {
sendKeepAlive(ack: true)
}
} else {
// Older peers can omit the keepAlive payload; default to ACK reply.
sendKeepAlive(ack: true)
}
} else { } else {
if offlineFrame.hasV1, offlineFrame.v1.hasType, offlineFrame.v1.type == .bandwidthUpgradeRetry { if offlineFrame.hasV1, offlineFrame.v1.hasType, offlineFrame.v1.type == .bandwidthUpgradeRetry {
@@ -466,17 +517,12 @@ class NearbyConnection {
guard peerKey.hasEcP256PublicKey else { throw NearbyError.requiredFieldMissing("peerKey.ecP256PublicKey") } guard peerKey.hasEcP256PublicKey else { throw NearbyError.requiredFieldMissing("peerKey.ecP256PublicKey") }
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
var clientX = peerKey.ecP256PublicKey.x let clientX = peerKey.ecP256PublicKey.x.ensureLength(length: 32)
var clientY = peerKey.ecP256PublicKey.y let clientY = peerKey.ecP256PublicKey.y.ensureLength(length: 32)
if clientX.count > 32 {
clientX = clientX.suffix(32)
}
if clientY.count > 32 {
clientY = clientY.suffix(32)
}
let key = try ECPublicKey(domain: domain, w: Point(BInt(magnitude: [UInt8](clientX)), BInt(magnitude: [UInt8](clientY)))) let key = try ECPublicKey(domain: domain, w: Point(BInt(magnitude: [UInt8](clientX)), BInt(magnitude: [UInt8](clientY))))
let dhs = try (privateKey?.domain.multiplyPoint(key.w, privateKey!.s).x.asMagnitudeBytes())! let dhsRaw = try (privateKey?.domain.multiplyPoint(key.w, privateKey!.s).x.asMagnitudeBytes())!
let dhs = Data(dhsRaw).ensureLength(length: 32)
var sha = SHA256() var sha = SHA256()
sha.update(data: dhs) sha.update(data: dhs)
let derivedSecretKey = Data(sha.finalize()) let derivedSecretKey = Data(sha.finalize())
@@ -539,6 +585,8 @@ class NearbyConnection {
connection.stateUpdateHandler = nil connection.stateUpdateHandler = nil
inactivityTimer?.cancel() inactivityTimer?.cancel()
inactivityTimer = nil inactivityTimer = nil
dataTransferInactivityTimer?.cancel()
dataTransferInactivityTimer = nil
connectionClosed = true connectionClosed = true
connection.send(content: nil, isComplete: true, completion: .contentProcessed { _ in }) connection.send(content: nil, isComplete: true, completion: .contentProcessed { _ in })
} }
@@ -550,14 +598,22 @@ class NearbyConnection {
offlineFrame.v1.type = .disconnection offlineFrame.v1.type = .disconnection
offlineFrame.v1.disconnection = Location_Nearby_Connections_DisconnectionFrame() offlineFrame.v1.disconnection = Location_Nearby_Connections_DisconnectionFrame()
if encryptionDone { if connectionClosed {
try encryptAndSendOfflineFrame(offlineFrame) disconnect()
} else { return
try sendFrameAsync(offlineFrame.serializedData())
} }
log("[NearbyConnection \(self.id)] Sent disconnection frame during sendDisconnectionAndDisconnect") let finishDisconnect: () -> Void = {
disconnect() log("[NearbyConnection \(self.id)] Sent disconnection frame during sendDisconnectionAndDisconnect")
self.disconnect()
}
if encryptionDone {
try encryptAndSendOfflineFrame(offlineFrame, completion: finishDisconnect)
} else {
let data = try offlineFrame.serializedData()
sendFrameAsync(data, completion: finishDisconnect)
}
} }
@@ -597,8 +653,11 @@ class NearbyConnection {
func startAndResetHeartbeatTimer() { func startAndResetHeartbeatTimer() {
if connectionClosed {
return
}
if lastKeepAliveFrameSent.isOlderThan(seconds: ackInterval) { if encryptionDone && lastKeepAliveFrameSent.isOlderThan(seconds: ackInterval) {
sendKeepAlive(ack: false) sendKeepAlive(ack: false)
} }
@@ -623,7 +682,14 @@ class NearbyConnection {
func startDataTransferTimer(previousBytesTransferred: Int64) { func startDataTransferTimer(previousBytesTransferred: Int64) {
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + ackTimeoutInterval) { dataTransferInactivityTimer?.cancel()
dataTransferInactivityTimer = DispatchSource.makeTimerSource(queue: NearbyConnection.dispatchQueue)
dataTransferInactivityTimer?.schedule(deadline: .now() + ackTimeoutInterval)
dataTransferInactivityTimer?.setEventHandler { [weak self] in
guard let self = self else { return }
guard !self.connectionClosed, self.isTransferring else { return }
if previousBytesTransferred < self.bytesTransferred { if previousBytesTransferred < self.bytesTransferred {
// everything good, transferred more than last check, schedule next check // everything good, transferred more than last check, schedule next check
@@ -631,13 +697,13 @@ class NearbyConnection {
} }
else { else {
// connection stale, need to abort // connection stale, need to abort
if !self.connectionClosed { log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.ackTimeoutInterval) seconds")
log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.ackTimeoutInterval) seconds") self.lastError = NearbyError.canceled(reason: .timedOut)
self.lastError = NearbyError.canceled(reason: .timedOut) self.disconnect()
self.disconnect()
}
} }
} }
dataTransferInactivityTimer?.resume()
} }
@@ -21,6 +21,8 @@ import LUI
public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject { public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject {
// MARK: Private Properties
#if !EXTENSION #if !EXTENSION
private let sleepManager = SleepManager.shared private let sleepManager = SleepManager.shared
#endif #endif
@@ -32,7 +34,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
private var inboundAppDelegates: [InboundAppDelegate] = [] private var inboundAppDelegates: [InboundAppDelegate] = []
private var outgoingTransfers: [String: OutgoingTransferInfo] = [:] private var outgoingTransfers: [String: OutgoingTransferInfo] = [:]
private var startedDeviceDiscovery = false private var startedDeviceDiscovery = false
private var startedAdvertising = false private var startedAdvertising = false { didSet { informAboutStatus() }}
private var browsers: [NWBrowser] = [] private var browsers: [NWBrowser] = []
private let serviceTypes = ["_FC9F5ED42C8A._tcp."] private let serviceTypes = ["_FC9F5ED42C8A._tcp."]
private var qrCodePrivateKey: ECPrivateKey? private var qrCodePrivateKey: ECPrivateKey?
@@ -42,24 +44,36 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue") private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue")
private var securityScopeUrl: URL? private var securityScopeUrl: URL?
private static let customDeviceNameKey = "com.leonboettger.quickdrop.deviceName" 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"
private let unnamedAndroidFallbackDeviceName = "AndroidDevice".localized()
public let endpointID: [UInt8] = generateEndpointID()
// MARK: Shared Instance
public static let shared = NearbyConnectionManager() public static let shared = NearbyConnectionManager()
// MARK: Published State
@Published var attachments: AttachmentDetails? = nil @Published var attachments: AttachmentDetails? = nil
@Published var hasLocalNetworkPermission = true @Published var hasLocalNetworkPermission = true
@Published var isConnectedToLocalNetwork = true @Published var isConnectedToLocalNetwork = true { didSet { informAboutStatus() }}
@Published var deviceInfo: EndpointInfo @Published var deviceInfo: EndpointInfo
public var connectionUpdateCallback: (Bool) -> Void = { _ in } {
didSet {
connectionUpdateCallback(isConnectedToLocalNetwork)
}
}
// MARK: Public Properties
public var endpointID: [UInt8] = getEndpointID(forceRegeneration: false)
public var connectionUpdateCallback: (Bool) -> Void = { _ in } { didSet { informAboutStatus() }}
public var changedDeviceNameCallback: () -> Void = { } public var changedDeviceNameCallback: () -> Void = { }
// MARK: Initializers
override init() { override init() {
self.deviceInfo = Self.getEndpointInfo() self.deviceInfo = Self.getEndpointInfo()
tcpListener = try! NWListener(using: NWParameters(tls: .none)) tcpListener = try! NWListener(using: NWParameters(tls: .none))
@@ -83,7 +97,6 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
log("[NearbyConnectionManager] Local network lost.") log("[NearbyConnectionManager] Local network lost.")
} }
self.isConnectedToLocalNetwork = isConnected self.isConnectedToLocalNetwork = isConnected
self.connectionUpdateCallback(isConnected)
} }
} }
@@ -113,6 +126,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
// MARK: Deinitializer
deinit { deinit {
self.stopAccessingSaveDirectory() self.stopAccessingSaveDirectory()
} }
@@ -161,22 +176,73 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
public func becomeVisible() { public func becomeVisible(randomPort: Bool = false) {
if startedAdvertising { if startedAdvertising {
log("[NearbyConnectionManager] Already advertising, skipping") log("[NearbyConnectionManager] Already advertising, skipping")
return return
} }
log("[NearbyConnectionManager] Becoming visible") log("[NearbyConnectionManager] Becoming visible")
let parameters = NWParameters(tls: .none)
tcpListener = try! NWListener(using: NWParameters(tls: .none)) do {
startedAdvertising = true if randomPort {
self.tcpListener = try NWListener(using: parameters)
tcpListener.stateUpdateHandler = { (state: NWListener.State) in } else {
if case .ready = state { self.tcpListener = try NWListener(using: parameters, on: defaultPort)
self.initMDNS()
} }
} }
catch {
log("[NearbyConnectionManager] Error starting TCP listener: \(error). Trying with random port.")
runAfter(seconds: 1) {
self.becomeVisible(randomPort: true)
}
return
}
startedAdvertising = true
tcpListener.stateUpdateHandler = { state in
switch state {
case .ready:
log("[NearbyConnectionManager] Listener ready")
self.initMDNS(forceIDRegeneration: randomPort)
if let port = self.tcpListener.port, port != self.defaultPort {
log("[NearbyConnectionManager] Updated default port to \(port)")
self.defaultPort = port
}
case .failed(let error):
log("[NearbyConnectionManager] Listener failed: \(error)")
self.becomeInvisible()
runAfter(seconds: 1) {
if case .posix(let posixError) = error,
posixError == .EADDRINUSE {
self.becomeVisible(randomPort: true)
}
else {
self.becomeVisible()
}
}
case .cancelled:
log("[NearbyConnectionManager] Listener cancelled")
case .setup:
log("[NearbyConnectionManager] Listener setup")
case .waiting(let state):
log("[NearbyConnectionManager] Listener waiting: \(state)")
@unknown default:
log("[NearbyConnectionManager] Listener unknown state")
}
}
tcpListener.newConnectionHandler = { (connection: NWConnection) in tcpListener.newConnectionHandler = { (connection: NWConnection) in
let id = UUID().uuidString let id = UUID().uuidString
let conn = InboundNearbyConnection(connection: connection, id: id) let conn = InboundNearbyConnection(connection: connection, id: id)
@@ -184,6 +250,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
conn.delegate = self conn.delegate = self
conn.start() conn.start()
} }
tcpListener.start(queue: .global(qos: .utility)) tcpListener.start(queue: .global(qos: .utility))
} }
@@ -202,11 +269,13 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
private static func generateEndpointID() -> [UInt8] { private static func getEndpointID(forceRegeneration: Bool) -> [UInt8] {
// Try to retrieve from UserDefaults // Try to retrieve from UserDefaults
if let savedString = Settings.shared.endpointID, if !forceRegeneration,
let savedString = Settings.sharedInstance.endpointID,
let savedData = savedString.data(using: .utf8) { let savedData = savedString.data(using: .utf8) {
log("[NearbyConnectionManager] Using cached endpoint ID: \(savedString)")
return [UInt8](savedData) return [UInt8](savedData)
} }
@@ -219,13 +288,17 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
// Save to UserDefaults as String // Save to UserDefaults as String
let idString = String(bytes: id, encoding: .utf8) ?? "" let idString = String(bytes: id, encoding: .utf8) ?? ""
Settings.shared.endpointID = idString Settings.sharedInstance.endpointID = idString
log("[NearbyConnectionManager] Storing new endpoint ID: \(idString) ======================================")
return id return id
} }
private func initMDNS() { private func initMDNS(forceIDRegeneration: Bool) {
self.endpointID = Self.getEndpointID(forceRegeneration: forceIDRegeneration)
let nameBytes: [UInt8] = [ let nameBytes: [UInt8] = [
0x23, // PCP 0x23, // PCP
endpointID[0], endpointID[1], endpointID[2], endpointID[3], endpointID[0], endpointID[1], endpointID[2], endpointID[3],
@@ -239,15 +312,30 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
mdnsServices = serviceTypes.map { serviceType in mdnsServices = serviceTypes.map { serviceType in
let service = NetService(domain: "", type: serviceType, name: name, port: port) let service = NetService(domain: "", type: serviceType, name: name, port: port)
service.delegate = self service.delegate = self
service.setTXTRecord(NetService.data(fromTXTRecord: [ var txtRecord: [String: Data] = [
"n": deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!, 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() service.publish()
return service return service
} }
} }
private func notificationSyncKeyFingerprintHex() -> String? {
guard let keyID = IdentityManager.shared.getPublicKey()?.toGenericPublicKey().id() else {
return nil
}
return keyID.hex.lowercased()
}
private func stopMDNS() { private func stopMDNS() {
// Gracefully stop each published service // Gracefully stop each published service
for service in mdnsServices { for service in mdnsServices {
@@ -274,6 +362,12 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) { func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) {
incomingConnections.removeValue(forKey: connection.id) incomingConnections.removeValue(forKey: connection.id)
// Control transfers suppress normal success callbacks, but
// still forward failures so pending consent/error UI can be cleaned up.
if connection.isControlTransfer, error == nil {
return
}
if !connection.wasUserRejected { if !connection.wasUserRejected {
inboundAppDelegates.forEach { delegate in 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, error: error)
@@ -281,6 +375,12 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
} }
func notificationSyncSetupConfirmed(connection _: InboundNearbyConnection) {
inboundAppDelegates.forEach { delegate in
delegate.notificationSyncSetupConfirmed()
}
}
func showPlusScreen() { func showPlusScreen() {
inboundAppDelegates.forEach { delegate in inboundAppDelegates.forEach { delegate in
@@ -296,11 +396,16 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
public func submitUserConsent(transferID: String, accept: Bool, trustDevice: Bool) { public func submitUserConsent(
transferID: String,
accept: Bool,
trustDevice: Bool,
pairingToken: String? = nil
) {
guard let conn = incomingConnections[transferID] else { return } guard let conn = incomingConnections[transferID] else { return }
log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept)") log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept)")
conn.submitUserConsent(accepted: accept, trustDevice: trustDevice) conn.submitUserConsent(accepted: accept, trustDevice: trustDevice, pairingToken: pairingToken)
} }
@@ -414,6 +519,12 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
} }
public func cancelAllIncomingConnections() {
let incomingTransfers = Array(incomingConnections.values)
for transfer in incomingTransfers {
transfer.cancel()
}
}
private func endpointID(for service: NWBrowser.Result) -> String? { private func endpointID(for service: NWBrowser.Result) -> String? {
guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _) = service.endpoint else { return nil } guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _) = service.endpoint else { return nil }
@@ -439,24 +550,24 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
var foundService = FoundServiceInfo(service: service) var foundService = FoundServiceInfo(service: service)
guard case let NWBrowser.Result.Metadata.bonjour(txtRecord) = service.metadata else { return } 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 let endpointInfoData = Data.dataFromUrlSafeBase64(endpointInfoEncoded) else { return }
guard var endpointInfo = EndpointInfo(data: endpointInfoData) else { return } var endpointInfo = EndpointInfo(data: endpointInfoData)
var deviceInfo: RemoteDeviceInfo? ?? EndpointInfo(name: unnamedAndroidFallbackDeviceName, deviceType: .unknown)
if let _ = endpointInfo.name { let cleanedName = endpointInfo.name?.trimmingCharacters(in: .whitespacesAndNewlines)
deviceInfo = addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID) if cleanedName?.isEmpty != false {
endpointInfo.name = unnamedAndroidFallbackDeviceName
} }
let deviceInfo = addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID)
if let qrData = endpointInfo.qrCodeData, let qrCodeAdvertisingToken = qrCodeAdvertisingToken, let qrCodeNameEncryptionKey = qrCodeNameEncryptionKey { if let qrData = endpointInfo.qrCodeData, let qrCodeAdvertisingToken = qrCodeAdvertisingToken, let qrCodeNameEncryptionKey = qrCodeNameEncryptionKey {
log("[NearbyConnectionManager] Device has QR data: \(qrData.base64EncodedString()), advertising token is \(qrCodeAdvertisingToken.base64EncodedString())") log("[NearbyConnectionManager] Device has QR data: \(qrData.base64EncodedString()), advertising token is \(qrCodeAdvertisingToken.base64EncodedString())")
if qrData == qrCodeAdvertisingToken { if qrData == qrCodeAdvertisingToken {
if let deviceInfo = deviceInfo { for delegate in outboundAppDelegates {
for delegate in outboundAppDelegates { delegate.startTransferWithQrCode(device: deviceInfo)
delegate.startTransferWithQrCode(device: deviceInfo)
}
} }
} }
else if qrData.count > 28 { else if qrData.count > 28 {
@@ -478,10 +589,17 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
private func addFoundDevice(foundService: inout FoundServiceInfo, endpointInfo: EndpointInfo, endpointID: String) -> RemoteDeviceInfo { private func addFoundDevice(foundService: inout FoundServiceInfo, endpointInfo: EndpointInfo, endpointID: String) -> RemoteDeviceInfo {
let hadPreviousDevice = foundServices[endpointID]?.device != nil
let deviceInfo = RemoteDeviceInfo(info: endpointInfo, id: endpointID) let deviceInfo = RemoteDeviceInfo(info: endpointInfo, id: endpointID)
foundService.device = deviceInfo foundService.device = deviceInfo
foundServices[endpointID] = foundService foundServices[endpointID] = foundService
if hadPreviousDevice {
for delegate in outboundAppDelegates {
delegate.removeDevice(id: endpointID)
}
}
for delegate in outboundAppDelegates { for delegate in outboundAppDelegates {
delegate.addDevice(device: deviceInfo) delegate.addDevice(device: deviceInfo)
} }
@@ -505,19 +623,18 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
let (pubKey, privKey) = domain.makeKeyPair() let (pubKey, privKey) = domain.makeKeyPair()
qrCodePrivateKey = privKey qrCodePrivateKey = privKey
var keyData = Data() var keyData = Data()
keyData.append(contentsOf: [0, 0, 2]) keyData.append(contentsOf: [0, 0, 2])
let keyBytes = Data(pubKey.w.x.asSignedBytes()) keyData.append(Data(pubKey.w.x.asSignedBytes()).ensureLength(length: 32))
// Sometimes, for some keys, there will be a leading zero byte. Strip that, Android really hates it (it breaks the endpoint info)
keyData.append(keyBytes.suffixOfAtMost(numBytes: 32))
let ikm = SymmetricKey(data: keyData) let ikm = SymmetricKey(data: keyData)
qrCodeAdvertisingToken = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputLength: 16).data() qrCodeAdvertisingToken = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputLength: 16).data()
qrCodeNameEncryptionKey = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputLength: 16) qrCodeNameEncryptionKey = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputLength: 16)
do { do {
let qrKey = keyData.urlSafeBase64EncodedString() let qrKey = keyData.urlSafeBase64EncodedString()
let qrCodeImage = try QRCode.build let qrCodeImage = try QRCode.build
@@ -554,6 +671,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
let conn = OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: localUrls, textToSend: textToSend) let conn = OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: localUrls, textToSend: textToSend)
conn.delegate = self conn.delegate = self
conn.qrCodePrivateKey = qrCodePrivateKey conn.qrCodePrivateKey = qrCodePrivateKey
let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate) let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate)
outgoingTransfers[deviceID] = transfer outgoingTransfers[deviceID] = transfer
conn.start() conn.start()
@@ -671,7 +789,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
return securityScopeUrl return securityScopeUrl
} }
if let bookmarkData = Settings.shared.saveFolderBookmark { if let bookmarkData = Settings.sharedInstance.saveFolderBookmark {
var isStale = false var isStale = false
do { do {
@@ -721,6 +839,14 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
// MARK: Callbacks
func informAboutStatus() {
DispatchQueue.main.async {
self.connectionUpdateCallback(self.isConnectedToLocalNetwork && self.startedAdvertising)
}
}
// -- MARK: - Internal Data Model // -- MARK: - Internal Data Model
@@ -147,15 +147,50 @@ class OutboundNearbyConnection: NearbyConnection {
switch currentState { switch currentState {
case .sentPairedKeyEncryption: case .sentPairedKeyEncryption:
try processPairedKeyEncryption(frame: frame) if frame.hasV1, frame.v1.hasPairedKeyEncryption {
try processPairedKeyEncryption(frame: frame)
return
}
if frame.hasV1, frame.v1.hasPairedKeyResult {
log("[OutboundNearbyConnection \(self.id)] Received pairedKeyResult before pairedKeyEncryption; continuing for legacy compatibility.")
try processPairedKeyResult(frame: frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .progressUpdate || frame.v1.type == .response {
log("[OutboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyEncryption.")
return
}
}
throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyEncryption")
case .sentPairedKeyResult: case .sentPairedKeyResult:
try processPairedKeyResult(frame: frame) if frame.hasV1, frame.v1.hasPairedKeyResult {
try processPairedKeyResult(frame: frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyEncryption || frame.v1.type == .progressUpdate || frame.v1.type == .response {
log("[OutboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for pairedKeyResult.")
return
}
}
throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyResult")
case .sentIntroduction: case .sentIntroduction:
try processConsent(frame: frame) if frame.version == .v1, frame.v1.type == .response {
try processConsent(frame: frame)
return
}
if frame.hasV1, frame.v1.hasType {
if frame.v1.type == .pairedKeyEncryption || frame.v1.type == .pairedKeyResult || frame.v1.type == .progressUpdate {
log("[OutboundNearbyConnection \(self.id)] Ignoring duplicate/intermediate transfer setup frame \(frame.v1.type) while waiting for consent.")
return
}
}
throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.type==response")
case .sendingFiles: case .sendingFiles:
break break
default: default:
assertionFailure("[OutboundNearbyConnection \(self.id)] Unexpected state \(currentState)") log("[OutboundNearbyConnection \(self.id)] Unexpected state \(currentState)")
} }
} }
@@ -269,6 +304,12 @@ class OutboundNearbyConnection: NearbyConnection {
private func processConnectionResponse(frame: Location_Nearby_Connections_OfflineFrame) throws { 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.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)] Ignoring keep-alive while waiting for connection response")
return
}
guard frame.v1.type == .connectionResponse else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") } guard frame.v1.type == .connectionResponse else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
+78 -5
View File
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import UniformTypeIdentifiers
import Network import Network
import CryptoKit import CryptoKit
import SwiftECC import SwiftECC
@@ -18,6 +19,7 @@ protocol InboundNearbyConnectionDelegate {
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double) func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double)
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?)
func showPlusScreen() func showPlusScreen()
func notificationSyncSetupConfirmed(connection: InboundNearbyConnection)
} }
@@ -36,6 +38,11 @@ public protocol InboundAppDelegate: AnyObject {
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: Error?) func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: Error?)
func transferProgress(connectionID: String, progress: Double) func transferProgress(connectionID: String, progress: Double)
func showPlusScreen() func showPlusScreen()
func notificationSyncSetupConfirmed()
}
extension InboundAppDelegate {
func notificationSyncSetupConfirmed() {}
} }
@@ -137,31 +144,71 @@ public struct TransferMetadata {
case file case file
case text case text
case url case url
case wifiPassword
case notificationSync
} }
private func getSummary() -> String { private func getSummary() -> String {
if let textTitle = textDescription { if let textTitle = textDescription {
return textTitle return textTitle
} else if files.count == 1 { } else if files.count == 1 {
return files[0].name return fileTypeSummary(for: files[0])
} else { } else {
return String.localizedStringWithFormat("NFiles".localized(), files.count) return String.localizedStringWithFormat("NFiles".localized(), files.count)
} }
} }
public func getDescription(deviceName: String, alreadyAccepted: Bool) -> String { private func fileTypeSummary(for file: FileMetadata) -> String {
let mimeType = file.mimeType.lowercased()
let ext = URL(fileURLWithPath: file.name).pathExtension.lowercased()
let type = UTType(mimeType: mimeType) ?? UTType(filenameExtension: ext)
if let type, type.conforms(to: .pdf) {
return UTType.pdf.localizedDescription ?? "PDF"
}
if let type, type.conforms(to: .image) {
return UTType.image.localizedDescription ?? "Image"
}
if let type, type.conforms(to: .video) || type.conforms(to: .movie) {
return UTType.video.localizedDescription ?? "Video"
}
if let type, type.conforms(to: .audio) {
return UTType.audio.localizedDescription ?? "Audio"
}
if let type, type.conforms(to: .text) {
return UTType.text.localizedDescription ?? "Text"
}
if let type, type.conforms(to: .archive) {
return UTType.archive.localizedDescription ?? "Archive"
}
if let type, let description = type.localizedDescription {
return description
}
return "File"
}
public func getDescription(deviceName: String) -> String {
let fileStr = getSummary() let fileStr = getSummary()
switch type { switch type {
case .file: case .file:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [deviceName, fileStr]) return "DeviceSendingFiles".localized(with: fileStr, deviceName)
case .text: case .text:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [deviceName, fileStr]) return "DeviceSendingText".localized(with: deviceName)
case .url: case .url:
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingUrl" : "DeviceSendingUrl").localized(), arguments: [deviceName, fileStr]) return "DeviceSendingUrl".localized(with: deviceName)
case .wifiPassword:
return "DeviceSendingWifiPassword".localized(with: deviceName)
case .notificationSync:
if let textDescription, !textDescription.isEmpty {
return textDescription
}
return "NotificationSyncConsentPromptFromDevice".localized(with: deviceName)
} }
} }
@@ -170,6 +217,31 @@ public struct TransferMetadata {
} }
} }
enum PairingUseCase: String {
case notificationSync = "notification_sync"
case clipboardSync = "clipboard_sync"
var protoValue: Sharing_Nearby_PairingMetadata.UseCase {
switch self {
case .notificationSync:
return .notificationSync
case .clipboardSync:
return .clipboardSync
}
}
init?(protoValue: Sharing_Nearby_PairingMetadata.UseCase) {
switch protoValue {
case .notificationSync:
self = .notificationSync
case .clipboardSync:
self = .clipboardSync
case .unknown:
return nil
}
}
}
public struct FileMetadata { public struct FileMetadata {
public let name: String public let name: String
@@ -289,6 +361,7 @@ public enum NearbyError: Error {
case packetFilterError case packetFilterError
case firewallError case firewallError
case inputOutput case inputOutput
case notificationSyncNotTrusted
case canceled(reason: CancellationReason) case canceled(reason: CancellationReason)
public enum CancellationReason { public enum CancellationReason {
+506 -186
View File
@@ -334,6 +334,151 @@ struct Sharing_Nearby_WifiCredentialsMetadata: Sendable {
fileprivate var _id: Int64? = nil fileprivate var _id: Int64? = nil
} }
/// NEXT_ID=10
struct Sharing_Nearby_MirroredNotificationMetadata: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var version: Int32 {
get {return _version ?? 1}
set {_version = newValue}
}
/// Returns true if `version` has been explicitly set.
var hasVersion: Bool {return self._version != nil}
/// Clears the value of `version`. Subsequent reads from it will return its default value.
mutating func clearVersion() {self._version = nil}
var notificationKey: String {
get {return _notificationKey ?? String()}
set {_notificationKey = newValue}
}
/// Returns true if `notificationKey` has been explicitly set.
var hasNotificationKey: Bool {return self._notificationKey != nil}
/// Clears the value of `notificationKey`. Subsequent reads from it will return its default value.
mutating func clearNotificationKey() {self._notificationKey = nil}
var packageName: String {
get {return _packageName ?? String()}
set {_packageName = newValue}
}
/// Returns true if `packageName` has been explicitly set.
var hasPackageName: Bool {return self._packageName != nil}
/// Clears the value of `packageName`. Subsequent reads from it will return its default value.
mutating func clearPackageName() {self._packageName = nil}
var appName: String {
get {return _appName ?? String()}
set {_appName = newValue}
}
/// Returns true if `appName` has been explicitly set.
var hasAppName: Bool {return self._appName != nil}
/// Clears the value of `appName`. Subsequent reads from it will return its default value.
mutating func clearAppName() {self._appName = nil}
var title: String {
get {return _title ?? String()}
set {_title = newValue}
}
/// Returns true if `title` has been explicitly set.
var hasTitle: Bool {return self._title != nil}
/// Clears the value of `title`. Subsequent reads from it will return its default value.
mutating func clearTitle() {self._title = nil}
var body: String {
get {return _body ?? String()}
set {_body = newValue}
}
/// Returns true if `body` has been explicitly set.
var hasBody: Bool {return self._body != nil}
/// Clears the value of `body`. Subsequent reads from it will return its default value.
mutating func clearBody() {self._body = nil}
var postedAtEpochMs: Int64 {
get {return _postedAtEpochMs ?? 0}
set {_postedAtEpochMs = newValue}
}
/// Returns true if `postedAtEpochMs` has been explicitly set.
var hasPostedAtEpochMs: Bool {return self._postedAtEpochMs != nil}
/// Clears the value of `postedAtEpochMs`. Subsequent reads from it will return its default value.
mutating func clearPostedAtEpochMs() {self._postedAtEpochMs = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
init() {}
fileprivate var _version: Int32? = nil
fileprivate var _notificationKey: String? = nil
fileprivate var _packageName: String? = nil
fileprivate var _appName: String? = nil
fileprivate var _title: String? = nil
fileprivate var _body: String? = nil
fileprivate var _postedAtEpochMs: Int64? = nil
}
/// NEXT_ID=5
struct Sharing_Nearby_PairingMetadata: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages.
var version: Int32 {
get {return _version ?? 1}
set {_version = newValue}
}
/// Returns true if `version` has been explicitly set.
var hasVersion: Bool {return self._version != nil}
/// Clears the value of `version`. Subsequent reads from it will return its default value.
mutating func clearVersion() {self._version = nil}
var useCase: Sharing_Nearby_PairingMetadata.UseCase {
get {return _useCase ?? .unknown}
set {_useCase = newValue}
}
/// Returns true if `useCase` has been explicitly set.
var hasUseCase: Bool {return self._useCase != nil}
/// Clears the value of `useCase`. Subsequent reads from it will return its default value.
mutating func clearUseCase() {self._useCase = nil}
var setupRequest: Bool {
get {return _setupRequest ?? false}
set {_setupRequest = newValue}
}
/// Returns true if `setupRequest` has been explicitly set.
var hasSetupRequest: Bool {return self._setupRequest != nil}
/// Clears the value of `setupRequest`. Subsequent reads from it will return its default value.
mutating func clearSetupRequest() {self._setupRequest = nil}
var setupPinHash: Data {
get {return _setupPinHash ?? Data()}
set {_setupPinHash = newValue}
}
/// Returns true if `setupPinHash` has been explicitly set.
var hasSetupPinHash: Bool {return self._setupPinHash != nil}
/// Clears the value of `setupPinHash`. Subsequent reads from it will return its default value.
mutating func clearSetupPinHash() {self._setupPinHash = nil}
var unknownFields = SwiftProtobuf.UnknownStorage()
enum UseCase: Int, SwiftProtobuf.Enum, Swift.CaseIterable {
case unknown = 0
case notificationSync = 1
case clipboardSync = 2
init() {
self = .unknown
}
}
init() {}
fileprivate var _version: Int32? = nil
fileprivate var _useCase: Sharing_Nearby_PairingMetadata.UseCase? = nil
fileprivate var _setupRequest: Bool? = nil
fileprivate var _setupPinHash: Data? = nil
}
/// NEXT_ID=8 /// NEXT_ID=8
struct Sharing_Nearby_AppMetadata: Sendable { struct Sharing_Nearby_AppMetadata: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the // SwiftProtobuf.Message conformance is added in an extension below. See the
@@ -505,76 +650,76 @@ struct Sharing_Nearby_Frame: Sendable {
} }
/// NEXT_ID=8 /// NEXT_ID=8
struct Sharing_Nearby_V1Frame: @unchecked Sendable { struct Sharing_Nearby_V1Frame: Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the // SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages. // methods supported on all messages.
var type: Sharing_Nearby_V1Frame.FrameType { var type: Sharing_Nearby_V1Frame.FrameType {
get {return _storage._type ?? .unknownFrameType} get {return _type ?? .unknownFrameType}
set {_uniqueStorage()._type = newValue} set {_type = newValue}
} }
/// Returns true if `type` has been explicitly set. /// Returns true if `type` has been explicitly set.
var hasType: Bool {return _storage._type != nil} var hasType: Bool {return self._type != nil}
/// Clears the value of `type`. Subsequent reads from it will return its default value. /// Clears the value of `type`. Subsequent reads from it will return its default value.
mutating func clearType() {_uniqueStorage()._type = nil} mutating func clearType() {self._type = nil}
/// At most one of the following fields will be set. /// At most one of the following fields will be set.
var introduction: Sharing_Nearby_IntroductionFrame { var introduction: Sharing_Nearby_IntroductionFrame {
get {return _storage._introduction ?? Sharing_Nearby_IntroductionFrame()} get {return _introduction ?? Sharing_Nearby_IntroductionFrame()}
set {_uniqueStorage()._introduction = newValue} set {_introduction = newValue}
} }
/// Returns true if `introduction` has been explicitly set. /// Returns true if `introduction` has been explicitly set.
var hasIntroduction: Bool {return _storage._introduction != nil} var hasIntroduction: Bool {return self._introduction != nil}
/// Clears the value of `introduction`. Subsequent reads from it will return its default value. /// Clears the value of `introduction`. Subsequent reads from it will return its default value.
mutating func clearIntroduction() {_uniqueStorage()._introduction = nil} mutating func clearIntroduction() {self._introduction = nil}
var connectionResponse: Sharing_Nearby_ConnectionResponseFrame { var connectionResponse: Sharing_Nearby_ConnectionResponseFrame {
get {return _storage._connectionResponse ?? Sharing_Nearby_ConnectionResponseFrame()} get {return _connectionResponse ?? Sharing_Nearby_ConnectionResponseFrame()}
set {_uniqueStorage()._connectionResponse = newValue} set {_connectionResponse = newValue}
} }
/// Returns true if `connectionResponse` has been explicitly set. /// Returns true if `connectionResponse` has been explicitly set.
var hasConnectionResponse: Bool {return _storage._connectionResponse != nil} var hasConnectionResponse: Bool {return self._connectionResponse != nil}
/// Clears the value of `connectionResponse`. Subsequent reads from it will return its default value. /// Clears the value of `connectionResponse`. Subsequent reads from it will return its default value.
mutating func clearConnectionResponse() {_uniqueStorage()._connectionResponse = nil} mutating func clearConnectionResponse() {self._connectionResponse = nil}
var pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame { var pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame {
get {return _storage._pairedKeyEncryption ?? Sharing_Nearby_PairedKeyEncryptionFrame()} get {return _pairedKeyEncryption ?? Sharing_Nearby_PairedKeyEncryptionFrame()}
set {_uniqueStorage()._pairedKeyEncryption = newValue} set {_pairedKeyEncryption = newValue}
} }
/// Returns true if `pairedKeyEncryption` has been explicitly set. /// Returns true if `pairedKeyEncryption` has been explicitly set.
var hasPairedKeyEncryption: Bool {return _storage._pairedKeyEncryption != nil} var hasPairedKeyEncryption: Bool {return self._pairedKeyEncryption != nil}
/// Clears the value of `pairedKeyEncryption`. Subsequent reads from it will return its default value. /// Clears the value of `pairedKeyEncryption`. Subsequent reads from it will return its default value.
mutating func clearPairedKeyEncryption() {_uniqueStorage()._pairedKeyEncryption = nil} mutating func clearPairedKeyEncryption() {self._pairedKeyEncryption = nil}
var pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame { var pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame {
get {return _storage._pairedKeyResult ?? Sharing_Nearby_PairedKeyResultFrame()} get {return _pairedKeyResult ?? Sharing_Nearby_PairedKeyResultFrame()}
set {_uniqueStorage()._pairedKeyResult = newValue} set {_pairedKeyResult = newValue}
} }
/// Returns true if `pairedKeyResult` has been explicitly set. /// Returns true if `pairedKeyResult` has been explicitly set.
var hasPairedKeyResult: Bool {return _storage._pairedKeyResult != nil} var hasPairedKeyResult: Bool {return self._pairedKeyResult != nil}
/// Clears the value of `pairedKeyResult`. Subsequent reads from it will return its default value. /// Clears the value of `pairedKeyResult`. Subsequent reads from it will return its default value.
mutating func clearPairedKeyResult() {_uniqueStorage()._pairedKeyResult = nil} mutating func clearPairedKeyResult() {self._pairedKeyResult = nil}
/// NOTE: This field was marked as deprecated in the .proto file. /// NOTE: This field was marked as deprecated in the .proto file.
var certificateInfo: Sharing_Nearby_CertificateInfoFrame { var certificateInfo: Sharing_Nearby_CertificateInfoFrame {
get {return _storage._certificateInfo ?? Sharing_Nearby_CertificateInfoFrame()} get {return _certificateInfo ?? Sharing_Nearby_CertificateInfoFrame()}
set {_uniqueStorage()._certificateInfo = newValue} set {_certificateInfo = newValue}
} }
/// Returns true if `certificateInfo` has been explicitly set. /// Returns true if `certificateInfo` has been explicitly set.
var hasCertificateInfo: Bool {return _storage._certificateInfo != nil} var hasCertificateInfo: Bool {return self._certificateInfo != nil}
/// Clears the value of `certificateInfo`. Subsequent reads from it will return its default value. /// Clears the value of `certificateInfo`. Subsequent reads from it will return its default value.
mutating func clearCertificateInfo() {_uniqueStorage()._certificateInfo = nil} mutating func clearCertificateInfo() {self._certificateInfo = nil}
/// NOTE: This field was marked as deprecated in the .proto file. /// NOTE: This field was marked as deprecated in the .proto file.
var progressUpdate: Sharing_Nearby_ProgressUpdateFrame { var progressUpdate: Sharing_Nearby_ProgressUpdateFrame {
get {return _storage._progressUpdate ?? Sharing_Nearby_ProgressUpdateFrame()} get {return _progressUpdate ?? Sharing_Nearby_ProgressUpdateFrame()}
set {_uniqueStorage()._progressUpdate = newValue} set {_progressUpdate = newValue}
} }
/// Returns true if `progressUpdate` has been explicitly set. /// Returns true if `progressUpdate` has been explicitly set.
var hasProgressUpdate: Bool {return _storage._progressUpdate != nil} var hasProgressUpdate: Bool {return self._progressUpdate != nil}
/// Clears the value of `progressUpdate`. Subsequent reads from it will return its default value. /// Clears the value of `progressUpdate`. Subsequent reads from it will return its default value.
mutating func clearProgressUpdate() {_uniqueStorage()._progressUpdate = nil} mutating func clearProgressUpdate() {self._progressUpdate = nil}
var unknownFields = SwiftProtobuf.UnknownStorage() var unknownFields = SwiftProtobuf.UnknownStorage()
@@ -600,56 +745,98 @@ struct Sharing_Nearby_V1Frame: @unchecked Sendable {
init() {} init() {}
fileprivate var _storage = _StorageClass.defaultInstance fileprivate var _type: Sharing_Nearby_V1Frame.FrameType? = nil
fileprivate var _introduction: Sharing_Nearby_IntroductionFrame? = nil
fileprivate var _connectionResponse: Sharing_Nearby_ConnectionResponseFrame? = nil
fileprivate var _pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame? = nil
fileprivate var _pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame? = nil
fileprivate var _certificateInfo: Sharing_Nearby_CertificateInfoFrame? = nil
fileprivate var _progressUpdate: Sharing_Nearby_ProgressUpdateFrame? = nil
} }
/// An introduction packet sent by the sending side. Contains a list of files /// An introduction packet sent by the sending side. Contains a list of files
/// they'd like to share. /// they'd like to share.
/// NEXT_ID=10 /// NEXT_ID=1003
struct Sharing_Nearby_IntroductionFrame: Sendable { struct Sharing_Nearby_IntroductionFrame: @unchecked Sendable {
// SwiftProtobuf.Message conformance is added in an extension below. See the // SwiftProtobuf.Message conformance is added in an extension below. See the
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for // `Message` and `Message+*Additions` files in the SwiftProtobuf library for
// methods supported on all messages. // methods supported on all messages.
var fileMetadata: [Sharing_Nearby_FileMetadata] = [] var fileMetadata: [Sharing_Nearby_FileMetadata] {
get {return _storage._fileMetadata}
set {_uniqueStorage()._fileMetadata = newValue}
}
var textMetadata: [Sharing_Nearby_TextMetadata] = [] var textMetadata: [Sharing_Nearby_TextMetadata] {
get {return _storage._textMetadata}
set {_uniqueStorage()._textMetadata = newValue}
}
/// The required app package to open the content. May be null. /// The required app package to open the content. May be null.
var requiredPackage: String { var requiredPackage: String {
get {return _requiredPackage ?? String()} get {return _storage._requiredPackage ?? String()}
set {_requiredPackage = newValue} set {_uniqueStorage()._requiredPackage = newValue}
} }
/// Returns true if `requiredPackage` has been explicitly set. /// Returns true if `requiredPackage` has been explicitly set.
var hasRequiredPackage: Bool {return self._requiredPackage != nil} var hasRequiredPackage: Bool {return _storage._requiredPackage != nil}
/// Clears the value of `requiredPackage`. Subsequent reads from it will return its default value. /// Clears the value of `requiredPackage`. Subsequent reads from it will return its default value.
mutating func clearRequiredPackage() {self._requiredPackage = nil} mutating func clearRequiredPackage() {_uniqueStorage()._requiredPackage = nil}
var wifiCredentialsMetadata: [Sharing_Nearby_WifiCredentialsMetadata] = [] var wifiCredentialsMetadata: [Sharing_Nearby_WifiCredentialsMetadata] {
get {return _storage._wifiCredentialsMetadata}
set {_uniqueStorage()._wifiCredentialsMetadata = newValue}
}
var appMetadata: [Sharing_Nearby_AppMetadata] = [] var appMetadata: [Sharing_Nearby_AppMetadata] {
get {return _storage._appMetadata}
set {_uniqueStorage()._appMetadata = newValue}
}
var startTransfer: Bool { var startTransfer: Bool {
get {return _startTransfer ?? false} get {return _storage._startTransfer ?? false}
set {_startTransfer = newValue} set {_uniqueStorage()._startTransfer = newValue}
} }
/// Returns true if `startTransfer` has been explicitly set. /// Returns true if `startTransfer` has been explicitly set.
var hasStartTransfer: Bool {return self._startTransfer != nil} var hasStartTransfer: Bool {return _storage._startTransfer != nil}
/// Clears the value of `startTransfer`. Subsequent reads from it will return its default value. /// Clears the value of `startTransfer`. Subsequent reads from it will return its default value.
mutating func clearStartTransfer() {self._startTransfer = nil} mutating func clearStartTransfer() {_uniqueStorage()._startTransfer = nil}
var streamMetadata: [Sharing_Nearby_StreamMetadata] = [] var streamMetadata: [Sharing_Nearby_StreamMetadata] {
get {return _storage._streamMetadata}
set {_uniqueStorage()._streamMetadata = newValue}
}
var useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase { var useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase {
get {return _useCase ?? .unknown} get {return _storage._useCase ?? .unknown}
set {_useCase = newValue} set {_uniqueStorage()._useCase = newValue}
} }
/// Returns true if `useCase` has been explicitly set. /// Returns true if `useCase` has been explicitly set.
var hasUseCase: Bool {return self._useCase != nil} var hasUseCase: Bool {return _storage._useCase != nil}
/// Clears the value of `useCase`. Subsequent reads from it will return its default value. /// Clears the value of `useCase`. Subsequent reads from it will return its default value.
mutating func clearUseCase() {self._useCase = nil} mutating func clearUseCase() {_uniqueStorage()._useCase = nil}
var previewPayloadIds: [Int64] = [] var previewPayloadIds: [Int64] {
get {return _storage._previewPayloadIds}
set {_uniqueStorage()._previewPayloadIds = newValue}
}
var mirroredNotificationMetadata: Sharing_Nearby_MirroredNotificationMetadata {
get {return _storage._mirroredNotificationMetadata ?? Sharing_Nearby_MirroredNotificationMetadata()}
set {_uniqueStorage()._mirroredNotificationMetadata = newValue}
}
/// Returns true if `mirroredNotificationMetadata` has been explicitly set.
var hasMirroredNotificationMetadata: Bool {return _storage._mirroredNotificationMetadata != nil}
/// Clears the value of `mirroredNotificationMetadata`. Subsequent reads from it will return its default value.
mutating func clearMirroredNotificationMetadata() {_uniqueStorage()._mirroredNotificationMetadata = nil}
var pairingMetadata: Sharing_Nearby_PairingMetadata {
get {return _storage._pairingMetadata ?? Sharing_Nearby_PairingMetadata()}
set {_uniqueStorage()._pairingMetadata = newValue}
}
/// Returns true if `pairingMetadata` has been explicitly set.
var hasPairingMetadata: Bool {return _storage._pairingMetadata != nil}
/// Clears the value of `pairingMetadata`. Subsequent reads from it will return its default value.
mutating func clearPairingMetadata() {_uniqueStorage()._pairingMetadata = nil}
var unknownFields = SwiftProtobuf.UnknownStorage() var unknownFields = SwiftProtobuf.UnknownStorage()
@@ -666,9 +853,7 @@ struct Sharing_Nearby_IntroductionFrame: Sendable {
init() {} init() {}
fileprivate var _requiredPackage: String? = nil fileprivate var _storage = _StorageClass.defaultInstance
fileprivate var _startTransfer: Bool? = nil
fileprivate var _useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase? = nil
} }
/// A progress update packet sent by the sending side. Contains transfer progress /// A progress update packet sent by the sending side. Contains transfer progress
@@ -1371,6 +1556,123 @@ extension Sharing_Nearby_WifiCredentialsMetadata.SecurityType: SwiftProtobuf._Pr
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_SECURITY_TYPE\0\u{1}OPEN\0\u{1}WPA_PSK\0\u{1}WEP\0\u{1}SAE\0") static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_SECURITY_TYPE\0\u{1}OPEN\0\u{1}WPA_PSK\0\u{1}WEP\0\u{1}SAE\0")
} }
extension Sharing_Nearby_MirroredNotificationMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".MirroredNotificationMetadata"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{3}notification_key\0\u{3}package_name\0\u{3}app_name\0\u{1}title\0\u{1}body\0\u{3}posted_at_epoch_ms\0\u{c}\u{8}\u{1}\u{c}\u{9}\u{1}")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt32Field(value: &self._version) }()
case 2: try { try decoder.decodeSingularStringField(value: &self._notificationKey) }()
case 3: try { try decoder.decodeSingularStringField(value: &self._packageName) }()
case 4: try { try decoder.decodeSingularStringField(value: &self._appName) }()
case 5: try { try decoder.decodeSingularStringField(value: &self._title) }()
case 6: try { try decoder.decodeSingularStringField(value: &self._body) }()
case 7: try { try decoder.decodeSingularInt64Field(value: &self._postedAtEpochMs) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._version {
try visitor.visitSingularInt32Field(value: v, fieldNumber: 1)
} }()
try { if let v = self._notificationKey {
try visitor.visitSingularStringField(value: v, fieldNumber: 2)
} }()
try { if let v = self._packageName {
try visitor.visitSingularStringField(value: v, fieldNumber: 3)
} }()
try { if let v = self._appName {
try visitor.visitSingularStringField(value: v, fieldNumber: 4)
} }()
try { if let v = self._title {
try visitor.visitSingularStringField(value: v, fieldNumber: 5)
} }()
try { if let v = self._body {
try visitor.visitSingularStringField(value: v, fieldNumber: 6)
} }()
try { if let v = self._postedAtEpochMs {
try visitor.visitSingularInt64Field(value: v, fieldNumber: 7)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_MirroredNotificationMetadata, rhs: Sharing_Nearby_MirroredNotificationMetadata) -> Bool {
if lhs._version != rhs._version {return false}
if lhs._notificationKey != rhs._notificationKey {return false}
if lhs._packageName != rhs._packageName {return false}
if lhs._appName != rhs._appName {return false}
if lhs._title != rhs._title {return false}
if lhs._body != rhs._body {return false}
if lhs._postedAtEpochMs != rhs._postedAtEpochMs {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_PairingMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".PairingMetadata"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}version\0\u{3}use_case\0\u{3}setup_request\0\u{3}setup_pin_hash\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularInt32Field(value: &self._version) }()
case 2: try { try decoder.decodeSingularEnumField(value: &self._useCase) }()
case 3: try { try decoder.decodeSingularBoolField(value: &self._setupRequest) }()
case 4: try { try decoder.decodeSingularBytesField(value: &self._setupPinHash) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._version {
try visitor.visitSingularInt32Field(value: v, fieldNumber: 1)
} }()
try { if let v = self._useCase {
try visitor.visitSingularEnumField(value: v, fieldNumber: 2)
} }()
try { if let v = self._setupRequest {
try visitor.visitSingularBoolField(value: v, fieldNumber: 3)
} }()
try { if let v = self._setupPinHash {
try visitor.visitSingularBytesField(value: v, fieldNumber: 4)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_PairingMetadata, rhs: Sharing_Nearby_PairingMetadata) -> Bool {
if lhs._version != rhs._version {return false}
if lhs._useCase != rhs._useCase {return false}
if lhs._setupRequest != rhs._setupRequest {return false}
if lhs._setupPinHash != rhs._setupPinHash {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_PairingMetadata.UseCase: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN\0\u{1}NOTIFICATION_SYNC\0\u{1}CLIPBOARD_SYNC\0")
}
extension Sharing_Nearby_AppMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { extension Sharing_Nearby_AppMetadata: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".AppMetadata" static let protoMessageName: String = _protobuf_package + ".AppMetadata"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}app_name\0\u{1}size\0\u{3}payload_id\0\u{1}id\0\u{3}file_name\0\u{3}file_size\0\u{3}package_name\0") static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}app_name\0\u{1}size\0\u{3}payload_id\0\u{1}id\0\u{3}file_name\0\u{3}file_size\0\u{3}package_name\0")
@@ -1531,14 +1833,86 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
static let protoMessageName: String = _protobuf_package + ".V1Frame" static let protoMessageName: String = _protobuf_package + ".V1Frame"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}introduction\0\u{3}connection_response\0\u{3}paired_key_encryption\0\u{3}paired_key_result\0\u{3}certificate_info\0\u{3}progress_update\0") static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}introduction\0\u{3}connection_response\0\u{3}paired_key_encryption\0\u{3}paired_key_result\0\u{3}certificate_info\0\u{3}progress_update\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeSingularEnumField(value: &self._type) }()
case 2: try { try decoder.decodeSingularMessageField(value: &self._introduction) }()
case 3: try { try decoder.decodeSingularMessageField(value: &self._connectionResponse) }()
case 4: try { try decoder.decodeSingularMessageField(value: &self._pairedKeyEncryption) }()
case 5: try { try decoder.decodeSingularMessageField(value: &self._pairedKeyResult) }()
case 6: try { try decoder.decodeSingularMessageField(value: &self._certificateInfo) }()
case 7: try { try decoder.decodeSingularMessageField(value: &self._progressUpdate) }()
default: break
}
}
}
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182
try { if let v = self._type {
try visitor.visitSingularEnumField(value: v, fieldNumber: 1)
} }()
try { if let v = self._introduction {
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
} }()
try { if let v = self._connectionResponse {
try visitor.visitSingularMessageField(value: v, fieldNumber: 3)
} }()
try { if let v = self._pairedKeyEncryption {
try visitor.visitSingularMessageField(value: v, fieldNumber: 4)
} }()
try { if let v = self._pairedKeyResult {
try visitor.visitSingularMessageField(value: v, fieldNumber: 5)
} }()
try { if let v = self._certificateInfo {
try visitor.visitSingularMessageField(value: v, fieldNumber: 6)
} }()
try { if let v = self._progressUpdate {
try visitor.visitSingularMessageField(value: v, fieldNumber: 7)
} }()
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_V1Frame, rhs: Sharing_Nearby_V1Frame) -> Bool {
if lhs._type != rhs._type {return false}
if lhs._introduction != rhs._introduction {return false}
if lhs._connectionResponse != rhs._connectionResponse {return false}
if lhs._pairedKeyEncryption != rhs._pairedKeyEncryption {return false}
if lhs._pairedKeyResult != rhs._pairedKeyResult {return false}
if lhs._certificateInfo != rhs._certificateInfo {return false}
if lhs._progressUpdate != rhs._progressUpdate {return false}
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_V1Frame.FrameType: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_FRAME_TYPE\0\u{1}INTRODUCTION\0\u{1}RESPONSE\0\u{1}PAIRED_KEY_ENCRYPTION\0\u{1}PAIRED_KEY_RESULT\0\u{1}CERTIFICATE_INFO\0\u{1}CANCEL\0\u{1}PROGRESS_UPDATE\0")
}
extension Sharing_Nearby_IntroductionFrame: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".IntroductionFrame"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}file_metadata\0\u{3}text_metadata\0\u{3}required_package\0\u{3}wifi_credentials_metadata\0\u{3}app_metadata\0\u{3}start_transfer\0\u{3}stream_metadata\0\u{3}use_case\0\u{3}preview_payload_ids\0\u{4}`\u{f}mirrored_notification_metadata\0\u{3}pairing_metadata\0")
fileprivate class _StorageClass { fileprivate class _StorageClass {
var _type: Sharing_Nearby_V1Frame.FrameType? = nil var _fileMetadata: [Sharing_Nearby_FileMetadata] = []
var _introduction: Sharing_Nearby_IntroductionFrame? = nil var _textMetadata: [Sharing_Nearby_TextMetadata] = []
var _connectionResponse: Sharing_Nearby_ConnectionResponseFrame? = nil var _requiredPackage: String? = nil
var _pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame? = nil var _wifiCredentialsMetadata: [Sharing_Nearby_WifiCredentialsMetadata] = []
var _pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame? = nil var _appMetadata: [Sharing_Nearby_AppMetadata] = []
var _certificateInfo: Sharing_Nearby_CertificateInfoFrame? = nil var _startTransfer: Bool? = nil
var _progressUpdate: Sharing_Nearby_ProgressUpdateFrame? = nil var _streamMetadata: [Sharing_Nearby_StreamMetadata] = []
var _useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase? = nil
var _previewPayloadIds: [Int64] = []
var _mirroredNotificationMetadata: Sharing_Nearby_MirroredNotificationMetadata? = nil
var _pairingMetadata: Sharing_Nearby_PairingMetadata? = nil
// This property is used as the initial default value for new instances of the type. // This property is used as the initial default value for new instances of the type.
// The type itself is protecting the reference to its storage via CoW semantics. // The type itself is protecting the reference to its storage via CoW semantics.
@@ -1549,13 +1923,17 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
private init() {} private init() {}
init(copying source: _StorageClass) { init(copying source: _StorageClass) {
_type = source._type _fileMetadata = source._fileMetadata
_introduction = source._introduction _textMetadata = source._textMetadata
_connectionResponse = source._connectionResponse _requiredPackage = source._requiredPackage
_pairedKeyEncryption = source._pairedKeyEncryption _wifiCredentialsMetadata = source._wifiCredentialsMetadata
_pairedKeyResult = source._pairedKeyResult _appMetadata = source._appMetadata
_certificateInfo = source._certificateInfo _startTransfer = source._startTransfer
_progressUpdate = source._progressUpdate _streamMetadata = source._streamMetadata
_useCase = source._useCase
_previewPayloadIds = source._previewPayloadIds
_mirroredNotificationMetadata = source._mirroredNotificationMetadata
_pairingMetadata = source._pairingMetadata
} }
} }
@@ -1574,13 +1952,17 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
// allocates stack space for every case branch when no optimizations are // allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034 // enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber { switch fieldNumber {
case 1: try { try decoder.decodeSingularEnumField(value: &_storage._type) }() case 1: try { try decoder.decodeRepeatedMessageField(value: &_storage._fileMetadata) }()
case 2: try { try decoder.decodeSingularMessageField(value: &_storage._introduction) }() case 2: try { try decoder.decodeRepeatedMessageField(value: &_storage._textMetadata) }()
case 3: try { try decoder.decodeSingularMessageField(value: &_storage._connectionResponse) }() case 3: try { try decoder.decodeSingularStringField(value: &_storage._requiredPackage) }()
case 4: try { try decoder.decodeSingularMessageField(value: &_storage._pairedKeyEncryption) }() case 4: try { try decoder.decodeRepeatedMessageField(value: &_storage._wifiCredentialsMetadata) }()
case 5: try { try decoder.decodeSingularMessageField(value: &_storage._pairedKeyResult) }() case 5: try { try decoder.decodeRepeatedMessageField(value: &_storage._appMetadata) }()
case 6: try { try decoder.decodeSingularMessageField(value: &_storage._certificateInfo) }() case 6: try { try decoder.decodeSingularBoolField(value: &_storage._startTransfer) }()
case 7: try { try decoder.decodeSingularMessageField(value: &_storage._progressUpdate) }() case 7: try { try decoder.decodeRepeatedMessageField(value: &_storage._streamMetadata) }()
case 8: try { try decoder.decodeSingularEnumField(value: &_storage._useCase) }()
case 9: try { try decoder.decodeRepeatedInt64Field(value: &_storage._previewPayloadIds) }()
case 1001: try { try decoder.decodeSingularMessageField(value: &_storage._mirroredNotificationMetadata) }()
case 1002: try { try decoder.decodeSingularMessageField(value: &_storage._pairingMetadata) }()
default: break default: break
} }
} }
@@ -1593,125 +1975,63 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
// allocates stack space for every if/case branch local when no optimizations // allocates stack space for every if/case branch local when no optimizations
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
// https://github.com/apple/swift-protobuf/issues/1182 // https://github.com/apple/swift-protobuf/issues/1182
try { if let v = _storage._type { if !_storage._fileMetadata.isEmpty {
try visitor.visitSingularEnumField(value: v, fieldNumber: 1) try visitor.visitRepeatedMessageField(value: _storage._fileMetadata, fieldNumber: 1)
} }()
try { if let v = _storage._introduction {
try visitor.visitSingularMessageField(value: v, fieldNumber: 2)
} }()
try { if let v = _storage._connectionResponse {
try visitor.visitSingularMessageField(value: v, fieldNumber: 3)
} }()
try { if let v = _storage._pairedKeyEncryption {
try visitor.visitSingularMessageField(value: v, fieldNumber: 4)
} }()
try { if let v = _storage._pairedKeyResult {
try visitor.visitSingularMessageField(value: v, fieldNumber: 5)
} }()
try { if let v = _storage._certificateInfo {
try visitor.visitSingularMessageField(value: v, fieldNumber: 6)
} }()
try { if let v = _storage._progressUpdate {
try visitor.visitSingularMessageField(value: v, fieldNumber: 7)
} }()
}
try unknownFields.traverse(visitor: &visitor)
}
static func ==(lhs: Sharing_Nearby_V1Frame, rhs: Sharing_Nearby_V1Frame) -> Bool {
if lhs._storage !== rhs._storage {
let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in
let _storage = _args.0
let rhs_storage = _args.1
if _storage._type != rhs_storage._type {return false}
if _storage._introduction != rhs_storage._introduction {return false}
if _storage._connectionResponse != rhs_storage._connectionResponse {return false}
if _storage._pairedKeyEncryption != rhs_storage._pairedKeyEncryption {return false}
if _storage._pairedKeyResult != rhs_storage._pairedKeyResult {return false}
if _storage._certificateInfo != rhs_storage._certificateInfo {return false}
if _storage._progressUpdate != rhs_storage._progressUpdate {return false}
return true
} }
if !storagesAreEqual {return false} if !_storage._textMetadata.isEmpty {
} try visitor.visitRepeatedMessageField(value: _storage._textMetadata, fieldNumber: 2)
if lhs.unknownFields != rhs.unknownFields {return false}
return true
}
}
extension Sharing_Nearby_V1Frame.FrameType: SwiftProtobuf._ProtoNameProviding {
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0UNKNOWN_FRAME_TYPE\0\u{1}INTRODUCTION\0\u{1}RESPONSE\0\u{1}PAIRED_KEY_ENCRYPTION\0\u{1}PAIRED_KEY_RESULT\0\u{1}CERTIFICATE_INFO\0\u{1}CANCEL\0\u{1}PROGRESS_UPDATE\0")
}
extension Sharing_Nearby_IntroductionFrame: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
static let protoMessageName: String = _protobuf_package + ".IntroductionFrame"
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}file_metadata\0\u{3}text_metadata\0\u{3}required_package\0\u{3}wifi_credentials_metadata\0\u{3}app_metadata\0\u{3}start_transfer\0\u{3}stream_metadata\0\u{3}use_case\0\u{3}preview_payload_ids\0")
mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
while let fieldNumber = try decoder.nextFieldNumber() {
// The use of inline closures is to circumvent an issue where the compiler
// allocates stack space for every case branch when no optimizations are
// enabled. https://github.com/apple/swift-protobuf/issues/1034
switch fieldNumber {
case 1: try { try decoder.decodeRepeatedMessageField(value: &self.fileMetadata) }()
case 2: try { try decoder.decodeRepeatedMessageField(value: &self.textMetadata) }()
case 3: try { try decoder.decodeSingularStringField(value: &self._requiredPackage) }()
case 4: try { try decoder.decodeRepeatedMessageField(value: &self.wifiCredentialsMetadata) }()
case 5: try { try decoder.decodeRepeatedMessageField(value: &self.appMetadata) }()
case 6: try { try decoder.decodeSingularBoolField(value: &self._startTransfer) }()
case 7: try { try decoder.decodeRepeatedMessageField(value: &self.streamMetadata) }()
case 8: try { try decoder.decodeSingularEnumField(value: &self._useCase) }()
case 9: try { try decoder.decodeRepeatedInt64Field(value: &self.previewPayloadIds) }()
default: break
} }
} try { if let v = _storage._requiredPackage {
} try visitor.visitSingularStringField(value: v, fieldNumber: 3)
} }()
func traverse<V: SwiftProtobuf.Visitor>(visitor: inout V) throws { if !_storage._wifiCredentialsMetadata.isEmpty {
// The use of inline closures is to circumvent an issue where the compiler try visitor.visitRepeatedMessageField(value: _storage._wifiCredentialsMetadata, fieldNumber: 4)
// allocates stack space for every if/case branch local when no optimizations }
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and if !_storage._appMetadata.isEmpty {
// https://github.com/apple/swift-protobuf/issues/1182 try visitor.visitRepeatedMessageField(value: _storage._appMetadata, fieldNumber: 5)
if !self.fileMetadata.isEmpty { }
try visitor.visitRepeatedMessageField(value: self.fileMetadata, fieldNumber: 1) try { if let v = _storage._startTransfer {
} try visitor.visitSingularBoolField(value: v, fieldNumber: 6)
if !self.textMetadata.isEmpty { } }()
try visitor.visitRepeatedMessageField(value: self.textMetadata, fieldNumber: 2) if !_storage._streamMetadata.isEmpty {
} try visitor.visitRepeatedMessageField(value: _storage._streamMetadata, fieldNumber: 7)
try { if let v = self._requiredPackage { }
try visitor.visitSingularStringField(value: v, fieldNumber: 3) try { if let v = _storage._useCase {
} }() try visitor.visitSingularEnumField(value: v, fieldNumber: 8)
if !self.wifiCredentialsMetadata.isEmpty { } }()
try visitor.visitRepeatedMessageField(value: self.wifiCredentialsMetadata, fieldNumber: 4) if !_storage._previewPayloadIds.isEmpty {
} try visitor.visitRepeatedInt64Field(value: _storage._previewPayloadIds, fieldNumber: 9)
if !self.appMetadata.isEmpty { }
try visitor.visitRepeatedMessageField(value: self.appMetadata, fieldNumber: 5) try { if let v = _storage._mirroredNotificationMetadata {
} try visitor.visitSingularMessageField(value: v, fieldNumber: 1001)
try { if let v = self._startTransfer { } }()
try visitor.visitSingularBoolField(value: v, fieldNumber: 6) try { if let v = _storage._pairingMetadata {
} }() try visitor.visitSingularMessageField(value: v, fieldNumber: 1002)
if !self.streamMetadata.isEmpty { } }()
try visitor.visitRepeatedMessageField(value: self.streamMetadata, fieldNumber: 7)
}
try { if let v = self._useCase {
try visitor.visitSingularEnumField(value: v, fieldNumber: 8)
} }()
if !self.previewPayloadIds.isEmpty {
try visitor.visitRepeatedInt64Field(value: self.previewPayloadIds, fieldNumber: 9)
} }
try unknownFields.traverse(visitor: &visitor) try unknownFields.traverse(visitor: &visitor)
} }
static func ==(lhs: Sharing_Nearby_IntroductionFrame, rhs: Sharing_Nearby_IntroductionFrame) -> Bool { static func ==(lhs: Sharing_Nearby_IntroductionFrame, rhs: Sharing_Nearby_IntroductionFrame) -> Bool {
if lhs.fileMetadata != rhs.fileMetadata {return false} if lhs._storage !== rhs._storage {
if lhs.textMetadata != rhs.textMetadata {return false} let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in
if lhs._requiredPackage != rhs._requiredPackage {return false} let _storage = _args.0
if lhs.wifiCredentialsMetadata != rhs.wifiCredentialsMetadata {return false} let rhs_storage = _args.1
if lhs.appMetadata != rhs.appMetadata {return false} if _storage._fileMetadata != rhs_storage._fileMetadata {return false}
if lhs._startTransfer != rhs._startTransfer {return false} if _storage._textMetadata != rhs_storage._textMetadata {return false}
if lhs.streamMetadata != rhs.streamMetadata {return false} if _storage._requiredPackage != rhs_storage._requiredPackage {return false}
if lhs._useCase != rhs._useCase {return false} if _storage._wifiCredentialsMetadata != rhs_storage._wifiCredentialsMetadata {return false}
if lhs.previewPayloadIds != rhs.previewPayloadIds {return false} if _storage._appMetadata != rhs_storage._appMetadata {return false}
if _storage._startTransfer != rhs_storage._startTransfer {return false}
if _storage._streamMetadata != rhs_storage._streamMetadata {return false}
if _storage._useCase != rhs_storage._useCase {return false}
if _storage._previewPayloadIds != rhs_storage._previewPayloadIds {return false}
if _storage._mirroredNotificationMetadata != rhs_storage._mirroredNotificationMetadata {return false}
if _storage._pairingMetadata != rhs_storage._pairingMetadata {return false}
return true
}
if !storagesAreEqual {return false}
}
if lhs.unknownFields != rhs.unknownFields {return false} if lhs.unknownFields != rhs.unknownFields {return false}
return true return true
} }
@@ -127,6 +127,32 @@ message WifiCredentialsMetadata {
optional int64 id = 5; optional int64 id = 5;
} }
// NEXT_ID=10
message MirroredNotificationMetadata {
optional int32 version = 1 [default = 1];
optional string notification_key = 2;
optional string package_name = 3;
optional string app_name = 4;
optional string title = 5;
optional string body = 6;
optional int64 posted_at_epoch_ms = 7;
reserved 8, 9;
}
// NEXT_ID=5
message PairingMetadata {
enum UseCase {
UNKNOWN = 0;
NOTIFICATION_SYNC = 1;
CLIPBOARD_SYNC = 2;
}
optional int32 version = 1 [default = 1];
optional UseCase use_case = 2 [default = UNKNOWN];
optional bool setup_request = 3;
optional bytes setup_pin_hash = 4;
}
// NEXT_ID=8 // NEXT_ID=8
message AppMetadata { message AppMetadata {
// The app name. This will be sent in introduction. // The app name. This will be sent in introduction.
@@ -212,7 +238,7 @@ message V1Frame {
// An introduction packet sent by the sending side. Contains a list of files // An introduction packet sent by the sending side. Contains a list of files
// they'd like to share. // they'd like to share.
// NEXT_ID=10 // NEXT_ID=1003
message IntroductionFrame { message IntroductionFrame {
enum SharingUseCase { enum SharingUseCase {
UNKNOWN = 0; UNKNOWN = 0;
@@ -230,6 +256,8 @@ message IntroductionFrame {
repeated StreamMetadata stream_metadata = 7; repeated StreamMetadata stream_metadata = 7;
optional SharingUseCase use_case = 8; optional SharingUseCase use_case = 8;
repeated int64 preview_payload_ids = 9; repeated int64 preview_payload_ids = 9;
optional MirroredNotificationMetadata mirrored_notification_metadata = 1001;
optional PairingMetadata pairing_metadata = 1002;
} }
// A progress update packet sent by the sending side. Contains transfer progress // A progress update packet sent by the sending side. Contains transfer progress
+88
View File
@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import Security
/// Contains information about the app group used to share data between main app and extensions /// Contains information about the app group used to share data between main app and extensions
struct AppGroup { struct AppGroup {
@@ -19,3 +20,90 @@ struct AppGroup {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName) return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupName)
} }
} }
struct KeychainStore {
private static let service = "com.leonboettger.quickdrop.securestorage"
// Must match the shared keychain access group declared in all Apple entitlements.
private static let sharedAccessGroup = "92SDAWLN76.com.leonboettger.neardrop"
static func loadData(account: String) throws -> Data? {
var query = baseQuery(account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
return result as? Data
case errSecItemNotFound:
return nil
default:
throw KeychainStoreError.unexpectedStatus(status)
}
}
static func saveData(_ data: Data, account: String) throws {
var addQuery = baseQuery(account: account)
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
if addStatus == errSecSuccess {
return
}
if addStatus != errSecDuplicateItem {
throw KeychainStoreError.unexpectedStatus(addStatus)
}
let attributesToUpdate: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let updateStatus = SecItemUpdate(baseQuery(account: account) as CFDictionary, attributesToUpdate as CFDictionary)
guard updateStatus == errSecSuccess else {
throw KeychainStoreError.unexpectedStatus(updateStatus)
}
}
static func deleteItem(account: String) throws {
let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainStoreError.unexpectedStatus(status)
}
}
private static func baseQuery(account: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
query[kSecAttrAccessGroup as String] = sharedAccessGroup
return query
}
}
enum KeychainStoreError: LocalizedError {
case unexpectedStatus(OSStatus)
var errorDescription: String? {
switch self {
case let .unexpectedStatus(status):
if let message = SecCopyErrorMessageString(status, nil) as String? {
return "Keychain error \(status): \(message)"
}
return "Keychain error \(status)"
}
}
}
-90
View File
@@ -1,90 +0,0 @@
//
// EXIFUtils.swift
// QuickDrop
//
// Created by Leon Böttger on 10.11.25.
//
import Foundation
import ImageIO
import LUI
struct EXIFUtils {
/// Applies EXIF timestamps to a given file or directory.
static func applyEXIFTimestamps(at url: URL) {
let fm = FileManager.default
var isDir: ObjCBool = false
guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return }
if isDir.boolValue {
// It's a directory — enumerate files
if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey]) {
for case let fileURL as URL in enumerator {
guard (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { continue }
applyEXIFTimestamp(to: fileURL)
}
}
} else {
// It's a single file
applyEXIFTimestamp(to: url)
}
}
/// Applies EXIF timestamp (if available) to a single file
private static func applyEXIFTimestamp(to fileURL: URL) {
guard let exifDate = exifOriginalDate(from: fileURL) else {
return
}
do {
// Set the "creation date"
try FileManager.default.setAttributes([.creationDate: exifDate], ofItemAtPath: fileURL.path)
}
catch {
log("[EXIFUtils] Failed to set file attributes for \(fileURL.path): \(error)")
}
}
/// Returns the EXIF original date of an image file, adjusted to the current time zone.
static func exifOriginalDate(from url: URL) -> Date? {
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any],
let dateString = exif[kCGImagePropertyExifDateTimeOriginal] as? String
else {
return nil
}
let offsetString = exif[kCGImagePropertyExifOffsetTimeOriginal] as? String
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
let offsetSeconds = offsetString.flatMap(secondsFromOffsetString)
if let seconds = offsetSeconds {
formatter.timeZone = TimeZone(secondsFromGMT: seconds)
} else {
// Fallback: treat as device local
formatter.timeZone = TimeZone.current
}
return formatter.date(from: dateString)
}
static private func secondsFromOffsetString(_ s: String) -> Int? {
// Expect formats like "+02:00" or "-05:30"
guard s.count == 6, (s.first == "+" || s.first == "-") else { return nil }
let sign = s.first == "-" ? -1 : 1
let parts = s.dropFirst().split(separator: ":")
guard parts.count == 2,
let hours = Int(parts[0]),
let minutes = Int(parts[1]) else { return nil }
return sign * (hours * 3600 + minutes * 60)
}
}
+8 -14
View File
@@ -5,8 +5,9 @@
// Created by Leon Böttger on 04.04.25. // Created by Leon Böttger on 04.04.25.
// //
import Foundation
import CryptoKit import CryptoKit
import Foundation
import LUI
extension Data { extension Data {
func urlSafeBase64EncodedString() -> String { func urlSafeBase64EncodedString() -> String {
@@ -30,17 +31,6 @@ extension Data {
return data return data
} }
func suffixOfAtMost(numBytes: Int) -> Data {
if count <= numBytes {
return self
}
return subdata(in: count-numBytes..<count)
}
static func dataFromUrlSafeBase64(_ str: String) -> Data? { static func dataFromUrlSafeBase64(_ str: String) -> Data? {
var regularB64 = String(str.map { var regularB64 = String(str.map {
if $0 == "_" { if $0 == "_" {
@@ -111,10 +101,14 @@ extension String {
public func fullVersion() -> Bool { public func fullVersion() -> Bool {
return Settings.shared.gotPlus #if GITHUB
return true
#else
return IAPManager.sharedInstance.plusVersionState
#endif
} }
public func isFileTransferRestricted() -> Bool { public func isFileTransferRestricted() -> Bool {
(!fullVersion() && Settings.shared.incomingTransmissionCount > 1) (!fullVersion() && Settings.sharedInstance.incomingTransmissionCount > 1)
} }
+34 -4
View File
@@ -52,16 +52,20 @@ class IdentityManager {
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
let (pubKey, privKey) = domain.makeKeyPair() let (pubKey, privKey) = domain.makeKeyPair()
AppGroup.appGroupUD.set(Data(privKey.s.asSignedBytes()), forKey: signingKeyTag) let privateKeyData = Data(privKey.s.asSignedBytes())
do {
try KeychainStore.saveData(privateKeyData, account: signingKeyTag)
AppGroup.appGroupUD.removeObject(forKey: signingKeyTag)
} catch {
log("[IdentityManager] Failed to store private key in keychain: \(error.localizedDescription)")
}
return (privKey, pubKey) return (privKey, pubKey)
} }
private func loadKeys() -> (ECPrivateKey, ECPublicKey)? { private func loadKeys() -> (ECPrivateKey, ECPublicKey)? {
let keyData = AppGroup.appGroupUD.data(forKey: signingKeyTag) guard let keyData = loadStoredKeyData() else { return nil }
guard let keyData = keyData else { return nil }
let domain = Domain.instance(curve: .EC256r1) let domain = Domain.instance(curve: .EC256r1)
guard let privateKey = try? ECPrivateKey(domain: domain, s: .init(signed: Array(keyData))) else { guard let privateKey = try? ECPrivateKey(domain: domain, s: .init(signed: Array(keyData))) else {
@@ -80,6 +84,32 @@ class IdentityManager {
return (privateKey, publicKey) return (privateKey, publicKey)
} }
private func loadStoredKeyData() -> Data? {
do {
if let keyData = try KeychainStore.loadData(account: signingKeyTag) {
AppGroup.appGroupUD.removeObject(forKey: signingKeyTag)
return keyData
}
} catch {
log("[IdentityManager] Failed to load private key from keychain: \(error.localizedDescription)")
}
guard let legacyKeyData = AppGroup.appGroupUD.data(forKey: signingKeyTag) else {
return nil
}
do {
try KeychainStore.saveData(legacyKeyData, account: signingKeyTag)
AppGroup.appGroupUD.removeObject(forKey: signingKeyTag)
log("[IdentityManager] Migrated private key from shared defaults to keychain.")
} catch {
log("[IdentityManager] Failed to migrate private key to keychain: \(error.localizedDescription)")
}
return legacyKeyData
}
} }
@@ -0,0 +1,54 @@
//
// NotificationSyncPairingToken.swift
// QuickDrop
//
// Created by Codex on 2026-03-15.
//
import Foundation
import QRCode
import SwiftUI
import LUI
enum NotificationSyncPairingToken {
static func generate() -> String {
Data.randomData(length: 16).urlSafeBase64EncodedString()
}
static func receiverFingerprintHex() -> String? {
guard let keyId = IdentityManager.shared.getPublicKey()?.toGenericPublicKey().id() else {
return nil
}
return keyId.hex.lowercased()
}
static func qrPayload(token: String, receiverFingerprint: String, useCase: PairingUseCase = .notificationSync) -> String {
"quickdrop://pair?token=\(token)&usecase=\(useCase.rawValue)&receiver=\(receiverFingerprint)"
}
static func makeQrImage(
token: String,
receiverFingerprint: String,
useCase: PairingUseCase = .notificationSync,
foregroundColor: CGColor = CGColor(gray: 0, alpha: 1),
backgroundColor: CGColor = CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 1)
) -> Image? {
let payload = qrPayload(token: token, receiverFingerprint: receiverFingerprint, useCase: useCase)
do {
let qrCodeImage = try QRCode.build
.text(payload)
.foregroundColor(foregroundColor)
.backgroundColor(backgroundColor)
.quietZonePixelCount(3)
.onPixels.shape(.circle())
.eye.shape(.squircle())
.errorCorrection(.low)
.generate
.image(dimension: 1000)
return Image(decorative: qrCodeImage, scale: 1.0, orientation: .up)
} catch {
log("[NotificationSyncPairingToken] QR code generation failed: \(error)")
return nil
}
}
}
+107 -15
View File
@@ -5,9 +5,8 @@
// Created by Leon Böttger on 22.08.25. // Created by Leon Böttger on 22.08.25.
// //
import Foundation import Foundation
import CryptoKit
class TrustStore: ObservableObject { class TrustStore: ObservableObject {
@@ -16,6 +15,9 @@ class TrustStore: ObservableObject {
@Published private(set) var trustedCertificates: [String: TrustedCertificate] = [:] @Published private(set) var trustedCertificates: [String: TrustedCertificate] = [:]
private let trustedKeysKey = "com.leonboettger.quickdrop.identity.trustedKeys" private let trustedKeysKey = "com.leonboettger.quickdrop.identity.trustedKeys"
private let pendingPairingQueue = DispatchQueue(label: "TrustStore.pairingPending")
private var pendingPairingTrust: [String: PendingPairingTrust] = [:]
private let pendingPairingTTL: TimeInterval = 5 * 60
private init() { private init() {
loadTrustedCertificates() loadTrustedCertificates()
@@ -25,29 +27,40 @@ class TrustStore: ObservableObject {
// MARK: - Persistence // MARK: - Persistence
private func loadTrustedCertificates() { private func loadTrustedCertificates() {
guard let dict = AppGroup.appGroupUD.dictionary(forKey: trustedKeysKey) as? [String: Data] else { do {
if let data = try KeychainStore.loadData(account: trustedKeysKey) {
trustedCertificates = try JSONDecoder().decode([String: TrustedCertificate].self, from: data)
AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey)
return
}
} catch {
print("[TrustStore] Failed to load trusted certificates from keychain: \(error.localizedDescription)")
}
guard let dict = legacyTrustedCertificates() else {
trustedCertificates = [:] trustedCertificates = [:]
return return
} }
var decoded: [String: TrustedCertificate] = [:] trustedCertificates = dict
for (key, data) in dict {
if let cert = try? JSONDecoder().decode(TrustedCertificate.self, from: data) { do {
decoded[key] = cert try persistTrustedCertificates()
} AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey)
print("[TrustStore] Migrated trusted certificates from shared defaults to keychain.")
} catch {
print("[TrustStore] Failed to migrate trusted certificates to keychain: \(error.localizedDescription)")
} }
trustedCertificates = decoded
} }
private func saveTrustedCertificates() { private func saveTrustedCertificates() {
var dict: [String: Data] = [:] do {
for (key, cert) in trustedCertificates { try persistTrustedCertificates()
if let data = try? JSONEncoder().encode(cert) { AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey)
dict[key] = data } catch {
} print("[TrustStore] Failed to save trusted certificates to keychain: \(error.localizedDescription)")
} }
AppGroup.appGroupUD.set(dict, forKey: trustedKeysKey)
} }
@@ -77,6 +90,52 @@ class TrustStore: ObservableObject {
} }
// MARK: - Pending Pairing Trust
func registerPendingPairingTrust(secretIdHex: String, pinCode: String, useCase: PairingUseCase) {
let normalized = secretIdHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let trimmedPin = pinCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty, !trimmedPin.isEmpty else { return }
let pinHash = Self.pinHash(for: trimmedPin)
pendingPairingQueue.sync {
pendingPairingTrust[normalized] = PendingPairingTrust(
useCase: useCase,
pinHash: pinHash,
createdAt: Date()
)
}
}
func confirmPendingPairingTrust(secretIdHex: String, useCase: PairingUseCase, pinHash: Data) -> Bool {
let normalized = secretIdHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty, !pinHash.isEmpty else { return false }
let now = Date()
return pendingPairingQueue.sync {
guard let pending = pendingPairingTrust[normalized] else { return false }
pendingPairingTrust.removeValue(forKey: normalized)
let expired = now.timeIntervalSince(pending.createdAt) > pendingPairingTTL
if expired {
return false
}
guard pending.useCase == useCase else {
return false
}
return pending.pinHash == pinHash
}
}
func clearPendingPairingTrust(secretIdHex: String) {
let normalized = secretIdHex.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !normalized.isEmpty else { return }
_ = pendingPairingQueue.sync {
pendingPairingTrust.removeValue(forKey: normalized)
}
}
struct TrustedCertificate: Codable, Equatable { struct TrustedCertificate: Codable, Equatable {
// warning: do not rename for persistence // warning: do not rename for persistence
@@ -84,4 +143,37 @@ class TrustStore: ObservableObject {
let creationDate: Date let creationDate: Date
let certificateData: Data let certificateData: Data
} }
struct PendingPairingTrust: Equatable {
let useCase: PairingUseCase
let pinHash: Data
let createdAt: Date
}
private func persistTrustedCertificates() throws {
let data = try JSONEncoder().encode(trustedCertificates)
try KeychainStore.saveData(data, account: trustedKeysKey)
}
private func legacyTrustedCertificates() -> [String: TrustedCertificate]? {
guard let dict = AppGroup.appGroupUD.dictionary(forKey: trustedKeysKey) as? [String: Data] else {
return nil
}
var decoded: [String: TrustedCertificate] = [:]
for (key, data) in dict {
if let cert = try? JSONDecoder().decode(TrustedCertificate.self, from: data) {
decoded[key] = cert
}
}
return decoded
}
private static func pinHash(for pinCode: String) -> Data {
let data = Data(pinCode.utf8)
let digest = SHA256.hash(data: data)
return Data(digest)
}
} }
+103 -49
View File
@@ -14,6 +14,16 @@
699DEBAE2AB0573200115D22 /* QuickDropShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 699DEBA12AB0573200115D22 /* QuickDropShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 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 */; }; 69DA9A1229E0BF5100A442DA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1129E0BF5100A442DA /* AppDelegate.swift */; };
69DA9A1429E0BF5200A442DA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69DA9A1329E0BF5200A442DA /* Assets.xcassets */; }; 69DA9A1429E0BF5200A442DA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69DA9A1329E0BF5200A442DA /* Assets.xcassets */; };
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 */; };
A1B2C3D72F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; };
A1B2C3D82F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; };
A1B2C3D92F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */; };
A1F5B3DB2F3A1A0100A1B2C6 /* NotificationSyncPairingToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F5B3D82F3A1A0100A1B2C3 /* NotificationSyncPairingToken.swift */; };
A1F5B3DC2F3A1A0100A1B2C7 /* NotificationSyncPairingToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F5B3D82F3A1A0100A1B2C3 /* NotificationSyncPairingToken.swift */; };
A1F5B3DD2F3A1A0100A1B2C8 /* NotificationSyncQrSheetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F5B3D92F3A1A0100A1B2C4 /* NotificationSyncQrSheetManager.swift */; };
A1F5B3DE2F3A1A0100A1B2C9 /* NotificationSyncQrSheetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F5B3D92F3A1A0100A1B2C4 /* NotificationSyncQrSheetManager.swift */; };
EB05DFBE2E59FC8900EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; }; EB05DFBE2E59FC8900EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; };
EB05DFBF2E59FD0A00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; }; EB05DFBF2E59FD0A00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; };
EB05DFC02E5A2EAA00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; }; EB05DFC02E5A2EAA00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; };
@@ -46,7 +56,6 @@
EB2F9BFE2DA05F48004010A2 /* BezelNotification in Frameworks */ = {isa = PBXBuildFile; productRef = EB2F9BFD2DA05F48004010A2 /* BezelNotification */; }; EB2F9BFE2DA05F48004010A2 /* BezelNotification in Frameworks */ = {isa = PBXBuildFile; productRef = EB2F9BFD2DA05F48004010A2 /* BezelNotification */; };
EB3643602D73A2A2009CDFF8 /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB36435F2D73A29F009CDFF8 /* AudioManager.swift */; }; EB3643602D73A2A2009CDFF8 /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB36435F2D73A29F009CDFF8 /* AudioManager.swift */; };
EB3B9F002EF7150400D6704E /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBD2A2162D2860BE00BCB8E2 /* WelcomeScreen.swift */; }; EB3B9F002EF7150400D6704E /* WelcomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBD2A2162D2860BE00BCB8E2 /* WelcomeScreen.swift */; };
EB3B9F012EF7150400D6704E /* ChangeDeviceNameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */; };
EB3B9F022EF7150400D6704E /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB36435F2D73A29F009CDFF8 /* AudioManager.swift */; }; EB3B9F022EF7150400D6704E /* AudioManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB36435F2D73A29F009CDFF8 /* AudioManager.swift */; };
EB3B9F032EF7150400D6704E /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; }; EB3B9F032EF7150400D6704E /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; };
EB3B9F042EF7150400D6704E /* SleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2B41E32DC63AC80070FC39 /* SleepManager.swift */; }; EB3B9F042EF7150400D6704E /* SleepManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2B41E32DC63AC80070FC39 /* SleepManager.swift */; };
@@ -76,7 +85,6 @@
EB3B9F1C2EF7150400D6704E /* SmallSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB96ED8A2D7F7CF4004B3FF1 /* SmallSheetView.swift */; }; EB3B9F1C2EF7150400D6704E /* SmallSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB96ED8A2D7F7CF4004B3FF1 /* SmallSheetView.swift */; };
EB3B9F1D2EF7150400D6704E /* License.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB886E662E4F2F7C0052B7D9 /* License.swift */; }; EB3B9F1D2EF7150400D6704E /* License.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB886E662E4F2F7C0052B7D9 /* License.swift */; };
EB3B9F1E2EF7150400D6704E /* LicenseWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7F54132D78E9E400989564 /* LicenseWindow.swift */; }; EB3B9F1E2EF7150400D6704E /* LicenseWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7F54132D78E9E400989564 /* LicenseWindow.swift */; };
EB3B9F1F2EF7150400D6704E /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; };
EB3B9F202EF7150400D6704E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1129E0BF5100A442DA /* AppDelegate.swift */; }; EB3B9F202EF7150400D6704E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1129E0BF5100A442DA /* AppDelegate.swift */; };
EB3B9F212EF7150400D6704E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */; }; EB3B9F212EF7150400D6704E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */; };
EB3B9F222EF7150400D6704E /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7F54112D78E95C00989564 /* TutorialView.swift */; }; EB3B9F222EF7150400D6704E /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7F54112D78E95C00989564 /* TutorialView.swift */; };
@@ -123,7 +131,6 @@
EB3B9F5B2EF7151900D6704E /* wire_format.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2929E18CB500A442DA /* wire_format.pb.swift */; }; EB3B9F5B2EF7151900D6704E /* wire_format.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2929E18CB500A442DA /* wire_format.pb.swift */; };
EB3B9F5C2EF7151900D6704E /* ukey.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2829E18CB500A442DA /* ukey.pb.swift */; }; EB3B9F5C2EF7151900D6704E /* ukey.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2829E18CB500A442DA /* ukey.pb.swift */; };
EB3B9F5D2EF7151900D6704E /* device_to_device_messages.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2A29E18CB500A442DA /* device_to_device_messages.pb.swift */; }; EB3B9F5D2EF7151900D6704E /* device_to_device_messages.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2A29E18CB500A442DA /* device_to_device_messages.pb.swift */; };
EB3B9F5E2EF7151900D6704E /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; };
EB3B9F5F2EF7151900D6704E /* Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB950B732E520954002D68FA /* Attachments.swift */; }; EB3B9F5F2EF7151900D6704E /* Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB950B732E520954002D68FA /* Attachments.swift */; };
EB3B9F602EF7151900D6704E /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1325D22E701D8900FD749C /* Settings.swift */; }; EB3B9F602EF7151900D6704E /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1325D22E701D8900FD749C /* Settings.swift */; };
EB3B9F612EF7151900D6704E /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB16B7762E59BA9F007D9792 /* AppGroup.swift */; }; EB3B9F612EF7151900D6704E /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB16B7762E59BA9F007D9792 /* AppGroup.swift */; };
@@ -160,10 +167,6 @@
EB3B9F952EF715A000D6704E /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F942EF715A000D6704E /* QRCode */; }; EB3B9F952EF715A000D6704E /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F942EF715A000D6704E /* QRCode */; };
EB3B9F972EF715A000D6704E /* LUI in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F962EF715A000D6704E /* LUI */; }; EB3B9F972EF715A000D6704E /* LUI in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F962EF715A000D6704E /* LUI */; };
EB3B9F992EF715A000D6704E /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F982EF715A000D6704E /* ZIPFoundation */; }; EB3B9F992EF715A000D6704E /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F982EF715A000D6704E /* ZIPFoundation */; };
EB4F9D1F2EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; };
EB4F9D202EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; };
EB4F9D212EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; };
EB4F9D222EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; };
EB4F9D252EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */; }; EB4F9D252EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */; };
EB4F9D272EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */; }; EB4F9D272EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */; };
EB53D8C42EF496AB004C95BA /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = EB53D8C32EF496AB004C95BA /* ZIPFoundation */; }; EB53D8C42EF496AB004C95BA /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = EB53D8C32EF496AB004C95BA /* ZIPFoundation */; };
@@ -228,6 +231,11 @@
EB5C383F2E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; }; EB5C383F2E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; };
EB5C38402E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; }; EB5C38402E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; };
EB5C38412E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; }; EB5C38412E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */; };
EB6E51A52F2F9370006D9812 /* IntroductionSheetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6E51A42F2F9370006D9812 /* IntroductionSheetManager.swift */; };
EB6E51A62F2F9370006D9812 /* IntroductionSheetManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6E51A42F2F9370006D9812 /* IntroductionSheetManager.swift */; };
EB6E51AB2F311ED4006D9812 /* QuickDropIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = EB721F232E75AFA900F2099F /* QuickDropIcon.icon */; };
EB6E51AC2F311ED4006D9812 /* QuickDropIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = EB721F232E75AFA900F2099F /* QuickDropIcon.icon */; };
EB6E51AD2F311ED4006D9812 /* QuickDropIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = EB721F232E75AFA900F2099F /* QuickDropIcon.icon */; };
EB6E6AB42ECBA46D00E71D57 /* PhotoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBEE1452EC9D92F00AA7831 /* PhotoManager.swift */; }; EB6E6AB42ECBA46D00E71D57 /* PhotoManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBBEE1452EC9D92F00AA7831 /* PhotoManager.swift */; };
EB6E6B212ECBA95000E71D57 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = EB6E6B202ECBA95000E71D57 /* InfoPlist.xcstrings */; }; EB6E6B212ECBA95000E71D57 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = EB6E6B202ECBA95000E71D57 /* InfoPlist.xcstrings */; };
EB6E6B222ECBA95000E71D57 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = EB6E6B202ECBA95000E71D57 /* InfoPlist.xcstrings */; }; EB6E6B222ECBA95000E71D57 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = EB6E6B202ECBA95000E71D57 /* InfoPlist.xcstrings */; };
@@ -264,7 +272,6 @@
EB950B3E2E52038A002D68FA /* SendModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F902E50CA23008B8C5C /* SendModel.swift */; }; EB950B3E2E52038A002D68FA /* SendModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F902E50CA23008B8C5C /* SendModel.swift */; };
EB950B3F2E52038A002D68FA /* FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8F2E50CA23008B8C5C /* FilePicker.swift */; }; EB950B3F2E52038A002D68FA /* FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8F2E50CA23008B8C5C /* FilePicker.swift */; };
EB950B402E52038A002D68FA /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8D2E50CA23008B8C5C /* Configuration.swift */; }; EB950B402E52038A002D68FA /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8D2E50CA23008B8C5C /* Configuration.swift */; };
EB950B412E52038A002D68FA /* PieProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F992E50CAA6008B8C5C /* PieProgressView.swift */; };
EB950B432E52038A002D68FA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8E2E50CA23008B8C5C /* ContentView.swift */; }; EB950B432E52038A002D68FA /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8E2E50CA23008B8C5C /* ContentView.swift */; };
EB950B442E520414002D68FA /* License.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB886E662E4F2F7C0052B7D9 /* License.swift */; }; EB950B442E520414002D68FA /* License.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB886E662E4F2F7C0052B7D9 /* License.swift */; };
EB950B452E520436002D68FA /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2F9BFA2DA04B67004010A2 /* Helpers.swift */; }; EB950B452E520436002D68FA /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2F9BFA2DA04B67004010A2 /* Helpers.swift */; };
@@ -307,7 +314,6 @@
EB979F962E50CA23008B8C5C /* FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8F2E50CA23008B8C5C /* FilePicker.swift */; }; EB979F962E50CA23008B8C5C /* FilePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F8F2E50CA23008B8C5C /* FilePicker.swift */; };
EB979F972E50CA23008B8C5C /* SendModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F902E50CA23008B8C5C /* SendModel.swift */; }; EB979F972E50CA23008B8C5C /* SendModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F902E50CA23008B8C5C /* SendModel.swift */; };
EB979F982E50CA23008B8C5C /* QuickDropiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F922E50CA23008B8C5C /* QuickDropiOSApp.swift */; }; EB979F982E50CA23008B8C5C /* QuickDropiOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F922E50CA23008B8C5C /* QuickDropiOSApp.swift */; };
EB979F9A2E50CAA6008B8C5C /* PieProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB979F992E50CAA6008B8C5C /* PieProgressView.swift */; };
EB97A00F2E511124008B8C5C /* ReceiveModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB97A00E2E511120008B8C5C /* ReceiveModel.swift */; }; EB97A00F2E511124008B8C5C /* ReceiveModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB97A00E2E511120008B8C5C /* ReceiveModel.swift */; };
EB97A0102E51CD13008B8C5C /* ReceiveModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB97A00E2E511120008B8C5C /* ReceiveModel.swift */; }; EB97A0102E51CD13008B8C5C /* ReceiveModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB97A00E2E511120008B8C5C /* ReceiveModel.swift */; };
EB97A0142E51DD00008B8C5C /* License.txt in Resources */ = {isa = PBXBuildFile; fileRef = EB5C36E32E34EC0E0052EFD7 /* License.txt */; }; EB97A0142E51DD00008B8C5C /* License.txt in Resources */ = {isa = PBXBuildFile; fileRef = EB5C36E32E34EC0E0052EFD7 /* License.txt */; };
@@ -319,7 +325,6 @@
EB9925852E45EF0900357055 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB9925842E45EF0900357055 /* QRCode */; }; EB9925852E45EF0900357055 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB9925842E45EF0900357055 /* QRCode */; };
EB9925872E45F12F00357055 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB9925862E45F12F00357055 /* QRCode */; }; EB9925872E45F12F00357055 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB9925862E45F12F00357055 /* QRCode */; };
EB9925892E45F13500357055 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB9925882E45F13500357055 /* QRCode */; }; EB9925892E45F13500357055 /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB9925882E45F13500357055 /* QRCode */; };
EBA1BB1D2EF1F5650068E46E /* ChangeDeviceNameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */; };
EBA1BB1E2EF1F6030068E46E /* ChangeDeviceNameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */; }; EBA1BB1E2EF1F6030068E46E /* ChangeDeviceNameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */; };
EBA1BB1F2EF1F6030068E46E /* ChangeDeviceNameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */; }; EBA1BB1F2EF1F6030068E46E /* ChangeDeviceNameModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */; };
EBADEB8F2E8029750021B5E4 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = EBADEB8E2E8029750021B5E4 /* DeviceKit */; }; EBADEB8F2E8029750021B5E4 /* DeviceKit in Frameworks */ = {isa = PBXBuildFile; productRef = EBADEB8E2E8029750021B5E4 /* DeviceKit */; };
@@ -497,6 +502,9 @@
69DA9A2B29E18CB500A442DA /* offline_wire_formats.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = offline_wire_formats.pb.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 69DA9A2D29E18CB500A442DA /* securemessage.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = securemessage.pb.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>"; };
EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedDevicesView.swift; sourceTree = "<group>"; }; EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrustedDevicesView.swift; sourceTree = "<group>"; };
EB1325D22E701D8900FD749C /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; }; EB1325D22E701D8900FD749C /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
EB1325E72E7025D900FD749C /* ActiveNetworkInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveNetworkInterface.swift; sourceTree = "<group>"; }; EB1325E72E7025D900FD749C /* ActiveNetworkInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveNetworkInterface.swift; sourceTree = "<group>"; };
@@ -514,7 +522,6 @@
EB3B9F492EF7150400D6704E /* QuickDrop copy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "QuickDrop copy-Info.plist"; path = "/Users/leonboettger/QuickDrop Backup with git/QuickDrop copy-Info.plist"; sourceTree = "<absolute>"; }; EB3B9F492EF7150400D6704E /* QuickDrop copy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "QuickDrop copy-Info.plist"; path = "/Users/leonboettger/QuickDrop Backup with git/QuickDrop copy-Info.plist"; sourceTree = "<absolute>"; };
EB3B9F892EF7151900D6704E /* QuickDropShareExtensionGitHub.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QuickDropShareExtensionGitHub.appex; sourceTree = BUILT_PRODUCTS_DIR; }; EB3B9F892EF7151900D6704E /* QuickDropShareExtensionGitHub.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QuickDropShareExtensionGitHub.appex; sourceTree = BUILT_PRODUCTS_DIR; };
EB3B9F8A2EF7151900D6704E /* QuickDropShareExtension copy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "QuickDropShareExtension copy-Info.plist"; path = "/Users/leonboettger/QuickDrop Backup with git/QuickDropShareExtension copy-Info.plist"; sourceTree = "<absolute>"; }; EB3B9F8A2EF7151900D6704E /* QuickDropShareExtension copy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "QuickDropShareExtension copy-Info.plist"; path = "/Users/leonboettger/QuickDrop Backup with git/QuickDropShareExtension copy-Info.plist"; sourceTree = "<absolute>"; };
EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EXIFUtils.swift; sourceTree = "<group>"; };
EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+DocumentsOpener.swift"; sourceTree = "<group>"; }; EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+DocumentsOpener.swift"; sourceTree = "<group>"; };
EB5C36922E34EBBD0052EFD7 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; EB5C36922E34EBBD0052EFD7 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
EB5C36952E34EBBD0052EFD7 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/ShareViewController.xcstrings; sourceTree = "<group>"; }; EB5C36952E34EBBD0052EFD7 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/ShareViewController.xcstrings; sourceTree = "<group>"; };
@@ -523,6 +530,7 @@
EB5C37E22E377C2E0052EFD7 /* QuickDropiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickDropiOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; EB5C37E22E377C2E0052EFD7 /* QuickDropiOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QuickDropiOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
EB5C38382E38C1F60052EFD7 /* justMaybe.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = justMaybe.mp3; sourceTree = "<group>"; }; EB5C38382E38C1F60052EFD7 /* justMaybe.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = justMaybe.mp3; sourceTree = "<group>"; };
EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; }; EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
EB6E51A42F2F9370006D9812 /* IntroductionSheetManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroductionSheetManager.swift; sourceTree = "<group>"; };
EB6E6B202ECBA95000E71D57 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; }; EB6E6B202ECBA95000E71D57 /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = "<group>"; };
EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
EB6FE7CC2EFEAFF000FF2BCF /* NoLocalNetworkAccessAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoLocalNetworkAccessAlertView.swift; sourceTree = "<group>"; }; EB6FE7CC2EFEAFF000FF2BCF /* NoLocalNetworkAccessAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoLocalNetworkAccessAlertView.swift; sourceTree = "<group>"; };
@@ -548,7 +556,6 @@
EB979F902E50CA23008B8C5C /* SendModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendModel.swift; sourceTree = "<group>"; }; EB979F902E50CA23008B8C5C /* SendModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendModel.swift; sourceTree = "<group>"; };
EB979F912E50CA23008B8C5C /* QuickDropiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QuickDropiOS.entitlements; sourceTree = "<group>"; }; EB979F912E50CA23008B8C5C /* QuickDropiOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QuickDropiOS.entitlements; sourceTree = "<group>"; };
EB979F922E50CA23008B8C5C /* QuickDropiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickDropiOSApp.swift; sourceTree = "<group>"; }; EB979F922E50CA23008B8C5C /* QuickDropiOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickDropiOSApp.swift; sourceTree = "<group>"; };
EB979F992E50CAA6008B8C5C /* PieProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PieProgressView.swift; sourceTree = "<group>"; };
EB97A00E2E511120008B8C5C /* ReceiveModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveModel.swift; sourceTree = "<group>"; }; EB97A00E2E511120008B8C5C /* ReceiveModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveModel.swift; sourceTree = "<group>"; };
EB97A0162E51DF4F008B8C5C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; EB97A0162E51DF4F008B8C5C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
EB97A0192E51EA9C008B8C5C /* UIAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlert.swift; sourceTree = "<group>"; }; EB97A0192E51EA9C008B8C5C /* UIAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlert.swift; sourceTree = "<group>"; };
@@ -706,6 +713,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
69DA9A1129E0BF5100A442DA /* AppDelegate.swift */, 69DA9A1129E0BF5100A442DA /* AppDelegate.swift */,
EB979F8D2E50CA23008B8C5C /* Configuration.swift */,
EB1325D22E701D8900FD749C /* Settings.swift */, EB1325D22E701D8900FD749C /* Settings.swift */,
EB644B852EA507CF006C2E6C /* ViewModel */, EB644B852EA507CF006C2E6C /* ViewModel */,
EBC05C6A2E33B55100EECC18 /* View */, EBC05C6A2E33B55100EECC18 /* View */,
@@ -742,6 +750,14 @@
path = NearbyShare; path = NearbyShare;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A1F5B3DA2F3A1A0100A1B2C5 /* NotificationSync */ = {
isa = PBXGroup;
children = (
A1F5B3D92F3A1A0100A1B2C4 /* NotificationSyncQrSheetManager.swift */,
);
path = NotificationSync;
sourceTree = "<group>";
};
EB1325EA2E7025D900FD749C /* SelfTest */ = { EB1325EA2E7025D900FD749C /* SelfTest */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -762,8 +778,8 @@
EB16B7762E59BA9F007D9792 /* AppGroup.swift */, EB16B7762E59BA9F007D9792 /* AppGroup.swift */,
EB886E512E4E19390052B7D9 /* HKDF.swift */, EB886E512E4E19390052B7D9 /* HKDF.swift */,
EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */, EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */,
EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */,
EB2F9BFA2DA04B67004010A2 /* Helpers.swift */, EB2F9BFA2DA04B67004010A2 /* Helpers.swift */,
A1F5B3D82F3A1A0100A1B2C3 /* NotificationSyncPairingToken.swift */,
); );
path = Util; path = Util;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -773,6 +789,7 @@
children = ( children = (
69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */, 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */,
69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */, 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */,
A1B2C3DA2F33AA0100ABC001 /* MirroredNotificationSupport.swift */,
691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */, 691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */,
698DFAE529E2F91A0064F247 /* NearbyConnection.swift */, 698DFAE529E2F91A0064F247 /* NearbyConnection.swift */,
); );
@@ -812,12 +829,12 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EB979F8E2E50CA23008B8C5C /* ContentView.swift */, EB979F8E2E50CA23008B8C5C /* ContentView.swift */,
EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */,
EB6FE7CC2EFEAFF000FF2BCF /* NoLocalNetworkAccessAlertView.swift */, EB6FE7CC2EFEAFF000FF2BCF /* NoLocalNetworkAccessAlertView.swift */,
EB979F8F2E50CA23008B8C5C /* FilePicker.swift */, EB979F8F2E50CA23008B8C5C /* FilePicker.swift */,
EB97A0192E51EA9C008B8C5C /* UIAlert.swift */, EB97A0192E51EA9C008B8C5C /* UIAlert.swift */,
EBBEE1452EC9D92F00AA7831 /* PhotoManager.swift */, EBBEE1452EC9D92F00AA7831 /* PhotoManager.swift */,
EBBC93B12EC8B75F00F541A1 /* LocalNetworkPermissionView.swift */, EBBC93B12EC8B75F00F541A1 /* LocalNetworkPermissionView.swift */,
EB979F992E50CAA6008B8C5C /* PieProgressView.swift */,
); );
path = View; path = View;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -830,6 +847,42 @@
path = Helpers; path = Helpers;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
EB6E51A72F2F95D0006D9812 /* Introduction */ = {
isa = PBXGroup;
children = (
EB6E51A42F2F9370006D9812 /* IntroductionSheetManager.swift */,
EB7A95F22EF6E1BE0021111B /* IntroductionView.swift */,
);
path = Introduction;
sourceTree = "<group>";
};
EB6E51A82F2F95E0006D9812 /* Settings */ = {
isa = PBXGroup;
children = (
EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */,
EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */,
);
path = Settings;
sourceTree = "<group>";
};
EB6E51A92F2F95EE006D9812 /* UserManual */ = {
isa = PBXGroup;
children = (
EBD2A2162D2860BE00BCB8E2 /* WelcomeScreen.swift */,
EB7F54112D78E95C00989564 /* TutorialView.swift */,
EB7F54132D78E9E400989564 /* LicenseWindow.swift */,
);
path = UserManual;
sourceTree = "<group>";
};
EB6E51AA2F2F9606006D9812 /* Toast */ = {
isa = PBXGroup;
children = (
EB869DB42E92AA33009AAAD4 /* QuickDropToastView.swift */,
);
path = Toast;
sourceTree = "<group>";
};
EB950B6C2E5206E2002D68FA /* iOSShareExtension */ = { EB950B6C2E5206E2002D68FA /* iOSShareExtension */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -845,7 +898,6 @@
children = ( children = (
EB97A0162E51DF4F008B8C5C /* Info.plist */, EB97A0162E51DF4F008B8C5C /* Info.plist */,
EB1325FC2E7034B000FD749C /* InfoPlist.xcstrings */, EB1325FC2E7034B000FD749C /* InfoPlist.xcstrings */,
EB979F8D2E50CA23008B8C5C /* Configuration.swift */,
EB644B862EA50807006C2E6C /* View */, EB644B862EA50807006C2E6C /* View */,
EB979F912E50CA23008B8C5C /* QuickDropiOS.entitlements */, EB979F912E50CA23008B8C5C /* QuickDropiOS.entitlements */,
EB979F922E50CA23008B8C5C /* QuickDropiOSApp.swift */, EB979F922E50CA23008B8C5C /* QuickDropiOSApp.swift */,
@@ -857,14 +909,11 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
EB7654772E525ACC006B4AA3 /* MainMenu.xib */, EB7654772E525ACC006B4AA3 /* MainMenu.xib */,
EBD2A2162D2860BE00BCB8E2 /* WelcomeScreen.swift */, EB6E51A92F2F95EE006D9812 /* UserManual */,
EB7F54112D78E95C00989564 /* TutorialView.swift */, EB6E51AA2F2F9606006D9812 /* Toast */,
EB7F54132D78E9E400989564 /* LicenseWindow.swift */, A1F5B3DA2F3A1A0100A1B2C5 /* NotificationSync */,
EB869DB42E92AA33009AAAD4 /* QuickDropToastView.swift */, EB6E51A82F2F95E0006D9812 /* Settings */,
EBA1BB1C2EF1F5650068E46E /* ChangeDeviceNameModifier.swift */, EB6E51A72F2F95D0006D9812 /* Introduction */,
EB7A95F22EF6E1BE0021111B /* IntroductionView.swift */,
EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */,
EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */,
EBC05C812E33B58300EECC18 /* IssueViews */, EBC05C812E33B58300EECC18 /* IssueViews */,
); );
path = View; path = View;
@@ -1134,6 +1183,7 @@
files = ( files = (
EBD6AFBC2EF826E600DED4CD /* ConfigSynced.storekit in Resources */, EBD6AFBC2EF826E600DED4CD /* ConfigSynced.storekit in Resources */,
EB5C36942E34EBBD0052EFD7 /* Localizable.xcstrings in Resources */, EB5C36942E34EBBD0052EFD7 /* Localizable.xcstrings in Resources */,
EB6E51AB2F311ED4006D9812 /* QuickDropIcon.icon in Resources */,
EB5C36E52E34EC0E0052EFD7 /* License.txt in Resources */, EB5C36E52E34EC0E0052EFD7 /* License.txt in Resources */,
691F53BC2ABB70840089FD92 /* DeviceListCell.xib in Resources */, 691F53BC2ABB70840089FD92 /* DeviceListCell.xib in Resources */,
699DEBA92AB0573200115D22 /* ShareViewController.xib in Resources */, 699DEBA92AB0573200115D22 /* ShareViewController.xib in Resources */,
@@ -1182,6 +1232,7 @@
files = ( files = (
EBD6AFC02EF826E600DED4CD /* ConfigSynced.storekit in Resources */, EBD6AFC02EF826E600DED4CD /* ConfigSynced.storekit in Resources */,
EB3B9F7D2EF7151900D6704E /* Localizable.xcstrings in Resources */, EB3B9F7D2EF7151900D6704E /* Localizable.xcstrings in Resources */,
EB6E51AD2F311ED4006D9812 /* QuickDropIcon.icon in Resources */,
EB3B9F7E2EF7151900D6704E /* License.txt in Resources */, EB3B9F7E2EF7151900D6704E /* License.txt in Resources */,
EB3B9F7F2EF7151900D6704E /* DeviceListCell.xib in Resources */, EB3B9F7F2EF7151900D6704E /* DeviceListCell.xib in Resources */,
EB3B9F802EF7151900D6704E /* ShareViewController.xib in Resources */, EB3B9F802EF7151900D6704E /* ShareViewController.xib in Resources */,
@@ -1215,6 +1266,7 @@
EB1325FE2E7034B000FD749C /* InfoPlist.xcstrings in Resources */, EB1325FE2E7034B000FD749C /* InfoPlist.xcstrings in Resources */,
EB950B702E520734002D68FA /* Localizable.xcstrings in Resources */, EB950B702E520734002D68FA /* Localizable.xcstrings in Resources */,
EBD6AFBE2EF826E600DED4CD /* ConfigSynced.storekit in Resources */, EBD6AFBE2EF826E600DED4CD /* ConfigSynced.storekit in Resources */,
EB6E51AC2F311ED4006D9812 /* QuickDropIcon.icon in Resources */,
EB950B5C2E52065A002D68FA /* justMaybe.mp3 in Resources */, EB950B5C2E52065A002D68FA /* justMaybe.mp3 in Resources */,
EB950B5D2E52065A002D68FA /* License.txt in Resources */, EB950B5D2E52065A002D68FA /* License.txt in Resources */,
EB7654C12E534A8E006B4AA3 /* Assets.xcassets in Resources */, EB7654C12E534A8E006B4AA3 /* Assets.xcassets in Resources */,
@@ -1246,7 +1298,6 @@
EB5C37C72E377A780052EFD7 /* wire_format.pb.swift in Sources */, EB5C37C72E377A780052EFD7 /* wire_format.pb.swift in Sources */,
EB5C37C82E377A780052EFD7 /* ukey.pb.swift in Sources */, EB5C37C82E377A780052EFD7 /* ukey.pb.swift in Sources */,
EB5C37C92E377A780052EFD7 /* device_to_device_messages.pb.swift in Sources */, EB5C37C92E377A780052EFD7 /* device_to_device_messages.pb.swift in Sources */,
EB4F9D222EC26C8100A74A0F /* EXIFUtils.swift in Sources */,
EB950B742E520957002D68FA /* Attachments.swift in Sources */, EB950B742E520957002D68FA /* Attachments.swift in Sources */,
EB1325D32E701D8E00FD749C /* Settings.swift in Sources */, EB1325D32E701D8E00FD749C /* Settings.swift in Sources */,
EB16B7782E59BAA1007D9792 /* AppGroup.swift in Sources */, EB16B7782E59BAA1007D9792 /* AppGroup.swift in Sources */,
@@ -1262,6 +1313,7 @@
EB5C37D12E377A7F0052EFD7 /* DataModel.swift in Sources */, EB5C37D12E377A7F0052EFD7 /* DataModel.swift in Sources */,
EB5C37D22E377A7F0052EFD7 /* NearbyConnection.swift in Sources */, EB5C37D22E377A7F0052EFD7 /* NearbyConnection.swift in Sources */,
EB5C37D32E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */, EB5C37D32E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */,
A1B2C3D42F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */,
EB5C37D42E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */, EB5C37D42E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */,
EB5C37BD2E377A700052EFD7 /* Helpers.swift in Sources */, EB5C37BD2E377A700052EFD7 /* Helpers.swift in Sources */,
EB1325EB2E7025D900FD749C /* ApIsolationTester.swift in Sources */, EB1325EB2E7025D900FD749C /* ApIsolationTester.swift in Sources */,
@@ -1276,7 +1328,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EBD2A2172D2860C100BCB8E2 /* WelcomeScreen.swift in Sources */, EBD2A2172D2860C100BCB8E2 /* WelcomeScreen.swift in Sources */,
EBA1BB1D2EF1F5650068E46E /* ChangeDeviceNameModifier.swift in Sources */,
EB3643602D73A2A2009CDFF8 /* AudioManager.swift in Sources */, EB3643602D73A2A2009CDFF8 /* AudioManager.swift in Sources */,
EB5C38412E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */, EB5C38412E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */,
EB5C37B82E377A6F0052EFD7 /* SleepManager.swift in Sources */, EB5C37B82E377A6F0052EFD7 /* SleepManager.swift in Sources */,
@@ -1295,10 +1346,13 @@
EB16B6152E58B4B9007D9792 /* IdentityManager.swift in Sources */, EB16B6152E58B4B9007D9792 /* IdentityManager.swift in Sources */,
EB5C37C42E377A780052EFD7 /* securegcm.pb.swift in Sources */, EB5C37C42E377A780052EFD7 /* securegcm.pb.swift in Sources */,
EB97A0102E51CD13008B8C5C /* ReceiveModel.swift in Sources */, EB97A0102E51CD13008B8C5C /* ReceiveModel.swift in Sources */,
A1F5B3DD2F3A1A0100A1B2C8 /* NotificationSyncQrSheetManager.swift in Sources */,
A1F5B3DB2F3A1A0100A1B2C6 /* NotificationSyncPairingToken.swift in Sources */,
EB5C37CB2E377A7F0052EFD7 /* NearbyConnection.swift in Sources */, EB5C37CB2E377A7F0052EFD7 /* NearbyConnection.swift in Sources */,
EB5C37CC2E377A7F0052EFD7 /* DataModel.swift in Sources */, EB5C37CC2E377A7F0052EFD7 /* DataModel.swift in Sources */,
EB5C37CD2E377A7F0052EFD7 /* OutboundNearbyConnection.swift in Sources */, EB5C37CD2E377A7F0052EFD7 /* OutboundNearbyConnection.swift in Sources */,
EB5C37CE2E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */, EB5C37CE2E377A7F0052EFD7 /* InboundNearbyConnection.swift in Sources */,
A1B2C3D52F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */,
EB5C37CF2E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */, EB5C37CF2E377A7F0052EFD7 /* NearbyConnectionManager.swift in Sources */,
EBE5FF422EDB6F6F0019B380 /* Configuration.swift in Sources */, EBE5FF422EDB6F6F0019B380 /* Configuration.swift in Sources */,
EB7654A12E53252D006B4AA3 /* AttachmentDetails.swift in Sources */, EB7654A12E53252D006B4AA3 /* AttachmentDetails.swift in Sources */,
@@ -1306,8 +1360,8 @@
EB96ED8C2D7F7E7B004B3FF1 /* SmallSheetView.swift in Sources */, EB96ED8C2D7F7E7B004B3FF1 /* SmallSheetView.swift in Sources */,
EB886E682E4F2F800052B7D9 /* License.swift in Sources */, EB886E682E4F2F800052B7D9 /* License.swift in Sources */,
EB7F54142D78E9E700989564 /* LicenseWindow.swift in Sources */, EB7F54142D78E9E700989564 /* LicenseWindow.swift in Sources */,
EB4F9D202EC26C8100A74A0F /* EXIFUtils.swift in Sources */,
69DA9A1229E0BF5100A442DA /* AppDelegate.swift in Sources */, 69DA9A1229E0BF5100A442DA /* AppDelegate.swift in Sources */,
EB6E51A62F2F9370006D9812 /* IntroductionSheetManager.swift in Sources */,
EB6F62D72D91F4AF00534EC2 /* SettingsView.swift in Sources */, EB6F62D72D91F4AF00534EC2 /* SettingsView.swift in Sources */,
EB7F54122D78E96000989564 /* TutorialView.swift in Sources */, EB7F54122D78E96000989564 /* TutorialView.swift in Sources */,
EB9925812E45D53E00357055 /* sharing_enums.pb.swift in Sources */, EB9925812E45D53E00357055 /* sharing_enums.pb.swift in Sources */,
@@ -1331,7 +1385,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
EB3B9F002EF7150400D6704E /* WelcomeScreen.swift in Sources */, EB3B9F002EF7150400D6704E /* WelcomeScreen.swift in Sources */,
EB3B9F012EF7150400D6704E /* ChangeDeviceNameModifier.swift in Sources */,
EB3B9F022EF7150400D6704E /* AudioManager.swift in Sources */, EB3B9F022EF7150400D6704E /* AudioManager.swift in Sources */,
EB3B9F032EF7150400D6704E /* LaunchAtLogin.swift in Sources */, EB3B9F032EF7150400D6704E /* LaunchAtLogin.swift in Sources */,
EB3B9F042EF7150400D6704E /* SleepManager.swift in Sources */, EB3B9F042EF7150400D6704E /* SleepManager.swift in Sources */,
@@ -1350,10 +1403,13 @@
EB3B9F112EF7150400D6704E /* IdentityManager.swift in Sources */, EB3B9F112EF7150400D6704E /* IdentityManager.swift in Sources */,
EB3B9F122EF7150400D6704E /* securegcm.pb.swift in Sources */, EB3B9F122EF7150400D6704E /* securegcm.pb.swift in Sources */,
EB3B9F132EF7150400D6704E /* ReceiveModel.swift in Sources */, EB3B9F132EF7150400D6704E /* ReceiveModel.swift in Sources */,
A1F5B3DE2F3A1A0100A1B2C9 /* NotificationSyncQrSheetManager.swift in Sources */,
A1F5B3DC2F3A1A0100A1B2C7 /* NotificationSyncPairingToken.swift in Sources */,
EB3B9F142EF7150400D6704E /* NearbyConnection.swift in Sources */, EB3B9F142EF7150400D6704E /* NearbyConnection.swift in Sources */,
EB3B9F152EF7150400D6704E /* DataModel.swift in Sources */, EB3B9F152EF7150400D6704E /* DataModel.swift in Sources */,
EB3B9F162EF7150400D6704E /* OutboundNearbyConnection.swift in Sources */, EB3B9F162EF7150400D6704E /* OutboundNearbyConnection.swift in Sources */,
EB3B9F172EF7150400D6704E /* InboundNearbyConnection.swift in Sources */, EB3B9F172EF7150400D6704E /* InboundNearbyConnection.swift in Sources */,
A1B2C3D62F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */,
EB3B9F182EF7150400D6704E /* NearbyConnectionManager.swift in Sources */, EB3B9F182EF7150400D6704E /* NearbyConnectionManager.swift in Sources */,
EB3B9F192EF7150400D6704E /* Configuration.swift in Sources */, EB3B9F192EF7150400D6704E /* Configuration.swift in Sources */,
EB3B9F1A2EF7150400D6704E /* AttachmentDetails.swift in Sources */, EB3B9F1A2EF7150400D6704E /* AttachmentDetails.swift in Sources */,
@@ -1361,8 +1417,8 @@
EB3B9F1C2EF7150400D6704E /* SmallSheetView.swift in Sources */, EB3B9F1C2EF7150400D6704E /* SmallSheetView.swift in Sources */,
EB3B9F1D2EF7150400D6704E /* License.swift in Sources */, EB3B9F1D2EF7150400D6704E /* License.swift in Sources */,
EB3B9F1E2EF7150400D6704E /* LicenseWindow.swift in Sources */, EB3B9F1E2EF7150400D6704E /* LicenseWindow.swift in Sources */,
EB3B9F1F2EF7150400D6704E /* EXIFUtils.swift in Sources */,
EB3B9F202EF7150400D6704E /* AppDelegate.swift in Sources */, EB3B9F202EF7150400D6704E /* AppDelegate.swift in Sources */,
EB6E51A52F2F9370006D9812 /* IntroductionSheetManager.swift in Sources */,
EB3B9F212EF7150400D6704E /* SettingsView.swift in Sources */, EB3B9F212EF7150400D6704E /* SettingsView.swift in Sources */,
EB3B9F222EF7150400D6704E /* TutorialView.swift in Sources */, EB3B9F222EF7150400D6704E /* TutorialView.swift in Sources */,
EB3B9F232EF7150400D6704E /* sharing_enums.pb.swift in Sources */, EB3B9F232EF7150400D6704E /* sharing_enums.pb.swift in Sources */,
@@ -1403,7 +1459,6 @@
EB3B9F5B2EF7151900D6704E /* wire_format.pb.swift in Sources */, EB3B9F5B2EF7151900D6704E /* wire_format.pb.swift in Sources */,
EB3B9F5C2EF7151900D6704E /* ukey.pb.swift in Sources */, EB3B9F5C2EF7151900D6704E /* ukey.pb.swift in Sources */,
EB3B9F5D2EF7151900D6704E /* device_to_device_messages.pb.swift in Sources */, EB3B9F5D2EF7151900D6704E /* device_to_device_messages.pb.swift in Sources */,
EB3B9F5E2EF7151900D6704E /* EXIFUtils.swift in Sources */,
EB3B9F5F2EF7151900D6704E /* Attachments.swift in Sources */, EB3B9F5F2EF7151900D6704E /* Attachments.swift in Sources */,
EB3B9F602EF7151900D6704E /* Settings.swift in Sources */, EB3B9F602EF7151900D6704E /* Settings.swift in Sources */,
EB3B9F612EF7151900D6704E /* AppGroup.swift in Sources */, EB3B9F612EF7151900D6704E /* AppGroup.swift in Sources */,
@@ -1419,6 +1474,7 @@
EB3B9F6B2EF7151900D6704E /* DataModel.swift in Sources */, EB3B9F6B2EF7151900D6704E /* DataModel.swift in Sources */,
EB3B9F6C2EF7151900D6704E /* NearbyConnection.swift in Sources */, EB3B9F6C2EF7151900D6704E /* NearbyConnection.swift in Sources */,
EB3B9F6D2EF7151900D6704E /* InboundNearbyConnection.swift in Sources */, EB3B9F6D2EF7151900D6704E /* InboundNearbyConnection.swift in Sources */,
A1B2C3D72F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */,
EB3B9F6E2EF7151900D6704E /* NearbyConnectionManager.swift in Sources */, EB3B9F6E2EF7151900D6704E /* NearbyConnectionManager.swift in Sources */,
EB3B9F6F2EF7151900D6704E /* Helpers.swift in Sources */, EB3B9F6F2EF7151900D6704E /* Helpers.swift in Sources */,
EB3B9F702EF7151900D6704E /* ApIsolationTester.swift in Sources */, EB3B9F702EF7151900D6704E /* ApIsolationTester.swift in Sources */,
@@ -1447,13 +1503,13 @@
EB5C37F92E377CE70052EFD7 /* securegcm.pb.swift in Sources */, EB5C37F92E377CE70052EFD7 /* securegcm.pb.swift in Sources */,
EB5C37FA2E377CE70052EFD7 /* Helpers.swift in Sources */, EB5C37FA2E377CE70052EFD7 /* Helpers.swift in Sources */,
EB5C37FB2E377CE70052EFD7 /* InboundNearbyConnection.swift in Sources */, EB5C37FB2E377CE70052EFD7 /* InboundNearbyConnection.swift in Sources */,
A1B2C3D82F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */,
EB9925802E45D53E00357055 /* sharing_enums.pb.swift in Sources */, EB9925802E45D53E00357055 /* sharing_enums.pb.swift in Sources */,
EB5C37FC2E377CE70052EFD7 /* OutboundNearbyConnection.swift in Sources */, EB5C37FC2E377CE70052EFD7 /* OutboundNearbyConnection.swift in Sources */,
EB1325D62E701D8E00FD749C /* Settings.swift in Sources */, EB1325D62E701D8E00FD749C /* Settings.swift in Sources */,
EBBC93B32EC8B76600F541A1 /* LocalNetworkPermissionView.swift in Sources */, EBBC93B32EC8B76600F541A1 /* LocalNetworkPermissionView.swift in Sources */,
EB97A00F2E511124008B8C5C /* ReceiveModel.swift in Sources */, EB97A00F2E511124008B8C5C /* ReceiveModel.swift in Sources */,
EB5C37FE2E377CE70052EFD7 /* offline_wire_formats.pb.swift in Sources */, EB5C37FE2E377CE70052EFD7 /* offline_wire_formats.pb.swift in Sources */,
EB4F9D1F2EC26C8100A74A0F /* EXIFUtils.swift in Sources */,
EB886E6A2E4F37B90052B7D9 /* ErrorAlertHandler.swift in Sources */, EB886E6A2E4F37B90052B7D9 /* ErrorAlertHandler.swift in Sources */,
EB886E5E2E4F13EB0052B7D9 /* Zip.swift in Sources */, EB886E5E2E4F13EB0052B7D9 /* Zip.swift in Sources */,
EB5C383D2E38C2D90052EFD7 /* AudioManager.swift in Sources */, EB5C383D2E38C2D90052EFD7 /* AudioManager.swift in Sources */,
@@ -1463,7 +1519,6 @@
EBBEE1462EC9D93400AA7831 /* PhotoManager.swift in Sources */, EBBEE1462EC9D93400AA7831 /* PhotoManager.swift in Sources */,
EB97A01A2E51EAA2008B8C5C /* UIAlert.swift in Sources */, EB97A01A2E51EAA2008B8C5C /* UIAlert.swift in Sources */,
EB979F962E50CA23008B8C5C /* FilePicker.swift in Sources */, EB979F962E50CA23008B8C5C /* FilePicker.swift in Sources */,
EB979F9A2E50CAA6008B8C5C /* PieProgressView.swift in Sources */,
EB979F972E50CA23008B8C5C /* SendModel.swift in Sources */, EB979F972E50CA23008B8C5C /* SendModel.swift in Sources */,
EB979F982E50CA23008B8C5C /* QuickDropiOSApp.swift in Sources */, EB979F982E50CA23008B8C5C /* QuickDropiOSApp.swift in Sources */,
EB16B61A2E58B4C8007D9792 /* TrustStore.swift in Sources */, EB16B61A2E58B4C8007D9792 /* TrustStore.swift in Sources */,
@@ -1488,8 +1543,8 @@
EB950B4A2E520436002D68FA /* securegcm.pb.swift in Sources */, EB950B4A2E520436002D68FA /* securegcm.pb.swift in Sources */,
EB950B4B2E520436002D68FA /* offline_wire_formats.pb.swift in Sources */, EB950B4B2E520436002D68FA /* offline_wire_formats.pb.swift in Sources */,
EB950B4C2E520436002D68FA /* InboundNearbyConnection.swift in Sources */, EB950B4C2E520436002D68FA /* InboundNearbyConnection.swift in Sources */,
A1B2C3D92F33AA0100ABC001 /* MirroredNotificationSupport.swift in Sources */,
EB16B6132E58B4B9007D9792 /* IdentityManager.swift in Sources */, EB16B6132E58B4B9007D9792 /* IdentityManager.swift in Sources */,
EB4F9D212EC26C8100A74A0F /* EXIFUtils.swift in Sources */,
EB950B4D2E520436002D68FA /* NearbyConnection.swift in Sources */, EB950B4D2E520436002D68FA /* NearbyConnection.swift in Sources */,
EB950B502E520436002D68FA /* wire_format.pb.swift in Sources */, EB950B502E520436002D68FA /* wire_format.pb.swift in Sources */,
EB950B582E5205B4002D68FA /* Zip.swift in Sources */, EB950B582E5205B4002D68FA /* Zip.swift in Sources */,
@@ -1510,7 +1565,6 @@
EB950B3F2E52038A002D68FA /* FilePicker.swift in Sources */, EB950B3F2E52038A002D68FA /* FilePicker.swift in Sources */,
EB4F9D252EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */, EB4F9D252EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */,
EB950B402E52038A002D68FA /* Configuration.swift in Sources */, EB950B402E52038A002D68FA /* Configuration.swift in Sources */,
EB950B412E52038A002D68FA /* PieProgressView.swift in Sources */,
EB950B592E520649002D68FA /* AudioManager.swift in Sources */, EB950B592E520649002D68FA /* AudioManager.swift in Sources */,
EB950B752E52111C002D68FA /* Attachments.swift in Sources */, EB950B752E52111C002D68FA /* Attachments.swift in Sources */,
EB950B6F2E5206E2002D68FA /* ShareViewController.swift in Sources */, EB950B6F2E5206E2002D68FA /* ShareViewController.swift in Sources */,
@@ -1579,7 +1633,7 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = QuickDrop; INFOPLIST_KEY_CFBundleDisplayName = QuickDrop;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -1631,7 +1685,7 @@
INFOPLIST_FILE = ShareExtension/Info.plist; INFOPLIST_FILE = ShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = QuickDrop; INFOPLIST_KEY_CFBundleDisplayName = QuickDrop;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -1719,8 +1773,8 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx; SDKROOT = macosx;
SHARED_BUILD = 107; SHARED_BUILD = 138;
SHARED_VERSION = 2.10.3; SHARED_VERSION = 3.0;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1782,8 +1836,8 @@
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
SDKROOT = macosx; SDKROOT = macosx;
SHARED_BUILD = 107; SHARED_BUILD = 138;
SHARED_VERSION = 2.10.3; SHARED_VERSION = 3.0;
STRING_CATALOG_GENERATE_SYMBOLS = YES; STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_EMIT_LOC_STRINGS = YES;
@@ -1826,7 +1880,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Leon Böttger"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
@@ -1891,7 +1945,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Leon Böttger"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
@@ -1955,7 +2009,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Leon Böttger"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
@@ -2021,7 +2075,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Leon Böttger"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSMainNibFile = MainMenu; INFOPLIST_KEY_NSMainNibFile = MainMenu;
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
@@ -2077,7 +2131,7 @@
INFOPLIST_FILE = "QuickDropShareExtension copy-Info.plist"; INFOPLIST_FILE = "QuickDropShareExtension copy-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = QuickDrop; INFOPLIST_KEY_CFBundleDisplayName = QuickDrop;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -2129,7 +2183,7 @@
INFOPLIST_FILE = "QuickDropShareExtension copy-Info.plist"; INFOPLIST_FILE = "QuickDropShareExtension copy-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = QuickDrop; INFOPLIST_KEY_CFBundleDisplayName = QuickDrop;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
@@ -2171,7 +2225,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Leon Böttger"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -2216,7 +2270,7 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES; INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025 Leon Böttger"; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network."; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library."; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -2256,7 +2310,7 @@
INFOPLIST_FILE = iOSShareExtension/Info.plist; INFOPLIST_FILE = iOSShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = iOSShareExtension; INFOPLIST_KEY_CFBundleDisplayName = iOSShareExtension;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -2287,7 +2341,7 @@
INFOPLIST_FILE = iOSShareExtension/Info.plist; INFOPLIST_FILE = iOSShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = iOSShareExtension; INFOPLIST_KEY_CFBundleDisplayName = iOSShareExtension;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/devicekit/DeviceKit", "location" : "https://github.com/devicekit/DeviceKit",
"state" : { "state" : {
"revision" : "581df61650bc457ec00373a592a84be3e7468eb1", "revision" : "56b997e8a61707218f9af09f32b2a1d1806fd792",
"version" : "5.7.0" "version" : "5.8.0"
} }
}, },
{ {
@@ -52,7 +52,7 @@
"location" : "https://github.com/leonboe1/LUI.git", "location" : "https://github.com/leonboe1/LUI.git",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "19b90ffd9778db54391501f0ba1319e0a1c12b1f" "revision" : "3582f59e758c39a849ab691f3fe26f7d0f618a94"
} }
}, },
{ {
@@ -87,8 +87,8 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git", "location" : "https://github.com/apple/swift-protobuf.git",
"state" : { "state" : {
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d", "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6",
"version" : "1.33.3" "version" : "1.36.1"
} }
}, },
{ {
@@ -124,7 +124,7 @@
"location" : "https://github.com/siteline/swiftui-introspect", "location" : "https://github.com/siteline/swiftui-introspect",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "b10fc8a284abb9bd908d77243d2ba3b3d8ea6ebc" "revision" : "26986a57e31c813f98262ddb62d4b07d40008535"
} }
}, },
{ {
@@ -34,7 +34,6 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "de"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
@@ -51,6 +50,9 @@
ReferencedContainer = "container:QuickDrop.xcodeproj"> ReferencedContainer = "container:QuickDrop.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../../ConfigSynced.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
+87 -109
View File
@@ -20,14 +20,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
private var statusItem: NSStatusItem? private var statusItem: NSStatusItem?
var welcomeWindow: NSWindow? var welcomeWindow: NSWindow?
var introductionWindow: NSWindow?
var plusWindow: NSWindow? var plusWindow: NSWindow?
private var sheetView: NSPanel? = nil private var sheetView: NSPanel? = nil
private var sheetAttachedWindow: NSWindow? = nil private var sheetAttachedWindow: NSWindow? = nil
private var errorAlertHandler = ErrorAlertHandler.shared private var errorAlertHandler = ErrorAlertHandler.shared
private var iapManager: IAPManager?
private var receiveModel: ReceiveModel? private var receiveModel: ReceiveModel?
var showsFirewallAlert = false var showsFirewallAlert = false
@@ -36,12 +34,42 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// MARK: NSApplicationDelegate functions // MARK: NSApplicationDelegate functions
func applicationWillFinishLaunching(_: Notification) {
LUIInit(configuration: configuration)
}
func applicationDidFinishLaunching(_: Notification) { func applicationDidFinishLaunching(_: Notification) {
// app did not lauch before
if !Settings.sharedInstance.appLaunchedBefore {
runAfter(seconds: 0.1) { log("[AppDelegate] Opening Welcome Screen") }
DispatchQueue.main.async {
// open welcome screen in next cycle (await LUIInit)
self.openMainWindow()
}
// user installed the app after the IAP was implemented, set the user as eligible for IAP
Settings.sharedInstance.isEligibleForIap = true
LUIInit(configuration: configuration)
} else {
// app launched before
#if GITHUB
runAfter(seconds: 0.1) { log("[AppDelegate] Downloaded from GitHub.") }
#else
// user installed the app before the IAP was implemented, grant the plus version
if !Settings.sharedInstance.isEligibleForIap {
runAfter(seconds: 0.1) { log("[AppDelegate] Granting QuickDrop+ for old user") }
UserDefaults.standard.set(true, forKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue)
}
#endif
BezelNotification.show(messageText: "ReadyToReceive".localized(), icon: .receiveIcon)
// only start receiving immediately for existing user, for new users we want to delay the permission prompt
LUIInit(configuration: configuration)
startReceiving()
}
UNUserNotificationCenter.current().delegate = self
// MARK: Menu Bar
let menu = NSMenu() let menu = NSMenu()
@@ -61,7 +89,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
menu.addItem(NSMenuItem.separator()) menu.addItem(NSMenuItem.separator())
let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openApplicableScreen), keyEquivalent: "") let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openMainWindow), keyEquivalent: "")
userManualItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: nil) userManualItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: nil)
menu.addItem(userManualItem) menu.addItem(userManualItem)
@@ -74,44 +102,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
statusItem?.menu = menu statusItem?.menu = menu
statusItem?.behavior = .removalAllowed statusItem?.behavior = .removalAllowed
iapManager = IAPManager.sharedInstance
// app did not lauch before // MARK: Nearby Connections Callbacks
if !Settings.shared.appLaunchedBefore {
log("[AppDelegate] Opening Welcome Screen")
// open welcome screen
openIntroductionWindow()
// user installed the app after the IAP was implemented, set the user as eligible for IAP
Settings.shared.isEligibleForIap = true
} else {
// app launched before
#if GITHUB
log("[AppDelegate] Downloaded from GitHub.")
#else
// user installed the app before the IAP was implemented, grant the plus version
if !Settings.shared.isEligibleForIap {
log("[AppDelegate] Granting QuickDrop+ for old user")
Settings.shared.gotPlus = true
UserDefaults.standard.set(true, forKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue)
} else {
if !fullVersion() {
log("[AppDelegate] New user - QuickDrop+ not available")
}
else {
log("[AppDelegate] QuickDrop+ available")
}
}
#endif
BezelNotification.show(messageText: "ReadyToReceive".localized(), icon: .receiveIcon)
// only start receiving immediately for existing user, for new users we want to delay the permission prompt
startReceiving()
}
UNUserNotificationCenter.current().delegate = self
NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in
if isConnected { if isConnected {
@@ -128,11 +120,20 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
self.visibleItem?.title = self.getDefaultVisibleLabel() self.visibleItem?.title = self.getDefaultVisibleLabel()
} }
// MARK: Logging
log("[AppDelegate] Application did finish launching") log("[AppDelegate] Application did finish launching")
log("[AppDelegate] Currently running apps: \(NSWorkspace.shared.runningApplications.map { $0.localizedName ?? "-" })")
} }
func startReceiving() { func startReceiving() {
guard receiveModel == nil else {
log("[AppDelegate] startReceiving called while receiver is already active. Ignoring duplicate call.")
return
}
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
if shouldOpen { if shouldOpen {
self.openPlusScreen() self.openPlusScreen()
@@ -150,7 +151,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
func windowWillClose(_ notification: Notification) { func windowWillClose(_ notification: Notification) {
if let window = notification.object as? NSWindow, window == welcomeWindow { if let window = notification.object as? NSWindow, window == welcomeWindow {
log("Welcome window closed") log("[AppDelegate] Welcome window closed")
welcomeWindow = nil welcomeWindow = nil
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
} }
@@ -158,22 +159,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
self.openApplicableScreen() self.openMainWindow()
statusItem?.isVisible = true statusItem?.isVisible = true
return true return true
} }
@objc func openApplicableScreen() {
if Settings.shared.appLaunchedBefore {
openMainWindow()
}
else {
openIntroductionWindow()
}
}
public func userNotificationCenter(_: UNUserNotificationCenter, public func userNotificationCenter(_: UNUserNotificationCenter,
willPresent _: UNNotification, willPresent _: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
@@ -183,22 +174,42 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
func applicationWillTerminate(_: Notification) { func applicationWillTerminate(_: Notification) {
UNUserNotificationCenter.current().removeAllDeliveredNotifications() UNUserNotificationCenter.current().removeAllDeliveredNotifications()
NearbyConnectionManager.shared.stopDeviceDiscovery()
NearbyConnectionManager.shared.becomeInvisible()
} }
func application(_ application: NSApplication, open urls: [URL]) { func application(_ application: NSApplication, open urls: [URL]) {
log("[AppDelegate] application(open:): Received URLs: \(urls)")
for url in urls { for url in urls {
if url.scheme == "quickdrop" { if url.scheme == "quickdrop" {
switch url.host { switch url.host {
case "sendLog": case "sendLog":
sendLoggingString() // If we're in an extension, redirect to the main app
if Bundle.main.bundlePath.hasSuffix(".appex") {
log("[AppDelegate] sendLoggingString: in extension, redirecting to main app")
if let url = URL(string: "quickdrop://sendLog") {
NSWorkspace.shared.open(url)
}
return
}
LogExportPresenter.showUploadLogsAlert()
case "openLog": case "openLog":
if let url = LogManager.sharedInstance.logFileURL { if let url = LogManager.sharedInstance.logFileURL {
log("[AppDelegate] Opening log file: \(url)")
NSWorkspace.shared.activateFileViewerSelecting([url]) NSWorkspace.shared.activateFileViewerSelecting([url])
} }
else {
log("[AppDelegate] No log file found to open.")
}
case "mockTransferFailure":
ErrorAlertHandler.shared.showErrorAlert(for: "Test Device", error: NearbyError.inputOutput)
case "removeData": case "removeData":
Settings.shared.deleteAllUserDefaults() Settings.sharedInstance.deleteAllUserDefaults()
default: default:
break break
} }
@@ -231,6 +242,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
@objc func openMainWindow() { @objc func openMainWindow() {
defer {
if let welcomeWindow, !Settings.sharedInstance.appLaunchedBefore {
runAfter(seconds: 0.2) {
IntroductionSheetManager.sharedInstance.openIntroductionWindow(on: welcomeWindow, startReceiving: self.startReceiving) {
self.startReceiving()
Settings.sharedInstance.appLaunchedBefore = true
}
}
}
}
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
@@ -273,59 +294,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
} }
@objc func openIntroductionWindow() {
NSApp.setActivationPolicy(.regular)
if let window = introductionWindow {
if window.isMiniaturized {
window.deminiaturize(nil)
}
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
return
}
let introductionView = IntroductionView(startReceiving: startReceiving) {
Settings.shared.appLaunchedBefore = true
self.introductionWindow?.close()
self.introductionWindow = nil
self.openMainWindow()
}
introductionWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: IntroductionView.width, height: IntroductionView.height),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
introductionWindow?.styleMask.insert(.fullSizeContentView)
introductionWindow?.titleVisibility = .hidden
introductionWindow?.titlebarAppearsTransparent = true
introductionWindow?.center()
introductionWindow?.isReleasedWhenClosed = false
introductionWindow?.setFrameAutosaveName("IntroductionScreen")
introductionWindow?.contentView = NSHostingView(rootView: introductionView)
introductionWindow?.delegate = self
NSApp.activate(ignoringOtherApps: true)
introductionWindow?.makeKeyAndOrderFront(nil)
introductionWindow?.level = .normal
}
@objc func openPlusScreen() { @objc func openPlusScreen() {
// If window already exists and is visible, just bring it to the front // If window already exists and is visible, just bring it to the front
if let window = plusWindow, window.isVisible { if let window = plusWindow, window.isVisible {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
log("Plus screen already open, bringing to front") log("[AppDelegate] Plus screen already open, bringing to front")
return return
} }
log("Opening Plus screen") log("[AppDelegate] Opening Plus screen")
// Create the welcome screen SwiftUI view // Create the welcome screen SwiftUI view
let plusView = GetPlusViewV4(showSheet: Binding(get: { self.plusWindow != nil }, set: { newValue in let plusView = GetPlusViewV4(showSheet: Binding(get: { self.plusWindow != nil }, set: { newValue in
@@ -335,7 +313,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
} }
}), hasSheet: false) }), hasSheet: false)
let plusViewWidth: CGFloat = 550 let plusViewWidth: CGFloat = 750
let plusViewHeight: CGFloat = 750 let plusViewHeight: CGFloat = 750
// Create an NSWindow to host the SwiftUI view // Create an NSWindow to host the SwiftUI view
@@ -365,7 +343,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// Ensure the window is always on top // Ensure the window is always on top
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.level = .normal window.level = .floating
} }
@@ -410,10 +388,10 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
let scanner = DeviceToDeviceHeuristicScanner.shared let scanner = DeviceToDeviceHeuristicScanner.shared
scanner.scan { allowed in scanner.scan { allowed in
if allowed { if allowed {
log("✅ Device-to-device likely allowed (peer responded on LAN).") log("[AppDelegate] ✅ Device-to-device likely allowed (peer responded on LAN).")
ErrorAlertHandler.shared.closeApIsolationAlert() ErrorAlertHandler.shared.closeApIsolationAlert()
} else { } else {
log("❌ Device-to-device check failed. Informing user...") log("[AppDelegate] ❌ Device-to-device check failed. Informing user...")
ErrorAlertHandler.shared.openAlert(type: .ApIsolation) ErrorAlertHandler.shared.openAlert(type: .ApIsolation)
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 618 KiB

After

Width:  |  Height:  |  Size: 585 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 663 KiB

After

Width:  |  Height:  |  Size: 672 KiB

@@ -6,32 +6,28 @@
// //
import LUI import LUI
import Foundation import SwiftUI
let configuration = LUIConfiguration( let configuration = LUIConfiguration(
appName: "QuickDrop", appName: "QuickDrop",
plusVersionInfo: GetPlusViewInformation(lifetimeID: "plusversion", monthlyID: "quickdrop.monthly", yearlyID: "quickdrop.yearly", plusViewHeader: isMac() ? "plusview_title" : nil, description: isMac() ? "plusview_description" : "plusview_description_phone", onBoughtPlusVersionAlertMessage: "plusview_success_description", plusVersionFeatures: [], settingsLabelColor: .orange, settingsFooter: "plusview_footer_phone"), plusVersionInfo: GetPlusViewInformation(lifetimeID: "plusversion", monthlyID: "quickdrop.monthly", yearlyID: "quickdrop.yearly", plusViewHeader: isMac() ? "plusview_title" : nil, description: isMac() ? "plusview_description" : "plusview_description_phone", onBoughtPlusVersionAlertMessage: "plusview_success_description", plusVersionFeatures: [], settingsLabelColor: .orange, settingsFooter: "plusview_footer_phone"),
setPlusVersion: { result in
#if !GITHUB
DispatchQueue.main.async {
Settings.shared.gotPlus = result
}
#endif
},
hasRoundedFont: false, hasRoundedFont: false,
switchedToIAPafterBundleVersion: AppVersionBundle(appVersion: "1.2", buildVersion: 0, legacyUserDefaultsKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue), switchedToIAPafterBundleVersion: AppVersionBundle(appVersion: "1.2", buildVersion: 0, legacyUserDefaultsKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue),
copyrightInformation: licenseText, copyrightInformation: licenseText,
logUploadDisclaimerMessage: "log_upload_disclaimer_message",
loggingUrl: AppGroup.appGroupDirectory, loggingUrl: AppGroup.appGroupDirectory,
introductionViewInformation: "QuickDropWelcomeDescription", introductionViewInformation: "QuickDropWelcomeDescription",
usesAutomaticReviewPrompt: false, introductionViewImage: Image(.quickDropMockup),
usesAutomaticReviewPromptAndShareSheet: false,
usesPagedIntroduction: true, usesPagedIntroduction: true,
@@ -41,5 +37,5 @@ let configuration = LUIConfiguration(
supportsPortraitSplitView: false, supportsPortraitSplitView: false,
supportsDarkModeShadow: false supportsDarkModeShadow: true
) )
+7 -4
View File
@@ -11,7 +11,8 @@
"value" : "QuickDrop" "value" : "QuickDrop"
} }
} }
} },
"shouldTranslate" : false
}, },
"CFBundleName" : { "CFBundleName" : {
"comment" : "Bundle name", "comment" : "Bundle name",
@@ -23,7 +24,8 @@
"value" : "QuickDrop" "value" : "QuickDrop"
} }
} }
} },
"shouldTranslate" : false
}, },
"NSHumanReadableCopyright" : { "NSHumanReadableCopyright" : {
"comment" : "Copyright (human-readable)", "comment" : "Copyright (human-readable)",
@@ -32,10 +34,11 @@
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "new", "state" : "new",
"value" : "" "value" : "Copyright © 2025-2026 Leon Böttger"
} }
} }
} },
"shouldTranslate" : false
}, },
"NSLocalNetworkUsageDescription" : { "NSLocalNetworkUsageDescription" : {
"comment" : "Privacy - Local Network Usage Description", "comment" : "Privacy - Local Network Usage Description",
+15592 -7897
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -6,5 +6,9 @@
<array> <array>
<string>group.com.leonboettger.neardrop</string> <string>group.com.leonboettger.neardrop</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
</array>
</dict> </dict>
</plist> </plist>
+10 -13
View File
@@ -5,25 +5,18 @@
// Created by Leon Böttger on 09.09.25. // Created by Leon Böttger on 09.09.25.
// //
import LUI
import SwiftUI import SwiftUI
class Settings: ObservableObject { final class Settings: SharedInstance {
private init() {} private init() {}
static var shared = Settings() static var sharedInstance = Settings()
// MARK: - Published UserDefaults Backed Properties // MARK: - Published UserDefaults Backed Properties
#if GITHUB
let gotPlus = true
#else
@Published var gotPlus = UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue)
{ didSet { UserDefaults.standard.set(gotPlus, forKey: UserDefaultsKeys.plusVersion.rawValue) }}
#endif
@Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) @Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue)
{ didSet { UserDefaults.standard.set(automaticallyAcceptFiles, forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) }} { didSet { UserDefaults.standard.set(automaticallyAcceptFiles, forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) }}
@@ -43,6 +36,10 @@ class Settings: ObservableObject {
{ didSet { UserDefaults.standard.set(incomingTransmissionCount, forKey: UserDefaultsKeys.transmissionCount.rawValue) }} { didSet { UserDefaults.standard.set(incomingTransmissionCount, forKey: UserDefaultsKeys.transmissionCount.rawValue) }}
var incomingNotificationCount: Int = UserDefaults.standard.integer(forKey: UserDefaultsKeys.incomingNotificationCount.rawValue)
{ didSet { UserDefaults.standard.set(incomingNotificationCount, forKey: UserDefaultsKeys.incomingNotificationCount.rawValue) }}
var endpointID: String? = UserDefaults.standard.string(forKey: UserDefaultsKeys.endpointID.rawValue) var endpointID: String? = UserDefaults.standard.string(forKey: UserDefaultsKeys.endpointID.rawValue)
{ didSet { UserDefaults.standard.set(endpointID, forKey: UserDefaultsKeys.endpointID.rawValue) }} { didSet { UserDefaults.standard.set(endpointID, forKey: UserDefaultsKeys.endpointID.rawValue) }}
@@ -65,18 +62,18 @@ class Settings: ObservableObject {
} }
defaults.synchronize() defaults.synchronize()
Settings.shared = Settings() Settings.sharedInstance = Settings()
} }
// MARK: - UserDefaults Keys // MARK: - UserDefaults Keys
public enum UserDefaultsKeys: String, CaseIterable { public enum UserDefaultsKeys: String, CaseIterable, RawRepresentable {
case isEligibleForIap = "isEligibleForIap" case isEligibleForIap = "isEligibleForIap"
case appLaunchedBefore = "ShowedWelcomeScreen" case appLaunchedBefore = "ShowedWelcomeScreen"
case plusVersion = "plusVersion"
case transmissionCount = "reviewRequestCountKey" case transmissionCount = "reviewRequestCountKey"
case incomingNotificationCount = "incomingNotificationCount"
case automaticallyAcceptFiles = "automaticallyAcceptFiles" case automaticallyAcceptFiles = "automaticallyAcceptFiles"
case saveFolderBookmark = "saveFolderBookmark" case saveFolderBookmark = "saveFolderBookmark"
case openFinderAfterReceiving = "openFinderAfterReceiving" case openFinderAfterReceiving = "openFinderAfterReceiving"
+10 -73
View File
@@ -36,7 +36,6 @@ class ErrorAlertHandler {
var description = "" var description = ""
let fixInstructions = " " + "Error.FixInstructions".localized() let fixInstructions = " " + "Error.FixInstructions".localized()
if let ne = (error as? NearbyError) { if let ne = (error as? NearbyError) {
switch ne { switch ne {
case .inputOutput: case .inputOutput:
@@ -61,6 +60,8 @@ class ErrorAlertHandler {
description = errorMessage + fixInstructions description = errorMessage + fixInstructions
case .ukey2: case .ukey2:
description = "Error.Crypto".localized() + ": \(ne.localizedDescription)" + fixInstructions description = "Error.Crypto".localized() + ": \(ne.localizedDescription)" + fixInstructions
case .notificationSyncNotTrusted:
description = "NotificationSyncNotTrustedMessage".localized()
case .canceled(reason: let reason): case .canceled(reason: let reason):
if reason == .timedOut { if reason == .timedOut {
description = reason.localizedDescription() + fixInstructions description = reason.localizedDescription() + fixInstructions
@@ -83,7 +84,7 @@ class ErrorAlertHandler {
let title = String(format: "TransferError".localized(), arguments: [deviceName]) let title = String(format: "TransferError".localized(), arguments: [deviceName])
log("Showing alert with title: \"\(title)\" and description: \"\(description)\"") log("Showing alert with title: \"\(title)\" and description: \"\(description)\"")
log("Unsuccessful transmission. Already successful transmissions: \(Settings.shared.incomingTransmissionCount)") log("Unsuccessful transmission. Already successful transmissions: \(Settings.sharedInstance.incomingTransmissionCount)")
#if os(macOS) #if os(macOS)
@@ -102,8 +103,13 @@ class ErrorAlertHandler {
self.isAlertShown = false self.isAlertShown = false
if result == .alertFirstButtonReturn { if result == .alertFirstButtonReturn {
//log("Sending logging string") if Bundle.main.bundlePath.hasSuffix(".appex") {
//sendLoggingString() if let url = URL(string: "quickdrop://sendLog") {
NSWorkspace.shared.open(url)
}
} else {
LogExportPresenter.showUploadLogsAlert(openSupportMailAfterUpload: false)
}
} }
#else #else
showAlert(title: title, message: description) showAlert(title: title, message: description)
@@ -161,75 +167,6 @@ class ErrorAlertHandler {
} }
#if os(macOS)
func sendLoggingString() {
// If we're in an extension, redirect to the main app
if Bundle.main.bundlePath.hasSuffix(".appex") {
log("sendLoggingString: in extension, redirecting to main app")
if let url = URL(string: "quickdrop://sendLog") {
NSWorkspace.shared.open(url)
}
return
}
// We're in the main app – do the actual sending
if let url = LogManager.sharedInstance.logFileURL {
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
log("Sending logging string with file URL: \(url)")
log("Currently running apps: \(NSWorkspace.shared.runningApplications.map { $0.localizedName ?? "Unknown" })")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: {
sendEmailWithAttachment(
fileURL: url,
recipients: ["quickdrop@leonboettger.com"],
subject: "QuickDrop \(appVersion) - \(getDeviceAndSystem())"
)
})
}
}
fileprivate func getDeviceAndSystem() -> String {
// Get Mac model identifier
var size: Int = 0
sysctlbyname("hw.model", nil, &size, nil, 0)
var modelBuffer = [CChar](repeating: 0, count: size)
sysctlbyname("hw.model", &modelBuffer, &size, nil, 0)
let model = String(cString: modelBuffer)
// Get macOS version
let version = ProcessInfo.processInfo.operatingSystemVersion
let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
return "\(model) - \(versionString)"
}
func sendEmailWithAttachment(fileURL: URL, recipients: [String], subject: String) {
guard let emailService = NSSharingService(named: .composeEmail) else {
log("No email service available")
return
}
emailService.recipients = recipients
emailService.subject = subject
emailService.perform(withItems: [fileURL])
log("Email service opened with recipients: \(recipients) and subject: \(subject)")
}
func copyToClipboard(_ text: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(text, forType: .string)
}
#endif
enum AlertType: String { enum AlertType: String {
case ApIsolation case ApIsolation
case NetworkFilter case NetworkFilter
+6 -1
View File
@@ -19,7 +19,12 @@ func sendToSharingService(items: [Any]) {
let alert = NSAlert() let alert = NSAlert()
alert.alertStyle = .critical alert.alertStyle = .critical
alert.messageText = "ShareExtensionDisabledTitle".localized() alert.messageText = "ShareExtensionDisabledTitle".localized()
alert.informativeText = "ShareExtensionDisabledDescription".localized() if #available(macOS 13.0, *) {
alert.informativeText = "ShareExtensionDisabledDescription".localized()
}
else {
alert.informativeText = "ShareExtensionDisabledDescriptionLegacy".localized()
}
alert.addButton(withTitle: "CloseAlert".localized()) alert.addButton(withTitle: "CloseAlert".localized())
alert.runModal() alert.runModal()
} }
@@ -0,0 +1,65 @@
//
// AlertManager.swift
// QuickDrop
//
// Created by Leon Böttger on 01.02.26.
//
import LUI
import SwiftUI
public final class IntroductionSheetManager: ObservableObject {
public static let sharedInstance = IntroductionSheetManager()
private var sheetView: NSPanel? = nil
private var sheetAttachedWindow: NSWindow? = nil
func openIntroductionWindow(on window: NSWindow, startReceiving: @escaping () -> Void, onFinish: @escaping () -> Void) {
openSheetWindow(on: window, contentView: IntroductionView(startReceiving: startReceiving, onFinish: {
self.closeSheetWindow()
onFinish()
}))
}
private func openSheetWindow<Content: View>(on window: NSWindow, contentView: Content) {
if sheetView == nil {
// Create the panel
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: IntroductionView.width, height: IntroductionView.height),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
// Set content (can be a NSHostingView for SwiftUI views)
let hostingView = NSHostingView(rootView: contentView)
panel.contentView = hostingView
// Present as sheet
window.beginSheet(panel, completionHandler: { response in
log("[IntroductionSheetManager] Sheet dismissed with response: \(response)")
})
self.sheetAttachedWindow = window
self.sheetView = panel
}
}
func closeSheetWindow() {
if let mainWindow = sheetAttachedWindow, let sheetView = sheetView {
log("[IntroductionSheetManager] Closing sheetView window")
mainWindow.endSheet(sheetView)
self.sheetView = nil
self.sheetAttachedWindow = nil
}
}
}
@@ -63,6 +63,10 @@ struct IntroductionView: View {
.padding(.top) .padding(.top)
.minimumScaleFactor(0.5) .minimumScaleFactor(0.5)
.opacity(0.8) .opacity(0.8)
if currentPage == .enableShareExtension {
EnableExtensionView(canShowIntialCheckmark: true)
}
} }
.padding(.horizontal, 40) .padding(.horizontal, 40)
@@ -78,8 +82,7 @@ struct IntroductionView: View {
Divider() Divider()
HStack { HStack {
if let skipAction = currentPage.skipAction, if let skipAction = currentPage.skipAction, pollingPage != nil {
(currentPage.needRequestBeforeSkipAvailable == false || pollingPage != nil) {
Button("introduction_skip") { Button("introduction_skip") {
self.skipAction = skipAction self.skipAction = skipAction
@@ -106,22 +109,6 @@ struct IntroductionView: View {
Spacer() Spacer()
if let helpAction = currentPage.helpAction, pollingPage != nil {
HStack(spacing: 3) {
Image(systemName: "questionmark.circle")
Text(helpAction.question.localized())
}
.opacity(0.8)
.foregroundColor(.blue)
.font(.system(size: 15))
.onTapGesture {
helpActionAlert = helpAction
}
.alert(item: $helpActionAlert) { helpAction in
.init(title: Text(helpAction.question.localized()), message: Text(helpAction.answer.localized()), dismissButton: .cancel(Text("introduction_help_action_alert_close")))
}
}
continueArea continueArea
} }
.padding(.horizontal) .padding(.horizontal)
@@ -156,6 +143,7 @@ struct IntroductionView: View {
} }
} }
@ViewBuilder @ViewBuilder
private var continueArea: some View { private var continueArea: some View {
ZStack { ZStack {
@@ -181,6 +169,7 @@ struct IntroductionView: View {
} }
private func pageDidChange(to newPage: IntroductionPage) { private func pageDidChange(to newPage: IntroductionPage) {
stopPolling() stopPolling()
@@ -190,6 +179,7 @@ struct IntroductionView: View {
} }
} }
private func continueTapped() { private func continueTapped() {
// If already allowed, advance immediately // If already allowed, advance immediately
if currentPage.canContinue() { if currentPage.canContinue() {
@@ -199,41 +189,18 @@ struct IntroductionView: View {
// Not allowed yet: show loading and start polling every 1s // Not allowed yet: show loading and start polling every 1s
pollingPage = currentPage pollingPage = currentPage
// Page-specific "kickoff" action that should happen only after user presses continue
if currentPage == .enableShareExtension {
if let url = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension") {
NSWorkspace.shared.open(url)
}
}
if currentPage == .localNetworkAccess {
Task {
do {
try await Task.sleep(nanoseconds: 20_000_000_000)
} catch {
return
}
await MainActor.run {
guard currentPage == .localNetworkAccess else { return }
guard skipAction == nil else { return }
if let action = currentPage.skipAction {
skipAction = action
}
}
}
}
} }
private func triggerLocalNetworkPermission() { private func triggerLocalNetworkPermission() {
self.startReceiving() self.startReceiving()
} }
private func advance() { private func advance() {
if currentPage == .splash { if currentPage == .localNetworkAccess {
let _ = DeviceToDeviceHeuristicScanner.shared.hasLocalNetworkAccess(completion: {_ in })
triggerLocalNetworkPermission() triggerLocalNetworkPermission()
} }
@@ -244,6 +211,7 @@ struct IntroductionView: View {
} }
} }
private func stopPolling() { private func stopPolling() {
// Cancels the .task(id:) by changing the id and resets UI state // Cancels the .task(id:) by changing the id and resets UI state
pollingPage = nil pollingPage = nil
@@ -251,7 +219,6 @@ struct IntroductionView: View {
} }
enum IntroductionPage: CaseIterable { enum IntroductionPage: CaseIterable {
case splash case splash
case noWifi case noWifi
@@ -274,6 +241,7 @@ enum IntroductionPage: CaseIterable {
} }
} }
var subtitle: String { var subtitle: String {
switch self { switch self {
case .splash: case .splash:
@@ -281,49 +249,29 @@ enum IntroductionPage: CaseIterable {
case .noWifi: case .noWifi:
return "introduction_no_wifi_description" return "introduction_no_wifi_description"
case .localNetworkAccess: case .localNetworkAccess:
return "introduction_local_network_access_description" return "introduction_local_network_access_description".localized(with: "introduction_continue".localized())
case .enableShareExtension: case .enableShareExtension:
if #available(macOS 13.0, *) { if #available(macOS 13.0, *) {
return "introduction_enable_share_extension_description" return "introduction_enable_share_extension_description".localized(with: "EnableQuickDropExtension".localized())
}
else {
return "introduction_enable_share_extension_description_legacy".localized(with: "EnableQuickDropExtension".localized())
} }
return "introduction_enable_share_extension_description_macos12"
case .finished: case .finished:
return "introduction_finished_description" return "introduction_finished_description"
} }
} }
var skipAction: SkipAction? { var skipAction: SkipAction? {
switch self { switch self {
case .splash:
return nil
case .noWifi: case .noWifi:
return .init(action: presentNextPage, warningTitle: "introduction_no_wifi_skip_title", warningMessage: "introduction_no_wifi_skip_message") return .init(action: presentNextPage, warningTitle: "introduction_no_wifi_skip_title", warningMessage: "introduction_no_wifi_skip_message")
case .localNetworkAccess:
return .init(action: presentNextPage, warningTitle: "introduction_local_network_access_skip_title", warningMessage: "introduction_local_network_access_skip_message")
case .enableShareExtension:
return .init(action: presentNextPage, warningTitle: "introduction_enable_share_extension_skip_title", warningMessage: "introduction_enable_share_extension_skip_message")
case .finished:
return nil
}
}
var helpAction: HelpAction? {
switch self {
case .enableShareExtension:
return .init(question: "introduction_enable_share_extension_not_visible", answer: "introduction_enable_share_extension_not_visible_answer")
default: default:
return nil return nil
} }
} }
var needRequestBeforeSkipAvailable: Bool {
switch self {
case .localNetworkAccess:
return true
default:
return false
}
}
var canPerformAction: Bool { var canPerformAction: Bool {
switch self { switch self {
@@ -334,11 +282,11 @@ enum IntroductionPage: CaseIterable {
} }
} }
func presentNextPage() -> IntroductionPage? { func presentNextPage() -> IntroductionPage? {
switch self { switch self {
case .splash: case .splash:
return nil return NearbyConnectionManager.shared.isConnectedToLocalNetwork ? firstPermissionPane() : .noWifi
//NearbyConnectionManager.shared.isConnectedToLocalNetwork ? firstPermissionPane() : .noWifi
case .noWifi: case .noWifi:
return firstPermissionPane() return firstPermissionPane()
case .localNetworkAccess: case .localNetworkAccess:
@@ -350,6 +298,7 @@ enum IntroductionPage: CaseIterable {
} }
} }
private func firstPermissionPane() -> IntroductionPage { private func firstPermissionPane() -> IntroductionPage {
if #available(macOS 15.0, *) { if #available(macOS 15.0, *) {
// Local network access is requested first on macOS 15+ // Local network access is requested first on macOS 15+
@@ -358,19 +307,17 @@ enum IntroductionPage: CaseIterable {
return .enableShareExtension return .enableShareExtension
} }
func canContinue() -> Bool { func canContinue() -> Bool {
switch self { switch self {
case .noWifi: case .noWifi:
return NearbyConnectionManager.shared.isConnectedToLocalNetwork return NearbyConnectionManager.shared.isConnectedToLocalNetwork
case .enableShareExtension:
return NSSharingService(named: NSSharingService.Name("com.leonboettger.neardrop.ShareExtension")) != nil
case .localNetworkAccess:
return DeviceToDeviceHeuristicScanner.shared.hasLocalNetworkAccess(completion: {_ in })
default: default:
return true return true
} }
} }
func topHeader(with height: CGFloat) -> some View { func topHeader(with height: CGFloat) -> some View {
ZStack { ZStack {
@@ -395,6 +342,7 @@ enum IntroductionPage: CaseIterable {
.foregroundColor(.blue) .foregroundColor(.blue)
} }
private func imageView(for image: String, with height: CGFloat) -> some View { private func imageView(for image: String, with height: CGFloat) -> some View {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
@@ -403,6 +351,7 @@ enum IntroductionPage: CaseIterable {
.padding(.top, height * 0.13) .padding(.top, height * 0.13)
} }
struct SkipAction: Identifiable { struct SkipAction: Identifiable {
let action: () -> IntroductionPage? let action: () -> IntroductionPage?
let warningTitle: String let warningTitle: String
@@ -411,6 +360,7 @@ enum IntroductionPage: CaseIterable {
var id: String { warningTitle } var id: String { warningTitle }
} }
struct HelpAction: Identifiable { struct HelpAction: Identifiable {
let question: String let question: String
let answer: String let answer: String
@@ -0,0 +1,89 @@
//
// NotificationSyncQrSheetManager.swift
// QuickDrop
//
// Created by Codex on 2026-03-15.
//
import AppKit
import SwiftUI
final class NotificationSyncQrSheetManager: NSObject, NSWindowDelegate {
static let shared = NotificationSyncQrSheetManager()
private var alert: NSAlert?
private weak var alertWindow: NSWindow?
func open(token: String, receiverFingerprint: String, deviceName: String) {
guard alert == nil else { return }
guard let qrImage = NotificationSyncPairingToken.makeQrImage(
token: token,
receiverFingerprint: receiverFingerprint
) else { return }
let alert = NSAlert()
alert.messageText = "NotificationSyncQrPairingTitle".localized()
alert.informativeText = "NotificationSyncQrPairingDescription".localized(with: deviceName)
alert.addButton(withTitle: "Cancel".localized())
alert.alertStyle = .informational
let accessoryView = NSHostingView(rootView: NotificationSyncQrAccessoryView(qrImage: qrImage))
accessoryView.frame = NSRect(x: 0, y: 0, width: 180, height: 180)
alert.accessoryView = accessoryView
alert.layout()
alert.buttons.forEach { button in
button.target = self
button.action = #selector(handleAlertButton(_:))
}
self.alert = alert
let alertWindow = alert.window
alertWindow.delegate = self
self.alertWindow = alertWindow
if let screen = NSApp.mainWindow?.screen ?? NSScreen.main {
let frame = alertWindow.frame
let screenFrame = screen.visibleFrame
let origin = NSPoint(
x: screenFrame.midX - (frame.width / 2),
y: screenFrame.midY - (frame.height / 2)
)
alertWindow.setFrameOrigin(origin)
} else {
alertWindow.center()
}
NSApp.activate(ignoringOtherApps: true)
alertWindow.makeKeyAndOrderFront(nil)
}
func close() {
alertWindow?.close()
alert = nil
alertWindow = nil
}
func windowWillClose(_ notification: Notification) {
alert = nil
alertWindow = nil
}
@objc private func handleAlertButton(_ sender: NSButton) {
close()
}
}
private struct NotificationSyncQrAccessoryView: View {
let qrImage: Image
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.white)
qrImage
.resizable()
.interpolation(.none)
.aspectRatio(1, contentMode: .fit)
.padding(10)
}
.frame(width: 160, height: 160)
}
}
-219
View File
@@ -1,219 +0,0 @@
//
// IncomingFileTransmissionView.swift
// QuickDrop
//
// Created by Leon Böttger on 05.10.25.
//
import SwiftUI
import AppKit
let toastViewSize = CGSize(width: 400, height: 100)
struct QuickDropToastView: View {
@ObservedObject var settings = Settings.shared
@ObservedObject var receiveModel: ReceiveModel
@State var autoHider = DispatchWorkItem(block: {})
/// Cancel while receiving.
public var onCancel: () -> Void
init(
receiveModel: ReceiveModel,
onCancel: @escaping () -> Void = {}
) {
self.receiveModel = receiveModel
self.onCancel = onCancel
}
public var body: some View {
let isDone = receiveModel.toastActions != nil
VStack(spacing: 12) {
HStack(spacing: 12) {
ZStack(alignment: .bottomLeading) {
Image(.quickDropIcon)
.resizable()
.scaledToFill()
.frame(width: 44, height: 44)
.clipShape(Circle())
}
VStack(alignment: .leading, spacing: 2) {
Text(String("QuickDrop"))
.font(.system(size: 16, weight: .semibold))
Text(isDone ? "FileTransferCompleted" : "Receiving")
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 12)
}
ZStack {
if let actions = receiveModel.toastActions {
HStack(spacing: 8) {
QuickDropToastViewButton(title: "OpenInFinder") {
actions.openFilesAction()
actions.closeToastAction()
}
if let onImportToPhotos = actions.importPhotosAction {
QuickDropToastViewButton(title: "ImportToPhotos") {
onImportToPhotos()
actions.closeToastAction()
}
}
Spacer()
QuickDropToastViewButton(title: "Done", action: actions.closeToastAction)
.onAppear {
self.autoHider = DispatchWorkItem(block: {
actions.closeToastAction()
})
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: self.autoHider)
}
.onDisappear {
self.autoHider.cancel()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
HStack {
ZStack {
CapsuleProgress(value: 0.5)
.opacity(0)
if let progress = receiveModel.progress {
CapsuleProgress(value: progress)
}
}
.animation(.easeInOut, value: receiveModel.progress == nil)
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
}
.buttonStyle(PlainButtonStyle())
}
.opacity(isDone ? 0 : 1)
}
}
.padding(16)
.background(
VisualEffectView(material: .hudWindow)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.1), radius: 20)
.frame(maxWidth: .infinity)
}
}
fileprivate struct QuickDropToastViewButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title.localized())
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(
Color.gray.opacity(0.15)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
)
}
.buttonStyle(.plain) // prevents blue macOS button look
}
}
// NSVisualEffectView wrapper for macOS 11 compatible blur/vibrancy
struct VisualEffectView: NSViewRepresentable {
var material: NSVisualEffectView.Material = .hudWindow
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
var state: NSVisualEffectView.State = .active
func makeNSView(context: Context) -> NSVisualEffectView {
let v = NSVisualEffectView()
v.material = material
v.blendingMode = blendingMode
v.state = state
v.isEmphasized = true
return v
}
func updateNSView(_ v: NSVisualEffectView, context: Context) {
v.material = material
v.blendingMode = blendingMode
v.state = state
}
}
// Capsule-style linear progress (0...1)
struct CapsuleProgress: View {
var value: Double
var track = Color(NSColor.separatorColor).opacity(0.28)
var fill = Color(NSColor.systemBlue)
var body: some View {
GeometryReader { geo in
let progress = max(0, min(1, value))
ZStack(alignment: .leading) {
Capsule()
.fill(track)
Capsule()
.fill(fill)
.mask(
Capsule()
.frame(width: progress * geo.size.width)
.frame(maxWidth: .infinity, alignment: .leading)
)
}
}
.frame(height: 8)
.animation(.easeInOut(duration: 0.25), value: value)
}
}
// MARK: - Preview
struct QuickDropToastView_Previews: PreviewProvider {
struct Demo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
@State var done = false
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.padding(100)
.background(Color.white.opacity(0.2))
.onAppear {
model.progress = 0.01
}
}
}
static var previews: some View { Demo() }
}
@@ -11,7 +11,7 @@ import LUI
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var settings = Settings.shared @ObservedObject var settings = Settings.sharedInstance
@ObservedObject var connectionManager = NearbyConnectionManager.shared @ObservedObject var connectionManager = NearbyConnectionManager.shared
@State private var isChangeDeviceNameAlertPresented = false @State private var isChangeDeviceNameAlertPresented = false
@@ -48,9 +48,8 @@ struct SettingsView: View {
.foregroundColor(.gray) .foregroundColor(.gray)
Button("Edit") { Button("Edit") {
isChangeDeviceNameAlertPresented.toggle() presentRenameAlert()
} }
.changeDeviceNameAlert(isPresented: $isChangeDeviceNameAlertPresented)
} }
Divider() Divider()
@@ -116,8 +115,8 @@ struct SettingsView: View {
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil) let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
Settings.shared.saveFolderBookmark = bookmarkData Settings.sharedInstance.saveFolderBookmark = bookmarkData
Settings.shared.openFinderAfterReceiving = true Settings.sharedInstance.openFinderAfterReceiving = true
} catch { } catch {
log("Failed to save security-scoped bookmark: \(error)") log("Failed to save security-scoped bookmark: \(error)")
@@ -149,12 +148,47 @@ struct SettingsView: View {
window.center() window.center()
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
window.title = "TrustedDevices".localized() window.title = "TrustedDevices".localized()
window.backgroundColor = NSColor(Color.defaultMacBackgroundColor)
window.contentView = NSHostingView(rootView: TrustedDevicesView()) window.contentView = NSHostingView(rootView: TrustedDevicesView())
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
window.level = .normal window.level = .normal
} }
private func presentRenameAlert() {
guard let window = NSApplication.shared.keyWindow else { return }
let alert = NSAlert()
alert.messageText = "ChangeDeviceName".localized()
alert.informativeText = "ChangeDeviceNameDescription".localized()
alert.alertStyle = .informational
let textField = NSTextField(string: NearbyConnectionManager.shared.deviceInfo.name ?? "")
textField.translatesAutoresizingMaskIntoConstraints = false
textField.frame = NSRect(x: 0, y: 0, width: 260, height: 24)
alert.accessoryView = textField
alert.addButton(withTitle: "Save".localized())
alert.addButton(withTitle: "Cancel".localized())
alert.beginSheetModal(for: window) { response in
if response == .alertFirstButtonReturn {
let trimmed = textField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
NearbyConnectionManager.shared.setCustomDeviceName(to: trimmed)
}
}
DispatchQueue.main.async {
let alertWindow = alert.window
alertWindow.makeFirstResponder(textField)
if let editor = alertWindow.fieldEditor(true, for: textField) as? NSTextView {
editor.selectedRange = NSRange(location: 0, length: textField.stringValue.count)
}
}
}
} }
@@ -0,0 +1,104 @@
//
// TrustedDevicesView.swift
// QuickDrop
//
// Created by Leon Böttger on 23.08.25.
//
import LUI
import SwiftUI
struct TrustedDevicesView: View {
@StateObject private var store = TrustStore.shared
@State private var deviceToRemove: String? = nil
@State private var showRemoveAlert = false
var body: some View {
NavigationSubView(header: isMac() ? "" : "TrustedDevices") {
VStack {
if store.trustedCertificates.isEmpty {
VStack(spacing: 12) {
Text("NoTrustedDevices")
.font(.headline)
.foregroundColor(.secondary)
Text("TrustedDevicesDescription")
.font(.subheadline)
.foregroundColor(.secondary)
}
.multilineTextAlignment(.center)
.padding()
.frame(maxHeight: .infinity)
} else {
CustomSection(footer: "TrustedDevicesDescription") {
devicesView
.padding(.vertical, 4)
}
}
}
.padding(.top, isMac() ? 18 : 0)
}
.animation(.default, value: store.trustedCertificates)
.alert(isPresented: $showRemoveAlert) {
Alert(
title: Text("RemoveTrustedDeviceAlertTitle"),
message: Text("RemoveTrustedDeviceAlertDescription"),
primaryButton: .destructive(Text("Remove")) {
if let key = deviceToRemove {
store.removeTrusted(secretIdHex: key)
}
},
secondaryButton: .cancel()
)
}
}
var devicesView: some View {
let entries = store.trustedCertificates.sorted { $0.value.creationDate < $1.value.creationDate }
return ForEach(entries, id: \.key) { key, cert in
HStack {
VStack(alignment: .leading, spacing: 4) {
LUIText(cert.device.name ?? "Unknown".localized(), isBold: true)
LUIText(Self.dateFormatter.string(from: cert.creationDate), color: .mainColor.opacity(0.7))
}
Spacer()
LUIButton {
deviceToRemove = key
showRemoveAlert = true
} label: {
ReorderListIcon(imageName: "minus.circle.fill", color: .red)
}
}
.padding(.vertical, 8)
}
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
}
#Preview {
TrustedDevicesView()
.onAppear {
var cert1 = Sharing_Nearby_PublicCertificate()
cert1.secretID = Data([0x01, 0x02, 0x03, 0x04])
TrustStore.shared.addTrusted(certificate: cert1, device: RemoteDeviceInfo(name: "MacBook Pro", type: .computer))
var cert2 = Sharing_Nearby_PublicCertificate()
cert2.secretID = Data([0x0A, 0x0B, 0x0C, 0x0D])
TrustStore.shared.addTrusted(certificate: cert2, device: RemoteDeviceInfo(name: "iPhone", type: .phone))
}
}
@@ -0,0 +1,606 @@
//
// IncomingFileTransmissionView.swift
// QuickDrop
//
// Created by Leon Böttger on 05.10.25.
//
import AppKit
import LUI
import SwiftUI
let toastViewSize = CGSize(width: 340, height: 65)
let toastCornerRadius: CGFloat = 22
let toastTrailingPadding: CGFloat = 20
let toastSlideDistance: CGFloat = 420
struct QuickDropToastView: View {
@ObservedObject var settings = Settings.sharedInstance
@ObservedObject var receiveModel: ReceiveModel
@Environment(\.colorScheme) var colorScheme
@State var autoHider = DispatchWorkItem(block: {})
@State private var isHovering = false
/// Cancel while receiving.
public var onCancel: () -> Void
init(
receiveModel: ReceiveModel,
onCancel: @escaping () -> Void = {}
) {
self.receiveModel = receiveModel
self.onCancel = onCancel
}
private func startAutoHide(_ actions: ReceiveModel.ToastViewAction) {
autoHider.cancel()
autoHider = DispatchWorkItem(block: {
actions.autoHideAction()
})
DispatchQueue.main.asyncAfter(deadline: .now() + actions.autoHideDelay, execute: autoHider)
}
private func startAutoHideIfPossible() {
guard !isHovering, let actions = receiveModel.toastActions else { return }
startAutoHide(actions)
}
public var body: some View {
let consent = receiveModel.consentState
let actions = receiveModel.toastActions
let hasActionButtons = actions?.openFilesAction != nil || actions?.importPhotosAction != nil
let deviceName = receiveModel.activeDeviceName ?? "AndroidDevice".localized()
let isNotificationSyncToast = consent?.notificationSyncStage != nil
let isNotificationSyncConsent = consent?.notificationSyncStage == .consent
let isNotificationSyncPinStage = consent?.notificationSyncStage == .pin
let showConsentActions = consent != nil && (!isNotificationSyncToast || isNotificationSyncConsent)
let showsRightColumn = showConsentActions || hasActionButtons
let subHeaderSize = 21.0
let closeButtonVisible = isHovering && (actions != nil || isNotificationSyncPinStage)
ZStack(alignment: .topTrailing) {
HStack(alignment: .center, spacing: 12) {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
let iconSize = 32.0
let headerFont = Font.system(.body).bold()
let subheaderFont = Font.system(.body)
let subheaderColor = Color.secondary
AppIconView(hasPlusIcon: false, size: iconSize, supportsDarkModeShadow: false)
.frame(width: iconSize, height: iconSize)
VStack(alignment: .leading, spacing: 1) {
if let consent = consent {
if isNotificationSyncToast {
HStack {
Text(String("QuickDrop"))
.font(headerFont)
.lineLimit(1)
Text("FromDevice".localized(with: deviceName))
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(1)
.truncationMode(.tail)
}
Text(consent.message)
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(2)
.truncationMode(.tail)
} else {
Text(verbatim: "QuickDrop | \(consent.pinCodeMessage)")
.font(headerFont)
.lineLimit(1)
Text(consent.message)
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(2)
.truncationMode(.tail)
}
} else {
HStack {
Text(String("QuickDrop"))
.font(headerFont)
.lineLimit(1)
if consent == nil, actions == nil {
Text("FromDevice".localized(with: deviceName))
.font(subheaderFont)
.foregroundColor(subheaderColor)
.lineLimit(1)
.truncationMode(.tail)
}
}
if consent == nil, actions == nil {
HStack {
ZStack {
CapsuleProgress(value: 0.5)
.opacity(0)
if let progress = receiveModel.progress {
CapsuleProgress(value: progress)
}
}
.animation(.easeInOut, value: receiveModel.progress == nil)
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
}
.buttonStyle(PlainButtonStyle())
}
.padding(.trailing, 16)
.frame(height: subHeaderSize)
}
else {
Text(LocalizedStringKey(actions?.completionMessageKey ?? "FileTransferCompleted"))
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(height: subHeaderSize)
}
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
if showsRightColumn {
ActionColumn() {
if showConsentActions, let consent = consent {
ActionButtonRow {
QuickDropToastViewButton(title: "Decline") {
consent.declineAction()
}
}
ActionButtonRow {
if consent.allowsTrust {
QuickDropToastViewMenuButton(title: "Accept") {
Button("AcceptOnce".localized()) {
consent.acceptAction(false)
}
Button("AutoAcceptFromThisDevice".localized()) {
consent.acceptAction(true)
}
}
} else {
QuickDropToastViewButton(title: "Accept") {
consent.acceptAction(false)
}
}
}
} else if let actions = actions {
if let openFilesAction = actions.openFilesAction {
ActionButtonRow {
QuickDropToastViewButton(title: "OpenInFinder") {
openFilesAction()
actions.closeToastAction()
}
}
}
if let onImportToPhotos = actions.importPhotosAction {
ActionButtonRow {
QuickDropToastViewButton(title: "ImportToPhotos") {
onImportToPhotos()
actions.closeToastAction()
}
}
}
}
}
}
}
}
.padding(.leading, 16)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.contentShape(Rectangle())
.onHover { hovering in
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = hovering
}
if hovering {
autoHider.cancel()
} else {
startAutoHideIfPossible()
}
}
.onChange(of: receiveModel.toastActions != nil) { hasActions in
if hasActions {
startAutoHideIfPossible()
} else {
autoHider.cancel()
}
}
.onAppear {
startAutoHideIfPossible()
}
.background(
AdaptiveToastBackgroundLayer(cornerRadius: toastCornerRadius)
)
.overlay(
RoundedRectangle(cornerRadius: toastCornerRadius, style: .continuous)
.stroke((colorScheme.isLight ? Color.white.opacity(0.5) : Color.gray.opacity(0.33)), lineWidth: 1)
)
.overlay(alignment: .topLeading) {
if closeButtonVisible {
Button(action: {
if let actions = actions {
actions.closeToastAction()
} else if isNotificationSyncPinStage {
receiveModel.hideQuickDropToast(style: .fade)
}
}) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
.background(
Circle()
.fill(Color(NSColor.windowBackgroundColor).opacity(0.9))
.shadow(color: Color.black.opacity(0.12), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(PlainButtonStyle())
.padding(.bottom, 5)
.padding(.trailing, 3)
.onHover { hovering in
if hovering {
withAnimation(.easeInOut(duration: 0.15)) {
isHovering = true
}
}
}
.transition(.opacity.combined(with: .scale(scale: 0.95)))
}
}
.shadow(color: Color.black.opacity(colorScheme.isLight ? 0.16 : 0.30), radius: 10, x: 0, y: 5)
.shadow(color: Color.black.opacity(colorScheme.isLight ? 0.06 : 0.14), radius: 2, x: 0, y: 1)
.frame(maxWidth: .infinity)
}
}
struct QuickDropToastHostView: View {
@ObservedObject var receiveModel: ReceiveModel
let onCancel: () -> Void
@State private var didRenderOnce = false
private let slideAnimation = Animation.smooth
private let fadeAnimation = Animation.easeInOut
var body: some View {
let isVisible = didRenderOnce && receiveModel.toastIsVisible
let isSlide = receiveModel.toastDismissStyle == .slide
let hiddenOffset: CGFloat = isSlide ? toastSlideDistance : 0
let hiddenOpacity: Double = isSlide ? 1 : 0
let hiddenBlur: CGFloat = isSlide ? 0 : 8
let animation = isSlide ? slideAnimation : fadeAnimation
QuickDropToastView(receiveModel: receiveModel, onCancel: onCancel)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.offset(x: isVisible ? 0 : hiddenOffset)
.opacity(isVisible ? 1 : hiddenOpacity)
.blur(radius: isVisible ? 0 : hiddenBlur)
.animation(animation, value: isVisible)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
.padding(.trailing, toastTrailingPadding)
.onAppear {
// Defer the visibility gate one run-loop tick so initial state is
// always rendered hidden before we animate to visible.
guard !didRenderOnce else { return }
DispatchQueue.main.async {
didRenderOnce = true
}
}
.onDisappear {
didRenderOnce = false
}
}
}
fileprivate struct QuickDropToastViewButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
ToastButtonLabel(title: title, fillsWidth: false, showsBackground: true)
}
.buttonStyle(.plain) // prevents blue macOS button look
}
}
fileprivate struct QuickDropToastViewMenuButton<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
ZStack {
ToastButtonLabel(title: title, fillsWidth: false, showsBackground: true)
.allowsHitTesting(false)
Menu {
content()
} label: {
ToastButtonLabel(title: title, fillsWidth: false, showsBackground: false)
}
.menuStyle(BorderlessButtonMenuStyle(showsMenuIndicator: false))
}
.fixedSize()
}
}
fileprivate struct ToastButtonLabel: View {
let title: String
let fillsWidth: Bool
let showsBackground: Bool
var body: some View {
Text(title.localized())
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(.mainColor.opacity(showsBackground ? 0.75 : 0))
.lineLimit(1)
.padding(.horizontal, 7)
.padding(.vertical, 3.5)
.background(Color.gray.opacity(showsBackground ? 0.1 : 0).cornerRadius(10))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.contentShape(Rectangle())
}
}
fileprivate struct ActionColumn<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .trailing, spacing: 0) {
content()
}
.padding(.vertical, 3)
.padding(.trailing, 4)
.frame(maxHeight: .infinity)
}
}
fileprivate struct ActionButtonRow<Content: View>: View {
@ViewBuilder let content: () -> Content
var body: some View {
content()
}
}
fileprivate struct ActionDividerLine: View {
var body: some View {
Rectangle()
.foregroundColor(.mainColor.opacity(0.05))
.frame(height: 2)
.frame(maxWidth: .infinity)
}
}
struct AdaptiveToastBackgroundLayer: View {
let cornerRadius: CGFloat
var body: some View {
Group {
if #available(macOS 26.0, *) {
Color.clear
.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous))
} else {
VisualEffectView(material: .hudWindow, blendingMode: .behindWindow, cornerRadius: cornerRadius)
}
}
}
}
// NSVisualEffectView wrapper for macOS 11 compatible blur/vibrancy
struct VisualEffectView: NSViewRepresentable {
var material: NSVisualEffectView.Material = .hudWindow
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
var state: NSVisualEffectView.State = .active
var cornerRadius: CGFloat = 0
func makeNSView(context: Context) -> NSVisualEffectView {
let v = NSVisualEffectView()
v.material = material
v.blendingMode = blendingMode
v.state = state
v.isEmphasized = true
if cornerRadius > 0 {
v.wantsLayer = true
v.layer?.cornerRadius = cornerRadius
v.layer?.masksToBounds = true
}
return v
}
func updateNSView(_ v: NSVisualEffectView, context: Context) {
v.material = material
v.blendingMode = blendingMode
v.state = state
if cornerRadius > 0 {
v.wantsLayer = true
v.layer?.cornerRadius = cornerRadius
v.layer?.masksToBounds = true
} else {
v.layer?.cornerRadius = 0
v.layer?.masksToBounds = false
}
}
}
// Capsule-style linear progress (0...1)
struct CapsuleProgress: View {
var value: Double
var track = Color(NSColor.systemBlue).opacity(0.28)
var fill = Color(NSColor.systemBlue)
var body: some View {
GeometryReader { geo in
let progress = max(0, min(1, value))
ZStack(alignment: .leading) {
Capsule()
.fill(track)
Capsule()
.fill(fill)
.mask(
Capsule()
.frame(width: progress * geo.size.width)
.frame(maxWidth: .infinity, alignment: .leading)
)
}
}
.frame(height: 6)
.animation(.easeInOut(duration: 0.25), value: value)
}
}
// MARK: - Preview
struct QuickDropToastView_Previews: PreviewProvider {
struct ConsentDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.consentState = .init(
transferID: "preview",
pinCodeMessage: "PIN: 1233",
message: "45 images from Pixel 6 Pro von Leon",
notificationSyncStage: nil,
allowsTrust: true,
acceptAction: { _ in },
declineAction: { }
)
}
}
}
struct ConsentDemo2: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.consentState = .init(
transferID: "preview",
pinCodeMessage: "PIN: 1233",
message: "45 images from Pixel 6 Pro",
notificationSyncStage: nil,
allowsTrust: false,
acceptAction: { _ in },
declineAction: { }
)
}
}
}
struct ProgressDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(QuickDropToastView_Previews.previewWallpaper)
.onAppear {
model.progress = 0.42
model.toastActions = nil
}
}
}
struct CompletedDemo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.clipped()
.padding(100)
.background(previewWallpaper)
.onAppear {
model.progress = 1
model.toastActions = .init(completionMessageKey: "Saved", autoHideDelay: 10, openFilesAction: {}, importPhotosAction: {}, closeToastAction: {}, autoHideAction: {})
}
}
}
private static let previewWallpaper = LinearGradient(
gradient: Gradient(colors: [
Color(red: 0.92, green: 0.82, blue: 0.94),
Color(red: 0.86, green: 0.76, blue: 0.90),
Color(red: 0.78, green: 0.70, blue: 0.86)
]),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
static var previews: some View {
Group {
ConsentDemo()
ConsentDemo2()
ProgressDemo()
CompletedDemo()
}
}
}
-125
View File
@@ -1,125 +0,0 @@
//
// TrustedDevicesView.swift
// QuickDrop
//
// Created by Leon Böttger on 23.08.25.
//
import SwiftUI
#if !os(macOS)
import LUI
#endif
struct TrustedDevicesView: View {
@StateObject private var store = TrustStore.shared
@State private var deviceToRemove: String? = nil
@State private var showRemoveAlert = false
var body: some View {
VStack {
if store.trustedCertificates.isEmpty {
VStack(spacing: 12) {
Text("NoTrustedDevices")
.font(.headline)
.foregroundColor(.secondary)
Text("TrustedDevicesDescription")
.font(.subheadline)
.foregroundColor(.secondary)
}
.multilineTextAlignment(.center)
.padding()
.frame(maxHeight: .infinity)
} else {
#if !os(macOS)
CustomSection(footer: "TrustedDevicesDescription") {
devicesView
.padding(.vertical, 4)
}
#else
List {
devicesView
Text("TrustedDevicesDescription")
.font(.footnote)
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.secondary)
.padding(.top, 4)
}
#endif
}
}
.animation(.default, value: store.trustedCertificates)
.alert(isPresented: $showRemoveAlert) {
Alert(
title: Text("RemoveTrustedDeviceAlertTitle"),
message: Text("RemoveTrustedDeviceAlertDescription"),
primaryButton: .destructive(Text("Remove")) {
if let key = deviceToRemove {
store.removeTrusted(secretIdHex: key)
}
},
secondaryButton: .cancel()
)
}
}
var devicesView: some View {
ForEach(Array(store.trustedCertificates.keys), id: \.self) { key in
if let cert = store.trustedCertificates[key] {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(cert.device.name ?? "Unknown".localized())
.font(.headline)
// macOS 11: use DateFormatter instead of .formatted
Text(Self.dateFormatter.string(from: cert.creationDate))
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Button(action: {
deviceToRemove = key
showRemoveAlert = true
}) {
Image(systemName: "minus.circle.fill")
.font(.system(size: 20))
.foregroundColor(.red)
}
.buttonStyle(BorderlessButtonStyle())
}
.padding(.vertical, 8)
}
}
}
// MARK: - DateFormatter for macOS 11
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter
}()
}
#Preview {
TrustedDevicesView()
.onAppear {
var cert1 = Sharing_Nearby_PublicCertificate()
cert1.secretID = Data([0x01, 0x02, 0x03, 0x04])
TrustStore.shared.addTrusted(certificate: cert1, device: RemoteDeviceInfo(name: "MacBook Pro", type: .computer))
var cert2 = Sharing_Nearby_PublicCertificate()
cert2.secretID = Data([0x0A, 0x0B, 0x0C, 0x0D])
TrustStore.shared.addTrusted(certificate: cert2, device: RemoteDeviceInfo(name: "iPhone", type: .phone))
}
}
@@ -7,7 +7,6 @@
import SwiftUI import SwiftUI
struct LicensePage: View { struct LicensePage: View {
var body: some View { var body: some View {
ZStack { ZStack {
@@ -15,18 +15,32 @@ struct TutorialView: View {
let openPlus: () -> Void let openPlus: () -> Void
@State private var licenseWindow: NSWindow? @State private var licenseWindow: NSWindow?
@ObservedObject var settings = Settings.shared @ObservedObject var iapManager = IAPManager.sharedInstance
var body: some View { var body: some View {
LargeAppIconView(title: tab.title) { LargeAppIconView(title: tab.title) {
VStack { VStack {
Group {
Text(tab.text.localized()) if tab == .troubleshooting {
.multilineTextAlignment(.leading) FAQView(faqItems: [
.fixedSize(horizontal: false, vertical: true) .init(question: "FaqNotVisibleOrConnectingQuestion", answer: "FaqNotVisibleOrConnectingAnswer"),
.padding() .init(question: "FaqPhotoDateNotPreservedQuestion", answer: "FaqPhotoDateNotPreservedAnswer"),
.frame(width: 550) .init(question: "FaqAndroidDeviceNotVisibleQuestion", answer: "FaqAndroidDeviceNotVisibleAnswer"),
.init(question: "FaqTrustedDevicesQuestion", answer: "FaqTrustedDevicesAnswer"),
.init(question: "MultipleFilesSendingQuestion", answer: "MultipleFilesSendingAnswer"),
.init(question: "FaqBugQuestion", answer: "FaqBugAnswer"),
])
.padding(.top)
}
else {
Text(tab.text.localized())
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.padding()
}
}
.frame(width: 550)
if tab == .receive { if tab == .receive {
HStack(spacing: 30) { HStack(spacing: 30) {
@@ -41,7 +55,8 @@ struct TutorialView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
if !settings.gotPlus { #if !GITHUB
if !iapManager.plusVersionState {
Button { Button {
openPlus() openPlus()
} label: { } label: {
@@ -52,10 +67,12 @@ struct TutorialView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
#endif
#if DEBUG #if DEBUG
Button { Button {
Settings.shared.deleteAllUserDefaults() IAPManager.sharedInstance.plusVersionState = false
Settings.sharedInstance.deleteAllUserDefaults()
} label: { } label: {
Text("ResetAllSettings") Text("ResetAllSettings")
.underline() .underline()
@@ -63,7 +80,7 @@ struct TutorialView: View {
.opacity(0.5) .opacity(0.5)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
#endif #endif
} }
.padding() .padding()
} }
@@ -86,8 +103,15 @@ struct EnableExtensionView: View {
private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
init() { init(canShowIntialCheckmark: Bool = false) {
_isExtensionEnabled = State(initialValue: Self.isEnabled()) let isEnabled = Self.isEnabled()
_isExtensionEnabled = State(initialValue: isEnabled)
if canShowIntialCheckmark && isEnabled {
_showSuccessCheckmark = State(initialValue: true)
_animateCheckmark = State(initialValue: true)
}
} }
var body: some View { var body: some View {
@@ -163,31 +187,6 @@ private struct SharingPickerPresenter: NSViewRepresentable {
} }
struct LargeAppIconView<Content: View>: View {
let title: String
let bottomView: () -> Content
var body: some View {
ScrollView {
VStack {
AppIconView(hasPlusIcon: false, size: 120)
.padding(.top, 35)
Text(title.localized())
.font(.largeTitle)
.padding()
bottomView()
}
.frame(maxWidth: 500)
.frame(maxWidth: .infinity)
}
}
}
#Preview { #Preview {
WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {}) WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {})
} }
@@ -44,7 +44,7 @@ struct WelcomeScreen: View {
Divider() Divider()
ExternalLinkLabel(label: "GetSupport", icon: "questionmark.circle") { ExternalLinkLabel(label: "GetSupport", icon: "questionmark.circle") {
sendLoggingString() SupportMail.sendSupportMail()
} }
ExternalLinkLabel(label: "PrivacyPolicy", icon: "hand.raised") { ExternalLinkLabel(label: "PrivacyPolicy", icon: "hand.raised") {
@@ -70,7 +70,6 @@ struct WelcomeScreen: View {
.listStyle(SidebarListStyle()) .listStyle(SidebarListStyle())
Divider() Divider()
.opacity(colorScheme == .light ? 1 : 0.5)
.edgesIgnoringSafeArea(.vertical) .edgesIgnoringSafeArea(.vertical)
ZStack { ZStack {
@@ -188,7 +187,7 @@ enum Tab: CaseIterable {
case .send: case .send:
return "SendFiles".localized() return "SendFiles".localized()
case .troubleshooting: case .troubleshooting:
return "DeviceNotShown".localized() return "TroubleshootingAndFaq".localized()
case .settings: case .settings:
return "Settings".localized() return "Settings".localized()
} }
@@ -196,8 +195,6 @@ enum Tab: CaseIterable {
var title: String { var title: String {
switch self { switch self {
case .troubleshooting:
return "Troubleshooting"
default: default:
return sidebarTitle return sidebarTitle
} }
@@ -209,9 +206,7 @@ enum Tab: CaseIterable {
"UserManualDescription" "UserManualDescription"
case .send: case .send:
"SendFilesDescription" "SendFilesDescription"
case .troubleshooting: default:
"TroubleshootingDescription"
case .settings:
"" ""
} }
} }
+399 -106
View File
@@ -7,11 +7,9 @@
import SwiftUI import SwiftUI
import StoreKit import StoreKit
import UserNotifications
import LUI import LUI
#if os(macOS) #if os(macOS)
import BezelNotification import AppKit
#endif #endif
class ReceiveModel: ObservableObject, InboundAppDelegate { class ReceiveModel: ObservableObject, InboundAppDelegate {
@@ -19,14 +17,21 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
/// For each connection ID, store the last reported progress value /// For each connection ID, store the last reported progress value
private var processes: [String: Double] = [:] private var processes: [String: Double] = [:]
#if os(macOS) #if os(macOS)
@Published var progress: Double? = nil @Published var progress: Double? = nil
@Published var toastActions: ToastViewAction? = nil @Published var toastActions: ToastViewAction? = nil
@Published var consentState: ConsentToastState? = nil
@Published var activeDeviceName: String? = nil
@Published var toastIsVisible: Bool = false
@Published var toastDismissStyle: ToastDismissStyle = .slide
private var pendingReview = false private var pendingReview = false
private var toastWindowPending = false
private var toastWindow: NSWindow? private var toastWindow: NSWindow?
private var toastHosting: NSHostingView<QuickDropToastView>? private var toastHosting: NSHostingView<QuickDropToastHostView>?
private var toastRevealTask: DispatchWorkItem?
private var toastCleanupTask: DispatchWorkItem?
private let monitor = AllowedWorkMonitor() private let monitor = AllowedWorkMonitor()
#endif #endif
let controlPlusScreen: (Bool) -> Void let controlPlusScreen: (Bool) -> Void
@@ -67,49 +72,101 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
AudioManager.playIncomingFileSound() AudioManager.playIncomingFileSound()
#endif #endif
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized(), alreadyAccepted: false) let mainMessage = transfer.getDescription(deviceName: device.name ?? "AndroidDevice".localized())
let pinCodeMessage = transfer.getPinCodeMessage() let pinCodeMessage = transfer.getPinCodeMessage()
let transferID = transfer.id let transferID = transfer.id
let title = "QuickDrop - \(pinCodeMessage)"
let primaryButtonTitle = "Accept".localized()
let primaryButtonAction = { (trustDevice: Bool) in let primaryButtonAction = { (trustDevice: Bool) in
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice) NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice)
#if os(macOS)
if transfer.type == .text {
DispatchQueue.main.async {
BezelNotification.show(messageText: "InsertedIntoClipboard".localized(), icon: .clipboard)
}
}
#endif
} }
let secondaryButtonTitle = "Decline".localized()
let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: false) } let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: false) }
#if os(macOS) #if os(macOS)
let alert = NSAlert() DispatchQueue.main.async {
alert.alertStyle = .informational 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! : "AndroidDevice".localized()
self.activeDeviceName = resolvedSenderName
alert.messageText = title let compareMessage = "NotificationSyncToastQrSubtitle".localized()
alert.informativeText = mainMessage self.consentState = ConsentToastState(
alert.addButton(withTitle: primaryButtonTitle) transferID: transferID,
alert.addButton(withTitle: secondaryButtonTitle) pinCodeMessage: "",
message: compareMessage,
notificationSyncStage: .consent,
allowsTrust: false,
acceptAction: { [weak self] _ in
guard let self else { return }
withAnimation {
self.consentState = nil
}
guard let receiverFingerprint = NotificationSyncPairingToken.receiverFingerprintHex() else {
log("[ReceiveModel] Missing local receiver fingerprint; cannot start notification sync pairing.")
secondaryButtonAction()
self.hideQuickDropToast(style: .fade)
return
}
let pairingToken = NotificationSyncPairingToken.generate()
NotificationSyncQrSheetManager.shared.open(
token: pairingToken,
receiverFingerprint: receiverFingerprint,
deviceName: resolvedSenderName
)
NearbyConnectionManager.shared.submitUserConsent(
transferID: transferID,
accept: true,
trustDevice: false,
pairingToken: pairingToken
)
self.hideQuickDropToast(style: .fade)
},
declineAction: { [weak self] in
guard let self else { return }
self.consentState = nil
secondaryButtonAction()
self.hideQuickDropToast(style: .fade)
}
)
self.showQuickDropToast(for: transferID)
return
}
if transfer.allowsToBeAddedAsTrustedDevice { self.toastActions = nil
alert.showsSuppressionButton = true self.progress = nil
alert.suppressionButton?.title = "AutoAcceptFromThisDevice".localized() self.activeDeviceName = device.name ?? "AndroidDevice".localized()
} self.consentState = ConsentToastState(
transferID: transferID,
let result = alert.runModal() pinCodeMessage: pinCodeMessage,
message: mainMessage,
if result == .alertFirstButtonReturn { notificationSyncStage: nil,
primaryButtonAction(alert.suppressionButton?.state == .on) allowsTrust: transfer.allowsToBeAddedAsTrustedDevice,
} else if result == .alertSecondButtonReturn { acceptAction: { [weak self] trustDevice in
secondaryButtonAction() DispatchQueue.main.async {
withAnimation {
self?.consentState = nil
}
}
primaryButtonAction(trustDevice)
},
declineAction: { [weak self] in
self?.consentState = nil
secondaryButtonAction()
self?.hideQuickDropToast()
}
)
self.showQuickDropToast(for: transferID)
} }
#else #else
let title = "QuickDrop - \(pinCodeMessage)"
let primaryButtonTitle = "Accept".localized()
let secondaryButtonTitle = "Decline".localized()
// iOS // iOS
let alwaysAcceptLabel = transfer.allowsToBeAddedAsTrustedDevice ? "AlwaysAccept".localized() : nil let alwaysAcceptLabel = transfer.allowsToBeAddedAsTrustedDevice ? "AlwaysAccept".localized() : nil
ProgressAlert.shared.askForUserPermission(title: title, message: mainMessage, acceptLabel: primaryButtonTitle, acceptAlwaysLabel: alwaysAcceptLabel, rejectLabel: secondaryButtonTitle) { accepted in ProgressAlert.shared.askForUserPermission(title: title, message: mainMessage, acceptLabel: primaryButtonTitle, acceptAlwaysLabel: alwaysAcceptLabel, rejectLabel: secondaryButtonTitle) { accepted in
@@ -129,33 +186,18 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) { func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) {
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized(), alreadyAccepted: true) let mainMessage = transfer.getDescription(deviceName: device.name ?? "AndroidDevice".localized())
#if os(macOS) #if os(macOS)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
AudioManager.playIncomingFileSound() AudioManager.playIncomingFileSound()
DispatchQueue.main.async {
// If file -> no notification, as there is the QuickDrop progress toast self.toastActions = nil
if transfer.type != .file { self.progress = nil
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in self.activeDeviceName = device.name ?? "AndroidDevice".localized()
if granted { self.consentState = nil
log("[ReceiveModel] User granted notification permissions") self.showQuickDropToast(for: transfer.id)
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "QuickDrop"
notificationContent.body = mainMessage
notificationContent.sound = nil
let notificationId = UUID().uuidString
let notificationReq = UNNotificationRequest(identifier: notificationId, content: notificationContent, trigger: nil)
UNUserNotificationCenter.current().add(notificationReq)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationId])
}
}
}
} }
#else #else
// If text is received, nothing is shown, as it is directly inserted into clipboard automatically // If text is received, nothing is shown, as it is directly inserted into clipboard automatically
@@ -190,17 +232,35 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
} }
func notificationSyncSetupConfirmed() {
#if os(macOS)
DispatchQueue.main.async {
if self.consentState?.notificationSyncStage != nil {
self.hideQuickDropToast(style: .fade)
}
NotificationSyncQrSheetManager.shared.close()
}
#endif
}
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, error: (any Error)?) {
processes.removeValue(forKey: connectionID) processes.removeValue(forKey: connectionID)
#if os(macOS) #if os(macOS)
DispatchQueue.main.async { DispatchQueue.main.async {
let wasPendingConsent = self.consentState?.transferID == connectionID
if self.processes.isEmpty { if self.processes.isEmpty {
self.progress = nil self.progress = nil
} }
if wasPendingConsent {
self.consentState = nil
self.toastActions = nil
self.hideQuickDropToast()
}
finishMacTermination(device: device, savedFiles: savedFiles, error: error, wasPendingConsent: wasPendingConsent)
} }
finish()
#else #else
if self.processes.isEmpty { if self.processes.isEmpty {
ProgressAlert.shared.updateProgress(nil, onCancel: {}) { ProgressAlert.shared.updateProgress(nil, onCancel: {}) {
@@ -247,20 +307,31 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
#endif #endif
let currentCount = Settings.shared.incomingTransmissionCount let currentCount = Settings.sharedInstance.incomingTransmissionCount
#if os(macOS) #if os(macOS)
let completionKey: String
let autoHideDelay: TimeInterval
if !savedFiles.isEmpty {
completionKey = "Saved"
autoHideDelay = 10
} else if wasPlainTextTransfer {
completionKey = "CopiedToClipboard"
autoHideDelay = 10
} else {
completionKey = "URLOpened"
autoHideDelay = 4
}
if currentCount == 0 { if currentCount == 0 {
pendingReview = true pendingReview = true
} }
let openFilesAction = { let openFilesAction: (() -> ())? = savedFiles.isEmpty ? nil : {
if !savedFiles.isEmpty { log("[SaveFilesManager] Opening \(savedFiles.count) file(s) in Finder.")
log("[SaveFilesManager] Opening \(savedFiles.count) file(s) in Finder.") NSWorkspace.shared.activateFileViewerSelecting(savedFiles)
NSWorkspace.shared.activateFileViewerSelecting(savedFiles)
}
showReviewIfAppropriate(currentTransmissionCount: currentCount) showReviewIfAppropriate(currentTransmissionCount: currentCount)
} }
@@ -286,28 +357,118 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
} : nil } : nil
let closeToastAction = { let closeToastAction = {
self.hideQuickDropToast(style: .fade)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
let autoHideAction = {
self.hideQuickDropToast() self.hideQuickDropToast()
showReviewIfAppropriate(currentTransmissionCount: currentCount) showReviewIfAppropriate(currentTransmissionCount: currentCount)
} }
DispatchQueue.main.async { DispatchQueue.main.async {
withAnimation { withAnimation {
self.toastActions = ToastViewAction(openFilesAction: openFilesAction, importPhotosAction: importPhotosAction, closeToastAction: closeToastAction) self.toastActions = ToastViewAction(completionMessageKey: completionKey, autoHideDelay: autoHideDelay, openFilesAction: openFilesAction, importPhotosAction: importPhotosAction, closeToastAction: closeToastAction, autoHideAction: autoHideAction)
} }
// Show received files if wanted // Show received files if wanted
if Settings.shared.openFinderAfterReceiving { if Settings.sharedInstance.openFinderAfterReceiving {
self.toastActions?.openFilesAction() self.toastActions?.openFilesAction?()
} }
} }
#endif #endif
Settings.shared.incomingTransmissionCount = currentCount + 1 Settings.sharedInstance.incomingTransmissionCount = currentCount + 1
log("[ReceiveModel] Successful transmission. Current count: \(currentCount)") log("[ReceiveModel] Successful transmission. Current count: \(currentCount)")
} }
} }
#if os(macOS) #if os(macOS)
func finishMacTermination(device: RemoteDeviceInfo?, savedFiles: [URL], error: (any Error)?, wasPendingConsent: Bool) {
if wasPendingConsent {
return
}
if let error = error {
if let name = device?.name {
self.hideQuickDropToast()
controlPlusScreen(false)
errorVibration()
ErrorAlertHandler.shared.showErrorAlert(for: name, error: error)
}
return
}
let currentCount = Settings.sharedInstance.incomingTransmissionCount
let completionKey: String
let autoHideDelay: TimeInterval
if !savedFiles.isEmpty {
completionKey = "Saved"
autoHideDelay = 10
} else if wasPlainTextTransfer {
completionKey = "CopiedToClipboard"
autoHideDelay = 10
} else {
completionKey = "URLOpened"
autoHideDelay = 4
}
if currentCount == 0 {
pendingReview = true
}
let openFilesAction: (() -> ())? = savedFiles.isEmpty ? nil : {
log("[SaveFilesManager] Opening \(savedFiles.count) file(s) in Finder.")
NSWorkspace.shared.activateFileViewerSelecting(savedFiles)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
let hasPhotoOrVideos = PhotoManager.hasPhotosOrVideos(at: savedFiles)
let importPhotosAction: (()->())? = hasPhotoOrVideos ? {
Task {
do {
try await PhotoManager.saveMediaToPhotoLibrary(from: savedFiles)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
catch {
DispatchQueue.main.async {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "CouldNotSaveMediaToPhotoLibrary".localized()
alert.informativeText = error.localizedDescription
alert.addButton(withTitle: "CloseAlert".localized())
alert.runModal()
}
}
}
} : nil
let closeToastAction = {
self.hideQuickDropToast(style: .fade)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
let autoHideAction = {
self.hideQuickDropToast()
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
withAnimation {
self.toastActions = ToastViewAction(completionMessageKey: completionKey, autoHideDelay: autoHideDelay, openFilesAction: openFilesAction, importPhotosAction: importPhotosAction, closeToastAction: closeToastAction, autoHideAction: autoHideAction)
}
// Show received files if wanted
if Settings.sharedInstance.openFinderAfterReceiving {
self.toastActions?.openFilesAction?()
}
Settings.sharedInstance.incomingTransmissionCount = currentCount + 1
log("[ReceiveModel] Successful transmission. Current count: \(currentCount)")
}
func showReviewIfAppropriate(currentTransmissionCount: Int) { func showReviewIfAppropriate(currentTransmissionCount: Int) {
if pendingReview { if pendingReview {
// Dont request review again // Dont request review again
@@ -324,6 +485,12 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
func showPlusScreen() { func showPlusScreen() {
DispatchQueue.main.async { DispatchQueue.main.async {
#if os(macOS)
self.consentState = nil
self.toastActions = nil
self.progress = nil
self.hideQuickDropToast()
#endif
self.controlPlusScreen(true) self.controlPlusScreen(true)
} }
} }
@@ -332,31 +499,88 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
// MARK: - macOS Progress Overlay // MARK: - macOS Progress Overlay
#if os(macOS) #if os(macOS)
func showQuickDropToast(for connectionID: String) { private func scheduleToastReveal(for window: NSWindow) {
guard toastWindow == nil else { return } toastRevealTask?.cancel()
toastRevealTask = nil
let contentView = QuickDropToastView( let revealTask = DispatchWorkItem { [weak self, weak window] in
guard let self, let window else { return }
guard self.toastWindow === window else { return }
self.toastIsVisible = true
self.toastRevealTask = nil
}
toastRevealTask = revealTask
// Reveal on the next run-loop tick to avoid pop-in without timer jitter.
DispatchQueue.main.async(execute: revealTask)
}
func showQuickDropToast(for connectionID: String) {
if !Thread.isMainThread {
DispatchQueue.main.async {
self.showQuickDropToast(for: connectionID)
}
return
}
// If a previous hide is still waiting for cleanup, keep the toast alive for this new request.
toastCleanupTask?.cancel()
toastCleanupTask = nil
toastRevealTask?.cancel()
toastRevealTask = nil
if let window = toastWindow {
toastDismissStyle = .slide
if !toastIsVisible {
window.makeKeyAndOrderFront(nil)
scheduleToastReveal(for: window)
}
return
}
guard !toastWindowPending else { return }
toastWindowPending = true
guard toastWindow == nil else {
toastWindowPending = false
return
}
toastIsVisible = false
toastDismissStyle = .slide
let contentView = QuickDropToastHostView(
receiveModel: self, receiveModel: self,
onCancel: { onCancel: { [weak self] in
NearbyConnectionManager.shared.cancelTransfer(id: connectionID) guard let self else { return }
NearbyConnectionManager.shared.cancelAllIncomingConnections()
self.processes.removeAll()
self.progress = nil
self.toastActions = nil
self.consentState = nil
self.hideQuickDropToast(style: .fade)
} }
) )
let hostingView = NSHostingView(rootView: contentView) let hostingView = NSHostingView(rootView: contentView)
hostingView.frame.size = toastViewSize
// Prefer the screen under the mouse, then the key window’s screen, then main // Prefer the screen under the mouse, then the key window’s screen, then main
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) }
?? NSApp.keyWindow?.screen ?? NSApp.keyWindow?.screen
?? NSScreen.main ?? NSScreen.main
guard let screen else { return } guard let screen else {
toastWindowPending = false
return
}
let frame = screen.frame let frame = screen.frame
let visible = screen.visibleFrame let visible = screen.visibleFrame
let marginX: CGFloat = 24 let marginX: CGFloat = 20
let marginFromTop: CGFloat = 30 let marginFromTop: CGFloat = 20
// If the menu bar is auto-hidden, visible.maxY == frame.maxY; subtract the status bar thickness to stay clear // If the menu bar is auto-hidden, visible.maxY == frame.maxY; subtract the status bar thickness to stay clear
let menuBarHidden = abs(frame.maxY - visible.maxY) < 0.5 let menuBarHidden = abs(frame.maxY - visible.maxY) < 0.5
@@ -364,61 +588,130 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
let targetX = visible.maxX - toastViewSize.width - marginX let targetX = visible.maxX - toastViewSize.width - marginX
let targetY = visible.maxY - toastViewSize.height - marginFromTop - extraTopInset let targetY = visible.maxY - toastViewSize.height - marginFromTop - extraTopInset
let windowWidth = visible.maxX - targetX
let targetFrame = CGRect(x: targetX, y: targetY, width: windowWidth, height: toastViewSize.height)
// Start slightly above the final spot for a smooth slide-in // Expand the borderless window so SwiftUI shadow can render without clipping.
let startY = min(frame.maxY + 10, targetY + 10) // Keep the toast anchored at the same screen position by offsetting hostingView
let startFrame = CGRect(x: targetX, y: startY, width: toastViewSize.width, height: toastViewSize.height) // inside a clear container view.
let shadowInsetLeft: CGFloat = 22
let shadowInsetTop: CGFloat = 18
let shadowInsetBottom: CGFloat = 24
let windowFrame = CGRect(
x: targetFrame.minX - shadowInsetLeft,
y: targetFrame.minY - shadowInsetBottom,
width: targetFrame.width + shadowInsetLeft,
height: targetFrame.height + shadowInsetTop + shadowInsetBottom
)
let window = NSWindow(contentRect: startFrame, let containerView = NSView(frame: CGRect(origin: .zero, size: windowFrame.size))
containerView.wantsLayer = true
containerView.layer?.backgroundColor = NSColor.clear.cgColor
hostingView.frame = CGRect(
x: shadowInsetLeft,
y: shadowInsetBottom,
width: targetFrame.width,
height: targetFrame.height
)
containerView.addSubview(hostingView)
let window = NSWindow(contentRect: windowFrame,
styleMask: [.borderless], styleMask: [.borderless],
backing: .buffered, backing: .buffered,
defer: false) defer: false)
window.isOpaque = false window.isOpaque = false
window.backgroundColor = .clear window.backgroundColor = .clear
window.hasShadow = true // Use SwiftUI shadow on the toast view itself. On macOS 12, window
// shadow can reveal a rectangular artifact in transparent trailing space.
window.hasShadow = false
window.level = .statusBar window.level = .statusBar
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.contentView = hostingView window.contentView = containerView
window.ignoresMouseEvents = false window.ignoresMouseEvents = false
window.alphaValue = 0 window.alphaValue = 1
window.makeKeyAndOrderFront(nil) window.makeKeyAndOrderFront(nil)
self.toastWindow = window toastWindow = window
self.toastHosting = hostingView toastHosting = hostingView
toastWindowPending = false
NSAnimationContext.runAnimationGroup { ctx in scheduleToastReveal(for: window)
ctx.duration = 0.35
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrameOrigin(CGPoint(x: targetX, y: targetY))
window.animator().alphaValue = 1
}
} }
func hideQuickDropToast() { func hideQuickDropToast(style: ToastDismissStyle = .fade) {
guard let window = toastWindow, if !Thread.isMainThread {
let screen = NSScreen.main else { return } DispatchQueue.main.async {
self.hideQuickDropToast(style: style)
}
return
}
let offscreenY = screen.visibleFrame.origin.y + screen.visibleFrame.height + 20 toastCleanupTask?.cancel()
toastCleanupTask = nil
toastRevealTask?.cancel()
toastRevealTask = nil
NSAnimationContext.runAnimationGroup({ ctx in guard let window = toastWindow else {
ctx.duration = 0.35 self.toastWindow = nil
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) self.toastHosting = nil
window.animator().setFrameOrigin(CGPoint(x: window.frame.origin.x, y: offscreenY)) self.toastActions = nil
window.animator().alphaValue = 0 self.consentState = nil
}, completionHandler: { self.toastWindowPending = false
return
}
self.toastDismissStyle = style
self.toastIsVisible = false
let cleanupDelay: TimeInterval = (style == .slide) ? 0.5 : 0.3
let cleanupTask = DispatchWorkItem { [weak self] in
guard let self else { return }
window.orderOut(nil) window.orderOut(nil)
self.toastWindow = nil self.toastWindow = nil
self.toastHosting = nil self.toastHosting = nil
self.toastActions = nil self.toastActions = nil
}) self.consentState = nil
self.activeDeviceName = nil
self.toastWindowPending = false
self.toastCleanupTask = nil
self.toastRevealTask = nil
}
toastCleanupTask = cleanupTask
DispatchQueue.main.asyncAfter(deadline: .now() + cleanupDelay, execute: cleanupTask)
} }
struct ToastViewAction { struct ToastViewAction {
let openFilesAction: () -> () let completionMessageKey: String
let autoHideDelay: TimeInterval
let openFilesAction: (() -> ())?
let importPhotosAction: (() -> ())? let importPhotosAction: (() -> ())?
let closeToastAction: () -> () let closeToastAction: () -> ()
let autoHideAction: () -> ()
}
enum ToastDismissStyle {
case slide
case fade
}
struct ConsentToastState {
enum NotificationSyncStage {
case consent
case pin
}
let transferID: String
let pinCodeMessage: String
let message: String
let notificationSyncStage: NotificationSyncStage?
let allowsTrust: Bool
let acceptAction: (Bool) -> ()
let declineAction: () -> ()
} }
#endif #endif
} }
+14 -2
View File
@@ -10,7 +10,19 @@
"layers" : [ "layers" : [
{ {
"image-name" : "QuickDropArrows.png", "image-name" : "QuickDropArrows.png",
"name" : "QuickDropArrows" "name" : "QuickDropArrows",
"position-specializations" : [
{
"idiom" : "square",
"value" : {
"scale" : 1,
"translation-in-points" : [
0,
-5
]
}
}
]
}, },
{ {
"image-name" : "QuickDropCircle.png", "image-name" : "QuickDropCircle.png",
@@ -24,7 +36,7 @@
}, },
"translucency" : { "translucency" : {
"enabled" : true, "enabled" : true,
"value" : 0.16 "value" : 0.52
} }
} }
], ],
+1 -1
View File
@@ -32,7 +32,7 @@
"en" : { "en" : {
"stringUnit" : { "stringUnit" : {
"state" : "new", "state" : "new",
"value" : "" "value" : "Copyright © 2025-2026 Leon Böttger"
} }
} }
} }
+4
View File
@@ -8,5 +8,9 @@
<array> <array>
<string>group.com.leonboettger.neardrop</string> <string>group.com.leonboettger.neardrop</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
</array>
</dict> </dict>
</plist> </plist>
@@ -30,6 +30,7 @@ struct ChangeDeviceNameAlertModifier: ViewModifier {
} }
} }
extension View { extension View {
func changeDeviceNameAlert( func changeDeviceNameAlert(
isPresented: Binding<Bool>, isPresented: Binding<Bool>,
+13 -21
View File
@@ -12,14 +12,14 @@ import StoreKit
struct ContentView: View { struct ContentView: View {
@ObservedObject var luiSettings = LUISettings.sharedInstance @ObservedObject var luiSettings = LUISettings.sharedInstance
@ObservedObject var settings = Settings.shared @ObservedObject var iapManager = IAPManager.sharedInstance
var body: some View { var body: some View {
AppRootView(isPlus: $settings.gotPlus, phoneView: { AppRootView(phoneView: {
DeviceListView() DeviceListView()
.environment(\.sheetActive, isShareExtension()) .environment(\.sheetActive, isShareExtension())
}, settingsView: { }, settingsView: {
CustomSection(header: settings.gotPlus ? "" : "General", footer: "TrustedDevicesFooterShort") { CustomSection(header: iapManager.plusVersionState ? "" : "General", footer: "TrustedDevicesFooterShort") {
LUIButton { LUIButton {
FileManager.default.openDocumentFolder() FileManager.default.openDocumentFolder()
} label: { } label: {
@@ -47,16 +47,19 @@ struct DeviceListView: View {
@StateObject var receiveModel = ReceiveModel(controlPlusScreen: { show in @StateObject var receiveModel = ReceiveModel(controlPlusScreen: { show in
log("[DeviceListView] Setting showPlusScreen to \(show)") log("[DeviceListView] Setting showPlusScreen to \(show)")
errorVibration() errorVibration()
DeviceListViewModel.sharedInstance.showPlusScreen = show if show {
presentPaywall()
}
else {
OverlayCoverStack.shared.dismissTop(animated: true)
}
}) })
#endif #endif
@ObservedObject var model = DeviceListViewModel.sharedInstance
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@Environment(\.sheetActive) var sheetActive @Environment(\.sheetActive) var sheetActive
@ObservedObject var nearbyConnectionManager = NearbyConnectionManager.shared @ObservedObject var nearbyConnectionManager = NearbyConnectionManager.shared
@ObservedObject var settings = Settings.shared @ObservedObject var settings = Settings.sharedInstance
var body: some View { var body: some View {
@@ -167,9 +170,8 @@ struct DeviceListView: View {
.font(.system(size: 14)) .font(.system(size: 14))
.foregroundColor(Color.primary.opacity(0.6)) .foregroundColor(Color.primary.opacity(0.6))
LoadingIndicator() LoadingIndicator(size: 12)
.opacity(0.75) .opacity(0.75)
.frame(width: 12, height: 12)
} }
.padding(.vertical, 16) .padding(.vertical, 16)
} }
@@ -203,7 +205,7 @@ struct DeviceListView: View {
#if !EXTENSION #if !EXTENSION
NearbyConnectionManager.shared.becomeVisible() NearbyConnectionManager.shared.becomeVisible()
if Settings.shared.incomingTransmissionCount > 0 { if Settings.sharedInstance.incomingTransmissionCount > 0 {
runAfter(seconds: 0.3) { runAfter(seconds: 0.3) {
requestReviewOnce() requestReviewOnce()
} }
@@ -220,9 +222,6 @@ struct DeviceListView: View {
NearbyConnectionManager.shared.stopDeviceDiscovery() NearbyConnectionManager.shared.stopDeviceDiscovery()
} }
} }
#if !EXTENSION
.modifier(PlusVersionSheetModifier(sheetActive: $model.showPlusScreen, isPlusVersion: $settings.gotPlus))
#endif
.animation(.smooth, value: nearbyConnectionManager.hasLocalNetworkPermission) .animation(.smooth, value: nearbyConnectionManager.hasLocalNetworkPermission)
.animation(.smooth, value: nearbyConnectionManager.isConnectedToLocalNetwork) .animation(.smooth, value: nearbyConnectionManager.isConnectedToLocalNetwork)
.navigationBarItems(trailing: ZStack { .navigationBarItems(trailing: ZStack {
@@ -237,13 +236,6 @@ struct DeviceListView: View {
} }
final class DeviceListViewModel: SharedInstance {
static var sharedInstance = DeviceListViewModel()
@Published var showPlusScreen = false
}
struct DeviceFilePickerButton: View { struct DeviceFilePickerButton: View {
let device: RemoteDeviceInfo let device: RemoteDeviceInfo
@@ -277,7 +269,7 @@ struct DeviceButtonLabel: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
let name = device.name ?? "UnknownDevice".localized() let name = device.name ?? "AndroidDevice".localized()
ZStack { ZStack {
Circle() Circle()
@@ -15,7 +15,7 @@ public struct LocalNetworkPermissionView: View {
public var body: some View { public var body: some View {
PermissionView(title: "introduction_local_network_access", symbol: "", subtitle: "introduction_local_network_access_description", permissionAction: { PermissionView(title: "introduction_local_network_access", watchSymbol: "", subtitle: "introduction_local_network_access_description".localized(with: "introduction_continue".localized()), permissionAction: {
if requestedLocalNetworkAccess { if requestedLocalNetworkAccess {
// Something went wrong, user pressed again on continue. In this case, just continue with setup // Something went wrong, user pressed again on continue. In this case, just continue with setup
@@ -27,15 +27,12 @@ public struct LocalNetworkPermissionView: View {
let manager = NearbyConnectionManager.shared let manager = NearbyConnectionManager.shared
manager.startDeviceDiscovery() manager.startDeviceDiscovery()
} }
}, canSkip: false, nextView: { }, continueLabel: "introduction_continue", canSkip: false, nextView: {
IntroductionDoneView() IntroductionDoneView()
}, nextViewSkip: {}, frontMockup: { }, nextViewSkip: {}, topView: {
AppMockupFrontView() IntroductionIconView(icon: "wifi")
}, backMockup: {
SymbolMockupView(symbol: "globe")
}) })
.onChange(of: scenePhase) { newValue in .onChange(of: scenePhase) { newValue in
// alert was removed from screen, app is foreground again -> continue with intro // alert was removed from screen, app is foreground again -> continue with intro
if newValue == .active && requestedLocalNetworkAccess { if newValue == .active && requestedLocalNetworkAccess {
IntroductionViewController.sharedInstance.canProceed = true IntroductionViewController.sharedInstance.canProceed = true
+12 -46
View File
@@ -19,7 +19,7 @@ struct PhotoManager {
/// Returns if there are photos or videos among the given URLs. /// Returns if there are photos or videos among the given URLs.
static func hasPhotosOrVideos(at urls: [URL]) -> Bool { static func hasPhotosOrVideos(at urls: [URL]) -> Bool {
urls.contains(where: { isImageFile(at: $0) || isVideoFile(at: $0) }) urls.contains(where: { EXIFUtils.isImageFile(at: $0) || EXIFUtils.isVideoFile(at: $0) })
} }
@@ -30,9 +30,6 @@ struct PhotoManager {
/// - Throws: `SaveToPhotosError` or underlying URLSession/Photos errors. /// - Throws: `SaveToPhotosError` or underlying URLSession/Photos errors.
static func saveMediaToPhotoLibrary(from urls: [URL], openPhotosOnSuccess: Bool = true) async throws { static func saveMediaToPhotoLibrary(from urls: [URL], openPhotosOnSuccess: Bool = true) async throws {
let candidates = urls.filter { isImageFile(at: $0) || isVideoFile(at: $0) }
guard !candidates.isEmpty else { throw SaveToPhotosError.noSavableContent }
let authStatus = await requestAddOnlyPhotoAuthorization() let authStatus = await requestAddOnlyPhotoAuthorization()
guard authStatus == .authorized || authStatus == .limited else { guard authStatus == .authorized || authStatus == .limited else {
throw SaveToPhotosError.permissionDenied throw SaveToPhotosError.permissionDenied
@@ -41,16 +38,15 @@ struct PhotoManager {
var imageItems: [ImageItem] = [] var imageItems: [ImageItem] = []
var videoItems: [VideoItem] = [] var videoItems: [VideoItem] = []
// Extract creation date for image and video // Extract creation date for image and video
for sourceURL in candidates { for sourceURL in urls {
let (type, date) = EXIFUtils.originalDate(from: sourceURL)
if isImageFile(at: sourceURL) { if type == .image {
let exifDate = EXIFUtils.exifOriginalDate(from: sourceURL) imageItems.append(ImageItem(fileURL: sourceURL, creationDate: date))
imageItems.append(ImageItem(fileURL: sourceURL, creationDate: exifDate)) }
} else { if type == .video {
let videoDate = getVideoCreationDate(from: sourceURL) videoItems.append(VideoItem(fileURL: sourceURL, creationDate: date))
videoItems.append(VideoItem(fileURL: sourceURL, creationDate: videoDate))
} }
} }
@@ -116,40 +112,6 @@ struct PhotoManager {
} }
private static func isImageFile(at url: URL) -> Bool {
let ext = url.pathExtension.lowercased()
guard let type = UTType(filenameExtension: ext) else {
return false
}
return type.conforms(to: .image)
}
private static func isVideoFile(at url: URL) -> Bool {
let ext = url.pathExtension.lowercased()
guard let type = UTType(filenameExtension: ext) else {
return false
}
// `.movie` is the canonical file-based video type; `.audiovisualContent`
// catches some broader cases if needed.
return type.conforms(to: .movie) || type.conforms(to: .audiovisualContent)
}
private static func getVideoCreationDate(from url: URL) -> Date? {
let asset = AVURLAsset(url: url)
// AVMetadataItem that typically contains an ISO‑8601 string
guard let item = asset.creationDate,
let dateString = item.stringValue else {
return nil
}
let formatter = ISO8601DateFormatter()
return formatter.date(from: dateString)
}
/// Requests add-only Photos permission /// Requests add-only Photos permission
private static func requestAddOnlyPhotoAuthorization() async -> PHAuthorizationStatus { private static func requestAddOnlyPhotoAuthorization() async -> PHAuthorizationStatus {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
@@ -158,8 +120,12 @@ struct PhotoManager {
} }
} }
} }
}
// MARK: - Private types
private extension PhotoManager {
private enum SaveToPhotosError: Error, LocalizedError { private enum SaveToPhotosError: Error, LocalizedError {
case noSavableContent case noSavableContent
case permissionDenied case permissionDenied
-69
View File
@@ -1,69 +0,0 @@
//
// PieProgressView.swift
// QuickDrop
//
// Created by Leon Böttger on 16.08.25.
//
import SwiftUI
struct PieProgressView: View {
let progress: CGFloat // value between 0.0 and 1.0
var size: CGFloat = 25.0
var color: Color? = nil
var body: some View {
let color = color ?? .gray
ZStack {
Circle()
.stroke(lineWidth: size * 0.08)
.foregroundColor(color)
ZStack {
PieSlice(progress: progress)
.fill(color)
.rotationEffect(.degrees(-90)) // Start from the top
}
.overlay (
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(color == .white ? .blue : .white)
.opacity(progress >= 1.0 ? 1.0 : 0.0) // Show checkmark only when complete
)
}
.frame(width: size, height: size)
}
}
struct PieSlice: Shape {
var progress: CGFloat // from 0.0 to 1.0
func path(in rect: CGRect) -> Path {
var path = Path()
let center = CGPoint(x: rect.midX, y: rect.midY)
let radius = rect.width / 2
let endAngle = Angle(degrees: 360 * Double(progress))
path.move(to: center)
path.addArc(center: center,
radius: radius,
startAngle: .degrees(0),
endAngle: endAngle,
clockwise: false)
path.closeSubpath()
return path
}
}
#Preview {
PieProgressView(progress: 0.76)
.padding()
}
+145 -58
View File
@@ -11,6 +11,8 @@ import SwiftUI
import LUI import LUI
#if os(macOS) #if os(macOS)
import Cocoa import Cocoa
#elseif canImport(UIKit)
import UIKit
#endif #endif
func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @escaping (AttachmentDetails) -> Void) { func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @escaping (AttachmentDetails) -> Void) {
@@ -42,7 +44,31 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
ignoredAttachments += 1 ignoredAttachments += 1
} }
checkIfAttachmentsLoaded(result: result, attachmentCount: attachments.count, ignoredAttachments: ignoredAttachments) checkIfAttachmentsLoaded(result: result,
attachmentCount: attachments.count,
ignoredAttachments: ignoredAttachments,
item: item,
loadedItems: loadedItems)
}
}
else if attachment.hasItemConformingToTypeIdentifier(UTType.image.identifier) {
let imageTypeIdentifier = attachment.registeredTypeIdentifiers.first(where: {
UTType($0)?.conforms(to: .image) == true
}) ?? UTType.image.identifier
attachment.loadItem(forTypeIdentifier: imageTypeIdentifier, options: nil) { data, _ in
if let url = imageUrl(from: data, typeIdentifier: imageTypeIdentifier) {
result.urls += [url]
}
else {
log("Attachment is not an image URL or data, ignoring. All attachments: \(attachments.description)")
ignoredAttachments += 1
}
checkIfAttachmentsLoaded(result: result,
attachmentCount: attachments.count,
ignoredAttachments: ignoredAttachments,
item: item,
loadedItems: loadedItems)
} }
} }
else { else {
@@ -52,20 +78,28 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
log("Found NSURL: \(url)") log("Found NSURL: \(url)")
result.urls += [(url as URL)] result.urls += [(url as URL)]
checkIfAttachmentsLoaded(result: result, attachmentCount: attachments.count, ignoredAttachments: ignoredAttachments) checkIfAttachmentsLoaded(result: result,
attachmentCount: attachments.count,
ignoredAttachments: ignoredAttachments,
item: item,
loadedItems: loadedItems)
} }
else { else {
log("Attachment is not a URL, ignoring. All attachments: \(attachments.description)") log("Attachment is not a URL, ignoring. All attachments: \(attachments.description)")
ignoredAttachments += 1 ignoredAttachments += 1
checkIfAttachmentsLoaded(result: result, attachmentCount: attachments.count, ignoredAttachments: ignoredAttachments) checkIfAttachmentsLoaded(result: result,
attachmentCount: attachments.count,
ignoredAttachments: ignoredAttachments,
item: item,
loadedItems: loadedItems)
} }
} }
} }
} }
} }
else { else {
loadTextIfPossible() deliverTextIfPossible(from: item, loadedItems: loadedItems)
} }
} }
else { else {
@@ -74,83 +108,136 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil) let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
extensionContext.cancelRequest(withError: cancelError) extensionContext.cancelRequest(withError: cancelError)
} }
}
func loadTextIfPossible() { private func deliverTextIfPossible(from item: NSExtensionItem, loadedItems: @escaping (AttachmentDetails) -> Void) {
if let text = item.attributedContentText?.string { guard let details = textAttachmentDetails(from: item) else { return }
DispatchQueue.main.async {
loadedItems(details)
}
}
log("Found text content: \(text)")
result.textToSend = text private func textAttachmentDetails(from item: NSExtensionItem) -> AttachmentDetails? {
guard let text = item.attributedContentText?.string else { return nil }
#if os(macOS) log("Found text content: \(text)")
result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
#else
result.previewImage = Image(systemName: "text.document")
#endif
let maxLength = 50 var result = AttachmentDetails(urls: [], textToSend: text, shortDescription: "", previewImage: nil)
let cleanedText = text.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: "")
if cleanedText.count > maxLength { #if os(macOS)
let index = cleanedText.index(cleanedText.startIndex, offsetBy: maxLength) result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
result.shortDescription = String(cleanedText[..<index]) + "..." #else
} else { result.previewImage = Image(systemName: "text.document")
result.shortDescription = cleanedText #endif
}
DispatchQueue.main.async { let maxLength = 50
loadedItems(result) let cleanedText = text.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: "")
}
} if cleanedText.count > maxLength {
let index = cleanedText.index(cleanedText.startIndex, offsetBy: maxLength)
result.shortDescription = String(cleanedText[..<index]) + "..."
} else {
result.shortDescription = cleanedText
} }
return result
}
func checkIfAttachmentsLoaded(result: AttachmentDetails, attachmentCount: Int, ignoredAttachments: Int = 0) {
var result = result private func checkIfAttachmentsLoaded(result: AttachmentDetails,
attachmentCount: Int,
ignoredAttachments: Int = 0,
item: NSExtensionItem,
loadedItems: @escaping (AttachmentDetails) -> Void) {
if result.urls.count == attachmentCount-ignoredAttachments { var result = result
// Unable to load anything useful, check if there is text as last resort if result.urls.count == attachmentCount-ignoredAttachments {
if result.urls.count == 0 {
loadTextIfPossible()
return
}
else if result.urls.count == 1 {
if result.urls[0].isFileURL { // Unable to load anything useful, check if there is text as last resort
if result.urls.count == 0 {
deliverTextIfPossible(from: item, loadedItems: loadedItems)
return
}
else if result.urls.count == 1 {
log("Single file URL found: \(result.urls[0])") if result.urls[0].isFileURL {
result.shortDescription = result.urls[0].lastPathComponent log("Single file URL found: \(result.urls[0])")
#if os(macOS)
result.previewImage = NSWorkspace.shared.icon(forFile: result.urls[0].path) result.shortDescription = result.urls[0].lastPathComponent
#else
result.previewImage = Image(systemName: "document")
#endif
}
else if result.urls[0].scheme == "http" || result.urls[0].scheme == "https" {
result.shortDescription = result.urls[0].absoluteString
#if os(macOS)
result.previewImage = NSImage(named: NSImage.networkName)
#else
result.previewImage = Image(systemName: "network")
#endif
}
}
else {
result.shortDescription = String.localizedStringWithFormat("NFiles".localized(), result.urls.count)
#if os(macOS) #if os(macOS)
result.previewImage = NSImage(named: NSImage.multipleDocumentsName) result.previewImage = NSWorkspace.shared.icon(forFile: result.urls[0].path)
#else #else
result.previewImage = Image(systemName: "document") result.previewImage = Image(systemName: "document")
#endif #endif
} }
else if result.urls[0].scheme == "http" || result.urls[0].scheme == "https" {
DispatchQueue.main.async { result.shortDescription = result.urls[0].absoluteString
loadedItems(result) #if os(macOS)
result.previewImage = NSImage(named: NSImage.networkName)
#else
result.previewImage = Image(systemName: "network")
#endif
} }
} }
else {
result.shortDescription = String.localizedStringWithFormat("NFiles".localized(), result.urls.count)
#if os(macOS)
result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
#else
result.previewImage = Image(systemName: "document")
#endif
}
DispatchQueue.main.async {
loadedItems(result)
}
}
}
private func imageUrl(from data: Any?, typeIdentifier: String) -> URL? {
if let url = data as? NSURL {
log("Found image URL: \(url)")
return url as URL
}
if let data = data as? Data {
let fileExtension = UTType(typeIdentifier)?.preferredFilenameExtension ?? "png"
return writeDataToTemporaryFile(data: data, fileExtension: fileExtension)
}
#if canImport(UIKit)
if let image = data as? UIImage,
let imageData = image.pngData() {
return writeDataToTemporaryFile(data: imageData, fileExtension: "png")
}
#elseif os(macOS)
if let image = data as? NSImage,
let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff),
let pngData = rep.representation(using: .png, properties: [:]) {
return writeDataToTemporaryFile(data: pngData, fileExtension: "png")
}
#endif
return nil
}
private func writeDataToTemporaryFile(data: Data, fileExtension: String) -> URL? {
let sanitizedExtension = fileExtension.isEmpty ? "dat" : fileExtension
let tempUrl = FileManager.default.temporaryDirectory
.appendingPathComponent("\(UUID().uuidString).\(sanitizedExtension)")
do {
try data.write(to: tempUrl)
return tempUrl
}
catch {
log("Failed to write attachment data to temp file: \(error)")
return nil
} }
} }
@@ -6,5 +6,9 @@
<array> <array>
<string>group.com.leonboettger.neardrop</string> <string>group.com.leonboettger.neardrop</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
</array>
</dict> </dict>
</plist> </plist>
+32 -11
View File
@@ -5,10 +5,11 @@
// Created by Grishka on 12.09.2023. // Created by Grishka on 12.09.2023.
// //
import AppKit
import Cocoa import Cocoa
import Foundation import Foundation
import SwiftUI
import LUI import LUI
import SwiftUI
class ShareViewController: NSViewController, OutboundAppDelegate { class ShareViewController: NSViewController, OutboundAppDelegate {
@@ -54,7 +55,7 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
loadAttachments(with: extensionContext, loadedItems: { result in loadAttachments(with: extensionContext, loadedItems: { result in
log("Loaded attachments: \(result)") log("[ShareViewController] Loaded attachments: \(result)")
self.urls = result.urls self.urls = result.urls
self.textToSend = result.textToSend self.textToSend = result.textToSend
@@ -107,7 +108,7 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
override func viewWillDisappear() { override func viewWillDisappear() {
log("ShareViewController: viewWillDisappear") log("[ShareViewController] ShareViewController: viewWillDisappear")
timeoutDispatchWorkItem?.cancel() timeoutDispatchWorkItem?.cancel()
@@ -268,10 +269,21 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
alert.alertStyle = .critical alert.alertStyle = .critical
alert.messageText = "TimeoutTitle".localized() alert.messageText = "TimeoutTitle".localized()
alert.informativeText = "TimeoutDescription".localized()
alert.addButton(withTitle: "TimeoutButton".localized())
alert.beginSheetModal(for: self.view.window!) { _ in } if #available(macOS 15.0, *) {
alert.informativeText = "TimeoutDescription".localized()
alert.addButton(withTitle: "TimeoutButton".localized())
alert.addButton(withTitle: "CloseAlert".localized())
} else {
alert.informativeText = "TimeoutDescriptionLegacy".localized()
alert.addButton(withTitle: "TimeoutButtonLegacy".localized())
}
alert.beginSheetModal(for: self.view.window!) { response in
if #available(macOS 15.0, *), response == .alertFirstButtonReturn {
openPrivacyAndSecuritySettings()
}
}
} }
} }
@@ -301,18 +313,18 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
let isRunning = runningApps.contains { $0.bundleIdentifier == bundleIdentifier } let isRunning = runningApps.contains { $0.bundleIdentifier == bundleIdentifier }
if !isRunning { if !isRunning {
log("Launching main app") log("[ShareViewController] Launching main app")
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) { if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
let configuration = NSWorkspace.OpenConfiguration() let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in
if let error = error { if let error = error {
log("Failed to launch application: \(error)") log("[ShareViewController] Failed to launch application: \(error)")
} else { } else {
log("Application launched successfully") log("[ShareViewController] Application launched successfully")
} }
} }
} else { } else {
log("Could not find application with bundle identifier \(bundleIdentifier)") log("[ShareViewController] Could not find application with bundle identifier \(bundleIdentifier)")
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
@@ -324,6 +336,9 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
NearbyConnectionManager.shared.startDeviceDiscovery() NearbyConnectionManager.shared.startDeviceDiscovery()
scheduleAutomaticQrCodeView() scheduleAutomaticQrCodeView()
} }
// Force local network access prompt if not already granted
let _ = DeviceToDeviceHeuristicScanner.shared.hasLocalNetworkAccess(completion: {_ in })
} }
@@ -386,6 +401,12 @@ extension ShareViewController: NSCollectionViewDataSource {
return name return name
} }
return "UnknownDevice".localized() return "AndroidDevice".localized()
} }
} }
fileprivate func openPrivacyAndSecuritySettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension") else { return }
NSWorkspace.shared.open(url)
}
@@ -8,5 +8,9 @@
<array> <array>
<string>group.com.leonboettger.neardrop</string> <string>group.com.leonboettger.neardrop</string>
</array> </array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
</array>
</dict> </dict>
</plist> </plist>