| name | shiny-discovery |
| description | Generate code using Shiny.Net.Discovery for cross-platform local network discovery - mDNS/DNS-SD (Bonjour/Zeroconf), SSDP/UPnP, and WS-Discovery (ONVIF) browsing, resolution, and publishing on iOS, Android, Mac Catalyst, macOS, Windows, and Linux |
| auto_invoke | true |
| triggers | ["mDNS","DNS-SD","Bonjour","Zeroconf","SSDP","UPnP","DLNA","M-SEARCH","ssdp:discover","ssdp:all","upnp:rootdevice","WS-Discovery","WSD","WSDAPI","ONVIF","ONVIF camera","IP camera discovery","find a printer on the network","find a media server","find a Chromecast","find a Sonos","service discovery","network discovery","local network discovery","discover devices on the network","find devices on LAN","advertise a service","publish a service","IMdnsManager","ISsdpManager","IWsDiscoveryManager","SsdpDevice","[Truncated]"] |
Shiny.Net.Discovery Skill
You are an expert in Shiny.Net.Discovery, a cross-platform local network discovery library covering
three protocols: mDNS/DNS-SD (Bonjour/Zeroconf), SSDP/UPnP, and WS-Discovery.
When to Use This Skill
Invoke this skill when the user wants to:
- Discover services or devices on the local network (printers, Chromecasts, IoT devices, peer apps)
- Advertise their own app as a discoverable service
- Find UPnP/DLNA devices, routers, Sonos, Roku, or smart TVs (SSDP)
- Find ONVIF cameras, WSD printers/scanners, or Windows machines (WS-Discovery)
- Read TXT record metadata, or a UPnP device description
- Resolve a known service instance to a host, port, and IP addresses
- Ask about Bonjour/mDNS on iOS without the multicast entitlement
- Register any of the three protocols in DI
Choosing a protocol
| Looking for | Use | Registration |
|---|
| Apple devices, AirPlay, IPP printers, peer apps, your own services | mDNS/DNS-SD | AddMdns() |
| Routers, UPnP/DLNA media servers, Sonos, Roku, smart TVs | SSDP | AddSsdp() |
| ONVIF IP cameras, WSD printers/scanners, Windows machines | WS-Discovery | AddWsDiscovery() |
They are independent - register only what you need. A device that speaks one usually does not
speak the others.
Library Overview
| Item | Value |
|---|
| GitHub | https://github.com/shinyorg/shiny |
| NuGet | Shiny.Net.Discovery |
| Namespace | Shiny.Net.Discovery (types); Shiny (registration extensions) |
| Platforms | iOS, tvOS, Mac Catalyst, macOS, Android, Windows, Linux, server .NET |
How each platform is backed
mDNS delegates to the OS wherever there is an API:
| Platform | Implementation | Why |
|---|
| iOS / tvOS / Mac Catalyst / macOS | NSNetService (Bonjour) | Goes through the system mDNSResponder, so no com.apple.developer.networking.multicast entitlement |
| Android | NsdManager | No CHANGE_WIFI_MULTICAST_STATE and no WifiManager.MulticastLock |
| Windows / Linux / server .NET | Managed responder on UDP 5353 | No OS DNS-SD API to lean on; dependency-free, AOT-safe |
SSDP and WS-Discovery are managed on every platform, including iOS and Android. This is not a
design choice - neither NsdManager nor Apple's Bonjour stack can speak these protocols, and no
OS exposes any other API for them. They therefore use raw UDP multicast everywhere.
⚠️ The entitlement rule differs by protocol
For mDNS: do not tell users they need the iOS multicast entitlement. That requirement applies
to raw multicast sockets, which the mDNS implementation deliberately avoids on Apple platforms.
For SSDP and WS-Discovery the opposite is true - they do need it on iOS, and it is
approval-gated by Apple:
| Platform | mDNS | SSDP / WS-Discovery |
|---|
| iOS | nothing | com.apple.developer.networking.multicast - request at https://developer.apple.com/contact/request/networking-multicast, granted per developer team. Cannot be tested in the simulator |
| Mac Catalyst / macOS | sandbox network entitlements | same sandbox entitlements; not the multicast entitlement (iOS only). macOS 15+ also prompts for Local Network |
| Android | nothing | CHANGE_WIFI_MULTICAST_STATE (the lock is acquired for you) plus ACCESS_LOCAL_NETWORK when targeting SDK 37+ |
| Windows | firewall; MSIX capability | same |
| Linux | firewall | same; Docker bridge networking does not work |
A missing permission throws DiscoveryPermissionException with a message naming exactly what to
add, rather than silently returning nothing.
Setup
using Shiny;
builder.Services.AddMdns();
builder.Services.AddSsdp();
builder.Services.AddWsDiscovery();
Register only the protocols you use. AddSsdp() also registers a named HttpClient
(SsdpConstants.HttpClientName) for fetching device descriptions, with redirects and cookies
disabled deliberately - you can reconfigure it with services.AddHttpClient(SsdpConstants.HttpClientName).
iOS / Mac Catalyst / macOS — Info.plist (required)
Browsing silently returns nothing if the service type is not declared. Both keys are required:
<key>NSLocalNetworkUsageDescription</key>
<string>This app discovers nearby devices on your local network.</string>
<key>NSBonjourServices</key>
<array>
<string>_myapp._tcp</string>
<string>_http._tcp</string>
</array>
Every service type the app browses for and publishes must be listed in NSBonjourServices.
The local-network permission prompt appears automatically on first use; there is no API to query
or pre-request it.
Mac Catalyst / macOS — App Sandbox entitlements (csproj)
For mDNS, iOS needs no entitlement at all. Mac Catalyst and macOS run sandboxed, so network access
must be declared — browsing/resolving needs the client entitlement, publishing also needs the
server one. These apply to all three protocols:
<ItemGroup Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">
<CustomEntitlements Include="com.apple.security.network.client" Type="Boolean" Value="true" />
<CustomEntitlements Include="com.apple.security.network.server" Type="Boolean" Value="true" />
</ItemGroup>
Still not com.apple.developer.networking.multicast — that is for raw multicast sockets.
Android — AndroidManifest.xml
No runtime permission is needed, but declare:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
Windows / Linux
The managed responder binds UDP 5353. It sets SO_REUSEADDR (plus SO_REUSEPORT on Unix) so it
coexists with avahi-daemon, mDNSResponder, or the Windows DNS client. Ensure the firewall
allows UDP 5353.
A packaged Windows app (MSIX/WinUI) also needs the local-network capability in
Package.appxmanifest, otherwise the responder sends and receives nothing:
<Capabilities>
<Capability Name="privateNetworkClientServer" />
</Capabilities>
SSDP / WS-Discovery — extra platform setup
Everything above still applies. These two protocols additionally need the following, because they
use raw multicast rather than an OS discovery API.
iOS — the multicast entitlement (blocking). Apple grants this per developer team on request;
budget days to weeks. Without it, sends fail and nothing is received.
<key>com.apple.developer.networking.multicast</key>
<true/>
Also set NSLocalNetworkUsageDescription in Info.plist. This cannot be tested in the simulator -
local network privacy is not enforced there, so the simulator neither reproduces the failure nor
proves the fix. NSBonjourServices is irrelevant to SSDP/WSD; it only gates mDNS.
Android:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
The WifiManager.MulticastLock is acquired and released automatically for the lifetime of a
browse or publication - do not write code to manage it. ACCESS_LOCAL_NETWORK is a runtime
(dangerous) permission in the NEARBY_DEVICES group, so request it before discovering.
Ports: SSDP is UDP 1900, WS-Discovery is UDP 3702, both on group 239.255.255.250 (and
ff02::c). On Windows the system SSDPSRV and FDResPub services already hold those ports;
SO_REUSEADDR is set so this coexists with them.
Docker: bridge networking does not work - NAT does not rewrite the addresses embedded in
LOCATION and XAddrs. Use --network host or macvlan.
mDNS API Reference
IMdnsManager
public interface IMdnsManager
{
IAsyncEnumerable<MdnsBrowseResult> Browse(MdnsBrowseConfig config, CancellationToken ct = default);
Task<MdnsService?> Resolve(string instanceName, string serviceType, TimeSpan? timeout = null, CancellationToken ct = default);
Task<IMdnsPublication> Publish(MdnsServiceRegistration registration, CancellationToken ct = default);
}
Extension helpers (MdnsExtensions):
IAsyncEnumerable<MdnsBrowseResult> Browse(string serviceType, CancellationToken ct = default);
Task<IReadOnlyList<MdnsService>> BrowseOnce(string serviceType, TimeSpan? scanTime = null, CancellationToken ct = default);
Task<IMdnsPublication> Publish(string instanceName, string serviceType, int port, CancellationToken ct = default);
Browsing (live)
Browse never completes on its own — it runs until the token is cancelled. Always pass a
CancellationToken.
await foreach (var result in mdns.Browse("_http._tcp", ct))
{
switch (result.Status)
{
case MdnsBrowseStatus.Found:
devices[result.Service.FullName] = result.Service;
break;
case MdnsBrowseStatus.Lost:
devices.Remove(result.Service.FullName);
break;
}
}
Browsing (one-shot scan)
var found = await mdns.BrowseOnce("_ipp._tcp", TimeSpan.FromSeconds(5), ct);
foreach (var printer in found)
Console.WriteLine($"{printer.InstanceName} => {printer.GetEndPoint()}");
Publishing
You are responsible for having something listening on the port — publishing only advertises it.
await using var publication = await mdns.Publish(
new MdnsServiceRegistration("Allan's Laptop", "_myapp._tcp", 8080)
{
TxtRecords = new Dictionary<string, string>
{
["version"] = "2",
["path"] = "/api"
}
},
ct
);
Console.WriteLine($"advertising as {publication.InstanceName}");
Disposing the publication sends a goodbye packet and stops advertising.
Resolving a known instance
var service = await mdns.Resolve("Allan's Laptop", "_myapp._tcp", TimeSpan.FromSeconds(5), ct);
if (service?.IsResolved == true)
await socket.ConnectAsync(service.GetEndPoint()!);
Returns null when the instance did not answer within the timeout.
mDNS Models
MdnsService
| Member | Notes |
|---|
InstanceName | Human readable, unescaped; may contain spaces, dots, and UTF8 |
ServiceType | eg _http._tcp |
Domain | Almost always local |
HostName | SRV target, eg printer.local; null when unresolved |
Port | 0 when unresolved |
Addresses | IReadOnlyList<IPAddress>; often both IPv4 and IPv6 |
TxtRecords | Case-insensitive key/value map; empty when there are none |
FullName | "Allan's Laptop._myapp._tcp.local" — use this as the dictionary key |
IsResolved | Port > 0 && Addresses.Count > 0 |
GetEndPoint(family?) | First matching IPEndPoint, or null when unresolved |
TXT helpers:
string? path = service.GetTxt("path");
int version = service.GetTxt<int>("version", 1);
bool secure = service.GetTxt<bool>("secure");
MdnsBrowseConfig
new MdnsBrowseConfig("_http._tcp")
{