Skip to content

feat(discovery): standardize desktop mDNS on DNS-SD for iOS/Android interop - #330

Merged
momics merged 24 commits into
mainfrom
feat/standard-dns-sd-discovery
Jul 9, 2026
Merged

feat(discovery): standardize desktop mDNS on DNS-SD for iOS/Android interop#330
momics merged 24 commits into
mainfrom
feat/standard-dns-sd-discovery

Conversation

@momics

@momics momics commented Jul 8, 2026

Copy link
Copy Markdown
Owner

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-SD
PTR record, so Apple's mDNSResponder / NWBrowser and Android's NsdManager
never register desktop advertisers — dns-sd -B finds 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-sd crate, wired to iroh's dialer through a custom AddressLookup so that
fetch(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.

⚠️ Breaking changes

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, iOS NWBrowser, and Android NsdManager. 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) and node.browse(config). The iroh-http peer discovery that previously lived under those names has moved to the explicit specialization node.advertisePeer() and node.browsePeers(). The node.dnsSd sub-object and the exported DnsSd class are removed. The rationale is captured in ADR-018, and interop details are documented in docs/features/discovery.md.

Migration:

Before After
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:mdns and iroh-http:dns-sd permission sets are replaced by a single iroh-http:discovery set covering all ten discovery commands. Capabilities that granted either of the old sets must grant iroh-http:discovery instead.

Generic DNS-SD surface

node.advertise and node.browse publish and discover any DNS-SD service, returning lossless ServiceRecords (instance label, host, port, socket addresses, and every TXT property). The iroh-http path, node.advertisePeer and node.browsePeers, is a thin specialization over the same engine that additionally wires the endpoint address lookup and injects the pk TXT. browsePeers also 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 generic browse primitive 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's dlopen runs during createNode. 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:

  • One permission. iroh-http:mdns + iroh-http:dns-sd collapse into iroh-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.
  • Generic DNS-SD on mobile. Mobile builds previously rejected generic advertise/browse with a hard not supported on mobile error, 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) via resolveService; iOS surfaces the instance name, service type, and TXT but leaves host/port/addresses unresolved, because NWBrowser does not resolve an endpoint without opening an NWConnection — a documented best-effort limitation, not a hard failure. The mobile Rust bridge is verified with cargo check/clippy against the iOS target; the native Swift and Kotlin changes cannot be compiled in CI and require on-device verification (tracked below).

