├── Tests ├── DiscoverableTests │ ├── DiscoverableTests.swift │ └── XCTestManifests.swift └── LinuxMain.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources └── Discoverable │ ├── Result.swift │ ├── Notification+Name.swift │ ├── Average+Collection.swift │ ├── DiscoverableError.swift │ ├── NetServiceBrowser+Timeout+Stop.swift │ └── Discoverable.swift ├── .travis.yml ├── Package.swift ├── .gitignore └── README.md /Tests/DiscoverableTests/DiscoverableTests.swift: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import DiscoverableTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += DiscoverableTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/DiscoverableTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(DiscoverableTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Sources/Discoverable/Result.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Result.swift 3 | // Assistive Technology 4 | // 5 | // Created by Ben Mechen on 29/01/2020. 6 | // Copyright © 2020 Team 30. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum Result { 12 | case success(T) 13 | case error(Error) 14 | } 15 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/Discoverable/Notification+Name.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Notification+Name.swift 3 | // Assistive Technology 4 | // 5 | // Created by Ben Mechen on 14/06/2020. 6 | // Copyright © 2020 Team 30. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension Notification.Name { 12 | static let connected = Notification.Name("connected") 13 | static let disconnected = Notification.Name("disconnected") 14 | } 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | # references: 2 | # * https://www.objc.io/issues/6-build-tools/travis-ci/ 3 | # * https://github.com/supermarin/xcpretty#usage 4 | 5 | osx_image: xcode7.3 6 | language: objective-c 7 | # cache: cocoapods 8 | # podfile: Example/Podfile 9 | # before_install: 10 | # - gem install cocoapods # Since Travis is not always on latest version 11 | # - pod install --project-directory=Example 12 | script: 13 | - set -o pipefail && xcodebuild test -enableCodeCoverage YES -workspace Example/Discoverable.xcworkspace -scheme Discoverable-Example -sdk iphonesimulator9.3 ONLY_ACTIVE_ARCH=NO | xcpretty 14 | - pod lib lint 15 | -------------------------------------------------------------------------------- /Sources/Discoverable/Average+Collection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Array+Average.swift 3 | // Assistive Technology 4 | // 5 | // Created by Ben Mechen on 05/02/2020. 6 | // Copyright © 2020 Team 30. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | 12 | extension Collection where Element == Float, Index == Int { 13 | /// Return the mean of a list of Floats 14 | var average: Float? { 15 | guard !isEmpty else { 16 | return nil 17 | } 18 | 19 | let sum = reduce(Float(0)) { current, next -> Float in 20 | return current + next 21 | } 22 | 23 | return sum / Float(count) 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Discoverable", 8 | platforms: [ 9 | .iOS(.v12) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "Discoverable", 15 | targets: ["Discoverable"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "Discoverable", 26 | dependencies: []), 27 | .testTarget( 28 | name: "DiscoverableTests", 29 | dependencies: ["Discoverable"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /Sources/Discoverable/DiscoverableError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiscoverableError.swift 3 | // Assistive Technology 4 | // 5 | // Created by Ben Mechen on 29/01/2020. 6 | // Copyright © 2020 Team 30. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Network 11 | 12 | /// Errors thrown by Discoverable. 13 | /// Split into three categories, all with a set prefix: 14 | /// * POSIX errors –> `connect` prefix, signify errors from the foundation level C library POSIX, used as a fundamental base for iOS connections 15 | /// * Bonjour service discovery errors –> `discover` prefix, signify errors ran into whilst trying to discover the server advertising on the local network using the Bonjour protocol 16 | /// * Service resolve errors –> `discoverResolve` prefix, signify errors ran into in the second component of the discovery process; resolving the server's IP address from the discovered services 17 | public enum DiscoverableError: Error { 18 | /// Other connection error 19 | case connectOther 20 | /// POSIX address not available 21 | case connectAddressUnavailable 22 | /// POSIX permission denied 23 | case connectPermissionDenied 24 | /// POSIX device busy 25 | case connectDeviceBusy 26 | /// POSIX operation canceled 27 | case connectCanceled 28 | /// POSIX connection refused 29 | case connectRefused 30 | /// POSIX host is down or unreachable 31 | case connectHostDown 32 | /// POSIX connection already exists 33 | case connectAlreadyConnected 34 | /// POSIX operation timed ouit 35 | case connectTimeout 36 | /// POSIX network is down, unreachable, or has been reset 37 | case connectNetworkDown 38 | /// Connection server discovery failed 39 | case connectShakeNoResponse 40 | 41 | /// Discovery search did not find service in time 42 | case discoverTimeout 43 | /// Service not found while resolving IP 44 | case discoverResolveServiceNotFound 45 | /// Resolve service activity in progress 46 | case discoverResolveBusy 47 | /// Resolve service not setup correctly or given bad argument (e.g. bad IP) 48 | case discoverIncorrectConfiguration 49 | /// Resolve service was canceled 50 | case discoverResolveCanceled 51 | /// Resolve service did not get IP in time 52 | case discoverResolveTimeout 53 | /// Unable to resolve IP address from sender 54 | case discoverResolveFailed 55 | /// Other, unknown error during IP resolve 56 | case discoverResolveUnknown 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Discoverable/NetServiceBrowser+Timeout+Stop.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetServiceBrowser+Timeout.swift 3 | // Assistive Technology 4 | // 5 | // Created by Ben Mechen on 08/02/2020. 6 | // Copyright © 2020 Team 30. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import Network 11 | 12 | 13 | extension NetServiceBrowser { 14 | /// Starts a search for services of a particular type within a specific domain. The search must discover a service within the given timeout 15 | /// 16 | /// Custom wrapper for the NetServiceBrowser method `searchForServices(ofType type: String, inDomain domain: String)` with an added ability to set the maximum amount of time to search for the service. 17 | /// 18 | /// This method returns immediately, sending a `netServiceBrowserWillSearch(_:)` message to the delegate if the network was ready to initiate the search.The delegate receives subsequent `netServiceBrowser(_:didFind:moreComing:)` messages for each service discovered. 19 | /// The serviceType argument must contain both the service type and transport layer information. To ensure that the mDNS responder searches for services, rather than hosts, make sure to prefix both the service name and transport layer name with an underscore character (“_”). For example, to search for an HTTP service on TCP, you would use the type string “_http._tcp.“. Note that the period character at the end is required. 20 | /// The domainName argument can be an explicit domain name, the generic local domain @"local." (note trailing period, which indicates an absolute name), or the empty string (@""), which indicates the default registration domains. Usually, you pass in an empty string. Note that it is acceptable to use an empty string for the domainName argument when publishing or browsing a service, but do not rely on this for resolution. 21 | /// - Parameters: 22 | /// - type: Type of the service to search for. 23 | /// - domain: Domain name in which to perform the search. 24 | /// - delay: Time in seconds before timing out 25 | public func searchForServices(ofType type: String, inDomain domain: String, withTimeout delay: Double) { 26 | self.searchForServices(ofType: type, inDomain: domain) 27 | 28 | DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + delay, execute: { 29 | if let delegate = self.delegate as? NetServiceBrowserDelegateExtension, delegate.discovered != true { 30 | self.stop(false) 31 | } 32 | }) 33 | } 34 | 35 | /// Halts a currently running search or resolution. 36 | /// 37 | /// Custom wrapper for NetServiceBrowser method `stop()`, with added ability to inform delegate if the search was successful 38 | /// This method sends a `netServiceBrowserDidStopSearch(_:)` message to the delegate and causes the browser to discard any pending search results. 39 | /// - Parameter success: Did the browser discover a service? 40 | public func stop(_ success: Bool = false) { 41 | self.stop() 42 | if let delegate = self.delegate as? NetServiceBrowserDelegateExtension { 43 | delegate.netServiceBrowserDidStopSearch?(self, success: success) 44 | } 45 | } 46 | } 47 | 48 | /// The interface a net service browser uses to inform a delegate about the state of service discovery. 49 | /// Extension of the `NetServiceBrowserDelegate`, adding custom `netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser, success: Bool)` function and `discovered` variable 50 | @objc protocol NetServiceBrowserDelegateExtension: NetServiceBrowserDelegate { 51 | @objc optional func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser, success: Bool) 52 | var discovered: Bool { get set } 53 | } 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | 7 | 8 | # Created by https://www.toptal.com/developers/gitignore/api/swift,swiftpackagemanager,xcode,macos 9 | # Edit at https://www.toptal.com/developers/gitignore?templates=swift,swiftpackagemanager,xcode,macos 10 | 11 | ### macOS ### 12 | # General 13 | .DS_Store 14 | .AppleDouble 15 | .LSOverride 16 | 17 | # Icon must end with two \r 18 | Icon 19 | 20 | # Thumbnails 21 | ._* 22 | 23 | # Files that might appear in the root of a volume 24 | .DocumentRevisions-V100 25 | .fseventsd 26 | .Spotlight-V100 27 | .TemporaryItems 28 | .Trashes 29 | .VolumeIcon.icns 30 | .com.apple.timemachine.donotpresent 31 | 32 | # Directories potentially created on remote AFP share 33 | .AppleDB 34 | .AppleDesktop 35 | Network Trash Folder 36 | Temporary Items 37 | .apdisk 38 | 39 | ### Swift ### 40 | # Xcode 41 | # 42 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 43 | 44 | ## User settings 45 | xcuserdata/ 46 | 47 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 48 | *.xcscmblueprint 49 | *.xccheckout 50 | 51 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 52 | build/ 53 | DerivedData/ 54 | *.moved-aside 55 | *.pbxuser 56 | !default.pbxuser 57 | *.mode1v3 58 | !default.mode1v3 59 | *.mode2v3 60 | !default.mode2v3 61 | *.perspectivev3 62 | !default.perspectivev3 63 | 64 | ## Obj-C/Swift specific 65 | *.hmap 66 | 67 | ## App packaging 68 | *.ipa 69 | *.dSYM.zip 70 | *.dSYM 71 | 72 | ## Playgrounds 73 | timeline.xctimeline 74 | playground.xcworkspace 75 | 76 | # Swift Package Manager 77 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 78 | # Packages/ 79 | # Package.pins 80 | # Package.resolved 81 | # *.xcodeproj 82 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 83 | # hence it is not needed unless you have added a package configuration file to your project 84 | # .swiftpm 85 | 86 | .build/ 87 | 88 | # CocoaPods 89 | # We recommend against adding the Pods directory to your .gitignore. However 90 | # you should judge for yourself, the pros and cons are mentioned at: 91 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 92 | # Pods/ 93 | # Add this line if you want to avoid checking in source code from the Xcode workspace 94 | # *.xcworkspace 95 | 96 | # Carthage 97 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 98 | # Carthage/Checkouts 99 | 100 | Carthage/Build/ 101 | 102 | # Accio dependency management 103 | Dependencies/ 104 | .accio/ 105 | 106 | # fastlane 107 | # It is recommended to not store the screenshots in the git repo. 108 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 109 | # For more information about the recommended setup visit: 110 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 111 | 112 | fastlane/report.xml 113 | fastlane/Preview.html 114 | fastlane/screenshots/**/*.png 115 | fastlane/test_output 116 | 117 | # Code Injection 118 | # After new code Injection tools there's a generated folder /iOSInjectionProject 119 | # https://github.com/johnno1962/injectionforxcode 120 | 121 | iOSInjectionProject/ 122 | 123 | ### SwiftPackageManager ### 124 | Packages 125 | xcuserdata 126 | *.xcodeproj 127 | 128 | 129 | ### Xcode ### 130 | # Xcode 131 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 132 | 133 | 134 | 135 | 136 | ## Gcc Patch 137 | /*.gcno 138 | 139 | ### Xcode Patch ### 140 | *.xcodeproj/* 141 | !*.xcodeproj/project.pbxproj 142 | !*.xcodeproj/xcshareddata/ 143 | !*.xcworkspace/contents.xcworkspacedata 144 | **/xcshareddata/WorkspaceSettings.xcsettings 145 | 146 | # End of https://www.toptal.com/developers/gitignore/api/swift,swiftpackagemanager,xcode,macos 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discoverable 2 | 3 | 4 | *Discoverable* is a Swift package that allows an iOS device to automatically discover and connect to any compatible devices on the network, **without the need for IP addresses**. 5 | 6 | Under the surface, Discoverable uses Foundation's Bonjour framework to find the service advertised on the netwok, and the Network framework to communicate over UDP+ using your custom defined networking protocol messages. 7 | 8 | Connections are a 3 stage process: 9 | 1. Using Bonjour/Zeroconf, find the service advertised on the network. 10 | 2. Once a service is discovered, resolve the IP address of the machine advertising and open a UDP+ connection. 11 | 3. Once a handshake has been completed, the connection will stay open until closed by either party, or the connection strength (see below) drops below 5%. 12 | 13 | ### UDP+ 14 | 15 | The framework uses UDP packets to send messages between parties, however, the DiscoverableProtocol builds a number of TCP features on top of this: 16 | * To start a connection, a handshake is required between the two parties to ensure both are able to accept a new connection 17 | * All messages from the client require the server to reply with an acknowledgement message within a given timeframe 18 | * Connection strength is a percentage value, calculated from the number of packets acknowledged by the server. It takes the average over the last `n` most recent responses to get an up-to-date strength value 19 | * If either party ends the connection or closes for any reason, they will send a last dying message to close the connection. This means no client will be left with a dangling connection. 20 | 21 | ### DiscoveryProtocol 22 | 23 | This Swift protocol contains the basic commands needed to operate the network: 24 | 25 | ``` 26 | enum DiscoverableProtocol: String { 27 | /// Disconnect & shut down server 28 | case disconnect = "dscv_disconnect" 29 | /// Send greeting to server, with device name appended in the format: ":device_name" 30 | case discover = "dscv_discover" 31 | /// Handshake response received from server 32 | case handshake = "dscv_shake" 33 | /// Acknowledgment message 34 | case acknowledge = "dscv_ack" 35 | } 36 | ``` 37 | 38 | Any other messages can be added by extending the DiscoverableProtocol, or just passing any string value into `send()`. 39 | 40 | ## Example 41 | 42 | A detailed walkthough is posted [here](https://dev.to/benmechen/automatically-discover-and-connect-to-devices-on-a-network) 43 | 44 | ## Requirements 45 | 46 | The framework uses Apple's Network framework, which is available on iOS 12+. 47 | 48 | ## Installation 49 | 50 | Discoverable is a Swift Package Manager package. Follow the instructions [here](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app) to install add the dependency to your app. 51 | 52 | ## API 53 | 54 | The ConnectionService class can be used in two ways: 55 | * Singleton `shared` instance 56 | * Create a new `ConnectionService` instance 57 | 58 | ### Discovery 59 | 60 | #### `discover(type: String, on: UInt16?)` 61 | 62 | Begin looking for a Bonjour service on the local network of the given type. This function times out after 5 seconds if no services are discovered. 63 | This function will automatically look for a service, resolve the IP of the discovered device, and then call the `connect` function below with the discovered IP and the given port, or 1024 if no port is given. 64 | 65 | ### Connection 66 | 67 | #### `connect(to host: String, on port: UInt16)` 68 | 69 | If you already know the IP address of the device you wish to connect to, you can skip the auto-discovery and connect directly using this function - it is the same function used internally by the `discover` function. 70 | 71 | On connection start, a function will be called to open a listener on incomming connections and the state will be set to `connecting`. This function will listen out for the handshake response from the server - once one is received, the connection state is updated to `connected`. 72 | 73 | #### `send()` 74 | 75 | Send a string value to the other device, if connected. This is where the connection strength is calculated - for each message sent, an acknowledgement should be sent back within 2 seconds. If the stength is below 5%, the connection is considered closed, and the state is set to `disconected`. 76 | 77 | #### `close(_ killServer = true, state = .disconnected)` 78 | 79 | Close the connection. By default, this will send a disconnect message to the server to shut it down too. If needed, the final connection state can be set too, however it is unlikely that this needs to be changed. 80 | 81 | ### Delegate 82 | 83 | If you wish to subscribe to connection state and strength updates, set the ConnectionService's delegate property to an object extending `ConnectionServiceDelegate`. This delegate object must implement the following functions to receive updates: 84 | * `connectionState(state: ConnectionService.State)` - The current state of the connection 85 | * `connectionStrength(strength: Float)` - The current strength of the connection, as a percentage 86 | 87 | ## Author 88 | 89 | benmechen, psybm7@nottingham.ac.uk 90 | 91 | ## License 92 | 93 | Discoverable is available under the MIT license. See the LICENSE file for more info. 94 | -------------------------------------------------------------------------------- /Sources/Discoverable/Discoverable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Discoverable.swift 3 | // Assistive Technology Project 4 | // 5 | // Created by Ben Mechen on 03/02/2020. 6 | // Copyright © 2020 Team 30. All rights reserved. 7 | // 8 | 9 | #if canImport(UIKit) 10 | 11 | import Foundation 12 | import Network 13 | import os.log 14 | import UIKit 15 | 16 | /// Connection protocol with set messages to interact with the corresponding server 17 | public enum DiscoverableProtocol: String { 18 | /// Disconnect & shut down server 19 | case disconnect = "dscv_disconnect" 20 | /// Send greeting to server, with device name appended in the format: ":device_name" 21 | case discover = "dscv_discover" 22 | /// Handshake response received from server 23 | case handshake = "dscv_shake" 24 | /// Acknowledgment message 25 | case acknowledge = "dscv_ack" 26 | } 27 | 28 | /// Protocol for Discoverable caller to conform to in order to received updates about the connection state and strength 29 | public protocol DiscoverableDelegate { 30 | /// Updates the current state of the connection 31 | /// - Parameter state: New state 32 | func connectionState(state: Discoverable.State) 33 | /// Updates the connection strength 34 | /// - Parameter strength: Strength percentage 35 | func connectionStrength(strength: Float) 36 | } 37 | 38 | /** 39 | Automatically discover, connect and communicate with a server comforming to the **Assistive Technology Communication Protocol** (`dscv`) 40 | This is based on UDP, however implements some TCP-like features to improve the connection resilience & feedback: 41 | * When a packet is sent to the server, the server must reply with an acknowledgement response. 42 | * If this acknowledgement is not received within the specified threshold (2 seconds), the strength is marked as 0. This will bring the average strength of the last 5 values down. Once the average strength is below the specified threshold (5%), the service will assume the connection has failed and will close the connetion. 43 | * All protocol messages relating to connecting to the server (Bonjour discovery, `discover` packets) expect a response back from the server - if the service is not discovered or a response is not received, the system will try again for a maximum of 5 seconds (Bonjour) or 5 sends (`discover`). 44 | * If either time out, the connection will be marked as `failed` 45 | * When the connection is closed, either on the client (iOS) side or server (PC) side, each will send one last dying message to the other (`disconnect`). This will shut down the other member, closing both open connections and allowing each to accept a new connection, so that they are not stuck being bound to a dead connection. 46 | 47 | Caller must conform to the DiscoverableDelegate protocol to receive status updates 48 | 49 | To use the service, either call the `connect(to host: String, on port: UInt16)` function to open a UDP connection with the server on the specified IP and port, or use the `discover(type: String)` function to automatically discover the server using Bonjour and connect to it, using the resolved host and default port 1024. 50 | To automatically discover the server, it must be 51 | advertising on the local mDNS network with the same name and type as supplied to the `discover(type: String)` function. If the service cannot find the server's advertisement within 5 seconds, the service will abort the search and return a `failed` state. 52 | */ 53 | public class Discoverable: NSObject { 54 | /// Delegate class implementing `DiscoverableDelegate` protocol. Used to send connction status updates to. 55 | public var delegate: DiscoverableDelegate? 56 | /// Singleton instance to access the service from any screen 57 | public static var shared = Discoverable() 58 | /// The current connection state 59 | public var state: Discoverable.State = .disconnected 60 | /// Raw connection, used for sending and receiving the UDP connection component of the connection 61 | private var connection: NWConnection? 62 | /// Bonjour service browser, used for discovering the server advertising locally on the Bonjour protocol 63 | private var browser = NetServiceBrowser() 64 | /// Service given by the browser, used to resolve the server's IP address 65 | private var service: NetService? 66 | /// Custom connection queue, used to asynchronously send and received UDP packets without operating on the main thread and stopping any UI updates 67 | private var queue = DispatchQueue(label: "DiscoverableQueue") 68 | /// Number of UDP packets sent to the server 69 | private var sent: Float = 0.0 70 | /// Number of UDP packets received from the server 71 | private var received: Float = 0.0 72 | /// List of the last `n` calculated strength percentages 73 | private var strengthBuffer: [Float] = [] 74 | /// The clock representing the last sent packet, awaiting a response from the server in order to kill the timer 75 | private var lastSentClock: Timer? 76 | /// Clocks currently waiting for their packets to receive a response from the server. Once a response is received, the clock is killed and removed from the list. 77 | private var previousClocks: [Timer] = [] 78 | /// The number of `DiscoverableProtocol.discover` messages sent to the server. Stop trying to communicate with the server when threshold is reached 79 | private var discoverTimeout: Int = 0 80 | /// Local discovered variable, mirrored by the `NetServiceBrowserDelegateExtension` 81 | private var _discovered = false 82 | // Temporarily store port while searching for services 83 | private var resolverPort: UInt16? 84 | 85 | /// The state of the connection handled by the service instance 86 | public enum State: Equatable { 87 | /// Connection currently open, sending and receiving data 88 | case connected 89 | /// Connection in progress, no sending, only receiving data 90 | case connecting 91 | /// Connection disconnected, can start new connection 92 | case disconnected 93 | /// Error connecting to server, throws DiscoverableError 94 | case failed(DiscoverableError) 95 | } 96 | 97 | /// Hide initialiser from other classes so they have to used shared instance 98 | // fileprivate override init() { 99 | // super.init() 100 | // } 101 | 102 | /// Remove any timeout clocks to save memory and avoid trying to close a dead connection 103 | deinit { 104 | killClocks() 105 | } 106 | 107 | /// Open a Network connection and greet the server 108 | /// 109 | /// Errors passed to delegate 110 | /// - Parameters: 111 | /// - host: IP address to connect to 112 | /// - port: Port on which to bind connection 113 | public func connect(to host: String, on port: UInt16) { 114 | let host = NWEndpoint.Host(host) 115 | guard let port = NWEndpoint.Port(rawValue: port) else { 116 | return 117 | } 118 | 119 | self.strengthBuffer.removeAll() 120 | 121 | self.connection = NWConnection(host: host, port: port, using: .udp) 122 | 123 | self.connection?.stateUpdateHandler = { (newState) in 124 | switch (newState) { 125 | case .ready: 126 | guard let connection = self.connection else { 127 | return 128 | } 129 | 130 | self.listen(on: connection) 131 | self.discoverTimeout = 0 132 | 133 | let device = UIDevice.current.name 134 | 135 | self.send(DiscoverableProtocol.discover.rawValue + ":" + device) 136 | case .failed(let error), .waiting(let error): 137 | self.handle(NWError: error) 138 | default: 139 | break 140 | } 141 | } 142 | 143 | connection?.start(queue: queue) 144 | 145 | print(" > Connection started on \(self.connection?.endpoint.debugDescription ?? "-")") 146 | } 147 | 148 | /// Send a message to the server on the open connection 149 | /// 150 | /// When sending a discovery message, wait 2 seconds before either trying to discover again or close the connection when the connection strength is less than threshold 151 | /// Errors passed to delegate 152 | /// - Warning: Will only send data if the connection is in the connected or connecting state 153 | /// - Parameter value: String to send to the server 154 | public func send(_ value: String) { 155 | guard self.state == .connected || self.state == .connecting else { return } 156 | guard let data = value.data(using: .utf8) else { return } 157 | 158 | self.connection?.send(content: data, completion: .contentProcessed( { error in 159 | if let error = error { 160 | self.handle(NWError: error) 161 | return 162 | } 163 | 164 | if self.state == .connected || self.state == .connecting { 165 | if let previousClock = self.lastSentClock { 166 | self.previousClocks.append(previousClock) 167 | } 168 | 169 | DispatchQueue.main.async { 170 | self.lastSentClock = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { timer in 171 | // No response received after 5 seconds, update connection status 172 | if self.calculateStrength(rate: 0.0) < 5 { 173 | if self.state == .connecting { 174 | guard self.discoverTimeout < 5 else { 175 | self.close(false, state: .failed(.connectShakeNoResponse)) 176 | return 177 | } 178 | 179 | let device = UIDevice.current.name 180 | 181 | self.send(DiscoverableProtocol.discover.rawValue + ":" + device) 182 | self.discoverTimeout += 1 183 | } else { 184 | self.close(false) 185 | } 186 | } 187 | } 188 | } 189 | } 190 | 191 | self.sent += 1 192 | print(" > Sent: \(data as NSData) string: \(value)") 193 | })) 194 | } 195 | 196 | /// Close the connection 197 | /// 198 | /// Removes all timers waiting for server response 199 | /// - Warning: Will only close the connection if in the connected or connecting states 200 | /// - Parameters: 201 | /// - killServer: Send shutdown command to the server to stop the application (default true) 202 | /// - state: State to set the connection to once killed (default disconnected state) 203 | public func close(_ killServer: Bool = true, state: Discoverable.State = .disconnected) { 204 | guard self.state == .connected || self.state == .connecting else { 205 | // Connection closed already 206 | return 207 | } 208 | // 209 | if killServer { 210 | self.send(DiscoverableProtocol.disconnect.rawValue) 211 | } 212 | self.killClocks() 213 | self.set(state: state) 214 | self.connection?.cancel() 215 | } 216 | 217 | /// Force service to tell delegate connection strength 218 | public func fetchConnectionStrength() { 219 | self.delegate?.connectionStrength(strength: self.strengthBuffer.average ?? 0) 220 | } 221 | 222 | /// Listen on open connection for incomming messages 223 | /// 224 | /// Interpret incomming messages according to DiscoverableProtocol 225 | /// Remove timeout 226 | /// Update strength 227 | /// Errors passed to delegate 228 | /// - Parameter connection: Open NWConnection to listen on 229 | private func listen(on connection: NWConnection) { 230 | connection.receiveMessage { (data, context, isComplete, error) in 231 | if (isComplete) { 232 | if let error = error { 233 | self.handle(NWError: error) 234 | return 235 | } 236 | 237 | if let data = data, let message = String(data: data, encoding: .utf8) { 238 | self.received += 1 239 | 240 | self.killClocks() 241 | 242 | if message.contains(DiscoverableProtocol.handshake.rawValue) { 243 | self.set(state: .connected) 244 | } 245 | 246 | if message.contains(DiscoverableProtocol.disconnect.rawValue) { 247 | self.close() 248 | } 249 | 250 | let percent: Float = (self.received / self.sent) * 100 251 | 252 | print(" > Received: \(data as NSData) string: \(message) -- \(self.calculateStrength(rate: percent))% successfull transmission") 253 | } 254 | 255 | self.listen(on: connection) 256 | } 257 | } 258 | } 259 | 260 | /// Calculate success rate of sent packets based on acknowledgement packets received from server 261 | /// 262 | /// Average of the last 5 strength values 263 | /// Update the delegate with the connection strength 264 | /// - Parameter percent: Current success percentage calculated from the number of sent and received packets 265 | private func calculateStrength(rate percent: Float) -> Float { 266 | guard self.state == .connected else { 267 | self.delegate?.connectionStrength(strength: 0) 268 | return 0 269 | } 270 | 271 | self.strengthBuffer.append(percent) 272 | 273 | self.strengthBuffer = Array(self.strengthBuffer.suffix(5)) 274 | 275 | let average = self.strengthBuffer.average ?? 100.0 276 | self.delegate?.connectionStrength(strength: average) 277 | return average 278 | } 279 | 280 | /// Remove all timeout clocks currently awaiting a response 281 | private func killClocks() { 282 | for i in 0...self.previousClocks.count { 283 | // Concurrency fix 284 | guard i < self.previousClocks.count else { return } 285 | self.previousClocks[i].invalidate() 286 | self.previousClocks.remove(at: i) 287 | } 288 | } 289 | 290 | /// Update current state and inform delegate 291 | /// - Parameter state: New state 292 | private func set(state: Discoverable.State) { 293 | self.state = state 294 | self.delegate?.connectionState(state: state) 295 | } 296 | 297 | /// Handle errors in the NWError format and set the service state 298 | /// - Parameter error: Error received from NWConnection 299 | private func handle(NWError error: NWError) { 300 | switch error { 301 | case .posix(let code): 302 | switch code { 303 | case .EADDRINUSE, .EADDRNOTAVAIL: 304 | self.state = .failed(.connectAddressUnavailable) 305 | self.set(state: .failed(.connectAddressUnavailable)) 306 | case .EACCES, .EPERM: 307 | self.set(state: .failed(.connectPermissionDenied)) 308 | case .EBUSY: 309 | self.set(state: .failed(.connectDeviceBusy)) 310 | case .ECANCELED: 311 | self.set(state: .failed(.connectCanceled)) 312 | case .ECONNREFUSED: 313 | self.set(state: .failed(.connectRefused)) 314 | case .EHOSTDOWN, .EHOSTUNREACH: 315 | self.set(state: .failed(.connectHostDown)) 316 | case .EISCONN: 317 | self.set(state: .failed(.connectAlreadyConnected)) 318 | case .ENOTCONN: 319 | self.set(state: .disconnected) 320 | case .ETIMEDOUT: 321 | self.set(state: .failed(.connectTimeout)) 322 | case .ENETDOWN, .ENETUNREACH, .ENETRESET: 323 | self.set(state: .failed(.connectNetworkDown)) 324 | default: 325 | os_log(.error, "POSIX connection error: %@", code.rawValue) 326 | self.set(state: .failed(.connectOther)) 327 | } 328 | default: 329 | self.set(state: .failed(.connectOther)) 330 | } 331 | } 332 | } 333 | 334 | // MARK: NetService extension 335 | extension Discoverable: NetServiceBrowserDelegate, NetServiceBrowserDelegateExtension, NetServiceDelegate { 336 | var discovered: Bool { 337 | get { 338 | return self._discovered 339 | } 340 | set { 341 | self._discovered = newValue 342 | } 343 | } 344 | 345 | /// Begin looking for the server advertising with the Bonjour protocol 346 | /// 347 | /// Set state to connecting 348 | /// Start browsing for services, abort the search if no service discovered after 5 seconds 349 | /// - Parameter type: Type of service to discover 350 | public func discover(type: String, on port: UInt16?) { 351 | self.set(state: .connecting) 352 | service = nil 353 | _discovered = false 354 | browser.delegate = self 355 | self.resolverPort = port 356 | browser.stop() 357 | browser.searchForServices(ofType: type, inDomain: "", withTimeout: 5.0) 358 | } 359 | 360 | // MARK: Service Discovery 361 | /// Browser stopped searching for service 362 | /// 363 | /// Modified to add success parameter to set state to failed if the search timed out 364 | /// - Parameters: 365 | /// - browser: Browser instance 366 | /// - success: Did the browser discover the service in time 367 | public func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser, success: Bool) { 368 | if !success { 369 | self.set(state: .failed(.discoverTimeout)) 370 | } 371 | } 372 | 373 | /// Browser found a matching service 374 | /// 375 | /// Set discovered parameter for NetServiceBrowser for success parameter in `netServiceBrowserDidStopSearch()` 376 | /// Resolve server's IP 377 | /// - Parameters: 378 | /// - browser: Browser instance 379 | /// - service: Service found 380 | /// - moreComing: Were more services discovered 381 | public func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) { 382 | self._discovered = true 383 | 384 | guard self.service == nil else { 385 | return 386 | } 387 | 388 | self.discovered = true 389 | 390 | self.set(state: .connecting) 391 | 392 | print("Discovered the service") 393 | print("- name:", service.name) 394 | print("- type", service.type) 395 | print("- domain:", service.domain) 396 | 397 | browser.stop() 398 | 399 | self.service = service 400 | self.service?.delegate = self 401 | self.service?.resolve(withTimeout: 5) 402 | } 403 | 404 | // MARK: Resolve IP Service 405 | /// Handle NetService errors, set connection state according to given error 406 | /// - Parameters: 407 | /// - sender: Resolve service 408 | /// - errorDict: Errors from NetService 409 | public func netService(_ sender: NetService, didNotResolve errorDict: [String : NSNumber]) { 410 | for key in errorDict.keys { 411 | switch errorDict[key] { 412 | case -72002: 413 | self.set(state: .failed(.discoverResolveServiceNotFound)) 414 | case -72003: 415 | self.set(state: .failed(.discoverResolveBusy)) 416 | case -72004, -72006: 417 | self.set(state: .failed(.discoverIncorrectConfiguration)) 418 | case -72005: 419 | self.set(state: .failed(.discoverResolveCanceled)) 420 | case -72007: 421 | self.set(state: .failed(.discoverResolveTimeout)) 422 | default: 423 | self.set(state: .failed(.discoverResolveUnknown)) 424 | } 425 | } 426 | } 427 | 428 | /// Resolve service got an IP address of the discovered server and connect to the server at that address 429 | /// - Parameter sender: Resolve service 430 | public func netServiceDidResolveAddress(_ sender: NetService) { 431 | if let serviceIp = resolveIPv4(addresses: sender.addresses!) { 432 | self.connect(to: serviceIp, on: resolverPort ?? 1024) 433 | } else { 434 | self.set(state: .failed(.discoverResolveFailed)) 435 | } 436 | } 437 | 438 | /// Get server IP address from list of address data 439 | /// - Parameter addresses: List of address data 440 | /// - Returns: Server IP address if found 441 | private func resolveIPv4(addresses: [Data]) -> String? { 442 | var result: String? 443 | 444 | for address in addresses { 445 | let data = address as NSData 446 | var storage = sockaddr_storage() 447 | data.getBytes(&storage, length: MemoryLayout.size) 448 | 449 | if Int32(storage.ss_family) == AF_INET { 450 | let addr4 = withUnsafePointer(to: &storage) { 451 | $0.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { 452 | $0.pointee 453 | } 454 | } 455 | 456 | if let ip = String(cString: inet_ntoa(addr4.sin_addr), encoding: .ascii) { 457 | result = ip 458 | break 459 | } 460 | } 461 | } 462 | 463 | return result 464 | } 465 | } 466 | 467 | #endif 468 | --------------------------------------------------------------------------------