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/.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..ac29088 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,44 @@ +// swift-tools-version:6.0 +// +// 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", + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + .testTarget( + name: "SwiftSSDPTests", + dependencies: ["SwiftSSDP"], + path: "Tests/SwiftSSDPTests", + resources: [ + .copy("Fixtures"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), ] ) diff --git a/README.md b/README.md index fe71efc..4cd17e8 100644 --- a/README.md +++ b/README.md @@ -1,109 +1,228 @@ -# 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 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) +[![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) -> Update: Unfortunately I do not have time to maintain this package +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: -Simple Service Discovery Protocol ([SSDP](https://en.wikipedia.org/wiki/Simple_Service_Discovery_Protocol)) session based discovery package for Swift. +- **Active discovery** — sending M-SEARCH broadcasts and collecting responses. +- **Passive listening** — subscribing to unsolicited NOTIFY broadcasts (`alive`, `byebye`, `update`). -# Package Management +The whole API is async/await — no delegates, no Combine, no callbacks. The only dependency is Apple's `Network.framework`. + +> **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: +```swift +.package(url: "https://github.com/happycodelucky/SwiftSSDP.git", from: "2.0.0") ``` -.Package(url: "https://github.com/pryomoax/SwiftSSDP.git", majorVersion: 0, minor: 5) + +Then add `"SwiftSSDP"` to the dependencies of any target that needs it. SwiftSSDP is SPM-only — no Carthage, no CocoaPods. + +## Usage + +### Active discovery (M-SEARCH) + +```swift +import SwiftSSDP + +let discovery = SSDPDiscovery() + +// 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)") +} ``` -### Using Carthage -SwiftSSDP is available through [Carthage](https://github.com/Carthage/Carthage). To install it, add the following line to your `Cartfile`: +For convenience, collect everything into a deduplicated array: +```swift +let devices = try await discovery + .search(for: .mediaServer, timeout: 10) + .collect() +print("Found \(devices.count) media servers") ``` -# SwiftSSDP -github "pryomoax/SwiftSSDP.git" ~> 0.5 + +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. ``` -### Using CocoaPods +`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: -SwiftSSDP is currently not supported by CocoaPods (coming soon) +**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 + } +} +``` -# Usage +**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: -[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. +```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() +``` -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. +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. -[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. +### Custom requests -## 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 -// Attach a default (basic) console logger implementation to Logger -Logger.attach(BasicConsoleLogger.logger) +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: -// Enable debug logging only for SSDPSwift -Logger.configureLevel(category: loggerDiscoveryCategory, level: .Debug) +```swift +let zonePlayer: SSDPSearchTarget = .deviceType( + schema: .upnpOrgSchema, + deviceType: "ZonePlayer", + version: 1 +) ``` -# Package Information +## iOS multicast entitlement -## Requirements +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: -* Xcode 8 -* iOS 10.0+ +> -## Author +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 -Paul Bates, **[paul.a.bates@gmail.com](mailto:paul.a.bates@gmail.com)** +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 +``` + +## Logging + +SwiftSSDP logs through `os.Logger` under the subsystem `com.pryomoax.SwiftSSDP`. View live logs in Console.app (filter by subsystem) or via `log stream`: + +```sh +log stream --predicate 'subsystem == "com.pryomoax.SwiftSSDP"' --level debug +``` + +Categories: `discovery`, `transport`, `listener`, `parser`. + +## Requirements + +- **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 -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..147fefe --- /dev/null +++ b/Sources/SwiftSSDP/SSDPDiscovery.swift @@ -0,0 +1,315 @@ +// +// 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. 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 { + 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 + } + // 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() + timeoutTask?.cancel() + continuation.finish() + } catch is CancellationError { + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + Task { await children.set(supervisor: supervisor) } + } + } + + // 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. + /// + /// 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() + 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 + } + let result = continuation.yield(n) + if case .terminated = result { break } + } + 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/Sources/SwiftSSDP/SwiftSSDP.swift b/Sources/SwiftSSDP/SwiftSSDP.swift new file mode 100644 index 0000000..94b666b --- /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.1.0" +} 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..8734f0e --- /dev/null +++ b/Tests/SwiftSSDPTests/SSDPDiscoveryTests.swift @@ -0,0 +1,242 @@ +// +// 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("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() + 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) + } +}