fix(clients): pin mDNS discovery to IPv4 on every client

Host machines' OS mDNS responders answer AAAA for hostname.local even though the punktfunk
stack binds IPv4 sockets exclusively, so a v6-resolved address rendered a host card whose
connect always failed. Apple's Network.framework prefers IPv6 (RFC 6724) — force the resolve
connection to v4; pf-client-core (Linux/Windows/Deck shells) now picks get_addresses_v4()
instead of an arbitrary first address. Android already did exactly this, for the same reason.
Lift all three when the stack actually speaks IPv6 (per-family shard sizing is the gating
item — the IPv6 header costs 20 bytes of the MTU budget shard_payload is maximal against).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 10:56:49 +02:00
parent 8e6e8bb25c
commit d0fa8bd3ee
2 changed files with 18 additions and 2 deletions
@@ -127,7 +127,16 @@ public final class HostDiscovery: ObservableObject {
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
let conn = NWConnection(to: result.endpoint, using: .udp)
// Resolve over IPv4 only: Network.framework prefers IPv6 (RFC 6724), and the host's OS
// mDNS responder often answers AAAA for its hostname even though the punktfunk host stack
// (control QUIC + data UDP) binds IPv4 sockets exclusively a v6-resolved address would
// produce a host card whose connect always fails in the Rust core (`host:port` parse).
// Same policy as the Android/desktop clients; lift when the stack speaks IPv6.
let params = NWParameters.udp
if let ip = params.defaultProtocolStack.internetProtocol as? NWProtocolIP.Options {
ip.version = .v4
}
let conn = NWConnection(to: result.endpoint, using: params)
connections[key] = conn
conn.stateUpdateHandler = { state in
MainActor.assumeIsolated { [weak self] in
+8 -1
View File
@@ -61,7 +61,14 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
ServiceEvent::ServiceResolved(info) => {
let props = info.get_properties();
let val = |k: &str| props.get_property_val_str(k).unwrap_or("").to_string();
let Some(addr) = info.get_addresses().iter().next().map(|a| a.to_string())
// IPv4 only, on purpose (same policy as the Android client): the core
// dials `format!("{host}:{port}").parse::<SocketAddr>()` over IPv4-bound
// sockets, so a v6 pick from this unordered address set (the host's OS
// responder often answers AAAA for its hostname) would render a host card
// that fails on every click. A v6-only advert is dropped — the honest
// "not found" — until the stack actually speaks IPv6.
let Some(addr) =
info.get_addresses_v4().iter().next().map(|a| a.to_string())
else {
continue;
};