Plan

  • Docs: mobile mDNS/DNS-SD setup guide (iOS 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 a pk TXT), with the base32 endpoint id as the instance name.
  • Custom iroh AddressLookup fed by the browse stream so desktop fetch(nodeId) still auto-resolves LAN peers (mirrors MobileAddressLookup).
  • Generic node.advertise / node.browse, iroh advertisePeer / browsePeers, and the asIrohPeer helper, with Deno, Node, and Tauri examples (ADR-018).
  • Unify the iroh-http:mdns + iroh-http:dns-sd Tauri permissions into a single iroh-http:discovery set.
  • Implement generic DNS-SD on mobile (Android full records; iOS metadata-only) over the existing native bridge.
  • Exclude the local node from browsePeers so a node that both advertises and browses doesn't surface itself (the generic browse stays faithful).
  • Interop verified on one LAN: desktop → iOS, desktop → Android, mobile → desktop (no regression), desktop ↔ desktop, mobile ↔ mobile.
  • Verify the native mobile discovery (Swift/Kotlin) on-device — behavioral mDNS testing requires a real LAN and stays manual.
  • Reconcile the plugin README iOS section (currently in fix(tauri): make the plugin build for iOS/Android #328) to point at the new canonical doc, post-merge.

Follow-ups (out of scope, tracked separately)

  • CI guardrails for the native FFI contract — compiling the Swift/Kotlin plugin sources in CI and a string-parity contract test between the Rust-invoked command names and the native @objc/@Command handlers, 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 main and is independent of the iOS build fix in #328.

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
@momics
momics force-pushed the feat/standard-dns-sd-discovery branch from 83111ad to 387840d Compare July 8, 2026 12:31
momics added 13 commits July 8, 2026 15:53
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
momics added 4 commits July 9, 2026 12:07
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
@momics

momics commented Jul 9, 2026

Copy link
Copy Markdown
Owner Author

Follow-up: peer-vs-generic naming applied end-to-end

Pushed 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 mdns / dns_sd transport split (both paths are DNS-SD-over-mDNS; the real distinction is the generic primitive vs. the iroh-peer specialization that wires AddressLookup).

Commit Scope
bbef6e0 Rust core seam: start_advertise/start_browseadvertise_peer/browse_peers (+ all call sites, example, interop test)
12678fa BREAKING — FFI exports, Deno dispatch keys, Tauri commands, permission leaves, shared IrohAdapter interface
79433ef BREAKING — mobile native contract (run_mobile_plugin strings + iOS @objc + Android @Command)
6022707 docs: ADR-017/018 alignment + a "Naming convention (as shipped)" table

Names, per layer

Layer Generic primitive Peer specialization
TS API (node) advertise / browse advertisePeer / browsePeers
Rust core seam dns_sd::advertise / dns_sd::browse advertise_peer / browse_peers
Node napi / Deno key advertise / browse / browseNext / advertiseClose / browseClose advertisePeer / browsePeers / browsePeersNext / advertisePeerClose / browsePeersClose
Tauri command advertise / browse / browse_next / … advertise_peer / browse_peers / browse_peers_next / …
Tauri permission leaf allow-advertise / allow-browse / allow-browse-next / … allow-advertise-peer / allow-browse-peers / allow-browse-peers-next / …
Mobile native method advertise_start/stop / browse_start/poll/stop advertise_peer_start/stop / browse_peers_start/poll/stop

The stable surface is unchanged: the single iroh-http:discovery permission set, the high-level node.advertise/browse/advertisePeer/browsePeers methods, and the DnsSdAdvertiseOptions/DnsSdBrowseOptions option types (still accurate for generic DNS-SD config). The rename is breaking only for low-level consumers of the raw FFI exports, Tauri invoke names, per-command permissions, or the mobile native methods.

Additional breaking detail

Beyond the permission-set unification already noted in the PR body, the permission leaf ids are renamed too (e.g. allow-mdns-browseallow-browse-peers). Capabilities that pinned individual leaves rather than the iroh-http:discovery set must migrate.

Verification

cargo clippy --workspace --all-targets --features discovery -D warnings, cargo test -p iroh-http-discovery, Tauri permission tests (4/4), npm run typecheck (node + tauri), shared tsc, deno check, and cargo check --target aarch64-apple-ios-sim --features discovery all green. The Swift/Kotlin @objc/@Command renames compile against the new contract but — like the rest of the mobile native path — still need on-device verification (already tracked in the plan checklist).

momics added 2 commits July 9, 2026 13:22
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
@momics

momics commented Jul 9, 2026

Copy link
Copy Markdown
Owner Author

Review pass — DNS-SD standardization

Read 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 deno fmt --check step fails on one file: packages/iroh-http-tauri/README.md — the permissions table wasn't reformatted after the iroh-http:mdns / iroh-http:dns-sdiroh-http:discovery collapse. It's the first job step, so it blocks everything. Fix is just deno fmt. Everything downstream of it passed locally: workspace clippy + the strict tauri clippy (unwrap_used/panic/arithmetic_side_effects), cargo fmt --all --check, typecheck (node/deno/tauri), tauri tests (26 + 4 permissions + doc), cargo check --no-default-features for node & deno, cargo-deny, and discovery unit tests (15/15).

Stale comments (couldn't inline — lines aren't in the diff)

ios/Sources/IrohHttpPlugin.swift:209 and android/.../IrohHttpPlugin.kt:137 still say the desktop advertiser "(iroh swarm-discovery) publishes the base32 id as the instance name and emits no pk TXT." This PR removes swarm-discovery, and the new mdns-sd desktop advertiser (advertise_peer in the discovery crate) does emit a pk TXT. The instance-name fallback still works, so it's harmless — but the rationale is now wrong and will mislead the next reader.

Biggest structural risk: mobile is untested by CI

The Swift/Kotlin plugins and the #[cfg(mobile)] command paths are not compiled or run by any CI job (no iOS/Android target). ADR-017/018 already mark on-device interop as pending. So the mobile half of "standardize across all platforms" rests entirely on review + future device testing — that's the main thing still to nail, and it's structural rather than a specific defect.

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 Display while desktop used 52-char base32 — which would trip validate_node_id (strict a-z2-7). It's fine: IrohEndpoint::node_id() returns the base32 string on every platform, and iroh's FromStr accepts both encodings, so MdnsSdAddressLookup auto-dial is robust either way.

Nits

  • Generic advertise and peer advertise_peer share one advertise_slab (node + tauri), so advertiseClose / advertisePeerClose are interchangeable. Works (same type, unique handles) but asymmetric with browse, which uses separate slabs.
  • deny.toml now emits unnecessary-skip warnings for r-efi and netlink-packet-route — leftovers from the dropped swarm-discovery dep tree; safe to prune.
  • Node advertise* are async fn (Tokio-handle reasons) while the Deno equivalents are sync — both correct, just inconsistent.
  • New tauri Rust (commands.rs, mobile_*.rs) isn't rustfmt-clean. CI doesn't fmt the tauri manifest (it's out of the workspace), so non-blocking, but worth a cargo fmt there for hygiene.
  • No shared test exercises the new generic node.advertise() / node.browse() / asIrohPeer()discovery.mjs still only covers the peer surface.
  • feat: exclude self from browsePeers() (b1490c5) looks correct. Note it filters at the JS layer, so on mobile the Rust MdnsSdAddressLookup may still upsert self before JS drops the event — harmless (you'd just resolve your own addrs), but worth being aware of.

@momics

momics commented Jul 9, 2026

Copy link
Copy Markdown
Owner Author

Heads-up: browsePeers reports isActive: false for every event on the Tauri adapter (desktop + iOS)

While building a discovered-peer list on top of this branch I hit a real bug that lives in the Tauri adapter on this PR (it's not example-only, and it's squarely "getting DNS-SD right").

Root cause — payload/contract mismatch. The native browse_peers_next command serializes PeerDiscoveryEventPayload as { isActive, nodeId, addrs } (camelCase), but the shared PeerDiscoveryEvent contract is tagged by type:

// packages/iroh-http-shared/src/discovery.ts
export interface PeerDiscoveryEvent {
  type: "discovered" | "expired";
  nodeId: string;
  addrs?: string[];
}

IrohNode.browsePeers derives the peer's liveness from that tag:

isActive: event.type === "discovered",

The Node adapter maps the native shape onto the contract (type: ev.isActive ? "discovered" : "expired"), but the Tauri guest-js adapter returned the raw invoke payload cast straight to PeerDiscoveryEvent:

// packages/iroh-http-tauri/guest-js/index.ts (before)
return invoke<PeerDiscoveryEvent | null>(`${PLUGIN}|browse_peers_next`, {
  browseHandle: Number(browseHandle),
});

So event.type is undefinedevent.type === "discovered" is always falseevery discovered peer is reported isActive: false. Arrivals never surface; only no-op departures fire. The raw event log still prints (it doesn't depend on isActive), which is what makes it look like discovery is "working."

Fix — map at the adapter boundary, mirroring Node:

// packages/iroh-http-tauri/guest-js/index.ts (after)
const ev = await invoke<
  { isActive: boolean; nodeId: string; addrs: string[] } | null
>(`${PLUGIN}|browse_peers_next`, { browseHandle: Number(browseHandle) });
if (!ev) return null;
return {
  type: ev.isActive ? "discovered" : "expired",
  nodeId: ev.nodeId,
  addrs: ev.addrs,
};

Note the generic browse path is unaffected — ServiceRecord.isActive is already carried natively, so only the peer event needed mapping.

Since someone is actively reviewing this PR, I haven't pushed to this branch — the one-hunk change is captured verbatim above so it can be folded in here. (For reference, I applied it on a derived branch as 511bd72.)

This is also a textbook instance of the silent Rust↔TS shape drift that #333 (native FFI contract guardrails) proposes to catch in CI. Refs #329, #333.

momics and others added 4 commits July 9, 2026 14:37
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>
@momics
momics marked this pull request as ready for review July 9, 2026 13:19
@momics
momics merged commit 3ba1ff3 into main Jul 9, 2026
7 checks passed
@momics
momics deleted the feat/standard-dns-sd-discovery branch July 9, 2026 13:19
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.

Standardize desktop mDNS on DNS-SD (PTR+SRV+TXT) so iOS/Android can discover desktop nodes

1 participant