From 47112f44b726967227ebf55351af30b1f13884a1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 13 Jun 2026 14:32:56 +0200 Subject: [PATCH] feat(apple): surface host online status on the home grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saved host cards now show a presence dot — green when the host is advertising on the LAN right now, grey when not seen. Cross-references each StoredHost against the live mDNS discovery set (HostDiscovery). No host changes: the host already advertises _punktfunk._udp with a stable id + cert fingerprint, which the client already browses. - StoredHost.matches(DiscoveredHost): fingerprint-first (survives a DHCP address change), address:port fallback. The discovered-section dedup now uses the same match, so a saved host whose IP changed no longer also shows up as a stranger. - HostCardView gains an isOnline presence dot (accessibility-labelled). - HomeView.isOnline recomputes on every @Published discovery change, so the dot tracks hosts joining/leaving the network live. Online detection is LAN-scoped by design: a remote/cross-subnet host that doesn't advertise here shows grey ("not seen"), not a false "offline". Swift-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/PunktfunkClient/HomeView.swift | 17 +++++++++++----- .../Sources/PunktfunkClient/HostCards.swift | 16 ++++++++++++--- .../Sources/PunktfunkClient/HostStore.swift | 20 +++++++++++++++++++ 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/HomeView.swift b/clients/apple/Sources/PunktfunkClient/HomeView.swift index f478386..3c5e951 100644 --- a/clients/apple/Sources/PunktfunkClient/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/HomeView.swift @@ -148,6 +148,7 @@ struct HomeView: View { private func hostCard(_ host: StoredHost) -> some View { HostCardView( host: host, + isOnline: isOnline(host), isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, isMostRecent: host.id == mostRecentHostID, isBusy: model.isBusy, @@ -176,12 +177,18 @@ struct HomeView: View { .padding(.top, store.hosts.isEmpty ? 0 : 8) } - /// Discovered hosts not already saved (matched by address+port) — the saved grid shows the - /// rest, so this section only surfaces genuinely-new hosts on the network. + /// A saved host is "online" iff a live mDNS advert currently matches it (see + /// `StoredHost.matches`). Recomputed on every discovery change (the @Published set), so the + /// dot tracks hosts appearing/leaving the network live. + private func isOnline(_ host: StoredHost) -> Bool { + discovery.hosts.contains { host.matches($0) } + } + + /// Discovered hosts not already saved — the saved grid shows the rest, so this section only + /// surfaces genuinely-new hosts on the network. Same match as the online dot, so a saved host + /// whose IP changed (still fingerprint-matched) doesn't also appear here as a stranger. private var discoveredUnsaved: [DiscoveredHost] { - discovery.hosts.filter { d in - !store.hosts.contains { $0.address == d.host && $0.port == d.port } - } + discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } } } /// The host of the most recent session — its card carries the accent ring. diff --git a/clients/apple/Sources/PunktfunkClient/HostCards.swift b/clients/apple/Sources/PunktfunkClient/HostCards.swift index 42e6bcd..cfb0270 100644 --- a/clients/apple/Sources/PunktfunkClient/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/HostCards.swift @@ -24,6 +24,9 @@ private struct CardMetrics { /// pairs / speed-tests / forgets / removes. Disabled while a session is busy. struct HostCardView: View { let host: StoredHost + /// Currently advertising on the LAN (matched against live mDNS discovery). False means + /// "not seen on this network" — off, or a remote/cross-subnet host we can't observe. + let isOnline: Bool let isConnecting: Bool let isMostRecent: Bool let isBusy: Bool @@ -48,9 +51,16 @@ struct HostCardView: View { } .frame(height: m.iconBox) VStack(spacing: 2) { - Text(host.displayName) - .font(m.nameFont) - .lineLimit(1) + HStack(spacing: 6) { + // Presence dot: green = advertising on the LAN now; grey = not seen. + Circle() + .fill(isOnline ? Color.green : Color.secondary.opacity(0.35)) + .frame(width: 7, height: 7) + .accessibilityLabel(isOnline ? "Online" : "Offline") + Text(host.displayName) + .font(m.nameFont) + .lineLimit(1) + } HStack(spacing: 4) { if host.pinnedSHA256 != nil { Image(systemName: "lock.fill") diff --git a/clients/apple/Sources/PunktfunkClient/HostStore.swift b/clients/apple/Sources/PunktfunkClient/HostStore.swift index 1ee9aa8..175e86b 100644 --- a/clients/apple/Sources/PunktfunkClient/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/HostStore.swift @@ -25,6 +25,26 @@ struct StoredHost: Identifiable, Codable, Hashable { var displayName: String { name.isEmpty ? address : name } } +extension StoredHost { + /// True when a live mDNS advert (`DiscoveredHost`) describes THIS saved host — drives the + /// "online" indicator and de-dupes the discovered section. Matched by certificate + /// fingerprint when both sides carry it (so it survives a DHCP address change), otherwise + /// by address:port. Online detection is LAN-scoped: a host not advertising on this network + /// (off, or a remote/cross-subnet address) simply won't match — "not seen", not proven off. + func matches(_ discovered: DiscoveredHost) -> Bool { + if let pin = pinnedSHA256, let fp = discovered.fingerprintHex, + pin.hexLower == fp.lowercased() { + return true + } + return address == discovered.host && port == discovered.port + } +} + +private extension Data { + /// Lowercase hex, no separators — to compare a pinned fingerprint against the mDNS `fp`. + var hexLower: String { map { String(format: "%02x", $0) }.joined() } +} + @MainActor final class HostStore: ObservableObject { private static let key = DefaultsKey.hosts