From c781af554f30526c79e6543272508d3e7bb8758b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6ttger?= Date: Fri, 15 Aug 2025 11:29:40 +0200 Subject: [PATCH] Add sending files, images, and clipboard to iOS app --- .../Connection/NearbyConnectionManager.swift | 148 +++++--- QuickDrop.xcodeproj/project.pbxproj | 10 + .../xcshareddata/swiftpm/Package.resolved | 4 +- QuickDrop/Localizable.xcstrings | 321 ++++++++++++++++++ QuickDrop/Util/License.swift | 18 + QuickDrop/View/LicenseWindow.swift | 9 +- QuickDropiOS/Configuration.swift | 26 ++ QuickDropiOS/ContentView.swift | 180 +++++----- QuickDropiOS/FilePicker.swift | 169 +++++++++ QuickDropiOS/Model.swift | 6 +- QuickDropiOS/QuickDropiOS.entitlements | 4 + QuickDropiOS/QuickDropiOSApp.swift | 10 + ShareExtension/ShareViewController.swift | 41 +-- ShareExtension/Util/Zip.swift | 87 ++--- 14 files changed, 802 insertions(+), 231 deletions(-) create mode 100644 QuickDrop/Util/License.swift create mode 100644 QuickDropiOS/Configuration.swift create mode 100644 QuickDropiOS/FilePicker.swift diff --git a/NearbyShare/Connection/NearbyConnectionManager.swift b/NearbyShare/Connection/NearbyConnectionManager.swift index a7be723..adbdccc 100644 --- a/NearbyShare/Connection/NearbyConnectionManager.swift +++ b/NearbyShare/Connection/NearbyConnectionManager.swift @@ -16,7 +16,7 @@ import SwiftECC import CryptoKit public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearbyConnectionDelegate, OutboundNearbyConnectionDelegate { - + private let sleepManager = SleepManager.shared private var tcpListener: NWListener private var mdnsServices: [NetService] = [] @@ -30,11 +30,11 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb private var qrCodePrivateKey: ECPrivateKey? private var qrCodeAdvertisingToken: Data? private var qrCodeNameEncryptionKey: SymmetricKey? - + public let endpointID: [UInt8] = generateEndpointID() public var mainAppDelegate: (any MainAppDelegate)? public static let shared = NearbyConnectionManager() - + override init() { tcpListener = try! NWListener(using: NWParameters(tls: .none)) @@ -46,7 +46,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb startTCPListener() } - + private func startTCPListener() { tcpListener.stateUpdateHandler = { (state: NWListener.State) in if case .ready = state { @@ -62,32 +62,32 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } tcpListener.start(queue: .global(qos: .utility)) } - + private static func generateEndpointID() -> [UInt8] { let userDefaultsKey = UserDefaultsKeys.endpointID.rawValue - + // Try to retrieve from UserDefaults if let savedString = UserDefaults.standard.string(forKey: userDefaultsKey), let savedData = savedString.data(using: .utf8) { return [UInt8](savedData) } - + // Generate a new random ID var id: [UInt8] = [] let alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ".compactMap { UInt8($0.asciiValue!) } for _ in 0 ..< 4 { id.append(alphabet[Int.random(in: 0 ..< alphabet.count)]) } - + // Save to UserDefaults as String let idString = String(bytes: id, encoding: .utf8) ?? "" UserDefaults.standard.set(idString, forKey: userDefaultsKey) - + return id } - + private func initMDNS() { let nameBytes: [UInt8] = [ @@ -99,7 +99,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb let name = Data(nameBytes).urlSafeBase64EncodedString() let port = Int32(tcpListener.port!.rawValue) - + mdnsServices = serviceTypes.map { serviceType in let service = NetService(domain: "", type: serviceType, name: name, port: port) service.delegate = self @@ -113,15 +113,15 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb func getEndpointInfo() -> EndpointInfo { - #if os(macOS) +#if os(macOS) let endpointInfo = EndpointInfo(name: Host.current().localizedName ?? "Mac", deviceType: .computer) - #else +#else let endpointInfo = EndpointInfo(name: UIDevice.current.name, deviceType: .phone) - #endif +#endif return endpointInfo } - + func obtainUserConsent(for transfer: TransferMetadata, from device: RemoteDeviceInfo, connection _: InboundNearbyConnection) { mainAppDelegate?.obtainUserConsent(for: transfer, from: device) @@ -135,7 +135,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb mainAppDelegate?.incomingTransfer(id: connection.id, didFinishWith: error) } } - + public func submitUserConsent(transferID: String, accept: Bool, storeInTemp: Bool = false) { guard let conn = incomingConnections[transferID] else { return } @@ -143,20 +143,20 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb log("[NearbyConnectionManager] Submitting user consent for transfer \(transferID), accepted: \(accept), store in temp: \(storeInTemp)") conn.submitUserConsent(accepted: accept, storeInTemp: storeInTemp) } - + public func startDeviceDiscovery() { if !startedDeviceDiscovery { startedDeviceDiscovery = true foundServices.removeAll() - + log("[NearbyConnectionManager] Starting device discovery") - + if browsers.isEmpty { for type in serviceTypes { log("[NearbyConnectionManager] Starting browser for type \(type)") - + let browser = NWBrowser(for: .bonjourWithTXTRecord(type: type, domain: nil), using: .tcp) browser.browseResultsChangedHandler = { _, changes in for change in changes { @@ -176,7 +176,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } } } - + public func stopDeviceDiscovery() { if startedDeviceDiscovery { @@ -186,12 +186,12 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb for browser in browsers { browser.cancel() } - + browsers.removeAll() startedDeviceDiscovery = false } } - + public func addShareExtensionDelegate(_ delegate: ShareExtensionDelegate) { shareExtensionDelegates.append(delegate) @@ -200,18 +200,18 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb delegate.addDevice(device: device) } } - + public func removeShareExtensionDelegate(_ delegate: ShareExtensionDelegate) { shareExtensionDelegates.removeAll(where: { $0 === delegate }) } - + public func cancelOutgoingTransfer(id: String) { guard let transfer = outgoingTransfers[id] else { return } transfer.connection.cancel() } - + private func endpointID(for service: NWBrowser.Result) -> String? { guard case let NWEndpoint.service(name: serviceName, type: _, domain: _, interface: _) = service.endpoint else { return nil } @@ -224,7 +224,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb guard serviceIDHash == Data([0xFC, 0x9F, 0x5E]) else { return nil } return endpointID } - + private func addFoundDevice(service: NWBrowser.Result) { log("[NearbyConnectionManager] Found Service \(service)") @@ -286,7 +286,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb return deviceInfo } - + private func removeFoundDevice(service: NWBrowser.Result) { guard let endpointID = endpointID(for: service) else { return } @@ -313,21 +313,33 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb return keyData.urlSafeBase64EncodedString() } - + public func startOutgoingTransfer(deviceID: String, delegate: ShareExtensionDelegate, urls: [URL], textToSend: String?) { + log("Starting outgoing transfer to \(deviceID)") guard let info = foundServices[deviceID] else { return } - let tcp = NWProtocolTCP.Options() - tcp.noDelay = true - let nwconn = NWConnection(to: info.service.endpoint, using: NWParameters(tls: .none, tcp: tcp)) - let conn = OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: urls, textToSend: textToSend) - conn.delegate = self - conn.qrCodePrivateKey = qrCodePrivateKey - let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate) - outgoingTransfers[deviceID] = transfer - conn.start() + + do { + let localUrls = try saveFilesToTemp(urls: urls) + + let tcp = NWProtocolTCP.Options() + tcp.noDelay = true + let nwconn = NWConnection(to: info.service.endpoint, using: NWParameters(tls: .none, tcp: tcp)) + + let conn = OutboundNearbyConnection(connection: nwconn, id: deviceID, urlsToSend: localUrls, textToSend: textToSend) + conn.delegate = self + conn.qrCodePrivateKey = qrCodePrivateKey + let transfer = OutgoingTransferInfo(service: info.service, device: info.device!, connection: conn, delegate: delegate) + outgoingTransfers[deviceID] = transfer + conn.start() + } catch { + log("[NearbyConnectionManager] Error zipping URLs: \(error)") + shareExtensionDelegates.forEach { delegate in + delegate.connectionFailed(with: error) + } + } } - + func outboundConnectionWasEstablished(connection: OutboundNearbyConnection) { guard let transfer = outgoingTransfers[connection.id] else { return } @@ -336,14 +348,14 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } } - + func outboundConnectionTransferAccepted(connection: OutboundNearbyConnection) { guard let transfer = outgoingTransfers[connection.id] else { return } DispatchQueue.main.async { transfer.delegate.transferAccepted() } } - + func outboundConnection(connection: OutboundNearbyConnection, transferProgress: Double) { guard let transfer = outgoingTransfers[connection.id] else { return } @@ -351,7 +363,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb transfer.delegate.transferProgress(progress: transferProgress) } } - + func outboundConnection(connection: OutboundNearbyConnection, failedWithError: Error) { guard let transfer = outgoingTransfers[connection.id] else { return } @@ -361,7 +373,7 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb outgoingTransfers.removeValue(forKey: connection.id) } - + func outboundConnectionTransferFinished(connection: OutboundNearbyConnection) { guard let transfer = outgoingTransfers[connection.id] else { return } DispatchQueue.main.async { @@ -369,13 +381,61 @@ public class NearbyConnectionManager: NSObject, NetServiceDelegate, InboundNearb } outgoingTransfers.removeValue(forKey: connection.id) } - + public func getActiveConnectionsCount() -> Int { return incomingConnections.count + outgoingTransfers.count } + private func saveFilesToTemp(urls: [URL]) throws -> [URL] { + + var modifiedUrls = [URL]() + + for url in urls { + + // if url is no file url or already in inside temp directory, do nothing + if url.isFileURL && !url.standardizedFileURL.path.contains(FileManager.default.temporaryDirectory.standardizedFileURL.path) { + + let accessSuccess = url.startAccessingSecurityScopedResource() + + if !accessSuccess { + log("[NearbyConnectionManager] Could not access security scoped resource at \(url)") + } + + var isDirectory: ObjCBool = false + if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue { + let zipUrl = try Zip.createAtTemporaryDirectory(zipFilename: url.lastPathComponent, fromDirectory: url) + + modifiedUrls.append(zipUrl) + } + else { + // Copy single file to temp directory + let tempUrl = FileManager.default.temporaryDirectory + .appendingPathComponent(url.lastPathComponent) + // Remove if already exists + if FileManager.default.fileExists(atPath: tempUrl.path) { + try FileManager.default.removeItem(at: tempUrl) + } + try FileManager.default.copyItem(at: url, to: tempUrl) + modifiedUrls.append(tempUrl) + } + + if accessSuccess { + url.stopAccessingSecurityScopedResource() + } + + } + else { + log("[NearbyConnectionManager] File URL \(url) does not point to a security scoped resource") + modifiedUrls.append(url) + } + } + + return modifiedUrls + } + + // -- MARK: - Internal Data Model private struct FoundServiceInfo { diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index 2bc8872..4bd3331 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -93,6 +93,10 @@ EB886E552E4E50F40052B7D9 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = EB5C36922E34EBBD0052EFD7 /* Localizable.xcstrings */; }; EB886E582E4E66420052B7D9 /* LUI in Frameworks */ = {isa = PBXBuildFile; productRef = EB886E572E4E66420052B7D9 /* LUI */; }; EB886E592E4F056B0052B7D9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69DA9A1329E0BF5200A442DA /* Assets.xcassets */; }; + EB886E5D2E4F13EB0052B7D9 /* Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCC27F82D80C9F70079C478 /* Zip.swift */; }; + EB886E5E2E4F13EB0052B7D9 /* Zip.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBCC27F82D80C9F70079C478 /* Zip.swift */; }; + EB886E672E4F2F800052B7D9 /* License.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB886E662E4F2F7C0052B7D9 /* License.swift */; }; + EB886E682E4F2F800052B7D9 /* License.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB886E662E4F2F7C0052B7D9 /* License.swift */; }; EB96ED8B2D7F7CF7004B3FF1 /* SmallSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB96ED8A2D7F7CF4004B3FF1 /* SmallSheetView.swift */; }; EB96ED8C2D7F7E7B004B3FF1 /* SmallSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB96ED8A2D7F7CF4004B3FF1 /* SmallSheetView.swift */; }; EB96ED8D2D7F7EA3004B3FF1 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 69DA9A1329E0BF5200A442DA /* Assets.xcassets */; }; @@ -223,6 +227,7 @@ EB7F54112D78E95C00989564 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; EB7F54132D78E9E400989564 /* LicenseWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseWindow.swift; sourceTree = ""; }; EB886E512E4E19390052B7D9 /* HKDF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKDF.swift; sourceTree = ""; }; + EB886E662E4F2F7C0052B7D9 /* License.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = License.swift; sourceTree = ""; }; EB96ED8A2D7F7CF4004B3FF1 /* SmallSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallSheetView.swift; sourceTree = ""; }; EB96ED8E2D7F8103004B3FF1 /* Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localized.swift; sourceTree = ""; }; EB99257F2E45D53E00357055 /* sharing_enums.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = sharing_enums.pb.swift; sourceTree = ""; }; @@ -434,6 +439,7 @@ isa = PBXGroup; children = ( EB5C383E2E38C49D0052EFD7 /* LaunchAtLogin.swift */, + EB886E662E4F2F7C0052B7D9 /* License.swift */, EB36435F2D73A29F009CDFF8 /* AudioManager.swift */, EBC05C5A2E33936C00EECC18 /* ErrorAlertHandler.swift */, ); @@ -712,6 +718,7 @@ EB5C37BA2E377A6F0052EFD7 /* Helpers.swift in Sources */, EBC05C5B2E33937200EECC18 /* ErrorAlertHandler.swift in Sources */, EBC05C712E33B57D00EECC18 /* Converter.swift in Sources */, + EB886E5D2E4F13EB0052B7D9 /* Zip.swift in Sources */, EBC05C722E33B57D00EECC18 /* Helper.swift in Sources */, EBC05C732E33B57D00EECC18 /* PlusView.swift in Sources */, EB5C37BF2E377A780052EFD7 /* ukey.pb.swift in Sources */, @@ -730,6 +737,7 @@ EBC05C7A2E33B58100EECC18 /* ApIsolationTester.swift in Sources */, EB886E522E4E19390052B7D9 /* HKDF.swift in Sources */, EB96ED8C2D7F7E7B004B3FF1 /* SmallSheetView.swift in Sources */, + EB886E682E4F2F800052B7D9 /* License.swift in Sources */, EB7F54142D78E9E700989564 /* LicenseWindow.swift in Sources */, 69DA9A1229E0BF5100A442DA /* AppDelegate.swift in Sources */, EB6F62D72D91F4AF00534EC2 /* SettingsView.swift in Sources */, @@ -752,6 +760,7 @@ EB5C37F12E377CE70052EFD7 /* ukey.pb.swift in Sources */, EB5C37F22E377CE70052EFD7 /* NearbyConnection.swift in Sources */, EB5C37F32E377CE70052EFD7 /* SaveFilesManager.swift in Sources */, + EB886E672E4F2F800052B7D9 /* License.swift in Sources */, EB5C37F42E377CE70052EFD7 /* NearbyConnectionManager.swift in Sources */, EB5C37F52E377CE70052EFD7 /* securemessage.pb.swift in Sources */, EB5C37F62E377CE70052EFD7 /* device_to_device_messages.pb.swift in Sources */, @@ -766,6 +775,7 @@ EB5C37FC2E377CE70052EFD7 /* OutboundNearbyConnection.swift in Sources */, EB5C37FD2E377CE70052EFD7 /* Logger.swift in Sources */, EB5C37FE2E377CE70052EFD7 /* offline_wire_formats.pb.swift in Sources */, + EB886E5E2E4F13EB0052B7D9 /* Zip.swift in Sources */, EB5C383D2E38C2D90052EFD7 /* AudioManager.swift in Sources */, EB5C38402E38C49D0052EFD7 /* LaunchAtLogin.swift in Sources */, EB5C37FF2E377CE70052EFD7 /* wire_format.pb.swift in Sources */, diff --git a/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0e15ecd..1082534 100644 --- a/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "dc2ed9804dbbcffdc405b2edfb84282a3cc98d80b17b0d759f119e81dde31eba", + "originHash" : "053324b22e9d13d48c4bf9290d746eb2c1a2e80e5900f9dde545bb150c5eff6a", "pins" : [ { "identity" : "applewatchscreensize", @@ -43,7 +43,7 @@ "location" : "https://github.com/leonboe1/LUI.git", "state" : { "branch" : "main", - "revision" : "a4a9e2fb64cd3e928a00bddf3b401bf1e56d69b6" + "revision" : "88c015024986364070bf127f156ca9b9f2705533" } }, { diff --git a/QuickDrop/Localizable.xcstrings b/QuickDrop/Localizable.xcstrings index af58fc1..c410457 100644 --- a/QuickDrop/Localizable.xcstrings +++ b/QuickDrop/Localizable.xcstrings @@ -1603,6 +1603,113 @@ } } }, + "ClipboardEmptyMessage" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يرجى نسخ النص إلى الحافظة، وحاول مرة أخرى." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitte kopiere Text in die Zwischenablage und versuche es erneut." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please copy text to the clipboard, and try again." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por favor, copie el texto al portapapeles y vuelva a intentarlo." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Veuillez copier le texte dans le presse-papiers, puis réessayez." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kérem, másolja a szöveget a vágólapra, majd próbálja újra." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copia il testo negli appunti e riprova." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "テキストをクリップボードにコピーして、もう一度お試しください。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드에 텍스트를 복사하고 다시 시도하세요." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kopieer de tekst naar het klembord en probeer opnieuw." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por favor, copie o texto para a área de transferência e tente novamente." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por favor, copie o texto para a área de transferência e tente novamente." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Te rog copiază textul în clipboard și încearcă din nou." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Пожалуйста, скопируйте текст в буфер обмена и попробуйте снова." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "โปรดคัดลอกข้อความไปยังคลิปบอร์ด แล้วลองอีกครั้ง" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скопіюйте текст у буфер обміну та спробуйте ще раз." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请将文本复制到剪贴板,然后重试。" + } + } + } + }, "CloseAlert" : { "extractionState" : "manual", "localizations" : { @@ -6201,6 +6308,113 @@ } } }, + "QuickDropWelcomeDescription" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "يتيح لك تطبيق QuickDrop نقل الملفات بسهولة وسرعة بين أجهزة iPhone وiPad وMac وAndroid." + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop macht es einfach und schnell, Dateien zwischen iPhone, iPad, Mac und Android-Geräten zu übertragen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop makes it easy and quick to transfer files between iPhone, iPad, Mac, and Android devices." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop facilita y agiliza la transferencia de archivos entre dispositivos iPhone, iPad, Mac y Android." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop facilite et accélère le transfert de fichiers entre iPhone, iPad, Mac et appareils Android." + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "A QuickDrop megkönnyíti és felgyorsítja a fájlok átvitelét iPhone, iPad, Mac és Android eszközök között." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop rende facile e veloce il trasferimento di file tra iPhone, iPad, Mac e dispositivi Android." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDropは、iPhone、iPad、Mac、およびAndroidデバイス間でのファイル転送を簡単かつ迅速に行えます。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop은 iPhone, iPad, Mac, Android 기기 간 파일 전송을 쉽고 빠르게 만들어줍니다." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop maakt het eenvoudig en snel om bestanden over te zetten tussen iPhone-, iPad-, Mac- en Android-apparaten." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop torna fácil e rápido transferir arquivos entre dispositivos iPhone, iPad, Mac e Android." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O QuickDrop facilita e acelera a transferência de ficheiros entre iPhone, iPad, Mac e dispositivos Android." + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop face ușor și rapid transferul de fișiere între dispozitive iPhone, iPad, Mac și Android." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop позволяет легко и быстро передавать файлы между iPhone, iPad, Mac и Android-устройствами." + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop ทำให้การถ่ายโอนไฟล์ระหว่าง iPhone, iPad, Mac และอุปกรณ์ Android ง่ายและรวดเร็ว" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop дозволяє легко та швидко передавати файли між пристроями iPhone, iPad, Mac та Android." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "QuickDrop让您可以轻松快捷地在iPhone、iPad、Mac和Android设备之间传输文件。" + } + } + } + }, "Quit" : { "extractionState" : "manual", "localizations" : { @@ -7483,6 +7697,113 @@ } } }, + "SendImages" : { + "extractionState" : "manual", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "إرسال الصور" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bilder senden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Send Images" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar imágenes" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Envoyer des images" + } + }, + "hu" : { + "stringUnit" : { + "state" : "translated", + "value" : "Képek küldése" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invia immagini" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を送信" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이미지 보내기" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afbeeldingen verzenden" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar Imagens" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enviar Imagens" + } + }, + "ro" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trimite Imagini" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отправить изображения" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ส่งรูปภาพ" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Надіслати зображення" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送图片" + } + } + } + }, "Sending" : { "extractionState" : "manual", "localizations" : { diff --git a/QuickDrop/Util/License.swift b/QuickDrop/Util/License.swift new file mode 100644 index 0000000..fcaf7c2 --- /dev/null +++ b/QuickDrop/Util/License.swift @@ -0,0 +1,18 @@ +// +// License.swift +// QuickDrop +// +// Created by Leon Böttger on 15.08.25. +// + +import Foundation + +var licenseText: String { + if let path = Bundle.main.path(forResource: "License", ofType: "txt"), + let licenseText = try? String(contentsOfFile: path) { + return licenseText + } + else { + return Bundle.main.path(forResource: "License", ofType: "txt") ?? "License file not found" + } +} diff --git a/QuickDrop/View/LicenseWindow.swift b/QuickDrop/View/LicenseWindow.swift index fe20546..386c9bd 100644 --- a/QuickDrop/View/LicenseWindow.swift +++ b/QuickDrop/View/LicenseWindow.swift @@ -15,14 +15,7 @@ struct LicensePage: View { .edgesIgnoringSafeArea(.all) ScrollView { LazyVStack { - if let path = Bundle.main.path(forResource: "License", ofType: "txt"), - let licenseText = try? String(contentsOfFile: path) { - Text(licenseText) - .padding() - } - else { - Text(Bundle.main.path(forResource: "License", ofType: "txt") ?? "License file not found") - } + Text(licenseText) } } } diff --git a/QuickDropiOS/Configuration.swift b/QuickDropiOS/Configuration.swift new file mode 100644 index 0000000..1e919d7 --- /dev/null +++ b/QuickDropiOS/Configuration.swift @@ -0,0 +1,26 @@ +// +// Configuration.swift +// QuickDrop +// +// Created by Leon Böttger on 15.08.25. +// + +import LUI + +let configuration = LUIConfiguration( + appName: "QuickDrop", + + hasRoundedFont: false, + + copyrightInformation: licenseText, + + introductionViewInformation: "QuickDropWelcomeDescription", + + usesPagedIntroduction: false, + + supportsSplitView: false, + + supportsPortraitSplitView: false, + + supportsDarkModeShadow: false +) diff --git a/QuickDropiOS/ContentView.swift b/QuickDropiOS/ContentView.swift index b820b91..bfadf81 100644 --- a/QuickDropiOS/ContentView.swift +++ b/QuickDropiOS/ContentView.swift @@ -13,7 +13,11 @@ struct ContentView: View { @StateObject var model = ShareViewModel() var body: some View { - DeviceListView(model: model) + AppRootView(isPlus: .constant(true), phoneView: { + DeviceListView(model: model) + }, settingsView: { + EmptyView() + }) } } @@ -23,112 +27,100 @@ struct DeviceListView: View { var body: some View { - NavigationView { - NavigationSubView(header: "QuickDrop ") { + NavigationSubView(header: "QuickDrop ") { + + // "You're visible as" + VStack(alignment: .leading, spacing: 8) { + FooterView(text: "YouAreVisibleAs") - // "You're visible as" - VStack(alignment: .leading, spacing: 8) { - FooterView(text: "YouAreVisibleAs") + Text(NearbyConnectionManager.shared.getEndpointInfo().name ?? "QuickDrop") + .fontWeight(.bold) + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(Color.defaultForegroundColor) + .cornerRadius(24) + .padding(.horizontal) + } + .padding(.top, 8) + .frame(maxWidth: .infinity, alignment: .leading) + + + if model.foundDevices.isEmpty { + + CustomSection(header: "NoDevicesFound") { - Text(NearbyConnectionManager.shared.getEndpointInfo().name ?? "QuickDrop") - .fontWeight(.bold) - .padding(.horizontal, 16) - .padding(.vertical, 6) - .background(Color.defaultForegroundColor) - .cornerRadius(24) - .padding(.horizontal) - } - .padding(.top, 8) - .frame(maxWidth: .infinity, alignment: .leading) - - - if model.foundDevices.isEmpty { - - CustomSection(header: "NoDevicesFound") { + HStack(spacing: 12) { + Image(systemName: "square.and.arrow.down") + .font(.system(size: 30)) + .foregroundColor(.gray) - HStack(spacing: 12) { - Image(systemName: "square.and.arrow.down") - .font(.system(size: 30)) - .foregroundColor(.gray) - - Text("DownloadQuickDropOnPlayStore") - } - .padding(.vertical) + Text("DownloadQuickDropOnPlayStore") } - } else { - - CustomSection(header: "AvailableDevices") { - ForEach(model.foundDevices) { device in - - LUIButton { - model.selectDevice(device: device) - } label: { - HStack(spacing: 12) { - - let name = device.name ?? "Android" - - ZStack { - Circle() - .fill( - LinearGradient( - colors: [Color(red: 0.623, green: 0.659, blue: 0.855), - Color(red: 0.474, green: 0.525, blue: 0.796)], - startPoint: .top, - endPoint: .bottom - ) + .padding(.vertical) + } + } else { + + CustomSection(header: "AvailableDevices") { + ForEach(model.foundDevices) { device in + + SendPickerButton { + HStack(spacing: 12) { + + let name = device.name ?? "Android" + + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color(red: 0.623, green: 0.659, blue: 0.855), + Color(red: 0.474, green: 0.525, blue: 0.796)], + startPoint: .top, + endPoint: .bottom ) - .frame(width: 40, height: 40) - - Image(systemName: device.icon) - .foregroundColor(.white) - .fontWeight(.bold) - } + ) + .frame(width: 40, height: 40) - VStack(alignment: .leading, spacing: 5) { - Text(name) - .fontWeight(.medium) - .foregroundColor(Color.primary) - - Text(model.selectedDevice == device ? model.progressState ?? "..." : "Available".localized()) - .font(.system(size: 12)) - .foregroundColor(Color.primary.opacity(0.6)) - } + Image(systemName: device.icon) + .foregroundColor(.white) + .fontWeight(.bold) + } + + VStack(alignment: .leading, spacing: 5) { + Text(name) + .fontWeight(.medium) + .foregroundColor(Color.primary) - Spacer() - - Image(systemName: "chevron.right") + Text(model.selectedDevice == device ? model.progressState ?? "..." : "Available".localized()) + .font(.system(size: 12)) .foregroundColor(Color.primary.opacity(0.6)) } - .padding(.vertical, 13) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(Color.primary.opacity(0.6)) } + .padding(.vertical, 13) + } onResult: { urls, text in + model.urls = urls ?? [] + model.textToSend = text + model.selectDevice(device: device) } } } - - HStack(spacing: 8) { - Text("SearchingForDevices") - .font(.system(size: 14)) - .foregroundColor(Color.primary.opacity(0.6)) - - ProgressView() - .frame(width: 12, height: 12) - } - .padding(.vertical, 16) - - Spacer() - - // Privacy policy - Button(action: { - if let url = URL(string: "https://leonboettger.com/quickdrop-privacy") { - UIApplication.shared.open(url) - } - }) { - Text("PrivacyPolicy") - .font(.system(size: 14)) - .foregroundColor(Color.accentColor.opacity(0.8)) - } } + + HStack(spacing: 8) { + Text("SearchingForDevices") + .font(.system(size: 14)) + .foregroundColor(Color.primary.opacity(0.6)) + + ProgressView() + .frame(width: 12, height: 12) + } + .padding(.vertical, 16) } + .navigationBarItems(trailing: LUISettingsButton()) } } @@ -146,5 +138,7 @@ extension DeviceListView { } #Preview { - DeviceListView.preview + NavigationView { + DeviceListView.preview + } } diff --git a/QuickDropiOS/FilePicker.swift b/QuickDropiOS/FilePicker.swift new file mode 100644 index 0000000..4a71855 --- /dev/null +++ b/QuickDropiOS/FilePicker.swift @@ -0,0 +1,169 @@ +// +// FilePicker.swift +// QuickDrop +// +// Created by Leon Böttger on 15.08.25. +// + +import SwiftUI +import PhotosUI +import LUI +import UniformTypeIdentifiers + +struct SendPickerButton: View { + var label: () -> Label + var onResult: (_ urls: [URL]?, _ text: String?) -> Void + + @State private var showPhotoPicker = false + @State private var selectedPhotoItems: [PhotosPickerItem] = [] + + @State private var showFilePicker = false + + var body: some View { + Menu { + Button("SendImages", systemImage: "photo") { + showPhotoPicker = true + } + Button("SendFiles", systemImage: "folder") { + showFilePicker = true + } + Button("SendClipboard", systemImage: "doc.on.clipboard") { + if let clipboardText = UIPasteboard.general.string, !clipboardText.isEmpty { + onResult(nil, clipboardText) + } else { + showAlert(title: "ClipboardEmpty", message: "ClipboardEmptyMessage") + } + } + } label: { + label() + } + .photosPicker( + isPresented: $showPhotoPicker, + selection: $selectedPhotoItems, + maxSelectionCount: nil, + matching: .any(of: [.images, .videos]) + ) + .onChange(of: selectedPhotoItems) { newItems in + guard !newItems.isEmpty else { return } + updatedPhotoSelection(content: newItems) + } + // File picker + .fileImporter(isPresented: $showFilePicker, + allowedContentTypes: [.item, .folder], // any file + allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): + onResult(urls, nil) + case .failure: + onResult(nil, nil) + } + } + } + + + func updatedPhotoSelection(content: [PhotosPickerItem]) { + Task { + do { + let urls = try await fetchContentUrls(content: content) + onResult(urls, nil) + selectedPhotoItems = [] + } catch { + log("Could not fetch photo URLs: \(error)") + } + } + } + + + func fetchContentUrls(content: [PhotosPickerItem]) async throws -> [URL] { + try await withThrowingTaskGroup(of: URL?.self, returning: [URL].self) { group in + let fileManager = FileManager.default + + for item in content { + group.addTask { + if let dataUrl = try await item.loadTransferable(type: Data.self) { + + if let contentType = item.supportedContentTypes.first { + // Step 2: make the URL file name and a get a file extention. + let url = fileManager.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).\(contentType.preferredFilenameExtension ?? "")") + + do { + // Step 3: write to temp App file directory and return in completionHandler + try dataUrl.write(to: url) + return url + } catch { + log("Faied to write data to file: \(error)") + throw error + } + + } + +// let destination = fileManager.temporaryDirectory.appendingPathComponent(dataUrl.url.lastPathComponent) +// +// // overwrite if file exists +// if fileManager.fileExists(atPath: destination.path) { +// try fileManager.removeItem(at: destination) +// } +// +// log("Copying photo at \(dataUrl.url) to \(destination)") +// +// do { +// try fileManager.copyItem(at: dataUrl.url, to: destination) +// } +// catch { +// log("Failed to copy photo: \(error)") +// throw error +// } +// return destination + } + return nil + } + } + + var finalUrls = [URL]() + for try await result in group { + if let url = result { + finalUrls.append(url) + } + } + + return finalUrls + } + } +} + + +/// File representation +fileprivate struct DataUrl: Transferable { + let url: URL + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(contentType: .data) { data in + + log("Received data: \(data.url)") + return SentTransferredFile(data.url) + } importing: { received in + + log("Importing file: \(received.file)") + return Self(url: received.file) + } + } +} + + +#Preview { + SendPickerButton { + Text("Send Files") + .font(.headline) + .padding() + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + } onResult: { urls, text in + if let urls = urls { + print("Selected URLs: \(urls)") + } + if let text = text { + print("Clipboard text: \(text)") + } + } +} diff --git a/QuickDropiOS/Model.swift b/QuickDropiOS/Model.swift index 724bbd2..0e113c7 100644 --- a/QuickDropiOS/Model.swift +++ b/QuickDropiOS/Model.swift @@ -16,8 +16,8 @@ class ShareViewModel: ObservableObject, ShareExtensionDelegate { @Published var progressState: String? = nil @Published var progressValue: Double? = nil - private var urls: [URL] = [] - private var textToSend = "Test from QuickDrop iOS" + var urls: [URL] = [] + var textToSend: String? = nil //private var errorAlertHandler = ErrorAlertHandler.shared private var connectionEstablished = false @@ -70,8 +70,6 @@ class ShareViewModel: ObservableObject, ShareExtensionDelegate { } func selectDevice(device: RemoteDeviceInfo) { - NearbyConnectionManager.shared.stopDeviceDiscovery() - progressValue = 0 progressState = "Connecting".localized() selectedDevice = device diff --git a/QuickDropiOS/QuickDropiOS.entitlements b/QuickDropiOS/QuickDropiOS.entitlements index ec706aa..dfc9800 100644 --- a/QuickDropiOS/QuickDropiOS.entitlements +++ b/QuickDropiOS/QuickDropiOS.entitlements @@ -4,5 +4,9 @@ com.apple.developer.networking.multicast + com.apple.security.application-groups + + group.com.leonboettger.neardrop + diff --git a/QuickDropiOS/QuickDropiOSApp.swift b/QuickDropiOS/QuickDropiOSApp.swift index c035f67..b46fe4e 100644 --- a/QuickDropiOS/QuickDropiOSApp.swift +++ b/QuickDropiOS/QuickDropiOSApp.swift @@ -6,9 +6,19 @@ // import SwiftUI +import LUI @main struct QuickDropiOSApp: App { + + #if !os(macOS) + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + #endif + + init() { + LUIInit(configuration: configuration) + } + var body: some Scene { WindowGroup { ContentView() diff --git a/ShareExtension/ShareViewController.swift b/ShareExtension/ShareViewController.swift index 59de20b..0e7a5df 100644 --- a/ShareExtension/ShareViewController.swift +++ b/ShareExtension/ShareViewController.swift @@ -56,7 +56,7 @@ class ShareViewController: NSViewController, ShareExtensionDelegate { textToSend = text DispatchQueue.main.async { - self.zipFolderAndSetUpIcon() + self.setUpIcon() } } else { for attachment in attachments { @@ -72,7 +72,7 @@ class ShareViewController: NSViewController, ShareExtensionDelegate { if self.urls.count == attachments.count { DispatchQueue.main.async { - self.zipFolderAndSetUpIcon() + self.setUpIcon() } } } @@ -209,42 +209,7 @@ class ShareViewController: NSViewController, ShareExtensionDelegate { } - private func zipFolderAndSetUpIcon() { - for url in urls { - if url.isFileURL { - let isDirectory = UnsafeMutablePointer.allocate(capacity: 1) - if FileManager.default.fileExists(atPath: url.path, isDirectory: isDirectory) && isDirectory.pointee.boolValue { - do { - let zipUrl = try createZipAtTmp(zipFilename: url.lastPathComponent, fromDirectory: url) - - let index = urls.firstIndex(of: url) - urls[index!] = zipUrl - } catch { - log("Error creating zip file: \(error)") - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - log("Canceling share request because URL \(url) is a directory") - - AudioManager.playErrorSound() - let alert = NSAlert() - alert.alertStyle = .critical - - alert.messageText = "TypeNotSupported".localized() - alert.informativeText = "TypeNotSupportedDescription".localized() - alert.addButton(withTitle: "TypeNotSupportedButton".localized()) - - alert.beginSheetModal(for: self.view.window!) { _ in - let cancelError = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil) - self.extensionContext!.cancelRequest(withError: cancelError) - } - } - - return - } - } - } - } - + private func setUpIcon() { if let textToSend = textToSend { let maxLength = 50 let cleanedText = textToSend.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: "") diff --git a/ShareExtension/Util/Zip.swift b/ShareExtension/Util/Zip.swift index d1c81d4..79ff9c7 100644 --- a/ShareExtension/Util/Zip.swift +++ b/ShareExtension/Util/Zip.swift @@ -7,41 +7,57 @@ import Foundation -enum CreateZipError: Swift.Error { - case urlNotADirectory(URL) - case failedToCreateZIP(Swift.Error) -} - - -func createZip(zipFinalURL: URL, fromDirectory directoryURL: URL) throws -> URL { - - guard directoryURL.isDirectory else { - throw CreateZipError.urlNotADirectory(directoryURL) +class Zip { + + static func createAtTemporaryDirectory(zipFilename: String, zipExtension: String = "zip", fromDirectory directoryURL: URL) throws -> URL { + + let finalURL = FileManager.default.temporaryDirectory + .appendingPathComponent(zipFilename) + .appendingPathExtension(zipExtension) + + return try createAt( + zipFinalURL: finalURL, + fromDirectory: 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 + + 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 } - if let error = coordinatorError ?? fileManagerError { - throw CreateZipError.failedToCreateZIP(error) - } - return zipFinalURL + enum CreateZipError: Swift.Error { + case urlNotADirectory(URL) + case failedToCreateZIP(Swift.Error) + } } @@ -50,16 +66,3 @@ extension URL { (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true } } - - -func createZipAtTmp(zipFilename: String, zipExtension: String = "zip", fromDirectory directoryURL: URL) throws -> URL { - - let finalURL = FileManager.default.temporaryDirectory - .appendingPathComponent(zipFilename) - .appendingPathExtension(zipExtension) - - return try createZip( - zipFinalURL: finalURL, - fromDirectory: directoryURL - ) -}