ci / web (pull_request) Successful in 1m4s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 1m32s
ci / docs-site (pull_request) Successful in 4m16s
android / android (pull_request) Successful in 6m25s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 7m14s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 3m30s
ci / rust (pull_request) Successful in 15m13s
A field report from an iPad: the host is not found on first run, and
restarting the client finds it. Pull-to-refresh appeared to do nothing.
Both were real. The Apple client's discovery had three ways to go
permanently deaf, each needing an app relaunch to clear:
- A failed resolve was never retried. `browseResultsChangedHandler`
only fires when the result SET changes, and a host whose resolve
failed is still in the set — so nothing ever re-offered it.
- A stuck resolve never ended. `NWConnection` has no timeout, so the
throwaway UDP flow used to resolve an address could sit in
`.preparing`/`.waiting` forever, and a service with a connection in
flight was skipped.
- `NWBrowser` parking in `.waiting` was ignored (only `.failed`
re-armed). On iOS that is where the local-network privacy prompt
lands on first launch after install: the browse starts, the system
asks, and the browser waits. Granting does not revive that browser —
only a new one sees the grant. That is the reported first-run bug.
HostDiscovery now runs a 1 Hz sweep that times out stuck resolves,
retries failed ones on a 1→30 s backoff, and re-arms a browser that
stopped working; the advert's TXT is re-read on every browse report, so
a host that re-keys or flips its pairing policy is followed. Returning
to the foreground re-arms the browse (iOS/tvOS: `onAppear` does not
fire across background/foreground, and a suspended browse stays dead).
Pull-to-refresh did nothing because there was no `.refreshable` in the
client at all. Added, plus the explicit control the report asked for:
a toolbar Refresh on iOS/macOS, an action-row button on tvOS, a Rescan
tile in the gamepad launcher, Scan Again on the empty state, a
header-bar button in the GTK client, a hosts-page button on Windows,
and Scan again on Android. Decky already had one.
The desktop/Android browses needed a rescan trigger to make those
buttons mean anything: mdns-sd re-queries on a doubling backoff capped
at ONE HOUR, so a long-lived browse is effectively passive and a host
that appears later can stay invisible. `discovery::Rescan` forces a
fresh query; the wake-and-wait loops use it too, so a host that just
booted is noticed in seconds rather than at the next backoff tick.
Also fixed, found on the way: clients/windows/src/discovery.rs is a
second copy of the browse that d0fa8bd3 ("pin mDNS discovery to IPv4 on
every client") missed. It took an arbitrary first address, so when a
host's OS responder answered AAAA the Windows GUI rendered a card that
failed on every click. It also never noticed a dropped receiver, leaking
a thread and a :5353 socket per wake-and-wait.
Gates: Apple macOS + iOS (arm64-apple-ios17.0, proven non-vacuous) build
clean, 195 tests pass incl. a new one asserting a rescan re-finds a
still-advertising host. On .21: fmt, clippy --all-targets -D warnings
and build clean for pf-client-core + client-linux + client-session,
117 tests pass. Android :kit: and :app: compileDebugKotlin clean.
The Windows client is UNGATED — its CI runner was unreachable.
71 lines
3.2 KiB
Swift
71 lines
3.2 KiB
Swift
// Advertise a fake punktfunk/1 host over real mDNS (NWListener) and assert HostDiscovery's
|
|
// NWBrowser finds it, resolves an address+port, and parses the TXT (id / pair / fp). This
|
|
// exercises the whole client discovery path on the loopback/LAN; it self-skips if the test
|
|
// environment blocks Bonjour (sandboxed CI without local-network access).
|
|
|
|
import Network
|
|
import PunktfunkKit
|
|
import XCTest
|
|
|
|
final class HostDiscoveryTests: XCTestCase {
|
|
func testFindsAdvertisedHost() async throws {
|
|
let serviceName = "PunktfunkTest-\(UUID().uuidString.prefix(8))"
|
|
let uniqueid = "test-\(UUID().uuidString)"
|
|
|
|
var txt = NWTXTRecord()
|
|
txt["proto"] = "punktfunk/1"
|
|
txt["fp"] = String(repeating: "ab", count: 32) // 64 hex chars, like a real cert SHA-256
|
|
txt["pair"] = "required"
|
|
txt["id"] = uniqueid
|
|
|
|
let listener = try NWListener(using: .udp)
|
|
listener.service = NWListener.Service(
|
|
name: String(serviceName), type: "_punktfunk._udp", txtRecord: txt)
|
|
// The resolver opens a throwaway UDP flow to read the resolved endpoint — accept and
|
|
// drop it so it doesn't linger.
|
|
listener.newConnectionHandler = { connection in connection.cancel() }
|
|
listener.start(queue: .global())
|
|
defer { listener.cancel() }
|
|
|
|
let discovery = await HostDiscovery()
|
|
await discovery.start()
|
|
defer { Task { await discovery.stop() } }
|
|
|
|
// Poll up to ~10s for the advert to be browsed AND resolved.
|
|
var found: DiscoveredHost?
|
|
let deadline = Date().addingTimeInterval(10)
|
|
while Date() < deadline {
|
|
if let host = await discovery.hosts.first(where: { $0.name == String(serviceName) }) {
|
|
found = host
|
|
break
|
|
}
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
}
|
|
|
|
guard let host = found else {
|
|
throw XCTSkip("mDNS discovery unavailable in this environment (no local network).")
|
|
}
|
|
XCTAssertEqual(host.id, uniqueid, "the stable mDNS id should key the host")
|
|
XCTAssertTrue(host.requiresPairing, "pair=required must surface as requiresPairing")
|
|
XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32))
|
|
XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect")
|
|
XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect")
|
|
|
|
// A rescan tears the browser down and re-arms it (the only way past the iOS local-network
|
|
// permission gate without relaunching). The host must come BACK — `refresh()` cancels every
|
|
// in-flight resolve and invalidates the previous generation's callbacks, so a re-arm that
|
|
// failed to re-drive them would leave the list permanently empty.
|
|
await discovery.rescan()
|
|
var reappeared = false
|
|
let rescanDeadline = Date().addingTimeInterval(10)
|
|
while Date() < rescanDeadline {
|
|
if await discovery.hosts.contains(where: { $0.id == uniqueid }) {
|
|
reappeared = true
|
|
break
|
|
}
|
|
try await Task.sleep(nanoseconds: 200_000_000)
|
|
}
|
|
XCTAssertTrue(reappeared, "a rescan must re-find a host that is still advertising")
|
|
}
|
|
}
|