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
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>
169 lines
6.5 KiB
Swift
169 lines
6.5 KiB
Swift
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
|
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
|
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
|
private struct CardMetrics {
|
|
let iconSize: CGFloat
|
|
let iconBox: CGFloat
|
|
let cardPadding: CGFloat
|
|
let nameFont: Font
|
|
|
|
static var current: CardMetrics {
|
|
#if os(iOS)
|
|
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
|
#else
|
|
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
|
/// 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
|
|
let onConnect: () -> Void
|
|
let onPair: () -> Void
|
|
let onSpeedTest: () -> Void
|
|
let onForget: () -> Void
|
|
let onRemove: () -> Void
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
VStack(spacing: 10) {
|
|
ZStack {
|
|
Image(systemName: "play.display")
|
|
.font(.system(size: m.iconSize, weight: .light))
|
|
.foregroundStyle(.tint)
|
|
.opacity(isConnecting ? 0.3 : 1)
|
|
if isConnecting {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.frame(height: m.iconBox)
|
|
VStack(spacing: 2) {
|
|
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")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Text("\(host.address):\(String(host.port))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
if let last = host.lastConnected {
|
|
Text("Connected \(last, format: .relative(presentation: .named))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, m.cardPadding)
|
|
.padding(.horizontal, 12)
|
|
#if !os(tvOS)
|
|
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
|
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay {
|
|
if isMostRecent {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
.contextMenu {
|
|
Button("Pair with PIN…", action: onPair)
|
|
Button("Test Network Speed…", action: onSpeedTest)
|
|
if host.pinnedSHA256 != nil {
|
|
Button("Forget Identity", action: onForget)
|
|
}
|
|
Button("Remove", role: .destructive, action: onRemove)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
|
/// tapping saves it and connects (or pairs, if the host requires it).
|
|
struct DiscoveredCardView: View {
|
|
let discovered: DiscoveredHost
|
|
let isBusy: Bool
|
|
let onConnect: () -> Void
|
|
|
|
var body: some View {
|
|
let m = CardMetrics.current
|
|
return Button(action: onConnect) {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "play.display")
|
|
.font(.system(size: m.iconSize, weight: .light))
|
|
.foregroundStyle(.tint)
|
|
.frame(height: m.iconBox)
|
|
VStack(spacing: 2) {
|
|
Text(discovered.name)
|
|
.font(m.nameFont)
|
|
.lineLimit(1)
|
|
HStack(spacing: 4) {
|
|
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.secondary)
|
|
Text("\(discovered.host):\(String(discovered.port))")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, m.cardPadding)
|
|
.padding(.horizontal, 12)
|
|
#if !os(tvOS)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 14)
|
|
.strokeBorder(
|
|
Color.secondary.opacity(0.25),
|
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
}
|
|
#endif
|
|
}
|
|
#if os(tvOS)
|
|
.buttonStyle(.card)
|
|
#else
|
|
.buttonStyle(.plain)
|
|
#endif
|
|
.disabled(isBusy)
|
|
}
|
|
}
|