Add sending files, images, and clipboard to iOS app

This commit is contained in:
Leon Böttger committed 2025-08-15 11:29:40 +02:00
1 parent 045725072b
commit c781af554f
14 files changed
+802 -231

No files matched your search

@@ -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 {
+10
View File
@@ -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 = "<group>"; };
EB7F54132D78E9E400989564 /* LicenseWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseWindow.swift; sourceTree = "<group>"; };
EB886E512E4E19390052B7D9 /* HKDF.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HKDF.swift; sourceTree = "<group>"; };
EB886E662E4F2F7C0052B7D9 /* License.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = License.swift; sourceTree = "<group>"; };
EB96ED8A2D7F7CF4004B3FF1 /* SmallSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmallSheetView.swift; sourceTree = "<group>"; };
EB96ED8E2D7F8103004B3FF1 /* Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Localized.swift; sourceTree = "<group>"; };
EB99257F2E45D53E00357055 /* sharing_enums.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = sharing_enums.pb.swift; sourceTree = "<group>"; };
@@ -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 */,
@@ -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"
}
},
{
+321
View File
@@ -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" : {
+18
View File
@@ -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"
}
}
+1 -8
View File
@@ -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)
}
}
}
+26
View File
@@ -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
)
+87 -93
View File
@@ -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
}
}
+169
View File
@@ -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<Label: View>: 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)")
}
}
}
+2 -4
View File
@@ -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
+4
View File
@@ -4,5 +4,9 @@
<dict>
<key>com.apple.developer.networking.multicast</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.leonboettger.neardrop</string>
</array>
</dict>
</plist>
+10
View File
@@ -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()
+3 -38
View File
@@ -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<ObjCBool>.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: "")
+45 -42
View File
@@ -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
)
}