8ab262f8f8
apple / swift (push) Successful in 54s
ci / rust (push) Failing after 1m12s
ci / web (push) Successful in 29s
android / android (push) Failing after 1m49s
ci / docs-site (push) Successful in 31s
ci / bench (push) Successful in 1m48s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
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 19s
flatpak / build-publish (push) Failing after 3s
deb / build-publish (push) Failing after 2m43s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 17s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m20s
TOFU let anyone who could reach the host click "Trust" and stream, which defeats the point on a LAN. Make SPAKE2 PIN pairing the default and only way to trust a NEW host; TOFU survives as an explicit HOST opt-in (for fully trusted networks), advertised over mDNS so clients render their trust UI from the host's policy rather than offering trust on faith. Contract: - Host advertises pair=required (default) or pair=optional. pair=required rejects unpaired clients at the handshake; pair=optional accepts them (TOFU). - Clients: a pinned host whose fingerprint matches connects silently; a pinned host whose fingerprint CHANGED forces re-pairing via PIN (no re-trust shortcut); a NEW host is offered TOFU only if it advertised pair=optional, otherwise PIN pairing is mandatory; a manually-typed or unknown-policy host is always PIN. Host (crates/punktfunk-host/src/main.rs): - m3-host now REQUIRES pairing by default (was open by default). New --allow-tofu opts into accepting unpaired clients + advertising pair=optional; pairing is always armed (PIN logged at startup). serve --native was already secure-by-default (serve --open). The mDNS advert and the accept loop already mapped require_pairing -> pair=required + reject; only the m3-host CLI default + help text changed. Clients honor the advertised policy: - Android (MainActivity.kt): TOFU only for a discovered pair=optional host; manual/unknown -> PIN; fp-change -> re-pair only (dropped the "Forget & re-TOFU" shortcut). - Apple (HostDiscovery/SessionModel/ContentView/HostCards/HostStore): new allowsTofu (pair==optional, distinct from unknown); connect() gates .awaitingTrust on it; unpinned non-optional hosts route to the PIN sheet; "Forget Identity" re-pairs rather than re-TOFUs. - Linux (app.rs/ui_hosts.rs/session.rs): ConnectRequest.pair_required -> pair_optional; initiate_connect routes pinned/fp-changed/optional/else; manual + --connect unknown -> PIN; a pinned connect rejected on trust grounds re-pairs. Docs (CLAUDE.md, README.md, docs-site/content/docs/pairing.md): describe the gated model — PIN is the default, TOFU an explicit opt-in with an impostor warning. Verified: host cargo check/clippy/fmt clean; Android built + live (emulator -> home-worker-2): a manual connect now opens the PIN dialog (no Trust button) and the PIN ceremony streams; Apple swift build clean; Linux clippy -D warnings + fmt clean on the Linux box. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
176 lines
7.0 KiB
Swift
176 lines
7.0 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
|
|
/// Open the experimental library browser — nil (no menu item) unless the feature flag is on.
|
|
var onBrowseLibrary: (() -> Void)? = nil
|
|
|
|
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 let onBrowseLibrary {
|
|
Button("Browse Library…", action: onBrowseLibrary)
|
|
}
|
|
if host.pinnedSHA256 != nil {
|
|
// Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via
|
|
// PIN (unless the host advertises pair=optional). Wording reflects that.
|
|
Button("Forget Identity (re-pair to reconnect)", 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)
|
|
}
|
|
}
|