Files
punktfunk/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift
T
enricobuehler b0ec759fad fix(brand): capitalize "Punktfunk" in user-facing text
The docs site's page title read "punktfunk docs". Fixed that and swept the
rest of the tree for the same defect, capitalizing the brand wherever it is
shown to a human and leaving it lowercase where it is a technical identifier
(CLI/package names, `punktfunk://` scheme, PnP enumerator, TLS SNI, logcat
tag, config paths, CMS tenant id).

User-visible fixes:

- docs-site: page title -> "Punktfunk Docs"; API reference title, meta
  description and the branded bar's aria-label; the BrandMark/Wordmark SVG
  accessible names (the web console already had these capitalized -- the
  docs site had drifted from it).
- Android: six strings of live UI copy -- the local-network permission
  dialog (x2), the connect-screen error banner, and the no-controller
  explainer.
- Apple: the "No Hosts" empty-state text and the fallback display name for
  a host that advertises no instance name.
- Windows client: the `--discover` progress line.
- KWin fake-input: the application name passed to `authenticate()` (the
  grant is cached per-exe, so the string is display-only).
- THIRD-PARTY-NOTICES: fixed in both generators (about.hbs and
  gen-third-party-notices.py) and applied to the three checked-in outputs
  so they match what a regeneration now produces.

Every changed line differs from the original only by letter case, so line
lengths are unchanged and no formatter width rule is affected.
`cargo fmt --all --check` passes.
2026-08-06 23:10:25 +02:00

481 lines
22 KiB
Swift

