Files
punktfunk/clients/apple/Sources/PunktfunkClient/HostStore.swift
T
enricobuehler 47112f44b7
ci / web (push) Failing after 36s
ci / docs-site (push) Failing after 39s
ci / rust (push) Successful in 1m19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
apple / swift (push) Successful in 1m24s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
deb / build-publish (push) Successful in 2m52s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (push) Successful in 5m25s
feat(apple): surface host online status on the home grid
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) <noreply@anthropic.com>
2026-06-13 14:32:57 +02:00

96 lines
3.5 KiB
Swift

// Saved hosts + their pinned identities, persisted as JSON in UserDefaults.
//
// Trust model (client side of punktfunk/1): the host serves a persistent certificate and
// logs its SHA-256 fingerprint at startup. The pin lands here one of two ways the
// trust-on-first-use prompt (user compares the observed fingerprint against the host's
// log) or the SPAKE2 PIN pairing ceremony (PairSheet; mutually verified, and the host
// stores our identity from ClientIdentityStore in return). Every later connect passes
// the pin into punktfunk-core, which refuses a host whose identity changed. Hosts running
// --require-pairing only admit paired clients, so for them pairing is the only way in.
import Foundation
import PunktfunkKit
import SwiftUI
struct StoredHost: Identifiable, Codable, Hashable {
var id = UUID()
var name: String
var address: String
var port: UInt16 = 9777
/// SHA-256 of the host's certificate, set after the user explicitly trusted it.
var pinnedSHA256: Data?
/// Last time a streaming session actually started (nil until the first one).
var lastConnected: Date?
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
@Published var hosts: [StoredHost] {
didSet { persist() }
}
init() {
if let data = UserDefaults.standard.data(forKey: Self.key),
let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) {
hosts = decoded
} else {
hosts = []
}
}
func add(_ host: StoredHost) {
hosts.append(host)
}
func remove(_ host: StoredHost) {
hosts.removeAll { $0.id == host.id }
}
func markConnected(_ hostID: UUID) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].lastConnected = Date()
}
func pin(_ hostID: UUID, fingerprint: Data) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].pinnedSHA256 = fingerprint
}
/// Drop the pinned identity (e.g. after a legitimate host reinstall) the next
/// connect goes through the trust prompt again.
func forgetIdentity(_ host: StoredHost) {
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
hosts[i].pinnedSHA256 = nil
}
private func persist() {
if let data = try? JSONEncoder().encode(hosts) {
UserDefaults.standard.set(data, forKey: Self.key)
}
}
}