diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index 71e2eef..f59b935 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -7,6 +7,13 @@ objects = { /* Begin PBXBuildFile section */ + FD0000000000000000000002 /* FileDropModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0000000000000000000001 /* FileDropModels.swift */; }; + FD0000000000000000000003 /* FileDropModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0000000000000000000001 /* FileDropModels.swift */; }; + FD0000000000000000000004 /* FileDropModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0000000000000000000001 /* FileDropModels.swift */; }; + FD0000000000000000000005 /* FileDropModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0000000000000000000001 /* FileDropModels.swift */; }; + FD0000000000000000000007 /* FilesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0000000000000000000006 /* FilesViewModel.swift */; }; + FD0000000000000000000009 /* iOSFilesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0000000000000000000008 /* iOSFilesView.swift */; }; + FD0000000000000000000011 /* FileSendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0000000000000000000010 /* FileSendView.swift */; }; 0C71C019350D42AB8F110336 /* iOSSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A4B98A775451786C5DDF8 /* iOSSettingsView.swift */; }; 1C4E6A81CD33FF6D2DEFF8D5 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 91FA1F06D3375864C74EAB3B /* Foundation.framework */; }; AA000C012F22000C00000001 /* NetBirdSDK.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50D402932BD9143900D4AC5B /* NetBirdSDK.xcframework */; }; @@ -290,6 +297,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + FD0000000000000000000001 /* FileDropModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileDropModels.swift; sourceTree = ""; }; + FD0000000000000000000006 /* FilesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesViewModel.swift; sourceTree = ""; }; + FD0000000000000000000008 /* iOSFilesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSFilesView.swift; sourceTree = ""; }; + FD0000000000000000000010 /* FileSendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSendView.swift; sourceTree = ""; }; 2F8A4B98A775451786C5DDF8 /* iOSSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSSettingsView.swift; sourceTree = ""; }; 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlobalConstantsTests.swift; sourceTree = ""; }; 42873052F9544A89B1408339 /* iOSPeersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSPeersView.swift; sourceTree = ""; }; @@ -626,6 +637,7 @@ 50C727EA2A82479B006E898D /* NetbirdKit */ = { isa = PBXGroup; children = ( + FD0000000000000000000001 /* FileDropModels.swift */, 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */, A7C0DE002F9A0001001A2B3C /* GoCrashCapture.swift */, F1B292092EE0BC40001D91B8 /* GlobalConstants.swift */, @@ -667,6 +679,7 @@ 50E608012A7950C000BAF09B /* ViewModels */ = { isa = PBXGroup; children = ( + FD0000000000000000000006 /* FilesViewModel.swift */, F1258DE12ED4EE4900C0D205 /* ServerViewModel.swift */, 50E608122A7958B100BAF09B /* MainViewModel.swift */, 50D402962BD9B89300D4AC5B /* PeerViewModel.swift */, @@ -740,6 +753,8 @@ A35A69207D60447AA6465276 /* iOS */ = { isa = PBXGroup; children = ( + FD0000000000000000000010 /* FileSendView.swift */, + FD0000000000000000000008 /* iOSFilesView.swift */, 8DE6A20955914E68BFADDEED /* iOSConnectionView.swift */, 42873052F9544A89B1408339 /* iOSPeersView.swift */, C7A1CFF65CC44187912007EC /* iOSNetworksView.swift */, @@ -1083,6 +1098,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD0000000000000000000004 /* FileDropModels.swift in Sources */, 445B5F752EECAF01008932B8 /* GlobalConstants.swift in Sources */, A1C3D5E92F000003001A2B3C /* WiFiOnDemandPolicy.swift in Sources */, A1C3D5ED2F000007001A2B3C /* CellularOnDemandPolicy.swift in Sources */, @@ -1124,6 +1140,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD0000000000000000000005 /* FileDropModels.swift in Sources */, 44F3E3982EE2F89200C87FEC /* NetworkChangeListener.swift in Sources */, 44F3E38B2EE214D300C87FEC /* PacketTunnelProviderSettingsManager.swift in Sources */, 445B5F742EECAE4E008932B8 /* EnvVarPackager.swift in Sources */, @@ -1152,6 +1169,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD0000000000000000000003 /* FileDropModels.swift in Sources */, 50CD81632AD0595E00CF830B /* DNSManager.swift in Sources */, 50C5D3102BDD96CF003159BE /* RoutesSelectionDetails.swift in Sources */, 50C727ED2A824C10006E898D /* NetBirdAdapter.swift in Sources */, @@ -1181,6 +1199,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FD0000000000000000000011 /* FileSendView.swift in Sources */, + FD0000000000000000000009 /* iOSFilesView.swift in Sources */, + FD0000000000000000000007 /* FilesViewModel.swift in Sources */, + FD0000000000000000000002 /* FileDropModels.swift in Sources */, 50CD84362AD82F9400CF830B /* ServerView.swift in Sources */, 50C5D30F2BDD96CF003159BE /* RoutesSelectionDetails.swift in Sources */, 50A8911D2A792A15007C48FC /* MainView.swift in Sources */, diff --git a/NetBird/Info.plist b/NetBird/Info.plist index 9040594..51b9486 100644 --- a/NetBird/Info.plist +++ b/NetBird/Info.plist @@ -7,6 +7,10 @@ inter-variable.ttf jetbrains-mono-variable.ttf + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + CFBundleURLTypes diff --git a/NetBird/Source/App/NetBirdApp.swift b/NetBird/Source/App/NetBirdApp.swift index 1bbc420..0e45514 100644 --- a/NetBird/Source/App/NetBirdApp.swift +++ b/NetBird/Source/App/NetBirdApp.swift @@ -79,6 +79,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele let center = UNUserNotificationCenter.current() center.delegate = self + center.setNotificationCategories([AppDelegate.fileDropOfferCategory()]) center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in if let error = error { AppLogger.shared.log("Notification authorization error: \(error.localizedDescription)") @@ -108,8 +109,49 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele if response.notification.request.identifier == GlobalConstants.notificationLoginRequired { NotificationCenter.default.post(name: .netbirdLoginNotificationTapped, object: nil) } + handleFileDropResponse(response) completionHandler() } + + /// Accept and Decline on an incoming offer, so answering does not require + /// opening the app first. The category lives here rather than in the + /// extension: categories registered from a Network Extension are not + /// reliably picked up by the system. + private static func fileDropOfferCategory() -> UNNotificationCategory { + let accept = UNNotificationAction( + identifier: FileDropNotification.acceptAction, + title: NSLocalizedString("file_drop_accept", value: "Accept", comment: ""), + options: []) + let decline = UNNotificationAction( + identifier: FileDropNotification.declineAction, + title: NSLocalizedString("file_drop_decline", value: "Decline", comment: ""), + options: [.destructive]) + + return UNNotificationCategory( + identifier: FileDropNotification.offerCategory, + actions: [accept, decline], + intentIdentifiers: [], + options: []) + } + + private func handleFileDropResponse(_ response: UNNotificationResponse) { + let verb: String + switch response.actionIdentifier { + case FileDropNotification.acceptAction: + verb = "Accept" + case FileDropNotification.declineAction: + verb = "Decline" + default: + return + } + + guard let id = response.notification.request.content + .userInfo[FileDropNotification.transferIDKey] as? String, !id.isEmpty else { return } + + DispatchQueue.main.async { + FilesViewModel.shared.answerFromNotification(verb: verb, id: id) + } + } } #endif @@ -144,6 +186,10 @@ struct NetBirdApp: App { handleWidgetURL(url, viewModel: viewModel) } .onAppear { + // Bound as early as the view model exists, so an answer + // queued by a notification action is released before the + // Files tab is ever opened. + FilesViewModel.shared.bind(adapter: viewModel.networkExtensionAdapter) if let url = pendingURL { handleWidgetURL(url, viewModel: viewModel) pendingURL = nil diff --git a/NetBird/Source/App/ViewModels/FilesViewModel.swift b/NetBird/Source/App/ViewModels/FilesViewModel.swift new file mode 100644 index 0000000..053d8bc --- /dev/null +++ b/NetBird/Source/App/ViewModels/FilesViewModel.swift @@ -0,0 +1,520 @@ +// +// FilesViewModel.swift +// NetBird +// +// Application-side view of the file drop state. All reads and writes go +// through the extension when its provider session answers, since the engine +// and the authoritative transfer state live there; when the extension is not +// running (tunnel down), the same persisted state is read directly through +// NetBirdSDK so history and settings stay visible offline. +// + +#if os(iOS) + +import Foundation +import Combine +import UIKit +import NetBirdSDK +import UniformTypeIdentifiers + +class FilesViewModel: ObservableObject { + + /// One instance backs the Files tab, the send sheet, the settings screen and + /// the notification actions, so they share a single poller and one view of + /// the receiving mode instead of each holding a copy that drifts. + static let shared = FilesViewModel() + + @Published var transfers: [FileDropTransferInfo] = [] + @Published var searchText: String = "" + @Published var mode: FileDropMode = .ask + + private weak var adapter: NetworkExtensionAdapter? + private var timer: Timer? + private var polling = false + private var lifecycleObservers: [NSObjectProtocol] = [] + private var queuedAnswers: [(verb: String, id: String)] = [] + private let directQueue = DispatchQueue(label: "io.netbird.filedrop.direct") + + static let outboxSubdir = "filedrop-outbox" + + init() { + observeLifecycle() + } + + deinit { + lifecycleObservers.forEach { NotificationCenter.default.removeObserver($0) } + } + + func bind(adapter: NetworkExtensionAdapter) { + self.adapter = adapter + + let queued = queuedAnswers + queuedAnswers = [] + for answer in queued { + action(answer.verb, id: answer.id) + } + } + + // MARK: - Derived rows + + var filteredTransfers: [FileDropTransferInfo] { + let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() + guard !query.isEmpty else { return transfers } + return transfers.filter { matches($0, query: query) } + } + + /// Incoming offers waiting for an answer, pinned above the log. + var pendingOffers: [FileDropTransferInfo] { + filteredTransfers.filter { $0.isAnswerable } + } + + /// The transfer log grouped by day, newest first, offers excluded. + var dayGroups: [(label: String, items: [FileDropTransferInfo])] { + var groups: [(label: String, items: [FileDropTransferInfo])] = [] + for transfer in filteredTransfers where !transfer.isAnswerable { + let label = FilesViewModel.dayLabel(for: transfer.createdAt) + if let last = groups.indices.last, groups[last].label == label { + groups[last].items.append(transfer) + } else { + groups.append((label: label, items: [transfer])) + } + } + return groups + } + + private func matches(_ transfer: FileDropTransferInfo, query: String) -> Bool { + if transfer.peerName.lowercased().contains(query) { return true } + if transfer.isText && transfer.text.lowercased().contains(query) { return true } + return transfer.fileNames.contains { $0.lowercased().contains(query) } + } + + static func dayLabel(for date: Date?) -> String { + guard let date = date else { + return NSLocalizedString("file_drop_group_earlier", value: "Earlier", comment: "") + } + let calendar = Calendar.current + if calendar.isDateInToday(date) { + return NSLocalizedString("file_drop_group_today", value: "Today", comment: "") + } + if calendar.isDateInYesterday(date) { + return NSLocalizedString("file_drop_group_yesterday", value: "Yesterday", comment: "") + } + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: date) + } + + // MARK: - Refresh & polling + + func refresh() { + if let adapter = adapter { + adapter.fileDropTransfers { [weak self] list in + if let list = list { + DispatchQueue.main.async { self?.apply(list) } + } else { + self?.refreshDirect() + } + } + } else { + refreshDirect() + } + cleanupOutbox() + } + + private func apply(_ list: [FileDropTransferInfo]) { + transfers = list + relocateDelivered(list) + } + + func refreshMode() { + if let adapter = adapter { + adapter.fileDropGetMode { [weak self] value in + if let value = value, let mode = FileDropMode(rawValue: value) { + DispatchQueue.main.async { self?.mode = mode } + } else { + self?.refreshModeDirect() + } + } + } else { + refreshModeDirect() + } + } + + func setMode(_ newMode: FileDropMode) { + mode = newMode + if let adapter = adapter { + adapter.fileDropSetMode(newMode.rawValue) { [weak self] ok in + if !ok { + self?.setModeDirect(newMode) + } + } + } else { + setModeDirect(newMode) + } + } + + /// The log is polled rather than pushed, so the interval follows what is + /// actually happening: a transfer in flight moves a progress bar every + /// couple of seconds, while an idle log only has to notice a new offer. + /// Polling stops outside the foreground entirely, since waking the + /// extension for a list nobody is looking at is pure battery cost. + private static let activeInterval: TimeInterval = 2 + private static let idleInterval: TimeInterval = 8 + + func startPolling() { + polling = true + refresh() + refreshMode() + scheduleTick() + } + + func stopPolling() { + polling = false + timer?.invalidate() + timer = nil + } + + private func scheduleTick() { + timer?.invalidate() + guard polling else { return } + + let interval = transfers.contains { !$0.isTerminal } + ? FilesViewModel.activeInterval + : FilesViewModel.idleInterval + + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + self?.refresh() + self?.scheduleTick() + } + } + + /// Backgrounding only parks the timer; `polling` stays set so returning to + /// the foreground resumes without the screen having to reappear. + private func observeLifecycle() { + let center = NotificationCenter.default + + lifecycleObservers.append(center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, queue: .main) { [weak self] _ in + self?.timer?.invalidate() + self?.timer = nil + }) + + lifecycleObservers.append(center.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, queue: .main) { [weak self] _ in + guard let self = self, self.polling else { return } + self.refresh() + self.scheduleTick() + }) + } + + // MARK: - Actions + + func accept(_ transfer: FileDropTransferInfo) { accept(id: transfer.id) } + func decline(_ transfer: FileDropTransferInfo) { decline(id: transfer.id) } + func stop(_ transfer: FileDropTransferInfo) { action("Stop", id: transfer.id) } + func remove(_ transfer: FileDropTransferInfo) { action("Remove", id: transfer.id) } + + /// Answering by ID alone: a notification action carries the transfer ID and + /// nothing else, and the log may not even be loaded when it arrives. + func accept(id: String) { action("Accept", id: id) } + func decline(id: String) { action("Decline", id: id) } + + /// A notification action can land before the app has an adapter at all, on + /// a cold launch straight into Accept. Those answers wait for the binding + /// instead of falling back to a second handle on the same store, which the + /// running engine would never see. + func answerFromNotification(verb: String, id: String) { + guard adapter != nil else { + queuedAnswers.append((verb: verb, id: id)) + return + } + action(verb, id: id) + } + + private func action(_ verb: String, id: String) { + if let adapter = adapter { + adapter.fileDropAction(verb, id: id) { [weak self] ok in + if !ok { + self?.actionDirect(verb, id: id) + } else { + self?.refresh() + } + } + } else { + actionDirect(verb, id: id) + } + } + + // MARK: - Sending + + /// Starts a transfer through the extension and reports the transfer ID the + /// row can follow. Sending needs the engine, so there is no direct path. + func send(_ request: FileDropSendRequest, completion: @escaping (Result) -> Void) { + guard let adapter = adapter else { + completion(.failure(FilesError.notRunning)) + return + } + adapter.fileDropSend(request) { response in + DispatchQueue.main.async { + guard let response = response else { + completion(.failure(FilesError.notRunning)) + return + } + if !response.error.isEmpty { + completion(.failure(FilesError.sendFailed(response.error))) + return + } + completion(.success(response.transferID)) + } + } + } + + enum FilesError: LocalizedError { + case notRunning + case sendFailed(String) + case unreadable + + var errorDescription: String? { + switch self { + case .notRunning: + return NSLocalizedString("file_drop_share_not_running", value: "NetBird is not running", comment: "") + case .sendFailed(let message): + return message + case .unreadable: + return NSLocalizedString("file_drop_share_unreadable", value: "The selected files could not be read", comment: "") + } + } + } + + // MARK: - Outgoing staging + + /// Copies the picked files into the shared outbox so the extension can read + /// them by path for as long as the upload runs: the picker's own access to + /// the originals ends with the sheet, and the extension could never reach + /// them anyway. + static func stageFiles(_ urls: [URL]) -> [FileDropSendFile] { + guard let container = sharedContainerURL() else { return [] } + let batchDir = container + .appendingPathComponent(outboxSubdir, isDirectory: true) + .appendingPathComponent(UUID().uuidString, isDirectory: true) + + let fileManager = FileManager.default + guard (try? fileManager.createDirectory(at: batchDir, withIntermediateDirectories: true)) != nil else { + return [] + } + + var staged: [FileDropSendFile] = [] + for url in urls { + let secured = url.startAccessingSecurityScopedResource() + defer { if secured { url.stopAccessingSecurityScopedResource() } } + + let destination = batchDir.appendingPathComponent(url.lastPathComponent) + do { + try fileManager.copyItem(at: url, to: destination) + } catch { + continue + } + + let attrs = try? fileManager.attributesOfItem(atPath: destination.path) + let size = (attrs?[.size] as? NSNumber)?.int64Value ?? 0 + staged.append(FileDropSendFile( + name: url.lastPathComponent, + size: size, + contentType: contentType(for: url), + path: destination.path + )) + } + return staged + } + + static func contentType(for url: URL) -> String { + if let type = UTType(filenameExtension: url.pathExtension), + let mime = type.preferredMIMEType { + return mime + } + return "application/octet-stream" + } + + /// Staged copies must outlive the sheet that created them, so they are + /// swept by age instead of on dismiss: a day is far beyond any plausible + /// transfer, while a crashed send never strands its copies forever. + private func cleanupOutbox() { + directQueue.async { + guard let container = FilesViewModel.sharedContainerURL() else { return } + let outbox = container.appendingPathComponent(FilesViewModel.outboxSubdir, isDirectory: true) + let fileManager = FileManager.default + guard let entries = try? fileManager.contentsOfDirectory( + at: outbox, includingPropertiesForKeys: [.contentModificationDateKey]) else { return } + + let cutoff = Date().addingTimeInterval(-24 * 60 * 60) + for entry in entries { + let modified = (try? entry.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + if let modified = modified, modified < cutoff { + try? fileManager.removeItem(at: entry) + } + } + } + } + + // MARK: - Delivered file relocation + + /// Received files land in the app group container, which nothing outside + /// the app can browse. Completed transfers are therefore moved into the + /// app's Documents directory, which Info.plist exposes in the Files app as + /// the NetBird folder, and the new locations are remembered per transfer + /// since the Go history keeps pointing at the old ones. + private static let relocatedKey = "netbird.filedrop.relocated" + + /// Returns where a transfer's received files live now: the relocated + /// copies when the move already happened, the original delivery paths + /// otherwise. + /// Paths that no longer resolve are dropped: a preview or a share sheet + /// handed a missing file shows an empty page rather than an error. + func deliveredURLs(for transfer: FileDropTransferInfo) -> [URL] { + let map = UserDefaults.standard.dictionary(forKey: FilesViewModel.relocatedKey) as? [String: [String]] ?? [:] + let paths = map[transfer.id] ?? transfer.deliveredPaths + return paths + .map { URL(fileURLWithPath: $0) } + .filter { FileManager.default.fileExists(atPath: $0.path) } + } + + /// True when the transfer has something a preview can actually open. + func isPreviewable(_ transfer: FileDropTransferInfo) -> Bool { + !transfer.outgoing && !transfer.isText + && transfer.transferState == .completed + && !deliveredURLs(for: transfer).isEmpty + } + + private func relocateDelivered(_ list: [FileDropTransferInfo]) { + directQueue.async { + let defaults = UserDefaults.standard + var map = defaults.dictionary(forKey: FilesViewModel.relocatedKey) as? [String: [String]] ?? [:] + let fileManager = FileManager.default + guard let documents = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + var changed = false + for transfer in list where !transfer.outgoing && !transfer.isText + && transfer.transferState == .completed && !transfer.deliveredPaths.isEmpty { + if map[transfer.id] != nil { continue } + + var moved: [String] = [] + for path in transfer.deliveredPaths { + let source = URL(fileURLWithPath: path) + guard fileManager.fileExists(atPath: source.path) else { continue } + let destination = FilesViewModel.uniqueDestination(for: source.lastPathComponent, in: documents) + do { + try fileManager.moveItem(at: source, to: destination) + moved.append(destination.path) + } catch { + AppLogger.shared.log("file drop relocation failed for \(source.lastPathComponent): \(error.localizedDescription)") + } + } + if !moved.isEmpty { + map[transfer.id] = moved + changed = true + } + } + + let ids = Set(list.map { $0.id }) + let pruned = map.filter { ids.contains($0.key) } + if pruned.count != map.count { + map = pruned + changed = true + } + + if changed { + defaults.set(map, forKey: FilesViewModel.relocatedKey) + } + } + } + + private static func uniqueDestination(for name: String, in directory: URL) -> URL { + let fileManager = FileManager.default + var candidate = directory.appendingPathComponent(name) + if !fileManager.fileExists(atPath: candidate.path) { + return candidate + } + + let base = (name as NSString).deletingPathExtension + let ext = (name as NSString).pathExtension + var counter = 2 + repeat { + let numbered = ext.isEmpty ? "\(base) (\(counter))" : "\(base) (\(counter)).\(ext)" + candidate = directory.appendingPathComponent(numbered) + counter += 1 + } while fileManager.fileExists(atPath: candidate.path) + return candidate + } + + // MARK: - Direct (extension not running) + + static func sharedContainerURL() -> URL? { + FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: GlobalConstants.userPreferencesSuiteName) + } + + private func withDirectHandle(_ work: @escaping (NetBirdSDKFileDrop) -> Void) { + directQueue.async { + guard let container = FilesViewModel.sharedContainerURL() else { return } + let profileID = ProfileManager.shared.getActiveProfileID() + var error: NSError? + guard let handle = NetBirdSDKNewFileDrop(container.path, profileID, &error) else { + if let error = error { + AppLogger.shared.log("file drop direct handle failed: \(error.localizedDescription)") + } + return + } + work(handle) + try? handle.close() + } + } + + private func refreshDirect() { + withDirectHandle { [weak self] handle in + guard let array = handle.transfers() else { return } + var list: [FileDropTransferInfo] = [] + for i in 0.. (NetBirdSDKAuth?, String?) in var error: NSError? - let authenticator = NetBirdSDKNewAuth(configPath, managementServerUrl, &error) + let authenticator = NetBirdSDKNewAuth(configPath, managementServerUrl, nil, &error) if let error = error { print(error.domain, error.code, error.description) diff --git a/NetBird/Source/App/Views/MainView.swift b/NetBird/Source/App/Views/MainView.swift index 06398f7..6af3f39 100644 --- a/NetBird/Source/App/Views/MainView.swift +++ b/NetBird/Source/App/Views/MainView.swift @@ -127,7 +127,7 @@ struct iOSMainView: View { viewModel.connectWithOnDemandDisabled() }, secondaryButton: .cancel(Text("Edit Rules")) { - selectedTab = 3 // Switch to Settings tab + selectedTab = 4 // Switch to Settings tab } ) case .onDemandDisconnect: @@ -175,6 +175,15 @@ struct iOSMainView: View { } .tag(2) + NavigationView { + iOSFilesView() + } + .navigationViewStyle(StackNavigationViewStyle()) + .tabItem { + Label("Files", systemImage: "folder.fill") + } + .tag(3) + NavigationView { iOSSettingsView() } @@ -182,7 +191,7 @@ struct iOSMainView: View { .tabItem { Label("Settings", systemImage: "gear") } - .tag(3) + .tag(4) } .onChange(of: viewModel.navigateToServerView) { newValue in if newValue { diff --git a/NetBird/Source/App/Views/PeerTabView.swift b/NetBird/Source/App/Views/PeerTabView.swift index dc6f2b7..53e96f3 100644 --- a/NetBird/Source/App/Views/PeerTabView.swift +++ b/NetBird/Source/App/Views/PeerTabView.swift @@ -81,10 +81,29 @@ struct NoPeersView: View { } } +/// One sheet slot for the peer list: two `.sheet(item:)` modifiers on the same +/// view are not reliably presented, so the detail and the send sheet share one +/// piece of state. +enum PeerSheet: Identifiable { + case detail(PeerInfo) + #if os(iOS) + case send(PeerInfo) + #endif + + var id: String { + switch self { + case .detail(let peer): return "detail-\(peer.id)" + #if os(iOS) + case .send(let peer): return "send-\(peer.id)" + #endif + } + } +} + struct PeerListView: View { @ObservedObject var peerViewModel: PeerViewModel @ObservedObject var viewModel: ViewModel - @State private var selectedPeer: PeerInfo? + @State private var sheet: PeerSheet? var body: some View { List { @@ -92,10 +111,21 @@ struct PeerListView: View { PeerCard(peer: peer) .contentShape(Rectangle()) .onTapGesture { - selectedPeer = peer + sheet = .detail(peer) } #if os(iOS) .contextMenu { + // Sending from the peer itself, the way the Android + // client offers it on the peer's own screen. + if !peer.pubKey.isEmpty && !peer.ip.isEmpty { + Button { + FilesViewModel.shared.bind(adapter: viewModel.networkExtensionAdapter) + sheet = .send(peer) + } label: { + Label("Send files", systemImage: "square.and.arrow.up") + } + } + if !peer.fqdn.isEmpty { Button("Copy FQDN") { UIPasteboard.general.string = peer.fqdn @@ -124,8 +154,16 @@ struct PeerListView: View { .listRowBackground(Color("BgMenu")) } .listStyle(.plain) - .sheet(item: $selectedPeer) { peer in - PeerDetailSheet(peer: peer) + .sheet(item: $sheet) { item in + switch item { + case .detail(let peer): + PeerDetailSheet(peer: peer) + #if os(iOS) + case .send(let peer): + FileSendView(filesVM: FilesViewModel.shared, presetPeer: peer.fqdn) + .environmentObject(viewModel) + #endif + } } } } diff --git a/NetBird/Source/App/Views/iOS/FileSendView.swift b/NetBird/Source/App/Views/iOS/FileSendView.swift new file mode 100644 index 0000000..72eac15 --- /dev/null +++ b/NetBird/Source/App/Views/iOS/FileSendView.swift @@ -0,0 +1,453 @@ +// +// FileSendView.swift +// NetBird +// +// The send sheet: pick what to share, then send it to one or more peers. The +// screen stays put so the same content can be fanned out, each row tracking +// its own transfer. +// +// Sending is an explicit button on the row rather than a tap on the row +// itself: on iOS a tap is a navigation gesture, and a send is neither +// navigation nor something to trigger by accident. +// + +import SwiftUI +import UniformTypeIdentifiers + +#if os(iOS) + +struct FileSendView: View { + @EnvironmentObject var viewModel: ViewModel + @ObservedObject var filesVM: FilesViewModel + @Environment(\.dismiss) private var dismiss + + /// A sheet opened from a peer's own row carries that peer into the picker. + let presetPeer: String? + + init(filesVM: FilesViewModel, presetPeer: String? = nil) { + self.filesVM = filesVM + self.presetPeer = presetPeer + _peerQuery = State(initialValue: presetPeer ?? "") + } + + enum ContentKind: String, CaseIterable { + case files + case text + + var label: LocalizedStringKey { + switch self { + case .files: return "Files" + case .text: return "Text" + } + } + } + + @State private var contentKind: ContentKind = .files + @State private var stagedFiles: [FileDropSendFile] = [] + @State private var stagingFailed = false + @State private var text: String = "" + @State private var peerQuery: String + @State private var showFilePicker = false + @State private var rowStates: [String: SendRowState] = [:] + @State private var targetToStop: SendTarget? = nil + + struct SendStatus { + let text: String + let color: Color + var systemImage: String? = nil + } + + struct SendRowState { + var transferID: String? = nil + var waiting = false + var failure: String? = nil + /// Set once the transfer list has shown this transfer. Until then an + /// ID with no matching entry means the list is still catching up, not + /// that the transfer is gone. + var seen = false + } + + struct SendTarget: Identifiable { + let pubKey: String + let name: String + let ip: String + let connected: Bool + + var id: String { pubKey } + + func matches(_ query: String) -> Bool { + let q = query.trimmingCharacters(in: .whitespaces).lowercased() + if q.isEmpty { return true } + return name.lowercased().contains(q) || ip.contains(q) + } + } + + var body: some View { + NavigationView { + List { + contentSection + peerSection + } + .listStyle(InsetGroupedListStyle()) + .navigationTitle("Send to") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $peerQuery, placement: .navigationBarDrawer(displayMode: .always), prompt: "Search peers") + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + .onChange(of: filesVM.transfers) { transfers in + let ids = Set(transfers.map { $0.id }) + for (key, state) in rowStates where !state.seen { + if let id = state.transferID, ids.contains(id) { + rowStates[key]?.seen = true + } + } + } + .fileImporter(isPresented: $showFilePicker, + allowedContentTypes: [.item], + allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): + DispatchQueue.global(qos: .userInitiated).async { + let staged = FilesViewModel.stageFiles(urls) + DispatchQueue.main.async { + stagingFailed = staged.isEmpty && !urls.isEmpty + stagedFiles = staged + } + } + case .failure: + stagingFailed = true + } + } + .confirmationDialog( + targetToStop?.name ?? "", + isPresented: Binding( + get: { targetToStop != nil }, + set: { if !$0 { targetToStop = nil } } + ), + titleVisibility: .visible, + presenting: targetToStop + ) { target in + Button("Stop", role: .destructive) { stopSending(to: target) } + Button("Cancel", role: .cancel) {} + } message: { _ in + Text("Stop sending to this peer?") + } + } + + // MARK: - Content selection + + private var contentSection: some View { + Section(header: Text("What to send")) { + Picker("", selection: $contentKind) { + ForEach(ContentKind.allCases, id: \.self) { kind in + Text(kind.label).tag(kind) + } + } + .pickerStyle(SegmentedPickerStyle()) + .listRowSeparator(.hidden) + + switch contentKind { + case .files: + Button { + showFilePicker = true + } label: { + HStack { + Image(systemName: "doc.badge.plus") + // The summary is composed from file names and a byte + // count, so it is shown verbatim rather than looked up. + if stagedFiles.isEmpty { + Text("Choose files") + } else { + Text(verbatim: filesSummary) + .lineLimit(2) + } + Spacer() + } + .foregroundColor(Color("TextPrimary")) + } + .buttonStyle(.borderless) + + if stagingFailed { + Text("The selected files could not be read") + .font(.footnote) + .foregroundColor(Color(.systemRed)) + } + case .text: + // Multi-line: what people send is usually a snippet, not a word, + // and a one-line field hides everything past its width. + TextEditor(text: $text) + .frame(minHeight: 88) + .font(.body) + + // Same treatment as "Choose files" on the other tab: a row + // action in the text colour, not an accent-tinted link. + Button { + paste() + } label: { + Label("Paste from clipboard", systemImage: "doc.on.clipboard") + .foregroundColor(Color("TextPrimary")) + } + .buttonStyle(.borderless) + .disabled(!UIPasteboard.general.hasStrings) + } + } + } + + private var filesSummary: String { + let totalSize = stagedFiles.reduce(Int64(0)) { $0 + $1.size } + let sizeLabel = FileDropFormat.size(totalSize) + if stagedFiles.count == 1 { + return "\(stagedFiles[0].name) · \(sizeLabel)" + } + let format = NSLocalizedString("file_drop_send_files_summary", value: "%1$d files · %2$@", + comment: "file count, total size") + return String(format: format, stagedFiles.count, sizeLabel) + } + + private var hasContent: Bool { + switch contentKind { + case .files: return !stagedFiles.isEmpty + case .text: return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } + + private func paste() { + guard let clipboard = UIPasteboard.general.string, + !clipboard.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + text = clipboard + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + // MARK: - Peers + + /// Deliberately not filtered on connection status: an idle peer is the + /// normal resting state under lazy connections, and the transfer's own + /// packets are what wake it. Connected peers first, then by name, so the + /// same peer sits in the same place as on the Peers screen. + private var targets: [SendTarget] { + viewModel.peerViewModel.peerInfo + .filter { !$0.ip.isEmpty } + .map { + SendTarget(pubKey: $0.pubKey, + name: $0.fqdn, + ip: $0.ip, + connected: $0.connStatus == "Connected") + } + .sorted { + if $0.connected != $1.connected { return $0.connected } + return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } + } + + @ViewBuilder + private var peerSection: some View { + let shown = targets.filter { $0.matches(peerQuery) } + + Section(header: Text("Send to")) { + if viewModel.vpnDisplayState != .connected { + emptyRow("NetBird is not running") + } else if shown.isEmpty { + emptyRow(targets.isEmpty ? "No peers to send to" : "No matching peers") + } else { + ForEach(shown) { target in + SendTargetRow(target: target, + status: statusLabel(for: target), + action: rowAction(for: target), + enabled: hasContent, + onTap: { tapped(target) }) + } + } + } + } + + private func emptyRow(_ message: LocalizedStringKey) -> some View { + Text(message) + .font(.subheadline) + .foregroundColor(Color("TextSecondary")) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 12) + } + + // MARK: - Row state + + private func transfer(for target: SendTarget) -> FileDropTransferInfo? { + guard let transferID = rowStates[target.pubKey]?.transferID else { return nil } + return filesVM.transfers.first { $0.id == transferID } + } + + /// The send is underway but there is no transfer to read yet: either the + /// call has not answered, or it has answered with an ID the polled list + /// has not caught up with. Without the second case the row would drop + /// back to idle for the length of one refresh and flash. + private func isInFlight(_ target: SendTarget) -> Bool { + guard let state = rowStates[target.pubKey], state.failure == nil else { return false } + if state.waiting { + return true + } + return state.transferID != nil && !state.seen + } + + /// What the row's button offers right now. A live send can be stopped, a + /// finished one can be sent again, everything else is a plain send. + private func rowAction(for target: SendTarget) -> SendTargetRow.Action { + if let transfer = transfer(for: target), !transfer.isTerminal { + return .stop + } + if isInFlight(target) { + return .inFlight + } + return .send + } + + private func statusLabel(for target: SendTarget) -> SendStatus { + guard let state = rowStates[target.pubKey] else { + return SendStatus(text: "", color: Color("TextSecondary")) + } + if let failure = state.failure { + let format = NSLocalizedString("file_drop_share_failed", value: "Could not send: %@", + comment: "error message") + return SendStatus(text: String(format: format, failure), color: Color(.systemRed)) + } + guard let transfer = transfer(for: target) else { + let waiting = NSLocalizedString("file_drop_share_state_waiting", value: "Waiting…", comment: "") + return SendStatus(text: isInFlight(target) ? waiting : "", color: Color("TextSecondary")) + } + if transfer.transferState == .completed { + return SendStatus(text: NSLocalizedString("file_drop_share_state_sent", value: "Sent", comment: ""), + color: Color(.systemGreen), + systemImage: "checkmark") + } + if transfer.transferState == .pending { + return SendStatus(text: NSLocalizedString("file_drop_share_state_waiting", value: "Waiting…", comment: ""), + color: Color("TextSecondary")) + } + return SendStatus(text: FileDropFormat.outcome(for: transfer), + color: FileDropFormat.outcomeColor(for: transfer)) + } + + // MARK: - Sending + + private func tapped(_ target: SendTarget) { + switch rowAction(for: target) { + case .stop: + targetToStop = target + case .inFlight: + return + case .send: + send(to: target) + } + } + + private func send(to target: SendTarget) { + guard hasContent else { return } + + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + rowStates[target.pubKey] = SendRowState(waiting: true) + + let request = FileDropSendRequest( + peerKey: target.pubKey, + peerName: target.name, + peerIP: target.ip, + text: contentKind == .text ? text.trimmingCharacters(in: .whitespacesAndNewlines) : "", + files: contentKind == .files ? stagedFiles : [] + ) + + filesVM.send(request) { result in + switch result { + case .success(let transferID): + // A refresh may already have listed it, in which case there is + // no later change for onChange to catch. + let listed = filesVM.transfers.contains { $0.id == transferID } + rowStates[target.pubKey] = SendRowState(transferID: transferID, seen: listed) + filesVM.refresh() + case .failure(let error): + UINotificationFeedbackGenerator().notificationOccurred(.error) + rowStates[target.pubKey] = SendRowState(failure: error.localizedDescription) + } + } + } + + private func stopSending(to target: SendTarget) { + guard let transfer = transfer(for: target) else { return } + filesVM.stop(transfer) + } +} + +private struct SendTargetRow: View { + enum Action { + case send + case stop + case inFlight + } + + let target: FileSendView.SendTarget + let status: FileSendView.SendStatus + let action: Action + let enabled: Bool + let onTap: () -> Void + + var body: some View { + HStack(spacing: 10) { + Circle() + .fill(target.connected ? Color(.systemGreen) : Color("TextSecondary")) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(target.name) + .font(.subheadline) + .foregroundColor(Color("TextPrimary")) + .lineLimit(1) + Text(target.ip) + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + + if !status.text.isEmpty { + HStack(spacing: 4) { + if let systemImage = status.systemImage { + Image(systemName: systemImage) + .font(.footnote.weight(.semibold)) + } + Text(status.text) + .font(.footnote) + .lineLimit(2) + } + .foregroundColor(status.color) + } + } + + Spacer(minLength: 8) + + // The control keeps one slot whether it shows Send, a spinner or + // Stop. Otherwise the narrow spinner hands the name column extra + // width for a moment and a truncated host name flashes to full. + button + .frame(minWidth: 72, alignment: .trailing) + } + .padding(.vertical, 2) + } + + @ViewBuilder + private var button: some View { + switch action { + case .send: + Button("Send", action: onTap) + .buttonStyle(.bordered) + .disabled(!enabled) + case .stop: + Button("Stop", role: .destructive, action: onTap) + .buttonStyle(.bordered) + case .inFlight: + ProgressView() + } + } +} + +#endif diff --git a/NetBird/Source/App/Views/iOS/iOSFilesView.swift b/NetBird/Source/App/Views/iOS/iOSFilesView.swift new file mode 100644 index 0000000..eb5f097 --- /dev/null +++ b/NetBird/Source/App/Views/iOS/iOSFilesView.swift @@ -0,0 +1,503 @@ +// +// iOSFilesView.swift +// NetBird +// +// Files tab: incoming offers awaiting consent pinned to the top, then the +// transfer log grouped by day, newest first. The receiving policy is not +// here; it lives under Settings, so this screen stays a log. +// +// The structure mirrors the Android screen, the interaction does not: a tap +// previews a received file, the row's own actions live in swipe actions, and +// the destructive ones ask through a confirmation dialog. +// + +import SwiftUI +import QuickLook + +#if os(iOS) + +struct iOSFilesView: View { + @EnvironmentObject var viewModel: ViewModel + @ObservedObject private var filesVM = FilesViewModel.shared + + @State private var showSendSheet = false + @State private var shareItems: [Any]? = nil + @State private var previewURLs: FilePreviewRequest? = nil + @State private var confirmAction: FilesConfirmAction? = nil + + var body: some View { + content + .navigationTitle("Files") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + showSendSheet = true + } label: { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel("Send files") + } + } + .searchable(text: $filesVM.searchText, prompt: "Search by file or peer") + .refreshable { + filesVM.refresh() + } + .sheet(isPresented: $showSendSheet) { + FileSendView(filesVM: filesVM) + .environmentObject(viewModel) + } + .sheet(item: $previewURLs) { request in + FilePreviewView(urls: request.urls) + } + .sheet(isPresented: Binding( + get: { shareItems != nil }, + set: { if !$0 { shareItems = nil } } + )) { + if let items = shareItems { + FileActivityView(items: items) + } + } + .confirmationDialog( + confirmAction?.title ?? "", + isPresented: Binding( + get: { confirmAction != nil }, + set: { if !$0 { confirmAction = nil } } + ), + titleVisibility: .visible, + presenting: confirmAction + ) { action in + switch action { + case .stop(let transfer): + Button("Stop", role: .destructive) { filesVM.stop(transfer) } + case .remove(let transfer): + Button("Remove", role: .destructive) { filesVM.remove(transfer) } + } + Button("Cancel", role: .cancel) {} + } message: { action in + Text(action.message) + } + .onAppear { + filesVM.bind(adapter: viewModel.networkExtensionAdapter) + filesVM.startPolling() + } + .onDisappear { + filesVM.stopPolling() + } + } + + // MARK: - List + + /// The empty state is a row inside the list rather than a view replacing + /// it, so the search field keeps its place while a query matches nothing. + private var content: some View { + List { + if filesVM.filteredTransfers.isEmpty { + emptyRow + } else { + if !filesVM.pendingOffers.isEmpty { + Section(header: Text("Incoming")) { + ForEach(filesVM.pendingOffers) { offer in + FileOfferRow(offer: offer, + onAccept: { answer(offer, accept: true) }, + onDecline: { answer(offer, accept: false) }) + } + } + } + + ForEach(filesVM.dayGroups, id: \.label) { group in + Section(header: Text(group.label)) { + ForEach(group.items) { transfer in + row(for: transfer) + } + } + } + } + } + .listStyle(InsetGroupedListStyle()) + } + + private var emptyRow: some View { + VStack(spacing: 8) { + Image(systemName: "arrow.up.arrow.down.circle") + .font(.largeTitle) + .foregroundColor(Color("TextSecondary")) + Text(filesVM.searchText.isEmpty ? "No transfers yet" : "No matching transfers") + .font(.subheadline) + .foregroundColor(Color("TextSecondary")) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 40) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + + /// A received file opens on tap, which is the row's primary action; the + /// rest hangs off swipe actions, with the context menu repeating them for + /// discovery rather than being the only way in. + @ViewBuilder + private func row(for transfer: FileDropTransferInfo) -> some View { + FileTransferRow(transfer: transfer) + .contentShape(Rectangle()) + .onTapGesture { open(transfer) } + .swipeActions(edge: .leading, allowsFullSwipe: false) { + if transfer.isText && !transfer.text.isEmpty { + Button { + copy(transfer) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .tint(.accentColor) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button(role: .destructive) { + confirmAction = .remove(transfer) + } label: { + Label("Remove", systemImage: "trash") + } + + if !transfer.isTerminal { + Button { + confirmAction = .stop(transfer) + } label: { + Label("Stop", systemImage: "stop.circle") + } + } + } + .contextMenu { rowActions(for: transfer) } + } + + @ViewBuilder + private func rowActions(for transfer: FileDropTransferInfo) -> some View { + if transfer.isText && !transfer.text.isEmpty { + Button { + copy(transfer) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + } + + if filesVM.isPreviewable(transfer) { + Button { + open(transfer) + } label: { + Label("Preview", systemImage: "eye") + } + + Button { + shareItems = filesVM.deliveredURLs(for: transfer) + } label: { + Label("Save or share", systemImage: "square.and.arrow.up") + } + } + + if !transfer.isTerminal { + Button { + confirmAction = .stop(transfer) + } label: { + Label("Stop", systemImage: "stop.circle") + } + } + + Button(role: .destructive) { + confirmAction = .remove(transfer) + } label: { + Label("Remove", systemImage: "trash") + } + } + + // MARK: - Actions + + private func open(_ transfer: FileDropTransferInfo) { + let urls = filesVM.deliveredURLs(for: transfer) + guard filesVM.isPreviewable(transfer), !urls.isEmpty else { return } + previewURLs = FilePreviewRequest(id: transfer.id, urls: urls) + } + + /// No toast: the pasteboard write is silent on iOS, and a haptic tick is + /// the platform's own way of saying it landed. + private func copy(_ transfer: FileDropTransferInfo) { + guard transfer.isText, !transfer.text.isEmpty else { return } + UIPasteboard.general.string = transfer.text + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + private func answer(_ offer: FileDropTransferInfo, accept: Bool) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + if accept { + filesVM.accept(offer) + } else { + filesVM.decline(offer) + } + } +} + +enum FilesConfirmAction: Identifiable { + case stop(FileDropTransferInfo) + case remove(FileDropTransferInfo) + + var id: String { + switch self { + case .stop(let t): return "stop-\(t.id)" + case .remove(let t): return "remove-\(t.id)" + } + } + + var title: String { + switch self { + case .stop(let t): return FileDropFormat.title(for: t) + case .remove(let t): return FileDropFormat.title(for: t) + } + } + + var message: LocalizedStringKey { + switch self { + case .stop: + return "Stop this transfer? It stays in the list so you can remove it afterwards." + case .remove: + return "Remove this transfer from the list?" + } + } +} + +/// A preview is addressed by the transfer it came from, so reopening the same +/// row does not present a stale set of URLs. +struct FilePreviewRequest: Identifiable { + let id: String + let urls: [URL] +} + +// MARK: - Rows + +struct FileOfferRow: View { + let offer: FileDropTransferInfo + let onAccept: () -> Void + let onDecline: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(FileDropFormat.title(for: offer)) + .font(.headline) + .foregroundColor(Color("TextPrimary")) + .lineLimit(2) + + Text(FileDropFormat.offerSubtitle(for: offer)) + .font(.subheadline) + .foregroundColor(Color("TextSecondary")) + + HStack(spacing: 12) { + Button("Accept", action: onAccept) + .buttonStyle(.borderedProminent) + + Button("Decline", action: onDecline) + .buttonStyle(.bordered) + } + } + .padding(.vertical, 6) + } +} + +struct FileTransferRow: View { + let transfer: FileDropTransferInfo + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 8) { + // The icon column reads direction: outgoing in the brand + // accent, incoming in green, as on the desktop and Android. + Image(systemName: transfer.isText + ? "text.quote" + : (transfer.outgoing ? "arrow.up.doc" : "arrow.down.doc")) + .foregroundColor(transfer.outgoing ? .accentColor : Color(.systemGreen)) + .frame(width: 24) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(FileDropFormat.title(for: transfer)) + .font(.subheadline) + .foregroundColor(Color("TextPrimary")) + .lineLimit(1) + .truncationMode(.tail) + + Text(FileDropFormat.peerLine(for: transfer)) + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + .lineLimit(1) + .truncationMode(.tail) + + Text(FileDropFormat.metaLine(for: transfer)) + .font(.footnote) + .foregroundColor(Color("TextSecondary")) + .lineLimit(1) + } + + Spacer(minLength: 8) + + // The outcome always keeps its place. Android hands this slot + // to a copy button on text rows and the desktop overlays it on + // hover; on a touch screen there is no hover to give it back, + // so a failed snippet would simply lose its red label. + Text(FileDropFormat.outcome(for: transfer)) + .font(.footnote) + .foregroundColor(FileDropFormat.outcomeColor(for: transfer)) + } + + if transfer.transferState == .transferring, transfer.totalSize > 0 { + ProgressView(value: Double(transfer.transferred), total: Double(transfer.totalSize)) + .padding(.leading, 32) + } + } + .padding(.vertical, 2) + } +} + +// MARK: - Labels + +enum FileDropFormat { + + static func title(for transfer: FileDropTransferInfo) -> String { + if transfer.isText { + let text = transfer.text.trimmingCharacters(in: .whitespacesAndNewlines) + return "\u{201C}\(text)\u{201D}" + } + if transfer.fileNames.count > 1 { + let format = NSLocalizedString("file_drop_title_multiple", value: "%1$@ + %2$d more", + comment: "first file name, count of the rest") + return String(format: format, transfer.fileNames[0], transfer.fileNames.count - 1) + } + return transfer.fileNames.first + ?? NSLocalizedString("file_drop_title_file", value: "File", comment: "") + } + + static func offerSubtitle(for transfer: FileDropTransferInfo) -> String { + let format = NSLocalizedString("file_drop_offer_subtitle", value: "%@ wants to send this", + comment: "peer name") + return String(format: format, transfer.peerName) + } + + static func peerLine(for transfer: FileDropTransferInfo) -> String { + let format = transfer.outgoing + ? NSLocalizedString("file_drop_direction_sent", value: "to %@", comment: "peer name") + : NSLocalizedString("file_drop_direction_received", value: "from %@", comment: "peer name") + return String(format: format, transfer.peerName) + } + + static func metaLine(for transfer: FileDropTransferInfo) -> String { + var parts: [String] = [] + if let created = transfer.createdAt { + let formatter = DateFormatter() + formatter.timeStyle = .short + parts.append(formatter.string(from: created)) + } + if !transfer.isText, transfer.totalSize > 0 { + parts.append(size(transfer.totalSize)) + } + return parts.joined(separator: " · ") + } + + static func size(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + /// Matches the desktop's wording: an unreachable send reads as declined, + /// since the sender only learns that nothing answered. + static func outcome(for transfer: FileDropTransferInfo) -> String { + if transfer.isUnreachable { + return NSLocalizedString("file_drop_state_declined", value: "Declined", comment: "") + } + switch transfer.transferState { + case .pending: + return NSLocalizedString("file_drop_state_pending", value: "Waiting", comment: "") + case .transferring: + if transfer.totalSize <= 0 { + return NSLocalizedString("file_drop_state_transferring", value: "Transferring", comment: "") + } + let percent = Int(transfer.transferred * 100 / transfer.totalSize) + let format = transfer.outgoing + ? NSLocalizedString("file_drop_state_progress", value: "Sending %d%%", comment: "percent") + : NSLocalizedString("file_drop_state_progress_incoming", value: "Receiving %d%%", comment: "percent") + return String(format: format, percent) + case .completed: + return transfer.outgoing + ? NSLocalizedString("file_drop_state_sent", value: "Sent", comment: "") + : NSLocalizedString("file_drop_state_received", value: "Received", comment: "") + case .declined: + return NSLocalizedString("file_drop_state_declined", value: "Declined", comment: "") + case .expired: + return NSLocalizedString("file_drop_state_expired", value: "No response", comment: "") + case .cancelled: + return NSLocalizedString("file_drop_state_cancelled", value: "Cancelled", comment: "") + case .failed: + return NSLocalizedString("file_drop_state_failed", value: "Failed", comment: "") + } + } + + /// The outcome column has its own legend, separate from the icon's: green + /// is a transfer that arrived, either way round, red is one the far side + /// refused or that broke, grey is everything else. A transfer in flight is + /// grey too, the progress bar already says it is moving; tinting the label + /// as well would hand the accent a second meaning next to the icon's. + static func outcomeColor(for transfer: FileDropTransferInfo) -> Color { + if transfer.isUnreachable { + return Color(.systemRed) + } + switch transfer.transferState { + case .completed: + return Color(.systemGreen) + case .declined, .failed: + return Color(.systemRed) + case .pending, .transferring, .cancelled, .expired: + return Color("TextSecondary") + } + } +} + +// MARK: - System presentations + +/// QuickLook over the delivered copies. Wrapped in a navigation controller so +/// the preview arrives with the Done button a modal needs. +struct FilePreviewView: UIViewControllerRepresentable { + let urls: [URL] + + func makeCoordinator() -> Coordinator { + Coordinator(urls: urls) + } + + func makeUIViewController(context: Context) -> UINavigationController { + let preview = QLPreviewController() + preview.dataSource = context.coordinator + return UINavigationController(rootViewController: preview) + } + + func updateUIViewController(_ controller: UINavigationController, context: Context) {} + + class Coordinator: NSObject, QLPreviewControllerDataSource { + private let urls: [URL] + + init(urls: [URL]) { + self.urls = urls + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + urls.count + } + + func previewController(_ controller: QLPreviewController, + previewItemAt index: Int) -> QLPreviewItem { + urls[index] as QLPreviewItem + } + } +} + +struct FileActivityView: UIViewControllerRepresentable { + let items: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: items, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +#endif diff --git a/NetBird/Source/App/Views/iOS/iOSSettingsView.swift b/NetBird/Source/App/Views/iOS/iOSSettingsView.swift index dccffea..33f3bb9 100644 --- a/NetBird/Source/App/Views/iOS/iOSSettingsView.swift +++ b/NetBird/Source/App/Views/iOS/iOSSettingsView.swift @@ -83,6 +83,18 @@ struct iOSSettingsView: View { .foregroundColor(Color("TextPrimary")) } } + + NavigationLink { + FileDropSettingsView() + } label: { + HStack { + Image(systemName: "arrow.down.doc") + .foregroundColor(.accentColor) + .frame(width: 24) + Text("Receiving files") + .foregroundColor(Color("TextPrimary")) + } + } } Section(header: Text("Information")) { @@ -141,4 +153,56 @@ struct iOSSettingsView: View { } } +/// The base policy for incoming transfers of the active profile. Per-sender +/// exceptions stay in Go and apply on top of it. +struct FileDropSettingsView: View { + @EnvironmentObject var viewModel: ViewModel + @ObservedObject private var filesVM = FilesViewModel.shared + + var body: some View { + List { + Section(footer: Text(footerText)) { + ForEach(FileDropMode.allCases, id: \.self) { mode in + Button { + guard filesVM.mode != mode else { return } + UISelectionFeedbackGenerator().selectionChanged() + filesVM.setMode(mode) + } label: { + HStack { + Text(label(for: mode)) + .foregroundColor(Color("TextPrimary")) + Spacer() + if filesVM.mode == mode { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + .contentShape(Rectangle()) + } + .accessibilityAddTraits(filesVM.mode == mode ? .isSelected : []) + } + } + } + .listStyle(InsetGroupedListStyle()) + .navigationTitle("Receiving files") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + filesVM.bind(adapter: viewModel.networkExtensionAdapter) + filesVM.refreshMode() + } + } + + private func label(for mode: FileDropMode) -> LocalizedStringKey { + switch mode { + case .off: return "Off" + case .ask: return "Ask every time" + case .autoAccept: return "Accept automatically" + } + } + + private var footerText: LocalizedStringKey { + "Other peers can send you files over NetBird. Received files show up in the Files tab, where you can save or share them." + } +} + #endif diff --git a/NetBird/en.lproj/Localizable.strings b/NetBird/en.lproj/Localizable.strings new file mode 100644 index 0000000..c56777d --- /dev/null +++ b/NetBird/en.lproj/Localizable.strings @@ -0,0 +1,109 @@ +/* + Localizable.strings + NetBird + + Base strings for the file drop screens. Keys prefixed with file_drop_ are + looked up explicitly from code where a value is built by format; the rest are + SwiftUI string literals, whose key is the English text itself. +*/ + +/* MARK: - Files tab */ + +"Files" = "Files"; +"Send files" = "Send files"; +"Search by file or peer" = "Search by file or peer"; +"Incoming" = "Incoming"; +"No transfers yet" = "No transfers yet"; +"No matching transfers" = "No matching transfers"; +"Accept" = "Accept"; +"Decline" = "Decline"; +"Copy" = "Copy"; +"Preview" = "Preview"; +"Save or share" = "Save or share"; +"Stop" = "Stop"; +"Remove" = "Remove"; +"Cancel" = "Cancel"; +"Stop this transfer? It stays in the list so you can remove it afterwards." = "Stop this transfer? It stays in the list so you can remove it afterwards."; +"Remove this transfer from the list?" = "Remove this transfer from the list?"; + +/* MARK: - Send sheet */ + +"Send to" = "Send to"; +"What to send" = "What to send"; +"Text" = "Text"; +"Choose files" = "Choose files"; +"Paste from clipboard" = "Paste from clipboard"; +"The selected files could not be read" = "The selected files could not be read"; +"Search peers" = "Search peers"; +"No peers to send to" = "No peers to send to"; +"No matching peers" = "No matching peers"; +"NetBird is not running" = "NetBird is not running"; +"Send" = "Send"; +"Stop sending to this peer?" = "Stop sending to this peer?"; + +/* MARK: - Receiving files settings */ + +"Receiving files" = "Receiving files"; +"Off" = "Off"; +"Ask every time" = "Ask every time"; +"Accept automatically" = "Accept automatically"; +"Other peers can send you files over NetBird. Received files show up in the Files tab, where you can save or share them." = "Other peers can send you files over NetBird. Received files show up in the Files tab, where you can save or share them."; + +/* MARK: - Composed labels */ + +/* First file name, then how many further files the transfer carries. */ +"file_drop_title_multiple" = "%1$@ + %2$d more"; +"file_drop_title_file" = "File"; +/* Peer name. */ +"file_drop_offer_subtitle" = "%@ wants to send this"; +/* Peer name. */ +"file_drop_direction_sent" = "to %@"; +/* Peer name. */ +"file_drop_direction_received" = "from %@"; +/* Number of files, then their total size. */ +"file_drop_send_files_summary" = "%1$d files · %2$@"; + +/* MARK: - Transfer outcomes */ + +"file_drop_state_pending" = "Waiting"; +"file_drop_state_transferring" = "Transferring"; +"file_drop_state_sent" = "Sent"; +"file_drop_state_received" = "Received"; +"file_drop_state_declined" = "Declined"; +"file_drop_state_expired" = "No response"; +"file_drop_state_cancelled" = "Cancelled"; +"file_drop_state_failed" = "Failed"; +/* Percent complete. */ +"file_drop_state_progress" = "Sending %d%%"; +/* Percent complete. */ +"file_drop_state_progress_incoming" = "Receiving %d%%"; + +/* MARK: - Send outcomes */ + +"file_drop_share_state_waiting" = "Waiting…"; +"file_drop_share_state_sent" = "Sent"; +"file_drop_share_not_running" = "NetBird is not running"; +"file_drop_share_unreadable" = "The selected files could not be read"; +/* Underlying error message. */ +"file_drop_share_failed" = "Could not send: %@"; + +/* MARK: - Day groups */ + +"file_drop_group_today" = "Today"; +"file_drop_group_yesterday" = "Yesterday"; +"file_drop_group_earlier" = "Earlier"; + +/* MARK: - Notifications */ + +"file_drop_accept" = "Accept"; +"file_drop_decline" = "Decline"; +"file_drop_notification_offer_title" = "Incoming file"; +/* Peer name, then what is being offered. */ +"file_drop_notification_offer_body" = "%1$@ wants to send you %2$@."; +"file_drop_notification_done_title" = "File received"; +/* What arrived, then the peer name. */ +"file_drop_notification_done_body" = "%1$@ from %2$@. Open NetBird to save it."; +"file_drop_notification_item_text" = "a text snippet"; +/* Number of files. */ +"file_drop_notification_item_files" = "%d files"; +"file_drop_notification_item_file" = "a file"; diff --git a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift index 743b70e..e76c50f 100644 --- a/NetBirdTVNetworkExtension/PacketTunnelProvider.swift +++ b/NetBirdTVNetworkExtension/PacketTunnelProvider.swift @@ -436,7 +436,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider { logger.info("initializeConfig: No config file found, initializing with management URL: \(managementURL, privacy: .public)") // Create Auth object with the management URL - guard let auth = NetBirdSDKNewAuth(configPath, managementURL, nil) else { + guard let auth = NetBirdSDKNewAuth(configPath, managementURL, nil, nil) else { logger.error("initializeConfig: Failed to create Auth object") let data = "false".data(using: .utf8) completionHandler(data) diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift index 17bb7a1..93f05a1 100644 --- a/NetbirdKit/ConfigurationProvider.swift +++ b/NetbirdKit/ConfigurationProvider.swift @@ -30,8 +30,9 @@ protocol ConfigurationProvider { // MARK: - Pre-Shared Key - /// The current pre-shared key (empty string if not set) - var preSharedKey: String { get set } + /// Stages a new pre-shared key, or clears it when given an empty string. + /// Write-only: the SDK never hands the key itself back to the native layer. + func setPreSharedKey(_ key: String) /// Whether a pre-shared key is configured var hasPreSharedKey: Bool { get } @@ -110,17 +111,18 @@ final class iOSConfigurationProvider: ConfigurationProvider { // MARK: - Pre-Shared Key - var preSharedKey: String { - get { - return preferences.getPreSharedKey(nil) - } - set { - preferences.setPreSharedKey(newValue) - } + func setPreSharedKey(_ key: String) { + preferences.setPreSharedKey(key) } var hasPreSharedKey: Bool { - return !preSharedKey.isEmpty + var result = ObjCBool(false) + do { + try preferences.hasPreSharedKey(&result) + } catch { + print("ConfigurationProvider: Failed to read hasPreSharedKey - \(error)") + } + return result.boolValue } // MARK: - Lifecycle @@ -181,13 +183,12 @@ final class tvOSConfigurationProvider: ConfigurationProvider { // MARK: - Pre-Shared Key - var preSharedKey: String { - get { extractJSONString(field: "PreSharedKey") ?? "" } - set { updateJSONField(field: "PreSharedKey", value: newValue) } + func setPreSharedKey(_ key: String) { + updateJSONField(field: "PreSharedKey", value: key) } var hasPreSharedKey: Bool { - return !preSharedKey.isEmpty + return !(extractJSONString(field: "PreSharedKey") ?? "").isEmpty } // MARK: - Lifecycle diff --git a/NetbirdKit/FileDropModels.swift b/NetbirdKit/FileDropModels.swift new file mode 100644 index 0000000..ec65d0e --- /dev/null +++ b/NetbirdKit/FileDropModels.swift @@ -0,0 +1,139 @@ +// +// FileDropModels.swift +// NetBird +// +// Shared file drop types passed between the app and the network extension. +// The integer raw values mirror the Go filedrop package constants exported +// through NetBirdSDK (mode, state, failure reason). +// + +import Foundation +import NetBirdSDK + +/// Base receiving policy, mirrors filedrop.Mode in netbird-core. +enum FileDropMode: Int, CaseIterable, Codable { + case off = 0 + case ask = 1 + case autoAccept = 2 +} + +/// Transfer lifecycle state, mirrors filedrop.State in netbird-core. +enum FileDropState: Int, Codable { + case pending = 0 + case transferring = 1 + case completed = 2 + case declined = 3 + case expired = 4 + case cancelled = 5 + case failed = 6 +} + +/// Failure detail, mirrors filedrop.FailureReason in netbird-core. +enum FileDropReason: Int, Codable { + case none = 0 + case unreachable = 1 +} + +/// Snapshot of one transfer, encoded by the extension for the app. +struct FileDropTransferInfo: Codable, Identifiable, Equatable { + var id: String + var outgoing: Bool + var peerKey: String + var peerName: String + var state: Int + var transferred: Int64 + var totalSize: Int64 + var createdAtMillis: Int64 + var updatedAtMillis: Int64 + var isText: Bool + var text: String + var error: String + var reason: Int + var fileNames: [String] + var deliveredPaths: [String] + + var transferState: FileDropState { FileDropState(rawValue: state) ?? .failed } + var isUnreachable: Bool { FileDropReason(rawValue: reason) == .unreachable } + var isTerminal: Bool { + switch transferState { + case .pending, .transferring: return false + default: return true + } + } + /// An incoming offer still waiting for the user's answer. + var isAnswerable: Bool { !outgoing && transferState == .pending } + var createdAt: Date? { + createdAtMillis > 0 ? Date(timeIntervalSince1970: TimeInterval(createdAtMillis) / 1000) : nil + } +} + +extension FileDropTransferInfo { + /// Snapshot of the gomobile transfer object, taken by whichever process + /// holds the SDK handle. + init(sdk t: NetBirdSDKFileDropTransfer) { + var fileNames: [String] = [] + var text = "" + for i in 0.. Void) { + guard let session = self.session, + let messageData = "FileDrop:\(command)".data(using: .utf8) else { + completion(nil) + return + } + do { + try session.sendProviderMessage(messageData) { response in + completion(response) + } + } catch { + completion(nil) + } + } + + func fileDropTransfers(completion: @escaping ([FileDropTransferInfo]?) -> Void) { + sendFileDropMessage("List") { response in + guard let response = response, + let transfers = try? self.decoder.decode([FileDropTransferInfo].self, from: response) else { + completion(nil) + return + } + completion(transfers) + } + } + + func fileDropSend(_ request: FileDropSendRequest, completion: @escaping (FileDropSendResponse?) -> Void) { + guard let requestData = try? JSONEncoder().encode(request), + let requestJSON = String(data: requestData, encoding: .utf8) else { + completion(nil) + return + } + sendFileDropMessage("Send:\(requestJSON)") { response in + guard let response = response, + let result = try? self.decoder.decode(FileDropSendResponse.self, from: response) else { + completion(nil) + return + } + completion(result) + } + } + + /// verb is one of Accept, Decline, Stop, Remove. + func fileDropAction(_ verb: String, id: String, completion: @escaping (Bool) -> Void) { + sendFileDropMessage("\(verb):\(id)") { response in + guard let response = response else { + completion(false) + return + } + completion(String(data: response, encoding: .utf8) == "true") + } + } + + func fileDropGetMode(completion: @escaping (Int?) -> Void) { + sendFileDropMessage("GetMode") { response in + guard let response = response, + let text = String(data: response, encoding: .utf8), + let mode = Int(text) else { + completion(nil) + return + } + completion(mode) + } + } + + func fileDropSetMode(_ mode: Int, completion: @escaping (Bool) -> Void) { + sendFileDropMessage("SetMode:\(mode)") { response in + guard let response = response else { + completion(false) + return + } + completion(String(data: response, encoding: .utf8) == "true") + } + } + func uploadDebugBundle(anonymize: Bool, completion: @escaping (Result) -> Void) { guard let session = self.session else { completion(.failure(NSError(domain: "NetBird", code: -1, userInfo: [NSLocalizedDescriptionKey: "VPN session not available. Connect first."]))) diff --git a/NetbirdNetworkExtension/NetBirdAdapter.swift b/NetbirdNetworkExtension/NetBirdAdapter.swift index a2221d7..ed9a237 100644 --- a/NetbirdNetworkExtension/NetBirdAdapter.swift +++ b/NetbirdNetworkExtension/NetBirdAdapter.swift @@ -386,6 +386,18 @@ public class NetBirdAdapter { return self.client.isLoginRequiredCached() } + /// Returns the active profile's file drop handle, creating it on first use. + /// The same handle serves the engine and the app's provider messages, so + /// history and policy stay consistent across engine restarts. + public func fileDrop() -> NetBirdSDKFileDrop? { + do { + return try client.fileDropHandle() + } catch { + adapterLogger.error("file drop unavailable: \(error.localizedDescription)") + return nil + } + } + /// Legacy synchronous login - returns URL string directly /// Used by iOS which opens Safari public func login() -> String { @@ -547,7 +559,7 @@ public class NetBirdAdapter { return } #endif - if let auth = NetBirdSDKNewAuth(configPath, managementURL, nil) { + if let auth = NetBirdSDKNewAuth(configPath, managementURL, nil, nil) { authRef = auth // Always pass the device name so the peer registers under the user's diff --git a/NetbirdNetworkExtension/PacketTunnelProvider.swift b/NetbirdNetworkExtension/PacketTunnelProvider.swift index 4627268..8b98210 100644 --- a/NetbirdNetworkExtension/PacketTunnelProvider.swift +++ b/NetbirdNetworkExtension/PacketTunnelProvider.swift @@ -36,6 +36,14 @@ class PacketTunnelProvider: NEPacketTunnelProvider { private var networkChangeWorkItem: DispatchWorkItem? + /// Serial queue for file drop provider messages, keeping their blocking Go + /// calls off the message-handling thread. + private let fileDropQueue = DispatchQueue(label: "io.netbird.filedrop") + + /// Posts local notifications for incoming transfers. Held strongly: the Go + /// side only keeps a weak-equivalent handle on the listener. + private let fileDropNotifier = FileDropNotifier() + override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { if let options = options, let logLevel = options["logLevel"] as? String { initializeLogging(loglevel: logLevel) @@ -79,6 +87,13 @@ class PacketTunnelProvider: NEPacketTunnelProvider { return } + // Register for transfer events off the message path: opening the handle + // can block on disk, and the events must flow with no app attached. + fileDropQueue.async { [weak self] in + guard let self = self, let fileDrop = self.adapter?.fileDrop() else { return } + fileDrop.setListener(self.fileDropNotifier) + } + // Skip this check when the main app has already established the login state (its own // isLoginRequired() call, or a login it just completed) and said so via the start // options. needsLogin() is a full Login RPC against the management server, so the @@ -230,6 +245,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider { case let s where s.hasPrefix("DebugBundle:"): let anonymize = s.dropFirst("DebugBundle:".count) == "true" debugBundle(anonymize: anonymize, completionHandler: completionHandler) + case let s where s.hasPrefix("FileDrop:"): + handleFileDropMessage(String(s.dropFirst("FileDrop:".count)), completionHandler: completionHandler) default: AppLogger.shared.log("Unknown message: \(string)") completionHandler(nil) @@ -634,10 +651,204 @@ class PacketTunnelProvider: NEPacketTunnelProvider { } } + /// Serves the app's file drop messages. Every Go call runs off the message + /// thread: sends and accepts can block on disk and network for a while, and + /// the provider must stay responsive to other messages meanwhile. + func handleFileDropMessage(_ command: String, completionHandler: @escaping (Data?) -> Void) { + guard let fileDrop = adapter?.fileDrop() else { + completionHandler(nil) + return + } + + fileDropQueue.async { [weak self] in + switch command { + case "List": + self?.fileDropList(fileDrop, completionHandler: completionHandler) + case let c where c.hasPrefix("Send:"): + self?.fileDropSend(fileDrop, payload: String(c.dropFirst("Send:".count)), completionHandler: completionHandler) + case let c where c.hasPrefix("Accept:"): + let ok = (try? fileDrop.accept(String(c.dropFirst("Accept:".count)))) != nil + completionHandler((ok ? "true" : "false").data(using: .utf8)) + case let c where c.hasPrefix("Decline:"): + let ok = (try? fileDrop.decline(String(c.dropFirst("Decline:".count)))) != nil + completionHandler((ok ? "true" : "false").data(using: .utf8)) + case let c where c.hasPrefix("Stop:"): + fileDrop.cancel(String(c.dropFirst("Stop:".count))) + completionHandler("true".data(using: .utf8)) + case let c where c.hasPrefix("Remove:"): + fileDrop.deleteTransfer(String(c.dropFirst("Remove:".count))) + completionHandler("true".data(using: .utf8)) + case "GetMode": + completionHandler(String(fileDrop.mode()).data(using: .utf8)) + case let c where c.hasPrefix("SetMode:"): + let mode = Int(c.dropFirst("SetMode:".count)) ?? -1 + let ok = mode >= 0 && (try? fileDrop.setMode(mode)) != nil + completionHandler((ok ? "true" : "false").data(using: .utf8)) + default: + AppLogger.shared.log("Unknown file drop message: \(command)") + completionHandler(nil) + } + } + } + + private func fileDropList(_ fileDrop: NetBirdSDKFileDrop, completionHandler: (Data?) -> Void) { + guard let array = fileDrop.transfers() else { + completionHandler(nil) + return + } + + var transfers: [FileDropTransferInfo] = [] + for i in 0.. Void) { + let respond: (FileDropSendResponse) -> Void = { response in + let data = try? PropertyListEncoder().encode(response) + completionHandler(data) + } + + guard let requestData = payload.data(using: .utf8), + let request = try? JSONDecoder().decode(FileDropSendRequest.self, from: requestData) else { + respond(FileDropSendResponse(transferID: "", error: "invalid send request")) + return + } + + guard let payloads = NetBirdSDKNewFileDropPayloads() else { + respond(FileDropSendResponse(transferID: "", error: "payloads unavailable")) + return + } + + do { + if !request.text.isEmpty { + try payloads.addText("text", text: request.text) + } + for file in request.files { + try payloads.addFile(file.name, size: file.size, contentType: file.contentType, path: file.path) + } + } catch { + respond(FileDropSendResponse(transferID: "", error: error.localizedDescription)) + return + } + + var error: NSError? + let transferID = fileDrop.send(request.peerKey, peerName: request.peerName, peerIP: request.peerIP, + payloads: payloads, error: &error) + if let error = error { + respond(FileDropSendResponse(transferID: "", error: error.localizedDescription)) + return + } + respond(FileDropSendResponse(transferID: transferID, error: "")) + } + override func sleep(completionHandler: @escaping () -> Void) { completionHandler() } + /// Mirrors incoming transfer events into local notifications, so an offer + /// or a finished download surfaces without the app being open. Delivery is + /// best-effort: notification scheduling from a Network Extension can be + /// rejected by the system, and the Files tab still shows everything. + private class FileDropNotifier: NSObject, NetBirdSDKFileDropListenerProtocol { + + private enum Event: Int { + case offer = 0 + case completed = 1 + case failed = 2 + case withdrawn = 3 + case progress = 4 + } + + func onFileDropEvent(_ kind: Int, transfer: NetBirdSDKFileDropTransfer?) { + guard let transfer = transfer, !transfer.outgoing else { return } + + let center = UNUserNotificationCenter.current() + let offerID = "netbird.filedrop.offer.\(transfer.id_)" + let doneID = "netbird.filedrop.done.\(transfer.id_)" + + switch Event(rawValue: kind) { + case .offer: + guard transfer.state == 0 else { return } + let format = NSLocalizedString( + "file_drop_notification_offer_body", + value: "%1$@ wants to send you %2$@.", + comment: "peer name, what is being offered") + post(id: offerID, + title: NSLocalizedString("file_drop_notification_offer_title", + value: "Incoming file", comment: ""), + body: String(format: format, transfer.peerName, itemLabel(transfer)), + category: FileDropNotification.offerCategory, + transferID: transfer.id_) + case .completed: + center.removePendingNotificationRequests(withIdentifiers: [offerID]) + center.removeDeliveredNotifications(withIdentifiers: [offerID]) + let format = NSLocalizedString( + "file_drop_notification_done_body", + value: "%1$@ from %2$@. Open NetBird to save it.", + comment: "what arrived, peer name") + post(id: doneID, + title: NSLocalizedString("file_drop_notification_done_title", + value: "File received", comment: ""), + body: String(format: format, itemLabel(transfer), transfer.peerName), + transferID: transfer.id_) + case .withdrawn, .failed: + center.removePendingNotificationRequests(withIdentifiers: [offerID]) + center.removeDeliveredNotifications(withIdentifiers: [offerID]) + default: + break + } + } + + private func itemLabel(_ transfer: NetBirdSDKFileDropTransfer) -> String { + if transfer.isText { + return NSLocalizedString("file_drop_notification_item_text", + value: "a text snippet", comment: "") + } + let count = transfer.fileCount() + if count > 1 { + let format = NSLocalizedString("file_drop_notification_item_files", + value: "%d files", comment: "file count") + return String(format: format, count) + } + return transfer.getFile(0)?.name + ?? NSLocalizedString("file_drop_notification_item_file", value: "a file", comment: "") + } + + /// The category carries the Accept and Decline buttons, which the app + /// registers: categories set from a Network Extension are not reliably + /// picked up by the system. The transfer ID rides along so the app can + /// answer the right offer straight from the notification. + private func post(id: String, title: String, body: String, + category: String? = nil, transferID: String) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = [FileDropNotification.transferIDKey: transferID] + content.threadIdentifier = "io.netbird.filedrop" + if let category = category { + content.categoryIdentifier = category + } + + let request = UNNotificationRequest(identifier: id, content: content, trigger: nil) + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + AppLogger.shared.log("file drop notification failed: \(error.localizedDescription)") + } + } + } + } + override func wake() { } diff --git a/filedrop-ios-todo.md b/filedrop-ios-todo.md new file mode 100644 index 0000000..c8750c5 --- /dev/null +++ b/filedrop-ios-todo.md @@ -0,0 +1,91 @@ +# File drop — iOS todo + +Az Android klienshez (`../android-client`, `app/src/main/java/io/netbird/client/ui/files/`) +képest hiányzó funkciók és az iOS design-nyelvtől való eltérések. + +Deployment target: **iOS 15.0** — a javaslatok ehhez igazodnak (nincs +`ContentUnavailableView` (17), nincs `scrollContentBackground` / `presentationDetents` (16) +availability-gate nélkül). + +## Funkcionális eltérések + +- [ ] **Share Extension** — Androidon a `ShareTargetActivity` `ACTION_SEND` / + `ACTION_SEND_MULTIPLE` intent-filterrel bármelyik appból elérhető, ez a fő + küldési útvonal. iOS-en nincs share target (a `project.pbxproj`-ban csak + Network- és Widget-extension van), így csak az appon belül, + `fileImporter`-rel lehet küldeni. Új target kell, app group staging + (`filedrop-outbox`) és NE-kommunikáció az extensionből. *(nagy darab)* +- [x] **Küldés a peer sorából** — Android: `PeerDetailFragment:241` → + `SendPickerSheet.forPeer(fqdn)`, előre kitöltött peer-kereséssel. + iOS-en a `PeerTabView`-ban nincs ilyen belépési pont. +- [x] **Tap a kész bejövő átvitelen = megnyitás** — Android: sor tap → + `ACTION_VIEW` chooser. iOS-en (`iOSFilesView.swift`) a sor `contentShape`-et + kap, de nincs `onTapGesture`; a "Save or share" csak long-press context + menüben él. Kell: tap → QuickLook preview. +- [x] **Látható másolás-akció a text-soron** — Androidon a bejövő szöveg sora a + status oszlopot cseréli vágólap-ikonra; iOS-en a Copy csak context menüben van. +- [x] **Vágólap-küldés** — `SendPickerSheet` felkínálja a vágólap tartalmát + előnézettel. iOS-en a Text fül üres `TextField`, kézzel kell beilleszteni. +- [x] **Stop akció elérhetősége** — iOS-en a Remove swipe-ból is megy, a Stop csak + context menüből. Legyen swipe/expliict gomb a futó átvitelre is. +- [x] **Polling helyett push** — `FilesViewModel.swift:131` 2 mp-es `Timer`; + Androidon `FileDropManager.TransfersListener` push. Minimum: leállítás + háttérbe lépéskor, és ritkítás, ha nincs futó transfer. + +## Design-nyelvi eltérések + +- [x] **Dynamic Type** — `.font(.system(size: 15/14/13))` végig az `iOSFilesView`-ban + és a `FileSendView`-ban; a szövegméret nem skálázódik. (Android `sp`-t használ, + tehát ott skálázódik — a portolás során veszett el.) Helyette szemantikus + stílusok: `.body` / `.subheadline` / `.footnote`. +- [x] **Kevert háttér / lista-stílus** — `ZStack { Color("BgMenu") }` alatt + `InsetGroupedListStyle`, aminek saját háttere van, így a `BgMenu` nem is + látszik, és a Files fül más hátteret ad, mint a végig custom kártyás + `iOSPeersView`. Egységesíteni kell (natív lista **vagy** app-stílusú kártyák). +- [x] **"Copied" toast törlése** — `iOSFilesView.swift:163` a Toast közvetlen + átirata; iOS-en a másolás visszajelzés nélkül (esetleg haptikával) történik. +- [x] **`confirmationDialog` a deprecated `Alert` helyett** — a + `Alert(title:message:primaryButton:secondaryButton:)` iOS 15 óta deprecated; + a Stop/Remove destruktív választás natív formája a confirmation dialog. + Érinti: `iOSFilesView` (stop/remove) és `FileSendView` (stop sending). +- [x] **Natív gomb-stílusok** — az Accept/Decline kézzel rajzolt + (`Color.accentColor` + `cornerRadius(8)` + `PlainButtonStyle`) a + `.borderedProminent` / `.bordered` utánzata; List-soron belüli custom + gomboknál ráadásul ütközik a soros tap-target. +- [x] **Notification action-ök az offerre** — a `PacketTunnelProvider` már küld + local notificationt bejövő ajánlatról; `UNNotificationAction`-ökkel + Accept/Decline közvetlenül az értesítésből mehetne. Ez dönti el az + "ask every time" mód használhatóságát, és Android nem tudja így. +- [x] **Send sheet interakció** — `FileSendView.swift:262`: "tap a peerre = + azonnali küldés, második tap = stop" rejtett és visszafordíthatatlan. + iOS-en explicit Send gomb / állapot a sor végén a szokás. A toolbar "Done" + is rossz szó egy elvethető sheeten — "Cancel" + explicit akció. +- [x] **Többsoros szövegküldés** — most egysoros `TextField`; `TextEditor` + + paste gomb kellene. +- [x] **Üres állapot és keresés** — a `content` üres ágán nincs lista, ezért a + `.searchable` mező viselkedése ugrál. Üres állapot legyen a listán belül. +- [x] **Pull-to-refresh + haptika** — `.refreshable` a naplóra, haptikus + visszajelzés küldésre/elfogadásra. +- [x] **Színek** — hardcode-olt `.red` / `.green` és `.accentColor` keveredik; + semantikus rendszerszínek + a brand narancs asset legyen egységesen + (Android: `nb_danger`, `nb_latency_good`, `nb_orange`). +- [x] **Lokalizáció** — Androidon minden string `strings.xml`-ben (`file_drop_*`, + `file_sharing_*`); iOS-en minden literál a Swift kódban, `.lproj` nincs a + projektben, tehát jelenleg nem lokalizálható. + +## Amiben az iOS előrébb tart (nem todo, csak jegyzet) + +A kész letöltések a Documents-be kerülnek és a Files appban látszanak +(`Info.plist` + `FilesViewModel.relocateDelivered`); Androidon csak FileProvider-es +"open" van. + +## Nyitva maradt + +- [ ] **Share Extension** — új Xcode target, entitlementek és provisioning kell + hozzá; a `project.pbxproj` kézi szerkesztése helyett Xcode-ban érdemes + létrehozni. A megosztott staging (`FilesViewModel.stageFiles` + + `filedrop-outbox`) és a `FileDropSendRequest` már készen áll rá. +- [ ] **`NetBird/en.lproj/Localizable.strings` hozzáadása a targethez** — a fájl + megvan, de amíg nincs a NetBird target Copy Bundle Resources fázisában, a + kulcsok a beégetett angol `value:` fallbackre esnek vissza (a felület + helyesen működik, csak nem lokalizálható). Xcode-ban egy behúzás. diff --git a/netbird-core b/netbird-core index 23a1487..290ad15 160000 --- a/netbird-core +++ b/netbird-core @@ -1 +1 @@ -Subproject commit 23a1487c26c5f00353c046bc818069189650dbfb +Subproject commit 290ad1507ac22598adc2043a1701bf3ef27fae51