// LAN auto-discovery of punktfunk/1 hosts over mDNS — the client side of the host's
// `crate::discovery` advert (`_punktfunk._udp`). Browses with NWBrowser (TXT rides in the
// result metadata), resolves each service to a connectable IP:port with a throwaway
// NWConnection, and publishes the live set.
//
// The advertised `fp` (host cert SHA-256) is ADVISORY: mDNS is unauthenticated, so TOFU /
// pinning still verifies the host on connect — it's surfaced only so a picker can show it and
// pre-fill. `pair=required` lets the UI route straight to the pairing ceremony.
//
// iOS/tvOS gate Bonjour browsing on Info.plist `NSBonjourServices` listing `_punktfunk._udp`
// (Config/Info.plist) — without it the system blocks the browse and nothing is returned.
//
// SELF-HEALING is what the bookkeeping below is for. Neither Network.framework primitive
// recovers on its own, and all three failure modes read as "the host isn't there":
//
// - `browseResultsChangedHandler` fires only when the result SET changes. A service that is
// found but whose resolve fails is never re-offered — from the browser's point of view
// nothing changed — so one unlucky resolve hid that host for the life of the process.
// - `NWConnection` has no timeout. A resolve that cannot complete (v6-only advert against our
// IPv4 pin, Wi-Fi still associating, host mid-reboot) parks in `.preparing`/`.waiting`
// forever instead of failing, so the retry path above was never even reached.
// - `NWBrowser` parks in `.waiting` when the browse is blocked. On iOS that is where the LOCAL
// NETWORK PRIVACY gate lands the first launch after install: the browse starts, the system
// puts up its "find and connect to devices on your local network" prompt, and the browser
// waits. Granting permission does NOT revive that browser — only a new one sees the grant.
//
// Every one of those presented as "restarting the app fixes it", which is what field reports
// described. A 1 Hz `sweep` therefore times out stuck resolves, retries failed ones on a backoff
// and re-arms a browser that stopped working; `refresh()` forces the same recovery immediately,
// behind the UI's pull-to-refresh and Refresh button.
#if canImport(Network)
import Foundation
import Network
import PunktfunkShared
/// A punktfunk/1 host found on the LAN. `fingerprintHex` is advisory (see file header).
public struct DiscoveredHost: Identifiable, Sendable, Equatable {
/// Stable host id (mDNS `id` TXT); falls back to the Bonjour instance name.
public let id: String
/// Bonjour instance name (the host's chosen label).
public let name: String
/// Resolved address to hand to `PunktfunkConnection`.
public let host: String
public let port: UInt16
/// Host cert SHA-256 (lowercase hex) the host advertised, or nil if absent.
public let fingerprintHex: String?
/// The host advertised `pair=required` — a client must pair before it can stream.
public let requiresPairing: Bool
/// The host EXPLICITLY advertised `pair=optional` — only then may the client offer the
/// reduced-security TOFU "Trust" path. A missing/unknown `pair` field is NOT optional:
/// pairing is mandatory unless this is true (the policy authority is the host's advert).
public let allowsTofu: Bool
/// Wake-on-LAN MAC address(es) the host advertised (mDNS `mac` TXT, comma-separated
/// `aa:bb:cc:dd:ee:ff`, routed NIC first). Empty when not advertised. A client persists these
/// onto the saved host so it can wake it after it sleeps; advisory/unauthenticated (a wrong
/// value only makes a wake fail — the magic packet is inert and the fingerprint still gates
/// the connection).
public let macAddresses: [String]
/// The host's OS-identity chain (mDNS `os` TXT, e.g. `linux/fedora/bazzite`), sanitized
/// (`sanitizeOsChain`) — drives the host card's OS mark and is persisted like the MACs.
/// Empty when not advertised (older host). Advisory/unauthenticated like the rest.
public let osChain: String
}
@MainActor
public final class HostDiscovery: ObservableObject {
/// Currently-visible hosts, deduped by `id`, sorted by name. Main-actor.
@Published public private(set) var hosts: [DiscoveredHost] = []
/// True for a moment after a rescan is kicked off, so a Refresh control can show that it did
/// something on the surfaces with no pull-to-refresh spinner of their own (macOS, tvOS).
@Published public private(set) var isScanning = false
private var browser: NWBrowser?
/// Every service the browser currently reports, keyed by the endpoint's description (a stable,
/// Sendable handle we can capture into the resolve callbacks without smuggling non-Sendable
/// Network types across hops). Held — not just diffed — so a retry can re-resolve a service
/// the browser will never report again (see the file header).
private var services: [String: NWBrowser.Result] = [:]
/// The transport address a completed resolve produced, per service key. The rest of a
/// `DiscoveredHost` comes from the advert's TXT, which is re-read on every browse report.
private var addresses: [String: (host: String, port: UInt16)] = [:]
private var connections: [String: NWConnection] = [:]
/// Deadline for each in-flight resolve — `NWConnection` has none of its own.
private var deadlines: [String: Date] = [:]
/// Consecutive failed resolves per service, and when the next attempt is allowed.
private var failures: [String: Int] = [:]
private var retryAt: [String: Date] = [:]
/// Services whose address should be re-resolved even though we already have one — set by
/// `refresh()`. The old address keeps showing until the new one lands, so a rescan never
/// blinks the list empty; without this a manual Refresh silently skipped every host it had
/// already resolved, which is exactly the host whose address may have moved.
private var staleAddresses: Set<String> = []
/// Consecutive non-ready browser states, and when to tear it down and re-arm. nil = healthy.
private var browserFailures = 0
private var browserRearmAt: Date?
/// Bumped on every re-arm so callbacks from a superseded browser — and from the resolves it
/// started — are ignored instead of clobbering the current generation's bookkeeping.
private var generation = 0
/// The 1 Hz maintenance tick. Nothing else re-drives a stuck resolve or a sick browser.
private var sweep: Task<Void, Never>?
private var scanningUntil: Date?
/// A LAN resolve answers in milliseconds; this only has to outlast a slow Wi-Fi wake.
private static let resolveTimeout: TimeInterval = 6
/// How long `isScanning` holds — and `rescan()` waits — after a manual refresh.
private static let scanSettle: TimeInterval = 1.5
/// 1s, 2s, 4s, 8s … capped at 30s, for the resolve retry and the browser re-arm alike. Long
/// enough that a genuinely-down network doesn't spin the main queue, short enough that a host
/// coming back is picked up while the user is still looking at the screen.
private static func backoff(_ failures: Int) -> TimeInterval {
min(pow(2, Double(max(0, failures - 1))), 30)
}
public init() {}
/// Start browsing `_punktfunk._udp`. Idempotent — a second call while live is a no-op.
public func start() {
#if DEBUG
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
#endif
guard browser == nil else { return }
armBrowser()
startSweep()
}
/// Stop browsing and drop all discovered state.
public func stop() {
sweep?.cancel()
sweep = nil
generation &+= 1
browser?.cancel()
browser = nil
for conn in connections.values { conn.cancel() }
connections.removeAll()
deadlines.removeAll()
services.removeAll()
addresses.removeAll()
failures.removeAll()
retryAt.removeAll()
staleAddresses.removeAll()
browserFailures = 0
browserRearmAt = nil
scanningUntil = nil
if isScanning { isScanning = false }
if !hosts.isEmpty { hosts = [] }
}
/// Force a rescan now: re-arm the browser and retry every service whose resolve had failed,
/// clearing the backoffs so nothing is left waiting. This is the manual escape hatch for the
/// failure modes in the file header — and the only thing that clears the iOS local-network
/// permission gate without an app restart, since only a NEW browser sees a permission the
/// user granted after the old one started.
///
/// Also starts discovery if it wasn't running, so a Refresh button does the obvious thing.
public func refresh() {
#if DEBUG
guard !debugPinned else { return } // as in `start()` — the harness's set is the truth
#endif
isScanning = true
scanningUntil = Date().addingTimeInterval(Self.scanSettle)
failures.removeAll()
retryAt.removeAll()
staleAddresses = Set(services.keys)
browserFailures = 0
armBrowser()
startSweep()
pump()
}
/// `refresh()` for a `.refreshable` gesture: holds briefly so the control's spinner reflects a
/// browse that had time to answer instead of blinking out instantly.
public func rescan() async {
refresh()
try? await Task.sleep(nanoseconds: UInt64(Self.scanSettle * 1_000_000_000))
}
/// `refresh()`, but only when discovery is already running — the app-foreground hook. iOS
/// suspends a backgrounded process's browse and `onAppear`/`onDisappear` don't fire across
/// background/foreground, so a browse that died while suspended stayed dead on return; this
/// re-arms it without starting a browse on a screen that deliberately isn't browsing
/// (mid-session, where the home tore discovery down).
public func refreshIfRunning() {
guard browser != nil else { return }
refresh()
}
deinit {
sweep?.cancel()
browser?.cancel()
for conn in connections.values { conn.cancel() }
}
#if DEBUG
/// A seeded advert set is in force — `start()` must not replace it with the live browse.
private var debugPinned = false
/// Screenshot/preview seam, the discovery counterpart to `HostWaker.debugSet`: publish a FIXED
/// set of adverts and keep browsing off. Without it a capture shows whatever happens to be on
/// the machine's LAN — the App Store screenshots shipped a stranger's hostname more than once —
/// and every mock host reads Offline because nothing advertises it.
public func debugSet(_ adverts: [DiscoveredHost]) {
stop()
debugPinned = true
hosts = adverts
}
/// Builds one advert. `DiscoveredHost`'s memberwise init is internal (a public struct's is), and
/// making it public would expose a wire-shaped model's construction to every consumer just to
/// serve the harness.
public static func debugAdvert(
id: String, name: String, host: String, port: UInt16 = 9777,
fingerprintHex: String? = nil, requiresPairing: Bool = false, allowsTofu: Bool = true,
macAddresses: [String] = [], osChain: String = ""
) -> DiscoveredHost {
DiscoveredHost(
id: id, name: name, host: host, port: port, fingerprintHex: fingerprintHex,
requiresPairing: requiresPairing, allowsTofu: allowsTofu,
macAddresses: macAddresses, osChain: osChain)
}
#endif
// MARK: - Browser
/// Build and start a fresh browser, retiring the previous one and every resolve it started.
/// Those resolves' callbacks are gated on `generation`, so they must not be left holding map
/// entries — `pump()` restarts them against the new generation.
private func armBrowser() {
generation &+= 1
browser?.cancel()
for conn in connections.values { conn.cancel() }
connections.removeAll()
deadlines.removeAll()
browserRearmAt = nil
let generation = self.generation
let browser = NWBrowser(
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
using: NWParameters())
browser.browseResultsChangedHandler = { results, _ in
MainActor.assumeIsolated { [weak self] in
guard let self, generation == self.generation else { return }
self.reconcile(results)
}
}
browser.stateUpdateHandler = { state in
MainActor.assumeIsolated { [weak self] in
guard let self, generation == self.generation else { return }
self.browserStateChanged(state)
}
}
self.browser = browser
browser.start(queue: .main)
}
/// A browser that stops working never recovers on its own, and it has two ways to stop:
/// `.failed` (dead) and `.waiting` (blocked — a network change, or the iOS local-network
/// permission gate described in the file header). Schedule a re-arm for both, on a backoff:
/// re-arming synchronously on `.failed` alone both missed the permission case entirely and
/// could spin the main queue on a browser that fails instantly every time.
private func browserStateChanged(_ state: NWBrowser.State) {
switch state {
case .ready:
browserFailures = 0
browserRearmAt = nil
case .failed, .waiting:
guard browserRearmAt == nil else { return } // one re-arm already scheduled
browserFailures += 1
browserRearmAt = Date().addingTimeInterval(Self.backoff(browserFailures))
default:
break // .setup / .cancelled — nothing to heal
}
}
/// Diff the browser's current result set against what we're tracking: drop departed services,
/// record the rest — re-reading the advert every time, so a host that re-keys, moves or flips
/// its pairing policy republishes under the same name and the card follows it — then resolve
/// whatever still needs an address.
private func reconcile(_ results: Set<NWBrowser.Result>) {
var live: Set<String> = []
for result in results {
let key = Self.key(result)
live.insert(key)
services[key] = result
}
for key in Array(services.keys) where !live.contains(key) { forget(key) }
publish()
pump()
}
private func forget(_ key: String) {
connections[key]?.cancel()
connections[key] = nil
deadlines[key] = nil
services[key] = nil
addresses[key] = nil
failures[key] = nil
retryAt[key] = nil
staleAddresses.remove(key)
}
// MARK: - Resolve
/// Start the resolves that are due: every live service with no address yet, nothing in flight,
/// and past its retry time.
private func pump() {
let now = Date()
for (key, result) in services {
guard addresses[key] == nil || staleAddresses.contains(key) else { continue }
guard connections[key] == nil else { continue }
if let at = retryAt[key], at > now { continue }
resolve(key, result)
}
}
/// Resolve one service to IP:port via a short UDP connection (it reaches `.ready` once the
/// path is established — no data is sent). The TXT is NOT read here: it comes from the browse
/// result at publish time, so a re-advertised host doesn't need a fresh resolve to be re-read.
private func resolve(_ key: String, _ result: NWBrowser.Result) {
// 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
deadlines[key] = Date().addingTimeInterval(Self.resolveTimeout)
let generation = self.generation
conn.stateUpdateHandler = { state in
MainActor.assumeIsolated { [weak self] in
// Look the connection back up rather than capturing it — capturing it here would
// retain the connection through its own handler.
guard let self, generation == self.generation,
let conn = self.connections[key] else { return }
switch state {
case .ready:
let endpoint = conn.currentPath?.remoteEndpoint
self.connections[key] = nil
self.deadlines[key] = nil
conn.cancel()
if case let .hostPort(host, port)? = endpoint,
let address = Self.hostString(host) {
self.addresses[key] = (address, port.rawValue)
self.failures[key] = nil
self.retryAt[key] = nil
self.staleAddresses.remove(key)
self.publish()
} else {
// Ready but no usable remote — a failed attempt, not a finished one.
self.resolveFailed(key)
}
case .failed, .cancelled:
self.connections[key] = nil
self.deadlines[key] = nil
self.resolveFailed(key)
default:
break // .preparing / .waiting — the sweep's deadline is what ends these
}
}
}
conn.start(queue: .main)
}
private func resolveFailed(_ key: String) {
let count = (failures[key] ?? 0) + 1
failures[key] = count
retryAt[key] = Date().addingTimeInterval(Self.backoff(count))
}
// MARK: - Sweep
private func startSweep() {
sweep?.cancel()
sweep = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled, let self else { return }
self.tick()
}
}
}
private func tick() {
let now = Date()
// Time out the resolves that parked. Without this they never end, and `pump()` skips a
// service that has a connection in flight — so that host stayed invisible indefinitely.
for key in deadlines.filter({ $0.value <= now }).keys {
connections[key]?.cancel()
connections[key] = nil
deadlines[key] = nil
resolveFailed(key)
}
if let at = browserRearmAt, at <= now { armBrowser() }
pump()
if let until = scanningUntil, until <= now {
scanningUntil = nil
isScanning = false
}
}
// MARK: - Publish
/// Publish the live adverts that have an address, deduped by `id` (a host on several
/// interfaces / re-advertising collapses to one row), sorted by name.
private func publish() {
var byID: [String: DiscoveredHost] = [:]
for key in services.keys.sorted() {
guard let result = services[key], let address = addresses[key] else { continue }
let host = Self.host(from: result, address: address.host, port: address.port)
byID[host.id] = host
}
let next = byID.values.sorted {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
if next != hosts { hosts = next }
}
/// Join a browse result's advert (instance name + TXT) to a resolved address.
private static func host(
from result: NWBrowser.Result, address: String, port: UInt16
) -> DiscoveredHost {
let name = instanceName(result.endpoint)
var fp: String?
var pair: String?
var id: String?
var macs: [String] = []
var osChain = ""
if case let .bonjour(txt) = result.metadata {
fp = entry(txt, "fp")
pair = entry(txt, "pair")
id = entry(txt, "id")
macs = (entry(txt, "mac") ?? "")
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
osChain = sanitizeOsChain(entry(txt, "os") ?? "")
}
return DiscoveredHost(
id: (id?.isEmpty == false) ? id! : name,
name: name, host: address, port: port,
fingerprintHex: fp, requiresPairing: pair == "required",
allowsTofu: pair == "optional", macAddresses: macs,
osChain: osChain)
}
private static func key(_ result: NWBrowser.Result) -> String {
"\(result.endpoint)"
}
private static func instanceName(_ endpoint: NWEndpoint) -> String {
if case let .service(name, _, _, _) = endpoint { return name }
return "Punktfunk host"
}
private static func entry(_ txt: NWTXTRecord, _ field: String) -> String? {
if case let .string(value) = txt.getEntry(for: field), !value.isEmpty { return value }
return nil
}
/// A resolved `NWEndpoint.Host` → a plain address string for `PunktfunkConnection` (the
/// scope id on a link-local address is stripped — the host+port pair is resolved again on
/// the Rust side, which can't parse the `%iface` suffix).
private static func hostString(_ host: NWEndpoint.Host) -> String? {
switch host {
case .ipv4(let address):
return "\(address)".split(separator: "%").first.map(String.init)
case .ipv6(let address):
return "\(address)".split(separator: "%").first.map(String.init)
case .name(let name, _):
return name
@unknown default:
return nil
}
}
}
#endif