v2.0.0: async/await rewrite with NOTIFY broadcast support#19
Merged
Conversation
Complete rewrite of the library, dropping every third-party dependency
in favor of Network.framework + os.Logger. The public API is async/await
end-to-end; the delegate protocol is gone.
Headline changes
----------------
* New: passive NOTIFY listening via discovery.notifications() — yields
AsyncThrowingStream<SSDPNotification> with .alive / .byebye / .update
cases. The 0.5.x library could only send M-SEARCH, never listen for
unsolicited device announcements; this is the feature gap that
motivated the rewrite.
* M-SEARCH discovery is now discovery.search(for:timeout:) returning
AsyncThrowingStream<SSDPMSearchResponse>. Plus a .collect() convenience
for the 80% case ("just give me a deduplicated array").
* Networking moved from CocoaAsyncSocket (GCDAsyncUdpSocket) to
Network.framework. A single shared NWConnectionGroup joined to
239.255.255.250:1900 handles both M-SEARCH sends AND receives all
unicast replies + multicast NOTIFY broadcasts. Per-search NWConnection
was tried and abandoned: connected UDP filters incoming datagrams by
expected peer, so unicast replies from device IPs were silently
dropped. NWConnectionGroup has no such filter.
* SSDPDiscovery is an actor (own mutable state: shared listener handle,
subscriber registry). Construction is cheap; defaultDiscovery
singleton removed.
Bug fixes carried in (regressions present since 2017)
-----------------------------------------------------
* SSDPMSearchResponse.cacheControl was 1000× too large because of a
stray `* 1000.0` in the parser. Type also changed from Date? (which
silently went stale) to TimeInterval? (raw max-age in seconds).
* DATE header parsing always returned nil because the DateFormatter
had no dateFormat set. Now uses RFC 1123 with en_US_POSIX locale.
* Lenient EXT handling — Hue bridges and some Roku firmware omit the
EXT header in M-SEARCH responses; the old parser rejected those.
New parser accepts them with ext: false.
Type renames (typo fixes)
-------------------------
* SSDPMSearchRequest.messsageHeader → .messageHeader (3 s's → 2)
* SSDPMessageAnnoucement → SSDPMessageAnnouncement (missing 'n')
Dependencies removed
--------------------
* CocoaAsyncSocket (GCD-based UDP wrapper)
* SwiftAbstractLogger (replaced with os.Logger, subsystem
com.pryomoax.SwiftSSDP)
* nvzqz/Weak (was used for [Weak<Session>] tracking; no longer needed
with structured concurrency)
Platform changes
----------------
* iOS 17, macOS 14, tvOS 17 — new floor (was iOS 10).
* watchOS dropped — the platform doesn't support arbitrary multicast
group joins, so promising NOTIFY support there was a lie.
* Swift 5.9 tools (was unspecified, effectively Swift 4 era).
Project layout
--------------
* SPM-only. Removed SwiftSSDP.xcodeproj, Cartfile, Cartfile.resolved,
Info.plist. Modern Xcode opens Package.swift natively.
* Sources moved from SwiftSSDP/ to Sources/SwiftSSDP/ (SPM convention).
* New Tests/SwiftSSDPTests/ with 30 Swift Testing tests covering
parser, search target round-trips, request serialization,
NOTIFY parsing, and end-to-end discovery via MockTransport.
* New Examples/ssdp-demo/ — separate SPM package, hand-rolled args,
three subcommands: search, listen, diag (network-level diagnostic
that bypasses SwiftSSDP for isolating "no results" issues).
iOS multicast entitlement
-------------------------
NOTIFY listening on iOS / iPadOS / tvOS requires the
com.apple.developer.networking.multicast entitlement, which Apple gates
behind a manual application form. Documented prominently in README and
MIGRATION. SSDPError.multicastEntitlementMissing is surfaced when the
join fails for that reason.
Documentation
-------------
* README.md rewritten with async examples and the entitlement banner.
* MIGRATION.md added — 13-section step-by-step from 0.5.x → 2.0.0.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI workflow (.github/workflows/ci.yml)
--------------------------------------
Triggers on push to master, pull requests, and manual dispatch. Three
parallel jobs on macos-15:
* test-macos: swift build, swift test (30 tests), and verify the
Examples/ssdp-demo separate package still builds.
* build-ios: xcodebuild for iOS Simulator (build-only — confirms the
library compiles for iOS without running tests there).
* build-tvos: same as build-ios for tvOS.
iOS/tvOS build-only checks catch platform-specific compile errors
(e.g. an availability annotation that's wrong on tvOS) without paying
for a full test run on a simulator that adds nothing useful — Network
.framework behavior is identical across the platforms.
Release workflow (.github/workflows/release.yml)
------------------------------------------------
Manual dispatch only. Takes a SemVer version (e.g. 2.0.0 or 2.0.0-beta.1)
and optional release notes. Steps in order:
1. Validate version format (SemVer regex).
2. Verify the resulting v<version> tag doesn't already exist locally
or on origin — fail fast.
3. Build + test on macos-15.
4. Rewrite the version literal in Sources/SwiftSSDP/SwiftSSDP.swift
via sed, with a verification grep to ensure the rewrite landed.
5. Re-build to catch compile errors from the bump.
6. Commit the bump back to the dispatched branch.
7. Tag and push v<version>.
8. Create the GitHub release (with provided notes or auto-generated).
Library releases don't ship binaries — SPM consumers pull source via
the git tag, so the release page is purely a tag + notes destination.
Sources/SwiftSSDP/SwiftSSDP.swift
---------------------------------
New top-level namespace exposing SwiftSSDP.version as a public static
String. Lets host apps log "SwiftSSDP \(SwiftSSDP.version)" and gives
the release workflow a concrete file to rewrite. Documented as
release-workflow-managed; not for hand-edit.
README badges
-------------
Replaced the basic shields with the awake-style for-the-badge variants:
* Swift 5.9 (with swift logo)
* Platforms iOS | macOS | tvOS (with apple logo)
* CI status (live from this repo's ci.yml workflow)
* Release version (live from GitHub Releases)
* License: MIT
* Maintained?: yes (links to commit activity graph)
Also fixed the install URL from pryomoax/SwiftSSDP to the actual
happycodelucky/SwiftSSDP origin. The MIGRATION.md reference to
pryomoax/SwiftSSDP is intentionally preserved — it points at the
old 0.5.x release for users who can't migrate.
Verified with actionlint (0 issues) and a full swift test run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a `firstDevice` convenience method on `SSDPDiscovery` that returns
the first matching M-SEARCH response and tears down the underlying
search promptly — sockets, retransmits, and timers all stop the moment
the response arrives, without waiting for the timeout.
Why a method on SSDPDiscovery and not an extension on the stream
----------------------------------------------------------------
The natural shape — `discovery.search(...).first()` as an
`AsyncThrowingStream` extension — sounds appealing but doesn't actually
tear down the underlying socket promptly. AsyncThrowingStream's
`onTermination` callback only fires on explicit `finish()`, `cancel()`,
or full-storage deallocation. Returning out of `for try await { return x }`
drops the iterator but keeps the underlying stream alive (the search
supervisor task still holds the continuation), so neither signal fires.
The fix is to give the iteration a Task we can cancel. `firstDevice`
wraps the iteration in a `withThrowingTaskGroup`, calls `cancelAll()`
when the first response is received, and *that* cancellation flows
through the search supervisor's `Task.isCancelled` checkpoint to release
the socket subscription.
In addition: document cancellation patterns in README
-----------------------------------------------------
Three idiomatic ways to stop a search are now covered with examples:
1. break out of the for-await loop
2. cancel the parent Task
3. use the timeout: parameter
All three run the same teardown via the TaskBox + onTermination wiring.
Internal: yield-result termination probe
----------------------------------------
The search and notifications supervisors now hold an explicit
AsyncIterator (rather than `for try await ... in datagrams`) so they
can check both `Task.isCancelled` and `continuation.yield(...)`'s
`.terminated` result on every iteration. This catches the timeout
path — when the timeoutTask calls `continuation.finish()`, the next
yield reports `.terminated` and the supervisor exits without blocking
on the transport stream's next datagram.
Tests
-----
Two new tests verify firstDevice's behavior:
* Returns the first response and the underlying subscription is torn
down within the wait budget.
* Returns nil cleanly when the timeout elapses with no response.
All 32 tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bumps the package to swift-tools-version 6.0 and enables Swift 6
language mode (strict concurrency checking) on all targets via
swiftLanguageMode(.v6) in swiftSettings.
The library compiles clean under both default Swift 6 mode and the
maximally-strict `-strict-concurrency=complete -warnings-as-errors`
flag combination — no source changes required. The v2.0 rewrite was
already concurrency-correct by design:
* All public value types declared Sendable explicitly
* Mutable state lives behind actors (MulticastListener, TaskBox)
* Cross-actor communication uses `Task { ... }` indirection
* No mutable globals — os.Logger instances are Sendable, SSDPLog is
an enum-namespace
* Network.framework types (NWConnectionGroup, NWConnection, NWParameters)
are Sendable in iOS 17+ SDKs (our floor)
* `@Sendable` annotations on the closures handed to onTermination
Turning on enforcement validates the design.
Verification
------------
* swift build -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors
— clean
* swift test — 32/32 pass
* Live LAN: ssdp-demo search ssdp:all --timeout 3 — 93 results
including Sonos ZPS45, no regressions
* swift build --package-path Examples/ssdp-demo — clean
Requirements
------------
Now requires Swift 6.0+ / Xcode 16+. Library platforms unchanged
(iOS 17, macOS 14, tvOS 17). README's "Requirements" section updated.
The Swift badge bumped from 5.9 to 6.
Version constant bumped to 2.1.0 (minor — strict concurrency is a
build-time check, not a public API change; existing call sites still
compile unchanged).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
v2.1.0: Swift 6 strict concurrency
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Complete rewrite of SwiftSSDP modernizing the API to async/await and adding the long-missing NOTIFY broadcast support. Tagged as v2.0.0 (skipping 1.x — see MIGRATION.md for the rationale).
Headline changes
discovery.notifications()returns anAsyncThrowingStream<SSDPNotification, Error>yielding.alive/.byebye/.updateevents. The 0.5.x library could only send M-SEARCH; this is the feature gap that motivated the rewrite.discovery.search(for:timeout:)returnsAsyncThrowingStream<SSDPMSearchResponse, Error>, plus a.collect()convenience for the "just give me a deduplicated array" case.Network.framework. A single sharedNWConnectionGroupjoined to239.255.255.250:1900handles both M-SEARCH sends and all UDP receives (multicast NOTIFY + unicast replies). Per-searchNWConnectionwas tried and abandoned — connected UDP filters by expected peer, dropping unicast replies from device IPs.CocoaAsyncSocket,SwiftAbstractLogger, andnvzqz/Weakin favor ofNetwork.framework+os.Logger.Bug fixes carried in (regressions present since 2017)
SSDPMSearchResponse.cacheControlwas 1000× too large because of a stray* 1000.0in the parser. Type also changed fromDate?(which silently went stale) toTimeInterval?(rawmax-agein seconds).DATEheader parsing always returnednilbecause theDateFormatterhad nodateFormatset. Now uses RFC 1123 withen_US_POSIXlocale.EXThandling — Hue bridges and some Roku firmware omitEXT; the old parser rejected those, the new parser accepts withext: false.Type renames (typo fixes)
SSDPMSearchRequest.messsageHeader→.messageHeader(3 s's → 2)SSDPMessageAnnoucement→SSDPMessageAnnouncement(missing 'n')Platform changes
CI / Release
.github/workflows/ci.yml— three parallel jobs on macos-15: test on macOS, build for iOS Simulator, build for tvOS Simulator. Validated withactionlint(0 issues)..github/workflows/release.yml— manual dispatch with SemVer version input. Validates the tag doesn't exist, builds + tests, rewritesSwiftSSDP.version, commits, tags, creates the GitHub release.iOS multicast entitlement
NOTIFY listening on iOS / iPadOS / tvOS requires the
com.apple.developer.networking.multicastentitlement, gated behind a manual application form. Documented prominently in README and MIGRATION.SSDPError.multicastEntitlementMissingis surfaced when the join fails for that reason.Testing
30 Swift Testing tests across 7 suites (parser, search target round-trips, request serialization, NOTIFY parsing, end-to-end discovery via
MockTransport). Real wire-format fixtures from Sonos, Hue, Roku.Live LAN verification
Tested on the author's network — found a Sonos ZPS45 (155 service responses) and confirmed live NOTIFY broadcasts. The
Examples/ssdp-demoCLI subcommandssearch,listen, anddiagare useful for anyone debugging SSDP issues in their own network.Breaking changes
This is a full breaking change — see MIGRATION.md for the 13-section migration guide from 0.5.x. Highlights:
SSDPDiscoveryDelegateprotocol →for try await … in discovery.search(...)SSDPDiscoverySession→AsyncThrowingStreamlifecycleSSDPDiscovery.defaultDiscoverysingleton →let discovery = SSDPDiscovery()SSDPMSearchRequest.messsageHeader→.messageHeader(typo fix)SSDPMessageAnnoucement→SSDPMessageAnnouncement(typo fix)cacheControlisTimeInterval?(seconds), notDate?🤖 Generated with Claude Code