fourb_mdns

Let people pick a server off the local network from inside your Flutter app, and remember which one they chose.

Unlike a browser, a Flutter app can speak mDNS itself — so there is no server to run. Discovery uses two backends at once, because each covers the other's blind spot:

  • nsd drives the platform's own stack (Android NsdManager, Apple NetService). It resolves reliably and satisfies the iOS/macOS Local Network permission the way the OS expects — but every platform API demands a concrete service type, so it only finds what you ask it for.
  • multicast_dns speaks the protocol directly, which lets it run the wildcard sweep that discovers types nobody declared.

Both run concurrently and merge by fully-qualified name. On one real network that was 5 services from nsd, 50 from the wildcard sweep, and 64 merged.

The companion package for React apps is 4b-react-mdns; the two share the same storage keys and record shape.

Quick start

dependencies:
  fourb_mdns: ^0.1.0

Then wire it up:

dart run fourb_mdns:config --http-only
  + entry point (lib/main.dart) — wrapped runApp in MdnsScope
  + iOS plist (ios/Runner/Info.plist) — added NSLocalNetworkUsageDescription + NSBonjourServices
  + macOS plist (macos/Runner/Info.plist) — added NSLocalNetworkUsageDescription + NSBonjourServices
  + macOS debug entitlements — added network client + server
  + macOS release entitlements — added network client + server
  + Android manifest — added multicast + network permissions

That wraps runApp() in MdnsScope and writes every platform permission mDNS needs — the part that otherwise makes a scan silently find nothing. Platforms your project doesn't have are skipped, and anything already done reports =, so it is safe to re-run.

Flag
--http-only Write httpOnly: true — only _http._tcp / _https._tcp
--no-launcher Write launcher: false — open the picker from your own button
--dry-run Show the plan without writing

Full restart afterwards (not hot reload — the permission changes are native). A Scan network pill appears bottom-right. Tap it, pick a server, done.

Wiring it by hand instead
import 'package:fourb_mdns/fourb_mdns.dart';

void main() => runApp(
      MdnsScope(
        httpOnly: true,
        child: MaterialApp(home: HomePage()),
      ),
    );

Plus the platform permissions below.

Reading the pick

Anywhere below the scope:

final mdns = MdnsScope.of(context);

if (mdns.address == null) {
  return TextButton(
    onPressed: () => showMdnsPicker(context),
    child: const Text('Choose a server'),
  );
}
return Text('Server at ${mdns.address}');

Outside the widget tree — in an API client with no BuildContext:

final address = await MdnsStore.savedAddress(); // "192.168.0.224:3000"
final base = Uri.parse('http://$address');

Stored in SharedPreferences under the same keys the React package uses:

Key
4b_mDNS_ip the plain ip:port string
4b_mDNS_selection the full record as JSON

Platform setup

dart run fourb_mdns:config writes all of this for you. Here is what it adds, if you would rather do it by hand or want to check its work.

mDNS needs explicit permission on Apple platforms, and multicast on Android. Skip these and the scan just finds nothing — there is no error dialog.

iOS

ios/Runner/Info.plist:

<key>NSLocalNetworkUsageDescription</key>
<string>Finds servers on your network so you can pick one.</string>
<key>NSBonjourServices</key>
<array>
  <string>_services._dns-sd._udp</string>
  <string>_http._tcp</string>
  <string>_https._tcp</string>
</array>

NSBonjourServices is an allow-list: iOS only returns types you declare. _services._dns-sd._udp is what enumerates the rest, so keep it and add any other type you care about.

macOS

Both macos/Runner/DebugProfile.entitlements and Release.entitlements:

<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>

macOS 15+ also wants the same NSLocalNetworkUsageDescription and NSBonjourServices keys in macos/Runner/Info.plist.

Android

android/app/src/main/AndroidManifest.xml:

<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"/>

Flutter Web

Not supported — browsers can't speak mDNS. Use 4b-react-mdns, which runs discovery in Node and serves it over HTTP.

