feat(discovery): standardize desktop mDNS on DNS-SD for iOS/Android interop - #330
Conversation
Document the iOS and Android configuration a Tauri app must add for local network discovery to work: iOS SystemConfiguration framework + Info.ios.plist (NSLocalNetworkUsageDescription, NSBonjourServices) and Android manifest permissions (CHANGE_WIFI_MULTICAST_STATE, NEARBY_WIFI_DEVICES). Explain the serviceName -> _<name>._udp mapping and how to sanity-check advertisers with dns-sd. Linked from the discovery feature doc, Tauri guidelines, and docs index. First deliverable for the desktop<->mobile DNS-SD interop rework. Refs #329
83111ad to
387840d
Compare
The previous backend (iroh-mdns-address-lookup / swarm-discovery) published SRV + TXT + A/AAAA but no PTR record, so desktop nodes were invisible to every standard DNS-SD browser, including Apple's NWBrowser (iOS) and Android's NsdManager. This is the cross-platform parity gap in issue #329. Rewrite iroh-http-discovery on mdns-sd, which speaks full RFC 6763 DNS-SD. Advertising now publishes PTR + SRV + TXT + A/AAAA under _<service>._udp.local, with the service instance name and pk TXT set to the node's base32 endpoint id. Verified on-wire: dns-sd -B enumerates the node on every interface and dns-sd -L resolves the SRV port and pk TXT. Node ids on the wire are lowercase base32 (52 chars) rather than iroh's 64-char hex Display, which exceeds the 63-byte DNS label limit and makes mdns-sd panic; iroh PublicKey::FromStr accepts base32 so ids still round-trip. Each session owns its own ServiceDaemon (not a process-wide one) because mdns-sd does not deliver a daemon's own registrations to its own browsers. Add a desktop MdnsSdAddressLookup (parity with the mobile one) so fetch(nodeId) auto-dials discovered LAN peers. The public API (start_advertise, start_browse, BrowseSession, AdvertiseSession, PeerDiscoveryEvent, DiscoveryError) is unchanged, so node/deno/tauri-desktop consumers are untouched. Adds an ignored end-to-end interop test, an advertise example for manual dns-sd verification, ADR-017 documenting the reversal of ADR-016 §3, and refreshed discovery docs. Refs #329
…mislabeled
The Deno FFI browse dispatch serialized mdns events as { isActive, nodeId,
addrs }, but the shared IrohNode.browse consumer reads event.type ("discovered"
| "expired"), matching the node adapter. As a result event.type was always
undefined and every discovered peer was reported as inactive/expired with its
addresses attached — an impossible combination that made LAN discovery look
broken in Deno.
Map is_active to the "discovered"/"expired" type string, matching
PeerDiscoveryEvent and the node adapter. Verified end-to-end: deno browse of a
standalone advertiser now reports 'discovered' with addresses.
Refs #329
Bump the deno example import range from ^0.5.1 to ^0.6.0 so it resolves the local workspace member (with the new DNS-SD discovery) instead of the published 0.5.2. Regenerate deno.lock accordingly (drops stale 0.5.2 iroh-http entries) and refresh the tauri example Cargo.lock, which no longer pulls acto (a swarm-discovery dependency) now that discovery uses mdns-sd. Refs #329
The new discovery example, address_lookup, lib, and interop test were committed without running rustfmt, failing the CI Verify (cargo fmt --all --check) job. No functional change.
Expose a protocol-neutral DNS-SD surface across all adapters so callers can advertise and browse arbitrary local services (custom instance name, port, TXT, and udp/tcp protocol), not just iroh nodes. iroh-http's own advertise/browse remain thin specialisations of the same engine — one bridge, two FFI entry points, per ADR-016. - core: new `dns_sd` engine (`advertise`/`browse`, lossless `ServiceRecord`); `start_advertise`/`start_browse` refactored to build on it. TXT_PK/TXT_RELAY now public for interop. - node/deno/tauri: generic `dnsSd*` FFI entry points feeding the one engine. - shared: `DnsSd` class (async-iterator + AbortSignal), `node.dnsSd` getter, `ServiceConfig`/`ServiceRecord` types, and interop helpers (`IROH_HTTP_SERVICE`, reserved TXT keys, `asIrohPeer`). Refs #329
Demonstrate the generic dnsSd surface end to end and make it reachable from each adapter's public entry point. - examples: deno `dnssd-advertise`/`dnssd-browse` tasks, node `dnssd-*` modes, and a "Generic DNS-SD" card in the Tauri console — each advertises a non-iroh service (custom instance, port, TXT, tcp) and flags iroh peers via asIrohPeer. - adapters: re-export asIrohPeer, the reserved TXT keys, IROH_HTTP_SERVICE, and the DnsSd/ServiceConfig/ServiceRecord types from node, deno, and tauri. - docs: README mDNS section refreshed (stale sample fixed) with a generic-surface snippet and an interop caveat; discovery feature doc gains Generic DNS-SD and "Interop with iroh's built-in mDNS" sections; ServiceConfig.port documents its u16 range. - adr: renumber the general-DNS-SD-surface ADR 016 -> 018 (016 was already taken by the superseded mdns-discovery-scope ADR); cross-link supersession and repoint code doc-comments. Refs #329
Remove the `node.dnsSd` sub-object. The generic DNS-SD engine is now the primitive, exposed directly as `node.advertise(config)` / `node.browse(config)`, and the iroh-http path is the explicit specialization `node.advertisePeer()` / `node.browsePeers()`. This mirrors the actual layering (the peer path is a thin layer over the generic engine that additionally wires the endpoint address lookup and the `pk` TXT) and avoids an overloaded `advertise` whose iroh-http behaviour was hidden behind a default. The `DnsSd` class is now internal. - shared: rename IrohNode.advertise/browse -> advertisePeer/browsePeers; add generic advertise/browse delegating to the internal DnsSd engine; drop the `DnsSd` export. - adapters: drop `DnsSd` from node/deno/tauri public re-exports. - examples (deno/node/tauri): use advertisePeer/browsePeers for iroh mDNS and node.advertise/node.browse for the generic services. - docs: README, discovery, api-overview, specification, mobile-mdns-setup, troubleshooting, and ADR-018 (decision, options table, consequences). - tests: cross-runtime discovery suite uses the renamed methods. BREAKING CHANGE: `node.advertise()` / `node.browse()` are now the generic DNS-SD surface; the iroh-http peer discovery previously under those names moved to `node.advertisePeer()` / `node.browsePeers()`. `node.dnsSd` and the exported `DnsSd` class are removed. Refs #329
…tions The internal DnsSd class held no state beyond the adapter reference that IrohNode already owns, so the class, its cached `#dnsSd` field, and the lazy `#dns()` accessor were ceremony around two stateless operations. Move the generic DNS-SD engine into `dns-sd.ts` as `advertiseService(adapter, options)` and `browseServices(adapter, options)`, mirroring the existing `fetch.ts` / `serve.ts` function modules. IrohNode.advertise/browse now call these directly. No public API change: node.advertise/browse/advertisePeer/browsePeers are unchanged. Refs #329
…nssd demo The generic DNS-SD demo (node.advertise/node.browse) defaults to serviceName "demo-printer" over TCP, i.e. the DNS-SD type _demo-printer._tcp. iOS denies NWBrowser for any Bonjour type not statically listed in NSBonjourServices even after the Local Network prompt, so the dnssd demo could not browse. Declare it alongside the existing _iroh-http._udp entry. Refs #329
The generic DNS-SD demo calls the plugin's dns-sd advertise/browse commands,
but the app capability only granted iroh-http:mdns. Tauri denied the IPC with a
permissions error ("dns-sd browse not allowed"). Add the iroh-http:dns-sd
permission set alongside iroh-http:mdns.
Refs #329
…overy The plugin exposed two permission sets for one capability — iroh-http:mdns (peer discovery) and iroh-http:dns-sd (generic DNS-SD) — which is awkward given peer discovery is just a specialization of DNS-SD. Collapse them into a single iroh-http:discovery set covering all ten discovery commands. Update the example and compliance-runner capabilities, the README permission table, the permission integrity test, and regenerate the autogenerated schema/reference. BREAKING CHANGE: the iroh-http:mdns and iroh-http:dns-sd Tauri permission sets are removed. Grant iroh-http:discovery instead (it covers both advertisePeer/ browsePeers and the generic advertise/browse). Refs #329
Mobile builds previously rejected generic DNS-SD advertise/browse with a hard 'not supported on mobile' error, even though peer discovery already bridged to the native NsdManager (Android) and NWBrowser/NWListener (iOS). Since peer discovery is just a specialization of DNS-SD, the generic path can reuse the same native machinery. Add generic advertise/browse to the mobile bridge (mobile_mdns.rs) and wire the mobile dns_sd_* commands to it, mirroring the peer path's long-poll model. Add native dns_sd_browse/advertise start/poll/stop to the Android (Kotlin) and iOS (Swift) plugins. Android resolves full records (host, port, TXT, addresses) via resolveService; iOS surfaces instance name, service type and TXT but leaves host/port/addresses unresolved (NWBrowser does not resolve endpoints without an NWConnection) — a documented best-effort limitation. The mobile Rust branch is verified with cargo check/clippy against the iOS target. The Swift and Kotlin changes cannot be compiled in CI (no mobile toolchain) and require on-device verification. Refs #329
Amend ADR-018 with the two harmonization decisions: a single iroh-http:discovery Tauri permission (replacing iroh-http:mdns + iroh-http:dns-sd), and generic DNS-SD parity on mobile (Android full records, iOS metadata-only). Update the discovery feature doc and the mobile mDNS/DNS-SD setup guide to cover the generic path on mobile and the unified permission, and drop the stale 'desktop only' comment on the generic commands. Refs #329
Rename the internal iroh-peer discovery seam start_advertise/start_browse to advertise_peer/browse_peers so the names reflect the peer-vs-generic axis instead of a bogus mdns/dns_sd transport split. Internal-only rename; FFI, command, and TS API names are unchanged. Updates all call sites in node, deno, and tauri plus the example, interop test, and doc links. Refs #329
…c axis Rename the discovery FFI surface across every JS/TS adapter, the Deno dispatch keys, the Tauri commands, and the permission leaves so the names mirror the peer-vs-generic distinction the TypeScript API already exposes, instead of a misleading mdns/dns-sd transport split (both paths are DNS-SD-over-mDNS). Peer specialization: browsePeers / browsePeersNext / browsePeersClose / advertisePeer / advertisePeerClose. Generic primitive: browse / browseNext / browseClose / advertise / advertiseClose. - Node napi: mdns_*/dns_sd_* exports renamed; index.d.ts/index.js regen. - Deno: dispatch keys and *_dispatch fns renamed; adapter methods renamed. - Tauri: #[command] fns renamed; handler list + build.rs updated; permission leaves renamed (allow-browse-peers, allow-advertise, ...); autogenerated command tomls + schema regenerated; permissions test primary leaf updated. - Shared IrohAdapter interface + IrohNode/dns-sd call sites renamed. The single iroh-http:discovery permission set is unchanged. Public TS API (node.advertise/browse/advertisePeer/browsePeers) and the DnsSd*Options option types are unchanged. Mobile native method strings are renamed separately. BREAKING CHANGE: discovery FFI export names, Tauri command names, and Tauri permission leaf identifiers are renamed. Callers using the low-level adapter methods, raw Tauri invoke names, or per-command permissions must migrate to the new peer-vs-generic names. The high-level IrohNode API is unaffected. Refs #329
… axis Align the mobile native bridge with the peer-vs-generic naming used across the rest of the stack. The run_mobile_plugin() method strings, the MobileMdns wrapper methods, and their iOS (@objc) and Android (@command) counterparts are renamed together so the native contract stays in sync. Native method map (Rust string <-> Swift func <-> Kotlin fun): mdns_browse_start -> browse_peers_start mdns_browse_poll -> browse_peers_poll mdns_browse_stop -> browse_peers_stop mdns_advertise_start-> advertise_peer_start mdns_advertise_stop -> advertise_peer_stop dns_sd_browse_start -> browse_start dns_sd_browse_poll -> browse_poll dns_sd_browse_stop -> browse_stop dns_sd_advertise_* -> advertise_start / advertise_stop Verified: cargo check --target aarch64-apple-ios-sim --features discovery, desktop clippy, and the permissions test suite. Swift/Kotlin ship compiled against the new contract but are not yet exercised on a device. BREAKING CHANGE: the Tauri mobile plugin native method names change. Any custom iOS/Android host embedding this plugin must update to the new method names. Refs #329
…names Update stale symbol references left by the naming refactor: - ADR-018: core-seam (advertise_peer/browse_peers) and FFI (advertise/browse/ browseNext, advertisePeer/browsePeers) names; add a "Naming convention (as shipped)" table documenting the peer-vs-generic axis across every layer. - ADR-017: note that the core seam fns were later renamed by ADR-018. - commands.rs: mobile doc comments now reference browse_next (was dns_sd_next_record). Docs-only; no code behavior change. Historical CHANGELOG entries, the replaced permission-set names in ADR-018's decision text, the internal BrowseSession:: next_record engine method, and the kept DnsSd*Options types are intentionally left as-is. Refs #329
Follow-up: peer-vs-generic naming applied end-to-endPushed four commits that finish the naming cleanup discussed on #329 — the low-level surface now mirrors the peer-vs-generic axis of the public API instead of the misleading
Names, per layer
The stable surface is unchanged: the single Additional breaking detailBeyond the permission-set unification already noted in the PR body, the permission leaf ids are renamed too (e.g. Verification
|
mDNS echoes a node's own multicast records back to itself, so a node that both advertises and browses would surface itself as a discoverable peer. The peer-oriented browsePeers() stream now filters the local node out (compared through PublicKey so encoding differences don't matter; unparseable ids are treated as non-self so real peers are never dropped). The generic browse() primitive stays faithful to DNS-SD and still reports the node's own records. Refs #329
…c axis The example's element ids and variables inverted the API's mental model: the peer specialization used short unprefixed names (advertise/browse) while the generic DNS-SD primitive carried the dnssd- prefix. Rename so the peer panel reads advertise-peer/browse-peers and the generic panel reads generic-*, mirroring node.advertisePeer/browsePeers vs node.advertise/browse. Refs #329
Review pass — DNS-SD standardizationRead through the whole branch (ADRs 016→018, core engine, all three FFI adapters, tauri permissions, and the mobile native plugins) and ran the CI gates locally. Overall this is coherent, well-argued work — the generic-primitive / iroh-peer-specialization layering carries cleanly through every layer, and the breaking renames are consistent. Left a few inline comments; the cross-cutting notes are below. Why CI is red (found it)The fail-fast Stale comments (couldn't inline — lines aren't in the diff)
Biggest structural risk: mobile is untested by CIThe Swift/Kotlin plugins and the Verified non-issue (chased it down so you don't have to)I suspected mobile advertised the node id as iroh's 64-char hex Nits
|
Heads-up:
|
Follow-up on PR #330 review findings (must-fix group): - deno fmt the permissions table in packages/iroh-http-tauri/README.md — it wasn't reformatted after the iroh-http:mdns/iroh-http:dns-sd → iroh-http:discovery collapse, and this was the fail-fast CI gate blocking the whole verify job. - Tauri guest-js browsePeersNext() (finding #10, reported after kickoff) cast the raw native invoke payload ({isActive, nodeId, addrs}) straight to the tagged PeerDiscoveryEvent contract ({type, nodeId, addrs}). event.type was therefore always undefined, so IrohNode.browsePeers() reported isActive: false for every peer — arrivals never surfaced, only no-op departures fired. Map at the adapter boundary, mirroring the Node adapter, and add a regression test that fails without the fix. - Android NsdManager.resolveService() was called concurrently from both browse_peers_start and browse_start's onServiceFound callbacks. NsdManager only allows one outstanding resolve; concurrent calls fail FAILURE_ALREADY_ACTIVE and onResolveFailed is a silent no-op, silently dropping records when several peers/services appear together. Serialize all resolves through a shared queue. - iOS generic browse (browse_start) deduped on knownInstances.contains(name), so a service whose TXT/addrs changed never re-emitted after its first sighting — unlike desktop, which re-announces on change. Track a snapshot (txt, addrs) per instance and re-emit only when it actually changes. Refs #329, #333. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…cord gap Follow-up on PR #330 review findings (should-fix group): - instance_from_fullname split on the first "._" occurrence to recover a DNS-SD instance label from a fullname. That's fragile on the generic surface: an instance label built from arbitrary bytes can itself contain "._" and would be truncated. Take the known ty_domain suffix (already available at both call sites) and strip it directly instead, which is correct regardless of what the instance label contains. Adds a regression test with a "._"-containing instance label. - ServiceRecord's doc comment claimed the generic node.browse() surface is always lossless, but iOS's implementation is metadata-only (host, port, addrs are empty/zeroed even on "discovered" records) because resolving them there would need an NWConnection per result. Document the platform caveat and point at ADR-018 section 8. Note: the two stale swarm-discovery rationale comments in IrohHttpPlugin.kt/swift (finding #6 — desktop's advertiser now emits a `pk` TXT, contradicting the old comments) were fixed alongside the must-fix Android/iOS changes in the previous commit since they touch the same functions. Refs #329, #333. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…eneric surface tests Follow-up on PR #330 review findings (nits/hygiene group): - deny.toml carried unnecessary-skip entries for r-efi and netlink-packet-route, leftovers from the dropped swarm-discovery dependency tree — both now resolve to a single version. Verified with `cargo deny check bans` (no more unnecessary-skip warnings). - packages/iroh-http-tauri isn't part of the cargo workspace, so workspace `cargo fmt --all --check` never covered it. Ran `cargo fmt` scoped to the crate (commands.rs, mobile_mdns.rs, mobile_address_lookup.rs, state.rs, tests.rs, tests/permissions.rs) — formatting only, no behavior change. - discovery.mjs (shared across node/deno/tauri runners) only exercised browsePeers()/advertisePeer()/pathChanges() — the iroh-peer specialization. Added shape/unit coverage for the generic node.advertise()/node.browse()/asIrohPeer() primitives too, and wired asIrohPeer through each runner's ctx. Refs #329, #333. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The CI-blocking deno fmt --check runs repo-wide; examples/tauri/src/main.ts (from 3c23998) was left unformatted after the peer-vs-generic UI rename, so Verify still failed even after the README fix. Formatting only, no logic change. Refs #330 review (CI blocker). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Closes #329.
Summary
Desktop iroh-http nodes are currently invisible to iOS and Android. The desktop
discovery stack (swarm-discovery via
iroh-mdns-address-lookup) emits no DNS-SDPTRrecord, so Apple's mDNSResponder /NWBrowserand Android'sNsdManagernever register desktop advertisers —
dns-sd -Bfinds nothing while a swarm-discovery advertiser is running (see #329 for the full root-cause analysis and evidence).This PR reworks desktop mDNS to speak standard DNS-SD (PTR + SRV + TXT) via the
mdns-sdcrate, wired to iroh's dialer through a customAddressLookupso thatfetch(nodeId)still auto-resolves LAN peers. Because that gives us a standards-compliant DNS-SD engine, the PR also exposes it as a generic advertise/browse API so applications can publish and discover any DNS-SD service, not just iroh-http peers — with the iroh peer path defined as a specialization of that generic surface, at parity across desktop and mobile.Wire format. Desktop mDNS moves from swarm-discovery to standard DNS-SD (
_iroh-http._udp.local, PTR + SRV + TXT), which changes the LAN discovery wire format. A node on a pre-switch version will not discover, or be discovered by, a node on this version over mDNS, so mixed-version LANs must upgrade together. iroh-http is also no longer interoperable with plain-iroh's built-in mDNS swarm discovery, which omits the DNS-SD PTR record; in exchange, desktop advertisers become visible to Bonjour, iOSNWBrowser, and AndroidNsdManager. This affects LAN enumeration only — once a node id and address are known (via ticket, direct address, or DNS), the QUIC handshake is unchanged.Discovery API. The generic DNS-SD engine is now the primitive, exposed directly as
node.advertise(config)andnode.browse(config). The iroh-http peer discovery that previously lived under those names has moved to the explicit specializationnode.advertisePeer()andnode.browsePeers(). Thenode.dnsSdsub-object and the exportedDnsSdclass are removed. The rationale is captured in ADR-018, and interop details are documented in docs/features/discovery.md.Migration:
node.advertise({ serviceName })(iroh peer)node.advertisePeer({ serviceName })node.browse({ serviceName })(iroh peer)node.browsePeers({ serviceName })node.dnsSd.advertise(config)node.advertise(config)node.dnsSd.browse(config)node.browse(config)Tauri permission. The
iroh-http:mdnsandiroh-http:dns-sdpermission sets are replaced by a singleiroh-http:discoveryset covering all ten discovery commands. Capabilities that granted either of the old sets must grantiroh-http:discoveryinstead.Generic DNS-SD surface
node.advertiseandnode.browsepublish and discover any DNS-SD service, returning losslessServiceRecords (instance label, host, port, socket addresses, and every TXT property). The iroh-http path,node.advertisePeerandnode.browsePeers, is a thin specialization over the same engine that additionally wires the endpoint address lookup and injects thepkTXT.browsePeersalso excludes the local node from its own stream — mDNS echoes a node's records back to itself, but a peer stream should only surface other nodes (you never dial yourself; self-fetch is loopback per ADR-015). The genericbrowseprimitive stays faithful to DNS-SD and still reports the node's own records. Both run on the node because the native discovery FFI is loaded through the node addon — Deno'sdlopenruns duringcreateNode.asIrohPeer(record)recognizes an iroh-http peer inside a generic browse.Discovery harmonization (mobile parity + one permission)
The peer and generic paths are now unified rather than two loosely-related groups:
iroh-http:mdns+iroh-http:dns-sdcollapse intoiroh-http:discovery, since the peer path is just a specialization of the generic one and both drive the same commands. The example and compliance-runner capabilities, the plugin README, the permission-integrity test, and the autogenerated schema/reference are updated accordingly.not supported on mobileerror, even though peer discovery already bridged to the native layer. The generic path now reuses that same machinery. Android resolves full records (host, port, TXT, addresses) viaresolveService; iOS surfaces the instance name, service type, and TXT but leaves host/port/addresses unresolved, becauseNWBrowserdoes not resolve an endpoint without opening anNWConnection— a documented best-effort limitation, not a hard failure. The mobile Rust bridge is verified withcargo check/clippyagainst the iOS target; the native Swift and Kotlin changes cannot be compiled in CI and require on-device verification (tracked below).Plan
Info.plist+ Android manifest), linked from the discovery feature doc, Tauri guidelines, and docs index.iroh-http-discovery: standard DNS-SD advertise/browse (mdns-sd) emitting PTR + SRV + TXT (including apkTXT), with the base32 endpoint id as the instance name.AddressLookupfed by the browse stream so desktopfetch(nodeId)still auto-resolves LAN peers (mirrorsMobileAddressLookup).node.advertise/node.browse, irohadvertisePeer/browsePeers, and theasIrohPeerhelper, with Deno, Node, and Tauri examples (ADR-018).iroh-http:mdns+iroh-http:dns-sdTauri permissions into a singleiroh-http:discoveryset.browsePeersso a node that both advertises and browses doesn't surface itself (the genericbrowsestays faithful).Follow-ups (out of scope, tracked separately)
@objc/@Commandhandlers, so a native rename can't silently break at runtime. Split out to Add CI guardrails for the mobile discovery FFI contract (native compile + string-parity test) #333 to keep this PR focused on the DNS-SD wire format and the peer-vs-generic API. (The behavioral mDNS verification stays manual regardless.)Notes
The #329 open question is resolved in favour of a full replacement of swarm-discovery, giving a single interoperable wire format. This branch is cut from
mainand is independent of the iOS build fix in #328.