Merge pull request 'fix(clients): host discovery heals itself, and every client can rescan' (#67) from worktree-host-discovery-refresh into main
ci / web (push) Successful in 1m14s
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m20s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
ci / rust-arm64 (push) Successful in 6m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 17s
docker / builders-arm64cross (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Canceled after 8m10s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Successful in 8m22s
ci / rust (push) Canceled after 8m34s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m13s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 7m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m13s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m37s
flatpak / build-publish (push) Failing after 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 15m37s
ci / web (push) Successful in 1m14s
apple / swift (push) Successful in 1m26s
ci / docs-site (push) Successful in 1m20s
deb / build-publish (push) Successful in 3m53s
deb / build-publish-host (push) Successful in 4m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 12s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m54s
ci / rust-arm64 (push) Successful in 6m58s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Failing after 17s
docker / builders-arm64cross (push) Skipped
deb / build-publish-client-arm64 (push) Successful in 2m33s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Canceled after 8m10s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Successful in 8m22s
ci / rust (push) Canceled after 8m34s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m13s
docker / deploy-docs (push) Canceled after 0s
release / apple (push) Canceled after 7m29s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 1m13s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m37s
flatpak / build-publish (push) Failing after 11m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 13m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 15m37s
Reviewed-on: #67
This commit was merged in pull request #67.
This commit is contained in:
@@ -168,8 +168,7 @@ fun ConnectScreen(
|
||||
lnpPrompt = false
|
||||
// The browse started while blocked (its sockets failed or received nothing) — restart it
|
||||
// now that the grant makes them work.
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
discovery.restart()
|
||||
} else {
|
||||
lnpPrompt = true // rationale + "Open settings" (a permanently-denied request returns instantly)
|
||||
}
|
||||
@@ -191,12 +190,27 @@ fun ConnectScreen(
|
||||
// or otherwise notify the app — this observer is what turns the grant into a live discovery.
|
||||
DisposableEffect(Unit) {
|
||||
val lifecycle = (context as? LifecycleOwner)?.lifecycle
|
||||
// Whether we've actually been away. ON_RESUME also fires on first entry, right after the
|
||||
// effect below starts the browse — restarting it there would be pure churn.
|
||||
var wasPaused = false
|
||||
val obs = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME && !lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.stop()
|
||||
discovery.start()
|
||||
when (event) {
|
||||
Lifecycle.Event.ON_PAUSE -> wasPaused = true
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
if (!lnpGranted && hasLocalNetworkPermission(context)) {
|
||||
lnpGranted = true
|
||||
lnpPrompt = false
|
||||
discovery.restart()
|
||||
} else if (wasPaused) {
|
||||
// Coming back from the background: the browse may have been sitting idle
|
||||
// (or had its multicast socket torn out from under it) while we were away,
|
||||
// and its own re-query interval has kept doubling. Re-arm and ask again,
|
||||
// so returning to the screen is enough — no app restart.
|
||||
discovery.restart()
|
||||
}
|
||||
wasPaused = false
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycle?.addObserver(obs)
|
||||
@@ -1009,20 +1023,28 @@ fun ConnectScreen(
|
||||
// rather than looking idle/empty. Suppressed while local network access is denied —
|
||||
// a spinner would be a lie there (the browse can't receive anything); the banner above
|
||||
// owns that state.
|
||||
if (lnpGranted && !connecting && discovered.isEmpty()) {
|
||||
// Scan again is offered whether or not anything turned up: the case that sends people
|
||||
// here is ONE expected host missing, not an empty list, and a browse that quietly went
|
||||
// deaf (blocked when it started, or backed off to its hour-long re-query) looks
|
||||
// exactly like a network without that host on it.
|
||||
if (lnpGranted && !connecting) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
if (discovered.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"Searching the local network…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
TextButton(onClick = { discovery.restart() }) { Text("Scan again") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,27 @@ class HostDiscovery(context: Context) {
|
||||
handler.post(poll)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear the browse down and start a fresh one. This is the manual rescan, and the recovery path
|
||||
* for a browse that started while blocked (permission not yet granted, multicast filtered) or
|
||||
* that never started at all ([start] gives up when `nativeDiscoveryStart` returns 0, and
|
||||
* nothing else would ever retry it).
|
||||
*
|
||||
* It also puts a query back on the wire: `mdns-sd` re-queries on a doubling backoff that caps
|
||||
* at an hour, so a long-lived browse is effectively passive — a host that appeared since, or
|
||||
* whose announcement was lost to multicast, may never be asked for again.
|
||||
*
|
||||
* The currently-shown host set is left alone across the swap (rather than blinking empty via
|
||||
* [stop]'s notification); the first poll of the new browse publishes the fresh set.
|
||||
*/
|
||||
fun restart() {
|
||||
val keep = onChange
|
||||
onChange = null
|
||||
stop()
|
||||
onChange = keep
|
||||
start()
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!running && nativeHandle == 0L) return
|
||||
running = false
|
||||
|
||||
@@ -206,6 +206,20 @@ struct ContentView: View {
|
||||
model.setStatsVerbosity(StatsVerbosity(rawValue: raw) ?? .normal)
|
||||
}
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Coming back to the app re-arms the LAN browse. The home's `onAppear`/`onDisappear` do
|
||||
// NOT fire across background/foreground, and a browse the system suspended while we were
|
||||
// away does not resume on its own — so the host grid came back empty and stayed empty
|
||||
// until the app was relaunched. No-op unless the browse is already running (mid-session
|
||||
// the home has deliberately torn it down).
|
||||
//
|
||||
// Mobile only: macOS never suspends the process, and its `scenePhase` flips on every
|
||||
// window focus change — re-arming there would rebuild the browser each time you alt-tab.
|
||||
// A Mac browse that genuinely breaks is caught by `HostDiscovery`'s own sweep instead.
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
if phase == .active { discovery.refreshIfRunning() }
|
||||
}
|
||||
#endif
|
||||
#if os(iOS) || os(tvOS)
|
||||
// Backgrounding driver. Only .background/.active matter; .inactive (a transient peek) is
|
||||
// ignored so neither branch fires for a Control-Center pull.
|
||||
//
|
||||
|
||||
@@ -23,14 +23,15 @@ import SwiftUI
|
||||
#if os(iOS) || os(macOS) || os(tvOS)
|
||||
import GameController
|
||||
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||
/// action. Hashable so it can be the carousel's scroll-position identity.
|
||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing
|
||||
/// actions. Hashable so it can be the carousel's scroll-position identity.
|
||||
private enum GamepadHomeTarget: Hashable {
|
||||
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a
|
||||
/// controller-first surface are THE profile affordance: focus and press, no menus.
|
||||
case saved(UUID, profile: String?)
|
||||
case discovered(String)
|
||||
case addHost
|
||||
case rescan
|
||||
}
|
||||
|
||||
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
|
||||
@@ -262,10 +263,14 @@ struct GamepadHomeView: View {
|
||||
|
||||
private var hints: [GamepadHint] {
|
||||
let selected = tiles.first { $0.id == selection }
|
||||
let action: String? = switch selected?.id {
|
||||
case .addHost: "Add Host"
|
||||
case .rescan: "Rescan"
|
||||
default: nil
|
||||
}
|
||||
var hints = [GamepadHint(
|
||||
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
|
||||
text: selected?.id == .addHost ? "Add Host"
|
||||
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
|
||||
if libraryEnabled, selected?.hasLibrary == true {
|
||||
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
|
||||
}
|
||||
@@ -325,7 +330,15 @@ struct GamepadHomeView: View {
|
||||
subtitle: "Register a host by address",
|
||||
icon: "plus",
|
||||
activate: { showAddHost = true })
|
||||
return saved + discovered + [add]
|
||||
// A controller surface has no toolbar and no pull-to-refresh, so the rescan the field
|
||||
// asked for is a tile like any other — one press from wherever the stick already is.
|
||||
let rescan = HomeTile(
|
||||
id: .rescan,
|
||||
title: "Rescan",
|
||||
subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network",
|
||||
icon: "arrow.clockwise",
|
||||
activate: { discovery.refresh() })
|
||||
return saved + discovered + [add, rescan]
|
||||
}
|
||||
|
||||
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
|
||||
|
||||
@@ -53,7 +53,18 @@ struct HomeView: View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
#if os(tvOS)
|
||||
emptyState // no pull-to-refresh on a remote; the action row carries Refresh
|
||||
#else
|
||||
// Inside a ScrollView purely so the pull gesture works on the ONE screen
|
||||
// where a rescan matters most: the one that found nothing.
|
||||
ScrollView {
|
||||
emptyState
|
||||
.frame(maxWidth: .infinity)
|
||||
.containerRelativeFrame(.vertical)
|
||||
}
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
} else {
|
||||
ScrollView {
|
||||
if !store.hosts.isEmpty {
|
||||
@@ -94,6 +105,7 @@ struct HomeView: View {
|
||||
} label: {
|
||||
Label("Settings", systemImage: "gearshape")
|
||||
}
|
||||
refreshButton
|
||||
}
|
||||
.padding(.top, 24)
|
||||
// One FULL-WIDTH focus target for any downward move out of the grid.
|
||||
@@ -106,6 +118,9 @@ struct HomeView: View {
|
||||
.focusSection()
|
||||
#endif
|
||||
}
|
||||
#if !os(tvOS)
|
||||
.refreshable { await discovery.rescan() }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunk")
|
||||
@@ -151,6 +166,7 @@ struct HomeView: View {
|
||||
if showsArrangeMenu {
|
||||
ToolbarItem(placement: .topBarTrailing) { arrangeMenu }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { refreshButton }
|
||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||
#else
|
||||
if showsArrangeMenu {
|
||||
@@ -159,6 +175,10 @@ struct HomeView: View {
|
||||
.help("Sort and group the host list")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
refreshButton
|
||||
.help("Scan the network for hosts again")
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
addHostButton
|
||||
.help("Add a host")
|
||||
@@ -324,13 +344,20 @@ struct HomeView: View {
|
||||
ContentUnavailableView {
|
||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||
} description: {
|
||||
Text("Add your punktfunk host with the + button.")
|
||||
Text("Add your punktfunk host with the + button, or scan the network again.")
|
||||
} actions: {
|
||||
Button("Add Host") { showAddHost = true }
|
||||
.glassProminentButtonStyle()
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
// The screen a host SHOULD have appeared on is where a rescan is worth offering
|
||||
// outright rather than hiding behind a pull gesture.
|
||||
Button("Scan Again") { discovery.refresh() }
|
||||
.disabled(discovery.isScanning)
|
||||
#if os(iOS)
|
||||
.controlSize(.large)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
Button("Settings") { showSettings = true }
|
||||
#endif
|
||||
@@ -345,6 +372,18 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-run mDNS discovery from scratch. Discovery heals itself now (`HostDiscovery`'s sweep),
|
||||
/// so this is the fallback the field asked for — and the fastest way past the iOS
|
||||
/// local-network permission gate, which only a NEW browser can clear.
|
||||
private var refreshButton: some View {
|
||||
Button {
|
||||
discovery.refresh()
|
||||
} label: {
|
||||
Label("Refresh", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(discovery.isScanning)
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// One host has no order and nothing to divide, so the control stays out of the way until
|
||||
/// there is a list to arrange.
|
||||
|
||||
@@ -9,6 +9,25 @@
|
||||
//
|
||||
// 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
|
||||
@@ -48,12 +67,50 @@ public struct DiscoveredHost: Identifiable, Sendable, Equatable {
|
||||
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?
|
||||
/// Keyed by the service endpoint's description (a stable, Sendable handle we can capture
|
||||
/// into the resolve callbacks without smuggling non-Sendable Network types across hops).
|
||||
private var resolved: [String: DiscoveredHost] = [:]
|
||||
/// 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() {}
|
||||
|
||||
@@ -63,34 +120,73 @@ public final class HostDiscovery: ObservableObject {
|
||||
guard !debugPinned else { return } // a seeded advert set outranks the live LAN
|
||||
#endif
|
||||
guard browser == nil else { return }
|
||||
let browser = NWBrowser(
|
||||
for: .bonjourWithTXTRecord(type: "_punktfunk._udp", domain: nil),
|
||||
using: NWParameters())
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.reconcile(results) }
|
||||
}
|
||||
browser.stateUpdateHandler = { state in
|
||||
// A failed browser never recovers on its own; tear down and re-arm so transient
|
||||
// network changes (Wi-Fi flip, VPN) don't leave discovery silently dead.
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
if case .failed = state { self?.restart() }
|
||||
}
|
||||
}
|
||||
self.browser = browser
|
||||
browser.start(queue: .main)
|
||||
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()
|
||||
resolved.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() }
|
||||
}
|
||||
@@ -124,48 +220,103 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
#endif
|
||||
|
||||
private func restart() {
|
||||
stop()
|
||||
start()
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Diff the browser's current result set against what we're tracking: drop departed
|
||||
/// services, resolve newly-seen ones.
|
||||
private func reconcile(_ results: Set<NWBrowser.Result>) {
|
||||
let live = Set(results.map { Self.key($0) })
|
||||
for key in resolved.keys where !live.contains(key) { resolved[key] = nil }
|
||||
for key in connections.keys where !live.contains(key) {
|
||||
connections[key]?.cancel()
|
||||
connections[key] = nil
|
||||
/// 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)
|
||||
if resolved[key] == nil, connections[key] == nil { resolve(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), reading the TXT up front so the callback only
|
||||
/// captures Sendable values + the endpoint key.
|
||||
private func resolve(_ result: NWBrowser.Result) {
|
||||
let key = Self.key(result)
|
||||
let name = Self.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 = Self.entry(txt, "fp")
|
||||
pair = Self.entry(txt, "pair")
|
||||
id = Self.entry(txt, "id")
|
||||
macs = (Self.entry(txt, "mac") ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
osChain = sanitizeOsChain(Self.entry(txt, "os") ?? "")
|
||||
}
|
||||
/// 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
|
||||
@@ -177,44 +328,125 @@ public final class HostDiscovery: ObservableObject {
|
||||
}
|
||||
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
|
||||
guard let self, let conn = self.connections[key] else { return }
|
||||
// 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:
|
||||
if case let .hostPort(host, port)? = conn.currentPath?.remoteEndpoint,
|
||||
let address = Self.hostString(host) {
|
||||
self.resolved[key] = DiscoveredHost(
|
||||
id: (id?.isEmpty == false) ? id! : name,
|
||||
name: name, host: address, port: port.rawValue,
|
||||
fingerprintHex: fp, requiresPairing: pair == "required",
|
||||
allowsTofu: pair == "optional", macAddresses: macs,
|
||||
osChain: osChain)
|
||||
self.publish()
|
||||
}
|
||||
conn.cancel()
|
||||
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
|
||||
break // .preparing / .waiting — the sweep's deadline is what ends these
|
||||
}
|
||||
}
|
||||
}
|
||||
conn.start(queue: .main)
|
||||
}
|
||||
|
||||
/// Publish the resolved set, deduped by `id` (a host on several interfaces / re-advertising
|
||||
/// collapses to one row), sorted by name.
|
||||
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 host in resolved.values { byID[host.id] = host }
|
||||
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)"
|
||||
}
|
||||
|
||||
@@ -50,5 +50,21 @@ final class HostDiscoveryTests: XCTestCase {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,6 +674,9 @@ pub struct HostsPage {
|
||||
saved: FactoryVecDeque<HostCard>,
|
||||
discovered: FactoryVecDeque<HostCard>,
|
||||
widgets: PageWidgets,
|
||||
/// Forces the mDNS browse to re-query (the header's Refresh button). `None` only if the
|
||||
/// browse never started — the button then just re-renders, which is what it did before.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
struct PageWidgets {
|
||||
@@ -693,6 +696,10 @@ pub enum HostsMsg {
|
||||
},
|
||||
/// Reload the disk store and re-render (fresh pairings, renames, the library gate).
|
||||
Refresh,
|
||||
/// Re-query mDNS *and* re-render — the header's Refresh button. Distinct from [`Self::Refresh`],
|
||||
/// which only re-reads local state: after a while `mdns-sd` re-queries about once an hour, so a
|
||||
/// host that appeared since (or whose announcement was lost) needs an actual query to show up.
|
||||
Rescan,
|
||||
/// A completed reachability sweep: saved-host key → reachable. Merged into the online pips.
|
||||
Probed(HashMap<String, bool>),
|
||||
/// Mark the card matching `ConnectRequest::card_key` as connecting; `None` restores.
|
||||
@@ -841,6 +848,13 @@ impl SimpleComponent for HostsPage {
|
||||
add_host_btn.set_tooltip_text(Some("Add host"));
|
||||
add_host_btn.set_action_name(Some("win.add-host"));
|
||||
header.pack_start(&add_host_btn);
|
||||
let rescan_btn = gtk::Button::from_icon_name("view-refresh-symbolic");
|
||||
rescan_btn.set_tooltip_text(Some("Scan the network for hosts again"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
rescan_btn.connect_clicked(move |_| sender.input(HostsMsg::Rescan));
|
||||
}
|
||||
header.pack_start(&rescan_btn);
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Preferences"), Some("win.preferences"));
|
||||
menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts"));
|
||||
@@ -867,8 +881,8 @@ impl SimpleComponent for HostsPage {
|
||||
}
|
||||
|
||||
// Stream mDNS adverts into the model; every add/remove re-evaluates both grids.
|
||||
let (rx, rescan) = discovery::browse();
|
||||
{
|
||||
let rx = discovery::browse();
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
@@ -937,6 +951,7 @@ impl SimpleComponent for HostsPage {
|
||||
disc_heading,
|
||||
searching,
|
||||
},
|
||||
rescan: Some(rescan),
|
||||
};
|
||||
model.rebuild();
|
||||
|
||||
@@ -954,6 +969,14 @@ impl SimpleComponent for HostsPage {
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Refresh => self.rebuild(),
|
||||
HostsMsg::Rescan => {
|
||||
if let Some(rescan) = &self.rescan {
|
||||
rescan.request();
|
||||
}
|
||||
// Adverts stream in as they answer; re-render now so the local half is current
|
||||
// either way.
|
||||
self.rebuild();
|
||||
}
|
||||
HostsMsg::Probed(map) => {
|
||||
self.probed = map;
|
||||
self.rebuild();
|
||||
|
||||
@@ -46,8 +46,13 @@ pub fn wake_and_connect(
|
||||
let sender = sender.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
use std::time::Duration;
|
||||
let events = crate::discovery::browse();
|
||||
let (events, rescan) = crate::discovery::browse();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told. Every 5th tick: often enough that a
|
||||
// host that came up is noticed promptly, rare enough not to hammer multicast.
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
if cancel.get() {
|
||||
waiting.close();
|
||||
@@ -100,6 +105,10 @@ pub fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
rescan.request();
|
||||
}
|
||||
glib::timeout_future(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -343,6 +343,7 @@ impl Service {
|
||||
probe_inflight: Arc::new(AtomicBool::new(false)),
|
||||
last_probe: Instant::now() - Duration::from_secs(60),
|
||||
wake_cancel: None,
|
||||
rescan: None,
|
||||
}
|
||||
.run(stop_w)
|
||||
})
|
||||
@@ -373,11 +374,14 @@ struct ServiceState {
|
||||
last_probe: Instant,
|
||||
/// Cancels the active wake thread (it owns the model's wake status).
|
||||
wake_cancel: Option<Arc<AtomicBool>>,
|
||||
/// Forces the mDNS browse to re-query. Installed by `run`; `None` before it starts.
|
||||
rescan: Option<discovery::Rescan>,
|
||||
}
|
||||
|
||||
impl ServiceState {
|
||||
fn run(mut self, stop: Arc<AtomicBool>) {
|
||||
let discovery_rx = discovery::browse();
|
||||
let (discovery_rx, rescan) = discovery::browse();
|
||||
self.rescan = Some(rescan);
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
// mDNS churn.
|
||||
while let Ok(ev) = discovery_rx.try_recv() {
|
||||
@@ -512,6 +516,14 @@ impl ServiceState {
|
||||
}
|
||||
ConsoleCmd::Probe => {
|
||||
self.last_probe = Instant::now() - Duration::from_secs(60);
|
||||
// "Refresh presence" means the mDNS half too, not just the QUIC sweep: the browse
|
||||
// runs for the process's lifetime and `mdns-sd` backs its re-query interval off to
|
||||
// as much as an hour, so a host that appeared since startup may never be asked
|
||||
// for again. (No console screen emits Probe yet — every face button on the home
|
||||
// screen is spoken for — but the plumbing is correct for when one does.)
|
||||
if let Some(r) = &self.rescan {
|
||||
r.request();
|
||||
}
|
||||
}
|
||||
ConsoleCmd::SetPin {
|
||||
key,
|
||||
|
||||
@@ -490,9 +490,13 @@ fn wake_and_connect(
|
||||
|
||||
let (ctx, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||
std::thread::spawn(move || {
|
||||
let rx = crate::discovery::browse();
|
||||
let (rx, rescan) = crate::discovery::browse();
|
||||
let mut seen: Vec<DiscoveredHost> = Vec::new();
|
||||
let mut wait = WakeWait::new();
|
||||
// A waking host starts advertising at a moment we can't predict, and `mdns-sd`'s own
|
||||
// re-query interval has doubled well past a minute by the time a boot finishes — so ask
|
||||
// again periodically instead of waiting to be told (matches the GTK client's wake wait).
|
||||
let mut ticks: u32 = 0;
|
||||
loop {
|
||||
// Cancel already returned the UI to the host list — stop re-sending and tear down.
|
||||
if cancel.load(Ordering::SeqCst) {
|
||||
@@ -555,6 +559,10 @@ fn wake_and_connect(
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
ticks += 1;
|
||||
if ticks % 5 == 0 {
|
||||
rescan.request();
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -595,6 +595,22 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
|
||||
move || sa.call(true)
|
||||
})
|
||||
.into()];
|
||||
// Re-query mDNS. The browse runs for the app's lifetime, and `mdns-sd` backs its
|
||||
// re-query interval off to as much as an hour — so a host that appeared since
|
||||
// startup, or whose announcement was lost to multicast, may need an actual ask.
|
||||
actions.push(
|
||||
icon_btn("Scan the network for hosts again", Symbol::Refresh)
|
||||
.on_click({
|
||||
let (c, st) = (ctx.clone(), set_status.clone());
|
||||
move || {
|
||||
if let Some(r) = c.shared.rescan.lock().unwrap().as_ref() {
|
||||
r.request();
|
||||
}
|
||||
st.call("Scanning the network\u{2026}".to_string());
|
||||
}
|
||||
})
|
||||
.into(),
|
||||
);
|
||||
// The couch UI's front door, beside the other page actions. Absent on ARM64,
|
||||
// where the session binary ships without its Skia console.
|
||||
if CONSOLE_UI_AVAILABLE {
|
||||
|
||||
@@ -147,6 +147,10 @@ impl PartialEq for Svc {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Shared {
|
||||
pub(crate) target: Mutex<Target>,
|
||||
/// Forces the app's single LAN browse to re-query — the hosts page's Refresh. Installed by
|
||||
/// the discovery effect below; `None` until then (and if the browse never started, in which
|
||||
/// case Refresh is simply inert rather than a second, competing browse).
|
||||
pub(crate) rescan: Mutex<Option<discovery::Rescan>>,
|
||||
/// The live session child (spawn mode) — the status page's Disconnect and the
|
||||
/// request-access Cancel kill it. A FRESH handle is installed per spawn.
|
||||
pub(crate) session: Mutex<crate::spawn::SessionChild>,
|
||||
@@ -459,8 +463,10 @@ fn root(cx: &mut RenderCx, ctx: &Arc<AppCtx>) -> Element {
|
||||
|
||||
cx.use_effect((), {
|
||||
let set_hosts = set_hosts.clone();
|
||||
let ctx = ctx.clone();
|
||||
move || {
|
||||
let rx = discovery::browse();
|
||||
let (rx, rescan) = discovery::browse();
|
||||
*ctx.shared.rescan.lock().unwrap() = Some(rescan);
|
||||
std::thread::spawn(move || {
|
||||
let mut acc: Vec<DiscoveredHost> = Vec::new();
|
||||
while let Ok(h) = rx.recv_blocking() {
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
//! results to the UI. Ported verbatim from the GTK client (`mdns-sd` is cross-platform).
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DiscoveredHost {
|
||||
@@ -24,10 +30,25 @@ pub struct DiscoveredHost {
|
||||
pub os: String,
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime. The thread exits when the receiver is
|
||||
/// dropped (the send fails) or the daemon dies.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
/// Forces the running browse to re-query now — the hosts page's Refresh. Mirrors
|
||||
/// `pf_client_core::discovery::Rescan`; see there for why a client needs one (`mdns-sd` re-queries
|
||||
/// on a backoff that doubles out to an hour, so a long-lived browse is effectively passive).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rescan(Arc<AtomicBool>);
|
||||
|
||||
impl Rescan {
|
||||
/// Ask the browse thread to put a fresh query on the wire. Coalesces; returns immediately.
|
||||
pub fn request(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse continuously for the app's lifetime, with a handle that forces an immediate re-query.
|
||||
/// The thread exits when the receiver is dropped (the send fails) or the daemon dies.
|
||||
pub fn browse() -> (async_channel::Receiver<DiscoveredHost>, Rescan) {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let requested = flag.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-mdns".into())
|
||||
.spawn(move || {
|
||||
@@ -38,18 +59,45 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let receiver = match daemon.browse("_punktfunk._udp.local.") {
|
||||
let mut receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Ok(event) = receiver.recv() {
|
||||
loop {
|
||||
// The worker has to notice that its consumer went away even when NOTHING is
|
||||
// arriving — the normal state of a LAN with no hosts on it. The old blocking
|
||||
// `recv()` only ever learned that from a failed send, so a bounded consumer (the
|
||||
// wake-and-wait below spawns one browse per wake) left this thread and its daemon
|
||||
// — another thread, and a socket bound to :5353 — running for the app's lifetime.
|
||||
// Checked at the TOP so the `continue` arms below can't skip it either.
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
// Re-browsing the same type replaces the daemon's listener: it replays the cache
|
||||
// into the new channel, queries immediately, and resets the backoff.
|
||||
if requested.swap(false, Ordering::Relaxed) {
|
||||
match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => receiver = r,
|
||||
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
|
||||
}
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() && receiver.is_empty() => break,
|
||||
Err(_) => continue, // timed out — go round and look for a rescan request
|
||||
};
|
||||
if let ServiceEvent::ServiceResolved(info) = event {
|
||||
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, like every other client (`pf_client_core::discovery`): the core
|
||||
// dials `format!("{host}:{port}").parse::<SocketAddr>()`, which cannot parse a
|
||||
// bare IPv6 literal, and the host stack binds IPv4 sockets exclusively. Taking
|
||||
// an arbitrary first address here rendered cards that failed on every click,
|
||||
// because a host's OS responder commonly answers AAAA for its hostname.
|
||||
let Some(addr) = info.get_addresses_v4().iter().next().map(|a| a.to_string())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -85,5 +133,5 @@ pub fn browse() -> async_channel::Receiver<DiscoveredHost> {
|
||||
let _ = daemon.shutdown();
|
||||
})
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
(rx, Rescan(flag))
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ fn run_headless_cli(args: &[String], identity: (String, String)) {
|
||||
fn discover_and_print() {
|
||||
use std::time::{Duration, Instant};
|
||||
println!("Browsing the LAN for punktfunk hosts (~5 s)…");
|
||||
let rx = discovery::browse();
|
||||
let (rx, _rescan) = discovery::browse();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while Instant::now() < deadline {
|
||||
|
||||
@@ -5,8 +5,13 @@
|
||||
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// DNS-SD service type punktfunk hosts advertise (host side: `punktfunk_host::discovery`).
|
||||
const SERVICE_TYPE: &str = "_punktfunk._udp.local.";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DiscoveredHost {
|
||||
/// Stable row key: the advertised host id, falling back to the mDNS fullname.
|
||||
@@ -54,10 +59,32 @@ pub enum DiscoveryEvent {
|
||||
Removed { fullname: String },
|
||||
}
|
||||
|
||||
/// Browse continuously. The worker exits when the returned receiver is dropped, or when the
|
||||
/// daemon dies — checked on a tick, so it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
/// Forces the running browse to re-query now. Cheap to clone and hand to a UI thread; a request
|
||||
/// made after the browse has ended is simply never read.
|
||||
///
|
||||
/// Why a client needs one at all: `mdns-sd` re-queries on a DOUBLING backoff (1s, 2s, 4s … capped
|
||||
/// at one hour), so a browse that has been up a while is effectively passive — it is listening for
|
||||
/// announcements rather than asking. A host that starts advertising later, or whose announcement
|
||||
/// was dropped (ordinary for multicast over Wi-Fi), can stay invisible for a very long time.
|
||||
/// Re-querying resets that clock, which is what a Refresh button should do.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Rescan(Arc<AtomicBool>);
|
||||
|
||||
impl Rescan {
|
||||
/// Ask the browse thread to put a fresh query on the wire. Returns immediately; the query
|
||||
/// follows within a tick. Coalesces — several requests in a row cost one query.
|
||||
pub fn request(&self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Browse continuously, with a handle that forces an immediate re-query ([`Rescan`]). The worker
|
||||
/// exits when the returned receiver is dropped, or when the daemon dies — checked on a tick, so
|
||||
/// it stops even on a LAN where no advert ever arrives.
|
||||
pub fn browse() -> (async_channel::Receiver<DiscoveryEvent>, Rescan) {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let requested = flag.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-mdns".into())
|
||||
.spawn(move || {
|
||||
@@ -68,7 +95,7 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let receiver = match daemon.browse("_punktfunk._udp.local.") {
|
||||
let mut receiver = match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "mDNS browse failed — discovery disabled");
|
||||
@@ -88,6 +115,17 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
// Also at the TOP, and for the same reason: every `continue` below would skip it.
|
||||
if requested.swap(false, Ordering::Relaxed) {
|
||||
// Browsing the same type again REPLACES the daemon's listener for it: it
|
||||
// replays the cache into the new channel (so nothing already known is lost),
|
||||
// puts a fresh PTR query on the wire immediately, and — the point — resets the
|
||||
// re-query backoff described on `Rescan`.
|
||||
match daemon.browse(SERVICE_TYPE) {
|
||||
Ok(r) => receiver = r,
|
||||
Err(e) => tracing::warn!(error = %e, "mDNS rescan failed"),
|
||||
}
|
||||
}
|
||||
let event = match receiver.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => event,
|
||||
Err(_) if receiver.is_disconnected() => break,
|
||||
@@ -147,7 +185,7 @@ pub fn browse() -> async_channel::Receiver<DiscoveryEvent> {
|
||||
let _ = daemon.shutdown();
|
||||
})
|
||||
.expect("spawn mdns thread");
|
||||
rx
|
||||
(rx, Rescan(flag))
|
||||
}
|
||||
|
||||
/// The advert map one browse window folded down to. Kept separate from [`discover_for`] so the
|
||||
@@ -174,7 +212,7 @@ fn fold(adverts: &mut Adverts, event: DiscoveryEvent) {
|
||||
/// wants one bounded call rather than a stream). The streaming [`browse`] stays the UI's door:
|
||||
/// a live hosts page wants adverts as they land, not a snapshot taken `timeout` after it opened.
|
||||
pub fn discover_for(timeout: Duration) -> Vec<DiscoveredHost> {
|
||||
let rx = browse();
|
||||
let (rx, _rescan) = browse();
|
||||
let deadline = Instant::now() + timeout;
|
||||
let mut adverts = Adverts::new();
|
||||
while Instant::now() < deadline {
|
||||
|
||||
Reference in New Issue
Block a user