// 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 #if canImport(WidgetKit) import WidgetKit #endif // `StoredHost` (the model + its JSON codec) now lives in PunktfunkShared so the widget extension // can read the same store; PunktfunkKit re-exports it. The discovery-join helpers below stay here // because they reference PunktfunkKit's `DiscoveredHost`/`HostDiscovery`. 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 } } /// The two joins of live mDNS discovery against the saved-host store, shared by the touch grid /// (HomeView) and the gamepad launcher (GamepadHomeView) so both screens classify hosts the same /// way. LAN-scoped like the underlying match: a host that isn't advertising here is "not seen", /// not proven off. extension HostDiscovery { /// A saved host is "online" iff a live advert currently matches it (see `StoredHost.matches`). /// Recomputed on every discovery change (the @Published set), so it tracks hosts /// appearing/leaving the network live. func advertises(_ host: StoredHost) -> Bool { hosts.contains { host.matches($0) } } /// Discovered hosts not already saved — the saved list shows the rest, so this only surfaces /// genuinely-new hosts on the network. Same match as `advertises`, so a saved host whose IP /// changed (still fingerprint-matched) doesn't also appear as a stranger. func unsaved(among saved: [StoredHost]) -> [DiscoveredHost] { hosts.filter { d in !saved.contains { $0.matches(d) } } } } @MainActor final class HostStore: ObservableObject { private static let key = DefaultsKey.hosts @Published var hosts: [StoredHost] { didSet { persist() } } /// Saved hosts proven reachable by the periodic QUIC probe (by id) — the mDNS-independent /// counterpart to discovery presence, OR'd into the "online" pip so a routed/VPN host that /// never advertises still reads Online. Not persisted (it's live reachability, not config). @Published var probedOnline: Set = [] /// The App-Group suite — shared with the Widget/Live-Activity extension so a launcher widget /// sees the same saved hosts. Falls back to `.standard` in an un-entitled process (see /// `AppGroup.defaults`). private let defaults = AppGroup.defaults init() { Self.migrateToAppGroupIfNeeded() if let data = defaults.data(forKey: Self.key), let decoded = try? JSONDecoder().decode([StoredHost].self, from: data) { hosts = decoded } else { hosts = [] } } /// One-time move of the saved-host JSON from `UserDefaults.standard` (where every build before /// the App Group wrote it) into the shared suite. Idempotent: only fires when the suite has no /// hosts yet but standard does. The old value is LEFT in place — during a staged TestFlight /// rollout an older build still reads `.standard`, so tombstoning it now would hide hosts from /// the not-yet-updated app. Remove the standard copy a release later. private static func migrateToAppGroupIfNeeded() { let suite = AppGroup.defaults let standard = UserDefaults.standard guard suite !== standard else { return } // un-entitled fallback: nothing to migrate guard suite.data(forKey: key) == nil, let legacy = standard.data(forKey: key) else { return } suite.set(legacy, forKey: key) } func add(_ host: StoredHost) { hosts.append(host) } func remove(_ host: StoredHost) { hosts.removeAll { $0.id == host.id } } /// Replace a saved host in place (the edit sheet) — matched by id, so identity/pin/last-connected /// carried on the passed value are preserved. func update(_ host: StoredHost) { guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return } hosts[i] = host } func markConnected(_ hostID: UUID) { guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } hosts[i].lastConnected = Date() // didSet → persist() writes the shared suite + reloads widget } /// One reachability sweep, driving `probedOnline`: probe every saved host NOT currently /// advertising on `discovery` (mDNS already answers for those) off the main actor via a bounded, /// trust-agnostic QUIC handshake, then publish the reachable set. This is the mDNS-independent /// half of presence — a host reached over a routed network (Tailscale/VPN) never advertises but /// answers here. Call in a loop from a home view's `.task` (cancelled on disappear). func refreshReachability(discovery: HostDiscovery) async { let targets = hosts.filter { !discovery.advertises($0) } var online: Set = [] for host in targets { let reachable = await Task.detached(priority: .utility) { PunktfunkConnection.probe(host: host.address, port: host.port) }.value if reachable { online.insert(host.id) } } probedOnline = online } func pin(_ hostID: UUID, fingerprint: Data) { guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } hosts[i].pinnedSHA256 = fingerprint } /// Learn/refresh this host's Wake-on-LAN MAC(s) from its live advert (called while the host is /// awake, so the client can wake it once it sleeps). No-op when unchanged, so it doesn't churn /// UserDefaults on every discovery tick. func updateMacs(_ hostID: UUID, macs: [String]) { guard !macs.isEmpty, let i = hosts.firstIndex(where: { $0.id == hostID }), hosts[i].macAddresses != macs else { return } hosts[i].macAddresses = macs } /// Drop the pinned identity (e.g. after a legitimate host reinstall). This does NOT downgrade /// to TOFU: the next connect re-pairs via the PIN ceremony, unless the host advertises /// `pair=optional` (the only case the connect path still offers the trust prompt). 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) { defaults.set(data, forKey: Self.key) } reloadHostsWidget() // the widget reads this store; any change refreshes its timeline } /// Ask WidgetKit to rebuild the hosts widget's timeline after any store change (add/remove/pin/ /// last-connected). iOS-only and a no-op where WidgetKit is absent; the widget uses /// `.never`-refresh entries and relies on this push. private func reloadHostsWidget() { #if canImport(WidgetKit) && os(iOS) WidgetCenter.shared.reloadTimelines(ofKind: "PunktfunkHosts") #endif } }