MdnsScope

Parameter Default
child Your app
httpOnly false Show only _http._tcp / _https._tcp
filterToggle false when httpOnly is set Show the HTTP-only switch in the picker
launcher true Show the built-in floating button
launcherLabel Scan network Its text
alignment Alignment.bottomRight Where it sits
scanOnStart true Sweep once on mount
timeout 6s How long each query phase listens
useNsd true Use the platform's native discovery
useMulticastDns true Run the raw wildcard sweep
serviceTypes _http._tcp, _https._tcp Types the native backend browses
onSelect Called when the pick changes

Passing httpOnly hides the picker's own switch: the app has decided, so a control that contradicts it only confuses. Pass filterToggle: true to bring it back.

MdnsScope.of(context)

Returns the `MdnsController` and rebuilds the caller when the scan or the pick changes. Use MdnsScope.read(context) in callbacks that only need to act.

selection      // MdnsService?  the pick
address, ip, url
services       // filtered + sorted
allServices    // everything found
webServerCount // how many are _http/_https
isScanning, isRestored, error
scan({fresh}), select(service), clear()
httpOnly       // get/set

isRestored tells "nothing selected" apart from "still reading storage", so you can avoid flashing an empty state on launch.

Your own button

MdnsScope(
  launcher: false,
  child: MaterialApp(home: HomePage()),
)
IconButton(
  icon: const Icon(Icons.wifi_find),
  onPressed: () => showMdnsPicker(context),
)

showMdnsPicker returns whatever is selected when the picker closes. Picking a server saves it but leaves the picker open, so the choice is visibly confirmed and can be changed; the ✕ closes it.

Using the scanner directly

No widgets involved — useful for a CLI or a background refresh:

final scanner = MdnsScanner(timeout: const Duration(seconds: 4));
scanner.stream.listen((services) => print('${services.length} so far'));
await scanner.scan();
scanner.dispose();

MdnsService gives you name, type, protocol, host, port, addresses, txt, plus derived ip (IPv4 first, then IPv6, then the .local host), address (ip:port, IPv6 bracketed), url, kind and isWebServer.

How the scan works

Native (nsd) browses each type in serviceTypes through the OS and gets back resolved services. Simple and dependable, but it can't tell you which types exist.

Wildcard (multicast_dns) fills that gap. The library only answers questions you ask, so the browse is five steps:

  1. PTR on _services._dns-sd._udp.local — which types are in use
  2. PTR on each type — instances of it
  3. SRV on each instance — host and port
  4. A / AAAA on the host — addresses
  5. TXT on the instance — metadata

Types resolve concurrently, and results stream out as they arrive rather than after the whole sweep, so the list fills in progressively. mDNS has no "done" signal, so every phase is bounded by timeout.

Merging. Both backends see some of the same services. Records are keyed by fully-qualified name; where both resolved one, addresses are unioned (the two sometimes see different interfaces of the same host) and the record that actually carries addresses wins — an entry you can't dial is no use.

Either backend can be turned off:

MdnsScope(
  useNsd: true,            // native discovery
  useMulticastDns: true,   // wildcard sweep — off makes scans faster and quieter
  serviceTypes: ['_http._tcp', '_https._tcp', '_myapp._tcp'],
  child: MyApp(),
)

If you only care about your own service type, useMulticastDns: false with an explicit serviceTypes is the fastest, most reliable configuration.

Example

cd example
flutter run -d macos

Or check discovery against the real network with no UI:

cd example
flutter test integration_test/scan_test.dart -d macos
54 service(s), 4 web

  mDNS: advertising "streaming-server" at Mac.local:3000 (_http._tcp.local)
        -> 192.168.0.224:3000   http://192.168.0.224:3000

It runs as an integration test rather than a dart run script because the scanner depends on nsd, a platform plugin — only the Flutter engine can compile it.

License

MIT

Libraries

fourb_mdns
Let people pick a server off the local network from inside your Flutter app, and remember which one they chose.