mirror of
https://github.com/leonboe1/QuickDrop.git
synced 2026-11-11 02:51:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d0b720114 | ||
|
|
13f3073077 | ||
|
|
ff39ba4c7d | ||
|
|
245358ae96 | ||
|
|
429069f372 | ||
|
|
04e056da02 | ||
|
|
50d4d4ee92 | ||
|
|
86b7e4ca24 | ||
|
|
d3a95c068f | ||
|
|
e3cbc19f11 | ||
|
|
c46f6eedbc | ||
|
|
ea213906d6 | ||
|
|
4700a9c56e | ||
|
|
4fe30da52b | ||
|
|
bd93961c59 | ||
|
|
f244572508 | ||
|
|
d4ea143d51 | ||
|
|
2dbc46f792 | ||
|
|
f61be69507 | ||
|
|
82f40d780f | ||
|
|
606082d76d | ||
|
|
2233699ece | ||
|
|
130935c01d | ||
|
|
74226d2fc3 | ||
|
|
e895e7a68e | ||
|
|
843ed9fe4f | ||
|
|
2ef3bc3b44 | ||
|
|
216e2cf851 | ||
|
|
d63665136b | ||
|
|
42c101605f | ||
|
|
fd1dfb4f4a | ||
|
|
767a5c5706 | ||
|
|
8e78f58b5e | ||
|
|
935bdc5a65 | ||
|
|
f559a6ac9d | ||
|
|
e853ba2b36 | ||
|
|
4621395a87 | ||
|
|
55e2a72a90 | ||
|
|
159e83179b | ||
|
|
6a77e4aba6 | ||
|
|
ffe3ac7a4a | ||
|
|
2dbe945f3e | ||
|
|
e828edd6ae | ||
|
|
7fbe254118 | ||
|
|
cffaabc076 | ||
|
|
3736605509 | ||
|
|
e0407d9398 | ||
|
|
3ed93e6079 | ||
|
|
e1924e1919 | ||
|
|
c10facbedd | ||
|
|
a1b4a075a6 | ||
|
|
bf7ad37d15 | ||
|
|
e68167788a | ||
|
|
48eb5be4a5 | ||
|
|
a352e9ed1c | ||
|
|
0f8c9d254e | ||
|
|
be4d342894 | ||
|
|
811b991fcd | ||
|
|
d46b62d3de | ||
|
|
093390084f | ||
|
|
b16556dd1b | ||
|
|
f5d895ed56 | ||
|
|
c489ef018f | ||
|
|
7e0a2e2830 | ||
|
|
0b94a888b5 | ||
|
|
15677b7843 | ||
|
|
6401c1969c | ||
|
|
bb1f28af0b | ||
|
|
d367e0ff34 | ||
|
|
ec093cf302 | ||
|
|
edc09b048c | ||
|
|
9f15ee85f6 | ||
|
|
10ef0c5a21 | ||
|
|
0aa4fd6894 | ||
|
|
71bddf6541 | ||
|
|
3ac2280dc6 | ||
|
|
8b965a7eb2 | ||
|
|
f11943475c | ||
|
|
a5781e6e96 | ||
|
|
ed20c9155e | ||
|
|
b55d6ff07b | ||
|
|
c86ed97bc2 | ||
|
|
eb32603c5f | ||
|
|
044753efb1 | ||
|
|
e8fdd0e94a | ||
|
|
09ef75d249 | ||
|
|
3551c1a006 | ||
|
|
d748167f0e | ||
|
|
02e8af22ab | ||
|
|
0e1fab8faa | ||
|
|
0396c56a31 | ||
|
|
ffdf667cc6 | ||
|
|
cce84e9460 | ||
|
|
b546370e87 | ||
|
|
fbd14c2280 | ||
|
|
bf1a8164d9 | ||
|
|
0a53ad79cb | ||
|
|
800fd23be3 | ||
|
|
487a27a019 | ||
|
|
c74858ea19 | ||
|
|
40169334ff | ||
|
|
ed213b4d3c | ||
|
|
0697b94547 | ||
|
|
e8fc6f719e | ||
|
|
9dfe864677 | ||
|
|
a9e6ed1ddf | ||
|
|
728385ff95 | ||
|
|
46cdec35d5 | ||
|
|
daa1ab77df | ||
|
|
6766ef69d9 | ||
|
|
7a964e35ca | ||
|
|
733ed8bec8 | ||
|
|
24a0202ae9 | ||
|
|
945fdcaf6e | ||
|
|
7cdfdcd474 | ||
|
|
ad1ab30b94 | ||
|
|
4a8ba26c8c | ||
|
|
c887ccb637 | ||
|
|
fb13405247 | ||
|
|
b61aecf315 | ||
|
|
8a9bb0bdfe | ||
|
|
1bd15acaef | ||
|
|
3b99eda41c | ||
|
|
8393a7495f | ||
|
|
a5e1030c1f | ||
|
|
8aa3341595 | ||
|
|
f5e3c88daf | ||
|
|
488f898d7b | ||
|
|
9e7278d327 | ||
|
|
fdc19b6f62 | ||
|
|
734cf766d5 | ||
|
|
f948c30027 | ||
|
|
0c440b7511 | ||
|
|
bf5724ece1 | ||
|
|
658a2ff28e | ||
|
|
4ddf39817c | ||
|
|
3fa1d8621e | ||
|
|
394aaa8093 | ||
|
|
5e62f3e107 | ||
|
|
3f65ace2ae | ||
|
|
41b6eb8e3a | ||
|
|
1256b91d4f |
No files matched your search
@@ -1,3 +1,4 @@
|
||||
DerivedData/
|
||||
.DerivedData/
|
||||
.DS_Store
|
||||
xcuserdata/
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"appPolicies" : {
|
||||
"eula" : "",
|
||||
"policies" : [
|
||||
{
|
||||
"locale" : "en_US",
|
||||
"policyText" : "",
|
||||
"policyURL" : ""
|
||||
}
|
||||
]
|
||||
},
|
||||
"identifier" : "07114EE9",
|
||||
"nonRenewingSubscriptions" : [
|
||||
|
||||
],
|
||||
"products" : [
|
||||
{
|
||||
"displayPrice" : "4.99",
|
||||
"familyShareable" : true,
|
||||
"internalID" : "6742512912",
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlocks unlimited file transfer, forever.",
|
||||
"displayName" : "QuickDrop+",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "plusversion",
|
||||
"referenceName" : "QuickDrop+ Lifetime",
|
||||
"type" : "NonConsumable"
|
||||
}
|
||||
],
|
||||
"settings" : {
|
||||
"_applicationInternalID" : "6740147178",
|
||||
"_askToBuyEnabled" : false,
|
||||
"_billingGracePeriodEnabled" : false,
|
||||
"_billingIssuesEnabled" : false,
|
||||
"_developerTeamID" : "92SDAWLN76",
|
||||
"_disableDialogs" : false,
|
||||
"_failTransactionsEnabled" : false,
|
||||
"_lastSynchronizedDate" : 788014826.59455097,
|
||||
"_locale" : "en_US",
|
||||
"_renewalBillingIssuesEnabled" : false,
|
||||
"_storefront" : "USA",
|
||||
"_storeKitErrors" : [
|
||||
|
||||
],
|
||||
"_timeRate" : 0
|
||||
},
|
||||
"subscriptionGroups" : [
|
||||
{
|
||||
"id" : "21845627",
|
||||
"localizations" : [
|
||||
|
||||
],
|
||||
"name" : "QuickDrop+ Subscription",
|
||||
"subscriptions" : [
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "1.99",
|
||||
"familyShareable" : false,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6756841799",
|
||||
"introductoryOffer" : null,
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlocks unlimited file transfer.",
|
||||
"displayName" : "QuickDrop+ (1 Month)",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "quickdrop.monthly",
|
||||
"recurringSubscriptionPeriod" : "P1M",
|
||||
"referenceName" : "QuickDrop+ (1 Month)",
|
||||
"subscriptionGroupID" : "21845627",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
"adHocOffers" : [
|
||||
|
||||
],
|
||||
"codeOffers" : [
|
||||
|
||||
],
|
||||
"displayPrice" : "3.99",
|
||||
"familyShareable" : true,
|
||||
"groupNumber" : 1,
|
||||
"internalID" : "6755912812",
|
||||
"introductoryOffer" : {
|
||||
"internalID" : "D30B2549",
|
||||
"numberOfPeriods" : 1,
|
||||
"paymentMode" : "free",
|
||||
"subscriptionPeriod" : "P3D"
|
||||
},
|
||||
"localizations" : [
|
||||
{
|
||||
"description" : "Unlocks unlimited file transfer.",
|
||||
"displayName" : "QuickDrop+ (1 Year)",
|
||||
"locale" : "en_US"
|
||||
}
|
||||
],
|
||||
"productID" : "quickdrop.yearly",
|
||||
"recurringSubscriptionPeriod" : "P1Y",
|
||||
"referenceName" : "QuickDrop+ (1 Year)",
|
||||
"subscriptionGroupID" : "21845627",
|
||||
"type" : "RecurringSubscription",
|
||||
"winbackOffers" : [
|
||||
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"version" : {
|
||||
"major" : 4,
|
||||
"minor" : 0
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import Network
|
||||
import System
|
||||
import BigInt
|
||||
import SwiftECC
|
||||
import ASN1
|
||||
import LUI
|
||||
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
@@ -29,7 +31,11 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
private var bytesToBeTransferred: Int64 = 0
|
||||
private var isAuthenticated = false
|
||||
private var peerCertificate: Sharing_Nearby_PublicCertificate? = nil
|
||||
private var mirroredNotificationMetadata: Sharing_Nearby_MirroredNotificationMetadata?
|
||||
private var pairingMetadata: Sharing_Nearby_PairingMetadata?
|
||||
private var isPairingSetupRequest = false
|
||||
var isPlainTextTransfer = false
|
||||
var isControlTransfer = false
|
||||
|
||||
var wasUserRejected = false
|
||||
var delegate: InboundNearbyConnectionDelegate?
|
||||
@@ -45,24 +51,22 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
|
||||
|
||||
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()
|
||||
currentState = .disconnected
|
||||
deletePartiallyReceivedFiles()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.connectionWasTerminated(connection: self, error: self.lastError)
|
||||
|
||||
if !self.downloadedFiles.isEmpty {
|
||||
|
||||
#if os(macOS)
|
||||
if Settings.shared.openFinderAfterReceiving {
|
||||
log("[SaveFilesManager] Opening \(self.downloadedFiles.count) file(s) in Finder.")
|
||||
NSWorkspace.shared.activateFileViewerSelecting(self.downloadedFiles)
|
||||
}
|
||||
#else
|
||||
FileManager.default.openDocumentFolder()
|
||||
#endif
|
||||
}
|
||||
self.delegate?.connectionWasTerminated(connection: self, savedFiles: self.downloadedFiles, error: self.lastError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +76,19 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
if currentState != .receivingFiles {
|
||||
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 {
|
||||
switch currentState {
|
||||
@@ -122,11 +139,64 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
}
|
||||
switch currentState {
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
if frame.hasV1, frame.v1.hasType, frame.v1.type == .progressUpdate {
|
||||
// ignore progress updates
|
||||
@@ -151,19 +221,23 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
|
||||
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
|
||||
|
||||
guard frame.payloadChunk.offset == currentOffset else { throw NearbyError.protocolError("Invalid offset into file \(frame.payloadChunk.offset), expected \(currentOffset)") }
|
||||
|
||||
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 {
|
||||
try fileInfo.fileHandle?.write(contentsOf: frame.payloadChunk.body)
|
||||
filesToBeReceived[id]!.bytesTransferred += Int64(frame.payloadChunk.body.count)
|
||||
fileInfo.progress?.completedUnitCount = filesToBeReceived[id]!.bytesTransferred
|
||||
fileInfo.bytesTransferred += Int64(frame.payloadChunk.body.count)
|
||||
fileInfo.progress?.completedUnitCount = fileInfo.bytesTransferred
|
||||
filesToBeReceived[id] = fileInfo
|
||||
|
||||
self.bytesTransferred += Int64(frame.payloadChunk.body.count)
|
||||
NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: Double(self.bytesTransferred) / Double(self.bytesToBeTransferred))
|
||||
@@ -173,15 +247,19 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
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()
|
||||
filesToBeReceived[id]!.fileHandle = nil
|
||||
fileInfo.fileHandle = nil
|
||||
#if os(macOS)
|
||||
fileInfo.progress?.unpublish()
|
||||
#endif
|
||||
downloadedFiles.append(fileInfo.destinationURL)
|
||||
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL)
|
||||
|
||||
EXIFUtils.applyTimestamps(at: fileInfo.destinationURL)
|
||||
filesToBeReceived[id] = fileInfo
|
||||
filesToBeReceived.removeValue(forKey: id)
|
||||
|
||||
if filesToBeReceived.isEmpty {
|
||||
@@ -190,7 +268,7 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
try sendDisconnectionAndDisconnect()
|
||||
}
|
||||
}
|
||||
else {
|
||||
else if !hasBody {
|
||||
log("[InboundNearbyConnection \(self.id)] Received file chunk with no body and no flags, ignoring it.")
|
||||
}
|
||||
}
|
||||
@@ -234,7 +312,7 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
#endif
|
||||
filesToBeReceived.removeValue(forKey: id)
|
||||
downloadedFiles.append(fileInfo.destinationURL)
|
||||
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL)
|
||||
EXIFUtils.applyTimestamps(at: fileInfo.destinationURL)
|
||||
|
||||
if filesToBeReceived.isEmpty {
|
||||
log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...")
|
||||
@@ -253,19 +331,12 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
|
||||
guard case .connectionRequest = frame.v1.type else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
|
||||
|
||||
let endpointInfo = 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
|
||||
|
||||
guard endpointInfo.count > 17 else { throw NearbyError.protocolError("Endpoint info too short") }
|
||||
|
||||
let deviceNameLength = Int(endpointInfo[17])
|
||||
|
||||
guard endpointInfo.count >= deviceNameLength + 18 else { throw NearbyError.protocolError("Endpoint info too short to contain the device name") }
|
||||
|
||||
guard let deviceName = String(data: endpointInfo[18 ..< (18 + deviceNameLength)], encoding: .utf8) else { throw NearbyError.protocolError("Device name is not valid UTF-8") }
|
||||
|
||||
let rawDeviceType = Int(endpointInfo[0] & 7) >> 1
|
||||
|
||||
remoteDeviceInfo = RemoteDeviceInfo(name: deviceName, type: RemoteDeviceInfo.DeviceType.fromRawValue(value: rawDeviceType))
|
||||
remoteDeviceInfo = RemoteDeviceInfo(name: deviceName, type: endpointInfo?.deviceType)
|
||||
currentState = .receivedConnectionRequest
|
||||
}
|
||||
|
||||
@@ -385,10 +456,29 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
pairedEncryption.v1 = Sharing_Nearby_V1Frame()
|
||||
pairedEncryption.v1.type = .pairedKeyEncryption
|
||||
pairedEncryption.v1.pairedKeyEncryption = Sharing_Nearby_PairedKeyEncryptionFrame()
|
||||
|
||||
// For inbound connections, we do not use provide secretIDHash and signedData
|
||||
pairedEncryption.v1.pairedKeyEncryption.secretIDHash = Data.randomData(length: 6)
|
||||
pairedEncryption.v1.pairedKeyEncryption.signedData = Data.randomData(length: 72)
|
||||
|
||||
if let signingPrivateKey = IdentityManager.shared.getPrivateKey(),
|
||||
let publicKey = IdentityManager.shared.getPublicKey()?.toGenericPublicKey(),
|
||||
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)
|
||||
currentState = .sentConnectionResponse
|
||||
@@ -435,6 +525,7 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
|
||||
if peerPublicKey.verify(signature: signature, msg: authKeyData) {
|
||||
log("[InboundNearbyConnection \(self.id)] Paired key authentication successful.")
|
||||
self.peerCertificate = trustedCert
|
||||
try sendPairedKeyResult(status: .success)
|
||||
isAuthenticated = true
|
||||
} else {
|
||||
@@ -525,26 +616,143 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
}
|
||||
let metadata = TransferMetadata(files: filesToBeReceived.map { $0.value.meta }, id: id, pinCode: pinCode, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
|
||||
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 metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isURL ? .url : .text, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
|
||||
textPayloadID = textMetadata.payloadID
|
||||
|
||||
|
||||
let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isURL ? .url : .text, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
|
||||
|
||||
if !isURL {
|
||||
isPlainTextTransfer = true
|
||||
}
|
||||
|
||||
checkIfCanProceed(metadata: metadata)
|
||||
}
|
||||
else if let wifiMetadata = frame.v1.introduction.wifiCredentialsMetadata.first {
|
||||
|
||||
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 {
|
||||
rejectDueToUnsupportedFileType(frame)
|
||||
}
|
||||
else {
|
||||
log("[InboundNearbyConnection \(self.id)] Rejecting transfer due to unsupported file type. Frame is \(frame.debugDescription)")
|
||||
|
||||
lastError = NearbyError.canceled(reason: .unsupportedType)
|
||||
rejectTransfer(with: .unsupportedAttachmentType)
|
||||
}
|
||||
|
||||
|
||||
func checkIfCanProceed(metadata: TransferMetadata) {
|
||||
|
||||
let acceptAutomatically = isAuthenticated || Settings.shared.automaticallyAcceptFiles
|
||||
let acceptAutomatically = isAuthenticated || Settings.sharedInstance.automaticallyAcceptFiles
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if acceptAutomatically {
|
||||
@@ -558,22 +766,36 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func rejectDueToUnsupportedFileType(_ frame: Sharing_Nearby_Frame) {
|
||||
|
||||
log("[InboundNearbyConnection \(self.id)] Rejecting transfer due to unsupported file type. Frame is \(frame.debugDescription)")
|
||||
|
||||
lastError = NearbyError.canceled(reason: .unsupportedType)
|
||||
rejectTransfer(with: .unsupportedAttachmentType)
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
TrustStore.shared.addTrusted(certificate: peerCertificate, device: remoteDeviceInfo)
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
NearbyConnection.dispatchQueue.async {
|
||||
if accepted {
|
||||
self.acceptTransfer()
|
||||
} else {
|
||||
@@ -589,7 +811,7 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
return
|
||||
}
|
||||
|
||||
if isFileTransferRestricted() {
|
||||
if !isControlTransfer && isFileTransferRestricted() {
|
||||
log("[InboundNearbyConnection \(self.id)] File transfer restrictions detected.")
|
||||
delegate?.showPlusScreen()
|
||||
rejectTransfer()
|
||||
@@ -633,17 +855,34 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
currentState = .receivingFiles
|
||||
isTransferring = true
|
||||
try sendTransferSetupFrame(frame)
|
||||
|
||||
if let mirroredNotificationMetadata,
|
||||
!isPairingSetupRequest {
|
||||
MirroredNotificationPresenter.shared.present(metadata: mirroredNotificationMetadata, senderDeviceName: remoteDeviceInfo?.name)
|
||||
}
|
||||
if isControlTransfer {
|
||||
try sendDisconnectionAndDisconnect()
|
||||
}
|
||||
} catch {
|
||||
lastError = error
|
||||
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
|
||||
if reason == .reject {
|
||||
if reason == .reject && markAsUserRejected {
|
||||
self.wasUserRejected = true
|
||||
}
|
||||
|
||||
@@ -676,4 +915,41 @@ class InboundNearbyConnection: NearbyConnection {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,12 @@ import Network
|
||||
import System
|
||||
import BigInt
|
||||
import SwiftECC
|
||||
import LUI
|
||||
|
||||
class NearbyConnection {
|
||||
|
||||
private let maxFrameLength = 5 * 1024 * 1024
|
||||
private static let dispatchQueue = DispatchQueue(label: "com.leonboettger.quickdrop.queue", qos: .utility)
|
||||
static let dispatchQueue = DispatchQueue(label: "com.leonboettger.quickdrop.queue", qos: .utility)
|
||||
|
||||
let id: String
|
||||
let connection: NWConnection
|
||||
@@ -28,8 +29,13 @@ class NearbyConnection {
|
||||
var cancelled: Bool = false
|
||||
var isTransferring: Bool = false {
|
||||
didSet {
|
||||
log("[NearbyConnection \(self.id)] Now transferring. Setting up data transfer inactivity timer.")
|
||||
startDataTransferTimer(previousBytesTransferred: bytesTransferred)
|
||||
dataTransferInactivityTimer?.cancel()
|
||||
dataTransferInactivityTimer = nil
|
||||
|
||||
if isTransferring {
|
||||
log("[NearbyConnection \(self.id)] Now transferring. Setting up data transfer inactivity timer.")
|
||||
startDataTransferTimer(previousBytesTransferred: bytesTransferred)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +43,11 @@ class NearbyConnection {
|
||||
private var connectionClosed: Bool = false
|
||||
|
||||
private var inactivityTimer: DispatchSourceTimer?
|
||||
private let timeoutInterval: TimeInterval = 30
|
||||
private var dataTransferInactivityTimer: DispatchSourceTimer?
|
||||
|
||||
private let ackInterval: TimeInterval = 5
|
||||
private let ackTimeoutInterval: TimeInterval = 30
|
||||
private var lastKeepAliveFrameSent = Date()
|
||||
|
||||
// UKEY2-related state
|
||||
var privateKey: ECPrivateKey?
|
||||
@@ -117,7 +127,7 @@ class NearbyConnection {
|
||||
|
||||
|
||||
func connectionClosedByPeer(onError: @escaping () -> Void) {
|
||||
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + 0.5) {
|
||||
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + 1) {
|
||||
|
||||
// If already closed, ignore the error, as file transfer has been processed
|
||||
if !self.connectionClosed {
|
||||
@@ -185,7 +195,14 @@ class NearbyConnection {
|
||||
}
|
||||
guard let content = content else {
|
||||
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
|
||||
}
|
||||
let frameLength = UInt32(content[0]) << 24 | UInt32(content[1]) << 16 | UInt32(content[2]) << 8 | UInt32(content[3])
|
||||
@@ -202,7 +219,7 @@ class NearbyConnection {
|
||||
|
||||
|
||||
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 {
|
||||
log("[NearbyConnection \(self.id)] Received FIN from peer, connection closed.")
|
||||
connection.cancel()
|
||||
@@ -213,6 +230,13 @@ class NearbyConnection {
|
||||
|
||||
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)")
|
||||
|
||||
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 {
|
||||
log("[NearbyConnection \(self.id)] Connection closed by peer during receiveFrameAsync(length:), but no content received anymore (\(String(describing: content))).")
|
||||
@@ -226,11 +250,23 @@ class NearbyConnection {
|
||||
|
||||
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 {
|
||||
log("[NearbyConnection \(self.id)] Received nil content during receiveFrameAsync(length:). IsComplete: \(isComplete)")
|
||||
self.protocolError()
|
||||
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.processReceivedFrame(frameData: content)
|
||||
@@ -252,7 +288,10 @@ class NearbyConnection {
|
||||
UInt8(truncatingIfNeeded: length),
|
||||
])
|
||||
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 {
|
||||
completion()
|
||||
}
|
||||
@@ -363,9 +402,17 @@ class NearbyConnection {
|
||||
let d2dMsg = try Securegcm_DeviceToDeviceMessage(serializedBytes: decryptedData)
|
||||
|
||||
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)
|
||||
|
||||
if offlineFrame.hasV1, offlineFrame.v1.hasType, case .payloadTransfer = offlineFrame.v1.type {
|
||||
@@ -428,12 +475,15 @@ class NearbyConnection {
|
||||
}
|
||||
}
|
||||
else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .keepAlive = offlineFrame.v1.type {
|
||||
|
||||
let bytesTransferred = self.bytesTransferred
|
||||
let gigabytesTransferred = Double(bytesTransferred) / 1_000_000_000
|
||||
|
||||
log("[NearbyConnection \(self.id)] Sent keep-alive, \(self.bytesTransferred) bytes (\(gigabytesTransferred) GB) sent")
|
||||
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 {
|
||||
|
||||
if offlineFrame.hasV1, offlineFrame.v1.hasType, offlineFrame.v1.type == .bandwidthUpgradeRetry {
|
||||
@@ -467,17 +517,12 @@ class NearbyConnection {
|
||||
guard peerKey.hasEcP256PublicKey else { throw NearbyError.requiredFieldMissing("peerKey.ecP256PublicKey") }
|
||||
|
||||
let domain = Domain.instance(curve: .EC256r1)
|
||||
var clientX = peerKey.ecP256PublicKey.x
|
||||
var clientY = peerKey.ecP256PublicKey.y
|
||||
if clientX.count > 32 {
|
||||
clientX = clientX.suffix(32)
|
||||
}
|
||||
if clientY.count > 32 {
|
||||
clientY = clientY.suffix(32)
|
||||
}
|
||||
let clientX = peerKey.ecP256PublicKey.x.ensureLength(length: 32)
|
||||
let clientY = peerKey.ecP256PublicKey.y.ensureLength(length: 32)
|
||||
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()
|
||||
sha.update(data: dhs)
|
||||
let derivedSecretKey = Data(sha.finalize())
|
||||
@@ -540,6 +585,8 @@ class NearbyConnection {
|
||||
connection.stateUpdateHandler = nil
|
||||
inactivityTimer?.cancel()
|
||||
inactivityTimer = nil
|
||||
dataTransferInactivityTimer?.cancel()
|
||||
dataTransferInactivityTimer = nil
|
||||
connectionClosed = true
|
||||
connection.send(content: nil, isComplete: true, completion: .contentProcessed { _ in })
|
||||
}
|
||||
@@ -550,15 +597,23 @@ class NearbyConnection {
|
||||
offlineFrame.version = .v1
|
||||
offlineFrame.v1.type = .disconnection
|
||||
offlineFrame.v1.disconnection = Location_Nearby_Connections_DisconnectionFrame()
|
||||
|
||||
if encryptionDone {
|
||||
try encryptAndSendOfflineFrame(offlineFrame)
|
||||
} else {
|
||||
try sendFrameAsync(offlineFrame.serializedData())
|
||||
|
||||
if connectionClosed {
|
||||
disconnect()
|
||||
return
|
||||
}
|
||||
|
||||
let finishDisconnect: () -> Void = {
|
||||
log("[NearbyConnection \(self.id)] Sent disconnection frame during sendDisconnectionAndDisconnect")
|
||||
self.disconnect()
|
||||
}
|
||||
|
||||
log("[NearbyConnection \(self.id)] Sent disconnection frame during sendDisconnectionAndDisconnect")
|
||||
disconnect()
|
||||
if encryptionDone {
|
||||
try encryptAndSendOfflineFrame(offlineFrame, completion: finishDisconnect)
|
||||
} else {
|
||||
let data = try offlineFrame.serializedData()
|
||||
sendFrameAsync(data, completion: finishDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -587,25 +642,36 @@ class NearbyConnection {
|
||||
} else {
|
||||
try sendFrameAsync(offlineFrame.serializedData())
|
||||
}
|
||||
|
||||
self.lastKeepAliveFrameSent = Date()
|
||||
} catch {
|
||||
log("[NearbyConnection \(self.id)] Error sending KEEP_ALIVE: \(error)")
|
||||
}
|
||||
|
||||
log("[NearbyConnection \(self.id)] Sent keep-alive \(ack ? "ack" : "no ack"), \(self.bytesTransferred) bytes (\(Double(bytesTransferred) / 1_000_000_000) GB) sent")
|
||||
}
|
||||
|
||||
|
||||
func startAndResetHeartbeatTimer() {
|
||||
if connectionClosed {
|
||||
return
|
||||
}
|
||||
|
||||
if encryptionDone && lastKeepAliveFrameSent.isOlderThan(seconds: ackInterval) {
|
||||
sendKeepAlive(ack: false)
|
||||
}
|
||||
|
||||
// Cancel previous timer if any
|
||||
inactivityTimer?.cancel()
|
||||
|
||||
inactivityTimer = DispatchSource.makeTimerSource(queue: NearbyConnection.dispatchQueue)
|
||||
inactivityTimer?.schedule(deadline: .now() + timeoutInterval)
|
||||
inactivityTimer?.schedule(deadline: .now() + ackTimeoutInterval)
|
||||
inactivityTimer?.setEventHandler { [weak self] in
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
if !self.connectionClosed {
|
||||
log("[NearbyConnection \(self.id)] Connection timeout: No message received in \(self.timeoutInterval) seconds")
|
||||
log("[NearbyConnection \(self.id)] Connection timeout: No message received in \(self.ackTimeoutInterval) seconds")
|
||||
self.lastError = NearbyError.canceled(reason: .timedOut)
|
||||
self.disconnect()
|
||||
}
|
||||
@@ -616,7 +682,14 @@ class NearbyConnection {
|
||||
|
||||
|
||||
func startDataTransferTimer(previousBytesTransferred: Int64) {
|
||||
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + timeoutInterval) {
|
||||
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 {
|
||||
// everything good, transferred more than last check, schedule next check
|
||||
@@ -624,13 +697,13 @@ class NearbyConnection {
|
||||
}
|
||||
else {
|
||||
// connection stale, need to abort
|
||||
if !self.connectionClosed {
|
||||
log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.timeoutInterval) seconds")
|
||||
self.lastError = NearbyError.canceled(reason: .timedOut)
|
||||
self.disconnect()
|
||||
}
|
||||
log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.ackTimeoutInterval) seconds")
|
||||
self.lastError = NearbyError.canceled(reason: .timedOut)
|
||||
self.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
dataTransferInactivityTimer?.resume()
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -17,9 +17,12 @@ import SwiftECC
|
||||
import CryptoKit
|
||||
import SwiftUI
|
||||
import QRCode
|
||||
import LUI
|
||||
|
||||
public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject {
|
||||
|
||||
// MARK: Private Properties
|
||||
|
||||
#if !EXTENSION
|
||||
private let sleepManager = SleepManager.shared
|
||||
#endif
|
||||
@@ -31,7 +34,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
private var inboundAppDelegates: [InboundAppDelegate] = []
|
||||
private var outgoingTransfers: [String: OutgoingTransferInfo] = [:]
|
||||
private var startedDeviceDiscovery = false
|
||||
private var startedAdvertising = false
|
||||
private var startedAdvertising = false { didSet { informAboutStatus() }}
|
||||
private var browsers: [NWBrowser] = []
|
||||
private let serviceTypes = ["_FC9F5ED42C8A._tcp."]
|
||||
private var qrCodePrivateKey: ECPrivateKey?
|
||||
@@ -40,33 +43,39 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
private let hasConnectionMonitor = NWPathMonitor()
|
||||
private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue")
|
||||
private var securityScopeUrl: URL?
|
||||
private static let customDeviceNameKey = "com.leonboettger.quickdrop.deviceName"
|
||||
private var defaultPort: NWEndpoint.Port = 50362
|
||||
private let txtKeyEndpointInfo = "n"
|
||||
private let txtKeyNotificationSyncCapabilities = "qd_caps"
|
||||
private let txtKeyNotificationSyncKeyFingerprint = "qd_kid"
|
||||
private let notificationSyncCapabilityToken = "nsync1"
|
||||
private let unnamedAndroidFallbackDeviceName = "AndroidDevice".localized()
|
||||
|
||||
public let deviceInfo: EndpointInfo
|
||||
public let endpointID: [UInt8] = generateEndpointID()
|
||||
|
||||
// MARK: Shared Instance
|
||||
|
||||
public static let shared = NearbyConnectionManager()
|
||||
|
||||
|
||||
// MARK: Published State
|
||||
|
||||
@Published var attachments: AttachmentDetails? = nil
|
||||
@Published var hasLocalNetworkPermission = true
|
||||
@Published var isConnectedToLocalNetwork = true
|
||||
@Published var isConnectedToLocalNetwork = true { didSet { informAboutStatus() }}
|
||||
@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 = { }
|
||||
|
||||
|
||||
// MARK: Initializers
|
||||
|
||||
override init() {
|
||||
|
||||
#if os(macOS)
|
||||
self.deviceInfo = EndpointInfo(name: Host.current().localizedName ?? "Mac", deviceType: .computer)
|
||||
#else
|
||||
let marketingName = Device.current.description.withoutBracketedContent
|
||||
let isiPad = UIDevice.current.model == "iPad"
|
||||
|
||||
self.deviceInfo = EndpointInfo(name: marketingName, deviceType: isiPad ? .tablet : .phone)
|
||||
#endif
|
||||
|
||||
self.deviceInfo = Self.getEndpointInfo()
|
||||
tcpListener = try! NWListener(using: NWParameters(tls: .none))
|
||||
|
||||
super.init()
|
||||
@@ -88,7 +97,6 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
log("[NearbyConnectionManager] Local network lost.")
|
||||
}
|
||||
self.isConnectedToLocalNetwork = isConnected
|
||||
self.connectionUpdateCallback(isConnected)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,22 +126,123 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
}
|
||||
|
||||
|
||||
public func becomeVisible() {
|
||||
// MARK: Deinitializer
|
||||
|
||||
deinit {
|
||||
self.stopAccessingSaveDirectory()
|
||||
}
|
||||
|
||||
|
||||
public static func getCustomDeviceName() -> String? {
|
||||
let storedName = AppGroup.appGroupUD.string(forKey: Self.customDeviceNameKey)
|
||||
|
||||
guard let name = storedName, !name.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
|
||||
public func setCustomDeviceName(to newName: String) {
|
||||
AppGroup.appGroupUD.setValue(newName, forKey: Self.customDeviceNameKey)
|
||||
|
||||
self.deviceInfo = Self.getEndpointInfo()
|
||||
changedDeviceNameCallback()
|
||||
|
||||
if startedAdvertising {
|
||||
self.becomeInvisible()
|
||||
|
||||
runAfter(seconds: 0.5) {
|
||||
self.becomeVisible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static func getEndpointInfo() -> EndpointInfo {
|
||||
|
||||
let deviceType = isMac() ? RemoteDeviceInfo.DeviceType.computer : (isiPadOrMac() ? .tablet : .phone)
|
||||
|
||||
if let customName = getCustomDeviceName() {
|
||||
return EndpointInfo(name: String(customName), deviceType: deviceType)
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
return EndpointInfo(name: String((Host.current().localizedName ?? "Mac")), deviceType: deviceType)
|
||||
#else
|
||||
return EndpointInfo(name: String(Device.current.description.withoutBracketedContent), deviceType: deviceType)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
public func becomeVisible(randomPort: Bool = false) {
|
||||
if startedAdvertising {
|
||||
log("[NearbyConnectionManager] Already advertising, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
log("[NearbyConnectionManager] Becoming visible")
|
||||
let parameters = NWParameters(tls: .none)
|
||||
|
||||
tcpListener = try! NWListener(using: NWParameters(tls: .none))
|
||||
startedAdvertising = true
|
||||
|
||||
tcpListener.stateUpdateHandler = { (state: NWListener.State) in
|
||||
if case .ready = state {
|
||||
self.initMDNS()
|
||||
do {
|
||||
if randomPort {
|
||||
self.tcpListener = try NWListener(using: parameters)
|
||||
} else {
|
||||
self.tcpListener = try NWListener(using: parameters, on: defaultPort)
|
||||
}
|
||||
}
|
||||
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
|
||||
let id = UUID().uuidString
|
||||
let conn = InboundNearbyConnection(connection: connection, id: id)
|
||||
@@ -141,6 +250,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
conn.delegate = self
|
||||
conn.start()
|
||||
}
|
||||
|
||||
tcpListener.start(queue: .global(qos: .utility))
|
||||
}
|
||||
|
||||
@@ -159,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
|
||||
if let savedString = Settings.shared.endpointID,
|
||||
if !forceRegeneration,
|
||||
let savedString = Settings.sharedInstance.endpointID,
|
||||
let savedData = savedString.data(using: .utf8) {
|
||||
log("[NearbyConnectionManager] Using cached endpoint ID: \(savedString)")
|
||||
return [UInt8](savedData)
|
||||
}
|
||||
|
||||
@@ -176,13 +288,17 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
|
||||
// Save to UserDefaults as String
|
||||
let idString = String(bytes: id, encoding: .utf8) ?? ""
|
||||
Settings.shared.endpointID = idString
|
||||
Settings.sharedInstance.endpointID = idString
|
||||
log("[NearbyConnectionManager] Storing new endpoint ID: \(idString) ======================================")
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
|
||||
private func initMDNS() {
|
||||
private func initMDNS(forceIDRegeneration: Bool) {
|
||||
|
||||
self.endpointID = Self.getEndpointID(forceRegeneration: forceIDRegeneration)
|
||||
|
||||
let nameBytes: [UInt8] = [
|
||||
0x23, // PCP
|
||||
endpointID[0], endpointID[1], endpointID[2], endpointID[3],
|
||||
@@ -196,14 +312,29 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
mdnsServices = serviceTypes.map { serviceType in
|
||||
let service = NetService(domain: "", type: serviceType, name: name, port: port)
|
||||
service.delegate = self
|
||||
service.setTXTRecord(NetService.data(fromTXTRecord: [
|
||||
"n": deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!,
|
||||
]))
|
||||
var txtRecord: [String: Data] = [
|
||||
txtKeyEndpointInfo: deviceInfo.serialize().urlSafeBase64EncodedString().data(using: .utf8)!,
|
||||
]
|
||||
#if os(macOS)
|
||||
txtRecord[txtKeyNotificationSyncCapabilities] = Data(notificationSyncCapabilityToken.utf8)
|
||||
if let keyFingerprint = notificationSyncKeyFingerprintHex() {
|
||||
txtRecord[txtKeyNotificationSyncKeyFingerprint] = Data(keyFingerprint.utf8)
|
||||
}
|
||||
#endif
|
||||
service.setTXTRecord(NetService.data(fromTXTRecord: txtRecord))
|
||||
service.publish()
|
||||
return service
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func notificationSyncKeyFingerprintHex() -> String? {
|
||||
guard let keyID = IdentityManager.shared.getPublicKey()?.toGenericPublicKey().id() else {
|
||||
return nil
|
||||
}
|
||||
return keyID.hex.lowercased()
|
||||
}
|
||||
|
||||
|
||||
private func stopMDNS() {
|
||||
// Gracefully stop each published service
|
||||
@@ -228,20 +359,27 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
}
|
||||
|
||||
|
||||
func connectionWasTerminated(connection: InboundNearbyConnection, error: Error?) {
|
||||
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) {
|
||||
incomingConnections.removeValue(forKey: connection.id)
|
||||
|
||||
// If there are no more incoming connections, we can stop accessing the save directory
|
||||
if incomingConnections.isEmpty {
|
||||
self.stopAccessingSaveDirectory()
|
||||
|
||||
// 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 {
|
||||
inboundAppDelegates.forEach { delegate in
|
||||
delegate.connectionWasTerminated(connectionID: connection.id, from: connection.remoteDeviceInfo ?? RemoteDeviceInfo(name: "UnknownDevice".localized(), type: .unknown), wasPlainTextTransfer: connection.isPlainTextTransfer, error: error)
|
||||
delegate.connectionWasTerminated(connectionID: connection.id, from: connection.remoteDeviceInfo, savedFiles: savedFiles, wasPlainTextTransfer: connection.isPlainTextTransfer, error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func notificationSyncSetupConfirmed(connection _: InboundNearbyConnection) {
|
||||
inboundAppDelegates.forEach { delegate in
|
||||
delegate.notificationSyncSetupConfirmed()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func showPlusScreen() {
|
||||
@@ -258,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 }
|
||||
|
||||
log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept)")
|
||||
conn.submitUserConsent(accepted: accept, trustDevice: trustDevice)
|
||||
conn.submitUserConsent(accepted: accept, trustDevice: trustDevice, pairingToken: pairingToken)
|
||||
}
|
||||
|
||||
|
||||
@@ -371,12 +514,18 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
if let transfer = incomingConnections[id] {
|
||||
transfer.cancel()
|
||||
}
|
||||
if let transfer = outgoingTransfers[id] {
|
||||
transfer.connection.cancel()
|
||||
if let info = outgoingTransfers[id] {
|
||||
info.connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public func cancelAllIncomingConnections() {
|
||||
let incomingTransfers = Array(incomingConnections.values)
|
||||
for transfer in incomingTransfers {
|
||||
transfer.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func endpointID(for service: NWBrowser.Result) -> String? {
|
||||
guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _) = service.endpoint else { return nil }
|
||||
guard let nameData = Data.dataFromUrlSafeBase64(serviceName) else { return nil }
|
||||
@@ -401,24 +550,24 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
var foundService = FoundServiceInfo(service: service)
|
||||
|
||||
guard case let NWBrowser.Result.Metadata.bonjour(txtRecord) = service.metadata else { return }
|
||||
guard let endpointInfoEncoded = txtRecord.dictionary["n"] else { return }
|
||||
guard let endpointInfoEncoded = txtRecord.dictionary[txtKeyEndpointInfo] else { return }
|
||||
guard let endpointInfoData = Data.dataFromUrlSafeBase64(endpointInfoEncoded) else { return }
|
||||
guard var endpointInfo = EndpointInfo(data: endpointInfoData) else { return }
|
||||
var deviceInfo: RemoteDeviceInfo?
|
||||
|
||||
if let _ = endpointInfo.name {
|
||||
deviceInfo = addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID)
|
||||
var endpointInfo = EndpointInfo(data: endpointInfoData)
|
||||
?? EndpointInfo(name: unnamedAndroidFallbackDeviceName, deviceType: .unknown)
|
||||
|
||||
let cleanedName = endpointInfo.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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 {
|
||||
|
||||
log("[NearbyConnectionManager] Device has QR data: \(qrData.base64EncodedString()), advertising token is \(qrCodeAdvertisingToken.base64EncodedString())")
|
||||
|
||||
if qrData == qrCodeAdvertisingToken {
|
||||
if let deviceInfo = deviceInfo {
|
||||
for delegate in outboundAppDelegates {
|
||||
delegate.startTransferWithQrCode(device: deviceInfo)
|
||||
}
|
||||
for delegate in outboundAppDelegates {
|
||||
delegate.startTransferWithQrCode(device: deviceInfo)
|
||||
}
|
||||
}
|
||||
else if qrData.count > 28 {
|
||||
@@ -440,10 +589,17 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
|
||||
|
||||
private func addFoundDevice(foundService: inout FoundServiceInfo, endpointInfo: EndpointInfo, endpointID: String) -> RemoteDeviceInfo {
|
||||
let hadPreviousDevice = foundServices[endpointID]?.device != nil
|
||||
let deviceInfo = RemoteDeviceInfo(info: endpointInfo, id: endpointID)
|
||||
foundService.device = deviceInfo
|
||||
foundServices[endpointID] = foundService
|
||||
|
||||
if hadPreviousDevice {
|
||||
for delegate in outboundAppDelegates {
|
||||
delegate.removeDevice(id: endpointID)
|
||||
}
|
||||
}
|
||||
|
||||
for delegate in outboundAppDelegates {
|
||||
delegate.addDevice(device: deviceInfo)
|
||||
}
|
||||
@@ -467,19 +623,18 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
|
||||
let domain = Domain.instance(curve: .EC256r1)
|
||||
let (pubKey, privKey) = domain.makeKeyPair()
|
||||
|
||||
qrCodePrivateKey = privKey
|
||||
|
||||
var keyData = Data()
|
||||
keyData.append(contentsOf: [0, 0, 2])
|
||||
let keyBytes = Data(pubKey.w.x.asSignedBytes())
|
||||
|
||||
// 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))
|
||||
keyData.append(Data(pubKey.w.x.asSignedBytes()).ensureLength(length: 32))
|
||||
|
||||
let ikm = SymmetricKey(data: keyData)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
do {
|
||||
let qrKey = keyData.urlSafeBase64EncodedString()
|
||||
let qrCodeImage = try QRCode.build
|
||||
@@ -516,6 +671,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
let conn = OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: localUrls, textToSend: textToSend)
|
||||
conn.delegate = self
|
||||
conn.qrCodePrivateKey = qrCodePrivateKey
|
||||
|
||||
let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate)
|
||||
outgoingTransfers[deviceID] = transfer
|
||||
conn.start()
|
||||
@@ -611,7 +767,6 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
if accessSuccess {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
log("[NearbyConnectionManager] File URL \(url) does not point to a security scoped resource")
|
||||
@@ -634,7 +789,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
||||
return securityScopeUrl
|
||||
}
|
||||
|
||||
if let bookmarkData = Settings.shared.saveFolderBookmark {
|
||||
if let bookmarkData = Settings.sharedInstance.saveFolderBookmark {
|
||||
var isStale = false
|
||||
|
||||
do {
|
||||
@@ -684,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
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ import UniformTypeIdentifiers
|
||||
import BigInt
|
||||
import SwiftECC
|
||||
import ASN1
|
||||
import BigInt // required dependency of ASN1
|
||||
import BigInt
|
||||
import LUI
|
||||
|
||||
|
||||
class OutboundNearbyConnection: NearbyConnection {
|
||||
@@ -62,6 +63,18 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
override func disconnect() {
|
||||
super.disconnect()
|
||||
|
||||
// delete all files
|
||||
for url in urlsToSend {
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
log("[OutboundNearbyConnection \(self.id)] Deleted file at \(url).")
|
||||
}
|
||||
catch {
|
||||
log("[OutboundNearbyConnection \(self.id)] Failed to delete file at \(url): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
if let error = lastError {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.failedWithError(connection: self, error: error)
|
||||
@@ -134,15 +147,50 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
|
||||
switch currentState {
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
break
|
||||
default:
|
||||
assertionFailure("[OutboundNearbyConnection \(self.id)] Unexpected state \(currentState)")
|
||||
log("[OutboundNearbyConnection \(self.id)] Unexpected state \(currentState)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +210,8 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
frame.v1 = Location_Nearby_Connections_V1Frame()
|
||||
frame.v1.type = .connectionRequest
|
||||
frame.v1.connectionRequest = Location_Nearby_Connections_ConnectionRequestFrame()
|
||||
frame.v1.connectionRequest.endpointID = String(bytes: NearbyConnectionManager.shared.endpointID, encoding: .ascii)!
|
||||
frame.v1.connectionRequest.endpointName = endpointInfo.name ?? "QuickDrop"
|
||||
frame.v1.connectionRequest.endpointID = Data(NearbyConnectionManager.shared.endpointID)
|
||||
frame.v1.connectionRequest.endpointName = Data((endpointInfo.name ?? "QuickDrop").utf8)
|
||||
frame.v1.connectionRequest.endpointInfo = endpointInfo.serialize()
|
||||
frame.v1.connectionRequest.mediums = [.wifiLan]
|
||||
try sendFrameAsync(frame.serializedData())
|
||||
@@ -256,6 +304,12 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
private func processConnectionResponse(frame: Location_Nearby_Connections_OfflineFrame) throws {
|
||||
|
||||
guard frame.version == .v1 else { throw NearbyError.protocolError("Unexpected offline frame version \(frame.version)") }
|
||||
guard frame.hasV1, frame.v1.hasType else { throw NearbyError.requiredFieldMissing("offlineFrame.v1.type") }
|
||||
|
||||
if frame.v1.type == .keepAlive {
|
||||
log("[OutboundNearbyConnection \(self.id)] Ignoring keep-alive while waiting for connection response")
|
||||
return
|
||||
}
|
||||
|
||||
guard frame.v1.type == .connectionResponse else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
|
||||
|
||||
@@ -334,6 +388,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
meta.textTitle = textToSend
|
||||
meta.size = Int64(textToSend.utf8.count)
|
||||
meta.payloadID = textPayloadID
|
||||
meta.id = Int64.random(in: Int64.min ... Int64.max)
|
||||
introduction.v1.introduction.textMetadata.append(meta)
|
||||
|
||||
} else if hasURL() {
|
||||
@@ -342,6 +397,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
meta.textTitle = urlsToSend[0].host ?? "URL"
|
||||
meta.size = Int64(urlsToSend[0].absoluteString.utf8.count)
|
||||
meta.payloadID = textPayloadID
|
||||
meta.id = Int64.random(in: Int64.min ... Int64.max)
|
||||
introduction.v1.introduction.textMetadata.append(meta)
|
||||
} else {
|
||||
for url in urlsToSend {
|
||||
@@ -369,12 +425,15 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
} else {
|
||||
meta.type = .unknown
|
||||
}
|
||||
|
||||
meta.payloadID = Int64.random(in: Int64.min ... Int64.max)
|
||||
try queue.append(OutgoingFileTransfer(url: url, payloadID: meta.payloadID, handle: FileHandle(forReadingFrom: url), totalBytes: meta.size, currentOffset: 0))
|
||||
meta.id = Int64.random(in: Int64.min ... Int64.max)
|
||||
|
||||
try queue.append(OutgoingFileTransfer(url: url, fileName: meta.name, payloadID: meta.payloadID, handle: FileHandle(forReadingFrom: url), totalBytes: meta.size, currentOffset: 0))
|
||||
introduction.v1.introduction.fileMetadata.append(meta)
|
||||
totalBytesToSend += meta.size
|
||||
|
||||
log("[OutboundNearbyConnection \(self.id)] Sending file with \(meta.size) bytes")
|
||||
log("[OutboundNearbyConnection \(self.id)] Sending file with \(meta.size) bytes and mime type \(meta.mimeType) and type \(meta.type) and name \(meta.name)")
|
||||
}
|
||||
}
|
||||
try sendTransferSetupFrame(introduction)
|
||||
@@ -441,8 +500,18 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
}
|
||||
if queue.isEmpty {
|
||||
log("[OutboundNearbyConnection \(self.id)] Disconnecting because all files have been transferred")
|
||||
try sendDisconnectionAndDisconnect()
|
||||
delegate?.transferFinished(connection: self)
|
||||
|
||||
// Delay disconnection to ensure EOF frame is processed at peer
|
||||
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + 0.5) {
|
||||
do {
|
||||
try self.sendDisconnectionAndDisconnect()
|
||||
self.delegate?.transferFinished(connection: self)
|
||||
}
|
||||
catch {
|
||||
log("[OutboundNearbyConnection \(self.id)] Error while sending disconnection: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
currentTransfer = queue.removeFirst()
|
||||
@@ -463,6 +532,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
transfer.payloadHeader.type = .file
|
||||
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
|
||||
transfer.payloadHeader.isSensitive = false
|
||||
transfer.payloadHeader.fileName = currentTransfer!.fileName
|
||||
currentTransfer!.currentOffset += Int64(fileBuffer.count)
|
||||
|
||||
var wrapper = Location_Nearby_Connections_OfflineFrame()
|
||||
@@ -485,11 +555,12 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
delegate?.updatedTransferProgress(connection: self, progress: Double(bytesTransferred) / Double(totalBytesToSend))
|
||||
|
||||
if currentTransfer!.currentOffset == currentTransfer!.totalBytes {
|
||||
// Signal end of file (yes, all this for one bit)
|
||||
|
||||
// Signal end of file
|
||||
var transfer = Location_Nearby_Connections_PayloadTransferFrame()
|
||||
transfer.packetType = .data
|
||||
transfer.payloadChunk.offset = currentTransfer!.currentOffset
|
||||
transfer.payloadChunk.flags = 1 // <- this one here
|
||||
transfer.payloadChunk.flags = 1
|
||||
transfer.payloadHeader.id = currentTransfer!.payloadID
|
||||
transfer.payloadHeader.type = .file
|
||||
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
|
||||
@@ -515,6 +586,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
||||
|
||||
private struct OutgoingFileTransfer {
|
||||
let url: URL
|
||||
let fileName: String
|
||||
let payloadID: Int64
|
||||
let handle: FileHandle?
|
||||
let totalBytes: Int64
|
||||
|
||||
@@ -6,17 +6,20 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import Network
|
||||
import CryptoKit
|
||||
import SwiftECC
|
||||
import LUI
|
||||
|
||||
|
||||
protocol InboundNearbyConnectionDelegate {
|
||||
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
|
||||
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
|
||||
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double)
|
||||
func connectionWasTerminated(connection: InboundNearbyConnection, error: Error?)
|
||||
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?)
|
||||
func showPlusScreen()
|
||||
func notificationSyncSetupConfirmed(connection: InboundNearbyConnection)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,9 +35,14 @@ protocol OutboundNearbyConnectionDelegate {
|
||||
public protocol InboundAppDelegate: AnyObject {
|
||||
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo)
|
||||
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo)
|
||||
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo, wasPlainTextTransfer: Bool, error: Error?)
|
||||
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: Error?)
|
||||
func transferProgress(connectionID: String, progress: Double)
|
||||
func showPlusScreen()
|
||||
func notificationSyncSetupConfirmed()
|
||||
}
|
||||
|
||||
extension InboundAppDelegate {
|
||||
func notificationSyncSetupConfirmed() {}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +64,9 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable {
|
||||
public let qrCodeData: Data?
|
||||
public var id: String?
|
||||
|
||||
init(name: String, type: DeviceType, id: String? = nil) {
|
||||
init(name: String?, type: DeviceType?, id: String? = nil) {
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.type = type ?? .phone
|
||||
self.id = id
|
||||
self.qrCodeData = nil
|
||||
}
|
||||
@@ -136,31 +144,71 @@ public struct TransferMetadata {
|
||||
case file
|
||||
case text
|
||||
case url
|
||||
case wifiPassword
|
||||
case notificationSync
|
||||
}
|
||||
|
||||
private func getSummary() -> String {
|
||||
if let textTitle = textDescription {
|
||||
return textTitle
|
||||
} else if files.count == 1 {
|
||||
return files[0].name
|
||||
return fileTypeSummary(for: files[0])
|
||||
} else {
|
||||
return String.localizedStringWithFormat("NFiles".localized(), files.count)
|
||||
}
|
||||
}
|
||||
|
||||
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, alreadyAccepted: Bool) -> String {
|
||||
public func getDescription(deviceName: String) -> String {
|
||||
|
||||
let fileStr = getSummary()
|
||||
|
||||
switch type {
|
||||
case .file:
|
||||
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [deviceName, fileStr])
|
||||
return "DeviceSendingFiles".localized(with: fileStr, deviceName)
|
||||
|
||||
case .text:
|
||||
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [deviceName, fileStr])
|
||||
return "DeviceSendingText".localized(with: deviceName)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,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 let name: String
|
||||
@@ -181,17 +254,21 @@ public struct EndpointInfo {
|
||||
var name: String?
|
||||
let deviceType: RemoteDeviceInfo.DeviceType
|
||||
let qrCodeData: Data?
|
||||
|
||||
|
||||
static let maxNameChars = 32
|
||||
|
||||
init(name: String, deviceType: RemoteDeviceInfo.DeviceType) {
|
||||
self.name = name
|
||||
self.name = String(decoding: Self.sanitizedString(name), as: UTF8.self)
|
||||
self.deviceType = deviceType
|
||||
self.qrCodeData = nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
init?(data: Data) {
|
||||
guard data.count > 17 else { return nil }
|
||||
guard data.count > 17 else {
|
||||
log("[EndpointInfo] Invalid endpoint info data received (too short)")
|
||||
return nil
|
||||
}
|
||||
|
||||
let hasName = (data[0] & 0x10) == 0
|
||||
let deviceNameLength: Int
|
||||
@@ -199,8 +276,14 @@ public struct EndpointInfo {
|
||||
|
||||
if hasName {
|
||||
deviceNameLength = Int(data[17])
|
||||
guard data.count >= deviceNameLength + 18 else { return nil }
|
||||
guard let newDeviceName = String(data: data[18..<(18 + deviceNameLength)], encoding: .utf8) else { return nil }
|
||||
guard data.count >= deviceNameLength + 18 else {
|
||||
log("[EndpointInfo] Invalid endpoint info data received (name length mismatch)")
|
||||
return nil
|
||||
}
|
||||
guard let newDeviceName = String(data: data[18..<(18 + deviceNameLength)], encoding: .utf8) else {
|
||||
log("[EndpointInfo] Invalid endpoint info data received (name decoding failed)")
|
||||
return nil
|
||||
}
|
||||
deviceName = newDeviceName
|
||||
}
|
||||
else {
|
||||
@@ -244,19 +327,30 @@ public struct EndpointInfo {
|
||||
var endpointInfo: [UInt8] = [UInt8(deviceType.rawValue << 1)]
|
||||
// 16 bytes: unknown random bytes
|
||||
for _ in 0 ... 15 {
|
||||
endpointInfo.append(UInt8.random(in: 0 ... 255))
|
||||
endpointInfo.append(UInt8.random(in: 0...255))
|
||||
}
|
||||
// Device name in UTF-8 prefixed with 1-byte length
|
||||
var nameChars = [UInt8]((name ?? "").utf8)
|
||||
if nameChars.count > 255 {
|
||||
nameChars = [UInt8](nameChars[0 ..< 255])
|
||||
}
|
||||
let nameChars = Self.sanitizedString(self.name ?? "")
|
||||
endpointInfo.append(UInt8(nameChars.count))
|
||||
for ch in nameChars {
|
||||
endpointInfo.append(UInt8(ch))
|
||||
}
|
||||
return Data(endpointInfo)
|
||||
}
|
||||
|
||||
|
||||
private static func sanitizedString(_ string: String) -> [UInt8] {
|
||||
var out: [UInt8] = []
|
||||
out.reserveCapacity(min(Self.maxNameChars, string.utf8.count))
|
||||
|
||||
for scalar in string.unicodeScalars {
|
||||
let bytes = Array(scalar.utf8)
|
||||
if out.count + bytes.count > Self.maxNameChars { break }
|
||||
out.append(contentsOf: bytes)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +361,7 @@ public enum NearbyError: Error {
|
||||
case packetFilterError
|
||||
case firewallError
|
||||
case inputOutput
|
||||
case notificationSyncNotTrusted
|
||||
case canceled(reason: CancellationReason)
|
||||
|
||||
public enum CancellationReason {
|
||||
|
||||
@@ -245,8 +245,8 @@ struct Location_Nearby_Connections_ConnectionRequestFrame: @unchecked Sendable {
|
||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||
// methods supported on all messages.
|
||||
|
||||
var endpointID: String {
|
||||
get {return _storage._endpointID ?? String()}
|
||||
var endpointID: Data {
|
||||
get {return _storage._endpointID ?? Data()}
|
||||
set {_uniqueStorage()._endpointID = newValue}
|
||||
}
|
||||
/// Returns true if `endpointID` has been explicitly set.
|
||||
@@ -254,8 +254,8 @@ struct Location_Nearby_Connections_ConnectionRequestFrame: @unchecked Sendable {
|
||||
/// Clears the value of `endpointID`. Subsequent reads from it will return its default value.
|
||||
mutating func clearEndpointID() {_uniqueStorage()._endpointID = nil}
|
||||
|
||||
var endpointName: String {
|
||||
get {return _storage._endpointName ?? String()}
|
||||
var endpointName: Data {
|
||||
get {return _storage._endpointName ?? Data()}
|
||||
set {_uniqueStorage()._endpointName = newValue}
|
||||
}
|
||||
/// Returns true if `endpointName` has been explicitly set.
|
||||
@@ -2550,8 +2550,8 @@ extension Location_Nearby_Connections_ConnectionRequestFrame: SwiftProtobuf.Mess
|
||||
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}endpoint_id\0\u{3}endpoint_name\0\u{3}handshake_data\0\u{1}nonce\0\u{1}mediums\0\u{3}endpoint_info\0\u{3}medium_metadata\0\u{3}keep_alive_interval_millis\0\u{3}keep_alive_timeout_millis\0\u{3}device_type\0\u{3}device_info\0\u{3}connections_device\0\u{3}presence_device\0\u{3}connection_mode\0\u{3}location_hint\0")
|
||||
|
||||
fileprivate class _StorageClass {
|
||||
var _endpointID: String? = nil
|
||||
var _endpointName: String? = nil
|
||||
var _endpointID: Data? = nil
|
||||
var _endpointName: Data? = nil
|
||||
var _handshakeData: Data? = nil
|
||||
var _nonce: Int32? = nil
|
||||
var _mediums: [Location_Nearby_Connections_ConnectionRequestFrame.Medium] = []
|
||||
@@ -2606,8 +2606,8 @@ extension Location_Nearby_Connections_ConnectionRequestFrame: SwiftProtobuf.Mess
|
||||
// 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.decodeSingularStringField(value: &_storage._endpointID) }()
|
||||
case 2: try { try decoder.decodeSingularStringField(value: &_storage._endpointName) }()
|
||||
case 1: try { try decoder.decodeSingularBytesField(value: &_storage._endpointID) }()
|
||||
case 2: try { try decoder.decodeSingularBytesField(value: &_storage._endpointName) }()
|
||||
case 3: try { try decoder.decodeSingularBytesField(value: &_storage._handshakeData) }()
|
||||
case 4: try { try decoder.decodeSingularInt32Field(value: &_storage._nonce) }()
|
||||
case 5: try { try decoder.decodeRepeatedEnumField(value: &_storage._mediums) }()
|
||||
@@ -2658,10 +2658,10 @@ extension Location_Nearby_Connections_ConnectionRequestFrame: SwiftProtobuf.Mess
|
||||
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
|
||||
// https://github.com/apple/swift-protobuf/issues/1182
|
||||
try { if let v = _storage._endpointID {
|
||||
try visitor.visitSingularStringField(value: v, fieldNumber: 1)
|
||||
try visitor.visitSingularBytesField(value: v, fieldNumber: 1)
|
||||
} }()
|
||||
try { if let v = _storage._endpointName {
|
||||
try visitor.visitSingularStringField(value: v, fieldNumber: 2)
|
||||
try visitor.visitSingularBytesField(value: v, fieldNumber: 2)
|
||||
} }()
|
||||
try { if let v = _storage._handshakeData {
|
||||
try visitor.visitSingularBytesField(value: v, fieldNumber: 3)
|
||||
|
||||
@@ -334,6 +334,151 @@ struct Sharing_Nearby_WifiCredentialsMetadata: Sendable {
|
||||
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
|
||||
struct Sharing_Nearby_AppMetadata: Sendable {
|
||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||
@@ -505,76 +650,76 @@ struct Sharing_Nearby_Frame: Sendable {
|
||||
}
|
||||
|
||||
/// 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
|
||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||
// methods supported on all messages.
|
||||
|
||||
var type: Sharing_Nearby_V1Frame.FrameType {
|
||||
get {return _storage._type ?? .unknownFrameType}
|
||||
set {_uniqueStorage()._type = newValue}
|
||||
get {return _type ?? .unknownFrameType}
|
||||
set {_type = newValue}
|
||||
}
|
||||
/// 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.
|
||||
mutating func clearType() {_uniqueStorage()._type = nil}
|
||||
mutating func clearType() {self._type = nil}
|
||||
|
||||
/// At most one of the following fields will be set.
|
||||
var introduction: Sharing_Nearby_IntroductionFrame {
|
||||
get {return _storage._introduction ?? Sharing_Nearby_IntroductionFrame()}
|
||||
set {_uniqueStorage()._introduction = newValue}
|
||||
get {return _introduction ?? Sharing_Nearby_IntroductionFrame()}
|
||||
set {_introduction = newValue}
|
||||
}
|
||||
/// 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.
|
||||
mutating func clearIntroduction() {_uniqueStorage()._introduction = nil}
|
||||
mutating func clearIntroduction() {self._introduction = nil}
|
||||
|
||||
var connectionResponse: Sharing_Nearby_ConnectionResponseFrame {
|
||||
get {return _storage._connectionResponse ?? Sharing_Nearby_ConnectionResponseFrame()}
|
||||
set {_uniqueStorage()._connectionResponse = newValue}
|
||||
get {return _connectionResponse ?? Sharing_Nearby_ConnectionResponseFrame()}
|
||||
set {_connectionResponse = newValue}
|
||||
}
|
||||
/// 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.
|
||||
mutating func clearConnectionResponse() {_uniqueStorage()._connectionResponse = nil}
|
||||
mutating func clearConnectionResponse() {self._connectionResponse = nil}
|
||||
|
||||
var pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame {
|
||||
get {return _storage._pairedKeyEncryption ?? Sharing_Nearby_PairedKeyEncryptionFrame()}
|
||||
set {_uniqueStorage()._pairedKeyEncryption = newValue}
|
||||
get {return _pairedKeyEncryption ?? Sharing_Nearby_PairedKeyEncryptionFrame()}
|
||||
set {_pairedKeyEncryption = newValue}
|
||||
}
|
||||
/// 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.
|
||||
mutating func clearPairedKeyEncryption() {_uniqueStorage()._pairedKeyEncryption = nil}
|
||||
mutating func clearPairedKeyEncryption() {self._pairedKeyEncryption = nil}
|
||||
|
||||
var pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame {
|
||||
get {return _storage._pairedKeyResult ?? Sharing_Nearby_PairedKeyResultFrame()}
|
||||
set {_uniqueStorage()._pairedKeyResult = newValue}
|
||||
get {return _pairedKeyResult ?? Sharing_Nearby_PairedKeyResultFrame()}
|
||||
set {_pairedKeyResult = newValue}
|
||||
}
|
||||
/// 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.
|
||||
mutating func clearPairedKeyResult() {_uniqueStorage()._pairedKeyResult = nil}
|
||||
mutating func clearPairedKeyResult() {self._pairedKeyResult = nil}
|
||||
|
||||
/// NOTE: This field was marked as deprecated in the .proto file.
|
||||
var certificateInfo: Sharing_Nearby_CertificateInfoFrame {
|
||||
get {return _storage._certificateInfo ?? Sharing_Nearby_CertificateInfoFrame()}
|
||||
set {_uniqueStorage()._certificateInfo = newValue}
|
||||
get {return _certificateInfo ?? Sharing_Nearby_CertificateInfoFrame()}
|
||||
set {_certificateInfo = newValue}
|
||||
}
|
||||
/// 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.
|
||||
mutating func clearCertificateInfo() {_uniqueStorage()._certificateInfo = nil}
|
||||
mutating func clearCertificateInfo() {self._certificateInfo = nil}
|
||||
|
||||
/// NOTE: This field was marked as deprecated in the .proto file.
|
||||
var progressUpdate: Sharing_Nearby_ProgressUpdateFrame {
|
||||
get {return _storage._progressUpdate ?? Sharing_Nearby_ProgressUpdateFrame()}
|
||||
set {_uniqueStorage()._progressUpdate = newValue}
|
||||
get {return _progressUpdate ?? Sharing_Nearby_ProgressUpdateFrame()}
|
||||
set {_progressUpdate = newValue}
|
||||
}
|
||||
/// 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.
|
||||
mutating func clearProgressUpdate() {_uniqueStorage()._progressUpdate = nil}
|
||||
mutating func clearProgressUpdate() {self._progressUpdate = nil}
|
||||
|
||||
var unknownFields = SwiftProtobuf.UnknownStorage()
|
||||
|
||||
@@ -600,56 +745,98 @@ struct Sharing_Nearby_V1Frame: @unchecked Sendable {
|
||||
|
||||
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
|
||||
/// they'd like to share.
|
||||
/// NEXT_ID=10
|
||||
struct Sharing_Nearby_IntroductionFrame: Sendable {
|
||||
/// NEXT_ID=1003
|
||||
struct Sharing_Nearby_IntroductionFrame: @unchecked 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 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.
|
||||
var requiredPackage: String {
|
||||
get {return _requiredPackage ?? String()}
|
||||
set {_requiredPackage = newValue}
|
||||
get {return _storage._requiredPackage ?? String()}
|
||||
set {_uniqueStorage()._requiredPackage = newValue}
|
||||
}
|
||||
/// 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.
|
||||
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 {
|
||||
get {return _startTransfer ?? false}
|
||||
set {_startTransfer = newValue}
|
||||
get {return _storage._startTransfer ?? false}
|
||||
set {_uniqueStorage()._startTransfer = newValue}
|
||||
}
|
||||
/// 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.
|
||||
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 {
|
||||
get {return _useCase ?? .unknown}
|
||||
set {_useCase = newValue}
|
||||
get {return _storage._useCase ?? .unknown}
|
||||
set {_uniqueStorage()._useCase = newValue}
|
||||
}
|
||||
/// 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.
|
||||
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()
|
||||
|
||||
@@ -666,9 +853,7 @@ struct Sharing_Nearby_IntroductionFrame: Sendable {
|
||||
|
||||
init() {}
|
||||
|
||||
fileprivate var _requiredPackage: String? = nil
|
||||
fileprivate var _startTransfer: Bool? = nil
|
||||
fileprivate var _useCase: Sharing_Nearby_IntroductionFrame.SharingUseCase? = nil
|
||||
fileprivate var _storage = _StorageClass.defaultInstance
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
@@ -1531,14 +1833,86 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
|
||||
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")
|
||||
|
||||
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 {
|
||||
var _type: Sharing_Nearby_V1Frame.FrameType? = nil
|
||||
var _introduction: Sharing_Nearby_IntroductionFrame? = nil
|
||||
var _connectionResponse: Sharing_Nearby_ConnectionResponseFrame? = nil
|
||||
var _pairedKeyEncryption: Sharing_Nearby_PairedKeyEncryptionFrame? = nil
|
||||
var _pairedKeyResult: Sharing_Nearby_PairedKeyResultFrame? = nil
|
||||
var _certificateInfo: Sharing_Nearby_CertificateInfoFrame? = nil
|
||||
var _progressUpdate: Sharing_Nearby_ProgressUpdateFrame? = nil
|
||||
var _fileMetadata: [Sharing_Nearby_FileMetadata] = []
|
||||
var _textMetadata: [Sharing_Nearby_TextMetadata] = []
|
||||
var _requiredPackage: String? = nil
|
||||
var _wifiCredentialsMetadata: [Sharing_Nearby_WifiCredentialsMetadata] = []
|
||||
var _appMetadata: [Sharing_Nearby_AppMetadata] = []
|
||||
var _startTransfer: Bool? = 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.
|
||||
// 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() {}
|
||||
|
||||
init(copying source: _StorageClass) {
|
||||
_type = source._type
|
||||
_introduction = source._introduction
|
||||
_connectionResponse = source._connectionResponse
|
||||
_pairedKeyEncryption = source._pairedKeyEncryption
|
||||
_pairedKeyResult = source._pairedKeyResult
|
||||
_certificateInfo = source._certificateInfo
|
||||
_progressUpdate = source._progressUpdate
|
||||
_fileMetadata = source._fileMetadata
|
||||
_textMetadata = source._textMetadata
|
||||
_requiredPackage = source._requiredPackage
|
||||
_wifiCredentialsMetadata = source._wifiCredentialsMetadata
|
||||
_appMetadata = source._appMetadata
|
||||
_startTransfer = source._startTransfer
|
||||
_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
|
||||
// enabled. https://github.com/apple/swift-protobuf/issues/1034
|
||||
switch fieldNumber {
|
||||
case 1: try { try decoder.decodeSingularEnumField(value: &_storage._type) }()
|
||||
case 2: try { try decoder.decodeSingularMessageField(value: &_storage._introduction) }()
|
||||
case 3: try { try decoder.decodeSingularMessageField(value: &_storage._connectionResponse) }()
|
||||
case 4: try { try decoder.decodeSingularMessageField(value: &_storage._pairedKeyEncryption) }()
|
||||
case 5: try { try decoder.decodeSingularMessageField(value: &_storage._pairedKeyResult) }()
|
||||
case 6: try { try decoder.decodeSingularMessageField(value: &_storage._certificateInfo) }()
|
||||
case 7: try { try decoder.decodeSingularMessageField(value: &_storage._progressUpdate) }()
|
||||
case 1: try { try decoder.decodeRepeatedMessageField(value: &_storage._fileMetadata) }()
|
||||
case 2: try { try decoder.decodeRepeatedMessageField(value: &_storage._textMetadata) }()
|
||||
case 3: try { try decoder.decodeSingularStringField(value: &_storage._requiredPackage) }()
|
||||
case 4: try { try decoder.decodeRepeatedMessageField(value: &_storage._wifiCredentialsMetadata) }()
|
||||
case 5: try { try decoder.decodeRepeatedMessageField(value: &_storage._appMetadata) }()
|
||||
case 6: try { try decoder.decodeSingularBoolField(value: &_storage._startTransfer) }()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1593,125 +1975,63 @@ extension Sharing_Nearby_V1Frame: SwiftProtobuf.Message, SwiftProtobuf._MessageI
|
||||
// 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 = _storage._type {
|
||||
try visitor.visitSingularEnumField(value: v, 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 !_storage._fileMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: _storage._fileMetadata, fieldNumber: 1)
|
||||
}
|
||||
if !storagesAreEqual {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")
|
||||
|
||||
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
|
||||
if !_storage._textMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: _storage._textMetadata, fieldNumber: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if !self.fileMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: self.fileMetadata, fieldNumber: 1)
|
||||
}
|
||||
if !self.textMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: self.textMetadata, fieldNumber: 2)
|
||||
}
|
||||
try { if let v = self._requiredPackage {
|
||||
try visitor.visitSingularStringField(value: v, fieldNumber: 3)
|
||||
} }()
|
||||
if !self.wifiCredentialsMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: self.wifiCredentialsMetadata, fieldNumber: 4)
|
||||
}
|
||||
if !self.appMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: self.appMetadata, fieldNumber: 5)
|
||||
}
|
||||
try { if let v = self._startTransfer {
|
||||
try visitor.visitSingularBoolField(value: v, fieldNumber: 6)
|
||||
} }()
|
||||
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 { if let v = _storage._requiredPackage {
|
||||
try visitor.visitSingularStringField(value: v, fieldNumber: 3)
|
||||
} }()
|
||||
if !_storage._wifiCredentialsMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: _storage._wifiCredentialsMetadata, fieldNumber: 4)
|
||||
}
|
||||
if !_storage._appMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: _storage._appMetadata, fieldNumber: 5)
|
||||
}
|
||||
try { if let v = _storage._startTransfer {
|
||||
try visitor.visitSingularBoolField(value: v, fieldNumber: 6)
|
||||
} }()
|
||||
if !_storage._streamMetadata.isEmpty {
|
||||
try visitor.visitRepeatedMessageField(value: _storage._streamMetadata, fieldNumber: 7)
|
||||
}
|
||||
try { if let v = _storage._useCase {
|
||||
try visitor.visitSingularEnumField(value: v, fieldNumber: 8)
|
||||
} }()
|
||||
if !_storage._previewPayloadIds.isEmpty {
|
||||
try visitor.visitRepeatedInt64Field(value: _storage._previewPayloadIds, fieldNumber: 9)
|
||||
}
|
||||
try { if let v = _storage._mirroredNotificationMetadata {
|
||||
try visitor.visitSingularMessageField(value: v, fieldNumber: 1001)
|
||||
} }()
|
||||
try { if let v = _storage._pairingMetadata {
|
||||
try visitor.visitSingularMessageField(value: v, fieldNumber: 1002)
|
||||
} }()
|
||||
}
|
||||
try unknownFields.traverse(visitor: &visitor)
|
||||
}
|
||||
|
||||
static func ==(lhs: Sharing_Nearby_IntroductionFrame, rhs: Sharing_Nearby_IntroductionFrame) -> Bool {
|
||||
if lhs.fileMetadata != rhs.fileMetadata {return false}
|
||||
if lhs.textMetadata != rhs.textMetadata {return false}
|
||||
if lhs._requiredPackage != rhs._requiredPackage {return false}
|
||||
if lhs.wifiCredentialsMetadata != rhs.wifiCredentialsMetadata {return false}
|
||||
if lhs.appMetadata != rhs.appMetadata {return false}
|
||||
if lhs._startTransfer != rhs._startTransfer {return false}
|
||||
if lhs.streamMetadata != rhs.streamMetadata {return false}
|
||||
if lhs._useCase != rhs._useCase {return false}
|
||||
if lhs.previewPayloadIds != rhs.previewPayloadIds {return false}
|
||||
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._fileMetadata != rhs_storage._fileMetadata {return false}
|
||||
if _storage._textMetadata != rhs_storage._textMetadata {return false}
|
||||
if _storage._requiredPackage != rhs_storage._requiredPackage {return false}
|
||||
if _storage._wifiCredentialsMetadata != rhs_storage._wifiCredentialsMetadata {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}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ message ConnectionRequestFrame {
|
||||
}
|
||||
// LINT.ThenChange(//depot/google3/third_party/nearby/proto/connections_enums.proto)
|
||||
|
||||
optional string endpoint_id = 1;
|
||||
optional string endpoint_name = 2;
|
||||
optional bytes endpoint_id = 1;
|
||||
optional bytes endpoint_name = 2;
|
||||
optional bytes handshake_data = 3;
|
||||
// A random number generated for each outgoing connection that is presently
|
||||
// used to act as a tiebreaker when 2 devices connect to each other
|
||||
|
||||
@@ -127,6 +127,32 @@ message WifiCredentialsMetadata {
|
||||
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
|
||||
message AppMetadata {
|
||||
// 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
|
||||
// they'd like to share.
|
||||
// NEXT_ID=10
|
||||
// NEXT_ID=1003
|
||||
message IntroductionFrame {
|
||||
enum SharingUseCase {
|
||||
UNKNOWN = 0;
|
||||
@@ -230,6 +256,8 @@ message IntroductionFrame {
|
||||
repeated StreamMetadata stream_metadata = 7;
|
||||
optional SharingUseCase use_case = 8;
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Contains information about the app group used to share data between main app and extensions
|
||||
struct AppGroup {
|
||||
@@ -19,3 +20,90 @@ struct AppGroup {
|
||||
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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// EXIFUtils.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 10.11.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Set the "creation date"
|
||||
try? FileManager.default.setAttributes([.creationDate: exifDate], ofItemAtPath: fileURL.path)
|
||||
}
|
||||
|
||||
|
||||
/// Returns the EXIF original date of an image file, adjusted to the current time zone.
|
||||
private 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 formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
|
||||
formatter.timeZone = TimeZone.current // Use the current device time zone
|
||||
return formatter.date(from: dateString)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
// Created by Leon Böttger on 04.04.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import LUI
|
||||
|
||||
extension Data {
|
||||
func urlSafeBase64EncodedString() -> String {
|
||||
@@ -30,17 +31,6 @@ extension 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? {
|
||||
var regularB64 = String(str.map {
|
||||
if $0 == "_" {
|
||||
@@ -111,11 +101,14 @@ extension String {
|
||||
|
||||
|
||||
public func fullVersion() -> Bool {
|
||||
// Enable full functionality if app distributed directly
|
||||
return Settings.shared.gotPlus || DistributionDetector.isDirectDistributionEnabled
|
||||
#if GITHUB
|
||||
return true
|
||||
#else
|
||||
return IAPManager.sharedInstance.plusVersionState
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
public func isFileTransferRestricted() -> Bool {
|
||||
(!fullVersion() && Settings.shared.incomingTransmissionCount > 1)
|
||||
(!fullVersion() && Settings.sharedInstance.incomingTransmissionCount > 1)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import Foundation
|
||||
import SwiftECC
|
||||
import Security
|
||||
import CryptoKit
|
||||
import LUI
|
||||
|
||||
class IdentityManager {
|
||||
|
||||
@@ -50,17 +51,21 @@ class IdentityManager {
|
||||
private func generateAndStoreKeyPair() -> (ECPrivateKey, ECPublicKey) {
|
||||
let domain = Domain.instance(curve: .EC256r1)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
private func loadKeys() -> (ECPrivateKey, ECPublicKey)? {
|
||||
let keyData = AppGroup.appGroupUD.data(forKey: signingKeyTag)
|
||||
|
||||
guard let keyData = keyData else { return nil }
|
||||
guard let keyData = loadStoredKeyData() else { return nil }
|
||||
|
||||
let domain = Domain.instance(curve: .EC256r1)
|
||||
guard let privateKey = try? ECPrivateKey(domain: domain, s: .init(signed: Array(keyData))) else {
|
||||
@@ -79,6 +84,32 @@ class IdentityManager {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
//
|
||||
// Localized.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 10.03.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension String {
|
||||
func localized() -> String {
|
||||
let localizedString = NSLocalizedString(self, comment: "")
|
||||
return localizedString
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
//
|
||||
// Logger.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 26.02.25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
fileprivate let loggerQueue = DispatchQueue(label: "loggerQueue", qos: .utility)
|
||||
|
||||
/// Manages logging functionality
|
||||
final class LogManager {
|
||||
|
||||
/// The URL of the log file
|
||||
let logFileURL: URL?
|
||||
|
||||
/// Shared instance
|
||||
public static let sharedInstance = LogManager()
|
||||
|
||||
@Published var loggingEnabled = true
|
||||
|
||||
|
||||
/// Private initializer
|
||||
private init() {
|
||||
// Use the App Group container
|
||||
let directory = AppGroup.appGroupDirectory
|
||||
|
||||
// Create file name from date
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "dd-MM-yyyy"
|
||||
let dateString = formatter.string(from: Date())
|
||||
let fileName = "\(dateString).log"
|
||||
|
||||
// Full path in app group container
|
||||
self.logFileURL = directory?.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
|
||||
/// Writes a specified string to the log file.
|
||||
func writeToFile(string: String) {
|
||||
guard let logFileURL = logFileURL else { return }
|
||||
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
let timestamp = formatter.string(from: Date())
|
||||
guard let data = (timestamp + " - " + string + "\n").data(using: .utf8) else { return }
|
||||
|
||||
loggerQueue.async {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
if !fileManager.fileExists(atPath: logFileURL.path) {
|
||||
// Clean up older logs
|
||||
self.deleteLogs()
|
||||
// Create file atomically
|
||||
try? data.write(to: logFileURL, options: .atomicWrite)
|
||||
return
|
||||
}
|
||||
|
||||
// Atomic append with file locking using FileHandle
|
||||
do {
|
||||
let fileHandle = try FileHandle(forWritingTo: logFileURL)
|
||||
defer { try? fileHandle.close() }
|
||||
|
||||
// Locking is handled by the serial dispatch queue
|
||||
try fileHandle.seekToEnd()
|
||||
try fileHandle.write(contentsOf: data)
|
||||
} catch {
|
||||
print("[Logger] Failed to write to log file: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Gets the contents of the log file.
|
||||
func getLogString() -> String {
|
||||
guard let logFileURL = logFileURL else { return "" }
|
||||
do {
|
||||
return try String(contentsOf: logFileURL, encoding: .utf8)
|
||||
} catch {
|
||||
log("[LUI] Error reading file: \(error.localizedDescription)")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Deletes all log files in the shared container.
|
||||
func deleteLogs() {
|
||||
log("[LUI] Deleting all logs...")
|
||||
|
||||
guard let directory = AppGroup.appGroupDirectory else { return }
|
||||
let fileManager = FileManager.default
|
||||
|
||||
guard let allFiles = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else {
|
||||
return
|
||||
}
|
||||
|
||||
let logFiles = allFiles.filter { $0.pathExtension == "log" }
|
||||
for file in logFiles {
|
||||
do {
|
||||
log("[LUI] Removing \(file.lastPathComponent)")
|
||||
try fileManager.removeItem(at: file)
|
||||
} catch {
|
||||
print("[Logger] Failed to delete file: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Logs a specified string
|
||||
public func log(_ text: String) {
|
||||
let logger = LogManager.sharedInstance
|
||||
|
||||
if logger.loggingEnabled {
|
||||
loggerQueue.async {
|
||||
print(text)
|
||||
#if !os(tvOS)
|
||||
let isAppExtension = Bundle.main.bundlePath.hasSuffix(".appex")
|
||||
let prefix = isAppExtension ? "[EXT]" : "[APP]"
|
||||
logger.writeToFile(string: "\(prefix) \(text)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import IOKit.pwr_mgt
|
||||
#elseif os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
import LUI
|
||||
|
||||
class SleepManager {
|
||||
public static let shared = SleepManager()
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
// Created by Leon Böttger on 22.08.25.
|
||||
//
|
||||
|
||||
|
||||
import Foundation
|
||||
|
||||
import CryptoKit
|
||||
|
||||
class TrustStore: ObservableObject {
|
||||
|
||||
@@ -16,6 +15,9 @@ class TrustStore: ObservableObject {
|
||||
@Published private(set) var trustedCertificates: [String: TrustedCertificate] = [:]
|
||||
|
||||
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() {
|
||||
loadTrustedCertificates()
|
||||
@@ -25,29 +27,40 @@ class TrustStore: ObservableObject {
|
||||
// MARK: - Persistence
|
||||
|
||||
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 = [:]
|
||||
return
|
||||
}
|
||||
|
||||
var decoded: [String: TrustedCertificate] = [:]
|
||||
for (key, data) in dict {
|
||||
if let cert = try? JSONDecoder().decode(TrustedCertificate.self, from: data) {
|
||||
decoded[key] = cert
|
||||
}
|
||||
trustedCertificates = dict
|
||||
|
||||
do {
|
||||
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() {
|
||||
var dict: [String: Data] = [:]
|
||||
for (key, cert) in trustedCertificates {
|
||||
if let data = try? JSONEncoder().encode(cert) {
|
||||
dict[key] = data
|
||||
}
|
||||
do {
|
||||
try persistTrustedCertificates()
|
||||
AppGroup.appGroupUD.removeObject(forKey: trustedKeysKey)
|
||||
} catch {
|
||||
print("[TrustStore] Failed to save trusted certificates to keychain: \(error.localizedDescription)")
|
||||
}
|
||||
AppGroup.appGroupUD.set(dict, forKey: trustedKeysKey)
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +88,52 @@ class TrustStore: ObservableObject {
|
||||
trustedCertificates.removeValue(forKey: secretIdHex)
|
||||
saveTrustedCertificates()
|
||||
}
|
||||
|
||||
|
||||
// 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 {
|
||||
@@ -84,4 +143,37 @@ class TrustStore: ObservableObject {
|
||||
let creationDate: Date
|
||||
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)
|
||||
}
|
||||
}
|
||||
+25
-45
@@ -6,63 +6,43 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ZIPFoundation
|
||||
|
||||
class Zip {
|
||||
|
||||
static func createAtTemporaryDirectory(zipFilename: String, zipExtension: String = "zip", fromDirectory directoryURL: URL) throws -> URL {
|
||||
|
||||
let finalURL = FileManager.default.temporaryDirectory
|
||||
final class Zip {
|
||||
|
||||
static func createAtTemporaryDirectory(
|
||||
zipFilename: String,
|
||||
zipExtension: String = "zip",
|
||||
fromDirectory directoryURL: URL
|
||||
) throws -> URL {
|
||||
|
||||
guard directoryURL.isDirectory else { throw CreateZipError.urlNotADirectory(directoryURL) }
|
||||
|
||||
let zipFinalURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(zipFilename)
|
||||
.appendingPathExtension(zipExtension)
|
||||
|
||||
return try createAt(
|
||||
zipFinalURL: finalURL,
|
||||
fromDirectory: directoryURL
|
||||
|
||||
if FileManager.default.fileExists(atPath: zipFinalURL.path) {
|
||||
try FileManager.default.removeItem(at: zipFinalURL)
|
||||
}
|
||||
|
||||
try FileManager.default.zipItem(
|
||||
at: directoryURL,
|
||||
to: zipFinalURL,
|
||||
shouldKeepParent: true,
|
||||
compressionMethod: .deflate
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
static func createAt(zipFinalURL: URL, fromDirectory directoryURL: URL) throws -> URL {
|
||||
|
||||
guard directoryURL.isDirectory else {
|
||||
throw CreateZipError.urlNotADirectory(directoryURL)
|
||||
}
|
||||
|
||||
var fileManagerError: Swift.Error?
|
||||
var coordinatorError: NSError?
|
||||
let coordinator = NSFileCoordinator()
|
||||
|
||||
coordinator.coordinate(readingItemAt: directoryURL, options: .forUploading, error: &coordinatorError) { zipCreatedURL in
|
||||
do {
|
||||
// clean if file exists
|
||||
if FileManager.default.fileExists(atPath: zipFinalURL.path) {
|
||||
try FileManager.default.removeItem(at: zipFinalURL)
|
||||
}
|
||||
|
||||
try FileManager.default.moveItem(at: zipCreatedURL, to: zipFinalURL)
|
||||
|
||||
} catch {
|
||||
fileManagerError = error
|
||||
}
|
||||
}
|
||||
|
||||
if let error = coordinatorError ?? fileManagerError {
|
||||
throw CreateZipError.failedToCreateZIP(error)
|
||||
}
|
||||
|
||||
return zipFinalURL
|
||||
}
|
||||
|
||||
|
||||
enum CreateZipError: Swift.Error {
|
||||
enum CreateZipError: Error {
|
||||
case urlNotADirectory(URL)
|
||||
case failedToCreateZIP(Swift.Error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension URL {
|
||||
private extension URL {
|
||||
var isDirectory: Bool {
|
||||
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
|
||||
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.leonboettger.neardrop</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>quickdrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIDesignRequiresCompatibility</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+1037
-93
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "270e8bfd245fae589b12faf08b86042cbbe7c809d59c5b0b8b735c7fb3a8d4bd",
|
||||
"originHash" : "85c9b997510f22b553d79f68fb6d51d149ac9324df643dbf1475023b39da33b3",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "applewatchscreensize",
|
||||
@@ -42,8 +42,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/devicekit/DeviceKit",
|
||||
"state" : {
|
||||
"revision" : "581df61650bc457ec00373a592a84be3e7468eb1",
|
||||
"version" : "5.7.0"
|
||||
"revision" : "56b997e8a61707218f9af09f32b2a1d1806fd792",
|
||||
"version" : "5.8.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -52,7 +52,7 @@
|
||||
"location" : "https://github.com/leonboe1/LUI.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "0b862decf853e1f542eeab1f8d548f8ff629deca"
|
||||
"revision" : "3582f59e758c39a849ab691f3fe26f7d0f618a94"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -87,8 +87,8 @@
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||
"state" : {
|
||||
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||
"version" : "1.33.3"
|
||||
"revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6",
|
||||
"version" : "1.36.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -124,7 +124,16 @@
|
||||
"location" : "https://github.com/siteline/swiftui-introspect",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "11d9782bea80656e8a65a643d6ac6396e4a8662e"
|
||||
"revision" : "26986a57e31c813f98262ddb62d4b07d40008535"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "zipfoundation",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/weichsel/ZIPFoundation.git",
|
||||
"state" : {
|
||||
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
|
||||
"version" : "0.9.20"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../ConfigSynced.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EB3B9EF02EF7150400D6704E"
|
||||
BuildableName = "QuickDropGitHub.app"
|
||||
BlueprintName = "QuickDropGitHub"
|
||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EB3B9EF02EF7150400D6704E"
|
||||
BuildableName = "QuickDropGitHub.app"
|
||||
BlueprintName = "QuickDropGitHub"
|
||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EB3B9EF02EF7150400D6704E"
|
||||
BuildableName = "QuickDropGitHub.app"
|
||||
BlueprintName = "QuickDropGitHub"
|
||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2600"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EB3B9F4A2EF7151900D6704E"
|
||||
BuildableName = "QuickDropShareExtensionGitHub.appex"
|
||||
BlueprintName = "QuickDropShareExtensionGitHub"
|
||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
launchStyle = "0"
|
||||
askForAppToLaunch = "Yes"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
askForAppToLaunch = "Yes"
|
||||
launchAutomaticallySubstyle = "2">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EB3B9F4A2EF7151900D6704E"
|
||||
BuildableName = "QuickDropShareExtensionGitHub.appex"
|
||||
BlueprintName = "QuickDropShareExtensionGitHub"
|
||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@@ -50,6 +50,9 @@
|
||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../../ConfigSynced.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
||||
+133
-81
@@ -12,6 +12,7 @@ import Network
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import LUI
|
||||
|
||||
@main
|
||||
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSWindowDelegate {
|
||||
@@ -25,7 +26,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
private var sheetAttachedWindow: NSWindow? = nil
|
||||
private var errorAlertHandler = ErrorAlertHandler.shared
|
||||
|
||||
private var iapManager: IAPManager?
|
||||
private var receiveModel: ReceiveModel?
|
||||
|
||||
var showsFirewallAlert = false
|
||||
@@ -35,6 +35,42 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
// MARK: NSApplicationDelegate functions
|
||||
|
||||
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 visibleItem = NSMenuItem(
|
||||
@@ -53,7 +89,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openWelcomeScreen), keyEquivalent: "")
|
||||
let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openMainWindow), keyEquivalent: "")
|
||||
userManualItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: nil)
|
||||
menu.addItem(userManualItem)
|
||||
|
||||
@@ -65,60 +101,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
statusItem?.button?.image = NSImage(named: "MenuBarIcon")
|
||||
statusItem?.menu = menu
|
||||
statusItem?.behavior = .removalAllowed
|
||||
|
||||
iapManager = IAPManager.sharedInstance
|
||||
iapManager?.startObserving()
|
||||
|
||||
if DistributionDetector.isDirectDistributionEnabled {
|
||||
log("[AppDelegate] Downloaded from GitHub.")
|
||||
}
|
||||
else {
|
||||
log("[AppDelegate] Downloaded from App Store.")
|
||||
}
|
||||
|
||||
// app did not lauch before
|
||||
if !Settings.shared.appLaunchedBefore {
|
||||
log("[AppDelegate] Opening Welcome Screen")
|
||||
// open welcome screen
|
||||
openWelcomeScreen()
|
||||
Settings.shared.appLaunchedBefore = true
|
||||
|
||||
// user installed the app after the IAP was implemented, set the user as eligible for IAP
|
||||
Settings.shared.isEligibleForIap = true
|
||||
} else {
|
||||
// app launched before
|
||||
|
||||
// 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
|
||||
} else {
|
||||
if !fullVersion() {
|
||||
log("[AppDelegate] New user - QuickDrop+ not available")
|
||||
}
|
||||
else {
|
||||
log("[AppDelegate] QuickDrop+ available")
|
||||
}
|
||||
}
|
||||
|
||||
BezelNotification.show(messageText: "ReadyToReceive".localized(), icon: .receiveIcon)
|
||||
}
|
||||
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
|
||||
if shouldOpen {
|
||||
self.openPlusScreen()
|
||||
}
|
||||
else {
|
||||
if let window = self.plusWindow {
|
||||
log("[AppDelegate] Closing plus screen because of error")
|
||||
window.close()
|
||||
self.plusWindow = nil
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// MARK: Nearby Connections Callbacks
|
||||
|
||||
NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in
|
||||
if isConnected {
|
||||
@@ -131,13 +116,42 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
}
|
||||
}
|
||||
|
||||
NearbyConnectionManager.shared.changedDeviceNameCallback = {
|
||||
self.visibleItem?.title = self.getDefaultVisibleLabel()
|
||||
}
|
||||
|
||||
|
||||
// MARK: Logging
|
||||
|
||||
log("[AppDelegate] Application did finish launching")
|
||||
log("[AppDelegate] Currently running apps: \(NSWorkspace.shared.runningApplications.map { $0.localizedName ?? "-" })")
|
||||
}
|
||||
|
||||
|
||||
func startReceiving() {
|
||||
guard receiveModel == nil else {
|
||||
log("[AppDelegate] startReceiving called while receiver is already active. Ignoring duplicate call.")
|
||||
return
|
||||
}
|
||||
|
||||
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
|
||||
if shouldOpen {
|
||||
self.openPlusScreen()
|
||||
}
|
||||
else {
|
||||
if let window = self.plusWindow {
|
||||
log("[AppDelegate] Closing plus screen because of error")
|
||||
window.close()
|
||||
self.plusWindow = nil
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
if let window = notification.object as? NSWindow, window == welcomeWindow {
|
||||
log("Welcome window closed")
|
||||
log("[AppDelegate] Welcome window closed")
|
||||
welcomeWindow = nil
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
@@ -145,7 +159,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
|
||||
|
||||
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
|
||||
openWelcomeScreen()
|
||||
self.openMainWindow()
|
||||
statusItem?.isVisible = true
|
||||
return true
|
||||
}
|
||||
@@ -160,21 +174,45 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
|
||||
func applicationWillTerminate(_: Notification) {
|
||||
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
||||
iapManager?.stopObserving()
|
||||
NearbyConnectionManager.shared.stopDeviceDiscovery()
|
||||
NearbyConnectionManager.shared.becomeInvisible()
|
||||
}
|
||||
|
||||
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
|
||||
log("[AppDelegate] application(open:): Received URLs: \(urls)")
|
||||
|
||||
for url in urls {
|
||||
if url.scheme == "quickdrop", url.host == "sendLog" {
|
||||
sendLoggingString() // this time it runs in the main app
|
||||
}
|
||||
|
||||
|
||||
if url.scheme == "quickdrop", url.host == "openLog", let url = LogManager.sharedInstance.logFileURL {
|
||||
log("Opening log file: \(url)")
|
||||
// open folder containing the log file
|
||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||
if url.scheme == "quickdrop" {
|
||||
switch url.host {
|
||||
case "sendLog":
|
||||
// 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":
|
||||
if let url = LogManager.sharedInstance.logFileURL {
|
||||
log("[AppDelegate] Opening log file: \(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":
|
||||
Settings.sharedInstance.deleteAllUserDefaults()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,7 +241,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
}
|
||||
|
||||
|
||||
@objc func openWelcomeScreen() {
|
||||
@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)
|
||||
|
||||
@@ -222,9 +270,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
openCableTransmissionView: { self.openSheetView(type: .downloadCableConnectionApp) },
|
||||
checkForNetworkIssues: performDeviceToDeviceCheck
|
||||
)
|
||||
|
||||
|
||||
welcomeWindow = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 400, height: 280),
|
||||
contentRect: NSRect(x: 0, y: 0, width: WelcomeScreen.width, height: WelcomeScreen.height),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
@@ -244,25 +292,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
welcomeWindow?.makeKeyAndOrderFront(nil)
|
||||
welcomeWindow?.level = .normal
|
||||
}
|
||||
|
||||
|
||||
|
||||
@objc func openPlusScreen() {
|
||||
// If window already exists and is visible, just bring it to the front
|
||||
if let window = plusWindow, window.isVisible {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
log("Plus screen already open, bringing to front")
|
||||
log("[AppDelegate] Plus screen already open, bringing to front")
|
||||
return
|
||||
}
|
||||
|
||||
log("Opening Plus screen")
|
||||
log("[AppDelegate] Opening Plus screen")
|
||||
|
||||
// Create the welcome screen SwiftUI view
|
||||
let plusView = PlusView(closeView: {
|
||||
log("Closing plus screen")
|
||||
self.plusWindow?.close()
|
||||
self.plusWindow = nil
|
||||
})
|
||||
let plusView = GetPlusViewV4(showSheet: Binding(get: { self.plusWindow != nil }, set: { newValue in
|
||||
if !newValue {
|
||||
self.plusWindow?.close()
|
||||
self.plusWindow = nil
|
||||
}
|
||||
}), hasSheet: false)
|
||||
|
||||
let plusViewWidth: CGFloat = 750
|
||||
let plusViewHeight: CGFloat = 750
|
||||
|
||||
// Create an NSWindow to host the SwiftUI view
|
||||
let window = NSWindow(
|
||||
@@ -291,7 +343,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
// Ensure the window is always on top
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.level = .normal
|
||||
window.level = .floating
|
||||
}
|
||||
|
||||
|
||||
@@ -333,13 +385,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
||||
// MARK: - Helper Functions
|
||||
|
||||
private func performDeviceToDeviceCheck() {
|
||||
let scanner = DeviceToDeviceHeuristicScanner()
|
||||
let scanner = DeviceToDeviceHeuristicScanner.shared
|
||||
scanner.scan { allowed in
|
||||
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()
|
||||
} else {
|
||||
log("❌ Device-to-device check failed. Informing user...")
|
||||
log("[AppDelegate] ❌ Device-to-device check failed. Informing user...")
|
||||
ErrorAlertHandler.shared.openAlert(type: .ApIsolation)
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 672 KiB |
@@ -12,15 +12,6 @@
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"filename" : "1024-1024.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Plain.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "FileTransfer Kopie-512.png",
|
||||
"filename" : "iDroidIcon-iOS-Default-1024x1024@1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// Configuration.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 15.08.25.
|
||||
//
|
||||
|
||||
import LUI
|
||||
import SwiftUI
|
||||
|
||||
let configuration = LUIConfiguration(
|
||||
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"),
|
||||
|
||||
hasRoundedFont: false,
|
||||
|
||||
switchedToIAPafterBundleVersion: AppVersionBundle(appVersion: "1.2", buildVersion: 0, legacyUserDefaultsKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue),
|
||||
|
||||
copyrightInformation: licenseText,
|
||||
|
||||
logUploadDisclaimerMessage: "log_upload_disclaimer_message",
|
||||
|
||||
loggingUrl: AppGroup.appGroupDirectory,
|
||||
|
||||
introductionViewInformation: "QuickDropWelcomeDescription",
|
||||
|
||||
introductionViewImage: Image(.quickDropMockup),
|
||||
|
||||
usesAutomaticReviewPromptAndShareSheet: false,
|
||||
|
||||
usesPagedIntroduction: true,
|
||||
|
||||
supportsSplitView: false,
|
||||
|
||||
supportsLiquidGlass: true,
|
||||
|
||||
supportsPortraitSplitView: false,
|
||||
|
||||
supportsDarkModeShadow: true
|
||||
)
|
||||
@@ -0,0 +1,745 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"CFBundleDisplayName" : {
|
||||
"comment" : "Bundle display name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "QuickDrop"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "QuickDrop"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"NSHumanReadableCopyright" : {
|
||||
"comment" : "Copyright (human-readable)",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Copyright © 2025-2026 Leon Böttger"
|
||||
}
|
||||
}
|
||||
},
|
||||
"shouldTranslate" : false
|
||||
},
|
||||
"NSLocalNetworkUsageDescription" : {
|
||||
"comment" : "Privacy - Local Network Usage Description",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "الوصول إلى الشبكة المحلية ضروري لاكتشاف الأجهزة ونقل الملفات عبر شبكة الواي فاي الخاصة بك."
|
||||
}
|
||||
},
|
||||
"ca" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Cal accedir a la xarxa local per detectar dispositius i transferir fitxers a través de la teva xarxa Wi-Fi."
|
||||
}
|
||||
},
|
||||
"cs" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Přístup k místní síti je potřebný k detekci zařízení a přenosu souborů přes vaši Wi-Fi síť."
|
||||
}
|
||||
},
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Adgang til det lokale netværk er nødvendig for at finde enheder og overføre filer via dit Wi-Fi-netværk."
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Zugriff auf das lokale Netzwerk wird benötigt, um Geräte zu erkennen und Dateien über dein WLAN zu übertragen."
|
||||
}
|
||||
},
|
||||
"el" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Η πρόσβαση στο τοπικό δίκτυο απαιτείται για να εντοπιστούν συσκευές και να μεταφερθούν αρχεία μέσω του Wi-Fi σας."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Local network access is needed to detect devices and transfer files over your Wi-Fi network."
|
||||
}
|
||||
},
|
||||
"en-AU" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Local network access is needed to detect devices and transfer files over your Wi-Fi network."
|
||||
}
|
||||
},
|
||||
"en-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Local network access is needed to detect devices and transfer files over your Wi-Fi network."
|
||||
}
|
||||
},
|
||||
"en-GB" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Local network access is needed to detect devices and transfer files over your Wi-Fi network."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Se necesita acceso a la red local para detectar dispositivos y transferir archivos por tu red Wi-Fi."
|
||||
}
|
||||
},
|
||||
"es-419" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Se necesita acceso a la red local para detectar dispositivos y transferir archivos por tu red Wi-Fi."
|
||||
}
|
||||
},
|
||||
"fi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Paikallisverkon käyttöä tarvitaan laitteiden havaitsemiseen ja tiedostojen siirtämiseen Wi-Fi-verkossasi."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L’accès au réseau local est nécessaire pour détecter les appareils et transférer des fichiers via votre réseau Wi-Fi."
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L’accès au réseau local est nécessaire pour détecter des appareils et transférer des fichiers via votre réseau Wi-Fi."
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "נדרש גישה לרשת המקומית כדי לאתר מכשירים ולהעביר קבצים דרך רשת ה-Wi-Fi שלך."
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "स्थानीय नेटवर्क एक्सेस आवश्यक है ताकि डिवाइसों का पता लगाया जा सके और आपकी वाई-फाई नेटवर्क पर फ़ाइलें भेजी जा सकें।"
|
||||
}
|
||||
},
|
||||
"hr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pristup lokalnoj mreži potreban je za otkrivanje uređaja i prijenos datoteka preko vaše Wi-Fi mreže."
|
||||
}
|
||||
},
|
||||
"hu" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A helyi hálózati hozzáférés szükséges az eszközök felismeréséhez és a fájlok Wi-Fi-hálózaton keresztüli átviteléhez."
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Akses jaringan lokal diperlukan untuk mendeteksi perangkat dan mengirim file melalui jaringan Wi-Fi Anda."
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L'accesso alla rete locale è necessario per rilevare dispositivi e trasferire file tramite la tua rete Wi-Fi."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Wi-Fiネットワーク上でデバイスを検出し、ファイルを転送するためにローカルネットワークへのアクセスが必要です。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "로컬 네트워크에 접근해야 기기를 감지하고 Wi-Fi 네트워크를 통해 파일을 전송할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Akses rangkaian tempatan diperlukan untuk mengesan peranti dan memindahkan fail melalui rangkaian Wi-Fi anda."
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tilgang til lokalt nettverk er nødvendig for å finne enheter og overføre filer via Wi-Fi-nettverket ditt."
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Toegang tot het lokale netwerk is nodig om apparaten te detecteren en bestanden via je wifi-netwerk te versturen."
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Dostęp do sieci lokalnej jest potrzebny, aby wykrywać urządzenia i przesyłać pliki przez twoją sieć Wi-Fi."
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "O acesso à rede local é necessário para detectar dispositivos e transferir arquivos pela sua rede Wi-Fi."
|
||||
}
|
||||
},
|
||||
"pt-PT" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "O acesso à rede local é necessário para detetar dispositivos e transferir ficheiros pela sua rede Wi-Fi."
|
||||
}
|
||||
},
|
||||
"ro" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Accesul la rețeaua locală este necesar pentru a detecta dispozitivele și a transfera fișiere prin rețeaua Wi-Fi."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Доступ к локальной сети необходим для обнаружения устройств и передачи файлов по вашей Wi-Fi сети."
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Åtkomst till det lokala nätverket krävs för att hitta enheter och överföra filer via ditt Wi-Fi-nätverk."
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "จำเป็นต้องเข้าถึงเครือข่ายท้องถิ่นเพื่อค้นหาอุปกรณ์และรับส่งไฟล์ผ่านเครือข่าย Wi-Fi ของคุณ"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Cihazları tespit etmek ve dosyaları Wi-Fi ağınız üzerinden aktarmak için yerel ağ erişimi gereklidir."
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Доступ до локальної мережі потрібен для виявлення пристроїв і передавання файлів через вашу Wi-Fi мережу."
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Cần quyền truy cập mạng nội bộ để phát hiện thiết bị và truyền tệp qua mạng Wi-Fi của bạn."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要本地网络访问权限,以便检测设备并通过您的 Wi-Fi 网络传输文件。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要本地網路存取權以偵測裝置及透過 Wi-Fi 網路傳輸檔案。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSPhotoLibraryAddUsageDescription" : {
|
||||
"comment" : "Privacy - Photo Library Additions Usage Description",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "إذن إضافة الصور مطلوب لحفظ الصور والفيديوهات المستلمة في مكتبة الصور الخاصة بك."
|
||||
}
|
||||
},
|
||||
"ca" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Es necessita permís per afegir fotos per desar les fotos i vídeos rebuts a la teva biblioteca de fotos."
|
||||
}
|
||||
},
|
||||
"cs" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Oprávnění pro přidání fotek je nutné pro ukládání přijatých fotografií a videí do vaší fotogalerie."
|
||||
}
|
||||
},
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tilladelse til at tilføje fotos er påkrævet for at gemme modtagne billeder og videoer i dit fotobibliotek."
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Die Berechtigung „Fotos hinzufügen“ wird benötigt, um empfangene Fotos und Videos in deiner Fotomediathek zu speichern."
|
||||
}
|
||||
},
|
||||
"el" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Η άδεια προσθήκης φωτογραφιών απαιτείται για την αποθήκευση ληφθέντων φωτογραφιών και βίντεο στη βιβλιοθήκη φωτογραφιών σας."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-AU" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-GB" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Se necesita el permiso para añadir fotos para guardar las fotos y videos recibidos en tu biblioteca de fotos."
|
||||
}
|
||||
},
|
||||
"es-419" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "El permiso para agregar fotos es necesario para guardar las fotos y videos recibidos en tu galería."
|
||||
}
|
||||
},
|
||||
"fi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Valokuvat-lisäysoikeus vaaditaan, jotta vastaanotetut kuvat ja videot voidaan tallentaa kuvakirjastoosi."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L'autorisation d’ajouter des photos est nécessaire pour enregistrer les photos et vidéos reçues dans votre photothèque."
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "La permission d’ajouter des photos est requise pour enregistrer les photos et vidéos reçues dans votre galerie."
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "נדרשת הרשאת הוספת תמונות כדי לשמור תמונות וסרטונים שהתקבלו בספריית התמונות שלך."
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "प्राप्त फ़ोटो और वीडियो को आपकी फ़ोटो लाइब्रेरी में सहेजने के लिए फ़ोटो जोड़ने की अनुमति आवश्यक है।"
|
||||
}
|
||||
},
|
||||
"hr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Dopuštenje za dodavanje fotografija potrebno je za spremanje primljenih fotografija i videozapisa u vašu galeriju."
|
||||
}
|
||||
},
|
||||
"hu" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A fotók hozzáadási engedélye szükséges az átvett fotók és videók mentéséhez a fotótárba."
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Izin menambah foto diperlukan untuk menyimpan foto dan video yang diterima ke perpustakaan foto Anda."
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Il permesso di aggiungere foto è necessario per salvare foto e video ricevuti nella tua libreria fotografica."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "受信した写真や動画を写真ライブラリに保存するために、写真追加の許可が必要です。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "사진 및 동영상 저장을 위해 사진 추가 권한이 필요합니다."
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kebenaran tambah foto diperlukan untuk menyimpan foto dan video yang diterima ke dalam pustaka foto anda."
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tillatelse til å legge til bilder er nødvendig for å lagre mottatte bilder og videoer i bildebiblioteket ditt."
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "De machtiging om foto's toe te voegen is nodig om ontvangen foto's en video's op te slaan in je fotobibliotheek."
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Uprawnienie do dodawania zdjęć jest wymagane, aby zapisywać odebrane zdjęcia i filmy w Twojej galerii."
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A permissão para adicionar fotos é necessária para salvar fotos e vídeos recebidos na sua galeria."
|
||||
}
|
||||
},
|
||||
"pt-PT" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A permissão para adicionar fotos é necessária para guardar fotos e vídeos recebidos na sua biblioteca de fotos."
|
||||
}
|
||||
},
|
||||
"ro" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Permisiunea de a adăuga fotografii este necesară pentru a salva pozele și videoclipurile primite în librăria foto."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Необходимо разрешение на добавление фото, чтобы сохранять полученные фото и видео в вашу фототеку."
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Behörigheten att lägga till foton krävs för att spara mottagna bilder och videor till ditt fotobibliotek."
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "จำเป็นต้องอนุญาตการเพิ่มรูปภาพเพื่อบันทึกรูปภาพและวิดีโอที่ได้รับลงในคลังรูปภาพของคุณ"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Alınan fotoğraf ve videoları fotoğraf arşivinize kaydetmek için fotoğraf ekleme izni gereklidir."
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Дозвіл на додавання фото потрібен для збереження отриманих фото й відео у вашій фотогалереї."
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Quyền thêm ảnh cần thiết để lưu ảnh và video nhận được vào thư viện ảnh của bạn."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "添加照片权限用于将接收的照片和视频保存到您的相册。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要新增相片權限才能將接收的相片和影片儲存到您的相簿。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSPhotoLibraryUsageDescription" : {
|
||||
"comment" : "Privacy - Photo Library Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "إذن الوصول إلى مكتبة الصور مطلوب لحفظ الصور ومقاطع الفيديو المستلمة في مكتبة الصور الخاصة بك."
|
||||
}
|
||||
},
|
||||
"ca" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Es necessita el permís de la biblioteca de fotos per desar les fotos i vídeos rebuts a la teva biblioteca."
|
||||
}
|
||||
},
|
||||
"cs" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Oprávnění k fotoknihovně je nutné pro ukládání přijatých fotek a videí do vaší fotoknihovny."
|
||||
}
|
||||
},
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tilladelse til fotobibliotek er påkrævet for at gemme modtagne fotos og videoer i dit fotobibliotek."
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Die Berechtigung für die Fotomediathek ist erforderlich, um empfangene Fotos und Videos in deiner Mediathek zu speichern."
|
||||
}
|
||||
},
|
||||
"el" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Η άδεια πρόσβασης στη βιβλιοθήκη φωτογραφιών απαιτείται για να αποθηκεύετε τις ληφθείσες φωτογραφίες και βίντεο στη βιβλιοθήκη σας."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "The photo library permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-AU" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The photo library permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The photo library permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-GB" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The photo library permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Se requiere permiso para acceder a la fototeca y poder guardar fotos y videos recibidos."
|
||||
}
|
||||
},
|
||||
"es-419" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "El permiso para la biblioteca de fotos es necesario para guardar fotos y videos recibidos en tu biblioteca."
|
||||
}
|
||||
},
|
||||
"fi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kuvakirjaston lupa vaaditaan, jotta vastaanotetut kuvat ja videot voidaan tallentaa kuvakirjastoosi."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L'autorisation d'accès à la photothèque est requise pour enregistrer les photos et vidéos reçues dans votre photothèque."
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L’autorisation d’accéder à la photothèque est requise pour enregistrer les photos et vidéos reçues dans votre photothèque."
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "נדרש אישור לספריית התמונות כדי לשמור תמונות וסרטונים שהתקבלו בספריית התמונות שלך."
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "प्राप्त फ़ोटो और वीडियो को आपकी फोटो लाइब्रेरी में सहेजने के लिए फोटो लाइब्रेरी की अनुमति आवश्यक है।"
|
||||
}
|
||||
},
|
||||
"hr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Dozvola za pristup fototeci potrebna je za spremanje primljenih fotografija i videozapisa u vašu fototeku."
|
||||
}
|
||||
},
|
||||
"hu" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A fotókönyvtár engedélye szükséges az érkezett képek és videók mentéséhez a fotókönyvtárba."
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Izin akses perpustakaan foto diperlukan untuk menyimpan foto dan video yang diterima ke perpustakaan foto Anda."
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L'autorizzazione alla libreria foto è necessaria per salvare foto e video ricevuti nella tua libreria."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "受信した写真や動画を写真ライブラリに保存するために、写真ライブラリへのアクセス許可が必要です。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "사진 및 동영상 저장을 위해 사진 라이브러리 접근 권한이 필요합니다."
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kebenaran perpustakaan foto diperlukan untuk menyimpan foto dan video yang diterima ke perpustakaan foto anda."
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tillatelse til å bruke bildebiblioteket er nødvendig for å lagre mottatte bilder og videoer i bildebiblioteket ditt."
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Toestemming voor de fotobibliotheek is vereist om ontvangen foto's en video's in je bibliotheek op te slaan."
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Uprawnienie do biblioteki zdjęć jest wymagane, aby zapisywać otrzymane zdjęcia i filmy w Twojej galerii."
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A permissão da biblioteca de fotos é necessária para salvar fotos e vídeos recebidos na sua galeria."
|
||||
}
|
||||
},
|
||||
"pt-PT" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A permissão para aceder à biblioteca de fotos é necessária para guardar fotos e vídeos recebidos na sua biblioteca."
|
||||
}
|
||||
},
|
||||
"ro" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Permisiunea pentru biblioteca foto este necesară pentru a salva fotografiile și videoclipurile primite în biblioteca ta foto."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Разрешение на доступ к фотобиблиотеке нужно для сохранения полученных фото и видео в вашу фотогалерею."
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Behörighet till fotobiblioteket krävs för att spara mottagna bilder och videor i ditt fotobibliotek."
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "ต้องอนุญาตเข้าถึงคลังรูปภาพเพื่อบันทึกรูปภาพและวิดีโอที่ได้รับไปยังคลังรูปภาพของคุณ"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Alınan fotoğraf ve videoları fotoğraf galerine kaydetmek için fotoğraf galerisi izni gereklidir."
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Дозвіл на доступ до фототеки необхідний для збереження отриманих фото та відео у вашу фототеку."
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Bạn cần cấp quyền truy cập thư viện ảnh để lưu ảnh và video nhận được vào thư viện ảnh của bạn."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要照片库权限才能将接收的照片和视频保存到您的照片库。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要相簿權限才能將接收的照片和影片儲存到您的相簿。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
+20552
-5191
File diff suppressed because it is too large.
Load diff
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// StoreChecker.swift
|
||||
// LUI
|
||||
//
|
||||
// Created by Leon Böttger on 19.08.24.
|
||||
//
|
||||
import StoreKit
|
||||
|
||||
class IAPConverter: NSObject, SKRequestDelegate {
|
||||
|
||||
let switchToIAPAfterAppVersion = "1.2"
|
||||
|
||||
static let shared = IAPConverter()
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func restoreIAPfromPreviousPurchase(success: ((Bool) -> ())? = nil) {
|
||||
Task {
|
||||
do {
|
||||
// Get the appTransaction.
|
||||
|
||||
if #available(macOS 13.0, *) {
|
||||
let shared = try await AppTransaction.shared
|
||||
|
||||
if case .verified(let appTransaction) = shared {
|
||||
|
||||
let originalVersion = appTransaction.originalAppVersion
|
||||
let newModelVersion = switchToIAPAfterAppVersion
|
||||
log("[LUI] Original Purchased Version: \(originalVersion) | New Model Version: \(newModelVersion)")
|
||||
|
||||
if originalVersion.compare(newModelVersion, options: .numeric) == .orderedAscending {
|
||||
grant(success: success)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback on earlier versions
|
||||
// Grant IAP, as we cannot check the original purchase date.
|
||||
DispatchQueue.main.async {
|
||||
log("[LUI] Cannot check version - Plus Version Granted!")
|
||||
success?(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Handle errors.
|
||||
log("[LUI] Error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
log("[LUI] Plus Version Denied!")
|
||||
success?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func grant(success: ((Bool) -> ())? = nil) {
|
||||
log("[LUI] Existing User - Plus Version Granted!")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
success?(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//
|
||||
// DistributionDetector.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 11.08.25.
|
||||
//
|
||||
|
||||
import Security
|
||||
import Foundation
|
||||
|
||||
class DistributionDetector {
|
||||
|
||||
static var isDirectDistributionEnabled: Bool {
|
||||
get {
|
||||
#if os(macOS)
|
||||
if let cached = _directDistributionCached {
|
||||
return cached
|
||||
}
|
||||
|
||||
let result = isDirectDistribution()
|
||||
self._directDistributionCached = result
|
||||
|
||||
return result
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static var _directDistributionCached: Bool? = nil
|
||||
|
||||
#if os(macOS)
|
||||
private static func isDirectDistribution() -> Bool {
|
||||
// 1. Get dynamic code object for our process
|
||||
var code: SecCode?
|
||||
var status = SecCodeCopySelf([], &code)
|
||||
guard status == errSecSuccess, let codeRef = code else {
|
||||
log("[DistributionDetector] Could not get code object)")
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. Convert to static code object
|
||||
var staticCode: SecStaticCode?
|
||||
status = SecCodeCopyStaticCode(codeRef, [], &staticCode)
|
||||
guard status == errSecSuccess, let staticCodeRef = staticCode else {
|
||||
log("[DistributionDetector] Could not get static code object)")
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Get signing info
|
||||
var infoCF: CFDictionary?
|
||||
status = SecCodeCopySigningInformation(staticCodeRef,
|
||||
SecCSFlags(rawValue: kSecCSSigningInformation),
|
||||
&infoCF)
|
||||
guard status == errSecSuccess, let info = infoCF as? [String: Any] else {
|
||||
log("[DistributionDetector] Could not get signing info")
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Look at the first certificate's common name
|
||||
if let certificates = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
|
||||
let firstCert = certificates.first {
|
||||
var commonName: CFString?
|
||||
status = SecCertificateCopyCommonName(firstCert, &commonName)
|
||||
if status == errSecSuccess, let commonNameStr = commonName as String? {
|
||||
if commonNameStr.contains("Developer ID Application") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
//
|
||||
// PurchaseHelper.swift
|
||||
// Loop
|
||||
//
|
||||
// Created by Leon Böttger on 26.10.20.
|
||||
// Copyright © 2020 Leon Böttger. All rights reserved.
|
||||
//
|
||||
|
||||
import StoreKit
|
||||
|
||||
let iapName = "plusversion"
|
||||
|
||||
class IAPManager: NSObject, ObservableObject {
|
||||
|
||||
enum IAPManagerError: Error {
|
||||
case noProductIDsFound
|
||||
case noProductsFound
|
||||
case paymentWasCancelled
|
||||
case productRequestFailed
|
||||
}
|
||||
|
||||
static let plusVersionID = iapName
|
||||
static let sharedInstance = IAPManager()
|
||||
var onReceiveProductsHandler: ((Result<[SKProduct], IAPManagerError>) -> Void)?
|
||||
var onBuyProductHandler: ((Result<Bool, Error>) -> Void)?
|
||||
var totalRestoredPurchases = 0
|
||||
var isActive = false
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
getProducts { _ in }
|
||||
}
|
||||
|
||||
fileprivate func getProductIDs() -> [String]? {
|
||||
return [IAPManager.plusVersionID]
|
||||
}
|
||||
|
||||
func startObserving() {
|
||||
SKPaymentQueue.default().add(self)
|
||||
}
|
||||
|
||||
|
||||
func stopObserving() {
|
||||
SKPaymentQueue.default().remove(self)
|
||||
}
|
||||
|
||||
|
||||
func canMakePayments() -> Bool {
|
||||
return SKPaymentQueue.canMakePayments()
|
||||
}
|
||||
|
||||
func getProducts(withHandler productsReceiveHandler: @escaping (_ result: Result<[SKProduct], IAPManagerError>) -> Void) {
|
||||
// Keep the handler (closure) that will be called when requesting for
|
||||
// products on the App Store is finished.
|
||||
onReceiveProductsHandler = productsReceiveHandler
|
||||
|
||||
// Get the product identifiers.
|
||||
guard let productIDs = getProductIDs() else {
|
||||
productsReceiveHandler(.failure(.noProductIDsFound))
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize a product request.
|
||||
let request = SKProductsRequest(productIdentifiers: Set(productIDs))
|
||||
|
||||
// Set self as the its delegate.
|
||||
request.delegate = self
|
||||
|
||||
// Make the request.
|
||||
request.start()
|
||||
}
|
||||
|
||||
func buy(product: SKProduct, withHandler handler: @escaping ((_ result: Result<Bool, Error>) -> Void)) {
|
||||
let payment = SKPayment(product: product)
|
||||
SKPaymentQueue.default().add(payment)
|
||||
|
||||
// Keep the completion handler.
|
||||
onBuyProductHandler = handler
|
||||
}
|
||||
|
||||
|
||||
func restorePurchases(withHandler handler: @escaping ((_ result: Result<Bool, Error>) -> Void)) {
|
||||
onBuyProductHandler = handler
|
||||
totalRestoredPurchases = 0
|
||||
SKPaymentQueue.default().restoreCompletedTransactions()
|
||||
}
|
||||
}
|
||||
|
||||
extension IAPManager: SKPaymentTransactionObserver {
|
||||
func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
|
||||
|
||||
log("IAP updatedTransactions")
|
||||
|
||||
transactions.forEach { (transaction) in
|
||||
|
||||
switch transaction.transactionState {
|
||||
case .purchased:
|
||||
log("IAP purchased")
|
||||
onBuyProductHandler?(.success(true))
|
||||
SKPaymentQueue.default().finishTransaction(transaction)
|
||||
|
||||
case .restored:
|
||||
log("IAP restored")
|
||||
totalRestoredPurchases += 1
|
||||
onBuyProductHandler?(.success(true))
|
||||
SKPaymentQueue.default().finishTransaction(transaction)
|
||||
|
||||
case .failed:
|
||||
log("[LUI] IAP failed")
|
||||
if let error = transaction.error as? SKError {
|
||||
if error.code != .paymentCancelled {
|
||||
onBuyProductHandler?(.failure(error))
|
||||
} else {
|
||||
onBuyProductHandler?(.failure(IAPManagerError.paymentWasCancelled))
|
||||
}
|
||||
log("[LUI] IAP Error:" + error.localizedDescription)
|
||||
}
|
||||
onBuyProductHandler?(.failure(transaction.error ?? IAPManagerError.productRequestFailed))
|
||||
SKPaymentQueue.default().finishTransaction(transaction)
|
||||
|
||||
case .deferred, .purchasing: log("[LUI] other cases")
|
||||
@unknown default: log("[LUI] other default cases")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
if self.totalRestoredPurchases == 0 {
|
||||
log("[LUI] IAP: No purchases to restore!")
|
||||
self.onBuyProductHandler?(.success(false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) {
|
||||
if let error = error as? SKError {
|
||||
if error.code != .paymentCancelled {
|
||||
log("[LUI] IAP Restore Error:" + error.localizedDescription)
|
||||
onBuyProductHandler?(.failure(error))
|
||||
} else {
|
||||
onBuyProductHandler?(.failure(IAPManagerError.paymentWasCancelled))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension IAPManager: SKProductsRequestDelegate {
|
||||
func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) {
|
||||
// Get the available products contained in the response.
|
||||
let products = response.products
|
||||
|
||||
// Check if there are any products available.
|
||||
if products.count > 0 {
|
||||
// Call the following handler passing the received products.
|
||||
onReceiveProductsHandler?(.success(products))
|
||||
} else {
|
||||
// No products were found.
|
||||
onReceiveProductsHandler?(.failure(.noProductsFound))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
func request(_ request: SKRequest, didFailWithError error: Error) {
|
||||
onReceiveProductsHandler?(.failure(.productRequestFailed))
|
||||
}
|
||||
|
||||
|
||||
func requestDidFinish(_ request: SKRequest) {
|
||||
// Implement this method OPTIONALLY and add any custom logic
|
||||
// you want to apply when a product request is finished.
|
||||
}
|
||||
}
|
||||
|
||||
extension IAPManager.IAPManagerError: LocalizedError {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noProductIDsFound: return "No In-App Purchase product identifiers were found."
|
||||
case .noProductsFound: return "No In-App Purchases were found."
|
||||
case .productRequestFailed: return "plusview_productRequestFailed".localized()
|
||||
case .paymentWasCancelled: return "In-App Purchase process was cancelled."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
//
|
||||
// PlusView.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 23.02.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
let plusViewWidth: CGFloat = 440
|
||||
let plusViewHeight: CGFloat = 200
|
||||
|
||||
struct PlusView: View {
|
||||
|
||||
@ObservedObject private var settings = Settings.shared
|
||||
@ObservedObject private var iaphelper = IAPManager.sharedInstance
|
||||
let closeView: () -> Void
|
||||
|
||||
@State private var restoring = false
|
||||
@State private var showRestoreError = false
|
||||
@State private var restoreError = ""
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State var products = [SKProduct]()
|
||||
@State var price: String = ""
|
||||
@State var warning = false
|
||||
@State var buying = false
|
||||
@State var errorText = ""
|
||||
@State var boughtSuccessAlert = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
|
||||
ZStack {
|
||||
Color.defaultBackground
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
let validPrice = price != ""
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("plusview_title")
|
||||
.font(.title)
|
||||
.bold()
|
||||
|
||||
Text("plusview_description")
|
||||
.multilineTextAlignment(.center)
|
||||
.padding()
|
||||
.padding(.top, -10)
|
||||
.alert(isPresented: $boughtSuccessAlert, content: {
|
||||
Alert(title: Text("plusview_success_title"), message: Text("plusview_success_description"), dismissButton: .default(Text("plusview_success_button"), action: {
|
||||
closeView()
|
||||
}))
|
||||
})
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button("plusview_restorepurchase") {
|
||||
restorePurchases()
|
||||
}
|
||||
.alert(isPresented: $showRestoreError, content: {
|
||||
Alert(title: Text("plusview_restorefailed"), message: Text(restoreError), dismissButton: .default(Text("plusview_restorefailed_proceed")))
|
||||
})
|
||||
.opacity(restoring ? 0 : 1)
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.opacity(restoring ? 1 : 0)
|
||||
)
|
||||
|
||||
let priceString = validPrice ? " (\(price))" : ""
|
||||
|
||||
Button("plusview_unlock_label".localized() + priceString) {
|
||||
buttonAction()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.alert(isPresented: $warning, content: {
|
||||
Alert(title: Text("plusview_purchasefailed"), message: Text("plusview_purchasefaileddescription".localized() + (errorText != "" ? " Error: " : "") + errorText), dismissButton: .default(Text("plusview_restorefailed_proceed")))
|
||||
})
|
||||
}
|
||||
.opacity(buying || restoring ? 0 : 1)
|
||||
.overlay(
|
||||
ProgressView()
|
||||
.scaleEffect(0.5)
|
||||
.opacity(buying || restoring ? 1 : 0)
|
||||
)
|
||||
}
|
||||
.onAppear {
|
||||
loadProducts(shouldBuy: false)
|
||||
}
|
||||
.animation(.default, value: validPrice)
|
||||
.padding()
|
||||
}
|
||||
.frame(width: plusViewWidth, height: plusViewHeight)
|
||||
}
|
||||
|
||||
func restorePurchases() {
|
||||
|
||||
withAnimation {
|
||||
restoring = true
|
||||
}
|
||||
|
||||
iaphelper.restorePurchases { result in
|
||||
DispatchQueue.main.async {
|
||||
|
||||
switch result {
|
||||
case .success(let success):
|
||||
if success {
|
||||
log("[LUI] Restored purchases successfully")
|
||||
withAnimation {
|
||||
settings.gotPlus = true
|
||||
}
|
||||
boughtSuccessAlert = true
|
||||
} else {
|
||||
log("[LUI] Nothing to restore")
|
||||
//nothing to restore
|
||||
restoreFailed(message: "plusview_nothingtorestore".localized())
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
log("[LUI] Error restoring purchases: \(error.localizedDescription)")
|
||||
log(error.localizedDescription)
|
||||
restoreFailed(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreFailed(message: String) {
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
isPlusVersion = true
|
||||
dismissView()
|
||||
#else
|
||||
|
||||
// now, try to convert from old version
|
||||
IAPConverter.shared.restoreIAPfromPreviousPurchase { success in
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
||||
if success {
|
||||
withAnimation {
|
||||
settings.gotPlus = true
|
||||
}
|
||||
|
||||
boughtSuccessAlert = true
|
||||
}
|
||||
else {
|
||||
restoreError = message
|
||||
showRestoreError = true
|
||||
|
||||
withAnimation {
|
||||
restoring = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func buttonAction() {
|
||||
if let product = products.first {
|
||||
purchase(product: product)
|
||||
}
|
||||
else {
|
||||
loadProducts(shouldBuy: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func purchase(product: SKProduct) {
|
||||
if !iaphelper.canMakePayments() {
|
||||
warning = true
|
||||
} else {
|
||||
withAnimation{
|
||||
buying = true
|
||||
}
|
||||
|
||||
iaphelper.buy(product: product) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
withAnimation{
|
||||
buying = false
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(_):
|
||||
|
||||
withAnimation {
|
||||
settings.gotPlus = true
|
||||
boughtSuccessAlert = true
|
||||
warning = false
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
warning = true
|
||||
errorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadProducts(shouldBuy: Bool) {
|
||||
iaphelper.getProducts { (result) in
|
||||
DispatchQueue.main.async {
|
||||
|
||||
switch result {
|
||||
case .success(let products):
|
||||
self.products = products
|
||||
price = products.first?.localizedPrice ?? ""
|
||||
|
||||
if let product = products.first, shouldBuy {
|
||||
purchase(product: product)
|
||||
}
|
||||
|
||||
case .failure(let error):
|
||||
errorText = error.localizedDescription
|
||||
|
||||
if shouldBuy {
|
||||
warning = true
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
buying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension SKProduct {
|
||||
var localizedPrice: String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.locale = priceLocale
|
||||
if let price = formatter.string(from: price) {
|
||||
return price
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
#Preview {
|
||||
PlusView(closeView: {})
|
||||
}
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>group.com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
+17
-11
@@ -5,21 +5,18 @@
|
||||
// Created by Leon Böttger on 09.09.25.
|
||||
//
|
||||
|
||||
import LUI
|
||||
import SwiftUI
|
||||
|
||||
class Settings: ObservableObject {
|
||||
final class Settings: SharedInstance {
|
||||
|
||||
private init() {}
|
||||
|
||||
static var shared = Settings()
|
||||
static var sharedInstance = Settings()
|
||||
|
||||
|
||||
// MARK: - Published UserDefaults Backed Properties
|
||||
|
||||
@Published var gotPlus = UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue)
|
||||
{ didSet { UserDefaults.standard.set(gotPlus, forKey: UserDefaultsKeys.plusVersion.rawValue) }}
|
||||
|
||||
|
||||
@Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue)
|
||||
{ didSet { UserDefaults.standard.set(automaticallyAcceptFiles, forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) }}
|
||||
|
||||
@@ -37,6 +34,10 @@ class Settings: ObservableObject {
|
||||
|
||||
var incomingTransmissionCount: Int = UserDefaults.standard.integer(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)
|
||||
@@ -55,25 +56,30 @@ class Settings: ObservableObject {
|
||||
// MARK: - Debug
|
||||
|
||||
func deleteAllUserDefaults() {
|
||||
for key in UserDefaultsKeys.allCases {
|
||||
UserDefaults.standard.removeObject(forKey: key.rawValue)
|
||||
let defaults = UserDefaults.standard
|
||||
if let bundleID = Bundle.main.bundleIdentifier {
|
||||
defaults.removePersistentDomain(forName: bundleID)
|
||||
}
|
||||
defaults.synchronize()
|
||||
|
||||
Settings.shared = Settings()
|
||||
Settings.sharedInstance = Settings()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - UserDefaults Keys
|
||||
|
||||
public enum UserDefaultsKeys: String, CaseIterable {
|
||||
public enum UserDefaultsKeys: String, CaseIterable, RawRepresentable {
|
||||
case isEligibleForIap = "isEligibleForIap"
|
||||
case appLaunchedBefore = "ShowedWelcomeScreen"
|
||||
case plusVersion = "isPlusVersion"
|
||||
case transmissionCount = "reviewRequestCountKey"
|
||||
case incomingNotificationCount = "incomingNotificationCount"
|
||||
case automaticallyAcceptFiles = "automaticallyAcceptFiles"
|
||||
case saveFolderBookmark = "saveFolderBookmark"
|
||||
case openFinderAfterReceiving = "openFinderAfterReceiving"
|
||||
case endpointID = "endpointID"
|
||||
|
||||
// Plus version used before StoreKit 2 update. New key is fetched from App Store directly.
|
||||
case plusVersionLegacy = "isPlusVersion"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//
|
||||
// AllowedWorkMonitor.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 26.12.25.
|
||||
//
|
||||
|
||||
|
||||
import AppKit
|
||||
import CoreGraphics
|
||||
import LUI
|
||||
|
||||
final class AllowedWorkMonitor {
|
||||
var onAllowed: (() -> Void)?
|
||||
var onStopped: (() -> Void)?
|
||||
|
||||
private var screenOn = true
|
||||
private var sessionActive = true
|
||||
private var lastAllowed: Bool?
|
||||
|
||||
private var observerTokens: [NSObjectProtocol] = []
|
||||
|
||||
|
||||
func start() {
|
||||
stop()
|
||||
|
||||
let nc = NSWorkspace.shared.notificationCenter
|
||||
|
||||
observerTokens.append(nc.addObserver(
|
||||
forName: NSWorkspace.screensDidSleepNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
log("[AllowedWorkMonitor] Screen did sleep")
|
||||
self?.screenOn = false
|
||||
self?.evaluateAndNotify()
|
||||
})
|
||||
|
||||
observerTokens.append(nc.addObserver(
|
||||
forName: NSWorkspace.screensDidWakeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
log("[AllowedWorkMonitor] Screen did wake")
|
||||
self?.screenOn = true
|
||||
self?.evaluateAndNotify()
|
||||
})
|
||||
|
||||
observerTokens.append(nc.addObserver(
|
||||
forName: NSWorkspace.sessionDidResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
log("[AllowedWorkMonitor] Session did resign active")
|
||||
self?.sessionActive = false
|
||||
self?.evaluateAndNotify()
|
||||
})
|
||||
|
||||
observerTokens.append(nc.addObserver(
|
||||
forName: NSWorkspace.sessionDidBecomeActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
log("[AllowedWorkMonitor] Session did become active")
|
||||
self?.sessionActive = true
|
||||
self?.evaluateAndNotify()
|
||||
})
|
||||
|
||||
// Initial fire to establish state
|
||||
evaluateAndNotify(forceNotify: true)
|
||||
}
|
||||
|
||||
|
||||
func stop() {
|
||||
let nc = NSWorkspace.shared.notificationCenter
|
||||
observerTokens.forEach { nc.removeObserver($0) }
|
||||
observerTokens.removeAll()
|
||||
lastAllowed = nil
|
||||
}
|
||||
|
||||
|
||||
deinit { stop() }
|
||||
|
||||
|
||||
private func computeAllowed() -> Bool {
|
||||
return screenOn && sessionActive
|
||||
}
|
||||
|
||||
|
||||
private func evaluateAndNotify(forceNotify: Bool = false) {
|
||||
let allowed = computeAllowed()
|
||||
|
||||
if forceNotify || lastAllowed == nil {
|
||||
lastAllowed = allowed
|
||||
allowed ? onAllowed?() : onStopped?()
|
||||
return
|
||||
}
|
||||
|
||||
guard allowed != lastAllowed else { return }
|
||||
lastAllowed = allowed
|
||||
allowed ? onAllowed?() : onStopped?()
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import LUI
|
||||
|
||||
class AudioManager {
|
||||
static private var incomingFileSoundPlayer: AVAudioPlayer?
|
||||
|
||||
@@ -10,12 +10,11 @@ import Network
|
||||
import StoreKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import LUI
|
||||
|
||||
#if os(macOS)
|
||||
import BezelNotification
|
||||
import Cocoa
|
||||
#else
|
||||
import LUI
|
||||
#endif
|
||||
|
||||
class ErrorAlertHandler {
|
||||
@@ -37,7 +36,6 @@ class ErrorAlertHandler {
|
||||
|
||||
var description = ""
|
||||
let fixInstructions = " " + "Error.FixInstructions".localized()
|
||||
|
||||
if let ne = (error as? NearbyError) {
|
||||
switch ne {
|
||||
case .inputOutput:
|
||||
@@ -62,6 +60,8 @@ class ErrorAlertHandler {
|
||||
description = errorMessage + fixInstructions
|
||||
case .ukey2:
|
||||
description = "Error.Crypto".localized() + ": \(ne.localizedDescription)" + fixInstructions
|
||||
case .notificationSyncNotTrusted:
|
||||
description = "NotificationSyncNotTrustedMessage".localized()
|
||||
case .canceled(reason: let reason):
|
||||
if reason == .timedOut {
|
||||
description = reason.localizedDescription() + fixInstructions
|
||||
@@ -84,7 +84,7 @@ class ErrorAlertHandler {
|
||||
|
||||
let title = String(format: "TransferError".localized(), arguments: [deviceName])
|
||||
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)
|
||||
|
||||
@@ -103,8 +103,13 @@ class ErrorAlertHandler {
|
||||
self.isAlertShown = false
|
||||
|
||||
if result == .alertFirstButtonReturn {
|
||||
//log("Sending logging string")
|
||||
//sendLoggingString()
|
||||
if Bundle.main.bundlePath.hasSuffix(".appex") {
|
||||
if let url = URL(string: "quickdrop://sendLog") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} else {
|
||||
LogExportPresenter.showUploadLogsAlert(openSupportMailAfterUpload: false)
|
||||
}
|
||||
}
|
||||
#else
|
||||
showAlert(title: title, message: description)
|
||||
@@ -162,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 {
|
||||
case ApIsolation
|
||||
case NetworkFilter
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import SwiftUI
|
||||
import ServiceManagement
|
||||
import os.log
|
||||
import LUI
|
||||
|
||||
@available(macOS 13.0, *)
|
||||
public enum LaunchAtLogin {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import LUI
|
||||
|
||||
/// Returns the name of the currently active network interface (e.g. "en0", "en1")
|
||||
func getActiveNetworkInterface() -> String? {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import LUI
|
||||
|
||||
class IPv6DeviceScanner {
|
||||
/// Scans for IPv6 devices using `ping6 ff02::1` multicast on the given interface.
|
||||
|
||||
@@ -7,45 +7,147 @@
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import LUI
|
||||
|
||||
final class DeviceToDeviceHeuristicScanner {
|
||||
static let shared = DeviceToDeviceHeuristicScanner()
|
||||
private init() {}
|
||||
|
||||
private enum ScanMode {
|
||||
case peerReachability // true only if some peer is reachable
|
||||
case localNetworkAccess // true if EHOSTDOWN seen (or ready)
|
||||
}
|
||||
|
||||
class DeviceToDeviceHeuristicScanner {
|
||||
private let scanQueue = DispatchQueue(label: "DeviceToDeviceScanQueue")
|
||||
private var reachableIPs = [String]()
|
||||
private var totalToScan = 0
|
||||
|
||||
private var reachableIPs: [String] = []
|
||||
private var activeConnections: [NWConnection] = []
|
||||
|
||||
private var completion: ((Bool) -> Void)?
|
||||
|
||||
private var finished = false
|
||||
private var currentMode: ScanMode = .peerReachability
|
||||
|
||||
// Cache: once true, stays true for subsequent calls
|
||||
private var cachedLocalNetworkAccess = false
|
||||
|
||||
private var peerReachableCached: Bool { !reachableIPs.isEmpty }
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Start scanning the subnet for reachable peers.
|
||||
/// - Parameters:
|
||||
/// - baseSubnet: e.g. `"192.168.1"` — if nil, will auto-detect
|
||||
/// - completion: true if any peer device was reachable
|
||||
func scan(subnet baseSubnet: String? = nil, port: UInt16 = 80, completion: @escaping (Bool) -> Void) {
|
||||
self.completion = completion
|
||||
let subnet = baseSubnet ?? Self.getLocalSubnetPrefix() ?? "192.168.1"
|
||||
let ipsToScan = (2...254).map { "\(subnet).\($0)" }
|
||||
totalToScan = ipsToScan.count
|
||||
|
||||
for ip in ipsToScan {
|
||||
testConnection(to: ip, port: port)
|
||||
/// - Returns: true if any peer device was reachable (same semantics as before).
|
||||
func scan(subnet baseSubnet: String? = nil,
|
||||
port: UInt16 = 80,
|
||||
timeout: TimeInterval = 10.0,
|
||||
completion: @escaping (Bool) -> Void) {
|
||||
|
||||
if peerReachableCached {
|
||||
completion(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Timeout fallback
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
|
||||
self.finish()
|
||||
startScan(
|
||||
mode: .peerReachability,
|
||||
subnet: baseSubnet,
|
||||
port: port,
|
||||
timeout: timeout,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns true if:
|
||||
/// - cached value is true, OR
|
||||
/// - during a scan at least one host produces `.waiting(.posix(.EHOSTDOWN))`.
|
||||
///
|
||||
/// As soon as EHOSTDOWN is observed, returns true immediately.
|
||||
/// Otherwise waits until timeout (default 10s) and returns false.
|
||||
func hasLocalNetworkAccess(subnet baseSubnet: String? = nil,
|
||||
port: UInt16 = 80,
|
||||
timeout: TimeInterval = 10.0,
|
||||
completion: @escaping (Bool) -> Void) -> Bool {
|
||||
|
||||
if cachedLocalNetworkAccess {
|
||||
return true
|
||||
}
|
||||
|
||||
startScan(
|
||||
mode: .localNetworkAccess,
|
||||
subnet: baseSubnet,
|
||||
port: port,
|
||||
timeout: timeout,
|
||||
completion: completion
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Core scan logic
|
||||
|
||||
private func startScan(mode: ScanMode,
|
||||
subnet baseSubnet: String?,
|
||||
port: UInt16,
|
||||
timeout: TimeInterval,
|
||||
completion: @escaping (Bool) -> Void) {
|
||||
|
||||
scanQueue.async {
|
||||
// Cancel any in-flight scan to keep behavior deterministic.
|
||||
self.cancelActiveConnections()
|
||||
|
||||
self.currentMode = mode
|
||||
self.completion = completion
|
||||
self.finished = false
|
||||
|
||||
let subnet = baseSubnet ?? Self.getLocalSubnetPrefix() ?? "192.168.1"
|
||||
let ipsToScan = (2...254).map { "\(subnet).\($0)" }
|
||||
|
||||
for ip in ipsToScan {
|
||||
self.testConnection(to: ip, port: port)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
|
||||
self.finish(result: self.resultForCurrentModeOnTimeout())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func testConnection(to ip: String, port: UInt16) {
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||
let connection = NWConnection(host: NWEndpoint.Host(ip), port: nwPort, using: .tcp)
|
||||
|
||||
connection.stateUpdateHandler = { state in
|
||||
let connection = NWConnection(host: NWEndpoint.Host(ip), port: nwPort, using: .tcp)
|
||||
activeConnections.append(connection)
|
||||
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
|
||||
switch state {
|
||||
case .ready:
|
||||
self.reachableIPs.append(ip)
|
||||
self.finish()
|
||||
self.scanQueue.async {
|
||||
self.reachableIPs.append(ip)
|
||||
// If a TCP connection succeeds, local network access is effectively confirmed as well.
|
||||
self.cachedLocalNetworkAccess = true
|
||||
|
||||
// For scan(): success means peer reachable.
|
||||
// For hasLocalNetworkAccess(): success also means local network access.
|
||||
self.finish(result: true)
|
||||
}
|
||||
connection.cancel()
|
||||
|
||||
case .waiting(let error):
|
||||
// "Host is down" is treated as a strong signal that local-network traffic is possible,
|
||||
// even if the specific host doesn't respond.
|
||||
if error == .posix(.EHOSTDOWN) {
|
||||
self.scanQueue.async {
|
||||
self.cachedLocalNetworkAccess = true
|
||||
|
||||
if self.currentMode == .localNetworkAccess {
|
||||
self.finish(result: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .failed, .cancelled:
|
||||
connection.cancel()
|
||||
// Ignore; scan completes via early success or timeout.
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -54,11 +156,38 @@ class DeviceToDeviceHeuristicScanner {
|
||||
connection.start(queue: scanQueue)
|
||||
}
|
||||
|
||||
private func finish() {
|
||||
completion?(reachableIPs.count > 0)
|
||||
completion = nil
|
||||
private func resultForCurrentModeOnTimeout() -> Bool {
|
||||
switch currentMode {
|
||||
case .peerReachability:
|
||||
return peerReachableCached
|
||||
case .localNetworkAccess:
|
||||
return cachedLocalNetworkAccess
|
||||
}
|
||||
}
|
||||
|
||||
private func finish(result: Bool) {
|
||||
scanQueue.async {
|
||||
guard !self.finished else { return }
|
||||
self.finished = true
|
||||
|
||||
let completion = self.completion
|
||||
self.completion = nil
|
||||
|
||||
self.cancelActiveConnections()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
completion?(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelActiveConnections() {
|
||||
for c in activeConnections { c.cancel() }
|
||||
activeConnections.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Subnet detection
|
||||
|
||||
/// Attempts to detect the local subnet prefix (e.g., "192.168.0")
|
||||
private static func getLocalSubnetPrefix() -> String? {
|
||||
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
||||
@@ -68,20 +197,27 @@ class DeviceToDeviceHeuristicScanner {
|
||||
defer { ptr = ptr!.pointee.ifa_next }
|
||||
let interface = ptr!.pointee
|
||||
let addrFamily = interface.ifa_addr.pointee.sa_family
|
||||
|
||||
if addrFamily == UInt8(AF_INET),
|
||||
let name = String(validatingUTF8: interface.ifa_name),
|
||||
name == getActiveNetworkInterface() {
|
||||
name == getActiveNetworkInterface() {
|
||||
|
||||
var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len),
|
||||
&hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST)
|
||||
getnameinfo(interface.ifa_addr,
|
||||
socklen_t(interface.ifa_addr.pointee.sa_len),
|
||||
&hostname,
|
||||
socklen_t(hostname.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
|
||||
if let ip = String(validatingUTF8: hostname),
|
||||
let lastDot = ip.lastIndex(of: ".") {
|
||||
|
||||
freeifaddrs(ifaddr)
|
||||
|
||||
|
||||
let result = String(ip[..<lastDot])
|
||||
|
||||
log("[LUI] Found local subnet prefix: \(result) for interface \(name)")
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ func sendToSharingService(items: [Any]) {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
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.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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
//
|
||||
// IntroductionView.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 20.12.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import LUI
|
||||
|
||||
struct IntroductionView: View {
|
||||
|
||||
static let width: CGFloat = 810
|
||||
static let height: CGFloat = 540
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var currentPage: IntroductionPage = .splash
|
||||
|
||||
@State private var pollingPage: IntroductionPage? = nil
|
||||
@State private var skipAction: IntroductionPage.SkipAction? = nil
|
||||
|
||||
@State private var helpActionAlert: IntroductionPage.HelpAction? = nil
|
||||
|
||||
let startReceiving: (() -> Void)
|
||||
let onFinish: (() -> Void)
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
ZStack {
|
||||
if colorScheme == .dark {
|
||||
Color.black.opacity(0.2)
|
||||
} else {
|
||||
Color.white
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack {
|
||||
let topHeight = geo.size.height * 0.35
|
||||
|
||||
ZStack {
|
||||
Color.gray.opacity(colorScheme == .light ? 0.1 : 0.12)
|
||||
currentPage.topHeader(with: topHeight)
|
||||
}
|
||||
.edgesIgnoringSafeArea(.top)
|
||||
.frame(height: topHeight)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack {
|
||||
Text(currentPage.title.localized())
|
||||
.font(.system(size: 32))
|
||||
.fontWeight(.semibold)
|
||||
.multilineTextAlignment(.center)
|
||||
.minimumScaleFactor(0.5)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(currentPage.subtitle.localized())
|
||||
.font(.system(size: 15))
|
||||
.lineSpacing(5)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top)
|
||||
.minimumScaleFactor(0.5)
|
||||
.opacity(0.8)
|
||||
|
||||
if currentPage == .enableShareExtension {
|
||||
EnableExtensionView(canShowIntialCheckmark: true)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 40)
|
||||
|
||||
Spacer()
|
||||
|
||||
if currentPage == .splash {
|
||||
BottomFootnoteView(type: .privacyBanner)
|
||||
}
|
||||
else {
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
if let skipAction = currentPage.skipAction, pollingPage != nil {
|
||||
|
||||
Button("introduction_skip") {
|
||||
self.skipAction = skipAction
|
||||
}
|
||||
.foregroundColor(.gray)
|
||||
.buttonStyle(.borderless)
|
||||
.alert(item: $skipAction) { action in
|
||||
|
||||
.init(title: Text(action.warningTitle.localized()), message: Text(action.warningMessage.localized()), primaryButton: .default(Text("introduction_continue".localized()), action: {
|
||||
|
||||
stopPolling()
|
||||
self.skipAction = nil
|
||||
|
||||
if let nextPage = action.action() {
|
||||
currentPage = nextPage
|
||||
}
|
||||
else {
|
||||
onFinish()
|
||||
}
|
||||
}), secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
continueArea
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { pageDidChange(to: currentPage) }
|
||||
.onChange(of: currentPage) { newValue in
|
||||
pageDidChange(to: newValue)
|
||||
}
|
||||
.task(id: pollingPage) {
|
||||
guard let page = pollingPage else { return }
|
||||
|
||||
// Poll every 1 second until condition is satisfied or canceled
|
||||
while !Task.isCancelled {
|
||||
if page.canContinue() {
|
||||
await MainActor.run {
|
||||
// Only advance if we're still on the page we started polling for
|
||||
guard currentPage == page else {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
stopPolling()
|
||||
advance()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var continueArea: some View {
|
||||
ZStack {
|
||||
|
||||
let isLoading = pollingPage != nil
|
||||
|
||||
Button("introduction_continue") {
|
||||
continueTapped()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.opacity(isLoading ? 0 : 1)
|
||||
.overlay (
|
||||
LoadingIndicator()
|
||||
.frame(width: 20, height: 20)
|
||||
.opacity(isLoading ? 1 : 0)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
private func pageDidChange(to newPage: IntroductionPage) {
|
||||
stopPolling()
|
||||
|
||||
// Cannot perform action, default to loading screen
|
||||
if !newPage.canPerformAction {
|
||||
pollingPage = newPage
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func continueTapped() {
|
||||
// If already allowed, advance immediately
|
||||
if currentPage.canContinue() {
|
||||
advance()
|
||||
return
|
||||
}
|
||||
|
||||
// Not allowed yet: show loading and start polling every 1s
|
||||
pollingPage = currentPage
|
||||
}
|
||||
|
||||
|
||||
private func triggerLocalNetworkPermission() {
|
||||
self.startReceiving()
|
||||
}
|
||||
|
||||
|
||||
private func advance() {
|
||||
|
||||
if currentPage == .localNetworkAccess {
|
||||
let _ = DeviceToDeviceHeuristicScanner.shared.hasLocalNetworkAccess(completion: {_ in })
|
||||
triggerLocalNetworkPermission()
|
||||
}
|
||||
|
||||
if let nextPage = currentPage.presentNextPage() {
|
||||
currentPage = nextPage
|
||||
} else {
|
||||
onFinish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func stopPolling() {
|
||||
// Cancels the .task(id:) by changing the id and resets UI state
|
||||
pollingPage = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
enum IntroductionPage: CaseIterable {
|
||||
case splash
|
||||
case noWifi
|
||||
case localNetworkAccess
|
||||
case enableShareExtension
|
||||
case finished
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .splash:
|
||||
return "WelcomeToQuickDrop"
|
||||
case .noWifi:
|
||||
return "introduction_no_wifi"
|
||||
case .localNetworkAccess:
|
||||
return "introduction_local_network_access"
|
||||
case .enableShareExtension:
|
||||
return "introduction_enable_share_extension"
|
||||
case .finished:
|
||||
return "introduction_finished"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .splash:
|
||||
return "QuickDropWelcomeDescription"
|
||||
case .noWifi:
|
||||
return "introduction_no_wifi_description"
|
||||
case .localNetworkAccess:
|
||||
return "introduction_local_network_access_description".localized(with: "introduction_continue".localized())
|
||||
case .enableShareExtension:
|
||||
if #available(macOS 13.0, *) {
|
||||
return "introduction_enable_share_extension_description".localized(with: "EnableQuickDropExtension".localized())
|
||||
}
|
||||
else {
|
||||
return "introduction_enable_share_extension_description_legacy".localized(with: "EnableQuickDropExtension".localized())
|
||||
}
|
||||
case .finished:
|
||||
return "introduction_finished_description"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var skipAction: SkipAction? {
|
||||
switch self {
|
||||
case .noWifi:
|
||||
return .init(action: presentNextPage, warningTitle: "introduction_no_wifi_skip_title", warningMessage: "introduction_no_wifi_skip_message")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var canPerformAction: Bool {
|
||||
switch self {
|
||||
case .noWifi:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func presentNextPage() -> IntroductionPage? {
|
||||
switch self {
|
||||
case .splash:
|
||||
return NearbyConnectionManager.shared.isConnectedToLocalNetwork ? firstPermissionPane() : .noWifi
|
||||
case .noWifi:
|
||||
return firstPermissionPane()
|
||||
case .localNetworkAccess:
|
||||
return .enableShareExtension
|
||||
case .enableShareExtension:
|
||||
return .finished
|
||||
case .finished:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func firstPermissionPane() -> IntroductionPage {
|
||||
if #available(macOS 15.0, *) {
|
||||
// Local network access is requested first on macOS 15+
|
||||
return .localNetworkAccess
|
||||
}
|
||||
return .enableShareExtension
|
||||
}
|
||||
|
||||
|
||||
func canContinue() -> Bool {
|
||||
switch self {
|
||||
case .noWifi:
|
||||
return NearbyConnectionManager.shared.isConnectedToLocalNetwork
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func topHeader(with height: CGFloat) -> some View {
|
||||
ZStack {
|
||||
|
||||
switch self {
|
||||
case .splash:
|
||||
Image(.quickDropMockup)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.padding(.top, height * 0.33)
|
||||
.padding(.bottom, height * 0.1)
|
||||
case .noWifi:
|
||||
imageView(for: "wifi.slash", with: height)
|
||||
case .localNetworkAccess:
|
||||
imageView(for: "network", with: height)
|
||||
case .enableShareExtension:
|
||||
imageView(for: "square.and.arrow.up", with: height)
|
||||
case .finished:
|
||||
imageView(for: "checkmark.seal.fill", with: height)
|
||||
}
|
||||
|
||||
}
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
|
||||
|
||||
private func imageView(for image: String, with height: CGFloat) -> some View {
|
||||
Image(systemName: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: height * 0.5)
|
||||
.padding(.top, height * 0.13)
|
||||
}
|
||||
|
||||
|
||||
struct SkipAction: Identifiable {
|
||||
let action: () -> IntroductionPage?
|
||||
let warningTitle: String
|
||||
let warningMessage: String
|
||||
|
||||
var id: String { warningTitle }
|
||||
}
|
||||
|
||||
|
||||
struct HelpAction: Identifiable {
|
||||
let question: String
|
||||
let answer: String
|
||||
|
||||
var id: String { question }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
IntroductionView(startReceiving: {}){}
|
||||
.frame(width: IntroductionView.width, height: IntroductionView.height)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import SwiftUI
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Network
|
||||
import LUI
|
||||
|
||||
fileprivate let routerCheckQueue = DispatchQueue(label: "routerCheckQueue")
|
||||
|
||||
@@ -17,7 +18,7 @@ struct ApIsolationIssueView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
let closeView : () -> Void
|
||||
|
||||
let scanner = DeviceToDeviceHeuristicScanner()
|
||||
let scanner = DeviceToDeviceHeuristicScanner.shared
|
||||
|
||||
var body: some View {
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,147 +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
|
||||
public var onCancel: () -> Void = {}
|
||||
|
||||
init(receiveModel: ReceiveModel,
|
||||
onCancel: @escaping () -> Void = {}) {
|
||||
self.receiveModel = receiveModel
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
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("Receiving")
|
||||
.font(.system(size: 13))
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
}
|
||||
|
||||
HStack() {
|
||||
|
||||
CapsuleProgress(value: receiveModel.progress ?? 1.0)
|
||||
|
||||
Button(action: onCancel) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.opacity(isFileTransferRestricted() ? 0 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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() // Use a plain rectangle
|
||||
.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 })
|
||||
|
||||
var body: some View {
|
||||
QuickDropToastView(
|
||||
receiveModel: model,
|
||||
onCancel: { }
|
||||
)
|
||||
.padding()
|
||||
.frame(width: toastViewSize.width, height: toastViewSize.height)
|
||||
.onAppear {
|
||||
model.progress = 0.01
|
||||
}
|
||||
}
|
||||
}
|
||||
static var previews: some View { Demo() }
|
||||
}
|
||||
@@ -7,10 +7,14 @@
|
||||
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import LUI
|
||||
|
||||
struct SettingsView: View {
|
||||
|
||||
@ObservedObject var settings = Settings.shared
|
||||
@ObservedObject var settings = Settings.sharedInstance
|
||||
@ObservedObject var connectionManager = NearbyConnectionManager.shared
|
||||
|
||||
@State private var isChangeDeviceNameAlertPresented = false
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -31,12 +35,23 @@ struct SettingsView: View {
|
||||
Text("SaveFilesTo")
|
||||
Spacer()
|
||||
Text(getSavedFolderPath() ?? "DownloadsFolder".localized())
|
||||
.foregroundColor((settings.saveFolderBookmark ?? Data()).isEmpty ? .gray : .primary)
|
||||
.foregroundColor(.gray)
|
||||
Button("SaveFilesToButton") {
|
||||
selectFolder()
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("DeviceName")
|
||||
Spacer()
|
||||
Text(connectionManager.deviceInfo.name ?? "Unknown".localized())
|
||||
.foregroundColor(.gray)
|
||||
|
||||
Button("Edit") {
|
||||
presentRenameAlert()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 10)
|
||||
|
||||
@@ -100,8 +115,8 @@ struct SettingsView: View {
|
||||
|
||||
let bookmarkData = try url.bookmarkData(options: .withSecurityScope, includingResourceValuesForKeys: nil, relativeTo: nil)
|
||||
|
||||
Settings.shared.saveFolderBookmark = bookmarkData
|
||||
Settings.shared.openFinderAfterReceiving = true
|
||||
Settings.sharedInstance.saveFolderBookmark = bookmarkData
|
||||
Settings.sharedInstance.openFinderAfterReceiving = true
|
||||
} catch {
|
||||
log("Failed to save security-scoped bookmark: \(error)")
|
||||
|
||||
@@ -133,12 +148,47 @@ struct SettingsView: View {
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
window.title = "TrustedDevices".localized()
|
||||
window.backgroundColor = NSColor(Color.defaultMacBackgroundColor)
|
||||
window.contentView = NSHostingView(rootView: TrustedDevicesView())
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
//
|
||||
// TutorialView.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 05.03.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TutorialView: View {
|
||||
|
||||
let title: String
|
||||
let text: String
|
||||
let showsLicense: Bool
|
||||
let openPlus: () -> Void
|
||||
|
||||
@State private var licenseWindow: NSWindow?
|
||||
@ObservedObject var settings = Settings.shared
|
||||
|
||||
var body: some View {
|
||||
|
||||
LargeAppIconView(title: title) {
|
||||
VStack {
|
||||
Text(text.localized())
|
||||
.multilineTextAlignment(.leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding()
|
||||
.frame(width: 550)
|
||||
|
||||
if showsLicense {
|
||||
HStack(spacing: 30) {
|
||||
|
||||
Button {
|
||||
licenseWindow = openLicenseWindow()
|
||||
} label: {
|
||||
Text("Acknowledgements")
|
||||
.underline()
|
||||
.font(.footnote)
|
||||
.opacity(0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if !settings.gotPlus {
|
||||
Button {
|
||||
openPlus()
|
||||
} label: {
|
||||
Text("SupportQuickDrop")
|
||||
.underline()
|
||||
.font(.footnote)
|
||||
.opacity(0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Button {
|
||||
Settings.shared.deleteAllUserDefaults()
|
||||
} label: {
|
||||
Text("ResetAllSettings")
|
||||
.underline()
|
||||
.font(.footnote)
|
||||
.opacity(0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct LargeAppIconView<Content: View>: View {
|
||||
|
||||
let title: String
|
||||
let bottomView: () -> Content
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
|
||||
VStack {
|
||||
Image("AppIconHighRes")
|
||||
.resizable()
|
||||
.frame(width: 150, height: 150)
|
||||
.padding(.top, 40)
|
||||
|
||||
Text(title.localized())
|
||||
.font(.largeTitle)
|
||||
.padding()
|
||||
|
||||
bottomView()
|
||||
}
|
||||
.frame(maxWidth: 500)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {})
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
struct LicensePage: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -0,0 +1,192 @@
|
||||
//
|
||||
// TutorialView.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 05.03.25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import LUI
|
||||
import SwiftUI
|
||||
|
||||
struct TutorialView: View {
|
||||
|
||||
let tab: Tab
|
||||
let openPlus: () -> Void
|
||||
|
||||
@State private var licenseWindow: NSWindow?
|
||||
@ObservedObject var iapManager = IAPManager.sharedInstance
|
||||
|
||||
var body: some View {
|
||||
|
||||
LargeAppIconView(title: tab.title) {
|
||||
VStack {
|
||||
Group {
|
||||
if tab == .troubleshooting {
|
||||
FAQView(faqItems: [
|
||||
.init(question: "FaqNotVisibleOrConnectingQuestion", answer: "FaqNotVisibleOrConnectingAnswer"),
|
||||
.init(question: "FaqPhotoDateNotPreservedQuestion", answer: "FaqPhotoDateNotPreservedAnswer"),
|
||||
.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 {
|
||||
HStack(spacing: 30) {
|
||||
|
||||
Button {
|
||||
licenseWindow = openLicenseWindow()
|
||||
} label: {
|
||||
Text("Acknowledgements")
|
||||
.underline()
|
||||
.font(.footnote)
|
||||
.opacity(0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
#if !GITHUB
|
||||
if !iapManager.plusVersionState {
|
||||
Button {
|
||||
openPlus()
|
||||
} label: {
|
||||
Text("SupportQuickDrop")
|
||||
.underline()
|
||||
.font(.footnote)
|
||||
.opacity(0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
Button {
|
||||
IAPManager.sharedInstance.plusVersionState = false
|
||||
Settings.sharedInstance.deleteAllUserDefaults()
|
||||
} label: {
|
||||
Text("ResetAllSettings")
|
||||
.underline()
|
||||
.font(.footnote)
|
||||
.opacity(0.5)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
else if tab == .send {
|
||||
EnableExtensionView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct EnableExtensionView: View {
|
||||
@State private var showSharePicker = false
|
||||
@State private var shareItems: [Any] = []
|
||||
|
||||
@State private var isExtensionEnabled = false
|
||||
@State private var showSuccessCheckmark = false
|
||||
@State private var animateCheckmark = false
|
||||
|
||||
private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
init(canShowIntialCheckmark: Bool = false) {
|
||||
let isEnabled = Self.isEnabled()
|
||||
|
||||
_isExtensionEnabled = State(initialValue: isEnabled)
|
||||
|
||||
if canShowIntialCheckmark && isEnabled {
|
||||
_showSuccessCheckmark = State(initialValue: true)
|
||||
_animateCheckmark = State(initialValue: true)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if !isExtensionEnabled {
|
||||
Button("EnableQuickDropExtension".localized()) {
|
||||
shareItems = [
|
||||
"EnableQuickDropExtensionDescription".localized()
|
||||
]
|
||||
showSharePicker = true
|
||||
}
|
||||
.background(
|
||||
SharingPickerPresenter(
|
||||
isPresented: $showSharePicker,
|
||||
sharingItems: shareItems
|
||||
)
|
||||
)
|
||||
.padding()
|
||||
.onReceive(timer) { _ in
|
||||
updateEnabledState()
|
||||
}
|
||||
}
|
||||
else if showSuccessCheckmark {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(.green)
|
||||
.background(Circle().foregroundColor(.white).frame(width: 18, height: 18))
|
||||
.scaleEffect(animateCheckmark ? 1.0 : 0.5)
|
||||
.animation(.easeOut(duration: 0.4), value: animateCheckmark)
|
||||
.onAppear {
|
||||
animateCheckmark = true
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateEnabledState() {
|
||||
let enabled = Self.isEnabled()
|
||||
if enabled != isExtensionEnabled {
|
||||
withAnimation(.smooth) {
|
||||
isExtensionEnabled = enabled
|
||||
|
||||
if enabled {
|
||||
showSuccessCheckmark = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func isEnabled() -> Bool {
|
||||
NSSharingService(
|
||||
named: NSSharingService.Name("com.leonboettger.neardrop.ShareExtension")
|
||||
) != nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private struct SharingPickerPresenter: NSViewRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
let sharingItems: [Any]
|
||||
|
||||
func makeNSView(context: Context) -> NSView { NSView() }
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
guard isPresented else { return }
|
||||
|
||||
let picker = NSSharingServicePicker(items: sharingItems)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
picker.show(relativeTo: nsView.bounds, of: nsView, preferredEdge: .minY)
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
WelcomeScreen(openPlusScreen: {}, openAppAdvertisementView: {}, openCableTransmissionView: {}, checkForNetworkIssues: {})
|
||||
}
|
||||
+55
-30
@@ -7,9 +7,13 @@
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import LUI
|
||||
|
||||
struct WelcomeScreen: View {
|
||||
|
||||
static let width: CGFloat = 1000
|
||||
static let height: CGFloat = 600
|
||||
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
let openPlusScreen: () -> Void
|
||||
@@ -17,14 +21,22 @@ struct WelcomeScreen: View {
|
||||
let openCableTransmissionView: () -> Void
|
||||
let checkForNetworkIssues: () -> Void
|
||||
|
||||
@State private var selection: Tabs? = Tabs.receive
|
||||
@State private var selection: Tab = Tab.receive
|
||||
|
||||
var body: some View {
|
||||
|
||||
HStack(spacing: 0) {
|
||||
List(selection: $selection) {
|
||||
ForEach(Tabs.allCases.filter({$0 != .settings }), id: \.self) { tab in
|
||||
Label(tab.title, systemImage: tab.systemImage)
|
||||
|
||||
let listBinding = Binding<Tab?>(
|
||||
get: { selection },
|
||||
set: { newValue in
|
||||
selection = newValue ?? .receive
|
||||
}
|
||||
)
|
||||
|
||||
List(selection: listBinding) {
|
||||
ForEach(Tab.allCases.filter({$0 != .settings }), id: \.self) { tab in
|
||||
Label(tab.sidebarTitle, systemImage: tab.systemImage)
|
||||
.tag(tab)
|
||||
.frame(height: 30)
|
||||
}
|
||||
@@ -32,7 +44,7 @@ struct WelcomeScreen: View {
|
||||
Divider()
|
||||
|
||||
ExternalLinkLabel(label: "GetSupport", icon: "questionmark.circle") {
|
||||
sendLoggingString()
|
||||
SupportMail.sendSupportMail()
|
||||
}
|
||||
|
||||
ExternalLinkLabel(label: "PrivacyPolicy", icon: "hand.raised") {
|
||||
@@ -49,8 +61,8 @@ struct WelcomeScreen: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Label(Tabs.settings.title, systemImage: Tabs.settings.systemImage)
|
||||
.tag(Tabs.settings)
|
||||
Label(Tab.settings.sidebarTitle, systemImage: Tab.settings.systemImage)
|
||||
.tag(Tab.settings)
|
||||
.frame(height: 30)
|
||||
}
|
||||
.minimumScaleFactor(0.5)
|
||||
@@ -58,34 +70,29 @@ struct WelcomeScreen: View {
|
||||
.listStyle(SidebarListStyle())
|
||||
|
||||
Divider()
|
||||
.opacity(colorScheme == .light ? 1 : 0.5)
|
||||
.edgesIgnoringSafeArea(.vertical)
|
||||
|
||||
ZStack {
|
||||
Color.defaultBackground.edgesIgnoringSafeArea(.vertical)
|
||||
|
||||
switch selection {
|
||||
case .receive:
|
||||
TutorialView(title: "WelcomeToQuickDrop", text: "UserManualDescription", showsLicense: true, openPlus: openPlusScreen)
|
||||
|
||||
case .send:
|
||||
TutorialView(title: "SendFiles", text: "SendFilesDescription", showsLicense: false, openPlus: openPlusScreen)
|
||||
|
||||
case .troubleshooting:
|
||||
TutorialView(title: "Troubleshooting", text: "TroubleshootingDescription", showsLicense: false, openPlus: openPlusScreen)
|
||||
.onAppear {
|
||||
checkForNetworkIssues()
|
||||
}
|
||||
default:
|
||||
SettingsView()
|
||||
}
|
||||
|
||||
if selection == .settings {
|
||||
SettingsView()
|
||||
}
|
||||
else {
|
||||
TutorialView(tab: selection, openPlus: openPlusScreen)
|
||||
.onAppear {
|
||||
if selection == .troubleshooting {
|
||||
checkForNetworkIssues()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 1000, height: 600)
|
||||
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
||||
handleDrop(providers: providers)
|
||||
return true
|
||||
}
|
||||
.frame(width: Self.width, height: Self.height)
|
||||
}
|
||||
|
||||
func openPrivacyPolicy() {
|
||||
@@ -112,7 +119,7 @@ struct WelcomeScreen: View {
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var urls: [URL] = []
|
||||
let urlQueue = DispatchQueue(label: "DroppedURLsQueue")
|
||||
|
||||
|
||||
for provider in providers {
|
||||
if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {
|
||||
dispatchGroup.enter()
|
||||
@@ -131,7 +138,7 @@ struct WelcomeScreen: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
if !urls.isEmpty {
|
||||
sendToSharingService(items: urls)
|
||||
@@ -167,25 +174,43 @@ struct ExternalLinkLabel: View {
|
||||
}
|
||||
|
||||
|
||||
enum Tabs: CaseIterable {
|
||||
enum Tab: CaseIterable {
|
||||
case receive
|
||||
case send
|
||||
case troubleshooting
|
||||
case settings
|
||||
|
||||
var title: String {
|
||||
var sidebarTitle: String {
|
||||
switch self {
|
||||
case .receive:
|
||||
return "ReceiveFiles".localized()
|
||||
case .send:
|
||||
return "SendFiles".localized()
|
||||
case .troubleshooting:
|
||||
return "DeviceNotShown".localized()
|
||||
return "TroubleshootingAndFaq".localized()
|
||||
case .settings:
|
||||
return "Settings".localized()
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
default:
|
||||
return sidebarTitle
|
||||
}
|
||||
}
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .receive:
|
||||
"UserManualDescription"
|
||||
case .send:
|
||||
"SendFilesDescription"
|
||||
default:
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .receive:
|
||||
@@ -7,12 +7,9 @@
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
import UserNotifications
|
||||
|
||||
#if os(macOS)
|
||||
import BezelNotification
|
||||
#else
|
||||
import LUI
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
@@ -20,24 +17,51 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
/// For each connection ID, store the last reported progress value
|
||||
private var processes: [String: Double] = [:]
|
||||
|
||||
#if os(macOS)
|
||||
#if os(macOS)
|
||||
@Published var progress: Double? = 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 toastWindowPending = false
|
||||
private var toastWindow: NSWindow?
|
||||
private var toastHosting: NSHostingView<QuickDropToastView>?
|
||||
#endif
|
||||
private var toastHosting: NSHostingView<QuickDropToastHostView>?
|
||||
private var toastRevealTask: DispatchWorkItem?
|
||||
private var toastCleanupTask: DispatchWorkItem?
|
||||
private let monitor = AllowedWorkMonitor()
|
||||
#endif
|
||||
|
||||
let controlPlusScreen: (Bool) -> Void
|
||||
|
||||
|
||||
init(controlPlusScreen: @escaping (Bool) -> Void) {
|
||||
self.controlPlusScreen = controlPlusScreen
|
||||
|
||||
NearbyConnectionManager.shared.addInboundAppDelegate(self)
|
||||
|
||||
#if os(macOS)
|
||||
self.monitor.onAllowed = {
|
||||
NearbyConnectionManager.shared.becomeVisible()
|
||||
}
|
||||
self.monitor.onStopped = {
|
||||
NearbyConnectionManager.shared.becomeInvisible()
|
||||
}
|
||||
|
||||
self.monitor.start()
|
||||
#else
|
||||
NearbyConnectionManager.shared.becomeVisible()
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
deinit {
|
||||
NearbyConnectionManager.shared.removeInboundAppDelegate(self)
|
||||
|
||||
#if os(macOS)
|
||||
self.monitor.stop()
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -48,49 +72,101 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
AudioManager.playIncomingFileSound()
|
||||
#endif
|
||||
|
||||
let mainMessage = transfer.getDescription(deviceName: device.name ?? "Android", alreadyAccepted: false)
|
||||
let mainMessage = transfer.getDescription(deviceName: device.name ?? "AndroidDevice".localized())
|
||||
let pinCodeMessage = transfer.getPinCodeMessage()
|
||||
let transferID = transfer.id
|
||||
|
||||
let primaryButtonAction = { (trustDevice: Bool) in
|
||||
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice)
|
||||
}
|
||||
|
||||
let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: false) }
|
||||
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
if transfer.type == .notificationSync {
|
||||
self.hideQuickDropToast()
|
||||
self.toastActions = nil
|
||||
self.progress = nil
|
||||
self.consentState = nil
|
||||
let senderName = device.name?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedSenderName = (senderName?.isEmpty == false) ? senderName! : "AndroidDevice".localized()
|
||||
self.activeDeviceName = resolvedSenderName
|
||||
|
||||
let compareMessage = "NotificationSyncToastQrSubtitle".localized()
|
||||
self.consentState = ConsentToastState(
|
||||
transferID: transferID,
|
||||
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
|
||||
}
|
||||
|
||||
self.toastActions = nil
|
||||
self.progress = nil
|
||||
self.activeDeviceName = device.name ?? "AndroidDevice".localized()
|
||||
self.consentState = ConsentToastState(
|
||||
transferID: transferID,
|
||||
pinCodeMessage: pinCodeMessage,
|
||||
message: mainMessage,
|
||||
notificationSyncStage: nil,
|
||||
allowsTrust: transfer.allowsToBeAddedAsTrustedDevice,
|
||||
acceptAction: { [weak self] trustDevice in
|
||||
DispatchQueue.main.async {
|
||||
withAnimation {
|
||||
self?.consentState = nil
|
||||
}
|
||||
}
|
||||
primaryButtonAction(trustDevice)
|
||||
},
|
||||
declineAction: { [weak self] in
|
||||
self?.consentState = nil
|
||||
secondaryButtonAction()
|
||||
self?.hideQuickDropToast()
|
||||
}
|
||||
)
|
||||
self.showQuickDropToast(for: transferID)
|
||||
}
|
||||
#else
|
||||
|
||||
let title = "QuickDrop - \(pinCodeMessage)"
|
||||
let primaryButtonTitle = "Accept".localized()
|
||||
let primaryButtonAction = { (trustDevice: Bool) in
|
||||
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) }
|
||||
|
||||
#if os(macOS)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .informational
|
||||
|
||||
alert.messageText = title
|
||||
alert.informativeText = mainMessage
|
||||
alert.addButton(withTitle: primaryButtonTitle)
|
||||
alert.addButton(withTitle: secondaryButtonTitle)
|
||||
|
||||
if transfer.allowsToBeAddedAsTrustedDevice {
|
||||
alert.showsSuppressionButton = true
|
||||
alert.suppressionButton?.title = "AutoAcceptFromThisDevice".localized()
|
||||
}
|
||||
|
||||
let result = alert.runModal()
|
||||
|
||||
if result == .alertFirstButtonReturn {
|
||||
primaryButtonAction(alert.suppressionButton?.state == .on)
|
||||
} else if result == .alertSecondButtonReturn {
|
||||
secondaryButtonAction()
|
||||
}
|
||||
#else
|
||||
// iOS
|
||||
let alwaysAcceptLabel = transfer.allowsToBeAddedAsTrustedDevice ? "AlwaysAccept".localized() : nil
|
||||
ProgressAlert.shared.askForUserPermission(title: title, message: mainMessage, acceptLabel: primaryButtonTitle, acceptAlwaysLabel: alwaysAcceptLabel, rejectLabel: secondaryButtonTitle) { accepted in
|
||||
@@ -110,33 +186,18 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
|
||||
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) {
|
||||
|
||||
let mainMessage = transfer.getDescription(deviceName: device.name ?? "Android", alreadyAccepted: true)
|
||||
let mainMessage = transfer.getDescription(deviceName: device.name ?? "AndroidDevice".localized())
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
AudioManager.playIncomingFileSound()
|
||||
|
||||
// If file -> no notification, as there is the QuickDrop progress toast
|
||||
if transfer.type != .file {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||
if granted {
|
||||
log("[ReceiveModel] User granted notification permissions")
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.toastActions = nil
|
||||
self.progress = nil
|
||||
self.activeDeviceName = device.name ?? "AndroidDevice".localized()
|
||||
self.consentState = nil
|
||||
self.showQuickDropToast(for: transfer.id)
|
||||
}
|
||||
#else
|
||||
// If text is received, nothing is shown, as it is directly inserted into clipboard automatically
|
||||
@@ -157,35 +218,52 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
let totalProgress = processes.values.reduce(0, +)
|
||||
let averageProgress = totalProgress / Double(processes.count)
|
||||
|
||||
#if os(iOS)
|
||||
#if os(iOS)
|
||||
ProgressAlert.shared.updateProgress(averageProgress, onCancel: {
|
||||
NearbyConnectionManager.shared.cancelTransfer(id: connectionID)
|
||||
})
|
||||
}, completion: {})
|
||||
#else
|
||||
DispatchQueue.main.async {
|
||||
self.toastActions = nil
|
||||
self.progress = averageProgress
|
||||
self.showQuickDropToast(for: connectionID)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
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, wasPlainTextTransfer: Bool, error: (any Error)?) {
|
||||
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: (any Error)?) {
|
||||
|
||||
processes.removeValue(forKey: connectionID)
|
||||
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
self.hideQuickDropToast()
|
||||
|
||||
let wasPendingConsent = self.consentState?.transferID == connectionID
|
||||
if self.processes.isEmpty {
|
||||
self.progress = nil
|
||||
}
|
||||
if wasPendingConsent {
|
||||
self.consentState = nil
|
||||
self.toastActions = nil
|
||||
self.hideQuickDropToast()
|
||||
}
|
||||
finishMacTermination(device: device, savedFiles: savedFiles, error: error, wasPendingConsent: wasPendingConsent)
|
||||
}
|
||||
finish()
|
||||
#else
|
||||
if self.processes.isEmpty {
|
||||
ProgressAlert.shared.updateProgress(nil) {
|
||||
ProgressAlert.shared.updateProgress(nil, onCancel: {}) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -193,39 +271,226 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
|
||||
func finish() {
|
||||
if let error = error {
|
||||
|
||||
#if os(iOS)
|
||||
errorVibration()
|
||||
#endif
|
||||
|
||||
controlPlusScreen(false)
|
||||
ErrorAlertHandler.shared.showErrorAlert(for: device.name ?? "Android", error: error)
|
||||
if let name = device?.name {
|
||||
|
||||
#if os(macOS)
|
||||
DispatchQueue.main.async {
|
||||
self.hideQuickDropToast()
|
||||
}
|
||||
#endif
|
||||
|
||||
controlPlusScreen(false)
|
||||
|
||||
errorVibration()
|
||||
ErrorAlertHandler.shared.showErrorAlert(for: name, error: error)
|
||||
}
|
||||
} else {
|
||||
|
||||
|
||||
#if os(iOS)
|
||||
doubleVibration()
|
||||
if !savedFiles.isEmpty {
|
||||
|
||||
let hasPhotoOrVideos = PhotoManager.hasPhotosOrVideos(at: savedFiles)
|
||||
let photoSaveAction: (() -> ())? = hasPhotoOrVideos ? {
|
||||
Task {
|
||||
do {
|
||||
try await PhotoManager.saveMediaToPhotoLibrary(from: savedFiles)
|
||||
}
|
||||
catch {
|
||||
showAlert(title: "CouldNotSaveMediaToPhotoLibrary", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
} : nil
|
||||
|
||||
ProgressAlert.shared.presentIncomingTransferDoneAlert(title: "FileTransferCompleted".localized(), message: "FileTransferCompletedMessage".localized(), onImportToPhotos: photoSaveAction)
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
let currentCount = Settings.shared.incomingTransmissionCount
|
||||
let currentCount = Settings.sharedInstance.incomingTransmissionCount
|
||||
|
||||
#if os(macOS)
|
||||
// If distributed directly, do not request review here as it does not work
|
||||
if currentCount == 0 && !DistributionDetector.isDirectDistributionEnabled {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
SKStoreReviewController.requestReview()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
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?()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
Settings.shared.incomingTransmissionCount = currentCount + 1
|
||||
Settings.sharedInstance.incomingTransmissionCount = currentCount + 1
|
||||
log("[ReceiveModel] Successful transmission. Current count: \(currentCount)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#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) {
|
||||
if pendingReview {
|
||||
// Dont request review again
|
||||
pendingReview = false
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
SKStoreReviewController.requestReview()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
func showPlusScreen() {
|
||||
DispatchQueue.main.async {
|
||||
#if os(macOS)
|
||||
self.consentState = nil
|
||||
self.toastActions = nil
|
||||
self.progress = nil
|
||||
self.hideQuickDropToast()
|
||||
#endif
|
||||
self.controlPlusScreen(true)
|
||||
}
|
||||
}
|
||||
@@ -234,31 +499,88 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
// MARK: - macOS Progress Overlay
|
||||
|
||||
#if os(macOS)
|
||||
private func scheduleToastReveal(for window: NSWindow) {
|
||||
toastRevealTask?.cancel()
|
||||
toastRevealTask = nil
|
||||
|
||||
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) {
|
||||
guard toastWindow == nil else { return }
|
||||
if !Thread.isMainThread {
|
||||
DispatchQueue.main.async {
|
||||
self.showQuickDropToast(for: connectionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let contentView = QuickDropToastView(
|
||||
// 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,
|
||||
onCancel: {
|
||||
NearbyConnectionManager.shared.cancelTransfer(id: connectionID)
|
||||
onCancel: { [weak self] in
|
||||
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)
|
||||
hostingView.frame.size = toastViewSize
|
||||
|
||||
// Prefer the screen under the mouse, then the key window’s screen, then main
|
||||
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) }
|
||||
?? NSApp.keyWindow?.screen
|
||||
?? NSScreen.main
|
||||
|
||||
guard let screen else { return }
|
||||
guard let screen else {
|
||||
toastWindowPending = false
|
||||
return
|
||||
}
|
||||
|
||||
let frame = screen.frame
|
||||
let visible = screen.visibleFrame
|
||||
|
||||
let marginX: CGFloat = 24
|
||||
let marginFromTop: CGFloat = 30
|
||||
let marginX: CGFloat = 20
|
||||
let marginFromTop: CGFloat = 20
|
||||
|
||||
// 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
|
||||
@@ -266,53 +588,130 @@ class ReceiveModel: ObservableObject, InboundAppDelegate {
|
||||
|
||||
let targetX = visible.maxX - toastViewSize.width - marginX
|
||||
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)
|
||||
|
||||
// Expand the borderless window so SwiftUI shadow can render without clipping.
|
||||
// Keep the toast anchored at the same screen position by offsetting hostingView
|
||||
// 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 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)
|
||||
|
||||
// Start slightly above the final spot for a smooth slide-in
|
||||
let startY = min(frame.maxY + 10, targetY + 10)
|
||||
let startFrame = CGRect(x: targetX, y: startY, width: toastViewSize.width, height: toastViewSize.height)
|
||||
|
||||
let window = NSWindow(contentRect: startFrame,
|
||||
let window = NSWindow(contentRect: windowFrame,
|
||||
styleMask: [.borderless],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
window.isOpaque = false
|
||||
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.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
window.contentView = hostingView
|
||||
window.contentView = containerView
|
||||
window.ignoresMouseEvents = false
|
||||
window.alphaValue = 0
|
||||
window.alphaValue = 1
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
|
||||
self.toastWindow = window
|
||||
self.toastHosting = hostingView
|
||||
|
||||
NSAnimationContext.runAnimationGroup { ctx in
|
||||
ctx.duration = 0.35
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrameOrigin(CGPoint(x: targetX, y: targetY))
|
||||
window.animator().alphaValue = 1
|
||||
}
|
||||
toastWindow = window
|
||||
toastHosting = hostingView
|
||||
toastWindowPending = false
|
||||
scheduleToastReveal(for: window)
|
||||
}
|
||||
|
||||
|
||||
func hideQuickDropToast() {
|
||||
guard let window = toastWindow,
|
||||
let screen = NSScreen.main else { return }
|
||||
func hideQuickDropToast(style: ToastDismissStyle = .fade) {
|
||||
if !Thread.isMainThread {
|
||||
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
|
||||
ctx.duration = 0.35
|
||||
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||
window.animator().setFrameOrigin(CGPoint(x: window.frame.origin.x, y: offscreenY))
|
||||
window.animator().alphaValue = 0
|
||||
}, completionHandler: {
|
||||
guard let window = toastWindow else {
|
||||
self.toastWindow = nil
|
||||
self.toastHosting = nil
|
||||
self.toastActions = nil
|
||||
self.consentState = nil
|
||||
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)
|
||||
self.toastWindow = nil
|
||||
self.toastHosting = 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 {
|
||||
let completionMessageKey: String
|
||||
let autoHideDelay: TimeInterval
|
||||
let openFilesAction: (() -> ())?
|
||||
let importPhotosAction: (() -> ())?
|
||||
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
|
||||
}
|
||||
@@ -61,11 +61,10 @@ class SendModel: ObservableObject, OutboundAppDelegate {
|
||||
|
||||
func connectionFailed(error: any Error) {
|
||||
|
||||
#if os(iOS)
|
||||
errorVibration()
|
||||
#endif
|
||||
|
||||
ErrorAlertHandler.shared.showErrorAlert(for: selectedDevice?.name ?? "UnknownDevice".localized(), error: error)
|
||||
if let name = selectedDevice?.name {
|
||||
errorVibration()
|
||||
ErrorAlertHandler.shared.showErrorAlert(for: name, error: error)
|
||||
}
|
||||
|
||||
progressValue = nil
|
||||
selectedDevice = nil
|
||||
@@ -126,9 +125,13 @@ class SendModel: ObservableObject, OutboundAppDelegate {
|
||||
else {
|
||||
|
||||
progressValue = 0
|
||||
progressState = "Connecting".localized()
|
||||
progressState = "Preparing".localized()
|
||||
selectedDevice = device
|
||||
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: urls, textToSend: textToSend)
|
||||
|
||||
runAfter(seconds: 0.3) {
|
||||
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: self.urls, textToSend: self.textToSend)
|
||||
self.progressState = "Connecting".localized()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,19 @@
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "QuickDropArrows.png",
|
||||
"name" : "QuickDropArrows"
|
||||
"name" : "QuickDropArrows",
|
||||
"position-specializations" : [
|
||||
{
|
||||
"idiom" : "square",
|
||||
"value" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
-5
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"image-name" : "QuickDropCircle.png",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<string>SUBQUERY(extensionItems, $extensionItem, TRUEPREDICATE).@count > 0</string>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
<key>UIDesignRequiresCompatibility</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,36 +0,0 @@
|
||||
//
|
||||
// Configuration.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 15.08.25.
|
||||
//
|
||||
|
||||
import LUI
|
||||
|
||||
let configuration = LUIConfiguration(
|
||||
appName: "QuickDrop",
|
||||
|
||||
inAppPurchaseName: "plusversion",
|
||||
|
||||
plusVersionInfo: GetPlusViewInformation(description: "plusview_description_phone", plusVersionFeatures: [], settingsLabelColor: .orange, settingsFooter: "plusview_footer_phone"),
|
||||
|
||||
hasRoundedFont: false,
|
||||
|
||||
copyrightInformation: licenseText,
|
||||
|
||||
loggingUrl: AppGroup.appGroupDirectory,
|
||||
|
||||
introductionViewInformation: "QuickDropWelcomeDescription",
|
||||
|
||||
usesAutomaticReviewPrompt: false,
|
||||
|
||||
usesPagedIntroduction: false,
|
||||
|
||||
supportsSplitView: false,
|
||||
|
||||
supportsLiquidGlass: true,
|
||||
|
||||
supportsPortraitSplitView: false,
|
||||
|
||||
supportsDarkModeShadow: false
|
||||
)
|
||||
@@ -32,7 +32,7 @@
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : ""
|
||||
"value" : "Copyright © 2025-2026 Leon Böttger"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,240 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSPhotoLibraryAddUsageDescription" : {
|
||||
"comment" : "Privacy - Photo Library Additions Usage Description",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "مطلوب إذن إضافة الصور لحفظ الصور ومقاطع الفيديو المستلمة في مكتبة الصور الخاصة بك."
|
||||
}
|
||||
},
|
||||
"ca" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Es requereix el permís d’afegir fotos per desar fotos i vídeos rebuts a la teva biblioteca."
|
||||
}
|
||||
},
|
||||
"cs" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Oprávnění k přidání fotek je nutné pro ukládání přijatých fotek a videí do vaší knihovny fotografií."
|
||||
}
|
||||
},
|
||||
"da" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tilladelse til at tilføje fotos er nødvendig for at gemme modtagne fotos og videoer i dit fotobibliotek."
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Die Berechtigung zum Hinzufügen von Fotos ist nötig, um empfangene Fotos und Videos in deiner Mediathek zu speichern."
|
||||
}
|
||||
},
|
||||
"el" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Η άδεια προσθήκης φωτογραφιών είναι απαραίτητη για την αποθήκευση των ληφθέντων φωτογραφιών και βίντεο στη βιβλιοθήκη φωτογραφιών σας."
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-AU" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"en-GB" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "The add photos permission is required to save received photos and videos to your photo library."
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Se necesita el permiso para agregar fotos para guardar las fotos y videos recibidos en tu biblioteca."
|
||||
}
|
||||
},
|
||||
"es-419" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Se necesita el permiso para agregar fotos para guardar las fotos y videos recibidos en tu galería."
|
||||
}
|
||||
},
|
||||
"fi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Valokuvien lisäämisoikeus vaaditaan, jotta vastaanotetut kuvat ja videot voidaan tallentaa kuva-albumiisi."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L'autorisation d'ajouter des photos est requise pour enregistrer les photos et vidéos reçues dans votre bibliothèque."
|
||||
}
|
||||
},
|
||||
"fr-CA" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "L’autorisation d’ajouter des photos est requise pour enregistrer les photos et vidéos reçues dans votre bibliothèque photo."
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "נדרשת הרשאת הוספת תמונות כדי לשמור תמונות וסרטונים שהתקבלו לספריית התמונות שלך."
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "प्राप्त फोटो और वीडियो को आपकी फोटो लाइब्रेरी में सहेजने के लिए फ़ोटो जोड़ने की अनुमति आवश्यक है।"
|
||||
}
|
||||
},
|
||||
"hr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Dopuštenje za dodavanje fotografija potrebno je za spremanje primljenih fotografija i videa u vašu galeriju."
|
||||
}
|
||||
},
|
||||
"hu" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A fotók hozzáadása engedély szükséges ahhoz, hogy a fogadott fotókat és videókat el tudd menteni a fotótáradba."
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Izin menambah foto diperlukan untuk menyimpan foto dan video yang diterima ke perpustakaan foto Anda."
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Il permesso di aggiungere foto è necessario per salvare le foto e i video ricevuti nella tua libreria fotografica."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "受信した写真や動画をフォトライブラリに保存するには、写真の追加許可が必要です。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "받은 사진과 동영상을 사진 보관함에 저장하려면 사진 추가 권한이 필요합니다."
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Kebenaran tambah foto diperlukan untuk menyimpan foto dan video yang diterima ke pustaka foto anda."
|
||||
}
|
||||
},
|
||||
"nb" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Tillatelse til å legge til bilder er nødvendig for å lagre mottatte bilder og videoer i bildebiblioteket ditt."
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "De toestemming om foto’s toe te voegen is nodig om ontvangen foto’s en video’s op te slaan in je fotobibliotheek."
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Uprawnienie do dodawania zdjęć jest wymagane, aby zapisywać otrzymane zdjęcia i filmy w Twojej bibliotece zdjęć."
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A permissão para adicionar fotos é necessária para salvar fotos e vídeos recebidos na sua biblioteca de fotos."
|
||||
}
|
||||
},
|
||||
"pt-PT" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "A permissão para adicionar fotos é necessária para guardar fotos e vídeos recebidos na sua biblioteca de fotos."
|
||||
}
|
||||
},
|
||||
"ro" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Permisiunea de a adăuga fotografii este necesară pentru a salva fotografii și videoclipuri primite în biblioteca ta foto."
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Необходимо разрешение на добавление фото для сохранения полученных фото и видео в вашу фототеку."
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Behörighet att lägga till foton krävs för att spara mottagna bilder och videor i ditt fotobibliotek."
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "จำเป็นต้องขออนุญาตเพิ่มรูปภาพเพื่อบันทึกรูปถ่ายและวิดีโอที่ได้รับลงในคลังรูปภาพของคุณ"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Alınan fotoğraf ve videoları fotoğraf galerine kaydetmek için fotoğraf ekleme izni gereklidir."
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Дозвіл на додавання фото потрібен для збереження отриманих фото та відео у вашу фототеку."
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Quyền thêm ảnh là cần thiết để lưu ảnh và video nhận được vào thư viện ảnh của bạn."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要添加照片权限才能将接收到的照片和视频保存到您的相册。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "需要新增照片權限才能將接收的照片和影片儲存到您的相簿。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
<array>
|
||||
<string>group.com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -11,9 +11,7 @@ import LUI
|
||||
@main
|
||||
struct QuickDropiOSApp: App {
|
||||
|
||||
#if !os(macOS)
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
init() {
|
||||
LUIInit(configuration: configuration)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// ChangeDeviceNameModifier.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 16.12.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import LUI
|
||||
|
||||
struct ChangeDeviceNameAlertModifier: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
@State var customName = ""
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
|
||||
content
|
||||
.alert("ChangeDeviceName", isPresented: $isPresented) {
|
||||
TextField("DeviceName", text: $customName)
|
||||
|
||||
Button("Save") {
|
||||
let trimmed = customName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
NearbyConnectionManager.shared.setCustomDeviceName(to: trimmed)
|
||||
}
|
||||
|
||||
Button("Cancel", role: .cancel) { }
|
||||
} message: {
|
||||
Text("ChangeDeviceNameDescription")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension View {
|
||||
func changeDeviceNameAlert(
|
||||
isPresented: Binding<Bool>,
|
||||
) -> some View {
|
||||
modifier(ChangeDeviceNameAlertModifier(
|
||||
isPresented: isPresented
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct ChangeDeviceNameModifier: ViewModifier {
|
||||
|
||||
let isEnabled: Bool
|
||||
|
||||
@State private var isPresented = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onTapGesture {
|
||||
guard isEnabled else { return }
|
||||
|
||||
lightVibration()
|
||||
isPresented = true
|
||||
}
|
||||
.changeDeviceNameAlert(isPresented: $isPresented)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
extension View {
|
||||
func changeDeviceNameAlert(isEnabled: Bool = true) -> some View {
|
||||
modifier(ChangeDeviceNameModifier(isEnabled: isEnabled))
|
||||
}
|
||||
}
|
||||
@@ -12,14 +12,14 @@ import StoreKit
|
||||
struct ContentView: View {
|
||||
|
||||
@ObservedObject var luiSettings = LUISettings.sharedInstance
|
||||
@ObservedObject var settings = Settings.shared
|
||||
@ObservedObject var iapManager = IAPManager.sharedInstance
|
||||
|
||||
var body: some View {
|
||||
AppRootView(isPlus: $settings.gotPlus, phoneView: {
|
||||
AppRootView(phoneView: {
|
||||
DeviceListView()
|
||||
.environment(\.sheetActive, isShareExtension())
|
||||
}, settingsView: {
|
||||
CustomSection(header: settings.gotPlus ? "" : "General", footer: "TrustedDevicesFooterShort") {
|
||||
CustomSection(header: iapManager.plusVersionState ? "" : "General", footer: "TrustedDevicesFooterShort") {
|
||||
LUIButton {
|
||||
FileManager.default.openDocumentFolder()
|
||||
} label: {
|
||||
@@ -32,6 +32,8 @@ struct ContentView: View {
|
||||
NavigationLinkLabel(imageName: "checkmark.shield.fill", text: "ManageTrustedDevices", backgroundColor: .green)
|
||||
}
|
||||
}
|
||||
}, firstIntroductionView: {
|
||||
LocalNetworkPermissionView()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -44,16 +46,20 @@ struct DeviceListView: View {
|
||||
#if !EXTENSION
|
||||
@StateObject var receiveModel = ReceiveModel(controlPlusScreen: { show in
|
||||
log("[DeviceListView] Setting showPlusScreen to \(show)")
|
||||
DeviceListViewModel.sharedInstance.showPlusScreen = show
|
||||
errorVibration()
|
||||
if show {
|
||||
presentPaywall()
|
||||
}
|
||||
else {
|
||||
OverlayCoverStack.shared.dismissTop(animated: true)
|
||||
}
|
||||
})
|
||||
#endif
|
||||
|
||||
@ObservedObject var model = DeviceListViewModel.sharedInstance
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@Environment(\.sheetActive) var sheetActive
|
||||
@ObservedObject var nearbyConnectionManager = NearbyConnectionManager.shared
|
||||
@ObservedObject var settings = Settings.shared
|
||||
@ObservedObject var settings = Settings.sharedInstance
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -62,44 +68,51 @@ struct DeviceListView: View {
|
||||
BottomBarView(header: "QuickDrop", navigationBarLayout: isShareExtension() ? .SmallOnlyAlways : .Default, bottomViewHeight: 30) {
|
||||
VStack {
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
|
||||
let attachments = nearbyConnectionManager.attachments
|
||||
|
||||
FormHeader(name: attachments == nil ? "YouAreVisibleAs".localized() : "YouAreSending".localized())
|
||||
|
||||
HStack {
|
||||
let attachments = nearbyConnectionManager.attachments
|
||||
|
||||
if nearbyConnectionManager.hasLocalNetworkPermission && attachments == nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
|
||||
if let image = attachments?.previewImage {
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: 10, height: 10)
|
||||
FormHeader(name: attachments == nil ? "YouAreVisibleAs".localized() : "YouAreSending".localized())
|
||||
|
||||
HStack {
|
||||
|
||||
LUIText(attachments?.shortDescription ?? NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized(), isBold: true)
|
||||
.lineLimit(1)
|
||||
|
||||
if attachments == nil {
|
||||
Image(systemName: "pencil")
|
||||
.foregroundColor(.mainColor.opacity(0.5))
|
||||
.padding(.horizontal, -3)
|
||||
}
|
||||
}
|
||||
|
||||
LUIText(attachments?.shortDescription ?? NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized(), isBold: true)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
.background(sheetActive ? Color.sheetForegroundColor : Color.defaultForegroundColor)
|
||||
.cornerRadius(24)
|
||||
.changeDeviceNameAlert(isEnabled: attachments == nil)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 6)
|
||||
.background(sheetActive ? Color.sheetForegroundColor : Color.defaultForegroundColor)
|
||||
.cornerRadius(24)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
if !nearbyConnectionManager.hasLocalNetworkPermission {
|
||||
CardView(backgroundColor: .red, title: "NoNetworkAccess", titleSymbol: "network.slash") {
|
||||
CardSubView(symbol: "exclamationmark.triangle.fill", text: "NoLocalNetworkAccessDescription")
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
.onTapGesture {
|
||||
openAppSettings()
|
||||
}
|
||||
NoLocalNetworkAccessAlertView()
|
||||
}
|
||||
|
||||
if sendModel.foundDevices.isEmpty {
|
||||
var availableDevices: [RemoteDeviceInfo] {
|
||||
var devices = sendModel.foundDevices
|
||||
|
||||
if let selected = sendModel.selectedDevice,
|
||||
!devices.contains(where: { $0.id == selected.id }) {
|
||||
devices.append(selected)
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
if availableDevices.isEmpty {
|
||||
|
||||
CustomSection(header: "NoDevicesFound") {
|
||||
|
||||
@@ -127,7 +140,7 @@ struct DeviceListView: View {
|
||||
} else {
|
||||
|
||||
CustomSection(header: "AvailableDevices") {
|
||||
ForEach(sendModel.foundDevices) { device in
|
||||
ForEach(availableDevices) { device in
|
||||
|
||||
let isSelected = sendModel.selectedDevice == device
|
||||
|
||||
@@ -157,9 +170,8 @@ struct DeviceListView: View {
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(Color.primary.opacity(0.6))
|
||||
|
||||
LoadingIndicator()
|
||||
LoadingIndicator(size: 12)
|
||||
.opacity(0.75)
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
@@ -193,7 +205,7 @@ struct DeviceListView: View {
|
||||
#if !EXTENSION
|
||||
NearbyConnectionManager.shared.becomeVisible()
|
||||
|
||||
if Settings.shared.incomingTransmissionCount > 0 {
|
||||
if Settings.sharedInstance.incomingTransmissionCount > 0 {
|
||||
runAfter(seconds: 0.3) {
|
||||
requestReviewOnce()
|
||||
}
|
||||
@@ -210,9 +222,6 @@ struct DeviceListView: View {
|
||||
NearbyConnectionManager.shared.stopDeviceDiscovery()
|
||||
}
|
||||
}
|
||||
#if !EXTENSION
|
||||
.modifier(PlusVersionSheetModifier(sheetActive: $model.showPlusScreen, isPlusVersion: $settings.gotPlus))
|
||||
#endif
|
||||
.animation(.smooth, value: nearbyConnectionManager.hasLocalNetworkPermission)
|
||||
.animation(.smooth, value: nearbyConnectionManager.isConnectedToLocalNetwork)
|
||||
.navigationBarItems(trailing: ZStack {
|
||||
@@ -226,12 +235,6 @@ struct DeviceListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
final class DeviceListViewModel: SharedInstance {
|
||||
static var sharedInstance = DeviceListViewModel()
|
||||
|
||||
@Published var showPlusScreen = false
|
||||
}
|
||||
|
||||
|
||||
struct DeviceFilePickerButton: View {
|
||||
|
||||
@@ -266,7 +269,7 @@ struct DeviceButtonLabel: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
|
||||
let name = device.name ?? "Android"
|
||||
let name = device.name ?? "AndroidDevice".localized()
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
|
||||
@@ -49,7 +49,6 @@ struct SendPickerButton<Label: View>: View {
|
||||
onPrepare?()
|
||||
updatedPhotoSelection(content: newItems)
|
||||
}
|
||||
// File picker
|
||||
.fileImporter(isPresented: $showFilePicker,
|
||||
allowedContentTypes: [.item, .folder], // any file
|
||||
allowsMultipleSelection: true) { result in
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// LocalNetworkPermissionView.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 15.11.25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import LUI
|
||||
|
||||
public struct LocalNetworkPermissionView: View {
|
||||
|
||||
@State var requestedLocalNetworkAccess = false
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
|
||||
public var body: some View {
|
||||
|
||||
PermissionView(title: "introduction_local_network_access", watchSymbol: "", subtitle: "introduction_local_network_access_description".localized(with: "introduction_continue".localized()), permissionAction: {
|
||||
|
||||
if requestedLocalNetworkAccess {
|
||||
// Something went wrong, user pressed again on continue. In this case, just continue with setup
|
||||
IntroductionViewController.sharedInstance.canProceed = true
|
||||
}
|
||||
else {
|
||||
// init manager to force local network access prompt
|
||||
requestedLocalNetworkAccess = true
|
||||
let manager = NearbyConnectionManager.shared
|
||||
manager.startDeviceDiscovery()
|
||||
}
|
||||
}, continueLabel: "introduction_continue", canSkip: false, nextView: {
|
||||
IntroductionDoneView()
|
||||
}, nextViewSkip: {}, topView: {
|
||||
IntroductionIconView(icon: "wifi")
|
||||
})
|
||||
.onChange(of: scenePhase) { newValue in
|
||||
// alert was removed from screen, app is foreground again -> continue with intro
|
||||
if newValue == .active && requestedLocalNetworkAccess {
|
||||
IntroductionViewController.sharedInstance.canProceed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
LocalNetworkPermissionView()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// NoLocalNetworkAccessAlertView.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 26.12.25.
|
||||
//
|
||||
|
||||
import LUI
|
||||
import SwiftUI
|
||||
|
||||
struct NoLocalNetworkAccessAlertView: View {
|
||||
var body: some View {
|
||||
CardView(backgroundColor: .red, title: "NoNetworkAccess", titleSymbol: "network.slash") {
|
||||
LUIText("NoLocalNetworkAccessDescription", color: .white)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
.onTapGesture {
|
||||
#if os(iOS)
|
||||
openAppSettings()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//
|
||||
// PhotoManager.swift
|
||||
// QuickDrop
|
||||
//
|
||||
// Created by Leon Böttger on 16.11.25.
|
||||
//
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
import LUI
|
||||
import Photos
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct PhotoManager {
|
||||
|
||||
/// Returns if there are photos or videos among the given URLs.
|
||||
static func hasPhotosOrVideos(at urls: [URL]) -> Bool {
|
||||
urls.contains(where: { EXIFUtils.isImageFile(at: $0) || EXIFUtils.isVideoFile(at: $0) })
|
||||
}
|
||||
|
||||
|
||||
/// Saves any image/video URLs to the user's Photos library.
|
||||
/// - Parameters:
|
||||
/// - urls: Remote or local URLs to inspect.
|
||||
/// - openPhotosOnSuccess: If true, will try to open the Photos app after saving.
|
||||
/// - Throws: `SaveToPhotosError` or underlying URLSession/Photos errors.
|
||||
static func saveMediaToPhotoLibrary(from urls: [URL], openPhotosOnSuccess: Bool = true) async throws {
|
||||
|
||||
let authStatus = await requestAddOnlyPhotoAuthorization()
|
||||
guard authStatus == .authorized || authStatus == .limited else {
|
||||
throw SaveToPhotosError.permissionDenied
|
||||
}
|
||||
|
||||
var imageItems: [ImageItem] = []
|
||||
var videoItems: [VideoItem] = []
|
||||
|
||||
// Extract creation date for image and video
|
||||
for sourceURL in urls {
|
||||
let (type, date) = EXIFUtils.originalDate(from: sourceURL)
|
||||
|
||||
if type == .image {
|
||||
imageItems.append(ImageItem(fileURL: sourceURL, creationDate: date))
|
||||
}
|
||||
if type == .video {
|
||||
videoItems.append(VideoItem(fileURL: sourceURL, creationDate: date))
|
||||
}
|
||||
}
|
||||
|
||||
guard !imageItems.isEmpty || !videoItems.isEmpty else {
|
||||
throw SaveToPhotosError.noSavableContent
|
||||
}
|
||||
|
||||
|
||||
// Import into Photos; explicitly set creationDate so it doesn't default to "now".
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
for item in imageItems {
|
||||
if let request = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: item.fileURL) {
|
||||
if let date = item.creationDate {
|
||||
request.creationDate = date
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for item in videoItems {
|
||||
if let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: item.fileURL) {
|
||||
if let date = item.creationDate {
|
||||
request.creationDate = date
|
||||
}
|
||||
}
|
||||
}
|
||||
}, completionHandler: { success, error in
|
||||
if success {
|
||||
continuation.resume()
|
||||
} else {
|
||||
continuation.resume(throwing: error ?? SaveToPhotosError.saveFailed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// Delete files in documents directory after successful import
|
||||
imageItems.forEach({ item in
|
||||
do { try FileManager.default.removeItem(at: item.fileURL) }
|
||||
catch { log("Failed to delete image at \(item.fileURL): \(error)") }
|
||||
})
|
||||
|
||||
videoItems.forEach({ item in
|
||||
do { try FileManager.default.removeItem(at: item.fileURL) }
|
||||
catch { log("Failed to delete video at \(item.fileURL): \(error)") }
|
||||
})
|
||||
|
||||
|
||||
// Open the Photos app
|
||||
if openPhotosOnSuccess {
|
||||
DispatchQueue.main.async {
|
||||
#if os(iOS)
|
||||
if let url = URL(string: "photos-redirect://") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#else
|
||||
if let url = URL(string: "photos-navigation://album?name=recently-saved") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Requests add-only Photos permission
|
||||
private static func requestAddOnlyPhotoAuthorization() async -> PHAuthorizationStatus {
|
||||
await withCheckedContinuation { continuation in
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
|
||||
continuation.resume(returning: status)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private types
|
||||
|
||||
private extension PhotoManager {
|
||||
private enum SaveToPhotosError: Error, LocalizedError {
|
||||
case noSavableContent
|
||||
case permissionDenied
|
||||
case saveFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noSavableContent:
|
||||
return "SaveToPhotosErrorNoSavableContent".localized()
|
||||
case .permissionDenied:
|
||||
return "SaveToPhotosErrorPermissionDenied".localized()
|
||||
case .saveFailed:
|
||||
return "SaveToPhotosErrorSaveFailed".localized()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private struct ImageItem {
|
||||
let fileURL: URL
|
||||
let creationDate: Date?
|
||||
}
|
||||
|
||||
|
||||
private struct VideoItem {
|
||||
let fileURL: URL
|
||||
let creationDate: Date?
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import LUI
|
||||
|
||||
// MARK: - UIKit Alert Presenter
|
||||
|
||||
@@ -42,6 +43,42 @@ final class ProgressAlert {
|
||||
vc.present(alert, animated: true)
|
||||
}
|
||||
|
||||
|
||||
func presentIncomingTransferDoneAlert(
|
||||
title: String,
|
||||
message: String,
|
||||
onImportToPhotos: (() -> Void)? = nil,
|
||||
onDone: (() -> Void)? = nil
|
||||
) {
|
||||
guard let vc = topMostViewController() else { return }
|
||||
|
||||
let alert = UIAlertController(
|
||||
title: title,
|
||||
message: message,
|
||||
preferredStyle: .alert
|
||||
)
|
||||
|
||||
// "Show in Files"
|
||||
alert.addAction(UIAlertAction(title: "OpenInFiles".localized(), style: .default) { _ in
|
||||
FileManager.default.openDocumentFolder()
|
||||
})
|
||||
|
||||
// Optional "Import to Photos"
|
||||
if let importHandler = onImportToPhotos {
|
||||
alert.addAction(UIAlertAction(title: "ImportToPhotos".localized(), style: .default) { _ in
|
||||
importHandler()
|
||||
})
|
||||
}
|
||||
|
||||
// "Done"
|
||||
alert.addAction(UIAlertAction(title: "Done".localized(), style: .cancel) { _ in
|
||||
onDone?()
|
||||
})
|
||||
|
||||
vc.present(alert, animated: true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// MARK: - Progress Alert
|
||||
private func showProgressAlert(onCancel: @escaping () -> Void) {
|
||||
@@ -76,7 +113,7 @@ final class ProgressAlert {
|
||||
|
||||
// MARK: - Update Progress
|
||||
// progress: 0…1 → update progress, nil → finished
|
||||
func updateProgress(_ progress: Double?, onCancel: @escaping () -> Void, completion: @escaping () -> Void = {}) {
|
||||
func updateProgress(_ progress: Double?, onCancel: @escaping () -> Void, completion: @escaping () -> Void) {
|
||||
|
||||
// No progress to show, nothing shown currently → just complete
|
||||
if progressAlert == nil && progress == nil {
|
||||
@@ -86,21 +123,22 @@ final class ProgressAlert {
|
||||
|
||||
DispatchQueue.main.async {
|
||||
|
||||
// Show progress if not shown yet
|
||||
if self.progressAlert == nil {
|
||||
self.showProgressAlert(onCancel: onCancel)
|
||||
}
|
||||
|
||||
if let p = progress {
|
||||
// Show progress if not shown yet
|
||||
if self.progressAlert == nil {
|
||||
self.showProgressAlert(onCancel: onCancel)
|
||||
}
|
||||
|
||||
self.progressView?.setProgress(Float(p), animated: true)
|
||||
completion()
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
// Completed: dismiss progress and show final alert
|
||||
self.progressAlert?.dismiss(animated: true) {
|
||||
completion()
|
||||
self.progressAlert = nil
|
||||
self.progressView = nil
|
||||
}
|
||||
self.progressAlert = nil
|
||||
self.progressView = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,15 +8,5 @@ QuickDrop is an improved version of NearDrop, a QuickShare client for macOS. Qui
|
||||
- QuickDrop iOS and iPadOS: [Download on the App Store](https://apps.apple.com/de/app/quickdrop-android-transfer/id6740147178)
|
||||
- QuickDrop for Android: [Download on Google Play](https://play.google.com/store/apps/details?id=com.leonboettger.quickdrop)
|
||||
|
||||
## Compile from source
|
||||
Download and open this repository in Xcode.
|
||||
|
||||
You have to re-sign the project with your own Apple ID.
|
||||
|
||||
Also, keep in mind that you may need to replace the prefix of the Bundle Identifiers with your identifier.
|
||||
Those can be found in Project -> Targets -> Signing -> Bundle Identifier
|
||||
|
||||
Please also change the App Group names accordingly. This can be done by editing the .entitlements files in the root folder of this repository.
|
||||
|
||||
## Disclaimer
|
||||
Nearby Share and Quick Share are trademarks of Google, LLC and Samsung Electronics. Android is a trademark of Google, LLC. This application is not affiliated with Google, LLC or Samsung Electronics.
|
||||
@@ -601,6 +601,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
ZIPFoundation
|
||||
https://github.com/weichsel/ZIPFoundation
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
SwiftECC
|
||||
https://github.com/leif-ibsen/SwiftECC
|
||||
Copyright (c) 2021 Leif Ibsen
|
||||
@@ -919,6 +944,112 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
|
||||
Introspect for SwiftUI
|
||||
https://github.com/siteline/SwiftUI-Introspect
|
||||
|
||||
Copyright 2019 Timber Software
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
MWKNumberRowInputAccessory
|
||||
https://github.com/mwkirk/MWKNumberRowInputAccessory
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016 Mark Kirk
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the \"Software\"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
Shimmer
|
||||
Used for the unlock animation when starting the app
|
||||
https://github.com/markiv/SwiftUI-Shimmer
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Vikram Kriplaney
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
MTSlideToOpen-SwiftUI
|
||||
Used for the unlock animation when starting the app
|
||||
https://github.com/lemanhtien/MTSlideToOpen-SwiftUI
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2017 Tien Le
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
LaunchAtLogin-Modern
|
||||
https://github.com/sindresorhus/LaunchAtLogin-Modern
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
All of the sounds listed below are provided under the Creative Commons Attribution 4.0 International License.
|
||||
|
||||
https://creativecommons.org/licenses/by/4.0/legalcode
|
||||
|
||||
@@ -8,8 +8,11 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
import SwiftUI
|
||||
import LUI
|
||||
#if os(macOS)
|
||||
import Cocoa
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @escaping (AttachmentDetails) -> Void) {
|
||||
@@ -41,7 +44,31 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
|
||||
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 {
|
||||
@@ -51,20 +78,28 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
|
||||
log("Found NSURL: \(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 {
|
||||
log("Attachment is not a URL, ignoring. All attachments: \(attachments.description)")
|
||||
ignoredAttachments += 1
|
||||
|
||||
checkIfAttachmentsLoaded(result: result, attachmentCount: attachments.count, ignoredAttachments: ignoredAttachments)
|
||||
checkIfAttachmentsLoaded(result: result,
|
||||
attachmentCount: attachments.count,
|
||||
ignoredAttachments: ignoredAttachments,
|
||||
item: item,
|
||||
loadedItems: loadedItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
loadTextIfPossible()
|
||||
deliverTextIfPossible(from: item, loadedItems: loadedItems)
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -73,83 +108,136 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
|
||||
let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
|
||||
extensionContext.cancelRequest(withError: cancelError)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func deliverTextIfPossible(from item: NSExtensionItem, loadedItems: @escaping (AttachmentDetails) -> Void) {
|
||||
guard let details = textAttachmentDetails(from: item) else { return }
|
||||
DispatchQueue.main.async {
|
||||
loadedItems(details)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func textAttachmentDetails(from item: NSExtensionItem) -> AttachmentDetails? {
|
||||
guard let text = item.attributedContentText?.string else { return nil }
|
||||
|
||||
log("Found text content: \(text)")
|
||||
|
||||
func loadTextIfPossible() {
|
||||
if let text = item.attributedContentText?.string {
|
||||
|
||||
log("Found text content: \(text)")
|
||||
|
||||
result.textToSend = text
|
||||
|
||||
#if os(macOS)
|
||||
result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
|
||||
#else
|
||||
result.previewImage = Image(systemName: "text.document")
|
||||
#endif
|
||||
|
||||
let maxLength = 50
|
||||
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
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
loadedItems(result)
|
||||
}
|
||||
}
|
||||
var result = AttachmentDetails(urls: [], textToSend: text, shortDescription: "", previewImage: nil)
|
||||
|
||||
#if os(macOS)
|
||||
result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
|
||||
#else
|
||||
result.previewImage = Image(systemName: "text.document")
|
||||
#endif
|
||||
|
||||
let maxLength = 50
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
private func checkIfAttachmentsLoaded(result: AttachmentDetails,
|
||||
attachmentCount: Int,
|
||||
ignoredAttachments: Int = 0,
|
||||
item: NSExtensionItem,
|
||||
loadedItems: @escaping (AttachmentDetails) -> Void) {
|
||||
|
||||
func checkIfAttachmentsLoaded(result: AttachmentDetails, attachmentCount: Int, ignoredAttachments: Int = 0) {
|
||||
var result = result
|
||||
|
||||
if result.urls.count == attachmentCount-ignoredAttachments {
|
||||
|
||||
var result = result
|
||||
|
||||
if result.urls.count == attachmentCount-ignoredAttachments {
|
||||
// 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 {
|
||||
|
||||
// Unable to load anything useful, check if there is text as last resort
|
||||
if result.urls.count == 0 {
|
||||
loadTextIfPossible()
|
||||
return
|
||||
}
|
||||
else if result.urls.count == 1 {
|
||||
if result.urls[0].isFileURL {
|
||||
|
||||
if result.urls[0].isFileURL {
|
||||
|
||||
log("Single file URL found: \(result.urls[0])")
|
||||
|
||||
result.shortDescription = result.urls[0].lastPathComponent
|
||||
#if os(macOS)
|
||||
result.previewImage = NSWorkspace.shared.icon(forFile: result.urls[0].path)
|
||||
#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)
|
||||
log("Single file URL found: \(result.urls[0])")
|
||||
|
||||
result.shortDescription = result.urls[0].lastPathComponent
|
||||
#if os(macOS)
|
||||
result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
|
||||
result.previewImage = NSWorkspace.shared.icon(forFile: result.urls[0].path)
|
||||
#else
|
||||
result.previewImage = Image(systemName: "document")
|
||||
#endif
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
loadedItems(result)
|
||||
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)
|
||||
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>
|
||||
<string>group.com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,8 +5,10 @@
|
||||
// Created by Grishka on 12.09.2023.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import LUI
|
||||
import SwiftUI
|
||||
|
||||
class ShareViewController: NSViewController, OutboundAppDelegate {
|
||||
@@ -49,9 +51,11 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
|
||||
override func loadView() {
|
||||
|
||||
super.loadView()
|
||||
LUIInit(configuration: configuration)
|
||||
|
||||
loadAttachments(with: extensionContext, loadedItems: { result in
|
||||
|
||||
log("Loaded attachments: \(result)")
|
||||
log("[ShareViewController] Loaded attachments: \(result)")
|
||||
|
||||
self.urls = result.urls
|
||||
self.textToSend = result.textToSend
|
||||
@@ -104,7 +108,7 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
|
||||
|
||||
|
||||
override func viewWillDisappear() {
|
||||
log("ShareViewController: viewWillDisappear")
|
||||
log("[ShareViewController] ShareViewController: viewWillDisappear")
|
||||
|
||||
timeoutDispatchWorkItem?.cancel()
|
||||
|
||||
@@ -249,27 +253,43 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
|
||||
progressDeviceName?.stringValue = getDeviceName(device: device)
|
||||
progressDeviceIcon?.image = imageForDeviceType(type: device.type)
|
||||
progressProgressBar?.startAnimation(nil)
|
||||
progressState?.stringValue = "Connecting".localized()
|
||||
progressState?.stringValue = "Preparing".localized()
|
||||
chosenDevice = device
|
||||
NearbyConnectionManager.shared.stopDeviceDiscovery()
|
||||
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: urls, textToSend: textToSend)
|
||||
|
||||
let timeoutAlert = DispatchWorkItem {
|
||||
if !self.connectionEstablished {
|
||||
AudioManager.playErrorSound()
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
|
||||
alert.messageText = "TimeoutTitle".localized()
|
||||
alert.informativeText = "TimeoutDescription".localized()
|
||||
alert.addButton(withTitle: "TimeoutButton".localized())
|
||||
|
||||
alert.beginSheetModal(for: self.view.window!) { _ in }
|
||||
runAfter(seconds: 0.3) {
|
||||
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: self.urls, textToSend: self.textToSend)
|
||||
|
||||
self.progressState?.stringValue = "Connecting".localized()
|
||||
|
||||
let timeoutAlert = DispatchWorkItem {
|
||||
if !self.connectionEstablished {
|
||||
AudioManager.playErrorSound()
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .critical
|
||||
|
||||
alert.messageText = "TimeoutTitle".localized()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.timeoutDispatchWorkItem = timeoutAlert
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert)
|
||||
}
|
||||
|
||||
timeoutDispatchWorkItem = timeoutAlert
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert)
|
||||
}
|
||||
|
||||
|
||||
@@ -293,18 +313,18 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
|
||||
let isRunning = runningApps.contains { $0.bundleIdentifier == bundleIdentifier }
|
||||
|
||||
if !isRunning {
|
||||
log("Launching main app")
|
||||
log("[ShareViewController] Launching main app")
|
||||
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) {
|
||||
let configuration = NSWorkspace.OpenConfiguration()
|
||||
NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in
|
||||
if let error = error {
|
||||
log("Failed to launch application: \(error)")
|
||||
log("[ShareViewController] Failed to launch application: \(error)")
|
||||
} else {
|
||||
log("Application launched successfully")
|
||||
log("[ShareViewController] Application launched successfully")
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
@@ -316,6 +336,9 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
|
||||
NearbyConnectionManager.shared.startDeviceDiscovery()
|
||||
scheduleAutomaticQrCodeView()
|
||||
}
|
||||
|
||||
// Force local network access prompt if not already granted
|
||||
let _ = DeviceToDeviceHeuristicScanner.shared.hasLocalNetworkAccess(completion: {_ in })
|
||||
}
|
||||
|
||||
|
||||
@@ -378,6 +401,12 @@ extension ShareViewController: NSCollectionViewDataSource {
|
||||
return name
|
||||
}
|
||||
|
||||
return "Android"
|
||||
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)
|
||||
}
|
||||
@@ -10,12 +10,24 @@ import SwiftUI
|
||||
import LUI
|
||||
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
override func loadView() {
|
||||
super.loadView()
|
||||
|
||||
// skip introduction for share extension
|
||||
LUIInit(configuration: configuration)
|
||||
LUISettings.sharedInstance.appLaunchedBefore = true
|
||||
}
|
||||
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
NearbyConnectionManager.shared.stopDeviceDiscovery()
|
||||
|
||||
loadAttachments(with: extensionContext, loadedItems: { [self] result in
|
||||
|
||||
log("ShareViewController: Loaded attachments: \(result)")
|
||||
log("[ShareViewController] Loaded attachments: \(result)")
|
||||
|
||||
var attachmentDetails = result
|
||||
attachmentDetails.closeView = { [weak self] in
|
||||
@@ -27,10 +39,6 @@ class ShareViewController: UIViewController {
|
||||
NearbyConnectionManager.shared.attachments = attachmentDetails
|
||||
}
|
||||
})
|
||||
|
||||
// skip introduction for share extension
|
||||
LUIInit(configuration: configuration)
|
||||
LUISettings.sharedInstance.appLaunchedBefore = true
|
||||
|
||||
let hostingController = UIHostingController(rootView: ContentView())
|
||||
|
||||
@@ -48,5 +56,12 @@ class ShareViewController: UIViewController {
|
||||
|
||||
hostingController.didMove(toParent: self)
|
||||
}
|
||||
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
NearbyConnectionManager.shared.stopDeviceDiscovery()
|
||||
|
||||
super.viewDidDisappear(animated)
|
||||
log("[ShareViewController] Disappeared view.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
<array>
|
||||
<string>group.com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.leonboettger.neardrop</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in new issue
Block a user