├── .editorconfig ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── SSDPClient │ ├── SSDPDiscovery.swift │ └── SSDPService.swift └── Tests ├── LinuxMain.swift └── SSDPClientTests ├── SSDPDiscoveryTests.swift └── SSDPServiceTests.swift /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.swift] 2 | indent_style = space 3 | indent_size = 4 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: https://www.buymeacoffee.com/pierrickrouxel 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | /.swiftpm -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Pierrick Rouxel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Socket", 6 | "repositoryURL": "https://github.com/Kitura/BlueSocket.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "c9894fd117457f1d006575fbfb2fdfd6f79eac03", 10 | "version": "1.0.200" 11 | } 12 | }, 13 | { 14 | "package": "HeliumLogger", 15 | "repositoryURL": "https://github.com/Kitura/HeliumLogger.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "55fd2f0b70793017acee853c53cfcf8da0bd8d8d", 19 | "version": "1.9.200" 20 | } 21 | }, 22 | { 23 | "package": "LoggerAPI", 24 | "repositoryURL": "https://github.com/Kitura/LoggerAPI.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "e82d34eab3f0b05391082b11ea07d3b70d2f65bb", 28 | "version": "1.9.200" 29 | } 30 | }, 31 | { 32 | "package": "swift-log", 33 | "repositoryURL": "https://github.com/apple/swift-log.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "5d66f7ba25daf4f94100e7022febf3c75e37a6c7", 37 | "version": "1.4.2" 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "SSDPClient", 7 | products: [ 8 | .library(name: "SSDPClient", targets: ["SSDPClient"]), 9 | ], 10 | dependencies: [ 11 | .package(url: "https://github.com/Kitura/BlueSocket.git", from: "1.0.200"), 12 | .package(url: "https://github.com/Kitura/HeliumLogger.git", from: "1.9.200"), 13 | ], 14 | targets: [ 15 | .target( 16 | name: "SSDPClient", 17 | dependencies: [ 18 | .product(name: "Socket", package: "BlueSocket"), 19 | .product(name: "HeliumLogger", package: "HeliumLogger") 20 | ] 21 | ), 22 | .testTarget( 23 | name: "SSDPClientTests", 24 | dependencies: ["SSDPClient"]), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSDPClient 2 | 3 | ![](https://img.shields.io/badge/swift-5.10-orange.svg) ![](https://img.shields.io/badge/plataforms-iOS%20%7C%20macOS%20%7C%20tvOS%20%7C%20watchOS%20%7C%20linux-lightgrey.svg) ![gitHub license](https://img.shields.io/badge/license-MIT-blue.svg) [![gitHub release](https://img.shields.io/badge/version-v1.0.0-brightgreen.svg)](https://github.com/pierrickrouxel/SSDPClient/releases) ![github stable](https://img.shields.io/badge/stable-true-brightgreen.svg) [![donate](https://img.shields.io/badge/donate-buy%20me%20a%20coffee-yellow?logo=buy-me-a-coffee)](https://www.buymeacoffee.com/pierrickrouxel) 4 | 5 | [SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) client for Swift using the Swift Package Manager. Works on iOS, macOS, tvOS, watchOS and Linux. 6 | 7 | ## Installation 8 | 9 | [![GitHub spm](https://img.shields.io/badge/spm-supported-brightgreen.svg)](https://swift.org/package-manager/) 10 | 11 | SSDPClient is available through [Swift Package Manager](https://swift.org/package-manager/). To install it, add the following line to your `Package.swift` dependencies: 12 | 13 | ```swift 14 | .package(url: "https://github.com/pierrickrouxel/SSDPClient.git", from: "1.0.0") 15 | ``` 16 | 17 | ## Usage 18 | 19 | SSDPClient can be used to discover SSDP devices and services : 20 | 21 | ```swift 22 | import SSDPClient 23 | 24 | class ServiceDiscovery { 25 | let client = SSDPDiscovery() 26 | 27 | init() { 28 | self.client.delegate = self 29 | self.client.discoverService() 30 | } 31 | } 32 | ``` 33 | 34 | To handle the discovery implement the `SSDPDiscoveryDelegate` protocol : 35 | 36 | ```swift 37 | extension ServiceDiscovery: SSDPDiscoveryDelegate { 38 | func ssdpDiscovery(_: SSDPDiscovery, didDiscoverService: SSDPService) { 39 | // ... 40 | } 41 | } 42 | ``` 43 | 44 | ### Discovery 45 | 46 | `SSDPDiscovery` provides two instance methods to discover services : 47 | 48 | - `discoverService(forDuration duration: TimeInterval = 10, searchTarget: String = "ssdp:all")` - Discover SSDP services for a duration. 49 | 50 | - `stop()` - Stop the discovery before the end. 51 | 52 | ### Delegate 53 | 54 | The `SSDPDiscoveryDelegate` protocol defines delegate methods that you should implement when using `SSDPDiscovery` discover tasks : 55 | 56 | - `func ssdpDiscovery(_ discovery: SSDPDiscovery, didDiscoverService service: SSDPService)` - Tells the delegate a requested service has been discovered. 57 | 58 | - `func ssdpDiscovery(_ discovery: SSDPDiscovery, didFinishWithError error: Error)` - Tells the delegate that the discovery ended due to an error. 59 | 60 | - `func ssdpDiscoveryDidStart(_ discovery: SSDPDiscovery)` - Tells the delegate that the discovery has started. 61 | 62 | - `func ssdpDiscoveryDidFinish(_ discovery: SSDPDiscovery)` - Tells the delegate that the discovery has finished. 63 | 64 | ### Service 65 | 66 | `SSDPService` is the discovered service. It contains the following attributes : 67 | 68 | - `host: String` - The host of service 69 | - `location: String?` - The value of `LOCATION` header 70 | - `server: String?` - The value of `SERVER` header 71 | - `searchTarget: String?` - The value of `ST` header 72 | - `uniqueServiceName: String?` - The value of `USN` header 73 | - `responseHeaders: [String: String]?` - Key-Value pairs of all original response headers 74 | 75 | ## Test 76 | 77 | Run test: 78 | 79 | ```swift 80 | swift test 81 | ``` 82 | 83 | ## Troubleshooting 84 | 85 | ### The application crash with error code `-9982 Bad file descriptor` 86 | 87 | ### iOS 14.0+ 88 | If you are targeting iOS 14.0+ you will need to request a special Multicast Entitlement from Apple: 89 | 90 | [`com.apple.developer.networking.multicast`](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.networking.multicast) 91 | 92 | Without the entitlement, you will not be able to test on an actual device; but you can still test in Simulator. 93 | 94 | Regarding the local network authorization access, you do not need to explicitly ask for it; the OS will ask the user upon the first time you attempt to access the local network. It's best practice that you provide your own description in the Info.plist with the key [`NSLocalNetworkUsageDescription`](https://developer.apple.com/documentation/bundleresources/information-property-list/nslocalnetworkusagedescription). 95 | 96 | ### < iOS 14.0 97 | You probably run a security issue. You should grant the local network authorization access. 98 | 99 | You can handle this error using the following code: 100 | 101 | ```swift 102 | let authorization = LocalNetworkAuthorization() 103 | authorization.requestAuthorization { granted in 104 | if granted { 105 | print("Permission Granted") 106 | let discovery = ServiceDiscovery(delegate: self) 107 | discovery.start() 108 | } else { 109 | print("Permission denied") 110 | } 111 | } 112 | ``` 113 | -------------------------------------------------------------------------------- /Sources/SSDPClient/SSDPDiscovery.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HeliumLogger 3 | import LoggerAPI 4 | import Socket 5 | 6 | // MARK: Protocols 7 | 8 | /// Delegate for service discovery 9 | public protocol SSDPDiscoveryDelegate { 10 | /// Tells the delegate a requested service has been discovered. 11 | func ssdpDiscovery(_ discovery: SSDPDiscovery, didDiscoverService service: SSDPService) 12 | 13 | /// Tells the delegate that the discovery ended due to an error. 14 | func ssdpDiscovery(_ discovery: SSDPDiscovery, didFinishWithError error: Error) 15 | 16 | /// Tells the delegate that the discovery has started. 17 | func ssdpDiscoveryDidStart(_ discovery: SSDPDiscovery) 18 | 19 | /// Tells the delegate that the discovery has finished. 20 | func ssdpDiscoveryDidFinish(_ discovery: SSDPDiscovery) 21 | } 22 | 23 | public extension SSDPDiscoveryDelegate { 24 | func ssdpDiscovery(_ discovery: SSDPDiscovery, didDiscoverService service: SSDPService) {} 25 | 26 | func ssdpDiscovery(_ discovery: SSDPDiscovery, didFinishWithError error: Error) {} 27 | 28 | func ssdpDiscoveryDidStart(_ discovery: SSDPDiscovery) {} 29 | 30 | func ssdpDiscoveryDidFinish(_ discovery: SSDPDiscovery) {} 31 | } 32 | 33 | /// SSDP discovery for UPnP devices on the LAN 34 | public class SSDPDiscovery { 35 | 36 | /// The UDP socket 37 | private var socket: Socket? 38 | 39 | /// Delegate for service discovery 40 | public var delegate: SSDPDiscoveryDelegate? 41 | 42 | /// The client is discovering 43 | public var isDiscovering: Bool { 44 | get { 45 | return self.socket != nil 46 | } 47 | } 48 | 49 | // MARK: Initialisation 50 | 51 | public init() { 52 | HeliumLogger.use() 53 | } 54 | 55 | deinit { 56 | self.stop() 57 | } 58 | 59 | // MARK: Private functions 60 | 61 | /// Read responses. 62 | private func readResponses() { 63 | do { 64 | var data = Data() 65 | let (bytesRead, address) = try self.socket!.readDatagram(into: &data) 66 | 67 | if bytesRead > 0 { 68 | let response = String(data: data, encoding: .utf8) 69 | let (remoteHost, _) = Socket.hostnameAndPort(from: address!)! 70 | Log.debug("Received: \(response!) from \(remoteHost)") 71 | self.delegate?.ssdpDiscovery(self, didDiscoverService: SSDPService(host: remoteHost, response: response!)) 72 | } 73 | 74 | } catch let error { 75 | Log.error("Socket error: \(error)") 76 | self.forceStop() 77 | self.delegate?.ssdpDiscovery(self, didFinishWithError: error) 78 | } 79 | } 80 | 81 | /// Read responses with timeout. 82 | private func readResponses(forDuration duration: TimeInterval) { 83 | let queue = DispatchQueue.global() 84 | 85 | queue.async() { 86 | while self.isDiscovering { 87 | self.readResponses() 88 | } 89 | } 90 | 91 | queue.asyncAfter(deadline: .now() + duration) { [unowned self] in 92 | self.stop() 93 | } 94 | } 95 | 96 | /// Force stop discovery closing the socket. 97 | private func forceStop() { 98 | if self.isDiscovering { 99 | self.socket!.close() 100 | } 101 | self.socket = nil 102 | } 103 | 104 | // MARK: Public functions 105 | 106 | /** 107 | Discover SSDP services for a duration. 108 | - Parameters: 109 | - duration: The amount of time to wait. 110 | - searchTarget: The type of the searched service. 111 | */ 112 | open func discoverService(forDuration duration: TimeInterval = 10, searchTarget: String = "ssdp:all", port: Int32 = 1900) { 113 | Log.info("Start SSDP discovery for \(Int(duration)) duration...") 114 | self.delegate?.ssdpDiscoveryDidStart(self) 115 | 116 | let message = "M-SEARCH * HTTP/1.1\r\n" + 117 | "MAN: \"ssdp:discover\"\r\n" + 118 | "HOST: 239.255.255.250:\(port)\r\n" + 119 | "ST: \(searchTarget)\r\n" + 120 | "MX: \(Int(duration))\r\n\r\n" 121 | 122 | do { 123 | self.socket = try Socket.create(type: .datagram, proto: .udp) 124 | try self.socket!.listen(on: 0) 125 | 126 | self.readResponses(forDuration: duration) 127 | 128 | Log.debug("Send: \(message)") 129 | try self.socket?.write(from: message, to: Socket.createAddress(for: "239.255.255.250", on: port)!) 130 | 131 | } catch let error { 132 | Log.error("Socket error: \(error)") 133 | self.forceStop() 134 | self.delegate?.ssdpDiscovery(self, didFinishWithError: error) 135 | } 136 | } 137 | 138 | /// Stop the discovery before the timeout. 139 | open func stop() { 140 | if self.socket != nil { 141 | Log.info("Stop SSDP discovery") 142 | self.forceStop() 143 | self.delegate?.ssdpDiscoveryDidFinish(self) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/SSDPClient/SSDPService.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | private let HeaderRegex = try! NSRegularExpression(pattern: "^([^\r\n:]+): (.*)$", options: [.anchorsMatchLines]) 4 | 5 | public class SSDPService { 6 | /// The host of service 7 | public internal(set) var host: String 8 | /// The headers of the original response 9 | public internal(set) var responseHeaders: [String: String]? 10 | /// The value of `LOCATION` header 11 | public internal(set) var location: String? 12 | /// The value of `SERVER` header 13 | public internal(set) var server: String? 14 | /// The value of `ST` header 15 | public internal(set) var searchTarget: String? 16 | /// The value of `USN` header 17 | public internal(set) var uniqueServiceName: String? 18 | 19 | // MARK: Initialisation 20 | 21 | /** 22 | Initialize the `SSDPService` with the discovery response. 23 | 24 | - Parameters: 25 | - host: The host of service 26 | - response: The discovery response. 27 | */ 28 | init(host: String, response: String) { 29 | self.host = host 30 | 31 | let headers = self.parse(response) 32 | self.responseHeaders = headers 33 | 34 | self.location = headers["LOCATION"] 35 | self.server = headers["SERVER"] 36 | self.searchTarget = headers["ST"] 37 | self.uniqueServiceName = headers["USN"] 38 | } 39 | 40 | // MARK: Private functions 41 | 42 | /** 43 | Parse the discovery response. 44 | 45 | - Parameters: 46 | - response: The discovery response. 47 | */ 48 | private func parse(_ response: String) -> [String: String] { 49 | var result = [String: String]() 50 | 51 | let matches = HeaderRegex.matches(in: response, range: NSRange(location: 0, length: response.utf16.count)) 52 | for match in matches { 53 | let keyCaptureGroupIndex = match.range(at: 1) 54 | let key = (response as NSString).substring(with: keyCaptureGroupIndex) 55 | let valueCaptureGroupIndex = match.range(at: 2) 56 | let value = (response as NSString).substring(with: valueCaptureGroupIndex) 57 | result[key.uppercased()] = value 58 | } 59 | 60 | return result 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SSDPDiscoveryTests 3 | @testable import SSDPServiceTests 4 | 5 | XCTMain([ 6 | testCase(SSDPDiscoveryTests.allTests), 7 | testCase(SSDPServiceTests.allTests), 8 | ]) 9 | -------------------------------------------------------------------------------- /Tests/SSDPClientTests/SSDPDiscoveryTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SSDPClient 3 | 4 | let duration: TimeInterval = 5 5 | 6 | class SSDPDiscoveryTests: XCTestCase { 7 | static var allTests = [ 8 | ("testDiscoverService", testDiscoverService), 9 | ("testStop", testStop), 10 | ] 11 | 12 | let client = SSDPDiscovery() 13 | 14 | var discoverServiceExpectation: XCTestExpectation? 15 | var startExpectation: XCTestExpectation? 16 | var stopExpectation: XCTestExpectation? 17 | var errorExpectation: XCTestExpectation? 18 | 19 | override func setUp() { 20 | super.setUp() 21 | 22 | self.errorExpectation = expectation(description: "Error") 23 | self.errorExpectation!.isInverted = true 24 | self.client.delegate = self 25 | } 26 | 27 | override func tearDown() { 28 | super.tearDown() 29 | } 30 | 31 | func testDiscoverService() { 32 | self.startExpectation = expectation(description: "Start") 33 | self.discoverServiceExpectation = expectation(description: "DiscoverService") 34 | 35 | self.client.discoverService(forDuration: duration, searchTarget: "ssdp:all", port: 1900) 36 | 37 | wait(for: [self.errorExpectation!, self.startExpectation!, self.discoverServiceExpectation!], timeout: duration) 38 | } 39 | 40 | func testStop() { 41 | self.stopExpectation = expectation(description: "Stop") 42 | self.client.discoverService() 43 | self.client.stop() 44 | wait(for: [self.errorExpectation!, self.stopExpectation!], timeout: duration) 45 | } 46 | } 47 | 48 | extension SSDPDiscoveryTests: SSDPDiscoveryDelegate { 49 | func ssdpDiscovery(_ discovery: SSDPDiscovery, didDiscoverService service: SSDPService) { 50 | self.discoverServiceExpectation?.fulfill() 51 | self.discoverServiceExpectation = nil 52 | } 53 | 54 | func ssdpDiscoveryDidStart(_ discovery: SSDPDiscovery) { 55 | self.startExpectation?.fulfill() 56 | } 57 | 58 | func ssdpDiscoveryDidFinish(_ discovery: SSDPDiscovery) { 59 | self.stopExpectation?.fulfill() 60 | } 61 | 62 | func ssdpDiscovery(_ discovery: SSDPDiscovery, didFinishWithError error: Error) { 63 | self.errorExpectation?.fulfill() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/SSDPClientTests/SSDPServiceTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import SSDPClient 4 | 5 | let response = "HTTP/1.1 200 OK\r\n" + 6 | "CACHE-CONTROL: max-age=120\r\n" + 7 | "ST: upnp:rootdevice\r\n" + 8 | "USN: uuid:111111111-2222-3333-4444-000000000000::upnp:rootdevice\r\n" + 9 | "EXT:\r\n" + 10 | "SERVER: system/system UPnP/1.1 MiniUPnPd/1.8\r\n" + 11 | "LOCATION: http://192.168.1.1:10000/root.xml\r\n" + 12 | "OPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n\r\n" 13 | 14 | let responseCaseInsensitive = "HTTP/1.1 200 OK\r\n" + 15 | "CACHE-CONTROL: max-age=120\r\n" + 16 | "St: upnp:rootdevice\r\n" + 17 | "Usn: uuid:111111111-2222-3333-4444-000000000000::upnp:rootdevice\r\n" + 18 | "EXT:\r\n" + 19 | "Server: system/system UPnP/1.1 MiniUPnPd/1.8\r\n" + 20 | "Location: http://192.168.1.1:10000/root.xml\r\n" + 21 | "OPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n\r\n" 22 | 23 | class SSDPServiceTests: XCTestCase { 24 | static var allTests = [ 25 | ("testParse", testParse), 26 | ("testParseCaseInsensitive", testParseCaseInsensitive), 27 | ] 28 | 29 | func testParse() { 30 | let service = SSDPService(host: "192.168.1.1", response: response) 31 | 32 | XCTAssertEqual("192.168.1.1", service.host) 33 | XCTAssertNotNil(service.searchTarget) 34 | XCTAssertEqual("upnp:rootdevice", service.searchTarget) 35 | XCTAssertNotNil(service.uniqueServiceName) 36 | XCTAssertEqual("uuid:111111111-2222-3333-4444-000000000000::upnp:rootdevice", service.uniqueServiceName) 37 | XCTAssertNotNil(service.server) 38 | XCTAssertEqual("system/system UPnP/1.1 MiniUPnPd/1.8", service.server) 39 | XCTAssertNotNil(service.location) 40 | XCTAssertEqual("http://192.168.1.1:10000/root.xml", service.location) 41 | XCTAssertEqual("max-age=120", service.responseHeaders?["CACHE-CONTROL"]) 42 | XCTAssertEqual("\"http://schemas.upnp.org/upnp/1/0/\"; ns=01", service.responseHeaders?["OPT"]) 43 | } 44 | 45 | func testParseCaseInsensitive() { 46 | let service = SSDPService(host: "192.168.1.1", response: responseCaseInsensitive) 47 | 48 | XCTAssertEqual("192.168.1.1", service.host) 49 | XCTAssertNotNil(service.searchTarget) 50 | XCTAssertEqual("upnp:rootdevice", service.searchTarget) 51 | XCTAssertNotNil(service.uniqueServiceName) 52 | XCTAssertEqual("uuid:111111111-2222-3333-4444-000000000000::upnp:rootdevice", service.uniqueServiceName) 53 | XCTAssertNotNil(service.server) 54 | XCTAssertEqual("system/system UPnP/1.1 MiniUPnPd/1.8", service.server) 55 | XCTAssertNotNil(service.location) 56 | XCTAssertEqual("http://192.168.1.1:10000/root.xml", service.location) 57 | XCTAssertEqual("max-age=120", service.responseHeaders?["CACHE-CONTROL"]) 58 | XCTAssertEqual("\"http://schemas.upnp.org/upnp/1/0/\"; ns=01", service.responseHeaders?["OPT"]) 59 | } 60 | } 61 | --------------------------------------------------------------------------------