diff --git a/NearbyShare/Data+Extensions.swift b/NearbyShare/Data+Extensions.swift deleted file mode 100644 index 7098bc4..0000000 --- a/NearbyShare/Data+Extensions.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// Data+Extensions.swift -// QuickDrop -// -// Created by Grishka on 08.04.2023. -// - -import CoreFoundation -import Foundation - -extension Data { - func urlSafeBase64EncodedString() -> String { - return String(base64EncodedString().replacingOccurrences(of: "=", with: "").map { - if $0 == "/" { - return "_" - } else if $0 == "+" { - return "-" - } else { - return $0 - } - }) - } - - static func randomData(length: Int) -> Data { - var data = Data(count: length) - data.withUnsafeMutableBytes { - guard SecRandomCopyBytes(kSecRandomDefault, length, $0.baseAddress!) == 0 else { fatalError() } - } - return data - } - - static func dataFromUrlSafeBase64(_ str: String) -> Data? { - var regularB64 = String(str.map { - if $0 == "_" { - return "/" - } else if $0 == "-" { - return "+" - } else { - return $0 - } - }) - while (regularB64.count % 4) != 0 { - regularB64 = regularB64 + "=" - } - return Data(base64Encoded: regularB64, options: .ignoreUnknownCharacters) - } -} diff --git a/NearbyShare/Helpers.swift b/NearbyShare/Helpers.swift index 559fe92..6fbc468 100644 --- a/NearbyShare/Helpers.swift +++ b/NearbyShare/Helpers.swift @@ -7,18 +7,87 @@ import Foundation +extension Data { + func urlSafeBase64EncodedString() -> String { + return String(base64EncodedString().replacingOccurrences(of: "=", with: "").map { + if $0 == "/" { + return "_" + } else if $0 == "+" { + return "-" + } else { + return $0 + } + }) + } + + + static func randomData(length: Int) -> Data { + var data = Data(count: length) + data.withUnsafeMutableBytes { + guard SecRandomCopyBytes(kSecRandomDefault, length, $0.baseAddress!) == 0 else { fatalError() } + } + return data + } + + + static func dataFromUrlSafeBase64(_ str: String) -> Data? { + var regularB64 = String(str.map { + if $0 == "_" { + return "/" + } else if $0 == "-" { + return "+" + } else { + return $0 + } + }) + while (regularB64.count % 4) != 0 { + regularB64 = regularB64 + "=" + } + return Data(base64Encoded: regularB64, options: .ignoreUnknownCharacters) + } + + + var hex: String { + return map { String(format: "%02hhx", $0) }.joined() + } +} + + +extension String { + var dataFromHex: Data { + var data = Data(capacity: count / 2) + var index = startIndex + while index < endIndex { + let nextIndex = self.index(index, offsetBy: 2) + if nextIndex <= endIndex, + let byte = UInt8(self[index ..< nextIndex], radix: 16) + { + data.append(byte) + } else { + break // or handle invalid hex gracefully + } + index = nextIndex + } + return data + } +} + + public func isPlusVersion() -> Bool { return UserDefaults.standard.bool(forKey: UserDefaultsKeys.plusVersion.rawValue) } + public func isFileTransferRestricted() -> Bool { (!isPlusVersion() && transmissionCount() > 1) } + public func transmissionCount() -> Int { return UserDefaults.standard.integer(forKey: UserDefaultsKeys.transmissionCount.rawValue) } + public enum UserDefaultsKeys: String, CaseIterable { case isEligibleForIap = "isEligibleForIap" case appLaunchedBefore = "ShowedWelcomeScreen" diff --git a/NearbyShare/InboundNearbyConnection.swift b/NearbyShare/InboundNearbyConnection.swift index 8ed02fb..e07198e 100644 --- a/NearbyShare/InboundNearbyConnection.swift +++ b/NearbyShare/InboundNearbyConnection.swift @@ -11,11 +11,11 @@ import CryptoKit import Foundation import Network import System -import ImageIO import BigInt import SwiftECC class InboundNearbyConnection: NearbyConnection { + private var currentState: State = .initial private var wasRejected = false public var delegate: InboundNearbyConnectionDelegate? @@ -27,13 +27,15 @@ class InboundNearbyConnection: NearbyConnection { enum State { case initial, receivedConnectionRequest, sentUkeyServerInit, receivedUkeyClientFinish, sentConnectionResponse, sentPairedKeyResult, receivedPairedKeyResult, waitingForUserConsent, receivingFiles, disconnected } + override init(connection: NWConnection, id: String) { super.init(connection: connection, id: id) } + - override func handleConnectionClosure() { - super.handleConnectionClosure() + override func disconnect() { + super.disconnect() currentState = .disconnected if !self.wasRejected { @@ -45,6 +47,7 @@ class InboundNearbyConnection: NearbyConnection { } } } + override func processReceivedFrame(frameData: Data) { @@ -89,6 +92,7 @@ class InboundNearbyConnection: NearbyConnection { protocolError() } } + override func processTransferSetupFrame(_ frame: Sharing_Nearby_Frame) throws { if frame.hasV1 && frame.v1.hasType, case .cancel = frame.v1.type { @@ -109,9 +113,11 @@ class InboundNearbyConnection: NearbyConnection { } } + override func isServer() -> Bool { return true } + override func processFileChunk(frame: Location_Nearby_Connections_PayloadTransferFrame) throws { @@ -151,6 +157,7 @@ class InboundNearbyConnection: NearbyConnection { } } + override func processBytesPayload(payload: Data, id: Int64) throws -> Bool { if id == textPayloadID { if let urlStr = String(data: payload, encoding: .utf8) { @@ -188,6 +195,7 @@ class InboundNearbyConnection: NearbyConnection { } return false } + private func processConnectionRequestFrame(_ frame: Location_Nearby_Connections_OfflineFrame) throws { @@ -210,6 +218,7 @@ class InboundNearbyConnection: NearbyConnection { remoteDeviceInfo = RemoteDeviceInfo(name: deviceName, type: RemoteDeviceInfo.DeviceType.fromRawValue(value: rawDeviceType)) currentState = .receivedConnectionRequest } + private func processUkey2ClientInit(_ msg: Securegcm_Ukey2Message) throws { guard msg.hasMessageType, msg.hasMessageData else { throw NearbyError.requiredFieldMissing("clientInit ukey2message.type|data") } @@ -275,6 +284,7 @@ class InboundNearbyConnection: NearbyConnection { currentState = .sentUkeyServerInit } + private func processUkey2ClientFinish(_ msg: Securegcm_Ukey2Message, raw: Data) throws { guard msg.hasMessageType, msg.hasMessageData else { throw NearbyError.requiredFieldMissing("clientFinish ukey2message.type|data") } guard case .clientFinish = msg.messageType else { throw NearbyError.ukey2 } @@ -291,6 +301,7 @@ class InboundNearbyConnection: NearbyConnection { currentState = .receivedUkeyClientFinish } + private func processConnectionResponseFrame(_ frame: Location_Nearby_Connections_OfflineFrame) throws { guard frame.hasV1, frame.v1.hasType else { throw NearbyError.requiredFieldMissing("offlineFrame.v1.type") } @@ -323,6 +334,7 @@ class InboundNearbyConnection: NearbyConnection { } } + private func processPairedKeyEncryptionFrame(_ frame: Sharing_Nearby_Frame) throws { guard frame.hasV1, frame.v1.hasPairedKeyEncryption else { throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyEncryption") } var pairedResult = Sharing_Nearby_Frame() @@ -335,10 +347,12 @@ class InboundNearbyConnection: NearbyConnection { currentState = .sentPairedKeyResult } + private func processPairedKeyResultFrame(_ frame: Sharing_Nearby_Frame) throws { guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.pairedKeyResult") } currentState = .receivedPairedKeyResult } + private func makeFileDestinationURL(_ initialDest: URL) -> URL { var dest = initialDest @@ -358,6 +372,7 @@ class InboundNearbyConnection: NearbyConnection { } return dest } + private func processIntroductionFrame(_ frame: Sharing_Nearby_Frame) throws { guard frame.hasV1, frame.v1.hasIntroduction else { throw NearbyError.requiredFieldMissing("shareNearbyFrame.v1.introduction") } @@ -403,6 +418,7 @@ class InboundNearbyConnection: NearbyConnection { } } + func rejectDueToUnsupportedFileType(_ frame: Sharing_Nearby_Frame) { log("Rejecting transfer due to unsupported file type. Frame is \(frame.debugDescription)") @@ -410,6 +426,7 @@ class InboundNearbyConnection: NearbyConnection { NearbyConnectionManager.shared.mainAppDelegate?.showUnsupportedFileAlert(for: remoteDeviceInfo) rejectTransfer(with: .unsupportedAttachmentType) } + func submitUserConsent(accepted: Bool, storeInTemp: Bool = false) { DispatchQueue.global(qos: .utility).async { @@ -421,6 +438,7 @@ class InboundNearbyConnection: NearbyConnection { } } + private func acceptTransfer(storeInTemp: Bool) { if currentState == .disconnected { log("Detected timeout, not accepting transfer") @@ -461,6 +479,7 @@ class InboundNearbyConnection: NearbyConnection { } } + private func rejectTransfer(with reason: Sharing_Nearby_ConnectionResponseFrame.Status = .reject) { self.wasRejected = true @@ -481,236 +500,8 @@ class InboundNearbyConnection: NearbyConnection { } } + protocol InboundNearbyConnectionDelegate { func obtainUserConsent(for transfer: TransferMetadata, from device: RemoteDeviceInfo, connection: InboundNearbyConnection) func connectionWasTerminated(connection: InboundNearbyConnection, error: Error?) } - -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("Temporary directory cleared.") - } - } catch { - log("Failed to list contents of temp directory: \(error)") - } - } - - public static let shared = SaveFilesManager() - - let tempDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("Pending") - private var securityScopeUrl: URL? - - private var filesFinishedDownloading = [URL]() - private var filesFinishedDownloadingSinceLastRun = [URL]() - - public func registerFileFinishedDownloading(_ fileURL: URL) { - filesFinishedDownloading.append(fileURL) - filesFinishedDownloadingSinceLastRun.append(fileURL) - - // read out EXIF timestamp and manually set file creation date to it - applyEXIFTimestamps(at: fileURL) - } - - public func movePendingFilesToTarget() { - - if isPlusVersion() { - do { - let fileManager = FileManager.default - let files = try fileManager.contentsOfDirectory(at: tempDirectory, includingPropertiesForKeys: nil) - - let target = getSaveDirectory() - var loggedExecution = false - - for file in files { - - if !loggedExecution { - log("Moving pending files to target directory") - loggedExecution = true - } - - let fileName = file.lastPathComponent - let destinationURL = target.appendingPathComponent(fileName) - - if !filesFinishedDownloading.contains(destinationURL) { - log("File \(file) not finished downloading, skipping") - continue - } - - log("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 - progress.publish() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - progress.completedUnitCount = 10 - progress.unpublish() - } - - try fileManager.removeItem(at: file) - } - catch { - log("Error moving file: \(error)") - } - } - log("Moved all pending files to target directory") - } - catch { - // Pending directory doesn't exist or is empty - } - } - - if !filesFinishedDownloadingSinceLastRun.isEmpty && !isFileTransferRestricted() { - - if UserDefaults.standard.bool(forKey: UserDefaultsKeys.openFinderAfterReceiving.rawValue) { - log("Opening \(filesFinishedDownloadingSinceLastRun.count) file(s) in Finder.") - NSWorkspace.shared.activateFileViewerSelecting(filesFinishedDownloadingSinceLastRun) - } - - // Clear the list of finished files - filesFinishedDownloadingSinceLastRun.removeAll() - } - - stopAccessingSecurityScopedResource() - } - - public func stopAccessingSecurityScopedResource() { - guard let url = securityScopeUrl else { - return - } - - log("Stopping access to security scoped resource: \(url)") - url.stopAccessingSecurityScopedResource() - securityScopeUrl = nil - } - - public func getSaveDirectory() -> URL { - if let securityScopeUrl = securityScopeUrl { - log("Using existing security scope URL: \(securityScopeUrl)") - return securityScopeUrl - } - - if let bookmarkData = UserDefaults.standard.data(forKey: UserDefaultsKeys.saveFolderBookmark.rawValue) { - var isStale = false - - do { - let url = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) - - if !isStale { - if url.startAccessingSecurityScopedResource() { - log("Successfully accessed security scoped resource: \(url)") - - securityScopeUrl = url - return url - } - } else { - log("Bookmark is stale, using default downloads folder.") - } - - } catch { - log("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("Failed to get downloads directory: \(error)") - } - } - - /// 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) - } -} - -extension Data { - var hex: String { - return map { String(format: "%02hhx", $0) }.joined() - } -} - -extension String { - var dataFromHex: Data { - var data = Data(capacity: count / 2) - var index = startIndex - while index < endIndex { - let nextIndex = self.index(index, offsetBy: 2) - if nextIndex <= endIndex, - let byte = UInt8(self[index ..< nextIndex], radix: 16) - { - data.append(byte) - } else { - break // or handle invalid hex gracefully - } - index = nextIndex - } - return data - } -} diff --git a/NearbyShare/NearbyConnection.swift b/NearbyShare/NearbyConnection.swift index 9f7728e..73332a9 100644 --- a/NearbyShare/NearbyConnection.swift +++ b/NearbyShare/NearbyConnection.swift @@ -112,12 +112,6 @@ class NearbyConnection { func connectionReady() {} - func handleConnectionClosure() { - log("Connection closed") - deletePartiallyReceivedFiles() - } - - func protocolError() { log("Protocol error: \(String(describing: lastError))") disconnect() @@ -506,7 +500,7 @@ class NearbyConnection { inactivityTimer = nil connectionClosed = true - self.handleConnectionClosure() + deletePartiallyReceivedFiles() connection.send(content: nil, isComplete: true, completion: .contentProcessed { _ in }) diff --git a/NearbyShare/OutboundNearbyConnection.swift b/NearbyShare/OutboundNearbyConnection.swift index 9e6df0b..a97b032 100644 --- a/NearbyShare/OutboundNearbyConnection.swift +++ b/NearbyShare/OutboundNearbyConnection.swift @@ -11,7 +11,6 @@ import Foundation import Network import System import UniformTypeIdentifiers - import BigInt import SwiftECC @@ -46,6 +45,7 @@ class OutboundNearbyConnection: NearbyConnection { } } + deinit { if let transfer = currentTransfer, let handle = transfer.handle { try? handle.close() @@ -57,8 +57,9 @@ class OutboundNearbyConnection: NearbyConnection { } } - override func handleConnectionClosure() { - super.handleConnectionClosure() + + override func disconnect() { + super.disconnect() if let error = lastError { DispatchQueue.main.async { @@ -67,6 +68,7 @@ class OutboundNearbyConnection: NearbyConnection { } } + public func cancel() { cancelled = true if encryptionDone { @@ -78,6 +80,7 @@ class OutboundNearbyConnection: NearbyConnection { } try? sendDisconnectionAndDisconnect() } + override func connectionReady() { super.connectionReady() @@ -89,11 +92,13 @@ class OutboundNearbyConnection: NearbyConnection { protocolError() } } + override func isServer() -> Bool { return false } + override func processReceivedFrame(frameData: Data) { do { log("received \(frameData), state is \(currentState)") @@ -123,6 +128,7 @@ class OutboundNearbyConnection: NearbyConnection { } } + override func processTransferSetupFrame(_ frame: Sharing_Nearby_Frame) throws { if frame.hasV1 && frame.v1.hasType, case .cancel = frame.v1.type { log("Transfer canceled") @@ -144,12 +150,14 @@ class OutboundNearbyConnection: NearbyConnection { assertionFailure("Unexpected state \(currentState)") } } + override func protocolError() { super.protocolError() delegate?.outboundConnection(connection: self, failedWithError: lastError!) } + private func sendConnectionRequest() throws { var frame = Location_Nearby_Connections_OfflineFrame() frame.version = .v1 @@ -164,6 +172,7 @@ class OutboundNearbyConnection: NearbyConnection { try sendFrameAsync(frame.serializedData()) } + private func sendUkey2ClientInit() throws { let domain = Domain.instance(curve: .EC256r1) let (pubKey, privKey) = domain.makeKeyPair() @@ -201,6 +210,7 @@ class OutboundNearbyConnection: NearbyConnection { sendFrameAsync(ukeyClientInitMsgData!) currentState = .sentUkeyClientInit } + private func processUkey2ServerInit(frame: Securegcm_Ukey2Message, raw: Data) throws { ukeyServerInitMsgData = raw @@ -242,6 +252,7 @@ class OutboundNearbyConnection: NearbyConnection { delegate?.outboundConnectionWasEstablished(connection: self) } + private func processConnectionResponse(frame: Location_Nearby_Connections_OfflineFrame) throws { log("connection response: \(frame)") guard frame.version == .v1 else { throw NearbyError.protocolError("Unexpected offline frame version \(frame.version)") } @@ -260,6 +271,7 @@ class OutboundNearbyConnection: NearbyConnection { currentState = .sentPairedKeyEncryption } + private func processPairedKeyEncryption(frame: Sharing_Nearby_Frame) throws { guard frame.hasV1, frame.v1.hasPairedKeyEncryption else { throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyEncryption") } var pairedResult = Sharing_Nearby_Frame() @@ -272,6 +284,7 @@ class OutboundNearbyConnection: NearbyConnection { currentState = .sentPairedKeyResult } + private func processPairedKeyResult(frame: Sharing_Nearby_Frame) throws { guard frame.hasV1, frame.v1.hasPairedKeyResult else { throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.pairedKeyResult") } @@ -331,6 +344,7 @@ class OutboundNearbyConnection: NearbyConnection { currentState = .sentIntroduction } + private func processConsent(frame: Sharing_Nearby_Frame) throws { guard frame.version == .v1, frame.v1.type == .response else { throw NearbyError.requiredFieldMissing("sharingNearbyFrame.v1.type==response") } @@ -362,19 +376,23 @@ class OutboundNearbyConnection: NearbyConnection { } } + private func hasURL() -> Bool { urlsToSend.count == 1 && !urlsToSend[0].isFileURL } + private func sendURL() throws { try sendText(text: urlsToSend[0].absoluteString) } + private func sendText(text: String) throws { try sendBytesPayload(data: Data(text.utf8), id: textPayloadID) delegate?.outboundConnectionTransferFinished(connection: self) try sendDisconnectionAndDisconnect() } + private func sendNextFileChunk() throws { if cancelled { @@ -448,11 +466,13 @@ class OutboundNearbyConnection: NearbyConnection { } } + private static func sanitizeFileName(name: String) -> String { return name.replacingOccurrences(of: "[\\/\\\\?%\\*:\\|\"<>=]", with: "_", options: .regularExpression) } } + private struct OutgoingFileTransfer { let url: URL let payloadID: Int64 @@ -461,6 +481,7 @@ private struct OutgoingFileTransfer { var currentOffset: Int64 } + protocol OutboundNearbyConnectionDelegate { func outboundConnectionWasEstablished(connection: OutboundNearbyConnection) func outboundConnection(connection: OutboundNearbyConnection, transferProgress: Double) diff --git a/NearbyShare/SaveFilesManager.swift b/NearbyShare/SaveFilesManager.swift new file mode 100644 index 0000000..73b3e71 --- /dev/null +++ b/NearbyShare/SaveFilesManager.swift @@ -0,0 +1,220 @@ +// +// SaveFilesManager.swift +// QuickDrop +// +// Created by Leon Böttger on 26.07.25. +// + +import Foundation +import AppKit +import ImageIO + +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("Temporary directory cleared.") + } + } catch { + log("Failed to list contents of temp directory: \(error)") + } + } + + public static let shared = SaveFilesManager() + + let tempDirectory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("Pending") + private var securityScopeUrl: URL? + + private var filesFinishedDownloading = [URL]() + private var filesFinishedDownloadingSinceLastRun = [URL]() + + + public func registerFileFinishedDownloading(_ fileURL: URL) { + filesFinishedDownloading.append(fileURL) + filesFinishedDownloadingSinceLastRun.append(fileURL) + + // read out EXIF timestamp and manually set file creation date to it + applyEXIFTimestamps(at: fileURL) + } + + + public func movePendingFilesToTarget() { + + if isPlusVersion() { + do { + let fileManager = FileManager.default + let files = try fileManager.contentsOfDirectory(at: tempDirectory, includingPropertiesForKeys: nil) + + let target = getSaveDirectory() + var loggedExecution = false + + for file in files { + + if !loggedExecution { + log("Moving pending files to target directory") + loggedExecution = true + } + + let fileName = file.lastPathComponent + let destinationURL = target.appendingPathComponent(fileName) + + if !filesFinishedDownloading.contains(destinationURL) { + log("File \(file) not finished downloading, skipping") + continue + } + + log("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 + progress.publish() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + progress.completedUnitCount = 10 + progress.unpublish() + } + + try fileManager.removeItem(at: file) + } + catch { + log("Error moving file: \(error)") + } + } + log("Moved all pending files to target directory") + } + catch { + // Pending directory doesn't exist or is empty + } + } + + if !filesFinishedDownloadingSinceLastRun.isEmpty && !isFileTransferRestricted() { + + if UserDefaults.standard.bool(forKey: UserDefaultsKeys.openFinderAfterReceiving.rawValue) { + log("Opening \(filesFinishedDownloadingSinceLastRun.count) file(s) in Finder.") + NSWorkspace.shared.activateFileViewerSelecting(filesFinishedDownloadingSinceLastRun) + } + + // Clear the list of finished files + filesFinishedDownloadingSinceLastRun.removeAll() + } + + stopAccessingSecurityScopedResource() + } + + + public func stopAccessingSecurityScopedResource() { + guard let url = securityScopeUrl else { + return + } + + log("Stopping access to security scoped resource: \(url)") + url.stopAccessingSecurityScopedResource() + securityScopeUrl = nil + } + + + public func getSaveDirectory() -> URL { + if let securityScopeUrl = securityScopeUrl { + log("Using existing security scope URL: \(securityScopeUrl)") + return securityScopeUrl + } + + if let bookmarkData = UserDefaults.standard.data(forKey: UserDefaultsKeys.saveFolderBookmark.rawValue) { + var isStale = false + + do { + let url = try URL(resolvingBookmarkData: bookmarkData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) + + if !isStale { + if url.startAccessingSecurityScopedResource() { + log("Successfully accessed security scoped resource: \(url)") + + securityScopeUrl = url + return url + } + } else { + log("Bookmark is stale, using default downloads folder.") + } + + } catch { + log("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("Failed to get downloads directory: \(error)") + } + } + + + /// 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) + } +} diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index 77afe2c..17b4a23 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -25,7 +25,6 @@ 69DCF4902AB70E9700CBE2CC /* InboundNearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */; }; 69DCF4912AB70E9700CBE2CC /* NearbyConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698DFAE529E2F91A0064F247 /* NearbyConnection.swift */; }; 69DCF4922AB70E9700CBE2CC /* NearbyConnectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */; }; - 69DCF4932AB70E9700CBE2CC /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2029E0CC4E00A442DA /* Data+Extensions.swift */; }; 69DCF4942AB8BF7B00CBE2CC /* libNearbyShare.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 69DCF4812AB70D0600CBE2CC /* libNearbyShare.dylib */; }; 69DCF4952AB8BF7B00CBE2CC /* libNearbyShare.dylib in Embed Libraries */ = {isa = PBXBuildFile; fileRef = 69DCF4812AB70D0600CBE2CC /* libNearbyShare.dylib */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 69DCF49A2AB8C58500CBE2CC /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = 69DCF4992AB8C58500CBE2CC /* SwiftProtobuf */; }; @@ -40,6 +39,7 @@ EB5C36942E34EBBD0052EFD7 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = EB5C36922E34EBBD0052EFD7 /* Localizable.xcstrings */; }; EB5C36E42E34EC0E0052EFD7 /* License.txt in Resources */ = {isa = PBXBuildFile; fileRef = EB5C36E32E34EC0E0052EFD7 /* License.txt */; }; EB5C36E52E34EC0E0052EFD7 /* License.txt in Resources */ = {isa = PBXBuildFile; fileRef = EB5C36E32E34EC0E0052EFD7 /* License.txt */; }; + EB5C36E72E3544FF0052EFD7 /* SaveFilesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB5C36E62E3544FB0052EFD7 /* SaveFilesManager.swift */; }; EB6E743E2DD5C77700331466 /* ConnectionFailureTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6E743D2DD5C76F00331466 /* ConnectionFailureTracker.swift */; }; EB6F62D72D91F4AF00534EC2 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */; }; EB7CD1452D6FB0C300CB0CB5 /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7CD1442D6FB0C100CB0CB5 /* Logger.swift */; }; @@ -164,7 +164,6 @@ 69DA9A1329E0BF5200A442DA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 69DA9A1829E0BF5200A442DA /* QuickDrop.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = QuickDrop.entitlements; sourceTree = ""; }; 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NearbyConnectionManager.swift; sourceTree = ""; }; - 69DA9A2029E0CC4E00A442DA /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundNearbyConnection.swift; sourceTree = ""; }; 69DA9A2829E18CB500A442DA /* ukey.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ukey.pb.swift; sourceTree = ""; }; 69DA9A2929E18CB500A442DA /* wire_format.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = wire_format.pb.swift; sourceTree = ""; }; @@ -185,6 +184,7 @@ EB5C36922E34EBBD0052EFD7 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; EB5C36952E34EBBD0052EFD7 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/ShareViewController.xcstrings; sourceTree = ""; }; EB5C36E32E34EC0E0052EFD7 /* License.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = License.txt; sourceTree = ""; }; + EB5C36E62E3544FB0052EFD7 /* SaveFilesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveFilesManager.swift; sourceTree = ""; }; EB6E743D2DD5C76F00331466 /* ConnectionFailureTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionFailureTracker.swift; sourceTree = ""; }; EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; EB7CD1442D6FB0C100CB0CB5 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; @@ -323,10 +323,10 @@ 69DA9A1E29E0C0B300A442DA /* NearbyConnectionManager.swift */, 69DA9A2229E17F0400A442DA /* InboundNearbyConnection.swift */, 691F53BD2ABF03820089FD92 /* OutboundNearbyConnection.swift */, + EB5C36E62E3544FB0052EFD7 /* SaveFilesManager.swift */, EB2F9BFA2DA04B67004010A2 /* Helpers.swift */, 698DFAE529E2F91A0064F247 /* NearbyConnection.swift */, EB6E743D2DD5C76F00331466 /* ConnectionFailureTracker.swift */, - 69DA9A2029E0CC4E00A442DA /* Data+Extensions.swift */, ); path = NearbyShare; sourceTree = ""; @@ -640,8 +640,8 @@ 69DCF4902AB70E9700CBE2CC /* InboundNearbyConnection.swift in Sources */, 69DCF48B2AB70E8C00CBE2CC /* ukey.pb.swift in Sources */, EB2B41E42DC63ACC0070FC39 /* SleepManager.swift in Sources */, + EB5C36E72E3544FF0052EFD7 /* SaveFilesManager.swift in Sources */, 69DCF48A2AB70E8C00CBE2CC /* wire_format.pb.swift in Sources */, - 69DCF4932AB70E9700CBE2CC /* Data+Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };