69 Commits
Author SHA1 Message Date
Leon Böttger 487a27a019 Simplify introduction 2026-01-08 21:00:43 +01:00
Leon Böttger c74858ea19 Add removeData URL scheme for debugging 2026-01-06 18:57:45 +01:00
Leon Böttger 40169334ff Fix connection drop for larger files 2026-01-01 12:03:50 +01:00
Leon Böttger ed213b4d3c Stop advertising when screen off or other user logged in 2025-12-26 19:08:05 +01:00
Leon Böttger 0697b94547 UI improvements 2025-12-26 18:18:12 +01:00
Leon Böttger e8fc6f719e Performance improvements 2025-12-26 17:50:08 +01:00
Leon Böttger 9dfe864677 Fix outgoing transfer to Windows 2025-12-26 15:41:22 +01:00
Leon Böttger a9e6ed1ddf Fix max device name chars 2025-12-26 13:42:46 +01:00
Leon Böttger 728385ff95 Add button to enable share extension 2025-12-26 13:18:54 +01:00
Leon Böttger 46cdec35d5 Improve strings 2025-12-26 11:43:47 +01:00
Leon Böttger daa1ab77df Fix connection to Windows 2025-12-26 11:36:24 +01:00
Leon Böttger 6766ef69d9 Add synced StoreKit file 2025-12-26 11:35:39 +01:00
Leon Böttger 7a964e35ca Improve strings 2025-12-26 11:35:27 +01:00
Leon Böttger 733ed8bec8 Automatically delete temporary files after file transfer 2025-12-21 13:48:31 +01:00
Leon Böttger 24a0202ae9 Improve strings for introduction 2025-12-21 11:23:16 +01:00
Leon Böttger 945fdcaf6e Show IntroductionView in separate window 2025-12-21 11:16:51 +01:00
Leon Böttger 7cdfdcd474 Fix edit device name alert for macOS 12 2025-12-20 21:05:01 +01:00
Leon Böttger ad1ab30b94 Add GitHub release target 2025-12-20 20:20:18 +01:00
Leon Böttger 4a8ba26c8c Update README 2025-12-20 15:50:02 +01:00
Leon Böttger c887ccb637 Improve macOS introduction 2025-12-20 15:48:39 +01:00
Leon Böttger fb13405247 Add Local Network Checker 2025-12-20 15:24:57 +01:00
Leon Böttger b61aecf315 Add local network capability for macOS 2025-12-20 14:19:42 +01:00
Leon Böttger 8a9bb0bdfe Fix: Delay local network permission prompt to introduction 2025-12-20 14:00:51 +01:00
Leon Böttger 1bd15acaef Improve macOS introduction 2025-12-20 13:36:30 +01:00
Leon Böttger 3b99eda41c macOS: Add new introduction 2025-12-20 12:12:12 +01:00
Leon Böttger 8393a7495f Fix ZIP creation for certain file types, improve UX for outgoing transfer 2025-12-18 21:31:36 +01:00
Leon Böttger a5e1030c1f Add ability to set custom device name 2025-12-18 20:37:44 +01:00
Leon Böttger 8aa3341595 Update version number 2025-12-16 18:47:32 +01:00
Leon Böttger f5e3c88daf Add StoreKit 2 2025-12-02 19:34:38 +01:00
Leon Böttger 488f898d7b FIx: Cannot save images to photos if custom directory chosen 2025-11-29 18:39:52 +01:00
Leon Böttger 9e7278d327 Fix: iOS share extension not detecting new devices after a while #2 2025-11-22 18:52:02 +01:00
Leon Böttger fdc19b6f62 Fix: iOS share extension not detecting new devices after a while 2025-11-22 16:08:11 +01:00
Leon Böttger 734cf766d5 Add missing App Uses Non-Exempt Encryption flag 2025-11-18 20:42:30 +01:00
Leon Böttger f948c30027 Fix macOS toast view autohide 2025-11-18 20:39:13 +01:00
Leon Böttger 0c440b7511 macOS: Request review only after toast was dismissed 2025-11-18 20:25:18 +01:00
Leon Böttger bf5724ece1 Bump version number 2025-11-18 20:10:02 +01:00
Leon Böttger 658a2ff28e macOS: Add ability to save images/videos directly to photos app 2025-11-18 19:16:07 +01:00
Leon Böttger 4ddf39817c Feat: Remove files that were already imported in photos app 2025-11-16 15:13:47 +01:00
Leon Böttger 3fa1d8621e Improve strings 2025-11-16 15:05:11 +01:00
Leon Böttger 394aaa8093 iOS: Add ability to save photos to gallery 2025-11-16 14:31:17 +01:00
Leon Böttger 5e62f3e107 Improve logging 2025-11-16 10:55:21 +01:00
Leon Böttger 3f65ace2ae Bump version 2025-11-15 15:19:35 +01:00
Leon Böttger 41b6eb8e3a Improve iOS introduction 2025-11-15 15:06:14 +01:00
Leon Böttger 1256b91d4f Refactoring 2025-11-15 14:11:20 +01:00
Leon Böttger 9fd07e9326 Comments 2025-11-14 18:57:29 +01:00
Leon Böttger 70a6b5ff3e Fix: Cannot receive phone numbers 2025-11-13 21:34:06 +01:00
Leon Böttger 9536854fa2 Refactoring 2025-11-10 21:22:46 +01:00
Leon Böttger 39ea48fc80 Refactoring 2025-11-10 20:54:17 +01:00
Leon Böttger 4e73fced74 Refactor UIAlertController Logic 2025-11-10 19:44:15 +01:00
Leon Böttger 21f0f865b6 Bump version 2025-11-08 09:50:14 +01:00
Leon Böttger 026ebc33db Do not show review on GitHub release 2025-11-08 09:49:22 +01:00
Leon Böttger d01e1d7c34 Refactoring 2025-11-06 20:57:46 +01:00
Leon Böttger 14390035a3 Refactoring 2025-11-06 19:17:23 +01:00
Leon Böttger a3d295cd38 UI improvements 2025-11-06 19:03:16 +01:00
Leon Böttger 0ca2a13d66 Improve Toast View UI 2025-11-05 15:50:12 +01:00
Leon Böttger 05321adb6d Fix: progress bar not shown immediately when receiving many files 2025-11-05 15:09:07 +01:00
Leon Böttger 31b93a34a2 Fix incoming transmission capsule shape 2025-11-05 11:16:39 +01:00
Leon Böttger 261f91bc94 Add menu bar menu icons 2025-11-05 10:18:52 +01:00
Leon Böttger 646da767a7 Show average process for multiple incoming connnections 2025-10-19 14:39:25 +02:00
Leon Böttger 9d1e698d9b Refactoring 2025-10-19 14:20:44 +02:00
Leon Böttger a69a75cb07 Refactoring 2025-10-19 14:01:02 +02:00
Leon Böttger 1ac988a4fc Add progress notification when receiving files 2025-10-06 19:42:06 +02:00
Leon Böttger 673d4418f8 Better localizations 2025-10-06 14:09:47 +02:00
Leon Böttger 779144d26a Fix: iOS app was unable to send URLs 2025-10-06 14:09:10 +02:00
Leon Böttger 0fa585652d Update error handling instructions 2025-10-05 14:16:53 +02:00
Leon Böttger 1b1206d0b8 Comments 2025-10-05 14:01:37 +02:00
Leon Böttger 86afa8a206 Fix: Connection not cleaned up after unexpected close be peer 2025-10-05 13:07:10 +02:00
Leon Böttger dda001cb1a Fix: Connection not cleaned up after FIN was sent 2025-10-05 12:17:34 +02:00
Leon Böttger 4ae1663779 Update Strings 2025-09-23 12:38:07 +02:00
80 changed files with 14871 additions and 3462 deletions

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
+126
View File
@@ -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
} }
@@ -495,7 +486,7 @@ 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>() var usedDestinations = Set<URL>()
for file in frame.v1.introduction.fileMetadata { for file in frame.v1.introduction.fileMetadata {
@@ -515,25 +506,22 @@ 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 {
isPlainTextTransfer = true
let metadata = TransferMetadata(files: [], id: id, pinCode: pinCode, textDescription: textMetadata.textTitle, transferType: isClipboardText ? .text : .url, allowsToBeAddedAsTrustedDevice: self.peerCertificate != nil)
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)
} }
@@ -542,29 +530,26 @@ class InboundNearbyConnection: NearbyConnection {
let acceptAutomatically = isAuthenticated || Settings.shared.automaticallyAcceptFiles 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) { func submitUserConsent(accepted: Bool, trustDevice: Bool) {
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) {
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()
} }
@@ -572,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)
+38 -12
View File
@@ -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()
} }
@@ -17,6 +17,7 @@ import SwiftECC
import CryptoKit import CryptoKit
import SwiftUI import SwiftUI
import QRCode import QRCode
import LUI
public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject { public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate, ObservableObject {
@@ -27,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
@@ -38,15 +40,17 @@ 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 hasLocalNetworkPermission = true @Published var hasLocalNetworkPermission = true
@Published var isConnectedToLocalNetwork = true @Published var isConnectedToLocalNetwork = true
@Published var deviceInfo: EndpointInfo
public var connectionUpdateCallback: (Bool) -> Void = { _ in } { public var connectionUpdateCallback: (Bool) -> Void = { _ in } {
didSet { didSet {
@@ -54,17 +58,10 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
} }
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 = Device.current.description.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()
@@ -91,6 +88,76 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb
} }
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
} }
@@ -123,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
} }
@@ -190,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)
} }
@@ -283,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 }
@@ -294,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)
@@ -303,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()
}
} }
@@ -353,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)
} }
} }
@@ -365,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 {
@@ -381,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)
} }
@@ -392,7 +493,7 @@ 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)
} }
} }
@@ -457,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)
} }
} }
@@ -548,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")
@@ -560,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,6 +63,18 @@ 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)
@@ -70,19 +83,6 @@ class OutboundNearbyConnection: NearbyConnection {
} }
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() {
super.connectionReady() super.connectionReady()
do { do {
@@ -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
+72 -16
View File
@@ -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 ?? "?"])
}
} }
@@ -148,16 +183,20 @@ public struct EndpointInfo {
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
+1 -1
View File
@@ -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
+90
View File
@@ -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
}
}
+1 -8
View File
@@ -111,14 +111,7 @@ extension String {
public func fullVersion() -> Bool { public func fullVersion() -> Bool {
return Settings.shared.gotPlus
#if os(macOS)
// Enable full functionality if app distributed directly
return Settings.shared.gotPlus || DistributionDetector.isDirectDistributionEnabled
#else
// iOS
return true
#endif
} }
+1
View File
@@ -10,6 +10,7 @@ import Foundation
import SwiftECC import SwiftECC
import Security import Security
import CryptoKit import CryptoKit
import LUI
class IdentityManager { class IdentityManager {
-15
View File
@@ -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
}
}
-126
View File
@@ -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
}
}
}
-259
View File
@@ -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 fullVersion() {
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 Settings.shared.openFinderAfterReceiving {
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 = 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
}
/// 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)
}
}
+1
View File
@@ -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()
+22 -42
View File
@@ -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,
zipExtension: String = "zip",
fromDirectory directoryURL: URL
) throws -> URL {
let finalURL = FileManager.default.temporaryDirectory 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
} }
} }
+21
View File
@@ -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>
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,5 @@
{ {
"originHash" : "270e8bfd245fae589b12faf08b86042cbbe7c809d59c5b0b8b735c7fb3a8d4bd", "originHash" : "85c9b997510f22b553d79f68fb6d51d149ac9324df643dbf1475023b39da33b3",
"pins" : [ "pins" : [
{ {
"identity" : "applewatchscreensize", "identity" : "applewatchscreensize",
@@ -33,8 +33,8 @@
"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"
} }
}, },
{ {
@@ -52,7 +52,7 @@
"location" : "https://github.com/leonboe1/LUI.git", "location" : "https://github.com/leonboe1/LUI.git",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "123192312165c23271cd49492ae7e4fc676d7711" "revision" : "19b90ffd9778db54391501f0ba1319e0a1c12b1f"
} }
}, },
{ {
@@ -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" : "d620dacd1423d9545635eee3fc42c1fa1ab2f665" "revision" : "b10fc8a284abb9bd908d77243d2ba3b3d8ea6ebc"
}
},
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
"location" : "https://github.com/weichsel/ZIPFoundation.git",
"state" : {
"revision" : "22787ffb59de99e5dc1fbfe80b19c97a904ad48d",
"version" : "0.9.20"
} }
} }
], ],
@@ -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>
@@ -32,8 +32,8 @@
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "0"
useCustomWorkingDirectory = "NO" useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO" ignoresPersistentStateOnLaunch = "NO"
@@ -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"
+122 -39
View File
@@ -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,7 +36,13 @@ 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(
@@ -48,14 +56,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
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")
@@ -63,26 +75,26 @@ 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 !Settings.shared.appLaunchedBefore { if !Settings.shared.appLaunchedBefore {
log("[AppDelegate] Opening Welcome Screen") log("[AppDelegate] Opening Welcome Screen")
// open welcome screen // open welcome screen
openWelcomeScreen() openIntroductionWindow()
Settings.shared.appLaunchedBefore = true
// 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
Settings.shared.isEligibleForIap = true 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 !Settings.shared.isEligibleForIap { if !Settings.shared.isEligibleForIap {
log("[AppDelegate] Granting QuickDrop+ for old user") log("[AppDelegate] Granting QuickDrop+ for old user")
Settings.shared.gotPlus = true Settings.shared.gotPlus = true
UserDefaults.standard.set(true, forKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue)
} else { } else {
if !fullVersion() { if !fullVersion() {
log("[AppDelegate] New user - QuickDrop+ not available") log("[AppDelegate] New user - QuickDrop+ not available")
@@ -91,26 +103,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
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
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
if shouldOpen {
self.openPlusScreen()
}
else {
if let window = self.plusWindow {
log("[AppDelegate] Closing plus screen because of error")
window.close()
self.plusWindow = nil
}
}
})
NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in NearbyConnectionManager.shared.connectionUpdateCallback = { isConnected in
if isConnected { if isConnected {
self.statusItem?.button?.image = NSImage(named: "MenuBarIcon") self.statusItem?.button?.image = NSImage(named: "MenuBarIcon")
@@ -122,10 +124,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
} }
} }
NearbyConnectionManager.shared.changedDeviceNameCallback = {
self.visibleItem?.title = self.getDefaultVisibleLabel()
}
log("[AppDelegate] Application did finish launching") log("[AppDelegate] Application did finish launching")
} }
func startReceiving() {
receiveModel = ReceiveModel(controlPlusScreen: { shouldOpen in
if shouldOpen {
self.openPlusScreen()
}
else {
if let window = self.plusWindow {
log("[AppDelegate] Closing plus screen because of error")
window.close()
self.plusWindow = nil
}
}
})
}
func windowWillClose(_ notification: Notification) { func windowWillClose(_ notification: Notification) {
if let window = notification.object as? NSWindow, window == welcomeWindow { if let window = notification.object as? NSWindow, window == welcomeWindow {
log("Welcome window closed") log("Welcome window closed")
@@ -136,12 +158,22 @@ 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,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
@@ -151,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 {
if url.scheme == "quickdrop", url.host == "openLog", let url = LogManager.sharedInstance.logFileURL { case "sendLog":
log("Opening log file: \(url)") sendLoggingString()
// open folder containing the log file case "openLog":
NSWorkspace.shared.activateFileViewerSelecting([url]) if let url = LogManager.sharedInstance.logFileURL {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
case "removeData":
Settings.shared.deleteAllUserDefaults()
default:
break
}
} }
} }
} }
@@ -194,7 +230,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
} }
@objc func openWelcomeScreen() { @objc func openMainWindow() {
NSApp.setActivationPolicy(.regular) NSApp.setActivationPolicy(.regular)
@@ -215,7 +251,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
) )
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
@@ -237,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 {
@@ -249,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(
@@ -324,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).")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 585 KiB

After

Width:  |  Height:  |  Size: 618 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

@@ -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" : {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "FileTransfer Kopie-512.png", "filename" : "iDroidIcon-iOS-Default-1024x1024@1x.png",
"idiom" : "universal", "idiom" : "universal",
"scale" : "1x" "scale" : "1x"
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+742
View File
@@ -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 foto­bibliotek er påkrævet for at gemme modtagne fotos og videoer i dit foto­bibliotek."
}
},
"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"
}
File diff suppressed because it is too large. Load diff
-66
View File
@@ -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)
}
}
}
-76
View File
@@ -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
}
-191
View File
@@ -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."
}
}
}
-256
View File
@@ -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 {
@ObservedObject private var settings = Settings.shared
@ObservedObject private var iaphelper = IAPManager.sharedInstance
let closeView: () -> Void
@State private var restoring = false
@State private var showRestoreError = false
@State private var restoreError = ""
@Environment(\.colorScheme) var colorScheme
@State var products = [SKProduct]()
@State var price: String = ""
@State var warning = false
@State var buying = false
@State var errorText = ""
@State var boughtSuccessAlert = false {
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 {
settings.gotPlus = true
}
boughtSuccessAlert = true
} else {
log("[LUI] Nothing to restore")
//nothing to restore
restoreFailed(message: "plusview_nothingtorestore".localized())
}
case .failure(let error):
log("[LUI] Error restoring purchases: \(error.localizedDescription)")
log(error.localizedDescription)
restoreFailed(message: error.localizedDescription)
}
}
}
func restoreFailed(message: String) {
#if targetEnvironment(simulator)
isPlusVersion = true
dismissView()
#else
// now, try to convert from old version
IAPConverter.shared.restoreIAPfromPreviousPurchase { success in
DispatchQueue.main.async {
if success {
withAnimation {
settings.gotPlus = true
}
boughtSuccessAlert = true
}
else {
restoreError = message
showRestoreError = true
withAnimation {
restoring = false
}
}
}
}
#endif
}
}
func buttonAction() {
if let product = products.first {
purchase(product: product)
}
else {
loadProducts(shouldBuy: true)
}
}
func purchase(product: SKProduct) {
if !iaphelper.canMakePayments() {
warning = true
} else {
withAnimation{
buying = true
}
iaphelper.buy(product: product) { (result) in
DispatchQueue.main.async {
withAnimation{
buying = false
}
switch result {
case .success(_):
withAnimation {
settings.gotPlus = true
boughtSuccessAlert = true
warning = false
}
case .failure(let error):
warning = true
errorText = error.localizedDescription
}
}
}
}
}
func loadProducts(shouldBuy: Bool) {
iaphelper.getProducts { (result) in
DispatchQueue.main.async {
switch result {
case .success(let products):
self.products = products
price = products.first?.localizedPrice ?? ""
if let product = products.first, shouldBuy {
purchase(product: product)
}
case .failure(let error):
errorText = error.localizedDescription
if shouldBuy {
warning = true
}
withAnimation {
buying = false
}
}
}
}
}
}
extension SKProduct {
var localizedPrice: String {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = priceLocale
if let price = formatter.string(from: price) {
return price
}
return ""
}
}
#Preview {
PlusView(closeView: {})
}
@@ -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
}
}
+12 -3
View File
@@ -16,8 +16,12 @@ class Settings: ObservableObject {
// MARK: - Published UserDefaults Backed Properties // MARK: - Published UserDefaults Backed Properties
#if GITHUB
let gotPlus = true
#else
@Published var gotPlus = UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue) @Published var gotPlus = UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue)
{ didSet { UserDefaults.standard.set(gotPlus, forKey: UserDefaultsKeys.plusVersion.rawValue) }} { didSet { UserDefaults.standard.set(gotPlus, forKey: UserDefaultsKeys.plusVersion.rawValue) }}
#endif
@Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue) @Published var automaticallyAcceptFiles: Bool = UserDefaults.standard.bool(forKey: UserDefaultsKeys.automaticallyAcceptFiles.rawValue)
@@ -55,9 +59,11 @@ class Settings: ObservableObject {
// MARK: - Debug // MARK: - Debug
func deleteAllUserDefaults() { func deleteAllUserDefaults() {
for key in UserDefaultsKeys.allCases { let defaults = UserDefaults.standard
UserDefaults.standard.removeObject(forKey: key.rawValue) if let bundleID = Bundle.main.bundleIdentifier {
defaults.removePersistentDomain(forName: bundleID)
} }
defaults.synchronize()
Settings.shared = Settings() Settings.shared = Settings()
} }
@@ -69,11 +75,14 @@ class Settings: ObservableObject {
public enum UserDefaultsKeys: String, CaseIterable { public enum UserDefaultsKeys: String, CaseIterable {
case isEligibleForIap = "isEligibleForIap" case isEligibleForIap = "isEligibleForIap"
case appLaunchedBefore = "ShowedWelcomeScreen" case appLaunchedBefore = "ShowedWelcomeScreen"
case plusVersion = "isPlusVersion" case plusVersion = "plusVersion"
case transmissionCount = "reviewRequestCountKey" case transmissionCount = "reviewRequestCountKey"
case automaticallyAcceptFiles = "automaticallyAcceptFiles" case automaticallyAcceptFiles = "automaticallyAcceptFiles"
case saveFolderBookmark = "saveFolderBookmark" case saveFolderBookmark = "saveFolderBookmark"
case openFinderAfterReceiving = "openFinderAfterReceiving" case openFinderAfterReceiving = "openFinderAfterReceiving"
case endpointID = "endpointID" case endpointID = "endpointID"
// Plus version used before StoreKit 2 update. New key is fetched from App Store directly.
case plusVersionLegacy = "isPlusVersion"
} }
} }
+103
View File
@@ -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?()
}
}
+1
View File
@@ -6,6 +6,7 @@
// //
import AVFoundation import AVFoundation
import LUI
class AudioManager { class AudioManager {
static private var incomingFileSoundPlayer: AVAudioPlayer? static private var incomingFileSoundPlayer: AVAudioPlayer?
+3 -4
View File
@@ -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 {
@@ -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)
+1
View File
@@ -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
}
}
@@ -0,0 +1,68 @@
//
// ChangeDeviceNameModifier.swift
// QuickDrop
//
// Created by Leon Böttger on 16.12.25.
//
import SwiftUI
import LUI
struct ChangeDeviceNameAlertModifier: ViewModifier {
@Binding var isPresented: Bool
@State var customName = ""
func body(content: Content) -> some View {
content
.alert("ChangeDeviceName", isPresented: $isPresented) {
TextField("DeviceName", text: $customName)
Button("Save") {
let trimmed = customName.trimmingCharacters(in: .whitespacesAndNewlines)
NearbyConnectionManager.shared.setCustomDeviceName(to: trimmed)
}
Button("Cancel", role: .cancel) { }
} message: {
Text("ChangeDeviceNameDescription")
}
}
}
extension View {
func changeDeviceNameAlert(
isPresented: Binding<Bool>,
) -> some View {
modifier(ChangeDeviceNameAlertModifier(
isPresented: isPresented
))
}
}
struct ChangeDeviceNameModifier: ViewModifier {
let isEnabled: Bool
@State private var isPresented = false
func body(content: Content) -> some View {
content
.onTapGesture {
guard isEnabled else { return }
lightVibration()
isPresented = true
}
.changeDeviceNameAlert(isPresented: $isPresented)
}
}
extension View {
func changeDeviceNameAlert(isEnabled: Bool = true) -> some View {
modifier(ChangeDeviceNameModifier(isEnabled: isEnabled))
}
}
+426
View File
@@ -0,0 +1,426 @@
//
// IntroductionView.swift
// QuickDrop
//
// Created by Leon Böttger on 20.12.25.
//
import SwiftUI
import LUI
struct IntroductionView: View {
static let width: CGFloat = 810
static let height: CGFloat = 540
@Environment(\.colorScheme) private var colorScheme
@State private var currentPage: IntroductionPage = .splash
@State private var pollingPage: IntroductionPage? = nil
@State private var skipAction: IntroductionPage.SkipAction? = nil
@State private var helpActionAlert: IntroductionPage.HelpAction? = nil
let startReceiving: (() -> Void)
let onFinish: (() -> Void)
var body: some View {
GeometryReader { geo in
ZStack {
ZStack {
if colorScheme == .dark {
Color.black.opacity(0.2)
} else {
Color.white
}
}
.ignoresSafeArea()
VStack {
let topHeight = geo.size.height * 0.35
ZStack {
Color.gray.opacity(colorScheme == .light ? 0.1 : 0.12)
currentPage.topHeader(with: topHeight)
}
.edgesIgnoringSafeArea(.top)
.frame(height: topHeight)
Spacer()
VStack {
Text(currentPage.title.localized())
.font(.system(size: 32))
.fontWeight(.semibold)
.multilineTextAlignment(.center)
.minimumScaleFactor(0.5)
.lineLimit(1)
Text(currentPage.subtitle.localized())
.font(.system(size: 15))
.lineSpacing(5)
.multilineTextAlignment(.center)
.padding(.top)
.minimumScaleFactor(0.5)
.opacity(0.8)
}
.padding(.horizontal, 40)
Spacer()
if currentPage == .splash {
BottomFootnoteView(type: .privacyBanner)
}
else {
Spacer()
}
Divider()
HStack {
if let skipAction = currentPage.skipAction,
(currentPage.needRequestBeforeSkipAvailable == false || pollingPage != nil) {
Button("introduction_skip") {
self.skipAction = skipAction
}
.foregroundColor(.gray)
.buttonStyle(.borderless)
.alert(item: $skipAction) { action in
.init(title: Text(action.warningTitle.localized()), message: Text(action.warningMessage.localized()), primaryButton: .default(Text("introduction_continue".localized()), action: {
stopPolling()
self.skipAction = nil
if let nextPage = action.action() {
currentPage = nextPage
}
else {
onFinish()
}
}), secondaryButton: .cancel()
)
}
}
Spacer()
if let helpAction = currentPage.helpAction, pollingPage != nil {
HStack(spacing: 3) {
Image(systemName: "questionmark.circle")
Text(helpAction.question.localized())
}
.opacity(0.8)
.foregroundColor(.blue)
.font(.system(size: 15))
.onTapGesture {
helpActionAlert = helpAction
}
.alert(item: $helpActionAlert) { helpAction in
.init(title: Text(helpAction.question.localized()), message: Text(helpAction.answer.localized()), dismissButton: .cancel(Text("introduction_help_action_alert_close")))
}
}
continueArea
}
.padding(.horizontal)
.padding(.bottom, 10)
}
}
}
.onAppear { pageDidChange(to: currentPage) }
.onChange(of: currentPage) { newValue in
pageDidChange(to: newValue)
}
.task(id: pollingPage) {
guard let page = pollingPage else { return }
// Poll every 1 second until condition is satisfied or canceled
while !Task.isCancelled {
if page.canContinue() {
await MainActor.run {
// Only advance if we're still on the page we started polling for
guard currentPage == page else {
stopPolling()
return
}
stopPolling()
advance()
}
return
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
}
@ViewBuilder
private var continueArea: some View {
ZStack {
let isLoading = pollingPage != nil
Button("introduction_continue") {
continueTapped()
}
.buttonStyle(.bordered)
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.opacity(isLoading ? 0 : 1)
.overlay (
LoadingIndicator()
.frame(width: 20, height: 20)
.opacity(isLoading ? 1 : 0)
.frame(maxWidth: .infinity, alignment: .trailing)
)
.padding(.horizontal)
.padding(.vertical, 10)
}
}
private func pageDidChange(to newPage: IntroductionPage) {
stopPolling()
// Cannot perform action, default to loading screen
if !newPage.canPerformAction {
pollingPage = newPage
}
}
private func continueTapped() {
// If already allowed, advance immediately
if currentPage.canContinue() {
advance()
return
}
// Not allowed yet: show loading and start polling every 1s
pollingPage = currentPage
// Page-specific "kickoff" action that should happen only after user presses continue
if currentPage == .enableShareExtension {
if let url = URL(string: "x-apple.systempreferences:com.apple.LoginItems-Settings.extension") {
NSWorkspace.shared.open(url)
}
}
if currentPage == .localNetworkAccess {
Task {
do {
try await Task.sleep(nanoseconds: 20_000_000_000)
} catch {
return
}
await MainActor.run {
guard currentPage == .localNetworkAccess else { return }
guard skipAction == nil else { return }
if let action = currentPage.skipAction {
skipAction = action
}
}
}
}
}
private func triggerLocalNetworkPermission() {
self.startReceiving()
}
private func advance() {
if currentPage == .splash {
triggerLocalNetworkPermission()
}
if let nextPage = currentPage.presentNextPage() {
currentPage = nextPage
} else {
onFinish()
}
}
private func stopPolling() {
// Cancels the .task(id:) by changing the id and resets UI state
pollingPage = nil
}
}
enum IntroductionPage: CaseIterable {
case splash
case noWifi
case localNetworkAccess
case enableShareExtension
case finished
var title: String {
switch self {
case .splash:
return "WelcomeToQuickDrop"
case .noWifi:
return "introduction_no_wifi"
case .localNetworkAccess:
return "introduction_local_network_access"
case .enableShareExtension:
return "introduction_enable_share_extension"
case .finished:
return "introduction_finished"
}
}
var subtitle: String {
switch self {
case .splash:
return "QuickDropWelcomeDescription"
case .noWifi:
return "introduction_no_wifi_description"
case .localNetworkAccess:
return "introduction_local_network_access_description"
case .enableShareExtension:
if #available(macOS 13.0, *) {
return "introduction_enable_share_extension_description"
}
return "introduction_enable_share_extension_description_macos12"
case .finished:
return "introduction_finished_description"
}
}
var skipAction: SkipAction? {
switch self {
case .splash:
return nil
case .noWifi:
return .init(action: presentNextPage, warningTitle: "introduction_no_wifi_skip_title", warningMessage: "introduction_no_wifi_skip_message")
case .localNetworkAccess:
return .init(action: presentNextPage, warningTitle: "introduction_local_network_access_skip_title", warningMessage: "introduction_local_network_access_skip_message")
case .enableShareExtension:
return .init(action: presentNextPage, warningTitle: "introduction_enable_share_extension_skip_title", warningMessage: "introduction_enable_share_extension_skip_message")
case .finished:
return nil
}
}
var helpAction: HelpAction? {
switch self {
case .enableShareExtension:
return .init(question: "introduction_enable_share_extension_not_visible", answer: "introduction_enable_share_extension_not_visible_answer")
default:
return nil
}
}
var needRequestBeforeSkipAvailable: Bool {
switch self {
case .localNetworkAccess:
return true
default:
return false
}
}
var canPerformAction: Bool {
switch self {
case .noWifi:
return false
default:
return true
}
}
func presentNextPage() -> IntroductionPage? {
switch self {
case .splash:
return nil
//NearbyConnectionManager.shared.isConnectedToLocalNetwork ? firstPermissionPane() : .noWifi
case .noWifi:
return firstPermissionPane()
case .localNetworkAccess:
return .enableShareExtension
case .enableShareExtension:
return .finished
case .finished:
return nil
}
}
private func firstPermissionPane() -> IntroductionPage {
if #available(macOS 15.0, *) {
// Local network access is requested first on macOS 15+
return .localNetworkAccess
}
return .enableShareExtension
}
func canContinue() -> Bool {
switch self {
case .noWifi:
return NearbyConnectionManager.shared.isConnectedToLocalNetwork
case .enableShareExtension:
return NSSharingService(named: NSSharingService.Name("com.leonboettger.neardrop.ShareExtension")) != nil
case .localNetworkAccess:
return DeviceToDeviceHeuristicScanner.shared.hasLocalNetworkAccess(completion: {_ in })
default:
return true
}
}
func topHeader(with height: CGFloat) -> some View {
ZStack {
switch self {
case .splash:
Image(.quickDropMockup)
.resizable()
.scaledToFit()
.padding(.top, height * 0.33)
.padding(.bottom, height * 0.1)
case .noWifi:
imageView(for: "wifi.slash", with: height)
case .localNetworkAccess:
imageView(for: "network", with: height)
case .enableShareExtension:
imageView(for: "square.and.arrow.up", with: height)
case .finished:
imageView(for: "checkmark.seal.fill", with: height)
}
}
.foregroundColor(.blue)
}
private func imageView(for image: String, with height: CGFloat) -> some View {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(height: height * 0.5)
.padding(.top, height * 0.13)
}
struct SkipAction: Identifiable {
let action: () -> IntroductionPage?
let warningTitle: String
let warningMessage: String
var id: String { warningTitle }
}
struct HelpAction: Identifiable {
let question: String
let answer: String
var id: String { question }
}
}
#Preview {
IntroductionView(startReceiving: {}){}
.frame(width: IntroductionView.width, height: IntroductionView.height)
}
@@ -9,6 +9,7 @@ import SwiftUI
import Foundation import Foundation
import AppKit import AppKit
import Network import Network
import LUI
fileprivate let routerCheckQueue = DispatchQueue(label: "routerCheckQueue") fileprivate let routerCheckQueue = DispatchQueue(label: "routerCheckQueue")
@@ -17,7 +18,7 @@ struct ApIsolationIssueView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let closeView : () -> Void let closeView : () -> Void
let scanner = DeviceToDeviceHeuristicScanner() let scanner = DeviceToDeviceHeuristicScanner.shared
var body: some View { var body: some View {
+219
View File
@@ -0,0 +1,219 @@
//
// IncomingFileTransmissionView.swift
// QuickDrop
//
// Created by Leon Böttger on 05.10.25.
//
import SwiftUI
import AppKit
let toastViewSize = CGSize(width: 400, height: 100)
struct QuickDropToastView: View {
@ObservedObject var settings = Settings.shared
@ObservedObject var receiveModel: ReceiveModel
@State var autoHider = DispatchWorkItem(block: {})
/// Cancel while receiving.
public var onCancel: () -> Void
init(
receiveModel: ReceiveModel,
onCancel: @escaping () -> Void = {}
) {
self.receiveModel = receiveModel
self.onCancel = onCancel
}
public var body: some View {
let isDone = receiveModel.toastActions != nil
VStack(spacing: 12) {
HStack(spacing: 12) {
ZStack(alignment: .bottomLeading) {
Image(.quickDropIcon)
.resizable()
.scaledToFill()
.frame(width: 44, height: 44)
.clipShape(Circle())
}
VStack(alignment: .leading, spacing: 2) {
Text(String("QuickDrop"))
.font(.system(size: 16, weight: .semibold))
Text(isDone ? "FileTransferCompleted" : "Receiving")
.font(.system(size: 13))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer(minLength: 12)
}
ZStack {
if let actions = receiveModel.toastActions {
HStack(spacing: 8) {
QuickDropToastViewButton(title: "OpenInFinder") {
actions.openFilesAction()
actions.closeToastAction()
}
if let onImportToPhotos = actions.importPhotosAction {
QuickDropToastViewButton(title: "ImportToPhotos") {
onImportToPhotos()
actions.closeToastAction()
}
}
Spacer()
QuickDropToastViewButton(title: "Done", action: actions.closeToastAction)
.onAppear {
self.autoHider = DispatchWorkItem(block: {
actions.closeToastAction()
})
DispatchQueue.main.asyncAfter(deadline: .now() + 10, execute: self.autoHider)
}
.onDisappear {
self.autoHider.cancel()
}
}
.buttonStyle(.bordered)
.controlSize(.small)
}
HStack {
ZStack {
CapsuleProgress(value: 0.5)
.opacity(0)
if let progress = receiveModel.progress {
CapsuleProgress(value: progress)
}
}
.animation(.easeInOut, value: receiveModel.progress == nil)
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .semibold))
.foregroundColor(.secondary)
.frame(width: 18, height: 18)
}
.buttonStyle(PlainButtonStyle())
}
.opacity(isDone ? 0 : 1)
}
}
.padding(16)
.background(
VisualEffectView(material: .hudWindow)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
)
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(Color.black.opacity(0.06), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.1), radius: 20)
.frame(maxWidth: .infinity)
}
}
fileprivate struct QuickDropToastViewButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title.localized())
.padding(.horizontal, 12)
.padding(.vertical, 4)
.background(
Color.gray.opacity(0.15)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
)
}
.buttonStyle(.plain) // prevents blue macOS button look
}
}
// NSVisualEffectView wrapper for macOS 11 compatible blur/vibrancy
struct VisualEffectView: NSViewRepresentable {
var material: NSVisualEffectView.Material = .hudWindow
var blendingMode: NSVisualEffectView.BlendingMode = .withinWindow
var state: NSVisualEffectView.State = .active
func makeNSView(context: Context) -> NSVisualEffectView {
let v = NSVisualEffectView()
v.material = material
v.blendingMode = blendingMode
v.state = state
v.isEmphasized = true
return v
}
func updateNSView(_ v: NSVisualEffectView, context: Context) {
v.material = material
v.blendingMode = blendingMode
v.state = state
}
}
// Capsule-style linear progress (0...1)
struct CapsuleProgress: View {
var value: Double
var track = Color(NSColor.separatorColor).opacity(0.28)
var fill = Color(NSColor.systemBlue)
var body: some View {
GeometryReader { geo in
let progress = max(0, min(1, value))
ZStack(alignment: .leading) {
Capsule()
.fill(track)
Capsule()
.fill(fill)
.mask(
Capsule()
.frame(width: progress * geo.size.width)
.frame(maxWidth: .infinity, alignment: .leading)
)
}
}
.frame(height: 8)
.animation(.easeInOut(duration: 0.25), value: value)
}
}
// MARK: - Preview
struct QuickDropToastView_Previews: PreviewProvider {
struct Demo: View {
@State var model = ReceiveModel(controlPlusScreen: { _ in })
@State var done = false
var body: some View {
QuickDropToastView(
receiveModel: model,
onCancel: { }
)
.frame(width: toastViewSize.width, height: toastViewSize.height)
.padding(100)
.background(Color.white.opacity(0.2))
.onAppear {
model.progress = 0.01
}
}
}
static var previews: some View { Demo() }
}
+17 -1
View File
@@ -7,10 +7,14 @@
import SwiftUI import SwiftUI
import AppKit import AppKit
import LUI
struct SettingsView: View { struct SettingsView: View {
@ObservedObject var settings = Settings.shared @ObservedObject var settings = Settings.shared
@ObservedObject var connectionManager = NearbyConnectionManager.shared
@State private var isChangeDeviceNameAlertPresented = false
var body: some View { var body: some View {
@@ -31,12 +35,24 @@ struct SettingsView: View {
Text("SaveFilesTo") Text("SaveFilesTo")
Spacer() Spacer()
Text(getSavedFolderPath() ?? "DownloadsFolder".localized()) Text(getSavedFolderPath() ?? "DownloadsFolder".localized())
.foregroundColor((settings.saveFolderBookmark ?? Data()).isEmpty ? .gray : .primary) .foregroundColor(.gray)
Button("SaveFilesToButton") { Button("SaveFilesToButton") {
selectFolder() selectFolder()
} }
} }
HStack {
Text("DeviceName")
Spacer()
Text(connectionManager.deviceInfo.name ?? "Unknown".localized())
.foregroundColor(.gray)
Button("Edit") {
isChangeDeviceNameAlertPresented.toggle()
}
.changeDeviceNameAlert(isPresented: $isChangeDeviceNameAlertPresented)
}
Divider() Divider()
.padding(.vertical, 10) .padding(.vertical, 10)
+99 -10
View File
@@ -5,13 +5,13 @@
// Created by Leon Böttger on 05.03.25. // Created by Leon Böttger on 05.03.25.
// //
import AppKit
import LUI
import SwiftUI import SwiftUI
struct TutorialView: View { struct TutorialView: View {
let title: String let tab: Tab
let text: String
let showsLicense: Bool
let openPlus: () -> Void let openPlus: () -> Void
@State private var licenseWindow: NSWindow? @State private var licenseWindow: NSWindow?
@@ -19,15 +19,16 @@ struct TutorialView: View {
var body: some View { var body: some View {
LargeAppIconView(title: title) { LargeAppIconView(title: tab.title) {
VStack { VStack {
Text(text.localized())
Text(tab.text.localized())
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding() .padding()
.frame(width: 550) .frame(width: 550)
if showsLicense { if tab == .receive {
HStack(spacing: 30) { HStack(spacing: 30) {
Button { Button {
@@ -66,12 +67,102 @@ struct TutorialView: View {
} }
.padding() .padding()
} }
else if tab == .send {
EnableExtensionView()
}
} }
} }
} }
} }
struct EnableExtensionView: View {
@State private var showSharePicker = false
@State private var shareItems: [Any] = []
@State private var isExtensionEnabled = false
@State private var showSuccessCheckmark = false
@State private var animateCheckmark = false
private let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
init() {
_isExtensionEnabled = State(initialValue: Self.isEnabled())
}
var body: some View {
if !isExtensionEnabled {
Button("EnableQuickDropExtension".localized()) {
shareItems = [
"EnableQuickDropExtensionDescription".localized()
]
showSharePicker = true
}
.background(
SharingPickerPresenter(
isPresented: $showSharePicker,
sharingItems: shareItems
)
)
.padding()
.onReceive(timer) { _ in
updateEnabledState()
}
}
else if showSuccessCheckmark {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 20))
.foregroundColor(.green)
.background(Circle().foregroundColor(.white).frame(width: 18, height: 18))
.scaleEffect(animateCheckmark ? 1.0 : 0.5)
.animation(.easeOut(duration: 0.4), value: animateCheckmark)
.onAppear {
animateCheckmark = true
}
.padding()
}
}
private func updateEnabledState() {
let enabled = Self.isEnabled()
if enabled != isExtensionEnabled {
withAnimation(.smooth) {
isExtensionEnabled = enabled
if enabled {
showSuccessCheckmark = true
}
}
}
}
private static func isEnabled() -> Bool {
NSSharingService(
named: NSSharingService.Name("com.leonboettger.neardrop.ShareExtension")
) != nil
}
}
private struct SharingPickerPresenter: NSViewRepresentable {
@Binding var isPresented: Bool
let sharingItems: [Any]
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ nsView: NSView, context: Context) {
guard isPresented else { return }
let picker = NSSharingServicePicker(items: sharingItems)
DispatchQueue.main.async {
picker.show(relativeTo: nsView.bounds, of: nsView, preferredEdge: .minY)
isPresented = false
}
}
}
struct LargeAppIconView<Content: View>: View { struct LargeAppIconView<Content: View>: View {
let title: String let title: String
@@ -81,10 +172,8 @@ struct LargeAppIconView<Content: View>: View {
ScrollView { ScrollView {
VStack { VStack {
Image("AppIconHighRes") AppIconView(hasPlusIcon: false, size: 120)
.resizable() .padding(.top, 35)
.frame(width: 150, height: 150)
.padding(.top, 40)
Text(title.localized()) Text(title.localized())
.font(.largeTitle) .font(.largeTitle)
+55 -25
View File
@@ -7,9 +7,13 @@
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import LUI
struct WelcomeScreen: View { struct WelcomeScreen: View {
static let width: CGFloat = 1000
static let height: CGFloat = 600
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let openPlusScreen: () -> Void let openPlusScreen: () -> Void
@@ -17,14 +21,22 @@ struct WelcomeScreen: View {
let openCableTransmissionView: () -> Void let openCableTransmissionView: () -> Void
let checkForNetworkIssues: () -> Void let checkForNetworkIssues: () -> Void
@State private var selection: Tabs? = Tabs.receive @State private var selection: Tab = Tab.receive
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
List(selection: $selection) {
ForEach(Tabs.allCases.filter({$0 != .settings }), id: \.self) { tab in let listBinding = Binding<Tab?>(
Label(tab.title, systemImage: tab.systemImage) get: { selection },
set: { newValue in
selection = newValue ?? .receive
}
)
List(selection: listBinding) {
ForEach(Tab.allCases.filter({$0 != .settings }), id: \.self) { tab in
Label(tab.sidebarTitle, systemImage: tab.systemImage)
.tag(tab) .tag(tab)
.frame(height: 30) .frame(height: 30)
} }
@@ -49,8 +61,8 @@ struct WelcomeScreen: View {
Divider() Divider()
Label(Tabs.settings.title, systemImage: Tabs.settings.systemImage) Label(Tab.settings.sidebarTitle, systemImage: Tab.settings.systemImage)
.tag(Tabs.settings) .tag(Tab.settings)
.frame(height: 30) .frame(height: 30)
} }
.minimumScaleFactor(0.5) .minimumScaleFactor(0.5)
@@ -58,34 +70,30 @@ struct WelcomeScreen: View {
.listStyle(SidebarListStyle()) .listStyle(SidebarListStyle())
Divider() Divider()
.opacity(colorScheme == .light ? 1 : 0) .opacity(colorScheme == .light ? 1 : 0.5)
.edgesIgnoringSafeArea(.vertical) .edgesIgnoringSafeArea(.vertical)
ZStack { ZStack {
Color.defaultBackground.edgesIgnoringSafeArea(.vertical) Color.defaultBackground.edgesIgnoringSafeArea(.vertical)
switch selection { if selection == .settings {
case .receive: SettingsView()
TutorialView(title: "WelcomeToQuickDrop", text: "UserManualDescription", showsLicense: true, openPlus: openPlusScreen) }
else {
case .send: TutorialView(tab: selection, openPlus: openPlusScreen)
TutorialView(title: "SendFiles", text: "SendFilesDescription", showsLicense: false, openPlus: openPlusScreen) .onAppear {
if selection == .troubleshooting {
case .troubleshooting: checkForNetworkIssues()
TutorialView(title: "Troubleshooting", text: "TroubleshootingDescription", showsLicense: false, openPlus: openPlusScreen) }
.onAppear { }
checkForNetworkIssues() }
}
default:
SettingsView()
}
} }
} }
.frame(width: 1000, height: 600)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in .onDrop(of: [.fileURL], isTargeted: nil) { providers in
handleDrop(providers: providers) handleDrop(providers: providers)
return true return true
} }
.frame(width: Self.width, height: Self.height)
} }
func openPrivacyPolicy() { func openPrivacyPolicy() {
@@ -167,13 +175,13 @@ struct ExternalLinkLabel: View {
} }
enum Tabs: CaseIterable { enum Tab: CaseIterable {
case receive case receive
case send case send
case troubleshooting case troubleshooting
case settings case settings
var title: String { var sidebarTitle: String {
switch self { switch self {
case .receive: case .receive:
return "ReceiveFiles".localized() return "ReceiveFiles".localized()
@@ -186,6 +194,28 @@ enum Tabs: CaseIterable {
} }
} }
var title: String {
switch self {
case .troubleshooting:
return "Troubleshooting"
default:
return sidebarTitle
}
}
var text: String {
switch self {
case .receive:
"UserManualDescription"
case .send:
"SendFilesDescription"
case .troubleshooting:
"TroubleshootingDescription"
case .settings:
""
}
}
var systemImage: String { var systemImage: String {
switch self { switch self {
case .receive: case .receive:
+424
View File
@@ -0,0 +1,424 @@
//
// ReceiveModel.swift
// QuickDrop
//
// Created by Leon Böttger on 16.08.25.
//
import SwiftUI
import StoreKit
import UserNotifications
import LUI
#if os(macOS)
import BezelNotification
#endif
class ReceiveModel: ObservableObject, InboundAppDelegate {
/// For each connection ID, store the last reported progress value
private var processes: [String: Double] = [:]
#if os(macOS)
@Published var progress: Double? = nil
@Published var toastActions: ToastViewAction? = nil
private var pendingReview = false
private var toastWindow: NSWindow?
private var toastHosting: NSHostingView<QuickDropToastView>?
private let monitor = AllowedWorkMonitor()
#endif
let controlPlusScreen: (Bool) -> Void
init(controlPlusScreen: @escaping (Bool) -> Void) {
self.controlPlusScreen = controlPlusScreen
NearbyConnectionManager.shared.addInboundAppDelegate(self)
#if os(macOS)
self.monitor.onAllowed = {
NearbyConnectionManager.shared.becomeVisible()
}
self.monitor.onStopped = {
NearbyConnectionManager.shared.becomeInvisible()
}
self.monitor.start()
#else
NearbyConnectionManager.shared.becomeVisible()
#endif
}
deinit {
NearbyConnectionManager.shared.removeInboundAppDelegate(self)
#if os(macOS)
self.monitor.stop()
#endif
}
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo) {
#if os(macOS)
NSApp.activate(ignoringOtherApps: true)
AudioManager.playIncomingFileSound()
#endif
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized(), alreadyAccepted: false)
let pinCodeMessage = transfer.getPinCodeMessage()
let transferID = transfer.id
let title = "QuickDrop - \(pinCodeMessage)"
let primaryButtonTitle = "Accept".localized()
let primaryButtonAction = { (trustDevice: Bool) in
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice)
#if os(macOS)
if transfer.type == .text {
DispatchQueue.main.async {
BezelNotification.show(messageText: "InsertedIntoClipboard".localized(), icon: .clipboard)
}
}
#endif
}
let secondaryButtonTitle = "Decline".localized()
let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: false) }
#if os(macOS)
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = title
alert.informativeText = mainMessage
alert.addButton(withTitle: primaryButtonTitle)
alert.addButton(withTitle: secondaryButtonTitle)
if transfer.allowsToBeAddedAsTrustedDevice {
alert.showsSuppressionButton = true
alert.suppressionButton?.title = "AutoAcceptFromThisDevice".localized()
}
let result = alert.runModal()
if result == .alertFirstButtonReturn {
primaryButtonAction(alert.suppressionButton?.state == .on)
} else if result == .alertSecondButtonReturn {
secondaryButtonAction()
}
#else
// iOS
let alwaysAcceptLabel = transfer.allowsToBeAddedAsTrustedDevice ? "AlwaysAccept".localized() : nil
ProgressAlert.shared.askForUserPermission(title: title, message: mainMessage, acceptLabel: primaryButtonTitle, acceptAlwaysLabel: alwaysAcceptLabel, rejectLabel: secondaryButtonTitle) { accepted in
switch accepted {
case .Accept:
primaryButtonAction(false)
case .AcceptAlways:
primaryButtonAction(true)
case .Decline:
secondaryButtonAction()
}
}
#endif
}
func obtainedUserConsentAutomatically(transfer: TransferMetadata, device: RemoteDeviceInfo) {
let mainMessage = transfer.getDescription(deviceName: device.name ?? "UnknownDevice".localized(), alreadyAccepted: true)
#if os(macOS)
NSApp.activate(ignoringOtherApps: true)
AudioManager.playIncomingFileSound()
// If file -> no notification, as there is the QuickDrop progress toast
if transfer.type != .file {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
if granted {
log("[ReceiveModel] User granted notification permissions")
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "QuickDrop"
notificationContent.body = mainMessage
notificationContent.sound = nil
let notificationId = UUID().uuidString
let notificationReq = UNNotificationRequest(identifier: notificationId, content: notificationContent, trigger: nil)
UNUserNotificationCenter.current().add(notificationReq)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationId])
}
}
}
}
#else
// If text is received, nothing is shown, as it is directly inserted into clipboard automatically
// Therefore, give a feedback dialog in this case
if transfer.type == .text {
showAlert(title: "QuickDrop", message: mainMessage)
}
#endif
}
func transferProgress(connectionID: String, progress: Double) {
// Update process dictionary
processes[connectionID] = progress
// Calcualate average value for all ongoing transfers
let totalProgress = processes.values.reduce(0, +)
let averageProgress = totalProgress / Double(processes.count)
#if os(iOS)
ProgressAlert.shared.updateProgress(averageProgress, onCancel: {
NearbyConnectionManager.shared.cancelTransfer(id: connectionID)
}, completion: {})
#else
DispatchQueue.main.async {
self.toastActions = nil
self.progress = averageProgress
self.showQuickDropToast(for: connectionID)
}
#endif
}
func connectionWasTerminated(connectionID: String, from device: RemoteDeviceInfo?, savedFiles: [URL], wasPlainTextTransfer: Bool, error: (any Error)?) {
processes.removeValue(forKey: connectionID)
#if os(macOS)
DispatchQueue.main.async {
if self.processes.isEmpty {
self.progress = nil
}
}
finish()
#else
if self.processes.isEmpty {
ProgressAlert.shared.updateProgress(nil, onCancel: {}) {
finish()
}
}
#endif
func finish() {
if let error = error {
if let name = device?.name {
#if os(macOS)
DispatchQueue.main.async {
self.hideQuickDropToast()
}
#endif
controlPlusScreen(false)
errorVibration()
ErrorAlertHandler.shared.showErrorAlert(for: name, error: error)
}
} else {
#if os(iOS)
doubleVibration()
if !savedFiles.isEmpty {
let hasPhotoOrVideos = PhotoManager.hasPhotosOrVideos(at: savedFiles)
let photoSaveAction: (() -> ())? = hasPhotoOrVideos ? {
Task {
do {
try await PhotoManager.saveMediaToPhotoLibrary(from: savedFiles)
}
catch {
showAlert(title: "CouldNotSaveMediaToPhotoLibrary", message: error.localizedDescription)
}
}
} : nil
ProgressAlert.shared.presentIncomingTransferDoneAlert(title: "FileTransferCompleted".localized(), message: "FileTransferCompletedMessage".localized(), onImportToPhotos: photoSaveAction)
}
#endif
let currentCount = Settings.shared.incomingTransmissionCount
#if os(macOS)
if currentCount == 0 {
pendingReview = true
}
let openFilesAction = {
if !savedFiles.isEmpty {
log("[SaveFilesManager] Opening \(savedFiles.count) file(s) in Finder.")
NSWorkspace.shared.activateFileViewerSelecting(savedFiles)
}
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
let hasPhotoOrVideos = PhotoManager.hasPhotosOrVideos(at: savedFiles)
let importPhotosAction: (()->())? = hasPhotoOrVideos ? {
Task {
do {
try await PhotoManager.saveMediaToPhotoLibrary(from: savedFiles)
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
catch {
DispatchQueue.main.async {
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "CouldNotSaveMediaToPhotoLibrary".localized()
alert.informativeText = error.localizedDescription
alert.addButton(withTitle: "CloseAlert".localized())
alert.runModal()
}
}
}
} : nil
let closeToastAction = {
self.hideQuickDropToast()
showReviewIfAppropriate(currentTransmissionCount: currentCount)
}
DispatchQueue.main.async {
withAnimation {
self.toastActions = ToastViewAction(openFilesAction: openFilesAction, importPhotosAction: importPhotosAction, closeToastAction: closeToastAction)
}
// Show received files if wanted
if Settings.shared.openFinderAfterReceiving {
self.toastActions?.openFilesAction()
}
}
#endif
Settings.shared.incomingTransmissionCount = currentCount + 1
log("[ReceiveModel] Successful transmission. Current count: \(currentCount)")
}
}
#if os(macOS)
func showReviewIfAppropriate(currentTransmissionCount: Int) {
if pendingReview {
// Dont request review again
pendingReview = false
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
SKStoreReviewController.requestReview()
}
}
}
#endif
}
func showPlusScreen() {
DispatchQueue.main.async {
self.controlPlusScreen(true)
}
}
// MARK: - macOS Progress Overlay
#if os(macOS)
func showQuickDropToast(for connectionID: String) {
guard toastWindow == nil else { return }
let contentView = QuickDropToastView(
receiveModel: self,
onCancel: {
NearbyConnectionManager.shared.cancelTransfer(id: connectionID)
}
)
let hostingView = NSHostingView(rootView: contentView)
hostingView.frame.size = toastViewSize
// Prefer the screen under the mouse, then the key window’s screen, then main
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) }
?? NSApp.keyWindow?.screen
?? NSScreen.main
guard let screen else { return }
let frame = screen.frame
let visible = screen.visibleFrame
let marginX: CGFloat = 24
let marginFromTop: CGFloat = 30
// If the menu bar is auto-hidden, visible.maxY == frame.maxY; subtract the status bar thickness to stay clear
let menuBarHidden = abs(frame.maxY - visible.maxY) < 0.5
let extraTopInset = menuBarHidden ? NSStatusBar.system.thickness : 0
let targetX = visible.maxX - toastViewSize.width - marginX
let targetY = visible.maxY - toastViewSize.height - marginFromTop - extraTopInset
// Start slightly above the final spot for a smooth slide-in
let startY = min(frame.maxY + 10, targetY + 10)
let startFrame = CGRect(x: targetX, y: startY, width: toastViewSize.width, height: toastViewSize.height)
let window = NSWindow(contentRect: startFrame,
styleMask: [.borderless],
backing: .buffered,
defer: false)
window.isOpaque = false
window.backgroundColor = .clear
window.hasShadow = true
window.level = .statusBar
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.contentView = hostingView
window.ignoresMouseEvents = false
window.alphaValue = 0
window.makeKeyAndOrderFront(nil)
self.toastWindow = window
self.toastHosting = hostingView
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.35
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrameOrigin(CGPoint(x: targetX, y: targetY))
window.animator().alphaValue = 1
}
}
func hideQuickDropToast() {
guard let window = toastWindow,
let screen = NSScreen.main else { return }
let offscreenY = screen.visibleFrame.origin.y + screen.visibleFrame.height + 20
NSAnimationContext.runAnimationGroup({ ctx in
ctx.duration = 0.35
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrameOrigin(CGPoint(x: window.frame.origin.x, y: offscreenY))
window.animator().alphaValue = 0
}, completionHandler: {
window.orderOut(nil)
self.toastWindow = nil
self.toastHosting = nil
self.toastActions = nil
})
}
struct ToastViewAction {
let openFilesAction: () -> ()
let importPhotosAction: (() -> ())?
let closeToastAction: () -> ()
}
#endif
}
@@ -29,7 +29,12 @@ class SendModel: ObservableObject, OutboundAppDelegate {
init() { init() {
NearbyConnectionManager.shared.startDeviceDiscovery() NearbyConnectionManager.shared.startDeviceDiscovery()
NearbyConnectionManager.shared.addShareExtensionDelegate(self) NearbyConnectionManager.shared.addOutboundAppDelegate(self)
}
deinit {
NearbyConnectionManager.shared.removeOutboundAppDelegate(self)
} }
@@ -56,11 +61,10 @@ class SendModel: ObservableObject, OutboundAppDelegate {
func connectionFailed(error: any Error) { func connectionFailed(error: any Error) {
#if os(iOS) if let name = selectedDevice?.name {
errorVibration() errorVibration()
#endif ErrorAlertHandler.shared.showErrorAlert(for: name, error: error)
}
ErrorAlertHandler.shared.showErrorAlert(for: selectedDevice?.name ?? "?", error: error)
progressValue = nil progressValue = nil
selectedDevice = nil selectedDevice = nil
@@ -116,14 +120,18 @@ class SendModel: ObservableObject, OutboundAppDelegate {
progressValue = 0 progressValue = 0
progressState = nil progressState = nil
selectedDevice = nil selectedDevice = nil
NearbyConnectionManager.shared.cancelOutgoingTransfer(id: device.id!) NearbyConnectionManager.shared.cancelTransfer(id: device.id!)
} }
else { else {
progressValue = 0 progressValue = 0
progressState = "Connecting".localized() progressState = "Preparing".localized()
selectedDevice = device selectedDevice = device
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: urls, textToSend: textToSend)
runAfter(seconds: 0.3) {
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: self.urls, textToSend: self.textToSend)
self.progressState = "Connecting".localized()
}
} }
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@
}, },
"translucency" : { "translucency" : {
"enabled" : true, "enabled" : true,
"value" : 0.52 "value" : 0.16
} }
} }
], ],
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<string>SUBQUERY(extensionItems, $extensionItem, TRUEPREDICATE).@count &gt; 0</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
<key>UIDesignRequiresCompatibility</key>
<true/>
</dict>
</plist>
+12 -3
View File
@@ -6,16 +6,25 @@
// //
import LUI import LUI
import Foundation
let configuration = LUIConfiguration( let configuration = LUIConfiguration(
appName: "QuickDrop", appName: "QuickDrop",
inAppPurchaseName: "plusversion", plusVersionInfo: GetPlusViewInformation(lifetimeID: "plusversion", monthlyID: "quickdrop.monthly", yearlyID: "quickdrop.yearly", plusViewHeader: isMac() ? "plusview_title" : nil, description: isMac() ? "plusview_description" : "plusview_description_phone", onBoughtPlusVersionAlertMessage: "plusview_success_description", plusVersionFeatures: [], settingsLabelColor: .orange, settingsFooter: "plusview_footer_phone"),
plusVersionInfo: GetPlusViewInformation(description: "plusview_description_phone", plusVersionFeatures: [], settingsLabelColor: .orange, settingsFooter: "plusview_footer_phone"), setPlusVersion: { result in
#if !GITHUB
DispatchQueue.main.async {
Settings.shared.gotPlus = result
}
#endif
},
hasRoundedFont: false, hasRoundedFont: false,
switchedToIAPafterBundleVersion: AppVersionBundle(appVersion: "1.2", buildVersion: 0, legacyUserDefaultsKey: Settings.UserDefaultsKeys.plusVersionLegacy.rawValue),
copyrightInformation: licenseText, copyrightInformation: licenseText,
loggingUrl: AppGroup.appGroupDirectory, loggingUrl: AppGroup.appGroupDirectory,
@@ -24,7 +33,7 @@ let configuration = LUIConfiguration(
usesAutomaticReviewPrompt: false, usesAutomaticReviewPrompt: false,
usesPagedIntroduction: false, usesPagedIntroduction: true,
supportsSplitView: false, supportsSplitView: false,
+234
View File
@@ -269,6 +269,240 @@
} }
} }
} }
},
"NSPhotoLibraryAddUsageDescription" : {
"comment" : "Privacy - Photo Library Additions Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "مطلوب إذن إضافة الصور لحفظ الصور ومقاطع الفيديو المستلمة في مكتبة الصور الخاصة بك."
}
},
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "Es requereix el permís d’afegir fotos per desar fotos i vídeos rebuts a la teva biblioteca."
}
},
"cs" : {
"stringUnit" : {
"state" : "translated",
"value" : "Oprávnění k přidání fotek je nutné pro ukládání přijatých fotek a videí do vaší knihovny fotografií."
}
},
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tilladelse til at tilføje fotos er nødvendig for at gemme modtagne fotos og videoer i dit fotobibliotek."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Die Berechtigung zum Hinzufügen von Fotos ist nötig, um empfangene Fotos und Videos in deiner Mediathek zu speichern."
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
"value" : "Η άδεια προσθήκης φωτογραφιών είναι απαραίτητη για την αποθήκευση των ληφθέντων φωτογραφιών και βίντεο στη βιβλιοθήκη φωτογραφιών σας."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "The add photos permission is required to save received photos and videos to your photo library."
}
},
"en-AU" : {
"stringUnit" : {
"state" : "translated",
"value" : "The add photos permission is required to save received photos and videos to your photo library."
}
},
"en-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "The add photos permission is required to save received photos and videos to your photo library."
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
"value" : "The add photos permission is required to save received photos and videos to your photo library."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se necesita el permiso para agregar fotos para guardar las fotos y videos recibidos en tu biblioteca."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
"value" : "Se necesita el permiso para agregar fotos para guardar las fotos y videos recibidos en tu galería."
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Valokuvien lisäämisoikeus vaaditaan, jotta vastaanotetut kuvat ja videot voidaan tallentaa kuva-albumiisi."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "L'autorisation d'ajouter des photos est requise pour enregistrer les photos et vidéos reçues dans votre bibliothèque."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "L’autorisation d’ajouter des photos est requise pour enregistrer les photos et vidéos reçues dans votre bibliothèque photo."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "נדרשת הרשאת הוספת תמונות כדי לשמור תמונות וסרטונים שהתקבלו לספריית התמונות שלך."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "प्राप्त फोटो और वीडियो को आपकी फोटो लाइब्रेरी में सहेजने के लिए फ़ोटो जोड़ने की अनुमति आवश्यक है।"
}
},
"hr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dopuštenje za dodavanje fotografija potrebno je za spremanje primljenih fotografija i videa u vašu galeriju."
}
},
"hu" : {
"stringUnit" : {
"state" : "translated",
"value" : "A fotók hozzáadása engedély szükséges ahhoz, hogy a fogadott fotókat és videókat el tudd menteni a fotótáradba."
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "Izin menambah foto diperlukan untuk menyimpan foto dan video yang diterima ke perpustakaan foto Anda."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "Il permesso di aggiungere foto è necessario per salvare le foto e i video ricevuti nella tua libreria fotografica."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "受信した写真や動画をフォトライブラリに保存するには、写真の追加許可が必要です。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "받은 사진과 동영상을 사진 보관함에 저장하려면 사진 추가 권한이 필요합니다."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kebenaran tambah foto diperlukan untuk menyimpan foto dan video yang diterima ke pustaka foto anda."
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tillatelse til å legge til bilder er nødvendig for å lagre mottatte bilder og videoer i bildebiblioteket ditt."
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "De toestemming om foto’s toe te voegen is nodig om ontvangen foto’s en video’s op te slaan in je fotobibliotheek."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "Uprawnienie do dodawania zdjęć jest wymagane, aby zapisywać otrzymane zdjęcia i filmy w Twojej bibliotece zdjęć."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "A permissão para adicionar fotos é necessária para salvar fotos e vídeos recebidos na sua biblioteca de fotos."
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "A permissão para adicionar fotos é necessária para guardar fotos e vídeos recebidos na sua biblioteca de fotos."
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
"value" : "Permisiunea de a adăuga fotografii este necesară pentru a salva fotografii și videoclipuri primite în biblioteca ta foto."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Необходимо разрешение на добавление фото для сохранения полученных фото и видео в вашу фототеку."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Behörighet att lägga till foton krävs för att spara mottagna bilder och videor i ditt fotobibliotek."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "จำเป็นต้องขออนุญาตเพิ่มรูปภาพเพื่อบันทึกรูปถ่ายและวิดีโอที่ได้รับลงในคลังรูปภาพของคุณ"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Alınan fotoğraf ve videoları fotoğraf galerine kaydetmek için fotoğraf ekleme izni gereklidir."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "Дозвіл на додавання фото потрібен для збереження отриманих фото та відео у вашу фототеку."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "Quyền thêm ảnh là cần thiết để lưu ảnh và video nhận được vào thư viện ảnh của bạn."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要添加照片权限才能将接收到的照片和视频保存到您的相册。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "需要新增照片權限才能將接收的照片和影片儲存到您的相簿。"
}
}
}
} }
}, },
"version" : "1.0" "version" : "1.0"
-2
View File
@@ -11,9 +11,7 @@ import LUI
@main @main
struct QuickDropiOSApp: App { struct QuickDropiOSApp: App {
#if !os(macOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
init() { init() {
LUIInit(configuration: configuration) LUIInit(configuration: configuration)
-212
View File
@@ -1,212 +0,0 @@
//
// ReceiveModel.swift
// QuickDrop
//
// Created by Leon Böttger on 16.08.25.
//
import SwiftUI
import StoreKit
import UserNotifications
#if os(macOS)
import BezelNotification
#else
import LUI
#endif
class ReceiveModel: ObservableObject, InboundAppDelegate {
let controlPlusScreen: (Bool) -> Void
init(controlPlusScreen: @escaping (Bool) -> Void = { _ in }) {
self.controlPlusScreen = controlPlusScreen
NearbyConnectionManager.shared.mainAppDelegate = self
NearbyConnectionManager.shared.becomeVisible()
}
func obtainUserConsent(transfer: TransferMetadata, device: RemoteDeviceInfo, acceptAutomatically: Bool) {
#if os(macOS)
NSApp.activate(ignoringOtherApps: true)
AudioManager.playIncomingFileSound()
#endif
let fileStr: String
if let textTitle = transfer.textDescription {
fileStr = textTitle
} else if transfer.files.count == 1 {
fileStr = transfer.files[0].name
} else {
fileStr = String.localizedStringWithFormat("NFiles".localized(), transfer.files.count)
}
let mainMessage: String
let name = device.name ?? "Android"
switch transfer.type {
case .file:
mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingFiles" : "DeviceSendingFiles").localized(), arguments: [name, fileStr])
case .text:
mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingText" : "DeviceSendingText").localized(), arguments: [name, fileStr])
case .url:
mainMessage = String(format: (acceptAutomatically ? "DeviceCurrentlySendingUrl" : "DeviceSendingUrl").localized(), arguments: [name, fileStr])
}
let pinCodeMessage = String(format: "PinCode".localized(), arguments: [transfer.pinCode ?? "?"])
let transferID = transfer.id
#if os(macOS)
let isMac = true
#else
let isMac = false
#endif
if acceptAutomatically && isMac {
pressAcceptButton(transferID: transfer.id, trustDevice: false)
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
if granted {
log("[ReceiveModel] User granted notification permissions")
let notificationContent = UNMutableNotificationContent()
notificationContent.title = "QuickDrop"
notificationContent.body = mainMessage
notificationContent.sound = nil
let notificationId = UUID().uuidString
let notificationReq = UNNotificationRequest(identifier: notificationId, content: notificationContent, trigger: nil)
UNUserNotificationCenter.current().add(notificationReq)
DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notificationId])
}
}
}
} else {
let title = "QuickDrop - \(pinCodeMessage)"
let primaryButtonTitle = "Accept".localized()
let primaryButtonAction = { (trustDevice: Bool) in self.pressAcceptButton(transferID: transferID, trustDevice: trustDevice) }
let secondaryButtonTitle = "Decline".localized()
let secondaryButtonAction = { NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: false, trustDevice: false, storeInTemp: false) }
#if os(macOS)
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = title
alert.informativeText = mainMessage
alert.addButton(withTitle: primaryButtonTitle)
alert.addButton(withTitle: secondaryButtonTitle)
if transfer.allowsToBeAddedAsTrustedDevice {
alert.showsSuppressionButton = true
alert.suppressionButton?.title = "AutoAcceptFromThisDevice".localized()
}
let result = alert.runModal()
if result == .alertFirstButtonReturn {
primaryButtonAction(alert.suppressionButton?.state == .on)
} else if result == .alertSecondButtonReturn {
secondaryButtonAction()
}
#else
// iOS
let alwaysAcceptLabel = transfer.allowsToBeAddedAsTrustedDevice ? "AlwaysAccept".localized() : nil
ProgressAlert.shared.askForUserPermission(title: title, message: mainMessage, acceptLabel: primaryButtonTitle, acceptAlwaysLabel: alwaysAcceptLabel, rejectLabel: secondaryButtonTitle, acceptAutomatically: acceptAutomatically) { accepted in
switch accepted {
case .Accept:
primaryButtonAction(false)
case .AcceptAlways:
primaryButtonAction(true)
case .Decline:
secondaryButtonAction()
}
}
#endif
}
}
func transferProgress(progress: Double) {
#if os(iOS)
ProgressAlert.shared.updateProgress(progress)
#endif
}
func connectionWasTerminated(from device: RemoteDeviceInfo, wasPlainTextTransfer: Bool, error: (any Error)?) {
#if os(macOS)
finish()
#else
ProgressAlert.shared.updateProgress(nil) {
finish()
}
#endif
func finish() {
if let error = error {
#if os(iOS)
errorVibration()
#endif
controlPlusScreen(false)
ErrorAlertHandler.shared.showErrorAlert(for: device.name ?? "Android", error: error)
} else {
#if os(iOS)
doubleVibration()
#endif
if wasPlainTextTransfer {
showCopiedToClipboardAlert()
}
let currentCount = Settings.shared.incomingTransmissionCount
#if os(macOS)
if currentCount == 0 {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
SKStoreReviewController.requestReview()
}
}
#endif
Settings.shared.incomingTransmissionCount = currentCount + 1
log("[ReceiveModel] Successful transmission. Current count: \(currentCount)")
}
}
}
private func showCopiedToClipboardAlert() {
#if os(macOS)
DispatchQueue.main.async {
BezelNotification.show(messageText: "InsertedIntoClipboard".localized(), icon: .clipboard)
}
#endif
}
private func pressAcceptButton(transferID: String, trustDevice: Bool) {
if isFileTransferRestricted() {
log("[ReceiveModel] Showing plus screen...")
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice, storeInTemp: true)
controlPlusScreen(true)
} else {
NearbyConnectionManager.shared.submitUserConsent(transferID: transferID, accept: true, trustDevice: trustDevice, storeInTemp: false)
}
}
}
@@ -21,7 +21,7 @@ struct ContentView: View {
}, settingsView: { }, settingsView: {
CustomSection(header: settings.gotPlus ? "" : "General", footer: "TrustedDevicesFooterShort") { CustomSection(header: settings.gotPlus ? "" : "General", footer: "TrustedDevicesFooterShort") {
LUIButton { LUIButton {
SaveFilesManager.shared.openDownloadedFilesFolder() FileManager.default.openDocumentFolder()
} label: { } label: {
NavigationLinkLabel(imageName: "folder.fill", text: "BrowseDownloadedFiles") NavigationLinkLabel(imageName: "folder.fill", text: "BrowseDownloadedFiles")
} }
@@ -32,6 +32,8 @@ struct ContentView: View {
NavigationLinkLabel(imageName: "checkmark.shield.fill", text: "ManageTrustedDevices", backgroundColor: .green) NavigationLinkLabel(imageName: "checkmark.shield.fill", text: "ManageTrustedDevices", backgroundColor: .green)
} }
} }
}, firstIntroductionView: {
LocalNetworkPermissionView()
}) })
} }
} }
@@ -41,9 +43,15 @@ struct DeviceListView: View {
@State private var qrCode: Image? = nil @State private var qrCode: Image? = nil
@StateObject var sendModel = SendModel() @StateObject var sendModel = SendModel()
#if !EXTENSION #if !EXTENSION
@StateObject var receiveModel = ReceiveModel() @StateObject var receiveModel = ReceiveModel(controlPlusScreen: { show in
#endif log("[DeviceListView] Setting showPlusScreen to \(show)")
errorVibration()
DeviceListViewModel.sharedInstance.showPlusScreen = show
})
#endif
@ObservedObject var model = DeviceListViewModel.sharedInstance
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@Environment(\.sheetActive) var sheetActive @Environment(\.sheetActive) var sheetActive
@@ -54,47 +62,54 @@ struct DeviceListView: View {
let showsLoadingIndicator = nearbyConnectionManager.hasLocalNetworkPermission && nearbyConnectionManager.isConnectedToLocalNetwork let showsLoadingIndicator = nearbyConnectionManager.hasLocalNetworkPermission && nearbyConnectionManager.isConnectedToLocalNetwork
BottomBarView(header: "QuickDrop ", navigationBarLayout: isShareExtension() ? .SmallOnlyAlways : .Default, bottomViewHeight: 30) { BottomBarView(header: "QuickDrop", navigationBarLayout: isShareExtension() ? .SmallOnlyAlways : .Default, bottomViewHeight: 30) {
VStack { VStack {
VStack(alignment: .leading, spacing: 8) { let attachments = nearbyConnectionManager.attachments
let attachments = nearbyConnectionManager.attachments if nearbyConnectionManager.hasLocalNetworkPermission && attachments == nil {
VStack(alignment: .leading, spacing: 8) {
FormHeader(name: attachments == nil ? "YouAreVisibleAs".localized() : "YouAreSending".localized()) FormHeader(name: attachments == nil ? "YouAreVisibleAs".localized() : "YouAreSending".localized())
HStack { HStack {
if let image = attachments?.previewImage { LUIText(attachments?.shortDescription ?? NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized(), isBold: true)
image .lineLimit(1)
.resizable()
.scaledToFill() if attachments == nil {
.frame(width: 10, height: 10) Image(systemName: "pencil")
.foregroundColor(.mainColor.opacity(0.5))
.padding(.horizontal, -3)
}
} }
.padding(.horizontal, 16)
LUIText(attachments?.shortDescription ?? NearbyConnectionManager.shared.deviceInfo.name ?? "Unknown".localized(), isBold: true) .padding(.vertical, 6)
.lineLimit(1) .background(sheetActive ? Color.sheetForegroundColor : Color.defaultForegroundColor)
.cornerRadius(24)
.changeDeviceNameAlert(isEnabled: attachments == nil)
} }
.padding(.horizontal, 16) .padding(.horizontal)
.padding(.vertical, 6) .padding(.top, 8)
.background(sheetActive ? Color.sheetForegroundColor : Color.defaultForegroundColor) .frame(maxWidth: .infinity, alignment: .leading)
.cornerRadius(24)
} }
.padding(.horizontal)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
if !nearbyConnectionManager.hasLocalNetworkPermission { if !nearbyConnectionManager.hasLocalNetworkPermission {
CardView(backgroundColor: .red, title: "NoNetworkAccess", titleSymbol: "network.slash") { NoLocalNetworkAccessAlertView()
CardSubView(symbol: "exclamationmark.triangle.fill", text: "NoLocalNetworkAccessDescription")
}
.padding(.bottom, 10)
.onTapGesture {
openAppSettings()
}
} }
if sendModel.foundDevices.isEmpty { var availableDevices: [RemoteDeviceInfo] {
var devices = sendModel.foundDevices
if let selected = sendModel.selectedDevice,
!devices.contains(where: { $0.id == selected.id }) {
devices.append(selected)
}
return devices
}
if availableDevices.isEmpty {
CustomSection(header: "NoDevicesFound") { CustomSection(header: "NoDevicesFound") {
@@ -122,7 +137,7 @@ struct DeviceListView: View {
} else { } else {
CustomSection(header: "AvailableDevices") { CustomSection(header: "AvailableDevices") {
ForEach(sendModel.foundDevices) { device in ForEach(availableDevices) { device in
let isSelected = sendModel.selectedDevice == device let isSelected = sendModel.selectedDevice == device
@@ -185,7 +200,7 @@ struct DeviceListView: View {
log("[ScenePhase] App became active") log("[ScenePhase] App became active")
NearbyConnectionManager.shared.startDeviceDiscovery() NearbyConnectionManager.shared.startDeviceDiscovery()
#if !EXTENSION #if !EXTENSION
NearbyConnectionManager.shared.becomeVisible() NearbyConnectionManager.shared.becomeVisible()
if Settings.shared.incomingTransmissionCount > 0 { if Settings.shared.incomingTransmissionCount > 0 {
@@ -193,18 +208,21 @@ struct DeviceListView: View {
requestReviewOnce() requestReviewOnce()
} }
} }
#endif #endif
} }
if newValue == .background { if newValue == .background {
log("[ScenePhase] App went to background") log("[ScenePhase] App went to background")
#if !EXTENSION #if !EXTENSION
NearbyConnectionManager.shared.becomeInvisible() NearbyConnectionManager.shared.becomeInvisible()
#endif #endif
NearbyConnectionManager.shared.stopDeviceDiscovery() NearbyConnectionManager.shared.stopDeviceDiscovery()
} }
} }
#if !EXTENSION
.modifier(PlusVersionSheetModifier(sheetActive: $model.showPlusScreen, isPlusVersion: $settings.gotPlus))
#endif
.animation(.smooth, value: nearbyConnectionManager.hasLocalNetworkPermission) .animation(.smooth, value: nearbyConnectionManager.hasLocalNetworkPermission)
.animation(.smooth, value: nearbyConnectionManager.isConnectedToLocalNetwork) .animation(.smooth, value: nearbyConnectionManager.isConnectedToLocalNetwork)
.navigationBarItems(trailing: ZStack { .navigationBarItems(trailing: ZStack {
@@ -219,6 +237,13 @@ struct DeviceListView: View {
} }
final class DeviceListViewModel: SharedInstance {
static var sharedInstance = DeviceListViewModel()
@Published var showPlusScreen = false
}
struct DeviceFilePickerButton: View { struct DeviceFilePickerButton: View {
let device: RemoteDeviceInfo let device: RemoteDeviceInfo
@@ -252,7 +277,7 @@ struct DeviceButtonLabel: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
let name = device.name ?? "Android" let name = device.name ?? "UnknownDevice".localized()
ZStack { ZStack {
Circle() Circle()
@@ -297,11 +322,11 @@ struct DeviceButtonLabel: View {
func isShareExtension() -> Bool { func isShareExtension() -> Bool {
#if EXTENSION #if EXTENSION
return true return true
#else #else
return false return false
#endif #endif
} }
@@ -49,7 +49,6 @@ struct SendPickerButton<Label: View>: View {
onPrepare?() onPrepare?()
updatedPhotoSelection(content: newItems) updatedPhotoSelection(content: newItems)
} }
// File picker
.fileImporter(isPresented: $showFilePicker, .fileImporter(isPresented: $showFilePicker,
allowedContentTypes: [.item, .folder], // any file allowedContentTypes: [.item, .folder], // any file
allowsMultipleSelection: true) { result in allowsMultipleSelection: true) { result in
@@ -162,10 +161,10 @@ fileprivate struct DataUrl: Transferable {
.cornerRadius(8) .cornerRadius(8)
} onResult: { urls, text in } onResult: { urls, text in
if let urls = urls { if let urls = urls {
print("Selected URLs: \(urls)") log("Selected URLs: \(urls)")
} }
if let text = text { if let text = text {
print("Clipboard text: \(text)") log("Clipboard text: \(text)")
} }
} }
} }
@@ -0,0 +1,52 @@
//
// LocalNetworkPermissionView.swift
// QuickDrop
//
// Created by Leon Böttger on 15.11.25.
//
import SwiftUI
import LUI
public struct LocalNetworkPermissionView: View {
@State var requestedLocalNetworkAccess = false
@Environment(\.scenePhase) var scenePhase
public var body: some View {
PermissionView(title: "introduction_local_network_access", symbol: "", subtitle: "introduction_local_network_access_description", permissionAction: {
if requestedLocalNetworkAccess {
// Something went wrong, user pressed again on continue. In this case, just continue with setup
IntroductionViewController.sharedInstance.canProceed = true
}
else {
// init manager to force local network access prompt
requestedLocalNetworkAccess = true
let manager = NearbyConnectionManager.shared
manager.startDeviceDiscovery()
}
}, canSkip: false, nextView: {
IntroductionDoneView()
}, nextViewSkip: {}, frontMockup: {
AppMockupFrontView()
}, backMockup: {
SymbolMockupView(symbol: "globe")
})
.onChange(of: scenePhase) { newValue in
// alert was removed from screen, app is foreground again -> continue with intro
if newValue == .active && requestedLocalNetworkAccess {
IntroductionViewController.sharedInstance.canProceed = true
}
}
}
}
#Preview {
NavigationView {
LocalNetworkPermissionView()
}
}
@@ -0,0 +1,24 @@
//
// NoLocalNetworkAccessAlertView.swift
// QuickDrop
//
// Created by Leon Böttger on 26.12.25.
//
import LUI
import SwiftUI
struct NoLocalNetworkAccessAlertView: View {
var body: some View {
CardView(backgroundColor: .red, title: "NoNetworkAccess", titleSymbol: "network.slash") {
LUIText("NoLocalNetworkAccessDescription", color: .white)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.bottom, 10)
.onTapGesture {
#if os(iOS)
openAppSettings()
#endif
}
}
}
+191
View File
@@ -0,0 +1,191 @@
//
// PhotoManager.swift
// QuickDrop
//
// Created by Leon Böttger on 16.11.25.
//
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import LUI
import Photos
import UniformTypeIdentifiers
struct PhotoManager {
/// Returns if there are photos or videos among the given URLs.
static func hasPhotosOrVideos(at urls: [URL]) -> Bool {
urls.contains(where: { isImageFile(at: $0) || isVideoFile(at: $0) })
}
/// Saves any image/video URLs to the user's Photos library.
/// - Parameters:
/// - urls: Remote or local URLs to inspect.
/// - openPhotosOnSuccess: If true, will try to open the Photos app after saving.
/// - Throws: `SaveToPhotosError` or underlying URLSession/Photos errors.
static func saveMediaToPhotoLibrary(from urls: [URL], openPhotosOnSuccess: Bool = true) async throws {
let candidates = urls.filter { isImageFile(at: $0) || isVideoFile(at: $0) }
guard !candidates.isEmpty else { throw SaveToPhotosError.noSavableContent }
let authStatus = await requestAddOnlyPhotoAuthorization()
guard authStatus == .authorized || authStatus == .limited else {
throw SaveToPhotosError.permissionDenied
}
var imageItems: [ImageItem] = []
var videoItems: [VideoItem] = []
// Extract creation date for image and video
for sourceURL in candidates {
if isImageFile(at: sourceURL) {
let exifDate = EXIFUtils.exifOriginalDate(from: sourceURL)
imageItems.append(ImageItem(fileURL: sourceURL, creationDate: exifDate))
} else {
let videoDate = getVideoCreationDate(from: sourceURL)
videoItems.append(VideoItem(fileURL: sourceURL, creationDate: videoDate))
}
}
guard !imageItems.isEmpty || !videoItems.isEmpty else {
throw SaveToPhotosError.noSavableContent
}
// Import into Photos; explicitly set creationDate so it doesn't default to "now".
try await withCheckedThrowingContinuation { continuation in
PHPhotoLibrary.shared().performChanges({
for item in imageItems {
if let request = PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: item.fileURL) {
if let date = item.creationDate {
request.creationDate = date
}
}
}
for item in videoItems {
if let request = PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: item.fileURL) {
if let date = item.creationDate {
request.creationDate = date
}
}
}
}, completionHandler: { success, error in
if success {
continuation.resume()
} else {
continuation.resume(throwing: error ?? SaveToPhotosError.saveFailed)
}
})
}
// Delete files in documents directory after successful import
imageItems.forEach({ item in
do { try FileManager.default.removeItem(at: item.fileURL) }
catch { log("Failed to delete image at \(item.fileURL): \(error)") }
})
videoItems.forEach({ item in
do { try FileManager.default.removeItem(at: item.fileURL) }
catch { log("Failed to delete video at \(item.fileURL): \(error)") }
})
// Open the Photos app
if openPhotosOnSuccess {
DispatchQueue.main.async {
#if os(iOS)
if let url = URL(string: "photos-redirect://") {
UIApplication.shared.open(url)
}
#else
if let url = URL(string: "photos-navigation://album?name=recently-saved") {
NSWorkspace.shared.open(url)
}
#endif
}
}
}
private static func isImageFile(at url: URL) -> Bool {
let ext = url.pathExtension.lowercased()
guard let type = UTType(filenameExtension: ext) else {
return false
}
return type.conforms(to: .image)
}
private static func isVideoFile(at url: URL) -> Bool {
let ext = url.pathExtension.lowercased()
guard let type = UTType(filenameExtension: ext) else {
return false
}
// `.movie` is the canonical file-based video type; `.audiovisualContent`
// catches some broader cases if needed.
return type.conforms(to: .movie) || type.conforms(to: .audiovisualContent)
}
private static func getVideoCreationDate(from url: URL) -> Date? {
let asset = AVURLAsset(url: url)
// AVMetadataItem that typically contains an ISO‑8601 string
guard let item = asset.creationDate,
let dateString = item.stringValue else {
return nil
}
let formatter = ISO8601DateFormatter()
return formatter.date(from: dateString)
}
/// Requests add-only Photos permission
private static func requestAddOnlyPhotoAuthorization() async -> PHAuthorizationStatus {
await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
continuation.resume(returning: status)
}
}
}
private enum SaveToPhotosError: Error, LocalizedError {
case noSavableContent
case permissionDenied
case saveFailed
var errorDescription: String? {
switch self {
case .noSavableContent:
return "SaveToPhotosErrorNoSavableContent".localized()
case .permissionDenied:
return "SaveToPhotosErrorPermissionDenied".localized()
case .saveFailed:
return "SaveToPhotosErrorSaveFailed".localized()
}
}
}
private struct ImageItem {
let fileURL: URL
let creationDate: Date?
}
private struct VideoItem {
let fileURL: URL
let creationDate: Date?
}
}
File renamed without changes.
@@ -7,6 +7,7 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
import LUI
// MARK: - UIKit Alert Presenter // MARK: - UIKit Alert Presenter
@@ -18,41 +19,72 @@ final class ProgressAlert {
private weak var progressView: UIProgressView? private weak var progressView: UIProgressView?
// MARK: - Initial Accept / Decline // MARK: - Initial Accept / Decline
func askForUserPermission(title: String, message: String, acceptLabel: String, acceptAlwaysLabel: String?, rejectLabel: String, acceptAutomatically: Bool, onAccept: @escaping (AcceptAction) -> Void) { func askForUserPermission(title: String, message: String, acceptLabel: String, acceptAlwaysLabel: String?, rejectLabel: String, onAccept: @escaping (AcceptAction) -> Void) {
guard let vc = topMostViewController() else { return } guard let vc = topMostViewController() else { return }
if acceptAutomatically { let alert = UIAlertController(title: title,
message: message,
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: rejectLabel, style: .cancel, handler: { _ in
onAccept(.Decline)
}))
alert.addAction(UIAlertAction(title: acceptLabel, style: .default, handler: { _ in
onAccept(.Accept) onAccept(.Accept)
self.showProgressAlert(on: vc) }))
}
else {
let alert = UIAlertController(title: title, if let acceptAlways = acceptAlwaysLabel {
message: message, alert.addAction(UIAlertAction(title: acceptAlways, style: .default, handler: { _ in
preferredStyle: .alert) onAccept(.AcceptAlways)
alert.addAction(UIAlertAction(title: rejectLabel, style: .cancel, handler: { _ in
onAccept(.Decline)
})) }))
alert.addAction(UIAlertAction(title: acceptLabel, style: .default, handler: { _ in
onAccept(.Accept)
self.showProgressAlert(on: vc)
}))
if let acceptAlways = acceptAlwaysLabel {
alert.addAction(UIAlertAction(title: acceptAlways, style: .default, handler: { _ in
onAccept(.AcceptAlways)
self.showProgressAlert(on: vc)
}))
}
vc.present(alert, animated: true)
} }
vc.present(alert, animated: true)
} }
func presentIncomingTransferDoneAlert(
title: String,
message: String,
onImportToPhotos: (() -> Void)? = nil,
onDone: (() -> Void)? = nil
) {
guard let vc = topMostViewController() else { return }
let alert = UIAlertController(
title: title,
message: message,
preferredStyle: .alert
)
// "Show in Files"
alert.addAction(UIAlertAction(title: "OpenInFiles".localized(), style: .default) { _ in
FileManager.default.openDocumentFolder()
})
// Optional "Import to Photos"
if let importHandler = onImportToPhotos {
alert.addAction(UIAlertAction(title: "ImportToPhotos".localized(), style: .default) { _ in
importHandler()
})
}
// "Done"
alert.addAction(UIAlertAction(title: "Done".localized(), style: .cancel) { _ in
onDone?()
})
vc.present(alert, animated: true)
}
// MARK: - Progress Alert // MARK: - Progress Alert
private func showProgressAlert(on vc: UIViewController) { private func showProgressAlert(onCancel: @escaping () -> Void) {
guard let vc = topMostViewController() else { return }
let alert = UIAlertController(title: "Receiving".localized(), let alert = UIAlertController(title: "Receiving".localized(),
message: "\n\n", message: "\n\n",
preferredStyle: .alert) preferredStyle: .alert)
@@ -67,7 +99,8 @@ final class ProgressAlert {
]) ])
let cancel = UIAlertAction(title: "Cancel".localized(), style: .cancel, handler: { _ in let cancel = UIAlertAction(title: "Cancel".localized(), style: .cancel, handler: { _ in
print("Cancel during progress") log("Cancel during progress")
onCancel()
}) })
alert.addAction(cancel) alert.addAction(cancel)
@@ -77,20 +110,35 @@ final class ProgressAlert {
vc.present(alert, animated: true) vc.present(alert, animated: true)
} }
// MARK: - Update Progress // MARK: - Update Progress
// progress: 0…1 → update progress, nil → finished // progress: 0…1 → update progress, nil → finished
func updateProgress(_ progress: Double?, completion: @escaping () -> Void = {}) { func updateProgress(_ progress: Double?, onCancel: @escaping () -> Void, completion: @escaping () -> Void) {
// No progress to show, nothing shown currently → just complete
if progressAlert == nil && progress == nil {
completion()
return
}
DispatchQueue.main.async { DispatchQueue.main.async {
if let p = progress { if let p = progress {
// Show progress if not shown yet
if self.progressAlert == nil {
self.showProgressAlert(onCancel: onCancel)
}
self.progressView?.setProgress(Float(p), animated: true) self.progressView?.setProgress(Float(p), animated: true)
completion() completion()
} else { }
else {
// Completed: dismiss progress and show final alert // Completed: dismiss progress and show final alert
self.progressAlert?.dismiss(animated: true) { self.progressAlert?.dismiss(animated: true) {
completion() completion()
self.progressAlert = nil
self.progressView = nil
} }
self.progressAlert = nil
self.progressView = nil
} }
} }
} }
-10
View File
@@ -8,15 +8,5 @@ QuickDrop is an improved version of NearDrop, a QuickShare client for macOS. Qui
- QuickDrop iOS and iPadOS: [Download on the App Store](https://apps.apple.com/de/app/quickdrop-android-transfer/id6740147178) - QuickDrop iOS and iPadOS: [Download on the App Store](https://apps.apple.com/de/app/quickdrop-android-transfer/id6740147178)
- QuickDrop for Android: [Download on Google Play](https://play.google.com/store/apps/details?id=com.leonboettger.quickdrop) - QuickDrop for Android: [Download on Google Play](https://play.google.com/store/apps/details?id=com.leonboettger.quickdrop)
## Compile from source
Download and open this repository in Xcode.
You have to re-sign the project with your own Apple ID.
Also, keep in mind that you may need to replace the prefix of the Bundle Identifiers with your identifier.
Those can be found in Project -> Targets -> Signing -> Bundle Identifier
Please also change the App Group names accordingly. This can be done by editing the .entitlements files in the root folder of this repository.
## Disclaimer ## Disclaimer
Nearby Share and Quick Share are trademarks of Google, LLC and Samsung Electronics. Android is a trademark of Google, LLC. This application is not affiliated with Google, LLC or Samsung Electronics. Nearby Share and Quick Share are trademarks of Google, LLC and Samsung Electronics. Android is a trademark of Google, LLC. This application is not affiliated with Google, LLC or Samsung Electronics.
+131
View File
@@ -601,6 +601,31 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
ZIPFoundation
https://github.com/weichsel/ZIPFoundation
MIT License
Copyright (c) 2017-2025 Thomas Zoechling (https://www.peakstep.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SwiftECC SwiftECC
https://github.com/leif-ibsen/SwiftECC https://github.com/leif-ibsen/SwiftECC
Copyright (c) 2021 Leif Ibsen Copyright (c) 2021 Leif Ibsen
@@ -919,6 +944,112 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.
Introspect for SwiftUI
https://github.com/siteline/SwiftUI-Introspect
Copyright 2019 Timber Software
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
MWKNumberRowInputAccessory
https://github.com/mwkirk/MWKNumberRowInputAccessory
The MIT License (MIT)
Copyright (c) 2016 Mark Kirk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the \"Software\"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Shimmer
Used for the unlock animation when starting the app
https://github.com/markiv/SwiftUI-Shimmer
MIT License
Copyright (c) 2021 Vikram Kriplaney
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
MTSlideToOpen-SwiftUI
Used for the unlock animation when starting the app
https://github.com/lemanhtien/MTSlideToOpen-SwiftUI
The MIT License (MIT)
Copyright (c) 2017 Tien Le
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
LaunchAtLogin-Modern
https://github.com/sindresorhus/LaunchAtLogin-Modern
MIT License
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
All of the sounds listed below are provided under the Creative Commons Attribution 4.0 International License. All of the sounds listed below are provided under the Creative Commons Attribution 4.0 International License.
https://creativecommons.org/licenses/by/4.0/legalcode https://creativecommons.org/licenses/by/4.0/legalcode
@@ -8,6 +8,7 @@
import Foundation import Foundation
import UniformTypeIdentifiers import UniformTypeIdentifiers
import SwiftUI import SwiftUI
import LUI
#if os(macOS) #if os(macOS)
import Cocoa import Cocoa
#endif #endif
@@ -24,31 +25,8 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
let item = extensionContext.inputItems[0] as! NSExtensionItem let item = extensionContext.inputItems[0] as! NSExtensionItem
if let attachments = item.attachments { if let attachments = item.attachments {
if let text = item.attributedContentText?.string {
log("Found text content: \(text)") if !attachments.isEmpty {
result.textToSend = text
#if os(macOS)
result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
#else
result.previewImage = Image(systemName: "text.document")
#endif
let maxLength = 50
let cleanedText = text.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: "")
if cleanedText.count > maxLength {
let index = cleanedText.index(cleanedText.startIndex, offsetBy: maxLength)
result.shortDescription = String(cleanedText[..<index]) + "..."
} else {
result.shortDescription = cleanedText
}
loadedItems(result)
} else {
for attachment in attachments { for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) { if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
@@ -59,6 +37,10 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
} else if let url = data as? NSURL { } else if let url = data as? NSURL {
result.urls += [(url as URL)] result.urls += [(url as URL)]
} }
else {
log("Attachment is not a URL, ignoring. All attachments: \(attachments.description)")
ignoredAttachments += 1
}
checkIfAttachmentsLoaded(result: result, attachmentCount: attachments.count, ignoredAttachments: ignoredAttachments) checkIfAttachmentsLoaded(result: result, attachmentCount: attachments.count, ignoredAttachments: ignoredAttachments)
} }
@@ -82,6 +64,9 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
} }
} }
} }
else {
loadTextIfPossible()
}
} }
else { else {
log("No attachments found in extension context. Exiting.") log("No attachments found in extension context. Exiting.")
@@ -91,12 +76,48 @@ func loadAttachments(with extensionContext: NSExtensionContext?, loadedItems: @e
} }
func loadTextIfPossible() {
if let text = item.attributedContentText?.string {
log("Found text content: \(text)")
result.textToSend = text
#if os(macOS)
result.previewImage = NSImage(named: NSImage.multipleDocumentsName)
#else
result.previewImage = Image(systemName: "text.document")
#endif
let maxLength = 50
let cleanedText = text.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: "")
if cleanedText.count > maxLength {
let index = cleanedText.index(cleanedText.startIndex, offsetBy: maxLength)
result.shortDescription = String(cleanedText[..<index]) + "..."
} else {
result.shortDescription = cleanedText
}
DispatchQueue.main.async {
loadedItems(result)
}
}
}
func checkIfAttachmentsLoaded(result: AttachmentDetails, attachmentCount: Int, ignoredAttachments: Int = 0) { func checkIfAttachmentsLoaded(result: AttachmentDetails, attachmentCount: Int, ignoredAttachments: Int = 0) {
var result = result var result = result
if result.urls.count == attachmentCount-ignoredAttachments { if result.urls.count == attachmentCount-ignoredAttachments {
if result.urls.count == 1 {
// Unable to load anything useful, check if there is text as last resort
if result.urls.count == 0 {
loadTextIfPossible()
return
}
else if result.urls.count == 1 {
if result.urls[0].isFileURL { if result.urls[0].isFileURL {
+26 -18
View File
@@ -8,6 +8,7 @@
import Cocoa import Cocoa
import Foundation import Foundation
import SwiftUI import SwiftUI
import LUI
class ShareViewController: NSViewController, OutboundAppDelegate { class ShareViewController: NSViewController, OutboundAppDelegate {
@@ -49,6 +50,8 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
override func loadView() { override func loadView() {
super.loadView() super.loadView()
LUIInit(configuration: configuration)
loadAttachments(with: extensionContext, loadedItems: { result in loadAttachments(with: extensionContext, loadedItems: { result in
log("Loaded attachments: \(result)") log("Loaded attachments: \(result)")
@@ -99,7 +102,7 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
super.viewDidLoad() super.viewDidLoad()
dicoverDevices() dicoverDevices()
NearbyConnectionManager.shared.addShareExtensionDelegate(self) NearbyConnectionManager.shared.addOutboundAppDelegate(self)
} }
@@ -111,13 +114,13 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
if chosenDevice == nil { if chosenDevice == nil {
NearbyConnectionManager.shared.stopDeviceDiscovery() NearbyConnectionManager.shared.stopDeviceDiscovery()
} }
NearbyConnectionManager.shared.removeShareExtensionDelegate(self) NearbyConnectionManager.shared.removeOutboundAppDelegate(self)
} }
@IBAction func cancel(_: AnyObject?) { @IBAction func cancel(_: AnyObject?) {
if let device = chosenDevice { if let device = chosenDevice {
NearbyConnectionManager.shared.cancelOutgoingTransfer(id: device.id!) NearbyConnectionManager.shared.cancelTransfer(id: device.id!)
} }
let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil) let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
extensionContext!.cancelRequest(withError: cancelError) extensionContext!.cancelRequest(withError: cancelError)
@@ -249,27 +252,32 @@ class ShareViewController: NSViewController, OutboundAppDelegate {
progressDeviceName?.stringValue = getDeviceName(device: device) progressDeviceName?.stringValue = getDeviceName(device: device)
progressDeviceIcon?.image = imageForDeviceType(type: device.type) progressDeviceIcon?.image = imageForDeviceType(type: device.type)
progressProgressBar?.startAnimation(nil) progressProgressBar?.startAnimation(nil)
progressState?.stringValue = "Connecting".localized() progressState?.stringValue = "Preparing".localized()
chosenDevice = device chosenDevice = device
NearbyConnectionManager.shared.stopDeviceDiscovery() NearbyConnectionManager.shared.stopDeviceDiscovery()
NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: urls, textToSend: textToSend)
let timeoutAlert = DispatchWorkItem { runAfter(seconds: 0.3) {
if !self.connectionEstablished { NearbyConnectionManager.shared.startOutgoingTransfer(deviceID: device.id!, delegate: self, urls: self.urls, textToSend: self.textToSend)
AudioManager.playErrorSound()
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "TimeoutTitle".localized() self.progressState?.stringValue = "Connecting".localized()
alert.informativeText = "TimeoutDescription".localized()
alert.addButton(withTitle: "TimeoutButton".localized())
alert.beginSheetModal(for: self.view.window!) { _ in } let timeoutAlert = DispatchWorkItem {
if !self.connectionEstablished {
AudioManager.playErrorSound()
let alert = NSAlert()
alert.alertStyle = .critical
alert.messageText = "TimeoutTitle".localized()
alert.informativeText = "TimeoutDescription".localized()
alert.addButton(withTitle: "TimeoutButton".localized())
alert.beginSheetModal(for: self.view.window!) { _ in }
}
} }
}
timeoutDispatchWorkItem = timeoutAlert self.timeoutDispatchWorkItem = timeoutAlert
DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert) DispatchQueue.main.asyncAfter(deadline: .now() + 10.0, execute: timeoutAlert)
}
} }
@@ -378,6 +386,6 @@ extension ShareViewController: NSCollectionViewDataSource {
return name return name
} }
return "Android" return "UnknownDevice".localized()
} }
} }
+22 -11
View File
@@ -10,12 +10,24 @@ import SwiftUI
import LUI import LUI
class ShareViewController: UIViewController { class ShareViewController: UIViewController {
override func loadView() {
super.loadView()
// skip introduction for share extension
LUIInit(configuration: configuration)
LUISettings.sharedInstance.appLaunchedBefore = true
}
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
NearbyConnectionManager.shared.stopDeviceDiscovery()
loadAttachments(with: extensionContext, loadedItems: { [self] result in loadAttachments(with: extensionContext, loadedItems: { [self] result in
log("ShareViewController: Loaded attachments: \(result)") log("[ShareViewController] Loaded attachments: \(result)")
var attachmentDetails = result var attachmentDetails = result
attachmentDetails.closeView = { [weak self] in attachmentDetails.closeView = { [weak self] in
@@ -28,15 +40,7 @@ class ShareViewController: UIViewController {
} }
}) })
// skip introduction for share extension let hostingController = UIHostingController(rootView: ContentView())
LUIInit(configuration: configuration)
LUISettings.sharedInstance.appLaunchedBefore = true
// Replace with your app’s SwiftUI entry view
let contentView = ContentView()
// Embed SwiftUI inside the extension’s controller
let hostingController = UIHostingController(rootView: contentView)
addChild(hostingController) addChild(hostingController)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false hostingController.view.translatesAutoresizingMaskIntoConstraints = false
@@ -52,5 +56,12 @@ class ShareViewController: UIViewController {
hostingController.didMove(toParent: self) hostingController.didMove(toParent: self)
} }
}
override func viewDidDisappear(_ animated: Bool) {
NearbyConnectionManager.shared.stopDeviceDiscovery()
super.viewDidDisappear(animated)
log("[ShareViewController] Disappeared view.")
}
}