├── 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 |
--------------------------------------------------------------------------------