Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
9fd07e9326 | ||
|
|
70a6b5ff3e | ||
|
|
9536854fa2 | ||
|
|
39ea48fc80 | ||
|
|
4e73fced74 | ||
|
|
21f0f865b6 | ||
|
|
026ebc33db | ||
|
|
d01e1d7c34 | ||
|
|
14390035a3 | ||
|
|
a3d295cd38 | ||
|
|
0ca2a13d66 | ||
|
|
05321adb6d | ||
|
|
31b93a34a2 | ||
|
|
261f91bc94 | ||
|
|
646da767a7 | ||
|
|
9d1e698d9b | ||
|
|
a69a75cb07 | ||
|
|
1ac988a4fc | ||
|
|
673d4418f8 | ||
|
|
779144d26a | ||
|
|
0fa585652d | ||
|
|
1b1206d0b8 | ||
|
|
86afa8a206 | ||
|
|
dda001cb1a | ||
|
|
4ae1663779 | ||
|
|
66acbf25a3 | ||
|
|
93537094e4 | ||
|
|
b6b11c00c0 | ||
|
|
92671d8983 | ||
|
|
bfc23fde15 | ||
|
|
a39a5a324a | ||
|
|
fd7dfd959b | ||
|
|
bd4b181a49 | ||
|
|
f066a11608 | ||
|
|
5429126c1e | ||
|
|
4bf8a9052d | ||
|
|
f786af9d12 | ||
|
|
6def7dbc9f | ||
|
|
fcc0ce0818 | ||
|
|
fd6978cc90 | ||
|
|
12ef87c4da | ||
|
|
518fabcd33 | ||
|
|
eb6bb8fc4d | ||
|
|
d443412697 | ||
|
|
110b0a4ae7 | ||
|
|
2b272c81c1 | ||
|
|
11afc232ab | ||
|
|
943414db58 | ||
|
|
b135229d30 | ||
|
|
6126d9428d |
No files matched your search
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 3.2 MiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
@@ -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,7 @@ import Network
|
|||||||
import System
|
import System
|
||||||
import BigInt
|
import BigInt
|
||||||
import SwiftECC
|
import SwiftECC
|
||||||
|
import LUI
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
@@ -22,6 +23,7 @@ import UIKit
|
|||||||
class InboundNearbyConnection: NearbyConnection {
|
class InboundNearbyConnection: NearbyConnection {
|
||||||
|
|
||||||
private var filesToBeReceived: [Int64: InternalFileInfo] = [:]
|
private var filesToBeReceived: [Int64: InternalFileInfo] = [:]
|
||||||
|
private var downloadedFiles = [URL]()
|
||||||
private var currentState: State = .initial
|
private var currentState: State = .initial
|
||||||
private var cipherCommitment: Data?
|
private var cipherCommitment: Data?
|
||||||
private var textPayloadID: Int64 = 0
|
private var textPayloadID: Int64 = 0
|
||||||
@@ -49,10 +51,7 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
deletePartiallyReceivedFiles()
|
deletePartiallyReceivedFiles()
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.delegate?.connectionWasTerminated(connection: self, error: self.lastError)
|
self.delegate?.connectionWasTerminated(connection: self, savedFiles: self.downloadedFiles, error: self.lastError)
|
||||||
|
|
||||||
SaveFilesManager.shared.movePendingFilesToTarget()
|
|
||||||
SaveFilesManager.shared.stopAccessingSecurityScopedResource()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +168,8 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
fileInfo.progress?.unpublish()
|
fileInfo.progress?.unpublish()
|
||||||
#endif
|
#endif
|
||||||
SaveFilesManager.shared.registerFileFinishedDownloading(fileInfo.destinationURL)
|
downloadedFiles.append(fileInfo.destinationURL)
|
||||||
|
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL)
|
||||||
|
|
||||||
filesToBeReceived.removeValue(forKey: id)
|
filesToBeReceived.removeValue(forKey: id)
|
||||||
|
|
||||||
@@ -222,7 +222,8 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
fileInfo.progress?.unpublish()
|
fileInfo.progress?.unpublish()
|
||||||
#endif
|
#endif
|
||||||
filesToBeReceived.removeValue(forKey: id)
|
filesToBeReceived.removeValue(forKey: id)
|
||||||
SaveFilesManager.shared.registerFileFinishedDownloading(fileInfo.destinationURL)
|
downloadedFiles.append(fileInfo.destinationURL)
|
||||||
|
EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL)
|
||||||
|
|
||||||
if filesToBeReceived.isEmpty {
|
if filesToBeReceived.isEmpty {
|
||||||
log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...")
|
log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...")
|
||||||
@@ -241,19 +242,9 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
|
|
||||||
guard case .connectionRequest = frame.v1.type else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
|
guard case .connectionRequest = frame.v1.type else { throw NearbyError.protocolError("Unexpected frame type \(frame.v1.type)") }
|
||||||
|
|
||||||
let endpointInfo = frame.v1.connectionRequest.endpointInfo
|
let endpointInfo = EndpointInfo(data: frame.v1.connectionRequest.endpointInfo)
|
||||||
|
|
||||||
guard endpointInfo.count > 17 else { throw NearbyError.protocolError("Endpoint info too short") }
|
remoteDeviceInfo = RemoteDeviceInfo(name: endpointInfo?.name, type: endpointInfo?.deviceType)
|
||||||
|
|
||||||
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))
|
|
||||||
currentState = .receivedConnectionRequest
|
currentState = .receivedConnectionRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +383,13 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
|
|
||||||
let pkeFrame = frame.v1.pairedKeyEncryption
|
let pkeFrame = frame.v1.pairedKeyEncryption
|
||||||
|
|
||||||
|
if frame.v1.certificateInfo.publicCertificate.isEmpty {
|
||||||
|
log("[InboundNearbyConnection \(self.id)] Detected legacy peer.")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
log("[InboundNearbyConnection \(self.id)] Detected QuickDrop peer.")
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we can accept the connection automatically
|
// Check if we can accept the connection automatically
|
||||||
if !pkeFrame.secretIDHash.isEmpty,
|
if !pkeFrame.secretIDHash.isEmpty,
|
||||||
let trustedCertData = TrustStore.shared.findTrustedKey(for: pkeFrame.secretIDHash),
|
let trustedCertData = TrustStore.shared.findTrustedKey(for: pkeFrame.secretIDHash),
|
||||||
@@ -461,9 +459,11 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func makeFileDestinationURL(_ initialDest: URL) -> URL {
|
private func makeFileDestinationURL(_ initialDest: URL, usedDestinations: Set<URL>) -> URL {
|
||||||
var dest = initialDest
|
var dest = initialDest
|
||||||
if FileManager.default.fileExists(atPath: dest.path) {
|
let fm = FileManager.default
|
||||||
|
|
||||||
|
if fm.fileExists(atPath: dest.path) || usedDestinations.contains(dest) {
|
||||||
var counter = 1
|
var counter = 1
|
||||||
var path: String
|
var path: String
|
||||||
let ext = dest.pathExtension
|
let ext = dest.pathExtension
|
||||||
@@ -474,8 +474,8 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
path += ".\(ext)"
|
path += ".\(ext)"
|
||||||
}
|
}
|
||||||
counter += 1
|
counter += 1
|
||||||
} while FileManager.default.fileExists(atPath: path)
|
dest = URL(fileURLWithPath: path)
|
||||||
dest = URL(fileURLWithPath: path)
|
} while fm.fileExists(atPath: dest.path) || usedDestinations.contains(dest)
|
||||||
}
|
}
|
||||||
return dest
|
return dest
|
||||||
}
|
}
|
||||||
@@ -486,13 +486,19 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
currentState = .waitingForUserConsent
|
currentState = .waitingForUserConsent
|
||||||
|
|
||||||
if frame.v1.introduction.fileMetadata.count > 0 && frame.v1.introduction.textMetadata.isEmpty {
|
if frame.v1.introduction.fileMetadata.count > 0 && frame.v1.introduction.textMetadata.isEmpty {
|
||||||
let saveDirectory = SaveFilesManager.shared.getSaveDirectory()
|
let saveDirectory = NearbyConnectionManager.shared.getSaveDirectory()
|
||||||
|
var usedDestinations = Set<URL>()
|
||||||
|
|
||||||
for file in frame.v1.introduction.fileMetadata {
|
for file in frame.v1.introduction.fileMetadata {
|
||||||
let dest = makeFileDestinationURL(saveDirectory.appendingPathComponent(file.name))
|
let initialDest = saveDirectory.appendingPathComponent(file.name)
|
||||||
let info = InternalFileInfo(meta: FileMetadata(name: file.name, size: file.size, mimeType: file.mimeType),
|
let dest = makeFileDestinationURL(initialDest, usedDestinations: usedDestinations)
|
||||||
payloadID: file.payloadID,
|
usedDestinations.insert(dest)
|
||||||
destinationURL: dest)
|
|
||||||
|
let info = InternalFileInfo(
|
||||||
|
meta: FileMetadata(name: file.name, size: file.size, mimeType: file.mimeType),
|
||||||
|
payloadID: file.payloadID,
|
||||||
|
destinationURL: dest
|
||||||
|
)
|
||||||
filesToBeReceived[file.payloadID] = info
|
filesToBeReceived[file.payloadID] = info
|
||||||
bytesToBeTransferred += file.size
|
bytesToBeTransferred += file.size
|
||||||
}
|
}
|
||||||
@@ -500,56 +506,50 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
checkIfCanProceed(metadata: metadata)
|
checkIfCanProceed(metadata: metadata)
|
||||||
}
|
}
|
||||||
else if let textMetadata = frame.v1.introduction.textMetadata.first {
|
else if let textMetadata = frame.v1.introduction.textMetadata.first {
|
||||||
|
let isURL = textMetadata.type == .url
|
||||||
|
|
||||||
if textMetadata.type == .url || textMetadata.type == .text {
|
let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isURL ? .url : .text, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
|
||||||
|
textPayloadID = textMetadata.payloadID
|
||||||
let isClipboardText = textMetadata.type == .text
|
|
||||||
|
if !isURL {
|
||||||
let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isClipboardText ? .text : .url, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
|
isPlainTextTransfer = true
|
||||||
textPayloadID = textMetadata.payloadID
|
|
||||||
|
|
||||||
if isClipboardText {
|
|
||||||
isPlainTextTransfer = true
|
|
||||||
}
|
|
||||||
|
|
||||||
checkIfCanProceed(metadata: metadata)
|
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
rejectDueToUnsupportedFileType(frame)
|
checkIfCanProceed(metadata: metadata)
|
||||||
}
|
}
|
||||||
} else {
|
else {
|
||||||
rejectDueToUnsupportedFileType(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 checkIfCanProceed(metadata: TransferMetadata) {
|
func checkIfCanProceed(metadata: TransferMetadata) {
|
||||||
|
|
||||||
let acceptAutomatically = isAuthenticated || UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue)
|
let acceptAutomatically = isAuthenticated || Settings.shared.automaticallyAcceptFiles
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.delegate?.obtainUserConsent(transfer: metadata, device: self.remoteDeviceInfo!, connection: self, acceptAutomatically: acceptAutomatically)
|
if acceptAutomatically {
|
||||||
|
self.delegate?.obtainedUserConsentAutomatically(transfer: metadata, device: self.remoteDeviceInfo!, connection: self)
|
||||||
|
self.submitUserConsent(accepted: true, trustDevice: false)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.delegate?.obtainUserConsent(transfer: metadata, device: self.remoteDeviceInfo!, connection: self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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, storeInTemp: Bool = false) {
|
func submitUserConsent(accepted: Bool, trustDevice: Bool) {
|
||||||
if trustDevice, let peerCertificate = peerCertificate, let remoteDeviceInfo = remoteDeviceInfo {
|
if trustDevice, let peerCertificate = peerCertificate, let remoteDeviceInfo = remoteDeviceInfo {
|
||||||
TrustStore.shared.addTrusted(certificate: peerCertificate, device: remoteDeviceInfo)
|
TrustStore.shared.addTrusted(certificate: peerCertificate, device: remoteDeviceInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
NearbyConnection.dispatchQueue.async {
|
||||||
if accepted {
|
if accepted {
|
||||||
self.acceptTransfer(storeInTemp: storeInTemp)
|
self.acceptTransfer()
|
||||||
} else {
|
} else {
|
||||||
self.rejectTransfer()
|
self.rejectTransfer()
|
||||||
}
|
}
|
||||||
@@ -557,19 +557,28 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func acceptTransfer(storeInTemp: Bool) {
|
private func acceptTransfer() {
|
||||||
if currentState == .disconnected {
|
if currentState == .disconnected {
|
||||||
log("[InboundNearbyConnection \(self.id)] Detected timeout, not accepting transfer")
|
log("[InboundNearbyConnection \(self.id)] Detected timeout, not accepting transfer")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isFileTransferRestricted() {
|
||||||
|
log("[InboundNearbyConnection \(self.id)] File transfer restrictions detected.")
|
||||||
|
delegate?.showPlusScreen()
|
||||||
|
rejectTransfer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if storeInTemp {
|
|
||||||
try FileManager.default.createDirectory(at: SaveFilesManager.shared.tempDirectory, withIntermediateDirectories: true)
|
if filesToBeReceived.count >= 1 {
|
||||||
|
// Show progress bar for file transfers immediately
|
||||||
|
NearbyConnectionManager.shared.updatedTransferProgress(connection: self, progress: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (id, file) in filesToBeReceived {
|
for (id, file) in filesToBeReceived {
|
||||||
let targetURL = storeInTemp ? SaveFilesManager.shared.tempDirectory.appendingPathComponent(file.destinationURL.lastPathComponent) : file.destinationURL
|
let targetURL = file.destinationURL
|
||||||
|
|
||||||
FileManager.default.createFile(atPath: targetURL.path, contents: nil)
|
FileManager.default.createFile(atPath: targetURL.path, contents: nil)
|
||||||
let handle = try FileHandle(forWritingTo: targetURL)
|
let handle = try FileHandle(forWritingTo: targetURL)
|
||||||
@@ -638,12 +647,7 @@ class InboundNearbyConnection: NearbyConnection {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
guard file.created else { continue }
|
guard file.created else { continue }
|
||||||
do {
|
try? FileManager.default.removeItem(at: file.destinationURL)
|
||||||
try FileManager.default.removeItem(at: file.destinationURL)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// if it fails, we don't care. Could be because file was not created yet
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,12 @@ import Network
|
|||||||
import System
|
import System
|
||||||
import BigInt
|
import BigInt
|
||||||
import SwiftECC
|
import SwiftECC
|
||||||
|
import LUI
|
||||||
|
|
||||||
class NearbyConnection {
|
class NearbyConnection {
|
||||||
|
|
||||||
private let maxFrameLength = 5 * 1024 * 1024
|
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 id: String
|
||||||
let connection: NWConnection
|
let connection: NWConnection
|
||||||
@@ -37,7 +38,10 @@ class NearbyConnection {
|
|||||||
private var connectionClosed: Bool = false
|
private var connectionClosed: Bool = false
|
||||||
|
|
||||||
private var inactivityTimer: DispatchSourceTimer?
|
private var inactivityTimer: DispatchSourceTimer?
|
||||||
private let timeoutInterval: TimeInterval = 30
|
|
||||||
|
private let ackInterval: TimeInterval = 5
|
||||||
|
private let ackTimeoutInterval: TimeInterval = 30
|
||||||
|
private var lastKeepAliveFrameSent = Date()
|
||||||
|
|
||||||
// UKEY2-related state
|
// UKEY2-related state
|
||||||
var privateKey: ECPrivateKey?
|
var privateKey: ECPrivateKey?
|
||||||
@@ -117,7 +121,7 @@ class NearbyConnection {
|
|||||||
|
|
||||||
|
|
||||||
func connectionClosedByPeer(onError: @escaping () -> Void) {
|
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 already closed, ignore the error, as file transfer has been processed
|
||||||
if !self.connectionClosed {
|
if !self.connectionClosed {
|
||||||
@@ -164,6 +168,8 @@ class NearbyConnection {
|
|||||||
private func receiveFrameAsync() {
|
private func receiveFrameAsync() {
|
||||||
connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { content, _, isComplete, error in
|
connection.receive(minimumIncompleteLength: 4, maximumLength: 4) { content, _, isComplete, error in
|
||||||
if self.connectionClosed {
|
if self.connectionClosed {
|
||||||
|
log("[NearbyConnection \(self.id)] Received FIN from peer, connection closed.")
|
||||||
|
self.connection.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isComplete {
|
if isComplete {
|
||||||
@@ -171,6 +177,7 @@ class NearbyConnection {
|
|||||||
self.connectionClosedByPeer {
|
self.connectionClosedByPeer {
|
||||||
self.lastError = NearbyError.protocolError("Error.ClosedByPeer".localized())
|
self.lastError = NearbyError.protocolError("Error.ClosedByPeer".localized())
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
|
self.connection.cancel()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -201,6 +208,8 @@ class NearbyConnection {
|
|||||||
private func receiveFrameAsync(length: UInt32) {
|
private func receiveFrameAsync(length: UInt32) {
|
||||||
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { [self] content, _, isComplete, _ in
|
connection.receive(minimumIncompleteLength: Int(length), maximumLength: Int(length)) { [self] content, _, isComplete, _ in
|
||||||
if self.connectionClosed {
|
if self.connectionClosed {
|
||||||
|
log("[NearbyConnection \(self.id)] Received FIN from peer, connection closed.")
|
||||||
|
connection.cancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if isComplete {
|
if isComplete {
|
||||||
@@ -216,6 +225,7 @@ class NearbyConnection {
|
|||||||
self.connectionClosedByPeer {
|
self.connectionClosedByPeer {
|
||||||
self.lastError = NearbyError.protocolError("Error.ClosedByPeer".localized())
|
self.lastError = NearbyError.protocolError("Error.ClosedByPeer".localized())
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
|
self.connection.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -422,11 +432,6 @@ class NearbyConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .keepAlive = offlineFrame.v1.type {
|
else if offlineFrame.hasV1, offlineFrame.v1.hasType, case .keepAlive = offlineFrame.v1.type {
|
||||||
|
|
||||||
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)
|
sendKeepAlive(ack: true)
|
||||||
} else {
|
} else {
|
||||||
|
|
||||||
@@ -515,6 +520,19 @@ class NearbyConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
cancelled = true
|
||||||
|
if encryptionDone {
|
||||||
|
var cancel = Sharing_Nearby_Frame()
|
||||||
|
cancel.version = .v1
|
||||||
|
cancel.v1 = Sharing_Nearby_V1Frame()
|
||||||
|
cancel.v1.type = .cancel
|
||||||
|
try? sendTransferSetupFrame(cancel)
|
||||||
|
}
|
||||||
|
try? sendDisconnectionAndDisconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func disconnect() {
|
func disconnect() {
|
||||||
log("[NearbyConnection \(self.id)] Disconnecting from connection.")
|
log("[NearbyConnection \(self.id)] Disconnecting from connection.")
|
||||||
|
|
||||||
@@ -568,25 +586,33 @@ class NearbyConnection {
|
|||||||
} else {
|
} else {
|
||||||
try sendFrameAsync(offlineFrame.serializedData())
|
try sendFrameAsync(offlineFrame.serializedData())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.lastKeepAliveFrameSent = Date()
|
||||||
} catch {
|
} catch {
|
||||||
log("[NearbyConnection \(self.id)] Error sending KEEP_ALIVE: \(error)")
|
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() {
|
func startAndResetHeartbeatTimer() {
|
||||||
|
|
||||||
|
if lastKeepAliveFrameSent.isOlderThan(seconds: ackInterval) {
|
||||||
|
sendKeepAlive(ack: false)
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel previous timer if any
|
// Cancel previous timer if any
|
||||||
inactivityTimer?.cancel()
|
inactivityTimer?.cancel()
|
||||||
|
|
||||||
inactivityTimer = DispatchSource.makeTimerSource(queue: NearbyConnection.dispatchQueue)
|
inactivityTimer = DispatchSource.makeTimerSource(queue: NearbyConnection.dispatchQueue)
|
||||||
inactivityTimer?.schedule(deadline: .now() + timeoutInterval)
|
inactivityTimer?.schedule(deadline: .now() + ackTimeoutInterval)
|
||||||
inactivityTimer?.setEventHandler { [weak self] in
|
inactivityTimer?.setEventHandler { [weak self] in
|
||||||
|
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
if !self.connectionClosed {
|
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.lastError = NearbyError.canceled(reason: .timedOut)
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
}
|
}
|
||||||
@@ -597,7 +623,7 @@ class NearbyConnection {
|
|||||||
|
|
||||||
|
|
||||||
func startDataTransferTimer(previousBytesTransferred: Int64) {
|
func startDataTransferTimer(previousBytesTransferred: Int64) {
|
||||||
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + timeoutInterval) {
|
NearbyConnection.dispatchQueue.asyncAfter(deadline: .now() + ackTimeoutInterval) {
|
||||||
|
|
||||||
if previousBytesTransferred < self.bytesTransferred {
|
if previousBytesTransferred < self.bytesTransferred {
|
||||||
// everything good, transferred more than last check, schedule next check
|
// everything good, transferred more than last check, schedule next check
|
||||||
@@ -606,7 +632,7 @@ class NearbyConnection {
|
|||||||
else {
|
else {
|
||||||
// connection stale, need to abort
|
// connection stale, need to abort
|
||||||
if !self.connectionClosed {
|
if !self.connectionClosed {
|
||||||
log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.timeoutInterval) seconds")
|
log("[NearbyConnection \(self.id)] Connection timeout: No more data received in \(self.ackTimeoutInterval) seconds")
|
||||||
self.lastError = NearbyError.canceled(reason: .timedOut)
|
self.lastError = NearbyError.canceled(reason: .timedOut)
|
||||||
self.disconnect()
|
self.disconnect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
import RNDeviceName
|
import DeviceKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
@@ -16,6 +16,8 @@ import System
|
|||||||
import SwiftECC
|
import SwiftECC
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import QRCode
|
||||||
|
import LUI
|
||||||
|
|
||||||
public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject {
|
public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject {
|
||||||
|
|
||||||
@@ -26,7 +28,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
private var mdnsServices: [NetService] = []
|
private var mdnsServices: [NetService] = []
|
||||||
private var incomingConnections: [String: InboundNearbyConnection] = [:]
|
private var incomingConnections: [String: InboundNearbyConnection] = [:]
|
||||||
private var foundServices: [String: FoundServiceInfo] = [:]
|
private var foundServices: [String: FoundServiceInfo] = [:]
|
||||||
private var shareExtensionDelegates: [OutboundAppDelegate] = []
|
private var outboundAppDelegates: [OutboundAppDelegate] = []
|
||||||
|
private var inboundAppDelegates: [InboundAppDelegate] = []
|
||||||
private var outgoingTransfers: [String: OutgoingTransferInfo] = [:]
|
private var outgoingTransfers: [String: OutgoingTransferInfo] = [:]
|
||||||
private var startedDeviceDiscovery = false
|
private var startedDeviceDiscovery = false
|
||||||
private var startedAdvertising = false
|
private var startedAdvertising = false
|
||||||
@@ -37,54 +40,124 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
private var qrCodeNameEncryptionKey: SymmetricKey?
|
private var qrCodeNameEncryptionKey: SymmetricKey?
|
||||||
private let hasConnectionMonitor = NWPathMonitor()
|
private let hasConnectionMonitor = NWPathMonitor()
|
||||||
private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue")
|
private let connectionMonitorQueue = DispatchQueue(label: "NetworkConnectionMonitorQueue")
|
||||||
|
private var securityScopeUrl: URL?
|
||||||
|
private static let customDeviceNameKey = "com.leonboettger.quickdrop.deviceName"
|
||||||
|
|
||||||
public let deviceInfo: EndpointInfo
|
|
||||||
public let endpointID: [UInt8] = generateEndpointID()
|
public let endpointID: [UInt8] = generateEndpointID()
|
||||||
public var mainAppDelegate: (any InboundAppDelegate)?
|
|
||||||
public static let shared = NearbyConnectionManager()
|
public static let shared = NearbyConnectionManager()
|
||||||
|
|
||||||
@Published var attachments: AttachmentDetails? = nil
|
@Published var attachments: AttachmentDetails? = nil
|
||||||
@Published var hasLocalNetworkAccess = true
|
@Published var hasLocalNetworkPermission = true
|
||||||
@Published var isConnectedViaWiFi = true
|
@Published var isConnectedToLocalNetwork = true
|
||||||
|
@Published var deviceInfo: EndpointInfo
|
||||||
|
|
||||||
public var connectionUpdateCallback: (Bool) -> Void = { _ in } {
|
public var connectionUpdateCallback: (Bool) -> Void = { _ in } {
|
||||||
didSet {
|
didSet {
|
||||||
connectionUpdateCallback(isConnectedViaWiFi)
|
connectionUpdateCallback(isConnectedToLocalNetwork)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var changedDeviceNameCallback: () -> Void = { }
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
|
self.deviceInfo = Self.getEndpointInfo()
|
||||||
#if os(macOS)
|
|
||||||
self.deviceInfo = EndpointInfo(name: Host.current().localizedName ?? "Mac", deviceType: .computer)
|
|
||||||
#else
|
|
||||||
let marketingName = UIDevice.current.marketingName.withoutBracketedContent
|
|
||||||
let isiPad = UIDevice.current.model == "iPad"
|
|
||||||
|
|
||||||
self.deviceInfo = EndpointInfo(name: marketingName, deviceType: isiPad ? .tablet : .phone)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
tcpListener = try! NWListener(using: NWParameters(tls: .none))
|
tcpListener = try! NWListener(using: NWParameters(tls: .none))
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
hasConnectionMonitor.pathUpdateHandler = { path in
|
hasConnectionMonitor.pathUpdateHandler = { path in
|
||||||
if path.usesInterfaceType(.wifi) {
|
|
||||||
log("[NearbyConnectionManager] Connected via WiFi.")
|
#if os(macOS)
|
||||||
DispatchQueue.main.async {
|
// On Mac, the local network is available as long as the path is satisfied, since Macs do not have cellular modems
|
||||||
self.isConnectedViaWiFi = true
|
let isConnected = path.status == .satisfied
|
||||||
self.connectionUpdateCallback(true)
|
#else
|
||||||
}
|
// On iPhone and iPad, the local network is only available using Wi-Fi or Ethernet
|
||||||
} else {
|
let isConnected = path.usesInterfaceType(.wifi) || path.usesInterfaceType(.wiredEthernet)
|
||||||
log("[NearbyConnectionManager] WiFi connection lost.")
|
#endif
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.isConnectedViaWiFi = false
|
DispatchQueue.main.async {
|
||||||
self.connectionUpdateCallback(false)
|
if isConnected {
|
||||||
|
log("[NearbyConnectionManager] Connected to local network.")
|
||||||
|
} else {
|
||||||
|
log("[NearbyConnectionManager] Local network lost.")
|
||||||
}
|
}
|
||||||
|
self.isConnectedToLocalNetwork = isConnected
|
||||||
|
self.connectionUpdateCallback(isConnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasConnectionMonitor.start(queue: connectionMonitorQueue)
|
hasConnectionMonitor.start(queue: connectionMonitorQueue)
|
||||||
|
|
||||||
|
|
||||||
|
// remove old temp directory
|
||||||
|
let tempPath = FileManager.default.temporaryDirectory
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
|
||||||
|
do {
|
||||||
|
let contents = try fileManager.contentsOfDirectory(at: tempPath, includingPropertiesForKeys: nil)
|
||||||
|
|
||||||
|
var didSomething = false
|
||||||
|
|
||||||
|
for item in contents {
|
||||||
|
didSomething = true
|
||||||
|
try fileManager.removeItem(at: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if didSomething {
|
||||||
|
log("[SaveFilesManager] Temporary directory cleared.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log("[SaveFilesManager] Failed to list contents of temp directory: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -117,7 +190,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
|
|
||||||
public func becomeInvisible() {
|
public func becomeInvisible() {
|
||||||
if !startedAdvertising {
|
if !startedAdvertising {
|
||||||
print("[NearbyConnectionManager] Already invisible, ignoring becomeInvisible()")
|
log("[NearbyConnectionManager] Already invisible, ignoring becomeInvisible()")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,12 +203,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
|
|
||||||
|
|
||||||
private static func generateEndpointID() -> [UInt8] {
|
private static func generateEndpointID() -> [UInt8] {
|
||||||
let userDefaultsKey = UserDefaultsKeys.endpointID.rawValue
|
|
||||||
|
|
||||||
// Try to retrieve from UserDefaults
|
// Try to retrieve from UserDefaults
|
||||||
if let savedString = UserDefaults.standard.string(forKey: userDefaultsKey),
|
if let savedString = Settings.shared.endpointID,
|
||||||
let savedData = savedString.data(using: .utf8)
|
let savedData = savedString.data(using: .utf8) {
|
||||||
{
|
|
||||||
return [UInt8](savedData)
|
return [UInt8](savedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,7 +219,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
|
|
||||||
// Save to UserDefaults as String
|
// Save to UserDefaults as String
|
||||||
let idString = String(bytes: id, encoding: .utf8) ?? ""
|
let idString = String(bytes: id, encoding: .utf8) ?? ""
|
||||||
UserDefaults.standard.set(idString, forKey: userDefaultsKey)
|
Settings.shared.endpointID = idString
|
||||||
|
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -186,30 +257,50 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection _: InboundNearbyConnection, acceptAutomatically: Bool) {
|
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection _: InboundNearbyConnection) {
|
||||||
mainAppDelegate?.obtainUserConsent(transfer: transfer, device: device, acceptAutomatically: acceptAutomatically)
|
inboundAppDelegates.forEach { delegate in
|
||||||
|
delegate.obtainUserConsent(transfer: transfer, device: device)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func connectionWasTerminated(connection: InboundNearbyConnection, error: Error?) {
|
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection) {
|
||||||
|
inboundAppDelegates.forEach { delegate in
|
||||||
|
delegate.obtainedUserConsentAutomatically(transfer: transfer, device: device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?) {
|
||||||
incomingConnections.removeValue(forKey: connection.id)
|
incomingConnections.removeValue(forKey: connection.id)
|
||||||
|
|
||||||
if !connection.wasUserRejected {
|
if !connection.wasUserRejected {
|
||||||
mainAppDelegate?.connectionWasTerminated(from: connection.remoteDeviceInfo ?? RemoteDeviceInfo(name: "??", type: .unknown), wasPlainTextTransfer: connection.isPlainTextTransfer, error: error)
|
inboundAppDelegates.forEach { delegate in
|
||||||
|
delegate.connectionWasTerminated(connectionID: connection.id, from: connection.remoteDeviceInfo, savedFiles: savedFiles, wasPlainTextTransfer: connection.isPlainTextTransfer, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func showPlusScreen() {
|
||||||
|
inboundAppDelegates.forEach { delegate in
|
||||||
|
delegate.showPlusScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double) {
|
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double) {
|
||||||
mainAppDelegate?.transferProgress(progress: progress)
|
inboundAppDelegates.forEach { delegate in
|
||||||
|
delegate.transferProgress(connectionID: connection.id, progress: progress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public func submitUserConsent(transferID: String, accept: Bool, trustDevice: Bool, storeInTemp: Bool = false) {
|
public func submitUserConsent(transferID: String, accept: Bool, trustDevice: Bool) {
|
||||||
guard let conn = incomingConnections[transferID] else { return }
|
guard let conn = incomingConnections[transferID] else { return }
|
||||||
|
|
||||||
log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept), store in temp: \(storeInTemp)")
|
log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept)")
|
||||||
conn.submitUserConsent(accepted: accept, trustDevice: trustDevice, storeInTemp: storeInTemp)
|
conn.submitUserConsent(accepted: accept, trustDevice: trustDevice)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -245,13 +336,13 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
log("[NearbyConnectionManager] Browser failed: \(error)")
|
log("[NearbyConnectionManager] Browser failed: \(error)")
|
||||||
case .ready:
|
case .ready:
|
||||||
log("[NearbyConnectionManager] Browser ready")
|
log("[NearbyConnectionManager] Browser ready")
|
||||||
self.hasLocalNetworkAccess = true
|
self.hasLocalNetworkPermission = true
|
||||||
case .waiting(let error):
|
case .waiting(let error):
|
||||||
log("[NearbyConnectionManager] Browser waiting: \(error)")
|
log("[NearbyConnectionManager] Browser waiting: \(error)")
|
||||||
|
|
||||||
if error == NWError.dns(DNSServiceErrorType(kDNSServiceErr_PolicyDenied)) {
|
if error == NWError.dns(DNSServiceErrorType(kDNSServiceErr_PolicyDenied)) {
|
||||||
log("[NearbyConnectionManager] Local network access not granted.")
|
log("[NearbyConnectionManager] Local network access not granted.")
|
||||||
self.hasLocalNetworkAccess = false
|
self.hasLocalNetworkPermission = false
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -279,7 +370,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
startedDeviceDiscovery = false
|
startedDeviceDiscovery = false
|
||||||
|
|
||||||
|
|
||||||
for delegate in shareExtensionDelegates {
|
for delegate in outboundAppDelegates {
|
||||||
|
|
||||||
foundServices.values.forEach { service in
|
foundServices.values.forEach { service in
|
||||||
guard let device = service.device, let id = device.id else { return }
|
guard let device = service.device, let id = device.id else { return }
|
||||||
@@ -290,8 +381,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public func addShareExtensionDelegate(_ delegate: OutboundAppDelegate) {
|
public func addOutboundAppDelegate(_ delegate: OutboundAppDelegate) {
|
||||||
shareExtensionDelegates.append(delegate)
|
outboundAppDelegates.append(delegate)
|
||||||
for service in foundServices.values {
|
for service in foundServices.values {
|
||||||
guard let device = service.device else { continue }
|
guard let device = service.device else { continue }
|
||||||
delegate.addDevice(device: device)
|
delegate.addDevice(device: device)
|
||||||
@@ -299,14 +390,28 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public func removeShareExtensionDelegate(_ delegate: OutboundAppDelegate) {
|
public func addInboundAppDelegate(_ delegate: InboundAppDelegate) {
|
||||||
shareExtensionDelegates.removeAll(where: { $0 === delegate })
|
inboundAppDelegates.append(delegate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public func cancelOutgoingTransfer(id: String) {
|
public func removeOutboundAppDelegate(_ delegate: OutboundAppDelegate) {
|
||||||
guard let transfer = outgoingTransfers[id] else { return }
|
outboundAppDelegates.removeAll(where: { $0 === delegate })
|
||||||
transfer.connection.cancel()
|
}
|
||||||
|
|
||||||
|
public func removeInboundAppDelegate(_ delegate: InboundAppDelegate) {
|
||||||
|
inboundAppDelegates.removeAll(where: { $0 === delegate })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public func cancelTransfer(id: String) {
|
||||||
|
|
||||||
|
if let transfer = incomingConnections[id] {
|
||||||
|
transfer.cancel()
|
||||||
|
}
|
||||||
|
if let info = outgoingTransfers[id] {
|
||||||
|
info.connection.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -349,7 +454,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
|
|
||||||
if qrData == qrCodeAdvertisingToken {
|
if qrData == qrCodeAdvertisingToken {
|
||||||
if let deviceInfo = deviceInfo {
|
if let deviceInfo = deviceInfo {
|
||||||
for delegate in shareExtensionDelegates {
|
for delegate in outboundAppDelegates {
|
||||||
delegate.startTransferWithQrCode(device: deviceInfo)
|
delegate.startTransferWithQrCode(device: deviceInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -361,7 +466,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
guard let name = String.init(data: decryptedName, encoding: .utf8) else { return }
|
guard let name = String.init(data: decryptedName, encoding: .utf8) else { return }
|
||||||
endpointInfo.name = name
|
endpointInfo.name = name
|
||||||
let deviceInfo = addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID)
|
let deviceInfo = addFoundDevice(foundService: &foundService, endpointInfo: endpointInfo, endpointID: endpointID)
|
||||||
for delegate in shareExtensionDelegates {
|
for delegate in outboundAppDelegates {
|
||||||
delegate.startTransferWithQrCode(device: deviceInfo)
|
delegate.startTransferWithQrCode(device: deviceInfo)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -377,7 +482,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
foundService.device = deviceInfo
|
foundService.device = deviceInfo
|
||||||
foundServices[endpointID] = foundService
|
foundServices[endpointID] = foundService
|
||||||
|
|
||||||
for delegate in shareExtensionDelegates {
|
for delegate in outboundAppDelegates {
|
||||||
delegate.addDevice(device: deviceInfo)
|
delegate.addDevice(device: deviceInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,19 +493,23 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
private func removeFoundDevice(service: NWBrowser.Result) {
|
private func removeFoundDevice(service: NWBrowser.Result) {
|
||||||
guard let endpointID = endpointID(for: service) else { return }
|
guard let endpointID = endpointID(for: service) else { return }
|
||||||
guard let _ = foundServices.removeValue(forKey: endpointID) else { return }
|
guard let _ = foundServices.removeValue(forKey: endpointID) else { return }
|
||||||
for delegate in shareExtensionDelegates {
|
for delegate in outboundAppDelegates {
|
||||||
delegate.removeDevice(id: endpointID)
|
delegate.removeDevice(id: endpointID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public func generateQrCodeKey() -> String{
|
public func generateQrCodeKey() -> Image? {
|
||||||
|
|
||||||
|
log("[NearbyConnectionManager] Generating new QR code key")
|
||||||
|
|
||||||
let domain = Domain.instance(curve: .EC256r1)
|
let domain = Domain.instance(curve: .EC256r1)
|
||||||
let (pubKey, privKey) = domain.makeKeyPair()
|
let (pubKey, privKey) = domain.makeKeyPair()
|
||||||
qrCodePrivateKey = privKey
|
qrCodePrivateKey = privKey
|
||||||
var keyData = Data()
|
var keyData = Data()
|
||||||
keyData.append(contentsOf: [0, 0, 2])
|
keyData.append(contentsOf: [0, 0, 2])
|
||||||
let keyBytes = Data(pubKey.w.x.asSignedBytes())
|
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)
|
// 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(keyBytes.suffixOfAtMost(numBytes: 32))
|
||||||
|
|
||||||
@@ -408,7 +517,26 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
qrCodeAdvertisingToken = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputLength: 16).data()
|
qrCodeAdvertisingToken = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "advertisingContext".data(using: .utf8)!, outputLength: 16).data()
|
||||||
qrCodeNameEncryptionKey = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputLength: 16)
|
qrCodeNameEncryptionKey = HKDF.deriveKey(ikm: ikm, salt: Data(), info: "encryptionKey".data(using: .utf8)!, outputLength: 16)
|
||||||
|
|
||||||
return keyData.urlSafeBase64EncodedString()
|
|
||||||
|
do {
|
||||||
|
let qrKey = keyData.urlSafeBase64EncodedString()
|
||||||
|
let qrCodeImage = try QRCode.build
|
||||||
|
.text("https://quickshare.google/qrcode#key=\(qrKey)")
|
||||||
|
.backgroundColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0))
|
||||||
|
.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("Error generating QR code: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -430,8 +558,8 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
outgoingTransfers[deviceID] = transfer
|
outgoingTransfers[deviceID] = transfer
|
||||||
conn.start()
|
conn.start()
|
||||||
} catch {
|
} catch {
|
||||||
log("[NearbyConnectionManager] Error zipping URLs: \(error)")
|
log("[NearbyConnectionManager] Error storing URLs: \(error)")
|
||||||
shareExtensionDelegates.forEach { delegate in
|
outboundAppDelegates.forEach { delegate in
|
||||||
delegate.connectionFailed(error: error)
|
delegate.connectionFailed(error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -521,7 +649,6 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
if accessSuccess {
|
if accessSuccess {
|
||||||
url.stopAccessingSecurityScopedResource()
|
url.stopAccessingSecurityScopedResource()
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log("[NearbyConnectionManager] File URL \(url) does not point to a security scoped resource")
|
log("[NearbyConnectionManager] File URL \(url) does not point to a security scoped resource")
|
||||||
@@ -533,6 +660,68 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -- MARK: - Inbound Connection Data Store
|
||||||
|
|
||||||
|
public func getSaveDirectory() -> URL {
|
||||||
|
|
||||||
|
// Not supported on iOS
|
||||||
|
#if os(macOS)
|
||||||
|
if let securityScopeUrl = securityScopeUrl {
|
||||||
|
log("[SaveFilesManager] Using existing security scope URL: \(securityScopeUrl)")
|
||||||
|
return securityScopeUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
if let bookmarkData = Settings.shared.saveFolderBookmark {
|
||||||
|
var isStale = false
|
||||||
|
|
||||||
|
do {
|
||||||
|
let url = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
||||||
|
|
||||||
|
if !isStale {
|
||||||
|
if url.startAccessingSecurityScopedResource() {
|
||||||
|
log("[SaveFilesManager] Successfully accessed security scoped resource: \(url)")
|
||||||
|
|
||||||
|
securityScopeUrl = url
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log("[SaveFilesManager] Bookmark is stale, using default downloads folder.")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
log("[SaveFilesManager] Failed to resolve bookmark: \(error), using default downloads folder.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try FileManager.default.url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: true).resolvingSymlinksInPath()
|
||||||
|
} catch {
|
||||||
|
fatalError("[SaveFilesManager] Failed to get downloads directory: \(error)")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
// Return the documents directory for iOS
|
||||||
|
do {
|
||||||
|
return try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).resolvingSymlinksInPath()
|
||||||
|
} catch {
|
||||||
|
fatalError("Failed to get documents directory: \(error)")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func stopAccessingSaveDirectory() {
|
||||||
|
// Clean up security scoped resource access
|
||||||
|
guard let url = securityScopeUrl else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[SaveFilesManager] Stopping access to security scoped resource: \(url)")
|
||||||
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
securityScopeUrl = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// -- MARK: - Internal Data Model
|
// -- MARK: - Internal Data Model
|
||||||
|
|
||||||
private struct FoundServiceInfo {
|
private struct FoundServiceInfo {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import UniformTypeIdentifiers
|
|||||||
import BigInt
|
import BigInt
|
||||||
import SwiftECC
|
import SwiftECC
|
||||||
import ASN1
|
import ASN1
|
||||||
import BigInt // required dependency of ASN1
|
import BigInt
|
||||||
|
import LUI
|
||||||
|
|
||||||
|
|
||||||
class OutboundNearbyConnection: NearbyConnection {
|
class OutboundNearbyConnection: NearbyConnection {
|
||||||
@@ -62,25 +63,24 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
override func disconnect() {
|
override func disconnect() {
|
||||||
super.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 {
|
if let error = lastError {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.delegate?.failedWithError(connection: self, error: error)
|
self.delegate?.failedWithError(connection: self, error: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public func cancel() {
|
|
||||||
cancelled = true
|
|
||||||
if encryptionDone {
|
|
||||||
var cancel = Sharing_Nearby_Frame()
|
|
||||||
cancel.version = .v1
|
|
||||||
cancel.v1 = Sharing_Nearby_V1Frame()
|
|
||||||
cancel.v1.type = .cancel
|
|
||||||
try? sendTransferSetupFrame(cancel)
|
|
||||||
}
|
|
||||||
try? sendDisconnectionAndDisconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override func connectionReady() {
|
override func connectionReady() {
|
||||||
@@ -175,8 +175,8 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
frame.v1 = Location_Nearby_Connections_V1Frame()
|
frame.v1 = Location_Nearby_Connections_V1Frame()
|
||||||
frame.v1.type = .connectionRequest
|
frame.v1.type = .connectionRequest
|
||||||
frame.v1.connectionRequest = Location_Nearby_Connections_ConnectionRequestFrame()
|
frame.v1.connectionRequest = Location_Nearby_Connections_ConnectionRequestFrame()
|
||||||
frame.v1.connectionRequest.endpointID = String(bytes: NearbyConnectionManager.shared.endpointID, encoding: .ascii)!
|
frame.v1.connectionRequest.endpointID = Data(NearbyConnectionManager.shared.endpointID)
|
||||||
frame.v1.connectionRequest.endpointName = endpointInfo.name ?? "QuickDrop"
|
frame.v1.connectionRequest.endpointName = Data((endpointInfo.name ?? "QuickDrop").utf8)
|
||||||
frame.v1.connectionRequest.endpointInfo = endpointInfo.serialize()
|
frame.v1.connectionRequest.endpointInfo = endpointInfo.serialize()
|
||||||
frame.v1.connectionRequest.mediums = [.wifiLan]
|
frame.v1.connectionRequest.mediums = [.wifiLan]
|
||||||
try sendFrameAsync(frame.serializedData())
|
try sendFrameAsync(frame.serializedData())
|
||||||
@@ -347,6 +347,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
meta.textTitle = textToSend
|
meta.textTitle = textToSend
|
||||||
meta.size = Int64(textToSend.utf8.count)
|
meta.size = Int64(textToSend.utf8.count)
|
||||||
meta.payloadID = textPayloadID
|
meta.payloadID = textPayloadID
|
||||||
|
meta.id = Int64.random(in: Int64.min ... Int64.max)
|
||||||
introduction.v1.introduction.textMetadata.append(meta)
|
introduction.v1.introduction.textMetadata.append(meta)
|
||||||
|
|
||||||
} else if hasURL() {
|
} else if hasURL() {
|
||||||
@@ -355,6 +356,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
meta.textTitle = urlsToSend[0].host ?? "URL"
|
meta.textTitle = urlsToSend[0].host ?? "URL"
|
||||||
meta.size = Int64(urlsToSend[0].absoluteString.utf8.count)
|
meta.size = Int64(urlsToSend[0].absoluteString.utf8.count)
|
||||||
meta.payloadID = textPayloadID
|
meta.payloadID = textPayloadID
|
||||||
|
meta.id = Int64.random(in: Int64.min ... Int64.max)
|
||||||
introduction.v1.introduction.textMetadata.append(meta)
|
introduction.v1.introduction.textMetadata.append(meta)
|
||||||
} else {
|
} else {
|
||||||
for url in urlsToSend {
|
for url in urlsToSend {
|
||||||
@@ -382,12 +384,15 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
} else {
|
} else {
|
||||||
meta.type = .unknown
|
meta.type = .unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
meta.payloadID = Int64.random(in: Int64.min ... Int64.max)
|
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)
|
introduction.v1.introduction.fileMetadata.append(meta)
|
||||||
totalBytesToSend += meta.size
|
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)
|
try sendTransferSetupFrame(introduction)
|
||||||
@@ -454,8 +459,18 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
}
|
}
|
||||||
if queue.isEmpty {
|
if queue.isEmpty {
|
||||||
log("[OutboundNearbyConnection \(self.id)] Disconnecting because all files have been transferred")
|
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
|
return
|
||||||
}
|
}
|
||||||
currentTransfer = queue.removeFirst()
|
currentTransfer = queue.removeFirst()
|
||||||
@@ -476,6 +491,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
transfer.payloadHeader.type = .file
|
transfer.payloadHeader.type = .file
|
||||||
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
|
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
|
||||||
transfer.payloadHeader.isSensitive = false
|
transfer.payloadHeader.isSensitive = false
|
||||||
|
transfer.payloadHeader.fileName = currentTransfer!.fileName
|
||||||
currentTransfer!.currentOffset += Int64(fileBuffer.count)
|
currentTransfer!.currentOffset += Int64(fileBuffer.count)
|
||||||
|
|
||||||
var wrapper = Location_Nearby_Connections_OfflineFrame()
|
var wrapper = Location_Nearby_Connections_OfflineFrame()
|
||||||
@@ -498,11 +514,12 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
delegate?.updatedTransferProgress(connection: self, progress: Double(bytesTransferred) / Double(totalBytesToSend))
|
delegate?.updatedTransferProgress(connection: self, progress: Double(bytesTransferred) / Double(totalBytesToSend))
|
||||||
|
|
||||||
if currentTransfer!.currentOffset == currentTransfer!.totalBytes {
|
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()
|
var transfer = Location_Nearby_Connections_PayloadTransferFrame()
|
||||||
transfer.packetType = .data
|
transfer.packetType = .data
|
||||||
transfer.payloadChunk.offset = currentTransfer!.currentOffset
|
transfer.payloadChunk.offset = currentTransfer!.currentOffset
|
||||||
transfer.payloadChunk.flags = 1 // <- this one here
|
transfer.payloadChunk.flags = 1
|
||||||
transfer.payloadHeader.id = currentTransfer!.payloadID
|
transfer.payloadHeader.id = currentTransfer!.payloadID
|
||||||
transfer.payloadHeader.type = .file
|
transfer.payloadHeader.type = .file
|
||||||
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
|
transfer.payloadHeader.totalSize = Int64(currentTransfer!.totalBytes)
|
||||||
@@ -528,6 +545,7 @@ class OutboundNearbyConnection: NearbyConnection {
|
|||||||
|
|
||||||
private struct OutgoingFileTransfer {
|
private struct OutgoingFileTransfer {
|
||||||
let url: URL
|
let url: URL
|
||||||
|
let fileName: String
|
||||||
let payloadID: Int64
|
let payloadID: Int64
|
||||||
let handle: FileHandle?
|
let handle: FileHandle?
|
||||||
let totalBytes: Int64
|
let totalBytes: Int64
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import Foundation
|
|||||||
import Network
|
import Network
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import SwiftECC
|
import SwiftECC
|
||||||
|
import LUI
|
||||||
|
|
||||||
|
|
||||||
protocol InboundNearbyConnectionDelegate {
|
protocol InboundNearbyConnectionDelegate {
|
||||||
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection, acceptAutomatically: Bool)
|
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
|
||||||
|
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo, connection: InboundNearbyConnection)
|
||||||
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double)
|
func updatedTransferProgress(connection: InboundNearbyConnection, progress: Double)
|
||||||
func connectionWasTerminated(connection: InboundNearbyConnection, error: Error?)
|
func connectionWasTerminated(connection: InboundNearbyConnection, savedFiles: [URL], error: Error?)
|
||||||
|
func showPlusScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -28,9 +31,11 @@ protocol OutboundNearbyConnectionDelegate {
|
|||||||
|
|
||||||
|
|
||||||
public protocol InboundAppDelegate: AnyObject {
|
public protocol InboundAppDelegate: AnyObject {
|
||||||
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, acceptAutomatically: Bool)
|
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo)
|
||||||
func connectionWasTerminated(from device: RemoteDeviceInfo, wasPlainTextTransfer: Bool, error: Error?)
|
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo)
|
||||||
func transferProgress(progress: Double)
|
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: Error?)
|
||||||
|
func transferProgress(connectionID: String, progress: Double)
|
||||||
|
func showPlusScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -52,9 +57,9 @@ public struct RemoteDeviceInfo: Codable, Identifiable, Equatable {
|
|||||||
public let qrCodeData: Data?
|
public let qrCodeData: Data?
|
||||||
public var id: String?
|
public var id: String?
|
||||||
|
|
||||||
init(name: String, type: DeviceType, id: String? = nil) {
|
init(name: String?, type: DeviceType?, id: String? = nil) {
|
||||||
self.name = name
|
self.name = name
|
||||||
self.type = type
|
self.type = type ?? .phone
|
||||||
self.id = id
|
self.id = id
|
||||||
self.qrCodeData = nil
|
self.qrCodeData = nil
|
||||||
}
|
}
|
||||||
@@ -133,6 +138,36 @@ public struct TransferMetadata {
|
|||||||
case text
|
case text
|
||||||
case url
|
case url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getSummary() -> String {
|
||||||
|
if let textTitle = textDescription {
|
||||||
|
return textTitle
|
||||||
|
} else if files.count == 1 {
|
||||||
|
return files[0].name
|
||||||
|
} else {
|
||||||
|
return String.localizedStringWithFormat("NFiles".localized(), files.count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getDescription(deviceName: String, alreadyAccepted: Bool) -> String {
|
||||||
|
|
||||||
|
let fileStr = getSummary()
|
||||||
|
|
||||||
|
switch type {
|
||||||
|
case .file:
|
||||||
|
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [deviceName, fileStr])
|
||||||
|
|
||||||
|
case .text:
|
||||||
|
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [deviceName, fileStr])
|
||||||
|
|
||||||
|
case .url:
|
||||||
|
return String(format: (alreadyAccepted ? "DeviceCurrentlySendingUrl" : "DeviceSendingUrl").localized(), arguments: [deviceName, fileStr])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getPinCodeMessage() -> String {
|
||||||
|
return String(format: "PinCode".localized(), arguments: [pinCode ?? "?"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,17 +182,21 @@ public struct EndpointInfo {
|
|||||||
var name: String?
|
var name: String?
|
||||||
let deviceType: RemoteDeviceInfo.DeviceType
|
let deviceType: RemoteDeviceInfo.DeviceType
|
||||||
let qrCodeData: Data?
|
let qrCodeData: Data?
|
||||||
|
|
||||||
|
|
||||||
|
static let maxNameChars = 32
|
||||||
|
|
||||||
init(name: String, deviceType: RemoteDeviceInfo.DeviceType) {
|
init(name: String, deviceType: RemoteDeviceInfo.DeviceType) {
|
||||||
self.name = name
|
self.name = String(decoding: Self.sanitizedString(name), as: UTF8.self)
|
||||||
self.deviceType = deviceType
|
self.deviceType = deviceType
|
||||||
self.qrCodeData = nil
|
self.qrCodeData = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
init?(data: Data) {
|
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 hasName = (data[0] & 0x10) == 0
|
||||||
let deviceNameLength: Int
|
let deviceNameLength: Int
|
||||||
@@ -165,8 +204,14 @@ public struct EndpointInfo {
|
|||||||
|
|
||||||
if hasName {
|
if hasName {
|
||||||
deviceNameLength = Int(data[17])
|
deviceNameLength = Int(data[17])
|
||||||
guard data.count >= deviceNameLength + 18 else { return nil }
|
guard data.count >= deviceNameLength + 18 else {
|
||||||
guard let newDeviceName = String(data: data[18..<(18 + deviceNameLength)], encoding: .utf8) else { return nil }
|
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
|
deviceName = newDeviceName
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -210,19 +255,30 @@ public struct EndpointInfo {
|
|||||||
var endpointInfo: [UInt8] = [UInt8(deviceType.rawValue << 1)]
|
var endpointInfo: [UInt8] = [UInt8(deviceType.rawValue << 1)]
|
||||||
// 16 bytes: unknown random bytes
|
// 16 bytes: unknown random bytes
|
||||||
for _ in 0 ... 15 {
|
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
|
// Device name in UTF-8 prefixed with 1-byte length
|
||||||
var nameChars = [UInt8]((name ?? "").utf8)
|
let nameChars = Self.sanitizedString(self.name ?? "")
|
||||||
if nameChars.count > 255 {
|
|
||||||
nameChars = [UInt8](nameChars[0 ..< 255])
|
|
||||||
}
|
|
||||||
endpointInfo.append(UInt8(nameChars.count))
|
endpointInfo.append(UInt8(nameChars.count))
|
||||||
for ch in nameChars {
|
for ch in nameChars {
|
||||||
endpointInfo.append(UInt8(ch))
|
endpointInfo.append(UInt8(ch))
|
||||||
}
|
}
|
||||||
return Data(endpointInfo)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -245,8 +245,8 @@ struct Location_Nearby_Connections_ConnectionRequestFrame: @unchecked Sendable {
|
|||||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||||
// methods supported on all messages.
|
// methods supported on all messages.
|
||||||
|
|
||||||
var endpointID: String {
|
var endpointID: Data {
|
||||||
get {return _storage._endpointID ?? String()}
|
get {return _storage._endpointID ?? Data()}
|
||||||
set {_uniqueStorage()._endpointID = newValue}
|
set {_uniqueStorage()._endpointID = newValue}
|
||||||
}
|
}
|
||||||
/// Returns true if `endpointID` has been explicitly set.
|
/// 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.
|
/// Clears the value of `endpointID`. Subsequent reads from it will return its default value.
|
||||||
mutating func clearEndpointID() {_uniqueStorage()._endpointID = nil}
|
mutating func clearEndpointID() {_uniqueStorage()._endpointID = nil}
|
||||||
|
|
||||||
var endpointName: String {
|
var endpointName: Data {
|
||||||
get {return _storage._endpointName ?? String()}
|
get {return _storage._endpointName ?? Data()}
|
||||||
set {_uniqueStorage()._endpointName = newValue}
|
set {_uniqueStorage()._endpointName = newValue}
|
||||||
}
|
}
|
||||||
/// Returns true if `endpointName` has been explicitly set.
|
/// 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")
|
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 {
|
fileprivate class _StorageClass {
|
||||||
var _endpointID: String? = nil
|
var _endpointID: Data? = nil
|
||||||
var _endpointName: String? = nil
|
var _endpointName: Data? = nil
|
||||||
var _handshakeData: Data? = nil
|
var _handshakeData: Data? = nil
|
||||||
var _nonce: Int32? = nil
|
var _nonce: Int32? = nil
|
||||||
var _mediums: [Location_Nearby_Connections_ConnectionRequestFrame.Medium] = []
|
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
|
// allocates stack space for every case branch when no optimizations are
|
||||||
// enabled. https://github.com/apple/swift-protobuf/issues/1034
|
// enabled. https://github.com/apple/swift-protobuf/issues/1034
|
||||||
switch fieldNumber {
|
switch fieldNumber {
|
||||||
case 1: try { try decoder.decodeSingularStringField(value: &_storage._endpointID) }()
|
case 1: try { try decoder.decodeSingularBytesField(value: &_storage._endpointID) }()
|
||||||
case 2: try { try decoder.decodeSingularStringField(value: &_storage._endpointName) }()
|
case 2: try { try decoder.decodeSingularBytesField(value: &_storage._endpointName) }()
|
||||||
case 3: try { try decoder.decodeSingularBytesField(value: &_storage._handshakeData) }()
|
case 3: try { try decoder.decodeSingularBytesField(value: &_storage._handshakeData) }()
|
||||||
case 4: try { try decoder.decodeSingularInt32Field(value: &_storage._nonce) }()
|
case 4: try { try decoder.decodeSingularInt32Field(value: &_storage._nonce) }()
|
||||||
case 5: try { try decoder.decodeRepeatedEnumField(value: &_storage._mediums) }()
|
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
|
// are enabled. https://github.com/apple/swift-protobuf/issues/1034 and
|
||||||
// https://github.com/apple/swift-protobuf/issues/1182
|
// https://github.com/apple/swift-protobuf/issues/1182
|
||||||
try { if let v = _storage._endpointID {
|
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 { 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 { if let v = _storage._handshakeData {
|
||||||
try visitor.visitSingularBytesField(value: v, fieldNumber: 3)
|
try visitor.visitSingularBytesField(value: v, fieldNumber: 3)
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ message ConnectionRequestFrame {
|
|||||||
}
|
}
|
||||||
// LINT.ThenChange(//depot/google3/third_party/nearby/proto/connections_enums.proto)
|
// LINT.ThenChange(//depot/google3/third_party/nearby/proto/connections_enums.proto)
|
||||||
|
|
||||||
optional string endpoint_id = 1;
|
optional bytes endpoint_id = 1;
|
||||||
optional string endpoint_name = 2;
|
optional bytes endpoint_name = 2;
|
||||||
optional bytes handshake_data = 3;
|
optional bytes handshake_data = 3;
|
||||||
// A random number generated for each outgoing connection that is presently
|
// A random number generated for each outgoing connection that is presently
|
||||||
// used to act as a tiebreaker when 2 devices connect to each other
|
// used to act as a tiebreaker when 2 devices connect to each other
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Contains information about the app group used to share data between main app and widget
|
/// Contains information about the app group used to share data between main app and extensions
|
||||||
struct AppGroup {
|
struct AppGroup {
|
||||||
|
|
||||||
/// Identifier of the app group
|
/// Identifier of the app group
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//
|
||||||
|
// EXIFUtils.swift
|
||||||
|
// QuickDrop
|
||||||
|
//
|
||||||
|
// Created by Leon Böttger on 10.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
import LUI
|
||||||
|
|
||||||
|
struct EXIFUtils {
|
||||||
|
|
||||||
|
/// Applies EXIF timestamps to a given file or directory.
|
||||||
|
static func applyEXIFTimestamps(at url: URL) {
|
||||||
|
let fm = FileManager.default
|
||||||
|
var isDir: ObjCBool = false
|
||||||
|
guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return }
|
||||||
|
|
||||||
|
if isDir.boolValue {
|
||||||
|
// It's a directory — enumerate files
|
||||||
|
if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey]) {
|
||||||
|
for case let fileURL as URL in enumerator {
|
||||||
|
guard (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { continue }
|
||||||
|
applyEXIFTimestamp(to: fileURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// It's a single file
|
||||||
|
applyEXIFTimestamp(to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Applies EXIF timestamp (if available) to a single file
|
||||||
|
private static func applyEXIFTimestamp(to fileURL: URL) {
|
||||||
|
guard let exifDate = exifOriginalDate(from: fileURL) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
// Set the "creation date"
|
||||||
|
try FileManager.default.setAttributes([.creationDate: exifDate], ofItemAtPath: fileURL.path)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
log("[EXIFUtils] Failed to set file attributes for \(fileURL.path): \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Returns the EXIF original date of an image file, adjusted to the current time zone.
|
||||||
|
static func exifOriginalDate(from url: URL) -> Date? {
|
||||||
|
guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||||
|
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
|
||||||
|
let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any],
|
||||||
|
let dateString = exif[kCGImagePropertyExifDateTimeOriginal] as? String
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let offsetString = exif[kCGImagePropertyExifOffsetTimeOriginal] as? String
|
||||||
|
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
|
formatter.calendar = Calendar(identifier: .gregorian)
|
||||||
|
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
|
||||||
|
|
||||||
|
let offsetSeconds = offsetString.flatMap(secondsFromOffsetString)
|
||||||
|
if let seconds = offsetSeconds {
|
||||||
|
formatter.timeZone = TimeZone(secondsFromGMT: seconds)
|
||||||
|
} else {
|
||||||
|
// Fallback: treat as device local
|
||||||
|
formatter.timeZone = TimeZone.current
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatter.date(from: dateString)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static private func secondsFromOffsetString(_ s: String) -> Int? {
|
||||||
|
// Expect formats like "+02:00" or "-05:30"
|
||||||
|
guard s.count == 6, (s.first == "+" || s.first == "-") else { return nil }
|
||||||
|
let sign = s.first == "-" ? -1 : 1
|
||||||
|
let parts = s.dropFirst().split(separator: ":")
|
||||||
|
guard parts.count == 2,
|
||||||
|
let hours = Int(parts[0]),
|
||||||
|
let minutes = Int(parts[1]) else { return nil }
|
||||||
|
return sign * (hours * 3600 + minutes * 60)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// FileManager+DocumentsOpener.swift
|
||||||
|
// QuickDrop
|
||||||
|
//
|
||||||
|
// Created by Leon Böttger on 10.11.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import LUI
|
||||||
|
|
||||||
|
extension FileManager {
|
||||||
|
public func openDocumentFolder() {
|
||||||
|
#if !EXTENSION
|
||||||
|
let documentsUrl = self.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||||
|
|
||||||
|
if let documentsUrl = documentsUrl, let sharedUrl = URL(string: "shareddocuments://\(documentsUrl.path)"), UIApplication.shared.canOpenURL(sharedUrl) {
|
||||||
|
UIApplication.shared.open(sharedUrl, options: [:])
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
showAlert(title: "CouldNotOpenDocumentsFolder", message: "CouldNotOpenDocumentsFolderDescription")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,35 +110,11 @@ extension String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public enum UserDefaultsKeys: String, CaseIterable {
|
public func fullVersion() -> Bool {
|
||||||
case isEligibleForIap = "isEligibleForIap"
|
return Settings.shared.gotPlus
|
||||||
case appLaunchedBefore = "ShowedWelcomeScreen"
|
|
||||||
case plusVersion = "isPlusVersion"
|
|
||||||
case transmissionCount = "reviewRequestCountKey"
|
|
||||||
case automaticallyAcceptFiles = "automaticallyAcceptFiles"
|
|
||||||
case saveFolderBookmark = "saveFolderBookmark"
|
|
||||||
case openFinderAfterReceiving = "openFinderAfterReceiving"
|
|
||||||
case endpointID = "endpointID"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public func isPlusVersion() -> Bool {
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
// Enable full functionality if app distributed directly
|
|
||||||
return DistributionDetector.isDirectDistributionEnabled || UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue)
|
|
||||||
#else
|
|
||||||
// iOS
|
|
||||||
return true
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public func isFileTransferRestricted() -> Bool {
|
public func isFileTransferRestricted() -> Bool {
|
||||||
(!isPlusVersion() && incomingTransmissionCount() > 1)
|
(!fullVersion() && Settings.shared.incomingTransmissionCount > 1)
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public func incomingTransmissionCount() -> Int {
|
|
||||||
return UserDefaults.standard.integer(forKey: UserDefaultsKeys.transmissionCount.rawValue)
|
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import Foundation
|
|||||||
import SwiftECC
|
import SwiftECC
|
||||||
import Security
|
import Security
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import LUI
|
||||||
|
|
||||||
class IdentityManager {
|
class IdentityManager {
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
//
|
|
||||||
// SaveFilesManager.swift
|
|
||||||
// QuickDrop
|
|
||||||
//
|
|
||||||
// Created by Leon Böttger on 26.07.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import ImageIO
|
|
||||||
#if os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#else
|
|
||||||
import LUI
|
|
||||||
import UIKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
public class SaveFilesManager {
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
// remove old temp directory
|
|
||||||
|
|
||||||
let tempPath = FileManager.default.temporaryDirectory
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
|
|
||||||
do {
|
|
||||||
let contents = try fileManager.contentsOfDirectory(at: tempPath, includingPropertiesForKeys: nil)
|
|
||||||
|
|
||||||
var didSomething = false
|
|
||||||
|
|
||||||
for item in contents {
|
|
||||||
didSomething = true
|
|
||||||
try fileManager.removeItem(at: item)
|
|
||||||
}
|
|
||||||
|
|
||||||
if didSomething {
|
|
||||||
log("[SaveFilesManager] Temporary directory cleared.")
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
log("[SaveFilesManager] Failed to list contents of temp directory: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static let shared = SaveFilesManager()
|
|
||||||
|
|
||||||
let tempDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("Pending")
|
|
||||||
private var securityScopeUrl: URL?
|
|
||||||
|
|
||||||
private var filesFinishedDownloading = [URL]()
|
|
||||||
private var filesFinishedDownloadingSinceLastRun = [URL]()
|
|
||||||
|
|
||||||
public func registerFileFinishedDownloading(_ fileURL: URL) {
|
|
||||||
filesFinishedDownloading.append(fileURL)
|
|
||||||
filesFinishedDownloadingSinceLastRun.append(fileURL)
|
|
||||||
|
|
||||||
// read out EXIF timestamp and manually set file creation date to it
|
|
||||||
applyEXIFTimestamps(at: fileURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public func movePendingFilesToTarget() {
|
|
||||||
|
|
||||||
if isPlusVersion() {
|
|
||||||
do {
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let files = try fileManager.contentsOfDirectory(at: tempDirectory, includingPropertiesForKeys: nil)
|
|
||||||
|
|
||||||
let target = getSaveDirectory()
|
|
||||||
var loggedExecution = false
|
|
||||||
|
|
||||||
for file in files {
|
|
||||||
|
|
||||||
if !loggedExecution {
|
|
||||||
log("[SaveFilesManager] Moving pending files to target directory")
|
|
||||||
loggedExecution = true
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileName = file.lastPathComponent
|
|
||||||
let destinationURL = target.appendingPathComponent(fileName)
|
|
||||||
|
|
||||||
if !filesFinishedDownloading.contains(destinationURL) {
|
|
||||||
log("[SaveFilesManager] File \(file) not finished downloading, skipping")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
log("[SaveFilesManager] Moving file: \(file.lastPathComponent) to \(destinationURL.lastPathComponent)")
|
|
||||||
|
|
||||||
do {
|
|
||||||
try fileManager.copyItem(at: file, to: destinationURL)
|
|
||||||
|
|
||||||
let progress = Progress()
|
|
||||||
progress.fileURL = destinationURL
|
|
||||||
progress.totalUnitCount = 10
|
|
||||||
progress.kind = .file
|
|
||||||
progress.isPausable = false
|
|
||||||
#if os(macOS)
|
|
||||||
progress.publish()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
progress.completedUnitCount = 10
|
|
||||||
#if os(macOS)
|
|
||||||
progress.unpublish()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
try fileManager.removeItem(at: file)
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
log("[SaveFilesManager] Error moving file: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log("[SaveFilesManager] Moved all pending files to target directory")
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// Pending directory doesn't exist or is empty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !filesFinishedDownloadingSinceLastRun.isEmpty && !isFileTransferRestricted() {
|
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
if UserDefaults.standard.bool(forKey: UserDefaultsKeys.openFinderAfterReceiving.rawValue) {
|
|
||||||
log("[SaveFilesManager] Opening \(filesFinishedDownloadingSinceLastRun.count) file(s) in Finder.")
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting(filesFinishedDownloadingSinceLastRun)
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
openDownloadedFilesFolder()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Clear the list of finished files
|
|
||||||
filesFinishedDownloadingSinceLastRun.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
stopAccessingSecurityScopedResource()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
public func openDownloadedFilesFolder() {
|
|
||||||
#if !EXTENSION
|
|
||||||
let documentsUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
|
||||||
|
|
||||||
if let documentsUrl = documentsUrl, let sharedUrl = URL(string: "shareddocuments://\(documentsUrl.path)"), UIApplication.shared.canOpenURL(sharedUrl) {
|
|
||||||
UIApplication.shared.open(sharedUrl, options: [:])
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
showAlert(title: "CouldNotOpenDocumentsFolder", message: "CouldNotOpenDocumentsFolderDescription")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
|
||||||
public func stopAccessingSecurityScopedResource() {
|
|
||||||
guard let url = securityScopeUrl else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log("[SaveFilesManager] Stopping access to security scoped resource: \(url)")
|
|
||||||
url.stopAccessingSecurityScopedResource()
|
|
||||||
securityScopeUrl = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public func getSaveDirectory() -> URL {
|
|
||||||
|
|
||||||
// Not supported on iOS
|
|
||||||
#if os(macOS)
|
|
||||||
if let securityScopeUrl = securityScopeUrl {
|
|
||||||
log("[SaveFilesManager] Using existing security scope URL: \(securityScopeUrl)")
|
|
||||||
return securityScopeUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
if let bookmarkData = UserDefaults.standard.data(forKey: UserDefaultsKeys.saveFolderBookmark.rawValue) {
|
|
||||||
var isStale = false
|
|
||||||
|
|
||||||
do {
|
|
||||||
let url = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale)
|
|
||||||
|
|
||||||
if !isStale {
|
|
||||||
if url.startAccessingSecurityScopedResource() {
|
|
||||||
log("[SaveFilesManager] Successfully accessed security scoped resource: \(url)")
|
|
||||||
|
|
||||||
securityScopeUrl = url
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log("[SaveFilesManager] Bookmark is stale, using default downloads folder.")
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
log("[SaveFilesManager] Failed to resolve bookmark: \(error), using default downloads folder.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
return try FileManager.default.url(for: .downloadsDirectory, in: .userDomainMask, appropriateFor: nil, create: true).resolvingSymlinksInPath()
|
|
||||||
} catch {
|
|
||||||
fatalError("[SaveFilesManager] Failed to get downloads directory: \(error)")
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
// Return the documents directory for iOS
|
|
||||||
do {
|
|
||||||
return try FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).resolvingSymlinksInPath()
|
|
||||||
} catch {
|
|
||||||
fatalError("Failed to get documents directory: \(error)")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Returns the EXIF original date of an image file, adjusted to the current time zone.
|
|
||||||
private 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/// Applies EXIF timestamps to a given file or directory.
|
|
||||||
private 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 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import IOKit.pwr_mgt
|
|||||||
#elseif os(iOS)
|
#elseif os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
import LUI
|
||||||
|
|
||||||
class SleepManager {
|
class SleepManager {
|
||||||
public static let shared = SleepManager()
|
public static let shared = SleepManager()
|
||||||
|
|||||||
@@ -6,63 +6,43 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ZIPFoundation
|
||||||
|
|
||||||
class Zip {
|
final class Zip {
|
||||||
|
|
||||||
static func createAtTemporaryDirectory(zipFilename: String, zipExtension: String = "zip", fromDirectory directoryURL: URL) throws -> URL {
|
static func createAtTemporaryDirectory(
|
||||||
|
zipFilename: String,
|
||||||
let finalURL = FileManager.default.temporaryDirectory
|
zipExtension: String = "zip",
|
||||||
|
fromDirectory directoryURL: URL
|
||||||
|
) throws -> URL {
|
||||||
|
|
||||||
|
guard directoryURL.isDirectory else { throw CreateZipError.urlNotADirectory(directoryURL) }
|
||||||
|
|
||||||
|
let zipFinalURL = FileManager.default.temporaryDirectory
|
||||||
.appendingPathComponent(zipFilename)
|
.appendingPathComponent(zipFilename)
|
||||||
.appendingPathExtension(zipExtension)
|
.appendingPathExtension(zipExtension)
|
||||||
|
|
||||||
return try createAt(
|
if FileManager.default.fileExists(atPath: zipFinalURL.path) {
|
||||||
zipFinalURL: finalURL,
|
try FileManager.default.removeItem(at: zipFinalURL)
|
||||||
fromDirectory: directoryURL
|
}
|
||||||
|
|
||||||
|
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
|
return zipFinalURL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CreateZipError: Error {
|
||||||
enum CreateZipError: Swift.Error {
|
|
||||||
case urlNotADirectory(URL)
|
case urlNotADirectory(URL)
|
||||||
case failedToCreateZIP(Swift.Error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extension URL {
|
||||||
extension URL {
|
|
||||||
var isDirectory: Bool {
|
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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "212cf044c2b0bab89578ca93a3666a656e0c44fb54a69f813c4cc4fae07827d7",
|
"originHash" : "85c9b997510f22b553d79f68fb6d51d149ac9324df643dbf1475023b39da33b3",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "applewatchscreensize",
|
"identity" : "applewatchscreensize",
|
||||||
@@ -33,8 +33,17 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/leif-ibsen/BigInt",
|
"location" : "https://github.com/leif-ibsen/BigInt",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "afb70a0038bfbba845271b60fa9a58d5840f8017",
|
"revision" : "8c6f93aa37504b7b1ba3954335b5548a19fbbd82",
|
||||||
"version" : "1.21.0"
|
"version" : "1.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "devicekit",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/devicekit/DeviceKit",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "581df61650bc457ec00373a592a84be3e7468eb1",
|
||||||
|
"version" : "5.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -43,7 +52,7 @@
|
|||||||
"location" : "https://github.com/leonboe1/LUI.git",
|
"location" : "https://github.com/leonboe1/LUI.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "5b2fe7161069c2314c5be17810ec7471b9decc8a"
|
"revision" : "19b90ffd9778db54391501f0ba1319e0a1c12b1f"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -55,15 +64,6 @@
|
|||||||
"version" : "26.10.0"
|
"version" : "26.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "rndevicename",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/souzainf3/RNDeviceName",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "68ada02a7135381425b01778aeb8719ce3bf8f62",
|
|
||||||
"version" : "2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swift-cross-kit-types",
|
"identity" : "swift-cross-kit-types",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@@ -87,8 +87,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/apple/swift-protobuf.git",
|
"location" : "https://github.com/apple/swift-protobuf.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "e3f69fd321d0c9fcdc16fb576a0cdd956675face",
|
"revision" : "c169a5744230951031770e27e475ff6eefe51f9d",
|
||||||
"version" : "1.31.0"
|
"version" : "1.33.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -124,7 +124,16 @@
|
|||||||
"location" : "https://github.com/siteline/swiftui-introspect",
|
"location" : "https://github.com/siteline/swiftui-introspect",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "9bedf1e79c7b7c34b35b74bef126f56d2400ef7f"
|
"revision" : "b10fc8a284abb9bd908d77243d2ba3b3d8ea6ebc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "zipfoundation",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/weichsel/ZIPFoundation.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
|
||||||
|
"version" : "0.9.20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
language = "de"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2600"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
@@ -50,6 +50,9 @@
|
|||||||
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
ReferencedContainer = "container:QuickDrop.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<StoreKitConfigurationFileReference
|
||||||
|
identifier = "../../ConfigSynced.storekit">
|
||||||
|
</StoreKitConfigurationFileReference>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2600"
|
||||||
wasCreatedForAppExtension = "YES"
|
wasCreatedForAppExtension = "YES"
|
||||||
version = "2.0">
|
version = "2.0">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2600"
|
||||||
wasCreatedForAppExtension = "YES"
|
wasCreatedForAppExtension = "YES"
|
||||||
version = "2.0">
|
version = "2.0">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Network
|
|||||||
import StoreKit
|
import StoreKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import LUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSWindowDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSWindowDelegate {
|
||||||
@@ -19,6 +20,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
private var statusItem: NSStatusItem?
|
private var statusItem: NSStatusItem?
|
||||||
|
|
||||||
var welcomeWindow: NSWindow?
|
var welcomeWindow: NSWindow?
|
||||||
|
var introductionWindow: NSWindow?
|
||||||
var plusWindow: NSWindow?
|
var plusWindow: NSWindow?
|
||||||
|
|
||||||
private var sheetView: NSPanel? = nil
|
private var sheetView: NSPanel? = nil
|
||||||
@@ -34,30 +36,38 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
|
|
||||||
// MARK: NSApplicationDelegate functions
|
// MARK: NSApplicationDelegate functions
|
||||||
|
|
||||||
|
func applicationWillFinishLaunching(_: Notification) {
|
||||||
|
LUIInit(configuration: configuration)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_: Notification) {
|
func applicationDidFinishLaunching(_: Notification) {
|
||||||
|
|
||||||
let menu = NSMenu()
|
let menu = NSMenu()
|
||||||
|
|
||||||
let visibleItem = NSMenuItem(
|
let visibleItem = NSMenuItem(
|
||||||
title: "VisibleToEveryone".localized(),
|
title: getDefaultVisibleLabel(),
|
||||||
action: nil,
|
action: nil,
|
||||||
keyEquivalent: ""
|
keyEquivalent: ""
|
||||||
)
|
)
|
||||||
self.visibleItem = visibleItem
|
self.visibleItem = visibleItem
|
||||||
menu.addItem(visibleItem)
|
menu.addItem(visibleItem)
|
||||||
|
|
||||||
menu.addItem(withTitle: String(format: "DeviceName".localized(), arguments: [Host.current().localizedName ?? "Mac"]), action: nil, keyEquivalent: "")
|
|
||||||
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
let sendClipboardItem = NSMenuItem(title: "SendClipboard".localized(), action: #selector(sendClipboard), keyEquivalent: "")
|
let sendClipboardItem = NSMenuItem(title: "SendClipboard".localized(), action: #selector(sendClipboard), keyEquivalent: "")
|
||||||
|
sendClipboardItem.image = NSImage(systemSymbolName: "doc.on.clipboard", accessibilityDescription: nil)
|
||||||
menu.addItem(sendClipboardItem)
|
menu.addItem(sendClipboardItem)
|
||||||
|
|
||||||
menu.addItem(NSMenuItem.separator())
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openWelcomeScreen), keyEquivalent: "")
|
let userManualItem = NSMenuItem(title: "UserManual".localized(), action: #selector(openApplicableScreen), keyEquivalent: "")
|
||||||
|
userManualItem.image = NSImage(systemSymbolName: "gear", accessibilityDescription: nil)
|
||||||
menu.addItem(userManualItem)
|
menu.addItem(userManualItem)
|
||||||
|
|
||||||
menu.addItem(withTitle: "Quit".localized(), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")
|
let quitItem = NSMenuItem(title: "Quit".localized(), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")
|
||||||
|
quitItem.image = NSImage(systemSymbolName: "xmark", accessibilityDescription: nil)
|
||||||
|
menu.addItem(quitItem)
|
||||||
|
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
statusItem?.button?.image = NSImage(named: "MenuBarIcon")
|
statusItem?.button?.image = NSImage(named: "MenuBarIcon")
|
||||||
@@ -65,40 +75,64 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
statusItem?.behavior = .removalAllowed
|
statusItem?.behavior = .removalAllowed
|
||||||
|
|
||||||
iapManager = IAPManager.sharedInstance
|
iapManager = IAPManager.sharedInstance
|
||||||
iapManager?.startObserving()
|
|
||||||
|
|
||||||
log("[AppDelegate] Downloaded directly: \(DistributionDetector.isDirectDistributionEnabled)")
|
|
||||||
|
|
||||||
// app did not lauch before
|
// app did not lauch before
|
||||||
if !UserDefaults.standard.bool(forKey: UserDefaultsKeys.appLaunchedBefore.rawValue) {
|
if !Settings.shared.appLaunchedBefore {
|
||||||
log("[AppDelegate] Opening Welcome Screen")
|
log("[AppDelegate] Opening Welcome Screen")
|
||||||
// open welcome screen
|
// open welcome screen
|
||||||
openWelcomeScreen()
|
openIntroductionWindow()
|
||||||
UserDefaults.standard.set(true, forKey: UserDefaultsKeys.appLaunchedBefore.rawValue)
|
|
||||||
|
|
||||||
// user installed the app after the IAP was implemented, set the user as eligible for IAP
|
// user installed the app after the IAP was implemented, set the user as eligible for IAP
|
||||||
UserDefaults.standard.set(true, forKey: UserDefaultsKeys.isEligibleForIap.rawValue)
|
Settings.shared.isEligibleForIap = true
|
||||||
} else {
|
} else {
|
||||||
// app launched before
|
// app launched before
|
||||||
|
|
||||||
|
#if GITHUB
|
||||||
|
log("[AppDelegate] Downloaded from GitHub.")
|
||||||
|
#else
|
||||||
// user installed the app before the IAP was implemented, grant the plus version
|
// user installed the app before the IAP was implemented, grant the plus version
|
||||||
if !UserDefaults.standard.bool(forKey: UserDefaultsKeys.isEligibleForIap.rawValue) {
|
if !Settings.shared.isEligibleForIap {
|
||||||
log("[AppDelegate] Granting QuickDrop+ for old user")
|
log("[AppDelegate] Granting QuickDrop+ for old user")
|
||||||
UserDefaults.standard.set(true, forKey: UserDefaultsKeys.plusVersion.rawValue)
|
Settings.shared.gotPlus = true
|
||||||
|
UserDefaults.standard.set(true, forKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue)
|
||||||
} else {
|
} else {
|
||||||
if !isPlusVersion() {
|
if !fullVersion() {
|
||||||
log("[AppDelegate] New user - QuickDrop+ not available")
|
log("[AppDelegate] New user - QuickDrop+ not available")
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
log("[AppDelegate] QuickDrop+ available")
|
log("[AppDelegate] QuickDrop+ available")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
BezelNotification.show(messageText: "ReadyToReceive".localized(), icon: .receiveIcon)
|
BezelNotification.show(messageText: "ReadyToReceive".localized(), icon: .receiveIcon)
|
||||||
|
|
||||||
|
// only start receiving immediately for existing user, for new users we want to delay the permission prompt
|
||||||
|
startReceiving()
|
||||||
}
|
}
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = self
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
|
||||||
|
NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in
|
||||||
|
if isConnected {
|
||||||
|
self.statusItem?.button?.image = NSImage(named: "MenuBarIcon")
|
||||||
|
self.visibleItem?.title = self.getDefaultVisibleLabel()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
self.statusItem?.button?.image = NSImage(named: "MenuBarIconSlash")
|
||||||
|
self.visibleItem?.title = "NoNetworkConnection".localized()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NearbyConnectionManager.shared.changedDeviceNameCallback = {
|
||||||
|
self.visibleItem?.title = self.getDefaultVisibleLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[AppDelegate] Application did finish launching")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func startReceiving() {
|
||||||
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
|
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
|
||||||
if shouldOpen {
|
if shouldOpen {
|
||||||
self.openPlusScreen()
|
self.openPlusScreen()
|
||||||
@@ -111,20 +145,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in
|
|
||||||
if isConnected {
|
|
||||||
self.statusItem?.button?.image = NSImage(named: "MenuBarIcon")
|
|
||||||
self.visibleItem?.title = "VisibleToEveryone".localized()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
self.statusItem?.button?.image = NSImage(named: "MenuBarIconSlash")
|
|
||||||
self.visibleItem?.title = "NoNetworkConnection".localized()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log("Application did finish launching")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -138,11 +158,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
|
|
||||||
|
|
||||||
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
|
func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool {
|
||||||
openWelcomeScreen()
|
self.openApplicableScreen()
|
||||||
statusItem?.isVisible = true
|
statusItem?.isVisible = true
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@objc func openApplicableScreen() {
|
||||||
|
if Settings.shared.appLaunchedBefore {
|
||||||
|
openMainWindow()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
openIntroductionWindow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public func userNotificationCenter(_: UNUserNotificationCenter,
|
public func userNotificationCenter(_: UNUserNotificationCenter,
|
||||||
willPresent _: UNNotification,
|
willPresent _: UNNotification,
|
||||||
@@ -153,21 +183,25 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
|
|
||||||
func applicationWillTerminate(_: Notification) {
|
func applicationWillTerminate(_: Notification) {
|
||||||
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
UNUserNotificationCenter.current().removeAllDeliveredNotifications()
|
||||||
iapManager?.stopObserving()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func application(_ application: NSApplication, open urls: [URL]) {
|
func application(_ application: NSApplication, open urls: [URL]) {
|
||||||
for url in urls {
|
for url in urls {
|
||||||
if url.scheme == "quickdrop", url.host == "sendLog" {
|
if url.scheme == "quickdrop" {
|
||||||
sendLoggingString() // this time it runs in the main app
|
|
||||||
}
|
switch url.host {
|
||||||
|
case "sendLog":
|
||||||
|
sendLoggingString()
|
||||||
if url.scheme == "quickdrop", url.host == "openLog", let url = LogManager.sharedInstance.logFileURL {
|
case "openLog":
|
||||||
log("Opening log file: \(url)")
|
if let url = LogManager.sharedInstance.logFileURL {
|
||||||
// open folder containing the log file
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
}
|
||||||
|
case "removeData":
|
||||||
|
Settings.shared.deleteAllUserDefaults()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,13 +225,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
let sharingService = NSSharingService(named: NSSharingService.Name("com.leonboettger.neardrop.ShareExtension"))
|
sendToSharingService(items: [clipboardString])
|
||||||
sharingService?.perform(withItems: [clipboardString])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@objc func openWelcomeScreen() {
|
@objc func openMainWindow() {
|
||||||
|
|
||||||
NSApp.setActivationPolicy(.regular)
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
|
||||||
@@ -216,9 +249,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
openCableTransmissionView: { self.openSheetView(type: .downloadCableConnectionApp) },
|
openCableTransmissionView: { self.openSheetView(type: .downloadCableConnectionApp) },
|
||||||
checkForNetworkIssues: performDeviceToDeviceCheck
|
checkForNetworkIssues: performDeviceToDeviceCheck
|
||||||
)
|
)
|
||||||
|
|
||||||
welcomeWindow = NSWindow(
|
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],
|
styleMask: [.titled, .closable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
@@ -240,6 +273,49 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@objc func openIntroductionWindow() {
|
||||||
|
|
||||||
|
NSApp.setActivationPolicy(.regular)
|
||||||
|
|
||||||
|
if let window = introductionWindow {
|
||||||
|
if window.isMiniaturized {
|
||||||
|
window.deminiaturize(nil)
|
||||||
|
}
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
window.makeKeyAndOrderFront(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let introductionView = IntroductionView(startReceiving: startReceiving) {
|
||||||
|
Settings.shared.appLaunchedBefore = true
|
||||||
|
self.introductionWindow?.close()
|
||||||
|
self.introductionWindow = nil
|
||||||
|
self.openMainWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
introductionWindow = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: IntroductionView.width, height: IntroductionView.height),
|
||||||
|
styleMask: [.titled, .closable],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
introductionWindow?.styleMask.insert(.fullSizeContentView)
|
||||||
|
introductionWindow?.titleVisibility = .hidden
|
||||||
|
introductionWindow?.titlebarAppearsTransparent = true
|
||||||
|
|
||||||
|
introductionWindow?.center()
|
||||||
|
introductionWindow?.isReleasedWhenClosed = false
|
||||||
|
introductionWindow?.setFrameAutosaveName("IntroductionScreen")
|
||||||
|
introductionWindow?.contentView = NSHostingView(rootView: introductionView)
|
||||||
|
introductionWindow?.delegate = self
|
||||||
|
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
introductionWindow?.makeKeyAndOrderFront(nil)
|
||||||
|
introductionWindow?.level = .normal
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@objc func openPlusScreen() {
|
@objc func openPlusScreen() {
|
||||||
// If window already exists and is visible, just bring it to the front
|
// If window already exists and is visible, just bring it to the front
|
||||||
if let window = plusWindow, window.isVisible {
|
if let window = plusWindow, window.isVisible {
|
||||||
@@ -252,11 +328,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
log("Opening Plus screen")
|
log("Opening Plus screen")
|
||||||
|
|
||||||
// Create the welcome screen SwiftUI view
|
// Create the welcome screen SwiftUI view
|
||||||
let plusView = PlusView(closeView: {
|
let plusView = GetPlusViewV4(showSheet: Binding(get: { self.plusWindow != nil }, set: { newValue in
|
||||||
log("Closing plus screen")
|
if !newValue {
|
||||||
self.plusWindow?.close()
|
self.plusWindow?.close()
|
||||||
self.plusWindow = nil
|
self.plusWindow = nil
|
||||||
})
|
}
|
||||||
|
}), hasSheet: false)
|
||||||
|
|
||||||
|
let plusViewWidth: CGFloat = 550
|
||||||
|
let plusViewHeight: CGFloat = 750
|
||||||
|
|
||||||
// Create an NSWindow to host the SwiftUI view
|
// Create an NSWindow to host the SwiftUI view
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
@@ -327,7 +407,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
// MARK: - Helper Functions
|
// MARK: - Helper Functions
|
||||||
|
|
||||||
private func performDeviceToDeviceCheck() {
|
private func performDeviceToDeviceCheck() {
|
||||||
let scanner = DeviceToDeviceHeuristicScanner()
|
let scanner = DeviceToDeviceHeuristicScanner.shared
|
||||||
scanner.scan { allowed in
|
scanner.scan { allowed in
|
||||||
if allowed {
|
if allowed {
|
||||||
log("✅ Device-to-device likely allowed (peer responded on LAN).")
|
log("✅ Device-to-device likely allowed (peer responded on LAN).")
|
||||||
@@ -338,6 +418,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private func getDefaultVisibleLabel() -> String {
|
||||||
|
let name = NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized()
|
||||||
|
return String(format: "VisibleAs".localized(), name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TransferInfo {
|
struct TransferInfo {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 1016 KiB |
|
Before Width: | Height: | Size: 752 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "1024-1024 1.png",
|
"filename" : "1024-1024 1 2.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
@@ -12,15 +12,6 @@
|
|||||||
{
|
{
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "3x"
|
"scale" : "3x"
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "1024-1024.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 663 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "FileTransfer Kopie-1024.png",
|
"filename" : "iDroidIcon-iOS-Default-1024x1024@1x.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"scale" : "1x"
|
"scale" : "1x"
|
||||||
},
|
},
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 647 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -15,5 +15,7 @@
|
|||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
|
<key>UIDesignRequiresCompatibility</key>
|
||||||
|
<true/>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage" : "en",
|
||||||
|
"strings" : {
|
||||||
|
"CFBundleDisplayName" : {
|
||||||
|
"comment" : "Bundle display name",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "QuickDrop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CFBundleName" : {
|
||||||
|
"comment" : "Bundle name",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "QuickDrop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSHumanReadableCopyright" : {
|
||||||
|
"comment" : "Copyright (human-readable)",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -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,256 +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 {
|
|
||||||
|
|
||||||
@AppStorage(UserDefaultsKeys.plusVersion.rawValue) var isPlusVersion = false
|
|
||||||
@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 {
|
|
||||||
didSet {
|
|
||||||
if boughtSuccessAlert {
|
|
||||||
log("User bought plus version. Moving pending files...")
|
|
||||||
SaveFilesManager.shared.movePendingFilesToTarget()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
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 {
|
|
||||||
isPlusVersion = 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 {
|
|
||||||
isPlusVersion = 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 {
|
|
||||||
isPlusVersion = 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: {})
|
|
||||||
}
|
|
||||||
@@ -2,19 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.leonboettger.neardrop</string>
|
<string>group.com.leonboettger.neardrop</string>
|
||||||
</array>
|
</array>
|
||||||
<key>com.apple.security.files.downloads.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.server</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
//
|
|
||||||
// ApIsolationTester.swift
|
|
||||||
// QuickDrop
|
|
||||||
//
|
|
||||||
// Created by Leon Böttger on 14.05.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Network
|
|
||||||
|
|
||||||
class DeviceToDeviceHeuristicScanner {
|
|
||||||
private let scanQueue = DispatchQueue(label: "DeviceToDeviceScanQueue")
|
|
||||||
private var reachableIPs = [String]()
|
|
||||||
private var totalToScan = 0
|
|
||||||
private var completion: ((Bool) -> Void)?
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timeout fallback
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
|
|
||||||
self.finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
switch state {
|
|
||||||
case .ready:
|
|
||||||
self.reachableIPs.append(ip)
|
|
||||||
self.finish()
|
|
||||||
connection.cancel()
|
|
||||||
case .failed, .cancelled:
|
|
||||||
connection.cancel()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
connection.start(queue: scanQueue)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finish() {
|
|
||||||
completion?(reachableIPs.count > 0)
|
|
||||||
completion = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attempts to detect the local subnet prefix (e.g., "192.168.0")
|
|
||||||
private static func getLocalSubnetPrefix() -> String? {
|
|
||||||
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
|
||||||
if getifaddrs(&ifaddr) == 0 {
|
|
||||||
var ptr = ifaddr
|
|
||||||
while ptr != nil {
|
|
||||||
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() {
|
|
||||||
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)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
freeifaddrs(ifaddr)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//
|
||||||
|
// Settings.swift
|
||||||
|
// QuickDrop
|
||||||
|
//
|
||||||
|
// Created by Leon Böttger on 09.09.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class Settings: ObservableObject {
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
static var shared = Settings()
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Published UserDefaults Backed Properties
|
||||||
|
|
||||||
|
#if GITHUB
|
||||||
|
let gotPlus = true
|
||||||
|
#else
|
||||||
|
@Published var gotPlus = UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(gotPlus, forKey: UserDefaultsKeys.plusVersion.rawValue) }}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
@Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(automaticallyAcceptFiles, forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) }}
|
||||||
|
|
||||||
|
|
||||||
|
@Published var openFinderAfterReceiving: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.openFinderAfterReceiving.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(openFinderAfterReceiving, forKey: UserDefaultsKeys.openFinderAfterReceiving.rawValue) }}
|
||||||
|
|
||||||
|
|
||||||
|
@Published var saveFolderBookmark: Data? = UserDefaults.standard.data(forKey: UserDefaultsKeys.saveFolderBookmark.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(saveFolderBookmark, forKey: UserDefaultsKeys.saveFolderBookmark.rawValue) }}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Non-Published UserDefaults Backed Properties
|
||||||
|
|
||||||
|
var incomingTransmissionCount: Int = UserDefaults.standard.integer(forKey: UserDefaultsKeys.transmissionCount.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(incomingTransmissionCount, forKey: UserDefaultsKeys.transmissionCount.rawValue) }}
|
||||||
|
|
||||||
|
|
||||||
|
var endpointID: String? = UserDefaults.standard.string(forKey: UserDefaultsKeys.endpointID.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(endpointID, forKey: UserDefaultsKeys.endpointID.rawValue) }}
|
||||||
|
|
||||||
|
|
||||||
|
var isEligibleForIap: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.isEligibleForIap.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(isEligibleForIap, forKey: UserDefaultsKeys.isEligibleForIap.rawValue) }}
|
||||||
|
|
||||||
|
|
||||||
|
var appLaunchedBefore: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.appLaunchedBefore.rawValue)
|
||||||
|
{ didSet { UserDefaults.standard.set(appLaunchedBefore, forKey: UserDefaultsKeys.appLaunchedBefore.rawValue) }}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - Debug
|
||||||
|
|
||||||
|
func deleteAllUserDefaults() {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
if let bundleID = Bundle.main.bundleIdentifier {
|
||||||
|
defaults.removePersistentDomain(forName: bundleID)
|
||||||
|
}
|
||||||
|
defaults.synchronize()
|
||||||
|
|
||||||
|
Settings.shared = Settings()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// MARK: - UserDefaults Keys
|
||||||
|
|
||||||
|
public enum UserDefaultsKeys: String, CaseIterable {
|
||||||
|
case isEligibleForIap = "isEligibleForIap"
|
||||||
|
case appLaunchedBefore = "ShowedWelcomeScreen"
|
||||||
|
case plusVersion = "plusVersion"
|
||||||
|
case transmissionCount = "reviewRequestCountKey"
|
||||||
|
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 AVFoundation
|
||||||
|
import LUI
|
||||||
|
|
||||||
class AudioManager {
|
class AudioManager {
|
||||||
static private var incomingFileSoundPlayer: AVAudioPlayer?
|
static private var incomingFileSoundPlayer: AVAudioPlayer?
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ import Network
|
|||||||
import StoreKit
|
import StoreKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
import LUI
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
import BezelNotification
|
import BezelNotification
|
||||||
import Cocoa
|
import Cocoa
|
||||||
#else
|
|
||||||
import LUI
|
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
class ErrorAlertHandler {
|
class ErrorAlertHandler {
|
||||||
@@ -84,7 +83,7 @@ class ErrorAlertHandler {
|
|||||||
|
|
||||||
let title = String(format: "TransferError".localized(), arguments: [deviceName])
|
let title = String(format: "TransferError".localized(), arguments: [deviceName])
|
||||||
log("Showing alert with title: \"\(title)\" and description: \"\(description)\"")
|
log("Showing alert with title: \"\(title)\" and description: \"\(description)\"")
|
||||||
log("Unsuccessful transmission. Already successful transmissions: \(incomingTransmissionCount())")
|
log("Unsuccessful transmission. Already successful transmissions: \(Settings.shared.incomingTransmissionCount)")
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|
||||||
@@ -103,8 +102,8 @@ class ErrorAlertHandler {
|
|||||||
self.isAlertShown = false
|
self.isAlertShown = false
|
||||||
|
|
||||||
if result == .alertFirstButtonReturn {
|
if result == .alertFirstButtonReturn {
|
||||||
log("Sending logging string")
|
//log("Sending logging string")
|
||||||
sendLoggingString()
|
//sendLoggingString()
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
showAlert(title: title, message: description)
|
showAlert(title: title, message: description)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
import os.log
|
import os.log
|
||||||
|
import LUI
|
||||||
|
|
||||||
@available(macOS 13.0, *)
|
@available(macOS 13.0, *)
|
||||||
public enum LaunchAtLogin {
|
public enum LaunchAtLogin {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import LUI
|
||||||
|
|
||||||
/// Returns the name of the currently active network interface (e.g. "en0", "en1")
|
/// Returns the name of the currently active network interface (e.g. "en0", "en1")
|
||||||
func getActiveNetworkInterface() -> String? {
|
func getActiveNetworkInterface() -> String? {
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
import LUI
|
||||||
|
|
||||||
class IPv6DeviceScanner {
|
class IPv6DeviceScanner {
|
||||||
/// Scans for IPv6 devices using `ping6 ff02::1` multicast on the given interface.
|
/// Scans for IPv6 devices using `ping6 ff02::1` multicast on the given interface.
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//
|
||||||
|
// ApIsolationTester.swift
|
||||||
|
// QuickDrop
|
||||||
|
//
|
||||||
|
// Created by Leon Böttger on 14.05.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let scanQueue = DispatchQueue(label: "DeviceToDeviceScanQueue")
|
||||||
|
|
||||||
|
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.
|
||||||
|
/// - 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
activeConnections.append(connection)
|
||||||
|
|
||||||
|
connection.stateUpdateHandler = { [weak self] state in
|
||||||
|
guard let self else { return }
|
||||||
|
|
||||||
|
switch state {
|
||||||
|
case .ready:
|
||||||
|
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:
|
||||||
|
// Ignore; scan completes via early success or timeout.
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.start(queue: scanQueue)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>?
|
||||||
|
if getifaddrs(&ifaddr) == 0 {
|
||||||
|
var ptr = ifaddr
|
||||||
|
while ptr != nil {
|
||||||
|
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() {
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freeifaddrs(ifaddr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||