From 59dfd9ecb2ac2f6089c5d42b39fd9f9aa7a4eb62 Mon Sep 17 00:00:00 2001 From: Paul Bates Date: Fri, 8 May 2026 21:50:48 -0700 Subject: [PATCH 1/4] v2.0.0: async/await rewrite with NOTIFY broadcast support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete rewrite of the library, dropping every third-party dependency in favor of Network.framework + os.Logger. The public API is async/await end-to-end; the delegate protocol is gone. Headline changes ---------------- * New: passive NOTIFY listening via discovery.notifications() — yields AsyncThrowingStream with .alive / .byebye / .update cases. The 0.5.x library could only send M-SEARCH, never listen for unsolicited device announcements; this is the feature gap that motivated the rewrite. * M-SEARCH discovery is now discovery.search(for:timeout:) returning AsyncThrowingStream. Plus a .collect() convenience for the 80% case ("just give me a deduplicated array"). * Networking moved from CocoaAsyncSocket (GCDAsyncUdpSocket) to Network.framework. A single shared NWConnectionGroup joined to 239.255.255.250:1900 handles both M-SEARCH sends AND receives all unicast replies + multicast NOTIFY broadcasts. Per-search NWConnection was tried and abandoned: connected UDP filters incoming datagrams by expected peer, so unicast replies from device IPs were silently dropped. NWConnectionGroup has no such filter. * SSDPDiscovery is an actor (own mutable state: shared listener handle, subscriber registry). Construction is cheap; defaultDiscovery singleton removed. Bug fixes carried in (regressions present since 2017) ----------------------------------------------------- * SSDPMSearchResponse.cacheControl was 1000× too large because of a stray `* 1000.0` in the parser. Type also changed from Date? (which silently went stale) to TimeInterval? (raw max-age in seconds). * DATE header parsing always returned nil because the DateFormatter had no dateFormat set. Now uses RFC 1123 with en_US_POSIX locale. * Lenient EXT handling — Hue bridges and some Roku firmware omit the EXT header in M-SEARCH responses; the old parser rejected those. New parser accepts them with ext: false. Type renames (typo fixes) ------------------------- * SSDPMSearchRequest.messsageHeader → .messageHeader (3 s's → 2) * SSDPMessageAnnoucement → SSDPMessageAnnouncement (missing 'n') Dependencies removed -------------------- * CocoaAsyncSocket (GCD-based UDP wrapper) * SwiftAbstractLogger (replaced with os.Logger, subsystem com.pryomoax.SwiftSSDP) * nvzqz/Weak (was used for [Weak] tracking; no longer needed with structured concurrency) Platform changes ---------------- * iOS 17, macOS 14, tvOS 17 — new floor (was iOS 10). * watchOS dropped — the platform doesn't support arbitrary multicast group joins, so promising NOTIFY support there was a lie. * Swift 5.9 tools (was unspecified, effectively Swift 4 era). Project layout -------------- * SPM-only. Removed SwiftSSDP.xcodeproj, Cartfile, Cartfile.resolved, Info.plist. Modern Xcode opens Package.swift natively. * Sources moved from SwiftSSDP/ to Sources/SwiftSSDP/ (SPM convention). * New Tests/SwiftSSDPTests/ with 30 Swift Testing tests covering parser, search target round-trips, request serialization, NOTIFY parsing, and end-to-end discovery via MockTransport. * New Examples/ssdp-demo/ — separate SPM package, hand-rolled args, three subcommands: search, listen, diag (network-level diagnostic that bypasses SwiftSSDP for isolating "no results" issues). iOS multicast entitlement ------------------------- NOTIFY listening on iOS / iPadOS / tvOS requires the com.apple.developer.networking.multicast entitlement, which Apple gates behind a manual application form. Documented prominently in README and MIGRATION. SSDPError.multicastEntitlementMissing is surfaced when the join fails for that reason. Documentation ------------- * README.md rewritten with async examples and the entitlement banner. * MIGRATION.md added — 13-section step-by-step from 0.5.x → 2.0.0. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 4 + Cartfile | 10 - Cartfile.resolved | 3 - Examples/ssdp-demo/Package.swift | 27 + .../ssdp-demo/Sources/ssdp-demo/diag.swift | 99 ++ .../ssdp-demo/Sources/ssdp-demo/main.swift | 159 +++ Info.plist | 24 - MIGRATION.md | 231 ++++ Package.swift | 36 +- README.md | 193 ++- Sources/SwiftSSDP/Internal/Logging.swift | 29 + .../Internal/MulticastListener.swift | 227 ++++ .../SwiftSSDP/Internal/NetworkTransport.swift | 46 + .../SwiftSSDP/Internal/SSDPTransport.swift | 47 + Sources/SwiftSSDP/SSDPDiscovery.swift | 249 ++++ Sources/SwiftSSDP/SSDPError.swift | 45 + Sources/SwiftSSDP/SSDPHeaderKeys.swift | 35 + Sources/SwiftSSDP/SSDPHeaders.swift | 81 ++ Sources/SwiftSSDP/SSDPMSearchRequest.swift | 75 ++ Sources/SwiftSSDP/SSDPMSearchResponse.swift | 85 ++ .../SwiftSSDP/SSDPMessageAnnouncement.swift | 23 + Sources/SwiftSSDP/SSDPMessageParser.swift | 222 ++++ Sources/SwiftSSDP/SSDPNotification.swift | 106 ++ Sources/SwiftSSDP/SSDPSearchTarget.swift | 100 ++ Sources/SwiftSSDP/SSDPUPnP.swift | 66 + SwiftSSDP.xcodeproj/project.pbxproj | 1131 ----------------- .../contents.xcworkspacedata | 7 - .../xcshareddata/WorkspaceSettings.xcsettings | 8 - .../xcschemes/SwiftSSDP iOS.xcscheme | 82 -- .../xcschemes/SwiftSSDP macOS.xcscheme | 82 -- .../xcschemes/SwiftSSDP tvOS.xcscheme | 82 -- .../xcschemes/SwiftSSDP watchOS.xcscheme | 82 -- SwiftSSDP/SSDPCommon.swift | 41 - SwiftSSDP/SSDPDiscovery.swift | 299 ----- SwiftSSDP/SSDPDiscoverySession.swift | 229 ---- SwiftSSDP/SSDPMSearchRequest.swift | 71 -- SwiftSSDP/SSDPMSearchResponse.swift | 44 - SwiftSSDP/SSDPResponse.swift | 255 ---- SwiftSSDP/SSDPSearchTarget.swift | 112 -- SwiftSSDP/SSDPUPnP.swift | 37 - .../Fixtures/malformed-missing-ext.txt | 7 + .../Fixtures/msearch-response-hue.txt | 9 + .../Fixtures/msearch-response-sonos.txt | 11 + .../Fixtures/notify-alive-roku.txt | 11 + .../SwiftSSDPTests/Fixtures/notify-byebye.txt | 6 + .../SwiftSSDPTests/Fixtures/notify-update.txt | 10 + Tests/SwiftSSDPTests/MockTransport.swift | 121 ++ Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift | 210 +++ .../SSDPMSearchRequestTests.swift | 61 + .../SSDPMessageParserTests.swift | 230 ++++ .../SSDPSearchTargetTests.swift | 72 ++ 51 files changed, 2860 insertions(+), 2672 deletions(-) delete mode 100644 Cartfile delete mode 100644 Cartfile.resolved create mode 100644 Examples/ssdp-demo/Package.swift create mode 100644 Examples/ssdp-demo/Sources/ssdp-demo/diag.swift create mode 100644 Examples/ssdp-demo/Sources/ssdp-demo/main.swift delete mode 100644 Info.plist create mode 100644 MIGRATION.md create mode 100644 Sources/SwiftSSDP/Internal/Logging.swift create mode 100644 Sources/SwiftSSDP/Internal/MulticastListener.swift create mode 100644 Sources/SwiftSSDP/Internal/NetworkTransport.swift create mode 100644 Sources/SwiftSSDP/Internal/SSDPTransport.swift create mode 100644 Sources/SwiftSSDP/SSDPDiscovery.swift create mode 100644 Sources/SwiftSSDP/SSDPError.swift create mode 100644 Sources/SwiftSSDP/SSDPHeaderKeys.swift create mode 100644 Sources/SwiftSSDP/SSDPHeaders.swift create mode 100644 Sources/SwiftSSDP/SSDPMSearchRequest.swift create mode 100644 Sources/SwiftSSDP/SSDPMSearchResponse.swift create mode 100644 Sources/SwiftSSDP/SSDPMessageAnnouncement.swift create mode 100644 Sources/SwiftSSDP/SSDPMessageParser.swift create mode 100644 Sources/SwiftSSDP/SSDPNotification.swift create mode 100644 Sources/SwiftSSDP/SSDPSearchTarget.swift create mode 100644 Sources/SwiftSSDP/SSDPUPnP.swift delete mode 100644 SwiftSSDP.xcodeproj/project.pbxproj delete mode 100644 SwiftSSDP.xcodeproj/project.xcworkspace/contents.xcworkspacedata delete mode 100644 SwiftSSDP.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP iOS.xcscheme delete mode 100644 SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP macOS.xcscheme delete mode 100644 SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP tvOS.xcscheme delete mode 100644 SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP watchOS.xcscheme delete mode 100644 SwiftSSDP/SSDPCommon.swift delete mode 100644 SwiftSSDP/SSDPDiscovery.swift delete mode 100644 SwiftSSDP/SSDPDiscoverySession.swift delete mode 100644 SwiftSSDP/SSDPMSearchRequest.swift delete mode 100644 SwiftSSDP/SSDPMSearchResponse.swift delete mode 100644 SwiftSSDP/SSDPResponse.swift delete mode 100644 SwiftSSDP/SSDPSearchTarget.swift delete mode 100644 SwiftSSDP/SSDPUPnP.swift create mode 100644 Tests/SwiftSSDPTests/Fixtures/malformed-missing-ext.txt create mode 100644 Tests/SwiftSSDPTests/Fixtures/msearch-response-hue.txt create mode 100644 Tests/SwiftSSDPTests/Fixtures/msearch-response-sonos.txt create mode 100644 Tests/SwiftSSDPTests/Fixtures/notify-alive-roku.txt create mode 100644 Tests/SwiftSSDPTests/Fixtures/notify-byebye.txt create mode 100644 Tests/SwiftSSDPTests/Fixtures/notify-update.txt create mode 100644 Tests/SwiftSSDPTests/MockTransport.swift create mode 100644 Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift create mode 100644 Tests/SwiftSSDPTests/SSDPMSearchRequestTests.swift create mode 100644 Tests/SwiftSSDPTests/SSDPMessageParserTests.swift create mode 100644 Tests/SwiftSSDPTests/SSDPSearchTargetTests.swift diff --git a/.gitignore b/.gitignore index 5eef19d..ca83bc3 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots fastlane/test_output + +## Swift Package Manager +.swiftpm/ +Package.resolved diff --git a/Cartfile b/Cartfile deleted file mode 100644 index 77729f8..0000000 --- a/Cartfile +++ /dev/null @@ -1,10 +0,0 @@ -# SwiftSSDP - -# SwiftAbstractLogger -github "pryomoax/SwiftAbstractLogger.git" ~> 0.3 - -# GCDAsyncSocket -github "robbiehanson/CocoaAsyncSocket" ~> 7.6 - -# Weak -github "nvzqz/Weak" "head" diff --git a/Cartfile.resolved b/Cartfile.resolved deleted file mode 100644 index b4df621..0000000 --- a/Cartfile.resolved +++ /dev/null @@ -1,3 +0,0 @@ -github "nvzqz/Weak" "0da0dea234b2d2aa1c01fabfb8ee35455735569f" -github "pryomoax/SwiftAbstractLogger" "v0.3.0" -github "robbiehanson/CocoaAsyncSocket" "7.6.2" diff --git a/Examples/ssdp-demo/Package.swift b/Examples/ssdp-demo/Package.swift new file mode 100644 index 0000000..501161c --- /dev/null +++ b/Examples/ssdp-demo/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version:5.9 +// +// Package.swift +// ssdp-demo +// +// CLI demo for SwiftSSDP — `ssdp-demo search ` and `ssdp-demo listen`. +// + +import PackageDescription + +let package = Package( + name: "ssdp-demo", + platforms: [ + .macOS(.v14), + ], + dependencies: [ + // Local path-dep back to the parent SwiftSSDP package. + .package(name: "SwiftSSDP", path: "../.."), + ], + targets: [ + .executableTarget( + name: "ssdp-demo", + dependencies: ["SwiftSSDP"], + path: "Sources/ssdp-demo" + ), + ] +) diff --git a/Examples/ssdp-demo/Sources/ssdp-demo/diag.swift b/Examples/ssdp-demo/Sources/ssdp-demo/diag.swift new file mode 100644 index 0000000..356e4e4 --- /dev/null +++ b/Examples/ssdp-demo/Sources/ssdp-demo/diag.swift @@ -0,0 +1,99 @@ +// +// diag.swift +// ssdp-demo +// +// Low-level network diagnostic: bypasses SwiftSSDP entirely and uses Network.framework +// directly. If `ssdp-demo diag` finds devices but `ssdp-demo search` does not, the bug +// is in SwiftSSDP. If neither finds devices, the bug is in your network or entitlements. +// + +import Foundation +import Network + +func runDiag() async { + print("Diagnostic: bypassing SwiftSSDP, using NWConnectionGroup directly.\n") + + let host = NWEndpoint.Host("239.255.255.250") + let port = NWEndpoint.Port(rawValue: 1900)! + + do { + let multicast = try NWMulticastGroup(for: [.hostPort(host: host, port: port)]) + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + let group = NWConnectionGroup(with: multicast, using: params) + + let receivedCount = Counter() + + group.setReceiveHandler(maximumMessageSize: 65_507, rejectOversizedMessages: true) + { message, content, _ in + guard let data = content, !data.isEmpty else { return } + let source = message.remoteEndpoint?.debugDescription ?? "unknown" + let preview = String(data: data.prefix(120), encoding: .utf8) ?? "" + let firstLine = preview.split(whereSeparator: \.isNewline).first ?? "" + Task { await receivedCount.bump() } + print("[recv] from \(source): \(firstLine)") + } + + group.stateUpdateHandler = { state in + print("[state] group state: \(state)") + } + + let queue = DispatchQueue(label: "diag", qos: .utility) + group.start(queue: queue) + + // Wait briefly for ready. + try await Task.sleep(for: .milliseconds(500)) + + // Send an M-SEARCH for ssdp:all. + let msearch = """ + M-SEARCH * HTTP/1.1\r + HOST: 239.255.255.250:1900\r + MAN: "ssdp:discover"\r + MX: 2\r + ST: ssdp:all\r + \r + + """ + let payload = Data(msearch.utf8) + let endpoint = NWEndpoint.hostPort(host: host, port: port) + + print("[send] M-SEARCH ssdp:all → 239.255.255.250:1900\n") + group.send(content: payload, to: endpoint, completion: { error in + if let error { + print("[send] failed: \(error)") + } else { + print("[send] sent successfully") + } + }) + + // Listen for 8 seconds. + try await Task.sleep(for: .seconds(8)) + + print("\n--- diag complete: received \(await receivedCount.value) datagrams ---") + if await receivedCount.value == 0 { + print(""" + + If 0 datagrams were received, possible causes: + 1. No SSDP-emitting devices are reachable on this LAN. + 2. macOS firewall is blocking inbound UDP/1900. + 3. The Wi-Fi network is using AP isolation / client isolation. + 4. Devices are on a different VLAN with no multicast forwarding. + + Try in another terminal: + sudo tcpdump -i en0 -A -n 'host 239.255.255.250 or (udp and port 1900)' + + If tcpdump shows packets but this program shows 0, the bug is in + Network.framework usage. + """) + } + group.cancel() + + } catch { + print("Diag failed at setup: \(error)") + } +} + +actor Counter { + private(set) var value = 0 + func bump() { value += 1 } +} diff --git a/Examples/ssdp-demo/Sources/ssdp-demo/main.swift b/Examples/ssdp-demo/Sources/ssdp-demo/main.swift new file mode 100644 index 0000000..6794db3 --- /dev/null +++ b/Examples/ssdp-demo/Sources/ssdp-demo/main.swift @@ -0,0 +1,159 @@ +// +// main.swift +// ssdp-demo +// +// Live verification harness for SwiftSSDP. Run on a network with at least one UPnP +// device (router, Apple TV, Sonos, Hue bridge, smart TV) and you should see results +// in seconds. +// +// Usage: +// swift run --package-path Examples/ssdp-demo ssdp-demo search [] [--timeout N] +// swift run --package-path Examples/ssdp-demo ssdp-demo listen +// + +import Foundation +import SwiftSSDP + +// stdout is line-buffered by default when connected to a pipe, which hides progress +// output during long-running streams (`listen` especially). Force unbuffered output so +// every print hits the terminal / log file immediately. +setbuf(stdout, nil) + +// MARK: - Argument parsing (hand-rolled, no swift-argument-parser dependency) + +let args = Array(CommandLine.arguments.dropFirst()) + +func usage() -> Never { + print(""" + Usage: + ssdp-demo search [] [--timeout ] + ssdp-demo listen + ssdp-demo diag + ssdp-demo help + + Subcommands: + search Send M-SEARCH and print discovered devices/services. + listen Subscribe to NOTIFY broadcasts indefinitely (Ctrl-C to stop). + diag Low-level network diagnostic that bypasses SwiftSSDP and uses + Network.framework directly. Useful for isolating whether a + 'no results' problem is the library or the network/firewall. + + Targets (defaults to ssdp:all): + ssdp:all Search for any device or service + upnp:rootdevice Search for root devices only + e.g. urn:schemas-upnp-org:device:MediaServer:1 + """) + exit(args.first == "help" ? 0 : 2) +} + +guard let subcommand = args.first else { usage() } + +switch subcommand { +case "search": + let rest = Array(args.dropFirst()) + let target = parseTarget(in: rest) ?? .all + let timeout = parseTimeout(in: rest) ?? 10 + await runSearch(target: target, timeout: timeout) + +case "listen": + await runListen() + +case "diag": + await runDiag() + +case "help", "-h", "--help": + usage() + +default: + print("Unknown subcommand: \(subcommand)\n") + usage() +} + +// MARK: - Subcommand implementations + +func runSearch(target: SSDPSearchTarget, timeout: TimeInterval) async { + let discovery = SSDPDiscovery() + print("Searching for \(target) for up to \(Int(timeout))s …\n") + + do { + var count = 0 + for try await response in discovery.search(for: target, timeout: timeout) { + count += 1 + print("[\(count)] \(response.searchTarget)") + print(" USN: \(response.usn)") + print(" Location: \(response.location)") + if let server = response.server { + print(" Server: \(server)") + } + if let cc = response.cacheControl { + print(" max-age: \(Int(cc))s") + } + print("") + } + print("Search complete — \(count) result(s).") + } catch { + print("Search failed: \(error)") + exit(1) + } +} + +func runListen() async { + let discovery = SSDPDiscovery() + print("Listening for SSDP NOTIFY broadcasts on \(SSDPDiscovery.ssdpHost):\(SSDPDiscovery.ssdpPort) …") + print("(Press Ctrl-C to stop.)\n") + + do { + for try await notification in discovery.notifications() { + switch notification { + case .alive(let ad): + print("→ ALIVE \(ad.notificationTarget)") + print(" USN: \(ad.usn)") + if let loc = ad.location { + print(" at \(loc)") + } + case .byebye(let ad): + print("← BYEBYE \(ad.notificationTarget)") + print(" USN: \(ad.usn)") + case .update(let ad): + print("⟳ UPDATE \(ad.notificationTarget) bootID=\(ad.bootID ?? -1)→\(ad.nextBootID ?? -1)") + print(" USN: \(ad.usn)") + } + print("") + } + } catch let error as SSDPError { + switch error { + case .multicastEntitlementMissing: + print("ERROR: This app is missing the com.apple.developer.networking.multicast entitlement.") + print("On iOS / iPadOS / tvOS, joining the SSDP multicast group requires that entitlement.") + print("Apply for it at: https://developer.apple.com/contact/request/networking-multicast") + print("(macOS does not require it — running this demo on macOS should just work.)") + default: + print("ERROR: \(error)") + } + exit(1) + } catch { + print("ERROR: \(error)") + exit(1) + } +} + +// MARK: - Argument helpers + +func parseTarget(in args: [String]) -> SSDPSearchTarget? { + // First non-flag positional is the target. + var iter = args.makeIterator() + while let arg = iter.next() { + if arg.hasPrefix("--") { + // Skip the value of two-token flags. + _ = iter.next() + continue + } + return SSDPSearchTarget(rawValue: arg) + } + return nil +} + +func parseTimeout(in args: [String]) -> TimeInterval? { + guard let i = args.firstIndex(of: "--timeout"), i + 1 < args.count else { return nil } + return Double(args[i + 1]) +} diff --git a/Info.plist b/Info.plist deleted file mode 100644 index d92c49f..0000000 --- a/Info.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - FMWK - CFBundleShortVersionString - 0.5.3 - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - NSPrincipalClass - - - diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..920a626 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,231 @@ +# Migrating from SwiftSSDP 0.5.x → 2.0.0 + +v2.0.0 is a clean break — the API is async/await, the delegate protocol is gone, and the third-party dependencies (CocoaAsyncSocket, SwiftAbstractLogger, Weak) are removed in favor of `Network.framework` + `os.Logger`. This document walks the migration step by step. + +## Tl;dr + +| Before (0.5.x) | After (2.0.0) | +|---|---| +| `SSDPDiscoveryDelegate` protocol | `for try await … in discovery.search(...)` | +| `SSDPDiscoverySession` | `AsyncThrowingStream` lifecycle | +| `SSDPDiscovery.defaultDiscovery` singleton | `let discovery = SSDPDiscovery()` | +| Combine-style or callback delegate | Native async/await | +| (no NOTIFY support) | `for try await n in discovery.notifications()` | + +## Step-by-step + +### 1. Replace the delegate with `for try await` + +**Before:** + +```swift +class DeviceDiscovery: SSDPDiscoveryDelegate { + var session: SSDPDiscoverySession? + + func searchForDevices() { + let request = SSDPMSearchRequest( + delegate: self, + searchTarget: .deviceType(schema: SSDPSearchTarget.upnpOrgSchema, + deviceType: "ZonePlayer", version: 1) + ) + session = try! SSDPDiscovery.defaultDiscovery.startDiscovery( + request: request, timeout: 10.0) + } + + func discoveredDevice(response: SSDPMSearchResponse, + session: SSDPDiscoverySession) { + print("Found \(response)") + } + func discoveredService(response: SSDPMSearchResponse, + session: SSDPDiscoverySession) {} + func closedSession(_ session: SSDPDiscoverySession) {} +} +``` + +**After:** + +```swift +class DeviceDiscovery { + let discovery = SSDPDiscovery() + + func searchForDevices() async throws { + let target: SSDPSearchTarget = .deviceType( + schema: .upnpOrgSchema, deviceType: "ZonePlayer", version: 1) + + for try await response in discovery.search(for: target, timeout: 10) { + print("Found \(response)") + } + } +} +``` + +The async function naturally captures the lifecycle — when it returns (or throws), the search ends. + +### 2. `SSDPDiscoverySession` is gone + +The session abstraction collapsed into the `AsyncThrowingStream`. Cancellation is automatic — break the `for try await` loop, return from the enclosing function, or cancel the parent `Task`. The library cleans up the underlying socket. + +If you previously stored the session as `var session: SSDPDiscoverySession?` and called `session?.close()` to stop, you now hold a `Task` (or just structured-concurrency scope) and call `task.cancel()`: + +```swift +var searchTask: Task? + +func searchForDevices() { + searchTask = Task { + for try await response in discovery.search(for: .rootDevice) { + // ... + } + } +} + +func stopSearching() { + searchTask?.cancel() +} +``` + +### 3. The `defaultDiscovery` singleton is removed + +Instantiate your own. `SSDPDiscovery()` is cheap. + +```swift +// Before +let discovery = SSDPDiscovery.defaultDiscovery + +// After +let discovery = SSDPDiscovery() // hold this in your AppDelegate, @State, or an Observable +``` + +If you really want a single shared instance app-wide, set one up yourself: + +```swift +extension SSDPDiscovery { + static let shared = SSDPDiscovery() +} +``` + +### 4. `SSDPMSearchRequest` no longer takes a `delegate` + +The `delegate:` parameter is gone — there's no delegate to plumb anymore. + +```swift +// Before +let request = SSDPMSearchRequest(delegate: self, searchTarget: target) + +// After +let request = SSDPMSearchRequest(searchTarget: target) +``` + +`maxWait` and `otherHeaders` parameters are unchanged. + +### 5. NOTIFY listening is now possible + +This was the headline missing feature. v2.0 implements it: + +```swift +for try await notification in discovery.notifications() { + switch notification { + case .alive(let ad): print("→ \(ad.usn)") + case .byebye(let ad): print("← \(ad.usn)") + case .update(let ad): print("⟳ \(ad.usn)") + } +} +``` + +**iOS/iPadOS/tvOS apps need the `com.apple.developer.networking.multicast` entitlement** — see the README's *iOS multicast entitlement* section. + +### 6. Renames (typo fixes) + +These public symbols had typos in 0.5.x; v2.0 spells them correctly: + +| Old | New | +|---|---| +| `SSDPMSearchRequest.messsageHeader` (3 s's) | `SSDPMSearchRequest.messageHeader` | +| `SSDPMessageAnnoucement` (missing 'n') | `SSDPMessageAnnouncement` | + +### 7. `SSDPMSearchResponse.cacheControl` semantic change + +In 0.5.x this was a `Date?` computed at parse time — useless after a few seconds in a long-running process, and broken by a `× 1000` bug that always made it 1000× too large. + +In v2.0 it's the raw `max-age` in seconds: + +```swift +// Before — Date?, possibly garbage +if let expires = response.cacheControl, expires < Date() { /* stale */ } + +// After — TimeInterval? in seconds +if let maxAge = response.cacheControl { + let expires = receivedAt.addingTimeInterval(maxAge) + if expires < Date() { /* stale */ } +} +``` + +If you depended on the old (buggy) behavior, replace it with the snippet above. + +### 8. `SSDPDiscovery.startDiscovery` and `stopAllDiscovery` are gone + +Replaced by the per-call streams. There's no global "stop everything" — cancel individual `Task`s, or scope them in `withTaskGroup` and cancel the group. + +### 9. Headers: `SSDPHeaders` is now a struct, not a typealias + +`SSDPHeaders` is now a case-insensitive `struct` wrapper around `[String: String]`, conforming to `ExpressibleByDictionaryLiteral`: + +```swift +// Before — plain dictionary +let headers: SSDPHeaders = ["X-Custom": "value"] // type was [String: String] + +// After — same call site works thanks to ExpressibleByDictionaryLiteral +let headers: SSDPHeaders = ["X-Custom": "value"] // type is SSDPHeaders +``` + +If you held the dictionary directly: use `headers.asDictionary` to get `[String: String]`. + +### 10. NOTIFY payload type — `SSDPAdvertisement` + +NOTIFY messages parse into `SSDPAdvertisement` (not `SSDPMSearchResponse`). The fields differ slightly: + +- `notificationTarget` (NT header), not `searchTarget` (ST header) +- No `ext` field (NOTIFY messages have no `EXT`) +- `location` is **optional** — `byebye` notifications omit it +- `bootID`, `configID`, `nextBootID` (UPnP 1.1) surfaced as typed `Int?` fields + +### 11. Logging + +`SwiftAbstractLogger` is gone. The library now uses `os.Logger`. Filter logs in Console.app or `log stream` by subsystem `com.pryomoax.SwiftSSDP`. Categories: `discovery`, `transport`, `listener`, `parser`. + +```sh +log stream --predicate 'subsystem == "com.pryomoax.SwiftSSDP"' --level debug +``` + +### 12. Removed dependencies + +You can drop these from your `Package.swift` if SwiftSSDP was the only consumer: + +- `CocoaAsyncSocket` +- `SwiftAbstractLogger` +- `Weak` (the nvzqz/Weak.swift package) + +### 13. Platform requirements changed + +| | Before | After | +|---|---|---| +| Swift | 4.0+ | 5.9+ | +| iOS | 10.0+ | 17.0+ | +| macOS | (project-target only) | 14.0+ | +| tvOS | (project-target only) | 17.0+ | +| watchOS | listed in project | **dropped** (multicast unavailable) | + +The platform bump is driven by: +- `Network.framework`'s `NWConnectionGroup` + `NWMulticastGroup` (iOS 14+, but matured significantly in 17). +- Modern `AsyncThrowingStream.makeStream(of:bufferingPolicy:)` ergonomics. +- `os.Logger` improvements. +- `Task.sleep(for: Duration)`. + +## If you can't migrate yet + +The 0.5.3 release is still tagged in this repository; pin to it: + +```swift +.package(url: "https://github.com/pryomoax/SwiftSSDP.git", .exact("0.5.3")) +``` + +Note that 0.5.x is unmaintained and its `cacheControl` and `DATE` parsing remain broken. diff --git a/Package.swift b/Package.swift index e570f29..f48d6db 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,38 @@ +// swift-tools-version:5.9 +// +// Package.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + import PackageDescription let package = Package( name: "SwiftSSDP", - dependencies: [ - .Package(url: "https://github.com/pryomoax/SwiftAbstractLogger.git", majorVersion: 0, minor: 3), - .Package(url: "https://github.com/robbiehanson/CocoaAsyncSocket.git", majorVersion: 7, minor: 6), - .Package(url: "https://github.com/nvzqz/Weak.git", majorVersion: 1) + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + ], + products: [ + .library( + name: "SwiftSSDP", + targets: ["SwiftSSDP"] + ), + ], + targets: [ + .target( + name: "SwiftSSDP", + path: "Sources/SwiftSSDP" + ), + .testTarget( + name: "SwiftSSDPTests", + dependencies: ["SwiftSSDP"], + path: "Tests/SwiftSSDPTests", + resources: [ + .copy("Fixtures"), + ] + ), ] ) diff --git a/README.md b/README.md index fe71efc..68fba11 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,164 @@ -# SwiftSSDP ![](https://img.shields.io/badge/swift-4.0-orange.svg) [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/pryomoax/SwiftSSDP/blob/master/LICENSE) [![GitHub release](https://img.shields.io/badge/version-v0.5.1-brightgreen.svg)](https://github.com/pryomoax/SwiftSSDP/releases) ![Github stable](https://img.shields.io/badge/stable-true-brightgreen.svg) +# SwiftSSDP + +![Swift 5.9](https://img.shields.io/badge/swift-5.9-orange.svg) +![Platforms](https://img.shields.io/badge/platforms-iOS%2017%20%7C%20macOS%2014%20%7C%20tvOS%2017-blue.svg) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +![Version](https://img.shields.io/badge/version-2.0.0-brightgreen.svg) + +A modern Swift package for [Simple Service Discovery Protocol](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) (SSDP) — the discovery layer of UPnP. SwiftSSDP supports both: -> Update: Unfortunately I do not have time to maintain this package +- **Active discovery** — sending M-SEARCH broadcasts and collecting responses. +- **Passive listening** — subscribing to unsolicited NOTIFY broadcasts (`alive`, `byebye`, `update`). -Simple Service Discovery Protocol ([SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol)) session based discovery package for Swift. +The whole API is async/await — no delegates, no Combine, no callbacks. The only dependency is Apple's `Network.framework`. -# Package Management +> **v2.0.0 is a breaking re-debut.** If you're upgrading from 0.5.x see [MIGRATION.md](MIGRATION.md). (No 1.x was ever released — the version jump goes 0.5.x → 2.0.0 to signal a breaking change without overloading 1.0 semantics that some consumers may have already pinned against.) ## Installation -[![GitHub spm](https://img.shields.io/badge/spm-supported-brightgreen.svg)](https://swift.org/package-manager/) -[![GitHub carthage](https://img.shields.io/badge/carthage-supported-brightgreen.svg)](https://github.com/Carthage/Carthage) -[![GitHub cocoapod](https://img.shields.io/badge/cocoapods-soon-red.svg)](http://cocoapods.org/) -### Using Swift Package Manager -SwiftSSDP is available through [Swift Package Manager](https://swift.org/package-manager/). To install it, add the following line to your `Package.swift` dependencies: +Add SwiftSSDP via Swift Package Manager: -``` -.Package(url: "https://github.com/pryomoax/SwiftSSDP.git", majorVersion: 0, minor: 5) +```swift +.package(url: "https://github.com/pryomoax/SwiftSSDP.git", from: "2.0.0") ``` -### Using Carthage -SwiftSSDP is available through [Carthage](https://github.com/Carthage/Carthage). To install it, add the following line to your `Cartfile`: +Then add `"SwiftSSDP"` to the dependencies of any target that needs it. SwiftSSDP is SPM-only — no Carthage, no CocoaPods. -``` -# SwiftSSDP -github "pryomoax/SwiftSSDP.git" ~> 0.5 -``` +## Usage -### Using CocoaPods +### Active discovery (M-SEARCH) -SwiftSSDP is currently not supported by CocoaPods (coming soon) +```swift +import SwiftSSDP -# Usage +let discovery = SSDPDiscovery() -[SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) can be used for many things, discovering devices or services. Sonos uses SSDP for device discovery and using the `urn:schemas-upnp-org:device:ZonePlayer:1` search target (ST) devices can be discovered and inspected. +// Streaming form — see results as they arrive. +for try await response in discovery.search(for: .rootDevice, timeout: 10) { + print("Found \(response.usn) at \(response.location)") +} +``` -Below is a simple class to start and stop Sonos device discovery. It uses a `10` second timeout, which will automatically close the discovery session `session` if not closed explictly. +For convenience, collect everything into a deduplicated array: -[SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) makes use of [UDP](https://en.wikipedia.org/wiki/User_Datagram_Protocol), which is an unreliable transport, and even less reliable over WiFi. SwiftSSDP automatically repeats [MSEARCH](http://www.upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0-20080424.pdf) broadcasts to ensure discovery of all devices. SwiftSSDP gradually backs off the interval between MSEARCH broadcasts are sent from 1/second to 1/minute. Discovery should be short lived as not to flood the network with broadcasts. Without a timeout the session should be closed explictly. +```swift +let devices = try await discovery + .search(for: .mediaServer, timeout: 10) + .collect() +print("Found \(devices.count) media servers") +``` -## Timed Sessions +For finer control (custom headers, longer `MX`), pass an explicit `SSDPMSearchRequest`: ```swift -public class DeviceDiscovery { - - private let discovery: SSDPDiscovery = SSDPDiscovery.defaultDiscovery - fileprivate var session: SSDPDiscoverySession? - - public func searchForDevices() { - // Create the request for Sonos ZonePlayer devices - let zonePlayerTarget = SSDPSearchTarget.deviceType(schema: SSDPSearchTarget.upnpOrgSchema, deviceType: "ZonePlayer", version: 1) - let request = SSDPMSearchRequest(delegate: self, searchTarget: zonePlayerTarget) - - // Start a discovery session for the request and timeout after 10 seconds of searching. - self.session = try! discovery.startDiscovery(request: request, timeout: 10.0) - } - - public func stopSearching() { - self.session?.close() - self.session = nil - } - +let request = SSDPMSearchRequest( + searchTarget: .deviceType(schema: .upnpOrgSchema, deviceType: "ZonePlayer", version: 1), + maxWait: 3, + otherHeaders: ["X-MyApp-Token": "abc123"] +) +for try await response in discovery.search(request, timeout: 10) { + print(response) } ``` -To handle the discovery implement the `SSDPDiscoveryDelegate` protocol, and use when initializing a `SSDPMSearchReqest` +The library follows UPnP recommendations and retransmits the M-SEARCH at a stepped cadence (1s up to 5s elapsed → 3s up to 10s → 10s up to 60s → 60s thereafter). UDP is unreliable, especially over Wi-Fi, so this materially improves discovery completeness. + +### Passive listening (NOTIFY) + +> **iOS / iPadOS / tvOS apps must request the multicast entitlement.** See [iOS multicast entitlement](#ios-multicast-entitlement) below before deploying. ```swift -extension DeviceDiscovery: SSDPDiscoveryDelegate { - - public func discoveredDevice(response: SSDPMSearchResponse, session: SSDPDiscoverySession) { - print("Found device \(response)\n") - } - - public func discoveredService(response: SSDPMSearchResponse, session: SSDPDiscoverySession) { - } - - public func closedSession(_ session: SSDPDiscoverySession) { - print("Session closed\n") - } +let discovery = SSDPDiscovery() + +for try await notification in discovery.notifications() { + switch notification { + case .alive(let advertisement): + print("→ \(advertisement.usn) joined at \(advertisement.location?.absoluteString ?? "?")") + case .byebye(let advertisement): + print("← \(advertisement.usn) left") + case .update(let advertisement): + print("⟳ \(advertisement.usn) updated boot ID") + } +} +``` + +Notification streams are long-lived — they continue until the consumer breaks the `for try await` loop. Multiple concurrent calls share one underlying multicast group join; the join is reference-counted and tears down when the last subscriber cancels. + +Filtering is just a `where` clause: +```swift +for try await n in discovery.notifications() where n.notificationTarget == .rootDevice { + print("Root device: \(n.advertisement.usn)") } ``` -# Logging -SwiftSSDP uses [SwiftAbstractLogger](https://github.com/pryomoax/SwiftAbstractLogger) for all logging. Logging can be independently configured for SwiftSSDP using the log category "SSDP". For convenience this is accessible via the `loggerDiscoveryCategory` constant. +### Search targets + +Common UPnP forum-defined targets are available as static members: + +```swift +SSDPSearchTarget.all // ssdp:all +SSDPSearchTarget.rootDevice // upnp:rootdevice +SSDPSearchTarget.mediaServer // urn:schemas-upnp-org:device:MediaServer:1 +SSDPSearchTarget.mediaRenderer +SSDPSearchTarget.internetGatewayDevice +SSDPSearchTarget.avTransportService // urn:schemas-upnp-org:service:AVTransport:1 +SSDPSearchTarget.contentDirectoryService +// …and more — see SSDPUPnP.swift +``` + +For other vendors or versions, build the target directly: ```swift -// Attach a default (basic) console logger implementation to Logger -Logger.attach(BasicConsoleLogger.logger) +let zonePlayer: SSDPSearchTarget = .deviceType( + schema: .upnpOrgSchema, + deviceType: "ZonePlayer", + version: 1 +) +``` + +## iOS multicast entitlement + +On iOS, iPadOS, and tvOS (14+), joining the SSDP multicast group `239.255.255.250` requires the **`com.apple.developer.networking.multicast`** entitlement. Apple gates this entitlement behind a manual application form: + +> + +Without the entitlement, `discovery.notifications()` will throw `SSDPError.multicastEntitlementMissing` on the first iteration. `discovery.search(...)` does not require the entitlement (M-SEARCH unicast replies don't need group membership). + +**macOS does not require this entitlement.** Run the demo CLI on a Mac to verify behavior before deploying to iOS. + +## Demo -// Enable debug logging only for SSDPSwift -Logger.configureLevel(category: loggerDiscoveryCategory, level: .Debug) +A small CLI tool lives under `Examples/ssdp-demo`: + +```sh +# Search the LAN for ten seconds and print everything. +swift run --package-path Examples/ssdp-demo ssdp-demo search ssdp:all --timeout 10 + +# Listen for NOTIFY broadcasts indefinitely (Ctrl-C to stop). +swift run --package-path Examples/ssdp-demo ssdp-demo listen ``` -# Package Information +## Logging -## Requirements +SwiftSSDP logs through `os.Logger` under the subsystem `com.pryomoax.SwiftSSDP`. View live logs in Console.app (filter by subsystem) or via `log stream`: -* Xcode 8 -* iOS 10.0+ +```sh +log stream --predicate 'subsystem == "com.pryomoax.SwiftSSDP"' --level debug +``` -## Author +Categories: `discovery`, `transport`, `listener`, `parser`. -Paul Bates, **[paul.a.bates@gmail.com](mailto:paul.a.bates@gmail.com)** +## Requirements + +- **Swift:** 5.9+ +- **Xcode:** 15+ +- **Platforms:** iOS 17, macOS 14, tvOS 17 (watchOS not supported — multicast is unavailable on watchOS) ## License -SwiftSSDP is available under the **MIT license**. See the `LICENSE` file for more +MIT — see [LICENSE](LICENSE). + +## Author + +Paul Bates · [paul.a.bates@gmail.com](mailto:paul.a.bates@gmail.com) diff --git a/Sources/SwiftSSDP/Internal/Logging.swift b/Sources/SwiftSSDP/Internal/Logging.swift new file mode 100644 index 0000000..91db855 --- /dev/null +++ b/Sources/SwiftSSDP/Internal/Logging.swift @@ -0,0 +1,29 @@ +// +// Logging.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import os + +/// Internal logging facade. +/// +/// `os.Logger` is platform-native unified logging — viewable in Console.app and `log stream` on +/// macOS, surfaced in Xcode's debug console, and zero-cost when nothing is reading. +/// All logs are emitted under the subsystem `com.pryomoax.SwiftSSDP`; categories distinguish +/// the source area (transport, parser, listener). Privacy markers default to `.private`, +/// so anything you'd want to read in Console at runtime needs an explicit `.public` marker. +enum SSDPLog { + /// Subsystem identifier for all SwiftSSDP logs. + static let subsystem = "com.pryomoax.SwiftSSDP" + + /// Logger for the public `SSDPDiscovery` actor and lifecycle events. + static let discovery = Logger(subsystem: subsystem, category: "discovery") + /// Logger for the M-SEARCH transport (`NetworkTransport`). + static let transport = Logger(subsystem: subsystem, category: "transport") + /// Logger for the multicast NOTIFY listener. + static let listener = Logger(subsystem: subsystem, category: "listener") + /// Logger for SSDP message parsing. + static let parser = Logger(subsystem: subsystem, category: "parser") +} diff --git a/Sources/SwiftSSDP/Internal/MulticastListener.swift b/Sources/SwiftSSDP/Internal/MulticastListener.swift new file mode 100644 index 0000000..c99078a --- /dev/null +++ b/Sources/SwiftSSDP/Internal/MulticastListener.swift @@ -0,0 +1,227 @@ +// +// MulticastListener.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation +import Network + +/// Shared, ref-counted SSDP socket bound to the multicast group `239.255.255.250:1900`. +/// +/// One `NWConnectionGroup` per process holds the IGMP membership, sends M-SEARCH +/// broadcasts, and receives every datagram destined for UDP/1900 — both unsolicited +/// NOTIFY multicasts *and* unicast M-SEARCH replies (which the kernel delivers to the +/// bound port irrespective of source address). +/// +/// > Why this matters: an `NWConnection` "connected" to a multicast destination can send +/// > but cannot receive unicast replies from arbitrary sources, because Network.framework +/// > filters incoming datagrams against the connection's expected peer. A +/// > `NWConnectionGroup` has no such filter — every datagram arriving on its bound port +/// > is delivered to the receive handler. This single-socket design is therefore not just +/// > simpler than per-search `NWConnection`s, it's the only thing that actually works. +/// +/// Subscribers fan out from the one socket. The group spins up on the first subscriber +/// and tears down when the last subscriber cancels. +/// +/// On iOS / iPadOS / tvOS, joining `239.255.255.250` requires the +/// `com.apple.developer.networking.multicast` entitlement. +actor MulticastListener { + + enum State: Sendable { + case idle + case starting + case ready + case failed(SSDPError) + } + + private var state: State = .idle + private var group: NWConnectionGroup? + + /// All subscribers — both NOTIFY streams and per-search streams use the same fan-out + /// machinery; filtering happens one layer up in ``SSDPDiscovery``. + private var subscribers: [UUID: AsyncThrowingStream.Continuation] = [:] + + private static let queue = DispatchQueue( + label: "com.pryomoax.SwiftSSDP.MulticastListener", + qos: .utility + ) + + init() {} + + // MARK: - Subscription + + /// Register a new subscriber and return its stream. + /// + /// Each subscriber receives every datagram the group sees — NOTIFY broadcasts and + /// unicast M-SEARCH replies alike. Filtering by message type / search target is the + /// caller's responsibility. The underlying group stays up as long as at least one + /// subscriber exists. + func subscribe() async throws -> AsyncThrowingStream { + let id = UUID() + let (stream, continuation) = AsyncThrowingStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + + continuation.onTermination = { [weak self] _ in + Task { await self?.unsubscribe(id: id) } + } + + subscribers[id] = continuation + + switch state { + case .idle: + await start() + case .starting, .ready: + break + case .failed(let error): + // Reset and surface the error to this subscriber. Next call will retry. + state = .idle + continuation.finish(throwing: error) + subscribers.removeValue(forKey: id) + throw error + } + + return stream + } + + private func unsubscribe(id: UUID) { + subscribers.removeValue(forKey: id) + if subscribers.isEmpty { + teardownGroup() + } + } + + // MARK: - Send + + /// Send a UDP datagram to the SSDP multicast endpoint via the shared group. + /// + /// If the group isn't ready yet, this method waits (with a short bound) for it to + /// reach `.ready`. Throws if the group has failed. + func send(_ data: Data) async throws { + // Wait briefly for ready. The group is started lazily on first subscribe; if no + // one has subscribed we cannot send — surface a clear error rather than silently + // dropping. + let waitDeadline = ContinuousClock.now + .seconds(2) + while case .starting = state, ContinuousClock.now < waitDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + + switch state { + case .ready: + break + case .failed(let err): + throw err + case .idle, .starting: + throw SSDPError.transportFailed( + details: "Multicast group not ready (state: \(state))" + ) + } + + guard let group = self.group else { + throw SSDPError.transportFailed(details: "Multicast group missing despite ready state") + } + + let host = NWEndpoint.Host(SSDPMSearchRequest.ssdpHost) + let port = NWEndpoint.Port(rawValue: UInt16(SSDPMSearchRequest.ssdpPort))! + let endpoint = NWEndpoint.hostPort(host: host, port: port) + + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in + group.send(content: data, to: endpoint, completion: { error in + if let error { + cont.resume(throwing: SSDPError.transportFailed(details: "\(error)")) + } else { + cont.resume() + } + }) + } + } + + // MARK: - Group lifecycle + + private func start() async { + guard case .idle = state else { return } + state = .starting + + do { + let host = NWEndpoint.Host(SSDPMSearchRequest.ssdpHost) + let port = NWEndpoint.Port(rawValue: UInt16(SSDPMSearchRequest.ssdpPort))! + let multicast = try NWMulticastGroup(for: [.hostPort(host: host, port: port)]) + + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + let group = NWConnectionGroup(with: multicast, using: params) + + group.setReceiveHandler(maximumMessageSize: 65_507, rejectOversizedMessages: true) + { [weak self] message, content, _ in + guard let self, let data = content, !data.isEmpty else { return } + let source = message.remoteEndpoint?.debugDescription ?? "" + let datagram = SSDPDatagram(data: data, source: source) + Task { await self.dispatch(datagram) } + } + + group.stateUpdateHandler = { [weak self] newState in + guard let self else { return } + Task { await self.handle(state: newState) } + } + + self.group = group + group.start(queue: Self.queue) + + } catch { + await fail(.multicastJoinFailed(details: "\(error)")) + } + } + + private func handle(state newState: NWConnectionGroup.State) async { + switch newState { + case .ready: + self.state = .ready + SSDPLog.listener.info("SSDP socket ready (multicast group joined)") + case .failed(let error): + await fail(mapError(error)) + case .cancelled, .setup, .waiting: + break + @unknown default: + break + } + } + + /// Map an `NWError` to the closest `SSDPError`, recognizing the multicast-entitlement + /// signature when possible. + private func mapError(_ error: NWError) -> SSDPError { + let raw = "\(error)" + if raw.contains("multicast") || raw.contains("PolicyDenied") + || raw.contains("operation not permitted") + { + return .multicastEntitlementMissing + } + return .multicastJoinFailed(details: raw) + } + + private func fail(_ error: SSDPError) async { + SSDPLog.listener.error("SSDP socket failed: \(String(describing: error), privacy: .public)") + state = .failed(error) + for cont in subscribers.values { + cont.finish(throwing: error) + } + subscribers.removeAll() + teardownGroup() + } + + private func dispatch(_ datagram: SSDPDatagram) { + // Snapshot to avoid mutation-during-iteration if a yield triggers a subscriber's + // termination handler (which removes from the dictionary). + let snapshot = Array(subscribers.values) + for cont in snapshot { + cont.yield(datagram) + } + } + + private func teardownGroup() { + group?.cancel() + group = nil + state = .idle + } +} diff --git a/Sources/SwiftSSDP/Internal/NetworkTransport.swift b/Sources/SwiftSSDP/Internal/NetworkTransport.swift new file mode 100644 index 0000000..7c5704c --- /dev/null +++ b/Sources/SwiftSSDP/Internal/NetworkTransport.swift @@ -0,0 +1,46 @@ +// +// NetworkTransport.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation +import Network + +/// Production ``SSDPTransport`` — a thin wrapper around a single shared +/// ``MulticastListener``. +/// +/// Both M-SEARCH and NOTIFY traffic flows through one socket: the multicast group at +/// `239.255.255.250:1900`. Every datagram (multicast NOTIFY, our looped-back M-SEARCH, +/// unicast replies from devices) lands in the listener's receive handler and fans out to +/// all subscribers. Filtering by message type / search target happens one layer up in +/// ``SSDPDiscovery``. +/// +/// > Earlier versions of this transport tried to use per-search `NWConnection`s for +/// > M-SEARCH. That doesn't work — `NWConnection` filters incoming datagrams against the +/// > connection's expected peer, and unicast replies from device IPs don't match the +/// > multicast destination peer. Use `NWConnectionGroup` for everything. +final class NetworkTransport: SSDPTransport, Sendable { + + private let listener = MulticastListener() + + init() {} + + // MARK: - SSDPTransport + + func sendSearch(_ request: SSDPMSearchRequest) + async throws -> AsyncThrowingStream + { + // Subscribe first so we don't miss replies that arrive before send completes. + let stream = try await listener.subscribe() + try await listener.send(Data(request.message.utf8)) + return stream + } + + func multicastDatagrams() + async throws -> AsyncThrowingStream + { + try await listener.subscribe() + } +} diff --git a/Sources/SwiftSSDP/Internal/SSDPTransport.swift b/Sources/SwiftSSDP/Internal/SSDPTransport.swift new file mode 100644 index 0000000..48136b8 --- /dev/null +++ b/Sources/SwiftSSDP/Internal/SSDPTransport.swift @@ -0,0 +1,47 @@ +// +// SSDPTransport.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// A wire-level UDP datagram received from the network. +/// +/// The transport returns these as opaque byte buffers; parsing into ``SSDPMessage`` +/// happens one layer up. Source endpoint is captured for diagnostic logging. +struct SSDPDatagram: Sendable { + let data: Data + /// Best-effort source description (e.g. `"192.168.1.42:1900"`). Empty if unavailable. + let source: String +} + +/// The seam between ``SSDPDiscovery`` and the underlying network transport. +/// +/// Production code uses ``NetworkTransport`` (built on `Network.framework`). Tests use +/// `MockTransport` to inject canned datagrams without touching the network. +/// +/// Two responsibilities, deliberately on one protocol: +/// +/// - ``sendSearch(_:)`` opens a transient unicast/multicast send for one M-SEARCH and +/// returns a stream of datagrams arriving on the source port (the unicast replies). +/// The stream finishes when the consumer cancels or the transport tears down. +/// - ``multicastDatagrams()`` returns the shared multicast NOTIFY stream. Multiple +/// callers fan out from one underlying socket. +protocol SSDPTransport: Sendable { + /// Send a single M-SEARCH and start receiving unicast replies on its source port. + /// + /// The returned stream finishes (without throwing) when the consumer cancels its + /// `for try await` loop. Throws if the connection setup fails. + func sendSearch(_ request: SSDPMSearchRequest) + async throws -> AsyncThrowingStream + + /// Subscribe to the shared multicast NOTIFY stream. + /// + /// Multiple subscribers fan out from one underlying multicast group join; the join + /// is reference-counted so the socket tears down when the last subscriber cancels. + /// Throws if the multicast group cannot be joined (e.g. missing entitlement on iOS). + func multicastDatagrams() + async throws -> AsyncThrowingStream +} diff --git a/Sources/SwiftSSDP/SSDPDiscovery.swift b/Sources/SwiftSSDP/SSDPDiscovery.swift new file mode 100644 index 0000000..789dbbd --- /dev/null +++ b/Sources/SwiftSSDP/SSDPDiscovery.swift @@ -0,0 +1,249 @@ +// +// SSDPDiscovery.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// Entry point for SSDP discovery — both active M-SEARCH and passive NOTIFY listening. +/// +/// Two operations: +/// +/// - ``search(for:maxWait:timeout:)`` (and the request-form ``search(_:timeout:)``) sends +/// M-SEARCH broadcasts and yields the matching responses as an +/// `AsyncThrowingStream`. The stream finishes when the +/// timeout elapses or the consumer breaks the `for try await` loop. +/// +/// - ``notifications()`` returns a long-lived `AsyncThrowingStream` +/// that yields unsolicited SSDP NOTIFY broadcasts (`alive`, `byebye`, `update`) on the +/// shared multicast group. Multiple concurrent consumers fan out from one underlying +/// listener, which tears down when the last consumer cancels. +/// +/// `SSDPDiscovery` is an `actor` because it owns mutable lifecycle state (the shared +/// multicast listener handle, in-flight searches). Construction is cheap; consumers can +/// hold a single instance for the app's lifetime, or per-feature instances if desired. +/// +/// ## Multicast entitlement (iOS / iPadOS / tvOS) +/// +/// On iOS, iPadOS, and tvOS, joining the SSDP multicast group requires the +/// `com.apple.developer.networking.multicast` entitlement, which Apple gates behind a +/// manual application form. Without it, ``notifications()`` will throw +/// ``SSDPError/multicastEntitlementMissing``. macOS does not require it. +public actor SSDPDiscovery { + + /// SSDP multicast group address (`239.255.255.250`). + public static let ssdpHost = SSDPMSearchRequest.ssdpHost + /// SSDP multicast port (`1900`). + public static let ssdpPort = SSDPMSearchRequest.ssdpPort + + private let transport: SSDPTransport + + /// Create a discovery using the default `Network.framework`-backed transport. + public init() { + self.transport = NetworkTransport() + } + + /// Create a discovery with a custom transport (primarily for testing). + init(transport: SSDPTransport) { + self.transport = transport + } + + // MARK: - M-SEARCH + + /// Convenience search by target. See ``search(_:timeout:)`` for full semantics. + public nonisolated func search( + for target: SSDPSearchTarget, + maxWait: Int = 1, + timeout: TimeInterval? = nil + ) -> AsyncThrowingStream { + let request = SSDPMSearchRequest(searchTarget: target, maxWait: maxWait) + return search(request, timeout: timeout) + } + + /// Send M-SEARCH broadcasts for `request` and yield matching responses. + /// + /// The library follows UPnP recommendations and retransmits the M-SEARCH at a stepped + /// cadence (1s up to 5s elapsed → 3s up to 10s → 10s up to 60s → 60s thereafter) for + /// reliability over UDP, especially on Wi-Fi. + /// + /// The stream finishes when: + /// + /// - `timeout` (if provided) elapses — stream finishes cleanly, even if zero responses. + /// - The consumer breaks the `for try await` loop — stream and underlying socket end. + /// - The transport fails — stream throws ``SSDPError/transportFailed(details:)``. + public nonisolated func search( + _ request: SSDPMSearchRequest, + timeout: TimeInterval? = nil + ) -> AsyncThrowingStream { + let transport = self.transport + return AsyncThrowingStream { continuation in + // Box for child tasks so onTermination can cancel them. The supervisor task + // populates these; onTermination — set up *before* the supervisor starts — + // reads them on consumer cancel/finish. + let children = TaskBox() + + continuation.onTermination = { @Sendable _ in + Task { await children.cancelAll() } + } + + let supervisor = Task { + do { + let datagrams = try await transport.sendSearch(request) + + // Retransmit task — preserves the original 1s/3s/10s/60s cadence. + let retransmitter = Task { + let start = ContinuousClock.now + while !Task.isCancelled { + let elapsed = ContinuousClock.now - start + let next: Duration + if elapsed < .seconds(5) { next = .seconds(1) } + else if elapsed < .seconds(10) { next = .seconds(3) } + else if elapsed < .seconds(60) { next = .seconds(10) } + else { next = .seconds(60) } + try await Task.sleep(for: next) + if Task.isCancelled { break } + // The transport already sent the first M-SEARCH on connection ready; + // subsequent rounds open a fresh send to keep filling in for lost + // packets while the same receive socket continues to drain replies. + _ = try? await transport.sendSearch(request) + } + } + + // Optional timeout task — finishes the consumer's stream cleanly when fired. + let timeoutTask: Task? = timeout.map { seconds in + Task { + try? await Task.sleep(for: .seconds(seconds)) + continuation.finish() + } + } + + await children.set(retransmitter: retransmitter, timeoutTask: timeoutTask) + + // Drain datagrams, parse, filter, yield. + for try await datagram in datagrams { + guard let raw = String(data: datagram.data, encoding: .utf8), + let message = SSDPMessageParser.parse(raw) + else { + SSDPLog.discovery.debug("Dropped unparseable M-SEARCH datagram from \(datagram.source, privacy: .public)") + continue + } + guard case .searchResponse(let response) = message else { + // Ignore stray NOTIFYs / requests on this stream. + continue + } + // Filter by search target — wildcard requests pass everything; + // specific requests pass only matching responses. + if request.searchTarget != .all && response.searchTarget != request.searchTarget { + continue + } + continuation.yield(response) + } + + retransmitter.cancel() + timeoutTask?.cancel() + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + Task { await children.set(supervisor: supervisor) } + } + } + + // MARK: - NOTIFY + + /// Subscribe to unsolicited SSDP NOTIFY broadcasts. + /// + /// Returns a long-lived `AsyncThrowingStream` that yields ``SSDPNotification`` values + /// (`alive` / `byebye` / `update`) for as long as the consumer iterates. Multiple + /// concurrent calls share one underlying multicast group join; the join is + /// reference-counted and tears down when the last consumer cancels. + /// + /// Throws ``SSDPError/multicastEntitlementMissing`` on iOS / iPadOS / tvOS if the + /// host app lacks the required entitlement. + public nonisolated func notifications() -> AsyncThrowingStream { + let transport = self.transport + return AsyncThrowingStream { continuation in + let children = TaskBox() + continuation.onTermination = { @Sendable _ in + Task { await children.cancelAll() } + } + let supervisor = Task { + do { + let datagrams = try await transport.multicastDatagrams() + for try await datagram in datagrams { + guard let raw = String(data: datagram.data, encoding: .utf8) else { continue } + guard case .notify(let n) = SSDPMessageParser.parse(raw) else { + // Ignore non-NOTIFY traffic on the multicast stream. + continue + } + continuation.yield(n) + } + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + Task { await children.set(supervisor: supervisor) } + } + } +} + +// MARK: - Internal helpers + +/// A small actor that accumulates the child Tasks spawned for a single search or +/// notification subscription and cancels them all on consumer teardown. +/// +/// Reason for an actor (vs a `[Task]` captured directly): the supervisor is a +/// non-isolated `Task { ... }`, so we can't synchronously hand a reference to its +/// `retransmitter` / `timeoutTask` into the `onTermination` closure that's set up +/// *before* the supervisor starts. The actor is the synchronization point. +private actor TaskBox { + private var supervisor: Task? + private var retransmitter: Task? + private var timeoutTask: Task? + + func set(supervisor: Task) { + self.supervisor = supervisor + } + + func set(retransmitter: Task, timeoutTask: Task?) { + self.retransmitter = retransmitter + self.timeoutTask = timeoutTask + } + + func cancelAll() { + supervisor?.cancel() + retransmitter?.cancel() + timeoutTask?.cancel() + } +} + +// MARK: - Convenience + +public extension AsyncThrowingStream where Element == SSDPMSearchResponse, Failure == Error { + + /// Drain the stream into a deduplicated array. + /// + /// Convenience for the common "I just want a list of devices" pattern. Equality and + /// hashing for ``SSDPMSearchResponse`` use `(usn, location)` so multiple responses + /// from the same device collapse to one entry. + /// + /// The returned array is built in arrival order; later duplicates are dropped. + func collect() async throws -> [SSDPMSearchResponse] { + var seen: Set = [] + var ordered: [SSDPMSearchResponse] = [] + for try await response in self { + if seen.insert(response).inserted { + ordered.append(response) + } + } + return ordered + } +} diff --git a/Sources/SwiftSSDP/SSDPError.swift b/Sources/SwiftSSDP/SSDPError.swift new file mode 100644 index 0000000..a5c0051 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPError.swift @@ -0,0 +1,45 @@ +// +// SSDPError.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// Errors thrown by SwiftSSDP operations. +/// +/// All cases are `Sendable` and `Equatable`. The underlying transport errors (typically +/// `NWError`) are stringified into the `details` payload because `NWError` is not +/// reliably `Sendable` or `Equatable` across SDK versions. +public enum SSDPError: Error, Sendable, Equatable { + /// The underlying network transport failed (UDP send, connection setup, etc.). + case transportFailed(details: String) + + /// Joining the SSDP multicast group (`239.255.255.250:1900`) failed. + /// + /// On iOS / iPadOS / tvOS, the most common cause is a missing + /// `com.apple.developer.networking.multicast` entitlement — see + /// ``SSDPError/multicastEntitlementMissing`` for a more specific signal. + case multicastJoinFailed(details: String) + + /// The multicast entitlement (`com.apple.developer.networking.multicast`) is required + /// on iOS / iPadOS / tvOS but is not present in the host app. + /// + /// Apple gates this entitlement behind a manual application form: + /// + case multicastEntitlementMissing + + /// A wire-format SSDP message could not be parsed. + case invalidResponse(reason: String) + + /// The configured timeout elapsed before any response arrived. + /// + /// Note: streams that simply complete because the timeout elapsed (and at least one + /// response was delivered) finish cleanly — they do not throw `.timedOut`. + case timedOut + + /// The operation was cancelled (typically because the consumer broke out of a + /// `for try await` loop or the parent `Task` was cancelled). + case cancelled +} diff --git a/Sources/SwiftSSDP/SSDPHeaderKeys.swift b/Sources/SwiftSSDP/SSDPHeaderKeys.swift new file mode 100644 index 0000000..66030ad --- /dev/null +++ b/Sources/SwiftSSDP/SSDPHeaderKeys.swift @@ -0,0 +1,35 @@ +// +// SSDPHeaderKeys.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// SSDP header key constants used in M-SEARCH, NOTIFY, and search-response messages. +/// +/// Header names in the SSDP wire format are case-insensitive. These constants use the +/// canonical UPnP-1.0 / UPnP-1.1 capitalization for outbound serialization. +enum SSDPHeaderKeys { + // RFC 2616 / UPnP 1.0 + static let cacheControl = "CACHE-CONTROL" + static let date = "DATE" + static let ext = "EXT" + static let host = "HOST" + static let location = "LOCATION" + static let man = "MAN" + static let maxWait = "MX" + static let notifyType = "NT" + static let notifySubType = "NTS" + static let searchTarget = "ST" + static let server = "SERVER" + static let usn = "USN" + + // UPnP 1.1 additions + static let bootID = "BOOTID.UPNP.ORG" + static let configID = "CONFIGID.UPNP.ORG" + static let searchPort = "SEARCHPORT.UPNP.ORG" + static let secureLocation = "SECURELOCATION.UPNP.ORG" + static let nextBootID = "NEXTBOOTID.UPNP.ORG" +} diff --git a/Sources/SwiftSSDP/SSDPHeaders.swift b/Sources/SwiftSSDP/SSDPHeaders.swift new file mode 100644 index 0000000..b864cd7 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPHeaders.swift @@ -0,0 +1,81 @@ +// +// SSDPHeaders.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// A case-insensitive collection of SSDP headers. +/// +/// Header names in SSDP are case-insensitive (they share HTTP/1.1's rules), but real-world +/// devices freely mix `CACHE-CONTROL`, `Cache-Control`, and `cache-control`. This wrapper +/// normalizes lookups so the parser and consumers can read by canonical key without juggling +/// case variants. +/// +/// Values are stored as supplied; only lookup is normalized. +public struct SSDPHeaders: Sendable, Equatable { + /// Storage indexed by uppercase keys (the SSDP/UPnP canonical form). + private var storage: [String: String] + + /// Creates an empty header set. + public init() { + self.storage = [:] + } + + /// Creates a header set from a dictionary of raw key/value pairs. + /// + /// If the input contains multiple keys that compare equal case-insensitively, the + /// last one encountered wins. + public init(_ pairs: [String: String]) { + var normalized: [String: String] = [:] + normalized.reserveCapacity(pairs.count) + for (k, v) in pairs { + normalized[k.uppercased()] = v + } + self.storage = normalized + } + + /// Case-insensitive lookup / mutation by header name. + public subscript(key: String) -> String? { + get { storage[key.uppercased()] } + set { storage[key.uppercased()] = newValue } + } + + /// True if no headers are set. + public var isEmpty: Bool { storage.isEmpty } + + /// Number of headers in the set. + public var count: Int { storage.count } + + /// All header names, in their normalized (uppercase) form. + public var keys: Dictionary.Keys { storage.keys } + + /// All header values. + public var values: Dictionary.Values { storage.values } + + /// The underlying dictionary (uppercase-keyed) for advanced use. + public var asDictionary: [String: String] { storage } + + /// Returns a copy of the header set with the given keys removed (case-insensitive). + func removing(_ keys: [String]) -> SSDPHeaders { + var copy = self + for k in keys { + copy.storage.removeValue(forKey: k.uppercased()) + } + return copy + } +} + +extension SSDPHeaders: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, String)...) { + self.init(Dictionary(uniqueKeysWithValues: elements)) + } +} + +extension SSDPHeaders: Sequence { + public func makeIterator() -> Dictionary.Iterator { + storage.makeIterator() + } +} diff --git a/Sources/SwiftSSDP/SSDPMSearchRequest.swift b/Sources/SwiftSSDP/SSDPMSearchRequest.swift new file mode 100644 index 0000000..a147a74 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPMSearchRequest.swift @@ -0,0 +1,75 @@ +// +// SSDPMSearchRequest.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// An SSDP M-SEARCH request describing what to discover on the local network. +/// +/// Used as the input to ``SSDPDiscovery/search(_:timeout:)``. For common cases prefer the +/// convenience overload ``SSDPDiscovery/search(for:maxWait:timeout:)``. +public struct SSDPMSearchRequest: Sendable, Equatable { + /// The leading request line (`M-SEARCH * HTTP/1.1`). + public static let messageHeader = "M-SEARCH * HTTP/1.1" + + /// SSDP multicast group address. + static let ssdpHost = "239.255.255.250" + /// SSDP multicast port. + static let ssdpPort = 1900 + + /// `ST` — what to search for. + public let searchTarget: SSDPSearchTarget + /// `MX` — maximum response wait time in seconds, advertised to responders. + /// + /// Devices choose a random delay in `[0, MX]` before replying, to avoid response storms. + /// Per UPnP recommendations this should be in the 1–5 second range. Default 1. + public let maxWaitTime: Int + /// Additional non-standard headers to include in the request. + /// + /// Standard headers (`HOST`, `MAN`, `MX`, `ST`) take precedence — values supplied here + /// for those keys are ignored. + public let otherHeaders: SSDPHeaders + + /// Build a request from explicit parameters. + public init( + searchTarget: SSDPSearchTarget, + maxWait: Int = 1, + otherHeaders: SSDPHeaders = [:] + ) { + self.searchTarget = searchTarget + self.maxWaitTime = maxWait + self.otherHeaders = otherHeaders + } + + /// The fully serialized M-SEARCH wire message. + /// + /// Headers are emitted in deterministic (alphabetical) order so the output is testable. + /// UPnP does not require any particular header ordering. + public var message: String { + // Standard headers always come from the request; consumer-supplied otherHeaders + // can only contribute non-standard keys. + var headers: [String: String] = [ + SSDPHeaderKeys.host: "\(Self.ssdpHost):\(Self.ssdpPort)", + SSDPHeaderKeys.man: "\"\(SSDPMessageAnnouncement.discover.rawValue)\"", + SSDPHeaderKeys.maxWait: String(maxWaitTime), + SSDPHeaderKeys.searchTarget: searchTarget.rawValue, + ] + for (k, v) in otherHeaders.asDictionary where headers[k] == nil { + headers[k] = v + } + + var lines: [String] = [Self.messageHeader] + for key in headers.keys.sorted() { + lines.append("\(key): \(headers[key]!)") + } + // SSDP messages terminate with CRLF and a final blank line. + return lines.joined(separator: "\r\n") + "\r\n\r\n" + } +} + +extension SSDPMSearchRequest: CustomStringConvertible { + public var description: String { message } +} diff --git a/Sources/SwiftSSDP/SSDPMSearchResponse.swift b/Sources/SwiftSSDP/SSDPMSearchResponse.swift new file mode 100644 index 0000000..a378688 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPMSearchResponse.swift @@ -0,0 +1,85 @@ +// +// SSDPMSearchResponse.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// A parsed response to an M-SEARCH request, describing one discovered device or service. +/// +/// Two responses are considered equal (and produce the same hash) when they share the same +/// `usn` and `location` — the natural deduplication key for SSDP. +public struct SSDPMSearchResponse: Sendable, Hashable { + /// `CACHE-CONTROL: max-age=` — how long the response is valid for. + /// + /// In v2.0 this is the raw `max-age` in seconds (a `TimeInterval`). The previous + /// release exposed this as a `Date` computed at parse time, which silently became + /// stale in long-running listeners. Consumers should compute their own expiration + /// instant if they need wall-clock semantics. + public let cacheControl: TimeInterval? + + /// `DATE` — the wall-clock instant at which the responder generated the message. + /// + /// Parsed as RFC 1123 (`EEE, dd MMM yyyy HH:mm:ss zzz`). Many devices omit this + /// header or send it in non-standard formats; expect `nil` to be common. + public let date: Date? + + /// `EXT` — a presence-only header required by UPnP 1.0. + /// + /// Note: real-world devices (notably some Hue and Roku firmware) omit `EXT`. The + /// parser is lenient — `ext == false` indicates the header was absent. + public let ext: Bool + + /// `LOCATION` — URL of the device's description document. + public let location: URL + + /// `SERVER` — server identification string, e.g. `Linux/3.14 UPnP/1.0 Sonos/12.3.1`. + public let server: String? + + /// `ST` — search target the responder is matching. + public let searchTarget: SSDPSearchTarget + + /// `USN` — Unique Service Name, a globally unique device/service identifier. + public let usn: String + + /// All headers from the response that are not surfaced as a typed property above. + /// + /// This includes UPnP 1.1 fields (`BOOTID.UPNP.ORG`, `CONFIGID.UPNP.ORG`, + /// `SEARCHPORT.UPNP.ORG`, `SECURELOCATION.UPNP.ORG`) when present. + public let otherHeaders: SSDPHeaders + + /// Designated initializer — primarily for parser use, but public for advanced clients + /// that want to construct synthetic responses (in tests or fixtures). + public init( + cacheControl: TimeInterval?, + date: Date?, + ext: Bool, + location: URL, + server: String?, + searchTarget: SSDPSearchTarget, + usn: String, + otherHeaders: SSDPHeaders + ) { + self.cacheControl = cacheControl + self.date = date + self.ext = ext + self.location = location + self.server = server + self.searchTarget = searchTarget + self.usn = usn + self.otherHeaders = otherHeaders + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(usn) + hasher.combine(location) + } + + public static func == (lhs: SSDPMSearchResponse, rhs: SSDPMSearchResponse) -> Bool { + lhs.usn == rhs.usn && lhs.location == rhs.location + } +} diff --git a/Sources/SwiftSSDP/SSDPMessageAnnouncement.swift b/Sources/SwiftSSDP/SSDPMessageAnnouncement.swift new file mode 100644 index 0000000..e5d1d97 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPMessageAnnouncement.swift @@ -0,0 +1,23 @@ +// +// SSDPMessageAnnouncement.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// SSDP announcement type used in `MAN` (M-SEARCH) and `NTS` (NOTIFY) headers. +/// +/// - Note: This type was previously misspelled `SSDPMessageAnnoucement` (missing 'n'). +/// The corrected name is the only one available in v2.0+. +public enum SSDPMessageAnnouncement: String, Sendable, Equatable { + /// `MAN: "ssdp:discover"` — used in M-SEARCH requests. + case discover = "ssdp:discover" + /// `NTS: ssdp:alive` — device or service is now reachable. + case alive = "ssdp:alive" + /// `NTS: ssdp:byebye` — device or service is leaving the network. + case byeBye = "ssdp:byebye" + /// `NTS: ssdp:update` (UPnP 1.1) — device's BOOTID is changing. + case update = "ssdp:update" +} diff --git a/Sources/SwiftSSDP/SSDPMessageParser.swift b/Sources/SwiftSSDP/SSDPMessageParser.swift new file mode 100644 index 0000000..acd1b07 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPMessageParser.swift @@ -0,0 +1,222 @@ +// +// SSDPMessageParser.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// A typed wire-format SSDP message. +/// +/// SSDP messages share an HTTP/1.1-like syntax (request line + headers + blank line) but +/// fall into three distinct families: +public enum SSDPMessage: Sendable { + /// An M-SEARCH request observed on the multicast group (uncommon for clients to receive). + case searchRequest + /// A unicast response to an M-SEARCH (`HTTP/1.1 200 OK`). + case searchResponse(SSDPMSearchResponse) + /// A NOTIFY broadcast — `alive`, `byebye`, or `update`. + case notify(SSDPNotification) +} + +/// Parses raw SSDP wire bytes into typed ``SSDPMessage`` values. +/// +/// Lenient by design: real-world devices don't all follow the spec strictly. Missing +/// non-critical headers (`EXT`, `SERVER`, `DATE`, `CACHE-CONTROL`) are tolerated; only the +/// genuinely required ones (`LOCATION`/`ST`/`USN` for responses; `NT`/`NTS`/`USN` for +/// NOTIFY, with `LOCATION` additionally required for `alive`/`update`) cause a `nil` return. +enum SSDPMessageParser { + + /// Parse a UTF-8 string of an SSDP message. + /// + /// Returns `nil` if the message can't be recognized. Returns `.searchRequest` for an + /// observed M-SEARCH (we have no use for these yet but they're surfaced for completeness). + static func parse(_ raw: String) -> SSDPMessage? { + guard !raw.isEmpty else { return nil } + + // First non-empty line is the request/status line. + var lines = raw.split(whereSeparator: \.isNewline).makeIterator() + guard let firstLine = lines.next() else { return nil } + let firstTokens = firstLine.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard let token = firstTokens.first else { return nil } + let leading = String(token).uppercased() + + // Collect headers from the remaining lines. + var headers = SSDPHeaders() + for line in lines { + // Headers continue until a blank line; once headers are done the rest is ignored. + if line.isEmpty { break } + guard let colon = line.firstIndex(of: ":") else { continue } + let key = String(line[.. SSDPMSearchResponse? { + // Required: LOCATION, ST, USN. + guard let locationString = headers[SSDPHeaderKeys.location], + let location = URL(string: locationString) + else { return nil } + guard let stString = headers[SSDPHeaderKeys.searchTarget], + let st = SSDPSearchTarget(rawValue: stString) + else { return nil } + guard let usn = headers[SSDPHeaderKeys.usn] else { return nil } + + let cacheControl = parseCacheControl(headers[SSDPHeaderKeys.cacheControl]) + let date = parseDate(headers[SSDPHeaderKeys.date]) + // EXT is required by spec but omitted by Hue bridges and some Roku firmware. Lenient. + let ext = headers[SSDPHeaderKeys.ext] != nil + let server = headers[SSDPHeaderKeys.server] + + let consumed = [ + SSDPHeaderKeys.cacheControl, + SSDPHeaderKeys.date, + SSDPHeaderKeys.ext, + SSDPHeaderKeys.location, + SSDPHeaderKeys.searchTarget, + SSDPHeaderKeys.server, + SSDPHeaderKeys.usn, + ] + let other = headers.removing(consumed) + + return SSDPMSearchResponse( + cacheControl: cacheControl, + date: date, + ext: ext, + location: location, + server: server, + searchTarget: st, + usn: usn, + otherHeaders: other + ) + } + + // MARK: - SSDPNotification construction + + private static func makeNotification(from headers: SSDPHeaders) -> SSDPNotification? { + // Required for all NOTIFYs: NT, NTS, USN. + guard let ntString = headers[SSDPHeaderKeys.notifyType], + let nt = SSDPSearchTarget(rawValue: ntString) + else { return nil } + guard let ntsString = headers[SSDPHeaderKeys.notifySubType], + let nts = SSDPMessageAnnouncement(rawValue: ntsString.lowercased()) + else { return nil } + guard let usn = headers[SSDPHeaderKeys.usn] else { return nil } + + let cacheControl = parseCacheControl(headers[SSDPHeaderKeys.cacheControl]) + let server = headers[SSDPHeaderKeys.server] + let bootID = headers[SSDPHeaderKeys.bootID].flatMap { Int($0) } + let configID = headers[SSDPHeaderKeys.configID].flatMap { Int($0) } + let nextBootID = headers[SSDPHeaderKeys.nextBootID].flatMap { Int($0) } + + // LOCATION is required for alive/update; absent for byebye. + let location: URL? + switch nts { + case .alive, .update: + guard let s = headers[SSDPHeaderKeys.location], let u = URL(string: s) else { + return nil + } + location = u + case .byeBye: + location = headers[SSDPHeaderKeys.location].flatMap { URL(string: $0) } + case .discover: + // ssdp:discover is not a valid NTS — defensive only. + return nil + } + + let consumed = [ + SSDPHeaderKeys.cacheControl, + SSDPHeaderKeys.location, + SSDPHeaderKeys.notifyType, + SSDPHeaderKeys.notifySubType, + SSDPHeaderKeys.server, + SSDPHeaderKeys.usn, + SSDPHeaderKeys.bootID, + SSDPHeaderKeys.configID, + SSDPHeaderKeys.nextBootID, + ] + let other = headers.removing(consumed) + + let advertisement = SSDPAdvertisement( + notificationTarget: nt, + usn: usn, + location: location, + server: server, + cacheControl: cacheControl, + bootID: bootID, + configID: configID, + nextBootID: nextBootID, + otherHeaders: other + ) + + switch nts { + case .alive: return .alive(advertisement) + case .byeBye: return .byebye(advertisement) + case .update: return .update(advertisement) + case .discover: return nil + } + } + + // MARK: - Header value parsing + + /// Parses a `CACHE-CONTROL` value, extracting the `max-age` directive. + /// + /// Returns the raw `max-age` in seconds (a `TimeInterval`). The pre-v2.0 release of + /// this library multiplied this value by 1000.0 — a 1000× error that has been here + /// since 2017. v2.0 returns the correct seconds value. + private static func parseCacheControl(_ value: String?) -> TimeInterval? { + guard let value else { return nil } + // CACHE-CONTROL can carry multiple directives separated by commas, e.g. + // "max-age=1800, no-cache". We only care about max-age. + for directive in value.split(separator: ",") { + let trimmed = directive.trimmingCharacters(in: .whitespaces) + let parts = trimmed.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = parts[0].trimmingCharacters(in: .whitespaces).lowercased() + let raw = parts[1].trimmingCharacters(in: .whitespaces) + guard key == "max-age", let seconds = Int(raw) else { continue } + return TimeInterval(seconds) + } + return nil + } + + /// RFC 1123 date formatter for SSDP `DATE` headers. + /// + /// The pre-v2.0 release used `DateFormatter()` with no format set, so the parse always + /// returned `nil`. v2.0 uses the correct RFC 1123 format with a POSIX locale (per + /// Apple's TN1480 — required to prevent the user's locale from breaking parsing). + private static let rfc1123Formatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "GMT") + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return f + }() + + private static func parseDate(_ value: String?) -> Date? { + guard let value else { return nil } + return rfc1123Formatter.date(from: value) + } +} diff --git a/Sources/SwiftSSDP/SSDPNotification.swift b/Sources/SwiftSSDP/SSDPNotification.swift new file mode 100644 index 0000000..58ea900 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPNotification.swift @@ -0,0 +1,106 @@ +// +// SSDPNotification.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// An unsolicited NOTIFY broadcast from a device on the local SSDP multicast group. +/// +/// Emitted by ``SSDPDiscovery/notifications()``. Devices broadcast NOTIFY messages when +/// they join the network (`alive`), leave (`byebye`), or change their UPnP boot identity +/// (`update`, UPnP 1.1). +public enum SSDPNotification: Sendable, Hashable { + /// `NTS: ssdp:alive` — device is reachable. + case alive(SSDPAdvertisement) + /// `NTS: ssdp:byebye` — device is leaving the network. + /// + /// `byebye` carries no `LOCATION` header (the device is going away, so there's + /// nothing to fetch); the advertisement's `location` will be `nil`. + case byebye(SSDPAdvertisement) + /// `NTS: ssdp:update` — device's `BOOTID.UPNP.ORG` is changing (UPnP 1.1). + case update(SSDPAdvertisement) + + /// The advertisement payload, regardless of which case. + public var advertisement: SSDPAdvertisement { + switch self { + case .alive(let a), .byebye(let a), .update(let a): + return a + } + } + + /// The notification target (`NT` header) for the announced device or service. + public var notificationTarget: SSDPSearchTarget { + advertisement.notificationTarget + } +} + +/// The data payload of a NOTIFY message. +/// +/// Closely related to ``SSDPMSearchResponse`` but distinct — NOTIFY messages use `NT` +/// (Notification Target) where M-SEARCH responses use `ST` (Search Target), and NOTIFY +/// has no `EXT` header. ``location`` is optional because `byebye` notifications omit it. +public struct SSDPAdvertisement: Sendable, Hashable { + /// `NT` — the notification target, identifying what kind of device/service is announcing. + public let notificationTarget: SSDPSearchTarget + + /// `USN` — Unique Service Name. + public let usn: String + + /// `LOCATION` — URL of the device description document. + /// + /// Always present in `alive` and `update`; absent in `byebye`. + public let location: URL? + + /// `SERVER` — server identification string. + public let server: String? + + /// `CACHE-CONTROL: max-age=` — validity duration in seconds. `nil` if absent. + public let cacheControl: TimeInterval? + + /// `BOOTID.UPNP.ORG` — UPnP 1.1 boot identifier. + public let bootID: Int? + + /// `CONFIGID.UPNP.ORG` — UPnP 1.1 configuration identifier. + public let configID: Int? + + /// `NEXTBOOTID.UPNP.ORG` — UPnP 1.1, present only on `ssdp:update`. + public let nextBootID: Int? + + /// All headers not surfaced as typed properties above. + public let otherHeaders: SSDPHeaders + + public init( + notificationTarget: SSDPSearchTarget, + usn: String, + location: URL?, + server: String?, + cacheControl: TimeInterval?, + bootID: Int?, + configID: Int?, + nextBootID: Int?, + otherHeaders: SSDPHeaders + ) { + self.notificationTarget = notificationTarget + self.usn = usn + self.location = location + self.server = server + self.cacheControl = cacheControl + self.bootID = bootID + self.configID = configID + self.nextBootID = nextBootID + self.otherHeaders = otherHeaders + } + + /// `Hashable` conformance via the natural deduplication key (`usn` + `notificationTarget`). + public func hash(into hasher: inout Hasher) { + hasher.combine(usn) + hasher.combine(notificationTarget) + } + + public static func == (lhs: SSDPAdvertisement, rhs: SSDPAdvertisement) -> Bool { + lhs.usn == rhs.usn && lhs.notificationTarget == rhs.notificationTarget + } +} diff --git a/Sources/SwiftSSDP/SSDPSearchTarget.swift b/Sources/SwiftSSDP/SSDPSearchTarget.swift new file mode 100644 index 0000000..f53ffe2 --- /dev/null +++ b/Sources/SwiftSSDP/SSDPSearchTarget.swift @@ -0,0 +1,100 @@ +// +// SSDPSearchTarget.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// A device or service search target. +/// +/// Search targets serve double duty in SSDP: they appear as `ST` in M-SEARCH requests and +/// search responses, and as `NT` in NOTIFY broadcasts. The wire format is identical in +/// both roles, so a single value type covers both. +/// +/// All cases follow the canonical UPnP forms: +/// +/// - `all` → `ssdp:all` +/// - `rootDevice` → `upnp:rootdevice` +/// - `uuid` → `uuid:` +/// - `deviceType` → `urn::device::` +/// - `serviceType` → `urn::service::` +public enum SSDPSearchTarget: Sendable, Hashable { + /// `ssdp:all` — match any device or service. + case all + /// `upnp:rootdevice` — match root devices only. + case rootDevice + /// `uuid:` — match a specific device by its UUID. + case uuid(String) + /// `urn::device::` — match any device of the given type. + /// + /// Per RFC 2141, period characters in the schema must be replaced with hyphens. + case deviceType(schema: String, deviceType: String, version: Int) + /// `urn::service::` — match any service of the given type. + /// + /// Per RFC 2141, period characters in the schema must be replaced with hyphens. + case serviceType(schema: String, serviceType: String, version: Int) + + /// Schema string for UPnP forum working-committee devices and services. + public static let upnpOrgSchema = "schemas-upnp-org" + + /// The wire string form, suitable for use as `ST` or `NT` header values. + public var rawValue: String { + switch self { + case .all: + return "ssdp:all" + case .rootDevice: + return "upnp:rootdevice" + case .uuid(let id): + return "uuid:\(id)" + case .deviceType(let schema, let type, let version): + return "urn:\(schema):device:\(type):\(version)" + case .serviceType(let schema, let type, let version): + return "urn:\(schema):service:\(type):\(version)" + } + } + + /// Parse a search target from a wire string (`ST` / `NT` header value). + /// + /// Returns `nil` if the string does not match one of the recognized SSDP forms. + /// Lenient parsing is intentional — some devices send slightly malformed URNs and + /// we'd rather surface them as `nil` than crash, but valid forms are accepted. + public init?(rawValue: String) { + let components = rawValue.components(separatedBy: ":") + guard !components.isEmpty else { return nil } + + switch components.count { + case 2: + switch components[0] { + case "ssdp" where components[1] == "all": + self = .all + case "upnp" where components[1] == "rootdevice": + self = .rootDevice + case "uuid": + self = .uuid(components[1]) + default: + return nil + } + case 5: + // urn::device|service:: + guard components[0] == "urn", + let version = Int(components[4]) + else { return nil } + switch components[2] { + case "device": + self = .deviceType(schema: components[1], deviceType: components[3], version: version) + case "service": + self = .serviceType(schema: components[1], serviceType: components[3], version: version) + default: + return nil + } + default: + return nil + } + } +} + +extension SSDPSearchTarget: CustomStringConvertible { + public var description: String { rawValue } +} diff --git a/Sources/SwiftSSDP/SSDPUPnP.swift b/Sources/SwiftSSDP/SSDPUPnP.swift new file mode 100644 index 0000000..20cccef --- /dev/null +++ b/Sources/SwiftSSDP/SSDPUPnP.swift @@ -0,0 +1,66 @@ +// +// SSDPUPnP.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// Convenience constants for common UPnP forum-defined device and service types. +/// +/// All constants use `SSDPSearchTarget.upnpOrgSchema` (`schemas-upnp-org`) and version `1`. +/// For other vendors or versions, construct the search target directly. +public extension SSDPSearchTarget { + // MARK: - UPnP Devices + + /// `urn:schemas-upnp-org:device:MediaServer:1` + static let mediaServer: SSDPSearchTarget = + .deviceType(schema: upnpOrgSchema, deviceType: "MediaServer", version: 1) + + /// `urn:schemas-upnp-org:device:MediaRenderer:1` + static let mediaRenderer: SSDPSearchTarget = + .deviceType(schema: upnpOrgSchema, deviceType: "MediaRenderer", version: 1) + + /// `urn:schemas-upnp-org:device:InternetGatewayDevice:1` + static let internetGatewayDevice: SSDPSearchTarget = + .deviceType(schema: upnpOrgSchema, deviceType: "InternetGatewayDevice", version: 1) + + /// `urn:schemas-upnp-org:device:WANConnectionDevice:1` + static let wanConnectionDevice: SSDPSearchTarget = + .deviceType(schema: upnpOrgSchema, deviceType: "WANConnectionDevice", version: 1) + + /// `urn:schemas-upnp-org:device:WANDevice:1` + static let wanDevice: SSDPSearchTarget = + .deviceType(schema: upnpOrgSchema, deviceType: "WANDevice", version: 1) + + // MARK: - UPnP Services + + /// `urn:schemas-upnp-org:service:AVTransport:1` + static let avTransportService: SSDPSearchTarget = + .serviceType(schema: upnpOrgSchema, serviceType: "AVTransport", version: 1) + + /// `urn:schemas-upnp-org:service:ConnectionManager:1` + static let connectionManagerService: SSDPSearchTarget = + .serviceType(schema: upnpOrgSchema, serviceType: "ConnectionManager", version: 1) + + /// `urn:schemas-upnp-org:service:ContentDirectory:1` + static let contentDirectoryService: SSDPSearchTarget = + .serviceType(schema: upnpOrgSchema, serviceType: "ContentDirectory", version: 1) + + /// `urn:schemas-upnp-org:service:RenderingControl:1` + static let renderingControlService: SSDPSearchTarget = + .serviceType(schema: upnpOrgSchema, serviceType: "RenderingControl", version: 1) + + /// `urn:schemas-upnp-org:service:Layer3Forwarding:1` + static let layer3ForwardingService: SSDPSearchTarget = + .serviceType(schema: upnpOrgSchema, serviceType: "Layer3Forwarding", version: 1) + + /// `urn:schemas-upnp-org:service:WANCommonInterfaceConfig:1` + static let wanCommonInterfaceConfigService: SSDPSearchTarget = + .serviceType(schema: upnpOrgSchema, serviceType: "WANCommonInterfaceConfig", version: 1) + + /// `urn:schemas-upnp-org:service:WANIPConnection:1` + static let wanIPConnectionService: SSDPSearchTarget = + .serviceType(schema: upnpOrgSchema, serviceType: "WANIPConnection", version: 1) +} diff --git a/SwiftSSDP.xcodeproj/project.pbxproj b/SwiftSSDP.xcodeproj/project.pbxproj deleted file mode 100644 index 1d7011e..0000000 --- a/SwiftSSDP.xcodeproj/project.pbxproj +++ /dev/null @@ -1,1131 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 46; - objects = { - -/* Begin PBXBuildFile section */ - 3C8F9C5B1E78FD0600EC88A2 /* SSDPCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C531E78FD0600EC88A2 /* SSDPCommon.swift */; }; - 3C8F9C5D1E78FD0600EC88A2 /* SSDPCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C531E78FD0600EC88A2 /* SSDPCommon.swift */; }; - 3C8F9C5E1E78FD0600EC88A2 /* SSDPCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C531E78FD0600EC88A2 /* SSDPCommon.swift */; }; - 3C8F9C5F1E78FD0600EC88A2 /* SSDPDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C541E78FD0600EC88A2 /* SSDPDiscovery.swift */; }; - 3C8F9C611E78FD0600EC88A2 /* SSDPDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C541E78FD0600EC88A2 /* SSDPDiscovery.swift */; }; - 3C8F9C621E78FD0600EC88A2 /* SSDPDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C541E78FD0600EC88A2 /* SSDPDiscovery.swift */; }; - 3C8F9C631E78FD0600EC88A2 /* SSDPDiscoverySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C551E78FD0600EC88A2 /* SSDPDiscoverySession.swift */; }; - 3C8F9C651E78FD0600EC88A2 /* SSDPDiscoverySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C551E78FD0600EC88A2 /* SSDPDiscoverySession.swift */; }; - 3C8F9C661E78FD0600EC88A2 /* SSDPDiscoverySession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C551E78FD0600EC88A2 /* SSDPDiscoverySession.swift */; }; - 3C8F9C671E78FD0600EC88A2 /* SSDPMSearchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C561E78FD0600EC88A2 /* SSDPMSearchRequest.swift */; }; - 3C8F9C691E78FD0600EC88A2 /* SSDPMSearchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C561E78FD0600EC88A2 /* SSDPMSearchRequest.swift */; }; - 3C8F9C6A1E78FD0600EC88A2 /* SSDPMSearchRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C561E78FD0600EC88A2 /* SSDPMSearchRequest.swift */; }; - 3C8F9C6B1E78FD0600EC88A2 /* SSDPMSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C571E78FD0600EC88A2 /* SSDPMSearchResponse.swift */; }; - 3C8F9C6D1E78FD0600EC88A2 /* SSDPMSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C571E78FD0600EC88A2 /* SSDPMSearchResponse.swift */; }; - 3C8F9C6E1E78FD0600EC88A2 /* SSDPMSearchResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C571E78FD0600EC88A2 /* SSDPMSearchResponse.swift */; }; - 3C8F9C6F1E78FD0600EC88A2 /* SSDPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C581E78FD0600EC88A2 /* SSDPResponse.swift */; }; - 3C8F9C711E78FD0600EC88A2 /* SSDPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C581E78FD0600EC88A2 /* SSDPResponse.swift */; }; - 3C8F9C721E78FD0600EC88A2 /* SSDPResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C581E78FD0600EC88A2 /* SSDPResponse.swift */; }; - 3C8F9C731E78FD0600EC88A2 /* SSDPSearchTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C591E78FD0600EC88A2 /* SSDPSearchTarget.swift */; }; - 3C8F9C751E78FD0600EC88A2 /* SSDPSearchTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C591E78FD0600EC88A2 /* SSDPSearchTarget.swift */; }; - 3C8F9C761E78FD0600EC88A2 /* SSDPSearchTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C591E78FD0600EC88A2 /* SSDPSearchTarget.swift */; }; - 3C8F9C771E78FD0600EC88A2 /* SSDPUPnP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C5A1E78FD0600EC88A2 /* SSDPUPnP.swift */; }; - 3C8F9C791E78FD0600EC88A2 /* SSDPUPnP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C5A1E78FD0600EC88A2 /* SSDPUPnP.swift */; }; - 3C8F9C7A1E78FD0600EC88A2 /* SSDPUPnP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8F9C5A1E78FD0600EC88A2 /* SSDPUPnP.swift */; }; - 3C9049AA1E7908F6001A8C1C /* CocoaAsyncSocket.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C9049A71E7908F6001A8C1C /* CocoaAsyncSocket.framework */; }; - 3C9049AC1E7908F6001A8C1C /* Weak.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C9049A91E7908F6001A8C1C /* Weak.framework */; }; - 3C9049B01E790901001A8C1C /* CocoaAsyncSocket.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C9049AD1E790901001A8C1C /* CocoaAsyncSocket.framework */; }; - 3C9049B21E790901001A8C1C /* Weak.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C9049AF1E790901001A8C1C /* Weak.framework */; }; - 3C9049B61E790913001A8C1C /* Weak.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C9049B41E790913001A8C1C /* Weak.framework */; }; - 3CF898021E80C3DF00DDE04F /* SwiftAbstractLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CF898011E80C3DF00DDE04F /* SwiftAbstractLogger.framework */; }; - 3CF898041E80C3EA00DDE04F /* SwiftAbstractLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CF898031E80C3EA00DDE04F /* SwiftAbstractLogger.framework */; }; - 3CF898061E80C3F100DDE04F /* SwiftAbstractLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CF898051E80C3F100DDE04F /* SwiftAbstractLogger.framework */; }; - F72F0CC5203FDDBD00759FB4 /* CocoaAsyncSocket.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C9049B71E79091D001A8C1C /* CocoaAsyncSocket.framework */; }; - F72F0CC6203FDDC400759FB4 /* SwiftAbstractLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CF897FF1E80C3D700DDE04F /* SwiftAbstractLogger.framework */; }; - F72F0CC7203FDDC900759FB4 /* Weak.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C9049B91E79091D001A8C1C /* Weak.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 3C8F9C1C1E78FB3100EC88A2 /* SwiftSSDP.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftSSDP.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3C8F9C2A1E78FB4000EC88A2 /* SwiftSSDP.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftSSDP.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3C8F9C371E78FB4E00EC88A2 /* SwiftSSDP.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftSSDP.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3C8F9C441E78FB6200EC88A2 /* SwiftSSDP.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SwiftSSDP.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 3C8F9C4E1E78FCA000EC88A2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 3C8F9C531E78FD0600EC88A2 /* SSDPCommon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPCommon.swift; sourceTree = ""; }; - 3C8F9C541E78FD0600EC88A2 /* SSDPDiscovery.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPDiscovery.swift; sourceTree = ""; }; - 3C8F9C551E78FD0600EC88A2 /* SSDPDiscoverySession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPDiscoverySession.swift; sourceTree = ""; }; - 3C8F9C561E78FD0600EC88A2 /* SSDPMSearchRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPMSearchRequest.swift; sourceTree = ""; }; - 3C8F9C571E78FD0600EC88A2 /* SSDPMSearchResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPMSearchResponse.swift; sourceTree = ""; }; - 3C8F9C581E78FD0600EC88A2 /* SSDPResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPResponse.swift; sourceTree = ""; }; - 3C8F9C591E78FD0600EC88A2 /* SSDPSearchTarget.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPSearchTarget.swift; sourceTree = ""; }; - 3C8F9C5A1E78FD0600EC88A2 /* SSDPUPnP.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SSDPUPnP.swift; sourceTree = ""; }; - 3C8F9C7B1E78FD1300EC88A2 /* Cartfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Cartfile; sourceTree = ""; }; - 3C8F9C7C1E78FD1300EC88A2 /* LICENSE */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; - 3C8F9C7D1E78FD1300EC88A2 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; - 3C9049A71E7908F6001A8C1C /* CocoaAsyncSocket.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CocoaAsyncSocket.framework; path = Carthage/Build/Mac/CocoaAsyncSocket.framework; sourceTree = ""; }; - 3C9049A91E7908F6001A8C1C /* Weak.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Weak.framework; path = Carthage/Build/Mac/Weak.framework; sourceTree = ""; }; - 3C9049AD1E790901001A8C1C /* CocoaAsyncSocket.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CocoaAsyncSocket.framework; path = Carthage/Build/tvOS/CocoaAsyncSocket.framework; sourceTree = ""; }; - 3C9049AF1E790901001A8C1C /* Weak.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Weak.framework; path = Carthage/Build/tvOS/Weak.framework; sourceTree = ""; }; - 3C9049B41E790913001A8C1C /* Weak.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Weak.framework; path = Carthage/Build/watchOS/Weak.framework; sourceTree = ""; }; - 3C9049B71E79091D001A8C1C /* CocoaAsyncSocket.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CocoaAsyncSocket.framework; path = Carthage/Build/iOS/CocoaAsyncSocket.framework; sourceTree = ""; }; - 3C9049B91E79091D001A8C1C /* Weak.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Weak.framework; path = Carthage/Build/iOS/Weak.framework; sourceTree = ""; }; - 3CF897FF1E80C3D700DDE04F /* SwiftAbstractLogger.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftAbstractLogger.framework; path = Carthage/Build/iOS/SwiftAbstractLogger.framework; sourceTree = ""; }; - 3CF898011E80C3DF00DDE04F /* SwiftAbstractLogger.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftAbstractLogger.framework; path = Carthage/Build/watchOS/SwiftAbstractLogger.framework; sourceTree = ""; }; - 3CF898031E80C3EA00DDE04F /* SwiftAbstractLogger.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftAbstractLogger.framework; path = Carthage/Build/tvOS/SwiftAbstractLogger.framework; sourceTree = ""; }; - 3CF898051E80C3F100DDE04F /* SwiftAbstractLogger.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftAbstractLogger.framework; path = Carthage/Build/Mac/SwiftAbstractLogger.framework; sourceTree = ""; }; - 3CF8980A1E80C88300DDE04F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 3C8F9C181E78FB3000EC88A2 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - F72F0CC5203FDDBD00759FB4 /* CocoaAsyncSocket.framework in Frameworks */, - F72F0CC6203FDDC400759FB4 /* SwiftAbstractLogger.framework in Frameworks */, - F72F0CC7203FDDC900759FB4 /* Weak.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C261E78FB4000EC88A2 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 3CF898021E80C3DF00DDE04F /* SwiftAbstractLogger.framework in Frameworks */, - 3C9049B61E790913001A8C1C /* Weak.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C331E78FB4E00EC88A2 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 3C9049B01E790901001A8C1C /* CocoaAsyncSocket.framework in Frameworks */, - 3CF898041E80C3EA00DDE04F /* SwiftAbstractLogger.framework in Frameworks */, - 3C9049B21E790901001A8C1C /* Weak.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C401E78FB6200EC88A2 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 3C9049AA1E7908F6001A8C1C /* CocoaAsyncSocket.framework in Frameworks */, - 3CF898061E80C3F100DDE04F /* SwiftAbstractLogger.framework in Frameworks */, - 3C9049AC1E7908F6001A8C1C /* Weak.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 3C8F9C101E78FB0300EC88A2 = { - isa = PBXGroup; - children = ( - 3C8F9C521E78FD0600EC88A2 /* SwiftSSDP */, - 3C8F9C7E1E78FD1900EC88A2 /* Package */, - 3C8F9C4C1E78FC2000EC88A2 /* Resources */, - 3C8F9C1D1E78FB3100EC88A2 /* Products */, - 3C9049A61E7908F5001A8C1C /* Frameworks */, - ); - sourceTree = ""; - }; - 3C8F9C1D1E78FB3100EC88A2 /* Products */ = { - isa = PBXGroup; - children = ( - 3C8F9C1C1E78FB3100EC88A2 /* SwiftSSDP.framework */, - 3C8F9C2A1E78FB4000EC88A2 /* SwiftSSDP.framework */, - 3C8F9C371E78FB4E00EC88A2 /* SwiftSSDP.framework */, - 3C8F9C441E78FB6200EC88A2 /* SwiftSSDP.framework */, - ); - name = Products; - sourceTree = ""; - }; - 3C8F9C4C1E78FC2000EC88A2 /* Resources */ = { - isa = PBXGroup; - children = ( - 3C8F9C4E1E78FCA000EC88A2 /* Info.plist */, - ); - name = Resources; - sourceTree = ""; - }; - 3C8F9C521E78FD0600EC88A2 /* SwiftSSDP */ = { - isa = PBXGroup; - children = ( - 3C8F9C531E78FD0600EC88A2 /* SSDPCommon.swift */, - 3C8F9C541E78FD0600EC88A2 /* SSDPDiscovery.swift */, - 3C8F9C551E78FD0600EC88A2 /* SSDPDiscoverySession.swift */, - 3C8F9C561E78FD0600EC88A2 /* SSDPMSearchRequest.swift */, - 3C8F9C571E78FD0600EC88A2 /* SSDPMSearchResponse.swift */, - 3C8F9C581E78FD0600EC88A2 /* SSDPResponse.swift */, - 3C8F9C591E78FD0600EC88A2 /* SSDPSearchTarget.swift */, - 3C8F9C5A1E78FD0600EC88A2 /* SSDPUPnP.swift */, - ); - path = SwiftSSDP; - sourceTree = ""; - }; - 3C8F9C7E1E78FD1900EC88A2 /* Package */ = { - isa = PBXGroup; - children = ( - 3C8F9C7B1E78FD1300EC88A2 /* Cartfile */, - 3C8F9C7C1E78FD1300EC88A2 /* LICENSE */, - 3C8F9C7D1E78FD1300EC88A2 /* Package.swift */, - 3CF8980A1E80C88300DDE04F /* README.md */, - ); - name = Package; - sourceTree = ""; - }; - 3C9049A61E7908F5001A8C1C /* Frameworks */ = { - isa = PBXGroup; - children = ( - 3C9049B71E79091D001A8C1C /* CocoaAsyncSocket.framework */, - 3C9049AD1E790901001A8C1C /* CocoaAsyncSocket.framework */, - 3C9049A71E7908F6001A8C1C /* CocoaAsyncSocket.framework */, - 3CF898051E80C3F100DDE04F /* SwiftAbstractLogger.framework */, - 3CF898031E80C3EA00DDE04F /* SwiftAbstractLogger.framework */, - 3CF898011E80C3DF00DDE04F /* SwiftAbstractLogger.framework */, - 3CF897FF1E80C3D700DDE04F /* SwiftAbstractLogger.framework */, - 3C9049B91E79091D001A8C1C /* Weak.framework */, - 3C9049B41E790913001A8C1C /* Weak.framework */, - 3C9049AF1E790901001A8C1C /* Weak.framework */, - 3C9049A91E7908F6001A8C1C /* Weak.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXHeadersBuildPhase section */ - 3C8F9C191E78FB3000EC88A2 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C271E78FB4000EC88A2 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C341E78FB4E00EC88A2 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C411E78FB6200EC88A2 /* Headers */ = { - isa = PBXHeadersBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXHeadersBuildPhase section */ - -/* Begin PBXNativeTarget section */ - 3C8F9C1B1E78FB3000EC88A2 /* SwiftSSDP iOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 3C8F9C221E78FB3100EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP iOS" */; - buildPhases = ( - 3C8F9C171E78FB3000EC88A2 /* Sources */, - 3C8F9C181E78FB3000EC88A2 /* Frameworks */, - 3C8F9C191E78FB3000EC88A2 /* Headers */, - 3C8F9C1A1E78FB3000EC88A2 /* Resources */, - 3C9049BD1E790982001A8C1C /* Carthage Copy Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SwiftSSDP iOS"; - productName = "SwiftSSDP-iOS"; - productReference = 3C8F9C1C1E78FB3100EC88A2 /* SwiftSSDP.framework */; - productType = "com.apple.product-type.framework"; - }; - 3C8F9C291E78FB4000EC88A2 /* SwiftSSDP watchOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 3C8F9C2F1E78FB4000EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP watchOS" */; - buildPhases = ( - 3C8F9C251E78FB4000EC88A2 /* Sources */, - 3C8F9C261E78FB4000EC88A2 /* Frameworks */, - 3C8F9C271E78FB4000EC88A2 /* Headers */, - 3C8F9C281E78FB4000EC88A2 /* Resources */, - 3CF898071E80C58700DDE04F /* Carthage Copy Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SwiftSSDP watchOS"; - productName = "SwiftSSDP-watchOS"; - productReference = 3C8F9C2A1E78FB4000EC88A2 /* SwiftSSDP.framework */; - productType = "com.apple.product-type.framework"; - }; - 3C8F9C361E78FB4E00EC88A2 /* SwiftSSDP tvOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 3C8F9C3C1E78FB4E00EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP tvOS" */; - buildPhases = ( - 3C8F9C321E78FB4E00EC88A2 /* Sources */, - 3C8F9C331E78FB4E00EC88A2 /* Frameworks */, - 3C8F9C341E78FB4E00EC88A2 /* Headers */, - 3C8F9C351E78FB4E00EC88A2 /* Resources */, - 3CF898081E80C58D00DDE04F /* Carthage Copy Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SwiftSSDP tvOS"; - productName = "SwiftSSDP-tvOS"; - productReference = 3C8F9C371E78FB4E00EC88A2 /* SwiftSSDP.framework */; - productType = "com.apple.product-type.framework"; - }; - 3C8F9C431E78FB6200EC88A2 /* SwiftSSDP macOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 3C8F9C491E78FB6300EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP macOS" */; - buildPhases = ( - 3C8F9C3F1E78FB6200EC88A2 /* Sources */, - 3C8F9C401E78FB6200EC88A2 /* Frameworks */, - 3C8F9C411E78FB6200EC88A2 /* Headers */, - 3C8F9C421E78FB6200EC88A2 /* Resources */, - 3CF898091E80C59200DDE04F /* Carthage Copy Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SwiftSSDP macOS"; - productName = "SwiftSSDP-macOS"; - productReference = 3C8F9C441E78FB6200EC88A2 /* SwiftSSDP.framework */; - productType = "com.apple.product-type.framework"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 3C8F9C111E78FB0300EC88A2 /* Project object */ = { - isa = PBXProject; - attributes = { - LastUpgradeCheck = 0920; - TargetAttributes = { - 3C8F9C1B1E78FB3000EC88A2 = { - CreatedOnToolsVersion = 8.2.1; - LastSwiftMigration = 0920; - ProvisioningStyle = Automatic; - }; - 3C8F9C291E78FB4000EC88A2 = { - CreatedOnToolsVersion = 8.2.1; - ProvisioningStyle = Automatic; - }; - 3C8F9C361E78FB4E00EC88A2 = { - CreatedOnToolsVersion = 8.2.1; - ProvisioningStyle = Automatic; - }; - 3C8F9C431E78FB6200EC88A2 = { - CreatedOnToolsVersion = 8.2.1; - ProvisioningStyle = Automatic; - }; - }; - }; - buildConfigurationList = 3C8F9C141E78FB0300EC88A2 /* Build configuration list for PBXProject "SwiftSSDP" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; - hasScannedForEncodings = 0; - knownRegions = ( - en, - ); - mainGroup = 3C8F9C101E78FB0300EC88A2; - productRefGroup = 3C8F9C1D1E78FB3100EC88A2 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 3C8F9C1B1E78FB3000EC88A2 /* SwiftSSDP iOS */, - 3C8F9C291E78FB4000EC88A2 /* SwiftSSDP watchOS */, - 3C8F9C361E78FB4E00EC88A2 /* SwiftSSDP tvOS */, - 3C8F9C431E78FB6200EC88A2 /* SwiftSSDP macOS */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 3C8F9C1A1E78FB3000EC88A2 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C281E78FB4000EC88A2 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C351E78FB4E00EC88A2 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C421E78FB6200EC88A2 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3C9049BD1E790982001A8C1C /* Carthage Copy Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "$(SRCROOT)/Carthage/Build/iOS/CocoaAsyncSocket.framework", - "$(SRCROOT)/Carthage/Build/iOS/SwiftAbstractLogger.framework", - "$(SRCROOT)/Carthage/Build/iOS/Weak.framework", - ); - name = "Carthage Copy Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/usr/local/bin/carthage copy-frameworks"; - showEnvVarsInLog = 0; - }; - 3CF898071E80C58700DDE04F /* Carthage Copy Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "$(SRCROOT)/Carthage/Build/watchOS/SwiftAbstractLogger.framework", - "$(SRCROOT)/Carthage/Build/watchOS/Weak.framework", - ); - name = "Carthage Copy Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/usr/local/bin/carthage copy-frameworks"; - showEnvVarsInLog = 0; - }; - 3CF898081E80C58D00DDE04F /* Carthage Copy Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "$(SRCROOT)/Carthage/Build/tvOS/CocoaAsyncSocket.framework", - "$(SRCROOT)/Carthage/Build/tvOS/SwiftAbstractLogger.framework", - "$(SRCROOT)/Carthage/Build/tvOS/Weak.framework", - ); - name = "Carthage Copy Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/usr/local/bin/carthage copy-frameworks"; - showEnvVarsInLog = 0; - }; - 3CF898091E80C59200DDE04F /* Carthage Copy Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "$(SRCROOT)/Carthage/Build/Mac/CocoaAsyncSocket.framework", - "$(SRCROOT)/Carthage/Build/Mac/SwiftAbstractLogger.framework", - "$(SRCROOT)/Carthage/Build/Mac/Weak.framework", - ); - name = "Carthage Copy Frameworks"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/usr/local/bin/carthage copy-frameworks"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 3C8F9C171E78FB3000EC88A2 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 3C8F9C731E78FD0600EC88A2 /* SSDPSearchTarget.swift in Sources */, - 3C8F9C6F1E78FD0600EC88A2 /* SSDPResponse.swift in Sources */, - 3C8F9C631E78FD0600EC88A2 /* SSDPDiscoverySession.swift in Sources */, - 3C8F9C671E78FD0600EC88A2 /* SSDPMSearchRequest.swift in Sources */, - 3C8F9C5F1E78FD0600EC88A2 /* SSDPDiscovery.swift in Sources */, - 3C8F9C5B1E78FD0600EC88A2 /* SSDPCommon.swift in Sources */, - 3C8F9C771E78FD0600EC88A2 /* SSDPUPnP.swift in Sources */, - 3C8F9C6B1E78FD0600EC88A2 /* SSDPMSearchResponse.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C251E78FB4000EC88A2 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C321E78FB4E00EC88A2 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 3C8F9C751E78FD0600EC88A2 /* SSDPSearchTarget.swift in Sources */, - 3C8F9C711E78FD0600EC88A2 /* SSDPResponse.swift in Sources */, - 3C8F9C651E78FD0600EC88A2 /* SSDPDiscoverySession.swift in Sources */, - 3C8F9C691E78FD0600EC88A2 /* SSDPMSearchRequest.swift in Sources */, - 3C8F9C611E78FD0600EC88A2 /* SSDPDiscovery.swift in Sources */, - 3C8F9C5D1E78FD0600EC88A2 /* SSDPCommon.swift in Sources */, - 3C8F9C791E78FD0600EC88A2 /* SSDPUPnP.swift in Sources */, - 3C8F9C6D1E78FD0600EC88A2 /* SSDPMSearchResponse.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 3C8F9C3F1E78FB6200EC88A2 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 3C8F9C761E78FD0600EC88A2 /* SSDPSearchTarget.swift in Sources */, - 3C8F9C721E78FD0600EC88A2 /* SSDPResponse.swift in Sources */, - 3C8F9C661E78FD0600EC88A2 /* SSDPDiscoverySession.swift in Sources */, - 3C8F9C6A1E78FD0600EC88A2 /* SSDPMSearchRequest.swift in Sources */, - 3C8F9C621E78FD0600EC88A2 /* SSDPDiscovery.swift in Sources */, - 3C8F9C5E1E78FD0600EC88A2 /* SSDPCommon.swift in Sources */, - 3C8F9C7A1E78FD0600EC88A2 /* SSDPUPnP.swift in Sources */, - 3C8F9C6E1E78FD0600EC88A2 /* SSDPMSearchResponse.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 3C8F9C151E78FB0300EC88A2 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INFOPLIST_FILE = Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.12; - ONLY_ACTIVE_ARCH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.paulbates.SwiftSSDP; - PRODUCT_NAME = SwiftSSDP; - }; - name = Debug; - }; - 3C8F9C161E78FB0300EC88A2 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INFOPLIST_FILE = Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MACOSX_DEPLOYMENT_TARGET = 10.12; - PRODUCT_BUNDLE_IDENTIFIER = com.paulbates.SwiftSSDP; - PRODUCT_NAME = SwiftSSDP; - }; - name = Release; - }; - 3C8F9C231E78FB3100EC88A2 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", - ); - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - 3C8F9C241E78FB3100EC88A2 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = ""; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/iOS", - ); - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 4.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; - 3C8F9C301E78FB4000EC88A2 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = ""; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/watchOS", - ); - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 3.0; - TARGETED_DEVICE_FAMILY = 4; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 3.0; - }; - name = Debug; - }; - 3C8F9C311E78FB4000EC88A2 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - APPLICATION_EXTENSION_API_ONLY = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = ""; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/watchOS", - ); - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 3.0; - TARGETED_DEVICE_FAMILY = 4; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - WATCHOS_DEPLOYMENT_TARGET = 3.0; - }; - name = Release; - }; - 3C8F9C3D1E78FB4E00EC88A2 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = ""; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/tvOS", - ); - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = appletvos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 3.0; - TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 10.0; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - 3C8F9C3E1E78FB4E00EC88A2 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = ""; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/tvOS", - ); - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = appletvos; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 3.0; - TARGETED_DEVICE_FAMILY = 3; - TVOS_DEPLOYMENT_TARGET = 10.0; - VALIDATE_PRODUCT = YES; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; - 3C8F9C4A1E78FB6300EC88A2 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "-"; - COMBINE_HIDPI_IMAGES = YES; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/Mac", - ); - FRAMEWORK_VERSION = A; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.12; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 3.0; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Debug; - }; - 3C8F9C4B1E78FB6300EC88A2 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "-"; - COMBINE_HIDPI_IMAGES = YES; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEFINES_MODULE = YES; - DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; - DYLIB_INSTALL_NAME_BASE = "@rpath"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Carthage/Build/Mac", - ); - FRAMEWORK_VERSION = A; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks @loader_path/Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 10.12; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SKIP_INSTALL = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 3.0; - VERSIONING_SYSTEM = "apple-generic"; - VERSION_INFO_PREFIX = ""; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 3C8F9C141E78FB0300EC88A2 /* Build configuration list for PBXProject "SwiftSSDP" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 3C8F9C151E78FB0300EC88A2 /* Debug */, - 3C8F9C161E78FB0300EC88A2 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 3C8F9C221E78FB3100EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP iOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 3C8F9C231E78FB3100EC88A2 /* Debug */, - 3C8F9C241E78FB3100EC88A2 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 3C8F9C2F1E78FB4000EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP watchOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 3C8F9C301E78FB4000EC88A2 /* Debug */, - 3C8F9C311E78FB4000EC88A2 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 3C8F9C3C1E78FB4E00EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP tvOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 3C8F9C3D1E78FB4E00EC88A2 /* Debug */, - 3C8F9C3E1E78FB4E00EC88A2 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 3C8F9C491E78FB6300EC88A2 /* Build configuration list for PBXNativeTarget "SwiftSSDP macOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 3C8F9C4A1E78FB6300EC88A2 /* Debug */, - 3C8F9C4B1E78FB6300EC88A2 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 3C8F9C111E78FB0300EC88A2 /* Project object */; -} diff --git a/SwiftSSDP.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SwiftSSDP.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 6e40f3e..0000000 --- a/SwiftSSDP.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/SwiftSSDP.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/SwiftSSDP.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index 08de0be..0000000 --- a/SwiftSSDP.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded - - - diff --git a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP iOS.xcscheme b/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP iOS.xcscheme deleted file mode 100644 index 08d526a..0000000 --- a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP iOS.xcscheme +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP macOS.xcscheme b/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP macOS.xcscheme deleted file mode 100644 index 6d7dc56..0000000 --- a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP macOS.xcscheme +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP tvOS.xcscheme b/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP tvOS.xcscheme deleted file mode 100644 index 33cab61..0000000 --- a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP tvOS.xcscheme +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP watchOS.xcscheme b/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP watchOS.xcscheme deleted file mode 100644 index 0b97322..0000000 --- a/SwiftSSDP.xcodeproj/xcshareddata/xcschemes/SwiftSSDP watchOS.xcscheme +++ /dev/null @@ -1,82 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SwiftSSDP/SSDPCommon.swift b/SwiftSSDP/SSDPCommon.swift deleted file mode 100644 index 5c3d6c8..0000000 --- a/SwiftSSDP/SSDPCommon.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// SSDPCommon.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/6/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation - -/// Category used for logging SSDP logs -public let loggerDiscoveryCategory = "SSDP" - -/// SSDP M-Search, Notify, and Response header keys -struct SSDPHeaderKeys { - static let cacheControl: String = "CACHE-CONTROL" - static let date: String = "DATE" - static let ext: String = "EXT" - static let host: String = "HOST" - static let location: String = "LOCATION" - static let man: String = "MAN" - static let maxWait: String = "MX" - static let notifyType: String = "NT" - static let notifySubType: String = "NTS" - static let searchTarget: String = "ST" - static let server: String = "SERVER" - static let usn: String = "USN" -} - -/// SSDP headers -public typealias SSDPHeaders = [String: String] - -/// SSDP message announcement type -public enum SSDPMessageAnnoucement: String { - /// For M-SEARCH requests, used as MAN value - case discover = "ssdp:discover" - /// For NOTIFY Alive multicast broadcasts, used in NTS - case alive = "ssdp:alive" - /// For NOTIFY ByeBye multicast broadcasts, used in NTS - case byeBye = "ssdp:byebye" -} diff --git a/SwiftSSDP/SSDPDiscovery.swift b/SwiftSSDP/SSDPDiscovery.swift deleted file mode 100644 index bbcfa63..0000000 --- a/SwiftSSDP/SSDPDiscovery.swift +++ /dev/null @@ -1,299 +0,0 @@ -// -// SSDPDiscovery.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/4/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation -import CocoaAsyncSocket -import SwiftAbstractLogger -import Weak - -// -// MARK: - Protocols -// - -/// Delegate for device discovery -public protocol SSDPDiscoveryDelegate { - /// Called when a requested device has been discovered - func discoveredDevice(response: SSDPMSearchResponse, session: SSDPDiscoverySession) - - /// Called when a requested service has been discovered - func discoveredService(response: SSDPMSearchResponse, session: SSDPDiscoverySession) - - /// Called when a session has been closed - func closedSession(_ session: SSDPDiscoverySession) -} - -extension SSDPDiscoveryDelegate { - func discoveredDevice(response: SSDPMSearchResponse, session: SSDPDiscoverySession) { - - } - - func discoveredService(response: SSDPMSearchResponse, session: SSDPDiscoverySession) { - - } - - func closedSession(_ session: SSDPDiscoverySession) { - - } -} - -// -// MARK: - -// - -/// SSDP discovery for UPnP devices on the LAN -/// -/// - Note: No checks are in place to ensure connectivity to the local area network -public class SSDPDiscovery: NSObject { - public static let ssdpHost: String = "239.255.255.250" - public static let ssdpPort: Int = 1900 - - /// Singleton access to a discovery operating on the main dispatch queue - open static let defaultDiscovery = SSDPDiscovery() - - /// Private initialization using the global queue - private override init() { - - } - - /// Init a new discovery with an alternative queue to handle responses on. By default responses are handled on the main dispatch queue. - /// - /// - Parameters: - /// - responseQueue: a dispatch queue to process and dispatch responses to - public init(responseQueue: DispatchQueue) { - self.responseQueue = responseQueue - } - - // - // MARK: Public functions - // - - /// Starts a discovery session based on an M-SEARCH `request`. - /// - /// Clients are in control of the session lifetime and should retain the returned `SSDPDiscoverySession` else the session close - /// immediately, unless an explict `timeout` is used. When a timeout is used the session will auto close after the timeout - /// automatically. For more information about discovery sessions see `SSDPDiscoverySessions`. - /// - /// - Parameters: - /// - request: The M-SEARCH request representing the devices to discover - /// - timeout: Time interval to automatically close the returned session after - /// - /// - Throws: `NSError` if there was an error establishing the socket for M-SEARCH broadcasts on the LAN - open func startDiscovery(request: SSDPMSearchRequest, timeout: TimeInterval? = nil) throws -> SSDPDiscoverySession { - try initDiscoverySocket() - - assert(self.asyncUdpSocket != nil) - assert(!self.asyncUdpSocket!.isClosed()) - assert(self.ssdpResponseQueue != nil) - - return startSession(request: request, timeout: timeout) - } - - /// Halts all discovery in flight for all `SSDPDisoverySession`s. Use with care t prevent unintented stopping of active sessions. - /// - /// Typically this will be used when a local network adapter becomes unavailable and all active sessions should be stopped. - open func stopAllDiscovery() { - // Close all sessions - activeSessions.forEach { $0.object?.forceClose() } - - // Should have deinit all things now - assert(self.activeSessions.isEmpty) - assert(self.asyncUdpSocket == nil) - assert(self.ssdpResponseQueue === nil) - } - - // - // MARK: Private Functions - // - - func handleMessage(_ message: SSDPMessage) { - var responseSearchTarget: SSDPSearchTarget? - switch message { - case .searchResponse(let response): - responseSearchTarget = response.searchTarget - break - - default: - break - } - - if let searchTarget = responseSearchTarget { - // We should not be getting responses with ssdp:all - if searchTarget == .all { - SwiftAbstractLogger.logWarning("Received MSEARCH response with ssdp:all") - return; - } - - for weakSession in activeSessions { - guard let session = weakSession.object else { - continue - } - - // Check if the session is capable of handling the target - let request = session.request - if request.searchTarget == searchTarget || request.searchTarget == SSDPSearchTarget.all { - switch message { - case .searchResponse(let response): - switch searchTarget { - case .all: - break - - case .rootDevice, .uuid, .deviceType: - session.discoveredDevice(response: response, session: session) - break - - case .serviceType: - session.discoveredService(response: response, session: session) - break - } - - default: - break - } - - } - } - } - } - - /// Initializes the socket used to perform device discovery on the local area network - private func initDiscoverySocket() throws { - // Check if the socket has already been created - if self.asyncUdpSocket != nil { - return - } - - // We'll use a different queue for handling responses so we can parse and process - // responses off the main queue. - var ssdpQueue = self.ssdpResponseQueue - if ssdpQueue == nil { - ssdpQueue = self.responseQueue - // If we are not on the main queue then we'll reuse the response queue for SSDP response processing - if ssdpQueue == nil || ssdpQueue == DispatchQueue.main { - ssdpQueue = DispatchQueue(label: loggerDiscoveryCategory, qos: .background, attributes: .concurrent, autoreleaseFrequency: .inherit, target: DispatchQueue.main) - } - } - ssdpQueue = DispatchQueue.main - let socket = GCDAsyncUdpSocket(delegate: self, delegateQueue: DispatchQueue.main) - try socket.enableBroadcast(true) - try socket.beginReceiving() - - self.asyncUdpSocket = socket - self.ssdpResponseQueue = ssdpQueue! - } - - /// Cleans up the discover socket when no more sessions are active - fileprivate func deinitDiscoverySocket() { - assert(activeSessions.isEmpty) - - self.asyncUdpSocket?.close() - self.asyncUdpSocket = nil - self.ssdpResponseQueue = nil - } - - // - // MARK: Private Instance Variables - // - - fileprivate var activeSessions: [Weak] = [] - fileprivate var asyncUdpSocket: GCDAsyncUdpSocket? - private var ssdpResponseQueue: DispatchQueue? - private var responseQueue: DispatchQueue? -} - -// -// MARK: - Session management -// - -extension SSDPDiscovery { - - /// Starts a new session based on an M-SEARCH `request`. - /// - /// - Parameters: - /// - request: The M-SEARCH request representing the devices to discover - /// - timeout: Time interval to automatically close the session after - /// - /// - Returns: A new discovery session for the request - internal func startSession(request: SSDPMSearchRequest, timeout: TimeInterval? = nil) -> SSDPDiscoverySession { - let session = SSDPDiscoverySession(request: request, discovery: self, timeout: timeout) - self.activeSessions.append(Weak(session)) - session.start() - return session - } - - /// Sends a single M-SEARCH broadcast over the local area network to discover devices. Sending a request does not guarentee a response - /// given the unreliablity of UDP. - /// - /// - Parameters: - /// - request: The M-SEARCH request representing the devices to discover - internal func sendRequestMessage(request: SSDPMSearchRequest) { - if let socket = self.asyncUdpSocket { - let messageData = request.message.data(using: .utf8)! - socket.send(messageData, toHost: SSDPDiscovery.ssdpHost, port: UInt16(SSDPDiscovery.ssdpPort), withTimeout: -1, tag: 1000) - } - } - - /// Closes a session and removs the association from the discovery. Once all sessions are close the discovery is free to reclaim - /// the sockets. - /// - /// - Parameters: - /// - session: The session to close - internal func closeSession(session: SSDPDiscoverySession) { - let count = self.activeSessions.count - self.activeSessions = activeSessions.filter { $0.object != nil && $0.object !== session as SSDPDiscoverySession? } - if activeSessions.isEmpty && count != self.activeSessions.count { - deinitDiscoverySocket() - } - } -} - -// -// MARK: - GCDAsyncUdpSocketDelegate -// - -extension SSDPDiscovery: GCDAsyncUdpSocketDelegate { - public func udpSocket(_ sock: GCDAsyncUdpSocket, didConnectToAddress address: Data) { - } - - public func udpSocket(_ sock: GCDAsyncUdpSocket, didNotConnect error: Error?) { - if (error != nil) { - SwiftAbstractLogger.logError(category: loggerDiscoveryCategory, "Unable to connect \(String(describing: error))") - } else { - SwiftAbstractLogger.logError(category: loggerDiscoveryCategory, "Unable to connect") - } - } - - public func udpSocket(_ sock: GCDAsyncUdpSocket, didSendDataWithTag tag: Int) { - } - - public func udpSocket(_ sock: GCDAsyncUdpSocket, didNotSendDataWithTag tag: Int, dueToError error: Error?) { - if (error != nil) { - SwiftAbstractLogger.logError(category: loggerDiscoveryCategory, "Unable to send data \(String(describing: error))") - } else { - SwiftAbstractLogger.logError(category: loggerDiscoveryCategory, "Unable to send data") - } - } - - public func udpSocket(_ sock: GCDAsyncUdpSocket, didReceive data: Data, fromAddress address: Data, withFilterContext filterContext: Any?) { - SwiftAbstractLogger.logVerbose(category: loggerDiscoveryCategory, "M-SEARCH response handled") - SwiftAbstractLogger.logDebug(category: loggerDiscoveryCategory, String(data: data, encoding: .utf8)!) - - // Ensure we have parsable data - guard let messageString = String(data: data, encoding: .utf8) else { - SwiftAbstractLogger.logError(category: loggerDiscoveryCategory, "Unable to parse M-SEARCH response") - return - } - - // Construct a real message based on parsing the string message - guard let message = SSDPMessageParser.parse(response: messageString) else { - SwiftAbstractLogger.logError(category: loggerDiscoveryCategory, "incomplete M-SEARCH response\n\(messageString)") - return - } - - self.handleMessage(message) - } -} diff --git a/SwiftSSDP/SSDPDiscoverySession.swift b/SwiftSSDP/SSDPDiscoverySession.swift deleted file mode 100644 index 0703372..0000000 --- a/SwiftSSDP/SSDPDiscoverySession.swift +++ /dev/null @@ -1,229 +0,0 @@ -// -// SSDPDiscoverySession.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/4/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation -import SwiftAbstractLogger - -/// SSDPDiscovery based session returned from `SSDPDiscovery`'s `startDiscovery`. -/// -/// A `SSDPDiscoverySession` should be retained by a client to ensure the session remains active and to `close()` the session when done. -/// Alternatively is a session was started and has a timeout it may be used in a fire-&-forget manner because the session will -/// automatically close when the timeout expires. -/// -/// Sessions will keep sending M-SEARCH requests to aid discover at a stepped cadence based on how long the session has been running. -/// This is a recommendation of UPnP due to the unreliable nature of UDP, especially over WiFi. To prevent flooding the network with -/// M-SEARCH broadcast packets the cadence will back off incrementally in steps, starting at 1 search/second to 1 search/minute. It's -/// to this reason why sessions should be closed as soon as the devices/services are discovered. -/// -/// To close a session, once devices have been discovered or a designated amount of time has elapsed, call `close()`. It is not recommended -/// to rely on `SSDPDiscoverySession` being reclaimed to end the session. -/// -/// - Note: -/// Failure to close a session will log warnings -public class SSDPDiscoverySession: Equatable { - public let request: SSDPMSearchRequest - - /// All discovered devices for the session - public var responses: Set { - return internalResponses - } - - /// Phase of the session. Once `Closed` a session can never be restarted. - public var phase: Phase { - return internalPhase; - } - - /// Session phase - /// - /// - Unknown: Session has not been fully initialized and started yet - /// - Searching: Session is actively search for devices - /// - Closed: Sessions has closed and will no longer be able to perform device discovery - public enum Phase { - case unknown - case searching - case closed - } - - // - // MARK: Initialization - // - - /// Initialize the session for an M-SEARCH `request` and a `discovery` - /// - /// - Parameters: - /// - request: M-SEARCH request with the relevant M-SEARCH search target and delegate to callback - /// - timeout: optional Timeout interval to automatically close the session after - /// - discovery: Discovery object to make M-SEARCH broadcast through and recieve raw responses from - internal init(request: SSDPMSearchRequest, discovery: SSDPDiscovery, timeout: TimeInterval?) { - self.request = request - self.timeout = timeout - self.discovery = discovery - self.internalResponses = Set() - } - - deinit { - close() - } - - // - // MARK: Public Functions - // - - /// Closes the session and halts any further M-SEARCH requests. - /// - /// Once closed a session cannot be reopened, and any responses from in-flight M-SEARCH broadcasts will be ignored - public func close() { - if let timer = self.broadcastTimer { - timer.invalidate() - self.broadcastTimer = nil - } - if let closeTimer = self.timeoutTimer { - closeTimer.invalidate() - self.timeoutTimer = nil - } - - self.discovery?.closeSession(session: self) - self.discovery = nil - self.internalPhase = .closed - } - - // - // MARK: Internal Functions - // - - /// Starts the session, and performs the first M-SEARCH request - /// - /// Only to be called internally - internal func start() { - if self.phase != .unknown { - return - } - - self.startDate = Date() - self.checkDate = self.startDate - self.internalPhase = .searching - - // Schedule timer for auto-session expiration - if let timeout = self.timeout { - // Intended to reference `self` because we can use fire-and-forget when using a timer - // close() will cancel the timer in other cases and deinit. - self.timeoutTimer = Timer.scheduledTimer(withTimeInterval: timeout, repeats: false, block: { (timer) in - self.forceClose() - }) - } - - sendSearchRequest() - } - - /// Forces the session to close. - /// - /// Only to be called internally - internal func forceClose() { - close() - - self.closedSession(self) - } - - // - // MARK: Private Functions - // - - /// Schedules the next timer based on the current time and start time. Timer cadence changes at intervals to ensure the network - /// is not flooded with M-SEARCH broadcasts. - private func scheduleNextTimer() { - assert(self.phase == .searching) - let now = Date() - - let interval = now.timeIntervalSince(self.startDate) - let cadence = SSDPDiscoverySession.timerCadence(forTimeInterval: interval) - self.broadcastTimer = Timer.scheduledTimer(withTimeInterval: cadence, repeats: false, block: { [unowned self] (Timer) in - if (self.phase == .searching) { - self.sendSearchRequest() - } - }) - - // Log a check to ensure the session is correctly closed - if now.timeIntervalSince(self.checkDate) > 30 { - logWarning(category: loggerDiscoveryCategory, "Session has been running longer than 30 seconds!") - self.checkDate = now - } - } - - /// Actually sends a single M-SEARCH on the LAN and schedules the next timer for the next M-SEARCH - private func sendSearchRequest() { - assert(self.phase == .searching) - self.discovery?.sendRequestMessage(request: self.request) - scheduleNextTimer() - } - - /// Calculates the timer cadence based on the time the session has been performing M-SEARCHs for. - private static func timerCadence(forTimeInterval interval: TimeInterval) -> TimeInterval { - if interval >= 60.0 { - return 60.0 - } else if interval >= 10.0 { - return 10.0 - } else if interval >= 5.0 { - return 3.0 - } else { - return 1.0 - } - } - - // - // MARK: Private instance variables - // - - private weak var discovery: SSDPDiscovery? - - private var internalPhase: Phase = .unknown - private var startDate: Date! - private var checkDate: Date! - private var broadcastTimer: Timer? - - private var timeout: TimeInterval? - private var timeoutTimer: Timer? - - // Discovered devices and services - fileprivate var internalResponses: Set -} - -// -// MARK: - -// - -extension SSDPDiscoverySession: SSDPDiscoveryDelegate { - public func discoveredDevice(response: SSDPMSearchResponse, session: SSDPDiscoverySession) { - // TODO: Add a write lock here to synchronize `internalResponses` - if !internalResponses.contains(response) { - internalResponses.insert(response) - - self.request.delegate.discoveredDevice(response: response, session: session) - } - } - - public func discoveredService(response: SSDPMSearchResponse, session: SSDPDiscoverySession) { - // TODO: Add a write lock here to synchronize `internalResponses` - if !internalResponses.contains(response) { - internalResponses.insert(response) - - self.request.delegate.discoveredService(response: response, session: session) - } - } - - public func closedSession(_ session: SSDPDiscoverySession) { - self.request.delegate.closedSession(session) - } -} - -// -// MARK: - -// - -public func ==(lhs: SSDPDiscoverySession, rhs: SSDPDiscoverySession) -> Bool { - return lhs === rhs; -} diff --git a/SwiftSSDP/SSDPMSearchRequest.swift b/SwiftSSDP/SSDPMSearchRequest.swift deleted file mode 100644 index df08a9c..0000000 --- a/SwiftSSDP/SSDPMSearchRequest.swift +++ /dev/null @@ -1,71 +0,0 @@ -// -// SSDPMessage.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/4/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation - -/// M-SEARCH request to peform a device discovery on the local area network for UPnP devices -public struct SSDPMSearchRequest { - public static let messsageHeader: String = "M-SEARCH * HTTP/1.1" - - /// Delegate to call discovery on - public let delegate: SSDPDiscoveryDelegate - /// `MAN` type - public let man: SSDPMessageAnnoucement = .discover - /// `MX` max wait time - public let maxWaitTime: Int - /// `ST` search target for M-SEARCH - public let searchTarget: SSDPSearchTarget - /// Any addition headers to include in the M-SEARCH request not standardized by UPnP - public let otherHeaders: SSDPHeaders? - /// M-SEARCH request message as `String` - public var message: String { - var headers: SSDPHeaders = [ - SSDPHeaderKeys.host: "\(SSDPDiscovery.ssdpHost):\(SSDPDiscovery.ssdpPort)", - SSDPHeaderKeys.man: "\"\(self.man.rawValue)\"", - SSDPHeaderKeys.maxWait: self.maxWaitTime.description, - SSDPHeaderKeys.searchTarget: self.searchTarget.description - ] - if let otherHeaders = self.otherHeaders { - for (key, value) in otherHeaders { - if headers.index(forKey: key) == nil { - headers[key] = value - } - } - } - var message: String = SSDPMSearchRequest.messsageHeader + "\r\n" - for (key, value) in headers { - message.append("\(key): \(value)\r\n") - } - message.append("\r\n") - return message - } - - // - // MARK: Initialization - // - - /// - /// - public init(delegate: SSDPDiscoveryDelegate, searchTarget: SSDPSearchTarget, maxWait: Int = 1, otherHeaders: SSDPHeaders? = nil) { - self.delegate = delegate - self.searchTarget = searchTarget - self.maxWaitTime = maxWait - self.otherHeaders = otherHeaders - } -} - -// -// MARK: - -// - -// SSDPMSearchRequest: CustomStringConvertible -extension SSDPMSearchRequest: CustomStringConvertible { - public var description: String { - return self.message - } -} diff --git a/SwiftSSDP/SSDPMSearchResponse.swift b/SwiftSSDP/SSDPMSearchResponse.swift deleted file mode 100644 index dfaa083..0000000 --- a/SwiftSSDP/SSDPMSearchResponse.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// SSDPMSearchResponse.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/8/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation - -/// An M-SEARCH response for a device or service found during device/service discovery -public struct SSDPMSearchResponse { - /// CACHE-CONTROL - public let cacheControl: Date? - /// DATE - public let date: Date? - /// EXT - public let ext: Bool - /// LOCATION - public let location: URL - /// SERVER - public let server: String? - /// ST - public let searchTarget: SSDPSearchTarget - /// USN - public let usn: String - - /// All other headers in the discovery response - public let otherHeaders: [String: String] -} - -// -// MARK: - -// - -extension SSDPMSearchResponse: Hashable { - public var hashValue: Int { - return (31 &* self.usn.hashValue) &+ self.location.hashValue - } -} - -public func ==(lhs: SSDPMSearchResponse, rhs: SSDPMSearchResponse) -> Bool { - return lhs.usn == rhs.usn && lhs.location == rhs.location -} diff --git a/SwiftSSDP/SSDPResponse.swift b/SwiftSSDP/SSDPResponse.swift deleted file mode 100644 index acbfc8e..0000000 --- a/SwiftSSDP/SSDPResponse.swift +++ /dev/null @@ -1,255 +0,0 @@ -// -// SSDPMessageScanner.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/7/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation - -/// Types of responses from discovery or from joining the SSDP multicast group -public enum SSDPMessage { - /// A M-SEARCH request broadcast - case searchRequest - /// A M-SEARCH response for a discovered device or service - case searchResponse(response: SSDPMSearchResponse) - /// A NOTIFY broadcast - case notify -} - -/// Case insensitive indexing for things like location etc -extension Dictionary where Key == String { - - subscript(caseInsensitive key: Key) -> Value? { - get { - if let k = keys.first(where: { $0.caseInsensitiveCompare(key) == .orderedSame }) { - return self[k] - } - return nil - } - set { - if let k = keys.first(where: { $0.caseInsensitiveCompare(key) == .orderedSame }) { - self[k] = newValue - } else { - self[key] = newValue - } - } - } -} -// -// MARK: - -// - -/// Parses M-SEARCH and NOTIFY responses from M-SEARCH broadcasts or NOTIFY multicasts on the local area network -class SSDPMessageParser { - - /// Parses an M-SEARCH request/response or a multicast NOTIFY broadcast - /// Note: NOTIFY currently not supported - /// - /// - Parameters: - /// - response: Full response to parse - public static func parse(response: String) -> SSDPMessage? { - if response.isEmpty { - return nil - } - - let scanner = Scanner(string: response) - scanner.charactersToBeSkipped = CharacterSet.newlines - - // Scan first token to ensure we have an expected response - guard let token = scanInitialToken(scanner: scanner) else { - return nil - } - - // Scan remaining headers to construct and init a response from - var headers: [String: String] = [:] - while(!scanner.isAtEnd) { - if let pair = scanKeyValuePair(scanner: scanner) { - headers[pair.key] = pair.value - } - } - - return constructMessage(token: token, headers: headers) - } - - // - // MARK: Private Functions - // - - /// Scans an initial token from the response to determine what the response type is - /// - /// - Parameters: - /// - scanner: Scanner to scan an initial token from - /// - /// - Returns: An initial token or nil if the scanner has already reached the end - private static func scanInitialToken(scanner: Scanner) -> String? { - if scanner.isAtEnd { - return nil - } - - var buffer: NSString? = nil - if scanner.scanUpToCharacters(from: CharacterSet.whitespacesAndNewlines, into: &buffer) { - // Scan to the end of the line - _ = scanLine(scanner: scanner) - - return buffer as String? - } - - return nil - } - - /// Scans a single line from the response - /// - /// - Parameters: - /// - scanner: Scanner to scan the next line from - /// - /// - Returns: An entire line or nil if the scanner has already reached the end - private static func scanLine(scanner: Scanner) -> String? { - if scanner.isAtEnd { - return nil - } - - var buffer: NSString? = nil - if scanner.scanUpToCharacters(from: CharacterSet.newlines, into: &buffer) { - return buffer as String? - } - - return nil - } - - /// Scans a single line's key/value pair in the form of "KEY: value" - /// - /// - Parameters: - /// - scanner: Scanner to scan the pair from - /// - /// - Returns: A tuple of the key/value pair or nil if no match could be found or the scanner has reached the end - private static func scanKeyValuePair(scanner: Scanner) -> (key: String, value: String)? { - if scanner.isAtEnd { - return nil - } - - var buffer: NSString? = nil - let delimiterSet = CharacterSet(charactersIn: ":").union(CharacterSet.whitespaces) - if scanner.scanUpToCharacters(from: delimiterSet, into: &buffer), let key = buffer as String? { - if scanner.scanCharacters(from: delimiterSet, into: nil) && !scanner.isAtEnd { - if CharacterSet.newlines.contains(UnicodeScalar((scanner.string as NSString).character(at: scanner.scanLocation))!) { - return (key: key, value: "") - } else if let value = scanLine(scanner: scanner) { - return (key: key, value: value.trimmingCharacters(in: CharacterSet.whitespaces)) - } else { - return nil - } - } - } - - return nil - } - - /// Constructs a message based on initial token of the raw response and the parsed headers. - /// - /// - Parameters - /// - token: Initial token parses from the response - /// - headers: Parsed dictionary of headers to construct a response with - /// - /// - Returns: A fully formed message - private static func constructMessage(token: String, headers: [String: String]) -> SSDPMessage? { - if token == "M-SEARCH" { - return nil - } else if token == "NOTIFY" { - return nil - } else if token == "HTTP/1.1" { - guard let response = SSDPMSearchResponse(fromHeaders: headers) else { - return nil - } - return .searchResponse(response: response) - } - - return nil - } - -} - -// -// MARK: - -// - -// Extension to support initializing an `SSDPMSearchResponse` from `SSDPMessageParser` parsed message -extension SSDPMSearchResponse { - - /// Attempts to initialize a `SSDPMSearchResponse` from parsed response headers. If critical headers are missing or malformed the - /// response will not be initialized - /// - /// - Parameters: - /// - fromHeaders: M-SEARCH response headers - init?(fromHeaders headers: [String: String]) { - var mutableHeaders = headers - - // CACHE-CONTROL - if let cacheControlString = headers[SSDPHeaderKeys.cacheControl] { - let maxAgeRegEx = try! NSRegularExpression(pattern: "max\\-age[ \t]*=[ \t]*([0-9]+)") - var matches: [String] = [] - for match in maxAgeRegEx.matches(in: cacheControlString, range: NSRange(location:0, length: cacheControlString.utf8.count)) { - let capturedRange = match.range(at: 1) - if !NSEqualRanges(capturedRange, NSMakeRange(NSNotFound, 0)) { - let theResult = (cacheControlString as NSString).substring(with: capturedRange) - matches.append(theResult) - } - } - if matches.count > 0, let maxAgeSeconds = Int(matches[0], radix: 10) { - self.cacheControl = Date(timeIntervalSinceNow: TimeInterval(maxAgeSeconds) * 1000.0) - mutableHeaders.removeValue(forKey: SSDPHeaderKeys.cacheControl) - } else { - self.cacheControl = nil - } - } else { - self.cacheControl = nil - } - - // DATE - if let dateString = headers[SSDPHeaderKeys.date], let date = DateFormatter().date(from: dateString) { - self.date = date - mutableHeaders.removeValue(forKey: SSDPHeaderKeys.date) - } else { - self.date = nil - } - - // EXT - guard let _ = headers[SSDPHeaderKeys.ext] else { - return nil - } - self.ext = true - mutableHeaders.removeValue(forKey: SSDPHeaderKeys.ext) - - // LOCATION - guard let location = headers[caseInsensitive:SSDPHeaderKeys.location], let locationUrl = URL(string: location) else { - return nil - } - self.location = locationUrl - mutableHeaders.removeValue(forKey: SSDPHeaderKeys.location) - - // SERVER - if let server = headers[SSDPHeaderKeys.server] { - self.server = server - mutableHeaders.removeValue(forKey: SSDPHeaderKeys.server) - } else { - self.server = nil - } - - // ST - guard let searchTargetString = headers[SSDPHeaderKeys.searchTarget], let searchTarget = SSDPSearchTarget(rawValue: searchTargetString) else { - return nil - } - self.searchTarget = searchTarget - mutableHeaders.removeValue(forKey: SSDPHeaderKeys.searchTarget) - - // USN - guard let usn = headers[SSDPHeaderKeys.usn] else { - return nil - } - self.usn = usn - mutableHeaders.removeValue(forKey: SSDPHeaderKeys.usn) - - self.otherHeaders = mutableHeaders - } -} diff --git a/SwiftSSDP/SSDPSearchTarget.swift b/SwiftSSDP/SSDPSearchTarget.swift deleted file mode 100644 index ebee10b..0000000 --- a/SwiftSSDP/SSDPSearchTarget.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// SSDPSearchTarget.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/7/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation - -/// A device search target for the request. Search targets are represented as ST in M-SEARCH requests and NT in responses. -public enum SSDPSearchTarget { - /// Search for all devices and services - case all - /// Search for root devices only - case rootDevice - /// Search for a particular device. Device UUID specified by UPnP vendor - case uuid(uuid: String) - /// Search for any device of this type. Domain name, device type and version defined by UPnP vendor. Period - /// characters in the domain name must be replaced with hyphens in accordance with RFC 2141. - case deviceType(schema: String, deviceType: String, version: Int) - /// Search for any service of this type. Domain name, service type and version defined by UPnP vendor. - /// Period characters in the domain name must be replaced with hyphens in accordance with RFC 2141. - case serviceType(schema: String, serviceType: String, version: Int) - - /// Initialize a SearchTarget from a search target/notify target - /// - /// - Parameters: - /// - rawValue: Raw search target from a response - init?(rawValue: String) { - let components = rawValue.components(separatedBy: ":") - if components.isEmpty { - return nil - } - - if components.count == 2 { - if components[0] == "ssdp" { - if components[1] == "all" { - self = .all - return - } - } else if components[0] == "upnp" { - if components[1] == "rootdevice" { - self = .rootDevice - return - } - } else if components[0] == "uuid" { - self = .uuid(uuid: components[1]) - return - } - } else if components.count == 5, let version = Int(components[4], radix: 10) { - if components[0] == "urn" { - if components[2] == "device" { - self = .deviceType(schema: components[1], deviceType: components[3], version: version) - return - } else if components[2] == "service" { - self = .serviceType(schema: components[1], serviceType: components[3], version: version) - return - } - } - } - - return nil - } - - /// Search target term used in the M-SEARCH as ST or in NOTIFY responses a NT - public var searchTarget: String { - switch self { - case .all: - return "ssdp:all" - case .rootDevice: - return "upnp:rootdevice" - case .uuid(let uuid): - return "uuid:\(uuid)" - case .deviceType(let schema, let deviceType, let version): - return "urn:\(schema):device:\(deviceType):\(version)" - case .serviceType(let schema, let serviceType, let version): - return "urn:\(schema):service:\(serviceType):\(version)" - } - } - - /// Schema to use with `DeviceType` or `ServiceType` to UPnP forum working committee devices or services - public static let upnpOrgSchema: String = "schemas-upnp-org" -} - -// -// MARK: - -// - -// SSDPSearchTarget: CustomStringConvertible -extension SSDPSearchTarget: Equatable, CustomStringConvertible { - public static func ==(lhs: SSDPSearchTarget, rhs: SSDPSearchTarget) -> Bool { - switch (lhs, rhs) { - case (.all, .all): - return true - case (.rootDevice, .rootDevice): - return true - case (.uuid(let lid), .uuid(let rid)): - return lid == rid - case (.deviceType(let lSchema, let lType, let lVer), .deviceType(let rSchema, let rType, let rVer)): - return lSchema == rSchema && lType == rType && lVer == rVer - case (.serviceType(let lSchema, let lType, let lVer), .serviceType(let rSchema, let rType, let rVer)): - return lSchema == rSchema && lType == rType && lVer == rVer - default: - return false - } - } - - public var description: String { - return self.searchTarget - } -} diff --git a/SwiftSSDP/SSDPUPnP.swift b/SwiftSSDP/SSDPUPnP.swift deleted file mode 100644 index 82ccd92..0000000 --- a/SwiftSSDP/SSDPUPnP.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// SSDPUPnP.swift -// SwiftSSDP -// -// Created by Paul Bates on 2/14/17. -// Copyright © 2017 Paul Bates. All rights reserved. -// - -import Foundation - -// Extended SSDPSearchTarget service and device search targets offically declared by UPnP.org -extension SSDPSearchTarget { - - // - // MARK: - UPnP Audio/Video - // - - static let deviceMediaServer: SSDPSearchTarget = .deviceType(schema: SSDPSearchTarget.upnpOrgSchema, deviceType: "MediaServer", version: 1) - static let deviceMediaRenderer: SSDPSearchTarget = .deviceType(schema: SSDPSearchTarget.upnpOrgSchema, deviceType: "MediaRenderer", version: 1) - - static let serviceAVTransport: SSDPSearchTarget = .serviceType(schema: SSDPSearchTarget.upnpOrgSchema, serviceType: "AVTransport", version: 1) - static let serviceConnectionManager: SSDPSearchTarget = .serviceType(schema: SSDPSearchTarget.upnpOrgSchema, serviceType: "ConnectionManager", version: 1) - static let serviceContentDirectory: SSDPSearchTarget = .serviceType(schema: SSDPSearchTarget.upnpOrgSchema, serviceType: "ContentDirectory", version: 1) - static let serviceRenderingControl: SSDPSearchTarget = .serviceType(schema: SSDPSearchTarget.upnpOrgSchema, serviceType: "RenderingControl", version: 1) - - // - // MARK: - UPnP Internet Gateway Device (IGD) - // - - static let deviceInternetGatewayDevice: SSDPSearchTarget = .deviceType(schema: SSDPSearchTarget.upnpOrgSchema, deviceType: "InternetGatewayDevice", version: 1) - static let deviceWANConnectionDevice: SSDPSearchTarget = .deviceType(schema: SSDPSearchTarget.upnpOrgSchema, deviceType: "WANConnectionDevice", version: 1) - static let deviceWANDevice: SSDPSearchTarget = .deviceType(schema: SSDPSearchTarget.upnpOrgSchema, deviceType: "WANDevice", version: 1) - - static let serviceLayer3Forwarding: SSDPSearchTarget = .serviceType(schema: SSDPSearchTarget.upnpOrgSchema, serviceType: "Layer3Forwarding", version: 1) - static let serviceWANCommonInterfaceConfig: SSDPSearchTarget = .serviceType(schema: SSDPSearchTarget.upnpOrgSchema, serviceType: "WANCommonInterfaceConfig", version: 1) - static let serviceWANIPConnection: SSDPSearchTarget = .serviceType(schema: SSDPSearchTarget.upnpOrgSchema, serviceType: "WANIPConnection", version: 1) -} diff --git a/Tests/SwiftSSDPTests/Fixtures/malformed-missing-ext.txt b/Tests/SwiftSSDPTests/Fixtures/malformed-missing-ext.txt new file mode 100644 index 0000000..0a51762 --- /dev/null +++ b/Tests/SwiftSSDPTests/Fixtures/malformed-missing-ext.txt @@ -0,0 +1,7 @@ +HTTP/1.1 200 OK +CACHE-CONTROL: max-age=300 +LOCATION: http://192.168.1.99:80/description.xml +SERVER: SomeOldDevice/1.0 UPnP/1.0 +ST: upnp:rootdevice +USN: uuid:malformed-but-real::upnp:rootdevice + diff --git a/Tests/SwiftSSDPTests/Fixtures/msearch-response-hue.txt b/Tests/SwiftSSDPTests/Fixtures/msearch-response-hue.txt new file mode 100644 index 0000000..0329e91 --- /dev/null +++ b/Tests/SwiftSSDPTests/Fixtures/msearch-response-hue.txt @@ -0,0 +1,9 @@ +HTTP/1.1 200 OK +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=100 +LOCATION: http://192.168.1.55:80/description.xml +SERVER: Hue/1.0 UPnP/1.0 IpBridge/1.55.0 +hue-bridgeid: 001788FFFE112233 +ST: upnp:rootdevice +USN: uuid:2f402f80-da50-11e1-9b23-001788112233::upnp:rootdevice + diff --git a/Tests/SwiftSSDPTests/Fixtures/msearch-response-sonos.txt b/Tests/SwiftSSDPTests/Fixtures/msearch-response-sonos.txt new file mode 100644 index 0000000..0864d71 --- /dev/null +++ b/Tests/SwiftSSDPTests/Fixtures/msearch-response-sonos.txt @@ -0,0 +1,11 @@ +HTTP/1.1 200 OK +CACHE-CONTROL: max-age = 1800 +EXT: +LOCATION: http://192.168.1.42:1400/xml/device_description.xml +SERVER: Linux UPnP/1.0 Sonos/76.1-37220 (ZP120) +ST: urn:schemas-upnp-org:device:ZonePlayer:1 +USN: uuid:RINCON_000E58A1B2C300400::urn:schemas-upnp-org:device:ZonePlayer:1 +X-RINCON-HOUSEHOLD: Sonos_household_1 +X-RINCON-BOOTSEQ: 23 +DATE: Sun, 06 Nov 1994 08:49:37 GMT + diff --git a/Tests/SwiftSSDPTests/Fixtures/notify-alive-roku.txt b/Tests/SwiftSSDPTests/Fixtures/notify-alive-roku.txt new file mode 100644 index 0000000..5675498 --- /dev/null +++ b/Tests/SwiftSSDPTests/Fixtures/notify-alive-roku.txt @@ -0,0 +1,11 @@ +NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +CACHE-CONTROL: max-age=1800 +LOCATION: http://192.168.1.77:8060/dial/dd.xml +NT: urn:dial-multiscreen-org:device:dial:1 +NTS: ssdp:alive +SERVER: Roku UPnP/1.0 Roku/12.0.0 +USN: uuid:roku:ecp:YR0070123456::urn:dial-multiscreen-org:device:dial:1 +BOOTID.UPNP.ORG: 7 +CONFIGID.UPNP.ORG: 1 + diff --git a/Tests/SwiftSSDPTests/Fixtures/notify-byebye.txt b/Tests/SwiftSSDPTests/Fixtures/notify-byebye.txt new file mode 100644 index 0000000..8153b13 --- /dev/null +++ b/Tests/SwiftSSDPTests/Fixtures/notify-byebye.txt @@ -0,0 +1,6 @@ +NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +NT: urn:schemas-upnp-org:device:MediaServer:1 +NTS: ssdp:byebye +USN: uuid:00000000-0000-0000-0000-aabbccddeeff::urn:schemas-upnp-org:device:MediaServer:1 + diff --git a/Tests/SwiftSSDPTests/Fixtures/notify-update.txt b/Tests/SwiftSSDPTests/Fixtures/notify-update.txt new file mode 100644 index 0000000..6f46984 --- /dev/null +++ b/Tests/SwiftSSDPTests/Fixtures/notify-update.txt @@ -0,0 +1,10 @@ +NOTIFY * HTTP/1.1 +HOST: 239.255.255.250:1900 +LOCATION: http://192.168.1.42:1400/xml/device_description.xml +NT: upnp:rootdevice +NTS: ssdp:update +USN: uuid:RINCON_000E58A1B2C300400::upnp:rootdevice +BOOTID.UPNP.ORG: 23 +NEXTBOOTID.UPNP.ORG: 24 +CONFIGID.UPNP.ORG: 1 + diff --git a/Tests/SwiftSSDPTests/MockTransport.swift b/Tests/SwiftSSDPTests/MockTransport.swift new file mode 100644 index 0000000..c2f8d74 --- /dev/null +++ b/Tests/SwiftSSDPTests/MockTransport.swift @@ -0,0 +1,121 @@ +// +// MockTransport.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation +@testable import SwiftSSDP + +/// In-memory ``SSDPTransport`` for tests. +/// +/// Test code drives behavior by calling ``deliverSearchReply(_:)`` and +/// ``deliverNotify(_:)``; subscribers see those bytes flow through their streams as if +/// they had arrived from the network. The mock never touches a real socket. +/// +/// `MockTransport` is an `actor` so test setup, mid-test injections, and concurrent +/// consumers can all coexist without data races. Failure modes (e.g. simulated multicast +/// join failure) can be primed before the discovery actor calls the transport. +actor MockTransport: SSDPTransport { + + // MARK: - Test instrumentation + + private var searchContinuations: [UUID: AsyncThrowingStream.Continuation] = [:] + private var multicastContinuations: [UUID: AsyncThrowingStream.Continuation] = [:] + + /// Recorded outbound M-SEARCH messages, in order. + private(set) var sentRequests: [SSDPMSearchRequest] = [] + + /// If non-nil, the next call to ``multicastDatagrams()`` throws this error + /// (and resets to `nil`). + var multicastFailure: SSDPError? = nil + + /// If non-nil, the next call to ``sendSearch(_:)`` throws this error + /// (and resets to `nil`). + var sendFailure: SSDPError? = nil + + init() {} + + // MARK: - Test driving + + /// Deliver a datagram to all currently-subscribed search streams. + func deliverSearchReply(_ raw: String, from source: String = "192.168.1.10:1900") { + let datagram = SSDPDatagram(data: Data(raw.utf8), source: source) + for cont in searchContinuations.values { + cont.yield(datagram) + } + } + + /// Deliver a datagram to all currently-subscribed multicast (NOTIFY) streams. + func deliverNotify(_ raw: String, from source: String = "192.168.1.10:1900") { + let datagram = SSDPDatagram(data: Data(raw.utf8), source: source) + for cont in multicastContinuations.values { + cont.yield(datagram) + } + } + + /// Number of currently-subscribed multicast streams (for ref-count tests). + var multicastSubscriberCount: Int { multicastContinuations.count } + + /// Number of currently-subscribed search streams. + var searchSubscriberCount: Int { searchContinuations.count } + + /// Prime a multicast join failure for the next call. + func setMulticastFailure(_ error: SSDPError?) { + multicastFailure = error + } + + /// Prime a send failure for the next call. + func setSendFailure(_ error: SSDPError?) { + sendFailure = error + } + + // MARK: - SSDPTransport + + func sendSearch(_ request: SSDPMSearchRequest) + async throws -> AsyncThrowingStream + { + if let err = sendFailure { + sendFailure = nil + throw err + } + sentRequests.append(request) + + let id = UUID() + let (stream, cont) = AsyncThrowingStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + searchContinuations[id] = cont + cont.onTermination = { [weak self] _ in + Task { await self?.removeSearch(id: id) } + } + return stream + } + + func multicastDatagrams() + async throws -> AsyncThrowingStream + { + if let err = multicastFailure { + multicastFailure = nil + throw err + } + let id = UUID() + let (stream, cont) = AsyncThrowingStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + multicastContinuations[id] = cont + cont.onTermination = { [weak self] _ in + Task { await self?.removeMulticast(id: id) } + } + return stream + } + + private func removeSearch(id: UUID) { + searchContinuations.removeValue(forKey: id) + } + + private func removeMulticast(id: UUID) { + multicastContinuations.removeValue(forKey: id) + } +} diff --git a/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift b/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift new file mode 100644 index 0000000..f76d084 --- /dev/null +++ b/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift @@ -0,0 +1,210 @@ +// +// SSDPDiscoveryTests.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation +import Testing +@testable import SwiftSSDP + +@Suite("SSDPDiscovery — search") +struct SSDPDiscoverySearchTests { + + @Test("search yields parsed responses matching the request's target") + func yieldsMatchingResponses() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + + let stream = discovery.search(for: .rootDevice, timeout: 0.5) + + // Subscribe first, then deliver. + let task = Task { try await stream.collect() } + try await waitForSearchSubscriber(in: mock) + + await mock.deliverSearchReply(try fixture("msearch-response-hue")) + // Sonos response targets a ZonePlayer, not rootDevice — should be filtered out. + await mock.deliverSearchReply(try fixture("msearch-response-sonos")) + + let collected = try await task.value + #expect(collected.count == 1) + #expect(collected.first?.location == + URL(string: "http://192.168.1.55:80/description.xml")) + } + + @Test("search with .all passes through every response") + func wildcardSearchPassesAll() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + let stream = discovery.search(for: .all, timeout: 0.5) + + let task = Task { try await stream.collect() } + try await waitForSearchSubscriber(in: mock) + await mock.deliverSearchReply(try fixture("msearch-response-hue")) + await mock.deliverSearchReply(try fixture("msearch-response-sonos")) + + let collected = try await task.value + #expect(collected.count == 2) + } + + @Test(".collect() deduplicates by (usn, location)") + func collectDeduplicates() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + let stream = discovery.search(for: .rootDevice, timeout: 0.5) + + let task = Task { try await stream.collect() } + try await waitForSearchSubscriber(in: mock) + + let raw = try fixture("msearch-response-hue") + await mock.deliverSearchReply(raw) + await mock.deliverSearchReply(raw) + await mock.deliverSearchReply(raw) + + let collected = try await task.value + #expect(collected.count == 1) + } + + @Test("Timeout finishes the stream cleanly without throwing") + func timeoutFinishesCleanly() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + let stream = discovery.search(for: .rootDevice, timeout: 0.2) + // No deliveries — just wait for timeout. + let result = try await stream.collect() + #expect(result.isEmpty) + } + + @Test("Transport send failure surfaces as a thrown error") + func sendFailurePropagates() async throws { + let mock = MockTransport() + await mock.setSendFailure(.transportFailed(details: "primed failure")) + let discovery = SSDPDiscovery(transport: mock) + let stream = discovery.search(for: .rootDevice, timeout: 1) + + do { + _ = try await stream.collect() + Issue.record("Expected stream to throw") + } catch let error as SSDPError { + if case .transportFailed = error {} else { + Issue.record("Expected .transportFailed, got \(error)") + } + } + } + + @Test("Cancelling the consumer task tears down the underlying subscription") + func cancellationTearsDownTransport() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + + let task = Task { + for try await _ in discovery.search(for: .rootDevice) { + // Iterate until cancelled. + } + } + try await waitForSearchSubscriber(in: mock) + #expect(await mock.searchSubscriberCount >= 1) + + task.cancel() + try await waitFor { await mock.searchSubscriberCount == 0 } + #expect(await mock.searchSubscriberCount == 0) + } +} + +@Suite("SSDPDiscovery — notifications") +struct SSDPDiscoveryNotificationTests { + + @Test("notifications() yields parsed alive/byebye/update events") + func yieldsNotificationEvents() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + + let stream = discovery.notifications() + let task = Task { () -> [SSDPNotification] in + var collected: [SSDPNotification] = [] + for try await n in stream { + collected.append(n) + if collected.count == 3 { break } + } + return collected + } + try await waitForMulticastSubscriber(in: mock, count: 1) + + await mock.deliverNotify(try fixture("notify-alive-roku")) + await mock.deliverNotify(try fixture("notify-byebye")) + await mock.deliverNotify(try fixture("notify-update")) + + let events = try await task.value + #expect(events.count == 3) + if case .alive(let ad) = events[0] { + #expect(ad.usn.contains("YR0070123456")) + } else { + Issue.record("Expected alive at index 0, got \(events[0])") + } + if case .byebye = events[1] {} else { Issue.record("Expected byebye at index 1") } + if case .update = events[2] {} else { Issue.record("Expected update at index 2") } + } + + @Test("Multiple consumers each receive every notification (fan-out)") + func fansOutToMultipleConsumers() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + + let consumer1 = Task { () -> SSDPNotification? in + for try await n in discovery.notifications() { return n } + return nil + } + let consumer2 = Task { () -> SSDPNotification? in + for try await n in discovery.notifications() { return n } + return nil + } + + try await waitForMulticastSubscriber(in: mock, count: 2) + await mock.deliverNotify(try fixture("notify-alive-roku")) + + let n1 = try await consumer1.value + let n2 = try await consumer2.value + #expect(n1 != nil) + #expect(n2 != nil) + #expect(n1 == n2) + } + + @Test("Multicast join failure is propagated as a thrown error") + func multicastFailurePropagates() async throws { + let mock = MockTransport() + await mock.setMulticastFailure(.multicastEntitlementMissing) + let discovery = SSDPDiscovery(transport: mock) + + do { + for try await _ in discovery.notifications() { + Issue.record("Should not yield") + } + Issue.record("Expected stream to throw") + } catch let error as SSDPError { + #expect(error == .multicastEntitlementMissing) + } + } +} + +// MARK: - Helpers + +/// Spin until predicate is true, with a short overall budget. Avoids fixed sleeps. +func waitFor(timeout: Duration = .seconds(2), + _ predicate: @Sendable () async -> Bool) async throws +{ + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if await predicate() { return } + try await Task.sleep(for: .milliseconds(5)) + } + Issue.record("waitFor timed out") +} + +func waitForSearchSubscriber(in mock: MockTransport, count: Int = 1) async throws { + try await waitFor { await mock.searchSubscriberCount >= count } +} + +func waitForMulticastSubscriber(in mock: MockTransport, count: Int = 1) async throws { + try await waitFor { await mock.multicastSubscriberCount >= count } +} diff --git a/Tests/SwiftSSDPTests/SSDPMSearchRequestTests.swift b/Tests/SwiftSSDPTests/SSDPMSearchRequestTests.swift new file mode 100644 index 0000000..67f60bb --- /dev/null +++ b/Tests/SwiftSSDPTests/SSDPMSearchRequestTests.swift @@ -0,0 +1,61 @@ +// +// SSDPMSearchRequestTests.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Testing +@testable import SwiftSSDP + +@Suite("SSDPMSearchRequest") +struct SSDPMSearchRequestTests { + + @Test("Default request serializes to deterministic wire format") + func defaultSerialization() { + let request = SSDPMSearchRequest(searchTarget: .rootDevice) + let expected = """ + M-SEARCH * HTTP/1.1\r + HOST: 239.255.255.250:1900\r + MAN: "ssdp:discover"\r + MX: 1\r + ST: upnp:rootdevice\r + \r + + """ + #expect(request.message == expected) + } + + @Test("MX value is honored") + func customMaxWait() { + let request = SSDPMSearchRequest(searchTarget: .all, maxWait: 5) + #expect(request.message.contains("MX: 5\r\n")) + } + + @Test("Custom non-standard headers are merged but cannot override standard ones") + func customHeaders() { + let request = SSDPMSearchRequest( + searchTarget: .rootDevice, + otherHeaders: [ + "X-Custom-Token": "abc123", + "MX": "999", // Should be ignored — standard header takes precedence + ] + ) + let msg = request.message + #expect(msg.contains("X-CUSTOM-TOKEN: abc123\r\n")) + #expect(msg.contains("MX: 1\r\n")) + #expect(!msg.contains("MX: 999")) + } + + @Test("Header order is alphabetical for testability") + func deterministicOrder() { + let request = SSDPMSearchRequest(searchTarget: .all) + let lines = request.message.components(separatedBy: "\r\n") + // First line is M-SEARCH, then HOST, MAN, MX, ST in alphabetical order. + #expect(lines[0] == "M-SEARCH * HTTP/1.1") + #expect(lines[1].hasPrefix("HOST: ")) + #expect(lines[2].hasPrefix("MAN: ")) + #expect(lines[3].hasPrefix("MX: ")) + #expect(lines[4].hasPrefix("ST: ")) + } +} diff --git a/Tests/SwiftSSDPTests/SSDPMessageParserTests.swift b/Tests/SwiftSSDPTests/SSDPMessageParserTests.swift new file mode 100644 index 0000000..66dea62 --- /dev/null +++ b/Tests/SwiftSSDPTests/SSDPMessageParserTests.swift @@ -0,0 +1,230 @@ +// +// SSDPMessageParserTests.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation +import Testing +@testable import SwiftSSDP + +@Suite("SSDPMessageParser — M-SEARCH responses") +struct SSDPMessageParserSearchResponseTests { + + @Test("Sonos M-SEARCH response parses all canonical fields") + func parsesSonosResponse() throws { + let raw = try fixture("msearch-response-sonos") + let message = try #require(SSDPMessageParser.parse(raw)) + guard case .searchResponse(let response) = message else { + Issue.record("Expected .searchResponse, got \(message)") + return + } + + #expect(response.location == URL(string: + "http://192.168.1.42:1400/xml/device_description.xml")) + #expect(response.usn == + "uuid:RINCON_000E58A1B2C300400::urn:schemas-upnp-org:device:ZonePlayer:1") + #expect(response.searchTarget == + .deviceType(schema: "schemas-upnp-org", deviceType: "ZonePlayer", version: 1)) + #expect(response.server == "Linux UPnP/1.0 Sonos/76.1-37220 (ZP120)") + #expect(response.ext == true) + // CACHE-CONTROL bug regression: pre-v2.0 multiplied by 1000. + // Sonos sends "max-age = 1800"; we must surface 1800 seconds, not 1_800_000. + #expect(response.cacheControl == 1800) + // DATE bug regression: pre-v2.0 used DateFormatter() with no format and always returned nil. + // RFC 1123: "Sun, 06 Nov 1994 08:49:37 GMT" + let expectedDate = DateComponents( + calendar: Calendar(identifier: .gregorian), + timeZone: TimeZone(identifier: "GMT"), + year: 1994, month: 11, day: 6, + hour: 8, minute: 49, second: 37 + ).date + #expect(response.date == expectedDate) + // Vendor headers preserved in otherHeaders. + #expect(response.otherHeaders["X-RINCON-HOUSEHOLD"] == "Sonos_household_1") + #expect(response.otherHeaders["X-RINCON-BOOTSEQ"] == "23") + } + + @Test("Hue bridge M-SEARCH response parses without EXT (lenient mode)") + func parsesHueWithoutExt() throws { + let raw = try fixture("msearch-response-hue") + let message = try #require(SSDPMessageParser.parse(raw)) + guard case .searchResponse(let response) = message else { + Issue.record("Expected .searchResponse, got \(message)") + return + } + #expect(response.searchTarget == .rootDevice) + #expect(response.cacheControl == 100) + #expect(response.ext == false) // Hue omits EXT — must not reject + #expect(response.otherHeaders["hue-bridgeid"] == "001788FFFE112233") + } + + @Test("Malformed response missing EXT still parses") + func parsesMalformedMissingExt() throws { + let raw = try fixture("malformed-missing-ext") + let message = try #require(SSDPMessageParser.parse(raw)) + guard case .searchResponse(let response) = message else { + Issue.record("Expected .searchResponse") + return + } + #expect(response.ext == false) + } + + @Test("Response missing required headers returns nil") + func rejectsMissingRequired() { + let noLocation = """ + HTTP/1.1 200 OK\r + ST: upnp:rootdevice\r + USN: uuid:foo\r + \r + + """ + #expect(SSDPMessageParser.parse(noLocation) == nil) + + let noUSN = """ + HTTP/1.1 200 OK\r + LOCATION: http://example.com/desc.xml\r + ST: upnp:rootdevice\r + \r + + """ + #expect(SSDPMessageParser.parse(noUSN) == nil) + } + + @Test("Empty input returns nil") + func rejectsEmpty() { + #expect(SSDPMessageParser.parse("") == nil) + } + + // MARK: - Cache-Control directive parsing + + @Test("CACHE-CONTROL accepts whitespace and multiple directives") + func cacheControlMultipleDirectives() throws { + // "max-age = 1800, no-cache" — Sonos sends spaces around the =. + let raw = """ + HTTP/1.1 200 OK\r + CACHE-CONTROL: max-age = 1800 , no-cache\r + EXT:\r + LOCATION: http://example.com/d.xml\r + ST: upnp:rootdevice\r + USN: uuid:test\r + \r + + """ + guard case .searchResponse(let response) = try #require(SSDPMessageParser.parse(raw)) else { + Issue.record("Expected response") + return + } + #expect(response.cacheControl == 1800) + } +} + +@Suite("SSDPMessageParser — NOTIFY") +struct SSDPMessageParserNotifyTests { + + @Test("Roku NOTIFY alive parses with UPnP 1.1 BOOTID/CONFIGID") + func parsesRokuAlive() throws { + let raw = try fixture("notify-alive-roku") + let message = try #require(SSDPMessageParser.parse(raw)) + guard case .notify(let n) = message else { + Issue.record("Expected .notify, got \(message)") + return + } + guard case .alive(let ad) = n else { + Issue.record("Expected .alive, got \(n)") + return + } + #expect(ad.location == URL(string: "http://192.168.1.77:8060/dial/dd.xml")) + #expect(ad.usn == + "uuid:roku:ecp:YR0070123456::urn:dial-multiscreen-org:device:dial:1") + #expect(ad.cacheControl == 1800) + #expect(ad.bootID == 7) + #expect(ad.configID == 1) + #expect(ad.nextBootID == nil) + } + + @Test("byebye parses without LOCATION (LOCATION optional for byebye)") + func parsesByebye() throws { + let raw = try fixture("notify-byebye") + let message = try #require(SSDPMessageParser.parse(raw)) + guard case .notify(let n) = message else { + Issue.record("Expected .notify, got \(message)") + return + } + guard case .byebye(let ad) = n else { + Issue.record("Expected .byebye, got \(n)") + return + } + #expect(ad.location == nil) + #expect(ad.notificationTarget == .mediaServer) + } + + @Test("ssdp:update parses with NEXTBOOTID") + func parsesUpdate() throws { + let raw = try fixture("notify-update") + let message = try #require(SSDPMessageParser.parse(raw)) + guard case .notify(.update(let ad)) = message else { + Issue.record("Expected .update, got \(message)") + return + } + #expect(ad.bootID == 23) + #expect(ad.nextBootID == 24) + } + + @Test("alive missing LOCATION is rejected") + func aliveMissingLocationRejected() { + let raw = """ + NOTIFY * HTTP/1.1\r + HOST: 239.255.255.250:1900\r + NT: upnp:rootdevice\r + NTS: ssdp:alive\r + USN: uuid:test\r + \r + + """ + #expect(SSDPMessageParser.parse(raw) == nil) + } + + @Test("Unknown NTS value is rejected") + func unknownNTSRejected() { + let raw = """ + NOTIFY * HTTP/1.1\r + HOST: 239.255.255.250:1900\r + NT: upnp:rootdevice\r + NTS: ssdp:nonsense\r + USN: uuid:test\r + \r + + """ + #expect(SSDPMessageParser.parse(raw) == nil) + } +} + +@Suite("SSDPMessageParser — request line") +struct SSDPMessageParserRequestLineTests { + @Test("M-SEARCH request returns .searchRequest case") + func recognizesMSearch() { + let raw = """ + M-SEARCH * HTTP/1.1\r + HOST: 239.255.255.250:1900\r + MAN: "ssdp:discover"\r + MX: 3\r + ST: ssdp:all\r + \r + + """ + let message = SSDPMessageParser.parse(raw) + if case .searchRequest = message {} else { + Issue.record("Expected .searchRequest, got \(String(describing: message))") + } + } +} + +// MARK: - Test helpers + +func fixture(_ name: String) throws -> String { + let url = try #require(Bundle.module.url(forResource: name, withExtension: "txt", + subdirectory: "Fixtures")) + return try String(contentsOf: url, encoding: .utf8) +} diff --git a/Tests/SwiftSSDPTests/SSDPSearchTargetTests.swift b/Tests/SwiftSSDPTests/SSDPSearchTargetTests.swift new file mode 100644 index 0000000..79306cf --- /dev/null +++ b/Tests/SwiftSSDPTests/SSDPSearchTargetTests.swift @@ -0,0 +1,72 @@ +// +// SSDPSearchTargetTests.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Testing +@testable import SwiftSSDP + +@Suite("SSDPSearchTarget") +struct SSDPSearchTargetTests { + + @Test("rawValue serializes each case to canonical wire form") + func rawValueSerialization() { + #expect(SSDPSearchTarget.all.rawValue == "ssdp:all") + #expect(SSDPSearchTarget.rootDevice.rawValue == "upnp:rootdevice") + #expect(SSDPSearchTarget.uuid("550e8400-e29b-41d4-a716").rawValue + == "uuid:550e8400-e29b-41d4-a716") + #expect(SSDPSearchTarget.deviceType(schema: "schemas-upnp-org", + deviceType: "MediaServer", + version: 1).rawValue + == "urn:schemas-upnp-org:device:MediaServer:1") + #expect(SSDPSearchTarget.serviceType(schema: "schemas-upnp-org", + serviceType: "AVTransport", + version: 2).rawValue + == "urn:schemas-upnp-org:service:AVTransport:2") + } + + @Test("init?(rawValue:) round-trips canonical forms", + arguments: [ + SSDPSearchTarget.all, + .rootDevice, + .uuid("550e8400-e29b-41d4-a716"), + .deviceType(schema: "schemas-upnp-org", deviceType: "ZonePlayer", version: 1), + .serviceType(schema: "schemas-upnp-org", serviceType: "ContentDirectory", version: 1), + ]) + func roundTrip(target: SSDPSearchTarget) { + let raw = target.rawValue + let parsed = SSDPSearchTarget(rawValue: raw) + #expect(parsed == target) + } + + @Test("init?(rawValue:) rejects malformed inputs") + func rejectsMalformed() { + #expect(SSDPSearchTarget(rawValue: "") == nil) + #expect(SSDPSearchTarget(rawValue: "garbage") == nil) + #expect(SSDPSearchTarget(rawValue: "ssdp:nope") == nil) + // version not an integer + #expect(SSDPSearchTarget(rawValue: "urn:schemas-upnp-org:device:Foo:notanumber") == nil) + // wrong middle token + #expect(SSDPSearchTarget(rawValue: "urn:schemas-upnp-org:gizmo:Foo:1") == nil) + } + + @Test("UPnP convenience constants resolve to expected wire forms") + func upnpConvenience() { + #expect(SSDPSearchTarget.mediaServer.rawValue + == "urn:schemas-upnp-org:device:MediaServer:1") + #expect(SSDPSearchTarget.avTransportService.rawValue + == "urn:schemas-upnp-org:service:AVTransport:1") + } + + @Test("Hashable groups equivalent values") + func hashableEquivalence() { + let a: SSDPSearchTarget = .deviceType(schema: "x", deviceType: "y", version: 1) + let b: SSDPSearchTarget = .deviceType(schema: "x", deviceType: "y", version: 1) + var set: Set = [] + set.insert(a) + set.insert(b) + #expect(set.count == 1) + } +} From 1535447a60f46a133abea38ccdfd614729ef6cf2 Mon Sep 17 00:00:00 2001 From: Paul Bates Date: Fri, 8 May 2026 21:56:09 -0700 Subject: [PATCH 2/4] Add CI / Release workflows + awake-style badges + version constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI workflow (.github/workflows/ci.yml) -------------------------------------- Triggers on push to master, pull requests, and manual dispatch. Three parallel jobs on macos-15: * test-macos: swift build, swift test (30 tests), and verify the Examples/ssdp-demo separate package still builds. * build-ios: xcodebuild for iOS Simulator (build-only — confirms the library compiles for iOS without running tests there). * build-tvos: same as build-ios for tvOS. iOS/tvOS build-only checks catch platform-specific compile errors (e.g. an availability annotation that's wrong on tvOS) without paying for a full test run on a simulator that adds nothing useful — Network .framework behavior is identical across the platforms. Release workflow (.github/workflows/release.yml) ------------------------------------------------ Manual dispatch only. Takes a SemVer version (e.g. 2.0.0 or 2.0.0-beta.1) and optional release notes. Steps in order: 1. Validate version format (SemVer regex). 2. Verify the resulting v tag doesn't already exist locally or on origin — fail fast. 3. Build + test on macos-15. 4. Rewrite the version literal in Sources/SwiftSSDP/SwiftSSDP.swift via sed, with a verification grep to ensure the rewrite landed. 5. Re-build to catch compile errors from the bump. 6. Commit the bump back to the dispatched branch. 7. Tag and push v. 8. Create the GitHub release (with provided notes or auto-generated). Library releases don't ship binaries — SPM consumers pull source via the git tag, so the release page is purely a tag + notes destination. Sources/SwiftSSDP/SwiftSSDP.swift --------------------------------- New top-level namespace exposing SwiftSSDP.version as a public static String. Lets host apps log "SwiftSSDP \(SwiftSSDP.version)" and gives the release workflow a concrete file to rewrite. Documented as release-workflow-managed; not for hand-edit. README badges ------------- Replaced the basic shields with the awake-style for-the-badge variants: * Swift 5.9 (with swift logo) * Platforms iOS | macOS | tvOS (with apple logo) * CI status (live from this repo's ci.yml workflow) * Release version (live from GitHub Releases) * License: MIT * Maintained?: yes (links to commit activity graph) Also fixed the install URL from pryomoax/SwiftSSDP to the actual happycodelucky/SwiftSSDP origin. The MIGRATION.md reference to pryomoax/SwiftSSDP is intentionally preserved — it points at the old 0.5.x release for users who can't migrate. Verified with actionlint (0 issues) and a full swift test run. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 78 +++++++++++++++++++ .github/workflows/release.yml | 121 ++++++++++++++++++++++++++++++ README.md | 12 +-- Sources/SwiftSSDP/SwiftSSDP.swift | 21 ++++++ 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 Sources/SwiftSSDP/SwiftSSDP.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9bab63d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + workflow_dispatch: + +jobs: + test-macos: + name: Test on macOS + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Show toolchain + run: | + xcodebuild -version + swift --version + + - name: Build + run: swift build + + - name: Test + run: swift test + + - name: Build CLI demo + run: swift build --package-path Examples/ssdp-demo + + build-ios: + name: Build for iOS + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Show toolchain + run: | + xcodebuild -version + swift --version + + - name: Build for iOS Simulator + run: | + xcodebuild build \ + -scheme SwiftSSDP \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + | xcbeautify --renderer github-actions + shell: bash + + build-tvos: + name: Build for tvOS + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Build for tvOS Simulator + run: | + xcodebuild build \ + -scheme SwiftSSDP \ + -destination 'generic/platform=tvOS Simulator' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + | xcbeautify --renderer github-actions + shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..20c7067 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,121 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the v prefix (e.g. 2.0.0) + required: true + type: string + notes: + description: Release notes for GitHub Releases (optional — auto-generated if blank) + required: false + type: string + +permissions: + contents: write + +jobs: + release: + runs-on: macos-15 + env: + DEVELOPER_DIR: /Applications/Xcode.app/Contents/Developer + + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate version format + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.-]+)?$ ]]; then + echo "::error::Version must be SemVer (e.g. 2.0.0 or 2.0.0-beta.1). Got: $INPUT_VERSION" + exit 1 + fi + echo "TAG=v${INPUT_VERSION}" >> "$GITHUB_ENV" + + - name: Verify tag does not already exist + env: + TAG: ${{ env.TAG }} + run: | + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists locally." + exit 1 + fi + if git ls-remote --tags origin "$TAG" | grep -q "refs/tags/$TAG"; then + echo "::error::Tag $TAG already exists on origin." + exit 1 + fi + + - name: Show toolchain + run: | + xcodebuild -version + swift --version + + - name: Build + run: swift build + + - name: Test + run: swift test + + - name: Update SwiftSSDP.version constant + env: + INPUT_VERSION: ${{ inputs.version }} + run: | + # Rewrite the version literal in Sources/SwiftSSDP/SwiftSSDP.swift. + # The constant is `public static let version = "X.Y.Z"`; we replace the + # quoted literal in place. Using a delimiter that won't appear in version + # strings keeps sed simple. + sed -i.bak -E \ + "s/public static let version = \"[^\"]+\"/public static let version = \"${INPUT_VERSION}\"/" \ + Sources/SwiftSSDP/SwiftSSDP.swift + rm -f Sources/SwiftSSDP/SwiftSSDP.swift.bak + + # Verify the change actually landed. + if ! grep -q "public static let version = \"${INPUT_VERSION}\"" Sources/SwiftSSDP/SwiftSSDP.swift; then + echo "::error::Failed to update version constant." + cat Sources/SwiftSSDP/SwiftSSDP.swift + exit 1 + fi + + - name: Re-build with updated version + run: swift build + + - name: Commit version bump + env: + TAG: ${{ env.TAG }} + INPUT_VERSION: ${{ inputs.version }} + run: | + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + if git diff --quiet Sources/SwiftSSDP/SwiftSSDP.swift; then + echo "Version constant already at ${INPUT_VERSION} — nothing to commit." + else + git add Sources/SwiftSSDP/SwiftSSDP.swift + git commit -m "Bump version to ${INPUT_VERSION}" + BRANCH="${GITHUB_REF#refs/heads/}" + git push origin "HEAD:${BRANCH}" + fi + + - name: Create and push tag + env: + TAG: ${{ env.TAG }} + run: | + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + + - name: Publish GitHub release + env: + GITHUB_TOKEN: ${{ github.token }} + TAG: ${{ env.TAG }} + NOTES: ${{ inputs.notes }} + run: | + if [[ -n "$NOTES" ]]; then + gh release create "$TAG" --title "$TAG" --notes "$NOTES" + else + gh release create "$TAG" --title "$TAG" --generate-notes + fi diff --git a/README.md b/README.md index 68fba11..2fae354 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,11 @@ # SwiftSSDP -![Swift 5.9](https://img.shields.io/badge/swift-5.9-orange.svg) -![Platforms](https://img.shields.io/badge/platforms-iOS%2017%20%7C%20macOS%2014%20%7C%20tvOS%2017-blue.svg) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -![Version](https://img.shields.io/badge/version-2.0.0-brightgreen.svg) +![Swift 5.9](https://img.shields.io/badge/swift-5.9-orange.svg?style=for-the-badge&logo=swift) +![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20tvOS-blue.svg?style=for-the-badge&logo=apple) +[![CI](https://img.shields.io/github/actions/workflow/status/happycodelucky/SwiftSSDP/ci.yml?style=for-the-badge&label=ci)](https://github.com/happycodelucky/SwiftSSDP/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/happycodelucky/SwiftSSDP?style=for-the-badge)](https://github.com/happycodelucky/SwiftSSDP/releases/latest) +[![License: MIT](https://img.shields.io/badge/license-MIT-orange.svg?style=for-the-badge)](LICENSE) +[![Maintained](https://img.shields.io/badge/Maintained%3F-yes-green.svg?style=for-the-badge)](https://github.com/happycodelucky/SwiftSSDP/graphs/commit-activity) A modern Swift package for [Simple Service Discovery Protocol](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol) (SSDP) — the discovery layer of UPnP. SwiftSSDP supports both: @@ -19,7 +21,7 @@ The whole API is async/await — no delegates, no Combine, no callbacks. The onl Add SwiftSSDP via Swift Package Manager: ```swift -.package(url: "https://github.com/pryomoax/SwiftSSDP.git", from: "2.0.0") +.package(url: "https://github.com/happycodelucky/SwiftSSDP.git", from: "2.0.0") ``` Then add `"SwiftSSDP"` to the dependencies of any target that needs it. SwiftSSDP is SPM-only — no Carthage, no CocoaPods. diff --git a/Sources/SwiftSSDP/SwiftSSDP.swift b/Sources/SwiftSSDP/SwiftSSDP.swift new file mode 100644 index 0000000..e0710af --- /dev/null +++ b/Sources/SwiftSSDP/SwiftSSDP.swift @@ -0,0 +1,21 @@ +// +// SwiftSSDP.swift +// SwiftSSDP +// +// Copyright © 2017-2026 Paul Bates. All rights reserved. +// + +import Foundation + +/// Top-level metadata for the SwiftSSDP library. +/// +/// This namespace exposes runtime-introspectable information about the library version. +/// Useful for diagnostic logging in host apps, and updated automatically by the release +/// workflow when a new version is tagged. +public enum SwiftSSDP { + /// The library's semantic version, matching the most recent published git tag. + /// + /// > Note: This constant is rewritten by `.github/workflows/release.yml` whenever a + /// > release is published. Do not edit it by hand outside of that workflow. + public static let version = "2.0.0" +} From ef08b9cb8c329e38ae393327d7d205ff7130e8d3 Mon Sep 17 00:00:00 2001 From: Paul Bates Date: Fri, 8 May 2026 22:14:52 -0700 Subject: [PATCH 3/4] Add discovery.firstDevice(for:timeout:) + document cancellation patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `firstDevice` convenience method on `SSDPDiscovery` that returns the first matching M-SEARCH response and tears down the underlying search promptly — sockets, retransmits, and timers all stop the moment the response arrives, without waiting for the timeout. Why a method on SSDPDiscovery and not an extension on the stream ---------------------------------------------------------------- The natural shape — `discovery.search(...).first()` as an `AsyncThrowingStream` extension — sounds appealing but doesn't actually tear down the underlying socket promptly. AsyncThrowingStream's `onTermination` callback only fires on explicit `finish()`, `cancel()`, or full-storage deallocation. Returning out of `for try await { return x }` drops the iterator but keeps the underlying stream alive (the search supervisor task still holds the continuation), so neither signal fires. The fix is to give the iteration a Task we can cancel. `firstDevice` wraps the iteration in a `withThrowingTaskGroup`, calls `cancelAll()` when the first response is received, and *that* cancellation flows through the search supervisor's `Task.isCancelled` checkpoint to release the socket subscription. In addition: document cancellation patterns in README ----------------------------------------------------- Three idiomatic ways to stop a search are now covered with examples: 1. break out of the for-await loop 2. cancel the parent Task 3. use the timeout: parameter All three run the same teardown via the TaskBox + onTermination wiring. Internal: yield-result termination probe ---------------------------------------- The search and notifications supervisors now hold an explicit AsyncIterator (rather than `for try await ... in datagrams`) so they can check both `Task.isCancelled` and `continuation.yield(...)`'s `.terminated` result on every iteration. This catches the timeout path — when the timeoutTask calls `continuation.finish()`, the next yield reports `.terminated` and the supervisor exits without blocking on the transport stream's next datagram. Tests ----- Two new tests verify firstDevice's behavior: * Returns the first response and the underlying subscription is torn down within the wait budget. * Returns nil cleanly when the timeout elapses with no response. All 32 tests pass. Co-Authored-By: Claude Opus 4.7 --- README.md | 60 +++++++++++++++ Sources/SwiftSSDP/SSDPDiscovery.swift | 76 +++++++++++++++++-- Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift | 32 ++++++++ 3 files changed, 163 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2fae354..55a9254 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,66 @@ let devices = try await discovery print("Found \(devices.count) media servers") ``` +Or stop at the first response — handy for "find any device of type X, then move on": + +```swift +if let device = try await discovery.firstDevice(for: .mediaServer, timeout: 10) { + print("First media server: \(device.usn)") +} +// firstDevice() returns nil if the timeout elapses before any response arrives. +``` + +`firstDevice()` cancels the underlying search the moment the response arrives — sockets, retransmits, and timers all tear down promptly without waiting for the timeout. + +### Cancelling a search + +Cancellation in Swift Concurrency is cooperative — the library's stream finishes cleanly whenever the consumer signals it's done, and the underlying socket and retransmit task tear down automatically. There are three idiomatic ways: + +**1. Break out of the loop** — when you're iterating and decide you've seen enough: + +```swift +for try await response in discovery.search(for: .rootDevice) { + if response.server?.contains("Sonos") == true { + handle(response) + break // ends the search, tears down sockets + } +} +``` + +**2. Cancel the parent `Task`** — when the search is running in a Task and an external trigger (button tap, view disappearing, timeout) needs to stop it: + +```swift +final class DeviceFinder { + private var searchTask: Task? + + func startSearching() { + searchTask = Task { + for try await response in discovery.search(for: .rootDevice) { + await handle(response) + } + } + } + + func stopSearching() { + searchTask?.cancel() // Task.isCancelled becomes true; loop exits cleanly + searchTask = nil + } +} +``` + +**3. Use a `timeout` parameter** — simplest of all when you know how long to wait: + +```swift +// Stream finishes cleanly after 10 seconds, regardless of whether anything was found. +let devices = try await discovery + .search(for: .rootDevice, timeout: 10) + .collect() +``` + +All three paths run the same teardown: the retransmit task is cancelled, the socket subscriber is removed, and (if no other consumers remain) the underlying multicast socket closes. + +### Custom requests + For finer control (custom headers, longer `MX`), pass an explicit `SSDPMSearchRequest`: ```swift diff --git a/Sources/SwiftSSDP/SSDPDiscovery.swift b/Sources/SwiftSSDP/SSDPDiscovery.swift index 789dbbd..147fefe 100644 --- a/Sources/SwiftSSDP/SSDPDiscovery.swift +++ b/Sources/SwiftSSDP/SSDPDiscovery.swift @@ -121,8 +121,13 @@ public actor SSDPDiscovery { await children.set(retransmitter: retransmitter, timeoutTask: timeoutTask) - // Drain datagrams, parse, filter, yield. - for try await datagram in datagrams { + // Drain datagrams, parse, filter, yield. We explicitly hold the + // transport's iterator so we can break out (and let it deinit) the + // moment yielding to the consumer signals the consumer is done. + var iterator = datagrams.makeAsyncIterator() + while let datagram = try await iterator.next() { + if Task.isCancelled { break } + guard let raw = String(data: datagram.data, encoding: .utf8), let message = SSDPMessageParser.parse(raw) else { @@ -138,7 +143,13 @@ public actor SSDPDiscovery { if request.searchTarget != .all && response.searchTarget != request.searchTarget { continue } - continuation.yield(response) + // YieldResult tells us whether the consumer's stream is still + // alive. If they've broken out (via .first(), an explicit break, + // or cancellation), terminated comes back and we exit immediately + // — otherwise we'd block in `iterator.next()` forever waiting on + // the transport stream that the consumer no longer cares about. + let result = continuation.yield(response) + if case .terminated = result { break } } retransmitter.cancel() @@ -154,6 +165,58 @@ public actor SSDPDiscovery { } } + // MARK: - Convenience search forms + + /// Search and return the first matching response, or `nil` if `timeout` elapses with + /// no response. + /// + /// Wraps a search inside a child Task so that when the first response arrives we + /// cancel that task — and the cancellation propagates through the search's + /// `Task.isCancelled` plumbing all the way down to releasing the underlying socket + /// subscription. Useful when you only need to confirm presence or discover one device. + /// + /// ```swift + /// if let device = try await discovery.firstDevice(for: .mediaServer, timeout: 10) { + /// print("Found \(device.usn) at \(device.location)") + /// } + /// ``` + public nonisolated func firstDevice( + for target: SSDPSearchTarget, + maxWait: Int = 1, + timeout: TimeInterval? = nil + ) async throws -> SSDPMSearchResponse? { + let request = SSDPMSearchRequest(searchTarget: target, maxWait: maxWait) + return try await firstDevice(for: request, timeout: timeout) + } + + /// Search with an explicit ``SSDPMSearchRequest`` and return the first matching + /// response, or `nil` if `timeout` elapses with no response. + public nonisolated func firstDevice( + for request: SSDPMSearchRequest, + timeout: TimeInterval? = nil + ) async throws -> SSDPMSearchResponse? { + // We deliberately route iteration through a TaskGroup so we have something + // *cancellable* when we're ready to stop. Returning out of a `for try await` does + // NOT fire AsyncThrowingStream.onTermination (storage stays alive while the + // supervisor holds the continuation), but cancelling the inner Task does — that + // cancellation flows through the supervisor's `Task.isCancelled` checkpoint and + // tears down the socket promptly. + try await withThrowingTaskGroup(of: SSDPMSearchResponse?.self) { group in + let stream = self.search(request, timeout: timeout) + group.addTask { + for try await response in stream { + return response + } + return nil + } + // Wait for the first task result, then cancel the group so the underlying + // search tears down even if we returned before the timeout elapsed. + let result = try await group.next() ?? nil + group.cancelAll() + return result + } + } + // MARK: - NOTIFY /// Subscribe to unsolicited SSDP NOTIFY broadcasts. @@ -175,13 +238,16 @@ public actor SSDPDiscovery { let supervisor = Task { do { let datagrams = try await transport.multicastDatagrams() - for try await datagram in datagrams { + var iterator = datagrams.makeAsyncIterator() + while let datagram = try await iterator.next() { + if Task.isCancelled { break } guard let raw = String(data: datagram.data, encoding: .utf8) else { continue } guard case .notify(let n) = SSDPMessageParser.parse(raw) else { // Ignore non-NOTIFY traffic on the multicast stream. continue } - continuation.yield(n) + let result = continuation.yield(n) + if case .terminated = result { break } } continuation.finish() } catch is CancellationError { diff --git a/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift b/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift index f76d084..8734f0e 100644 --- a/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift +++ b/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift @@ -48,6 +48,38 @@ struct SSDPDiscoverySearchTests { #expect(collected.count == 2) } + @Test("firstDevice() returns the first response and tears down the search") + func firstDeviceReturnsFirstResponse() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + + let task = Task { + try await discovery.firstDevice(for: .all, timeout: 5) + } + try await waitForSearchSubscriber(in: mock) + + await mock.deliverSearchReply(try fixture("msearch-response-hue")) + // Even if a second response is delivered, firstDevice() only returns the first. + await mock.deliverSearchReply(try fixture("msearch-response-sonos")) + + let response = try await task.value + #expect(response != nil) + #expect(response?.location == + URL(string: "http://192.168.1.55:80/description.xml")) + + // After firstDevice() returns, the underlying subscription should tear down. + try await waitFor { await mock.searchSubscriberCount == 0 } + #expect(await mock.searchSubscriberCount == 0) + } + + @Test("firstDevice() returns nil when the stream times out without a response") + func firstDeviceReturnsNilOnTimeout() async throws { + let mock = MockTransport() + let discovery = SSDPDiscovery(transport: mock) + let response = try await discovery.firstDevice(for: .rootDevice, timeout: 0.2) + #expect(response == nil) + } + @Test(".collect() deduplicates by (usn, location)") func collectDeduplicates() async throws { let mock = MockTransport() From 6c9be1a6a425e277d330e349e013abfddcfe5732 Mon Sep 17 00:00:00 2001 From: Paul Bates Date: Sat, 9 May 2026 08:23:11 -0700 Subject: [PATCH 4/4] v2.1.0: Swift 6 strict concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the package to swift-tools-version 6.0 and enables Swift 6 language mode (strict concurrency checking) on all targets via swiftLanguageMode(.v6) in swiftSettings. The library compiles clean under both default Swift 6 mode and the maximally-strict `-strict-concurrency=complete -warnings-as-errors` flag combination — no source changes required. The v2.0 rewrite was already concurrency-correct by design: * All public value types declared Sendable explicitly * Mutable state lives behind actors (MulticastListener, TaskBox) * Cross-actor communication uses `Task { ... }` indirection * No mutable globals — os.Logger instances are Sendable, SSDPLog is an enum-namespace * Network.framework types (NWConnectionGroup, NWConnection, NWParameters) are Sendable in iOS 17+ SDKs (our floor) * `@Sendable` annotations on the closures handed to onTermination Turning on enforcement validates the design. Verification ------------ * swift build -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors — clean * swift test — 32/32 pass * Live LAN: ssdp-demo search ssdp:all --timeout 3 — 93 results including Sonos ZPS45, no regressions * swift build --package-path Examples/ssdp-demo — clean Requirements ------------ Now requires Swift 6.0+ / Xcode 16+. Library platforms unchanged (iOS 17, macOS 14, tvOS 17). README's "Requirements" section updated. The Swift badge bumped from 5.9 to 6. Version constant bumped to 2.1.0 (minor — strict concurrency is a build-time check, not a public API change; existing call sites still compile unchanged). Co-Authored-By: Claude Opus 4.7 --- Package.swift | 10 ++++++++-- README.md | 8 +++++--- Sources/SwiftSSDP/SwiftSSDP.swift | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Package.swift b/Package.swift index f48d6db..ac29088 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 // // Package.swift // SwiftSSDP @@ -24,7 +24,10 @@ let package = Package( targets: [ .target( name: "SwiftSSDP", - path: "Sources/SwiftSSDP" + path: "Sources/SwiftSSDP", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] ), .testTarget( name: "SwiftSSDPTests", @@ -32,6 +35,9 @@ let package = Package( path: "Tests/SwiftSSDPTests", resources: [ .copy("Fixtures"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), ] ), ] diff --git a/README.md b/README.md index 55a9254..4cd17e8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SwiftSSDP -![Swift 5.9](https://img.shields.io/badge/swift-5.9-orange.svg?style=for-the-badge&logo=swift) +![Swift 6](https://img.shields.io/badge/swift-6-orange.svg?style=for-the-badge&logo=swift) ![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20tvOS-blue.svg?style=for-the-badge&logo=apple) [![CI](https://img.shields.io/github/actions/workflow/status/happycodelucky/SwiftSSDP/ci.yml?style=for-the-badge&label=ci)](https://github.com/happycodelucky/SwiftSSDP/actions/workflows/ci.yml) [![Release](https://img.shields.io/github/v/release/happycodelucky/SwiftSSDP?style=for-the-badge)](https://github.com/happycodelucky/SwiftSSDP/releases/latest) @@ -213,10 +213,12 @@ Categories: `discovery`, `transport`, `listener`, `parser`. ## Requirements -- **Swift:** 5.9+ -- **Xcode:** 15+ +- **Swift:** 6.0+ (Swift 6 language mode with strict concurrency checking) +- **Xcode:** 16+ - **Platforms:** iOS 17, macOS 14, tvOS 17 (watchOS not supported — multicast is unavailable on watchOS) +The library compiles cleanly under `-strict-concurrency=complete -warnings-as-errors`. Public API surface is fully `Sendable` so it composes naturally with actor-isolated callers. + ## License MIT — see [LICENSE](LICENSE). diff --git a/Sources/SwiftSSDP/SwiftSSDP.swift b/Sources/SwiftSSDP/SwiftSSDP.swift index e0710af..94b666b 100644 --- a/Sources/SwiftSSDP/SwiftSSDP.swift +++ b/Sources/SwiftSSDP/SwiftSSDP.swift @@ -17,5 +17,5 @@ public enum SwiftSSDP { /// /// > Note: This constant is rewritten by `.github/workflows/release.yml` whenever a /// > release is published. Do not edit it by hand outside of that workflow. - public static let version = "2.0.0" + public static let version = "2.1.0" }