Skip to content

v2.0.0: async/await rewrite with NOTIFY broadcast support#19

Merged
happycodelucky merged 5 commits into
masterfrom
v2.0-async-rewrite
May 9, 2026
Merged

v2.0.0: async/await rewrite with NOTIFY broadcast support#19
happycodelucky merged 5 commits into
masterfrom
v2.0-async-rewrite

Conversation

@happycodelucky

Copy link
Copy Markdown
Owner

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

  • New: passive NOTIFY listening. discovery.notifications() returns an AsyncThrowingStream<SSDPNotification, Error> yielding .alive / .byebye / .update events. The 0.5.x library could only send M-SEARCH; this is the feature gap that motivated the rewrite.
  • M-SEARCH is now async. discovery.search(for:timeout:) returns AsyncThrowingStream<SSDPMSearchResponse, Error>, plus a .collect() convenience for the "just give me a deduplicated array" case.
  • Networking on Network.framework. A single shared NWConnectionGroup joined to 239.255.255.250:1900 handles both M-SEARCH sends and all UDP receives (multicast NOTIFY + unicast replies). Per-search NWConnection was tried and abandoned — connected UDP filters by expected peer, dropping unicast replies from device IPs.
  • Zero third-party dependencies. Dropped CocoaAsyncSocket, SwiftAbstractLogger, and nvzqz/Weak in favor of Network.framework + os.Logger.

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 EXT; the old parser rejected those, the new parser accepts with ext: false.

Type renames (typo fixes)

  • SSDPMSearchRequest.messsageHeader.messageHeader (3 s's → 2)
  • SSDPMessageAnnoucementSSDPMessageAnnouncement (missing 'n')

Platform changes

  • iOS 17, macOS 14, tvOS 17 (was iOS 10).
  • watchOS dropped — the platform doesn't support arbitrary multicast group joins.
  • Swift 5.9 tools (was unspecified, effectively Swift 4 era).

CI / Release

  • .github/workflows/ci.yml — three parallel jobs on macos-15: test on macOS, build for iOS Simulator, build for tvOS Simulator. Validated with actionlint (0 issues).
  • .github/workflows/release.yml — manual dispatch with SemVer version input. Validates the tag doesn't exist, builds + tests, rewrites SwiftSSDP.version, commits, tags, creates the GitHub release.

iOS multicast entitlement

NOTIFY listening on iOS / iPadOS / tvOS requires the com.apple.developer.networking.multicast entitlement, gated behind a manual application form. Documented prominently in README and MIGRATION. SSDPError.multicastEntitlementMissing is 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-demo CLI subcommands search, listen, and diag are 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:

  • SSDPDiscoveryDelegate protocol → for try await … in discovery.search(...)
  • SSDPDiscoverySessionAsyncThrowingStream lifecycle
  • SSDPDiscovery.defaultDiscovery singleton → let discovery = SSDPDiscovery()
  • SSDPMSearchRequest.messsageHeader.messageHeader (typo fix)
  • SSDPMessageAnnoucementSSDPMessageAnnouncement (typo fix)
  • cacheControl is TimeInterval? (seconds), not Date?

🤖 Generated with Claude Code

happycodelucky and others added 4 commits May 8, 2026 21:50
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>
@happycodelucky happycodelucky merged commit ff83de5 into master May 9, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant