Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift
T
enricobuehlerandClaude Fable 5 88c8688b47 feat(client/apple): host cards wear the OS mark, and the store learns it
`DiscoveredHost` reads the new `os=` TXT (sanitized in PunktfunkShared's
OsChain.swift — the pure grammar + walk, mirrored from pf-client-core so every
platform resolves identically, and dependency-free so the widget could use it);
`StoredHost` appends optional `osChain` per the frozen app↔widget contract
(optional + appended last; legacy JSON still decodes, pinned by the round-trip
tests), and `HostStore.updateOsChain` learns it beside the MACs.

The art rides PunktfunkKit's proven resource path (the fonts precedent): ten
template vector imagesets in Resources/OsIcons.xcassets, compiled by the Xcode
build into the Kit bundle's Assets.car (verified: all ten resolve in the built
app) and tinting via foregroundStyle like an SF Symbol. Saved and discovered
cards lead their status line with the mark; a host that advertises nothing
renders exactly as before. GamepadHome tile + widget marks are follow-ups.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 17:58:33 +02:00

448 lines
20 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 "monogram module" look — a squared brand-purple
// monogram tile + a left-aligned bold Geist name over monospaced technical metadata
// (address, status), framed by a hairline panel border. Industrial, not soft.
import PunktfunkKit
import SwiftUI
/// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS.
private struct CardMetrics {
let tile: CGFloat // monogram tile side
let monogram: CGFloat // monogram letter point size
let name: CGFloat // host-name point size
let meta: CGFloat // address (mono) point size
let status: CGFloat // status-label (mono) point size
let padding: CGFloat
let spacing: CGFloat // tile ↔ text gap
let radius: CGFloat
static var current: CardMetrics {
#if os(iOS)
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
padding: 16, spacing: 14, radius: 12)
#elseif os(tvOS)
// 10-foot sizes — the 24pt-name tier read like a phone card from the couch.
CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
padding: 24, spacing: 22, radius: 18)
#else
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
padding: 13, spacing: 12, radius: 10)
#endif
}
}
/// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet.
private func monogram(_ name: String) -> String {
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
return String(first).uppercased()
}
/// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a
/// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting.
private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View {
let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous)
return ZStack {
shape.fill(filled
? AnyShapeStyle(LinearGradient(
colors: [Color.brand, Color.brand.opacity(0.72)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(Color.brand.opacity(0.14)))
if connecting {
ProgressView().tint(filled ? .white : Color.brand)
} else {
// Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must
// not scale up and spill out at large accessibility text sizes. minimumScaleFactor +
// the clip below are belt-and-suspenders for an unusually wide glyph.
Text(letter)
.font(.geistFixed(m.monogram, .bold))
.minimumScaleFactor(0.5)
.lineLimit(1)
.foregroundStyle(filled ? Color.white : Color.brand)
}
}
.frame(width: m.tile, height: m.tile)
.clipShape(shape)
.overlay {
if !filled {
shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1)
}
}
}
/// Everything a card's profile affordances need: the catalog to offer, what this host is bound
/// and pinned to, and the acts a menu can perform (design/client-settings-profiles.md §5.2/§5.2a).
///
/// Passed as one value rather than eight closures because every surface that renders a host card
/// has to offer the SAME set — a menu that quietly lacks "Pin as card" on one screen is how a
/// feature becomes folklore.
struct HostProfileMenu {
var profiles: [StreamProfile]
/// The host's default profile — the chip, and the checkmark in "Connect with ▸".
var boundID: String?
var pinnedIDs: [String]
/// A ONE-OFF connect. Never rebinds: rebinding is `setDefault`, an explicit act (§5.2).
var connectWith: (ProfileSelection) -> Void
var setDefault: (String?) -> Void
var togglePin: (String) -> Void
/// Copy a `punktfunk://` link for this host, optionally carrying a profile.
var copyLink: (String?) -> Void
}
/// A saved host. A left accent bar marks the most-recently-connected one; the context menu
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
///
/// The same view renders a PINNED host+profile card (§5.2a): same host, same live status, with
/// the profile as the prominent subtitle. A pinned card is a shortcut, not a second host, so its
/// menu carries only connect-shaped actions — edit/pair/forget/remove stay on the primary card,
/// where the thing they act on actually lives.
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
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
var onWake: (() -> Void)? = nil
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
var onEdit: (() -> Void)? = nil
/// This card's profile affordances — nil on surfaces that don't offer them.
var profileMenu: HostProfileMenu? = nil
/// Set on a PINNED card: the profile this card connects with. nil = the host's primary card,
/// which follows the binding.
var pinnedProfile: StreamProfile? = nil
/// The profile this card announces: a pinned card's own, else the host's binding.
private var shownProfile: StreamProfile? {
pinnedProfile ?? profileMenu.flatMap { menu in
menu.boundID.flatMap { id in menu.profiles.first { $0.id == id } }
}
}
var body: some View {
let m = CardMetrics.current
return Button(action: onConnect) {
HStack(spacing: m.spacing) {
monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true)
VStack(alignment: .leading, spacing: 4) {
// The chip rides the TITLE line, anchored to the card's trailing edge — not
// trailing the name, where it read as part of the title, and not on a line of
// its own, where it made cards with a profile taller than cards without and a
// pinned card stuck out of its grid row. The spacer is what anchors it: the
// name truncates against that gap instead of ever running into the chip.
HStack(spacing: 6) {
Text(host.displayName)
.font(.geist(m.name, .bold, relativeTo: .title3))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer(minLength: 8)
if let profile = shownProfile {
ProfileChip(
profile: profile, size: m.status,
prominent: pinnedProfile != nil)
// The name gives up width first: a truncated host name still reads,
// a truncated profile name is the one thing the chip exists to say.
.layoutPriority(1)
}
}
Text("\(host.address):\(String(host.port))")
.font(.geist(m.meta, relativeTo: .caption))
.foregroundStyle(.secondary)
.lineLimit(1)
statusRow(m)
}
// Fills the card so the title row's trailing spacer reaches the real edge; without
// it the text column hugs its content and "trailing" means "just after the name".
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(m.padding)
.frame(maxWidth: .infinity, alignment: .leading)
#if !os(tvOS)
// tvOS: the .card button style owns platter + focus motion; extra chrome mutes it.
// Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob),
// and a brand accent bar down the leading edge for the most-recent host.
.background(.regularMaterial)
.overlay(alignment: .leading) {
if isMostRecent {
Rectangle().fill(Color.brand).frame(width: 3)
}
}
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
.strokeBorder(.quaternary, lineWidth: 1)
}
#endif
}
#if os(tvOS)
.buttonStyle(.card)
#elseif os(iOS)
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
#else
.buttonStyle(.plain)
#endif
.disabled(isBusy)
.contextMenu { menuItems }
}
@ViewBuilder private var menuItems: some View {
if let pinned = pinnedProfile, let menu = profileMenu {
// A pinned card is a shortcut, not a second host: only connect-shaped actions, plus
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
// the host's default binding.
connectWithMenu(menu)
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
}
Button("Unpin Card", systemImage: "pin.slash", role: .destructive) {
menu.togglePin(pinned.id)
}
} else {
if let onEdit {
Button("Edit…", systemImage: "pencil", action: onEdit)
}
if let menu = profileMenu {
connectWithMenu(menu)
pinMenu(menu)
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(nil) }
}
}
Button("Pair with PIN…", action: onPair)
Button("Test Network Speed…", action: onSpeedTest)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if !isOnline, !host.wakeMacs.isEmpty, PunktfunkConnection.wakeOnLANAvailable, let onWake {
Button("Wake Host", systemImage: "power", action: onWake)
}
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)
}
}
/// "Connect with ▸" — a ONE-OFF pick that never rebinds the host, with a checkmark on what a
/// plain click would use. Its last item is the explicit way to rebind, for the users who do
/// want that from here.
@ViewBuilder private func connectWithMenu(_ menu: HostProfileMenu) -> some View {
if !menu.profiles.isEmpty {
Menu("Connect with") {
Button {
menu.connectWith(.defaults)
} label: {
checkable("Default settings", on: menu.boundID == nil)
}
ForEach(menu.profiles) { profile in
Button {
menu.connectWith(.profile(profile.id))
} label: {
checkable(profile.name, on: menu.boundID == profile.id)
}
}
Divider()
Menu("Set Default Profile") {
Button("Default settings") { menu.setDefault(nil) }
ForEach(menu.profiles) { profile in
Button(profile.name) { menu.setDefault(profile.id) }
}
}
}
}
}
/// "Pin as card ▸" — a host+profile combo gets its own one-click card in the grid, which is
/// what turns a regularly-used profile from a menu dive into a press (§5.2a).
@ViewBuilder private func pinMenu(_ menu: HostProfileMenu) -> some View {
if !menu.profiles.isEmpty {
Menu("Pin as Card") {
ForEach(menu.profiles) { profile in
Button {
menu.togglePin(profile.id)
} label: {
checkable(profile.name, on: menu.pinnedIDs.contains(profile.id))
}
}
}
}
}
/// A menu row that carries a checkmark when it is the current choice. Built as two shapes
/// rather than one `Label` with an empty symbol name — `Image(systemName: "")` is not a
/// blank image, it is an invalid one.
@ViewBuilder private func checkable(_ title: String, on: Bool) -> some View {
if on {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
/// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a
/// certificate is pinned (the lock state, spelled out).
@ViewBuilder private func statusRow(_ m: CardMetrics) -> some View {
HStack(spacing: 6) {
// The host's OS mark leads the row (template asset — tints like an SF Symbol);
// absent entirely for a host that never advertised one, so those cards render
// exactly as they always did.
if let mark = osIconImage(for: host.osChain) {
mark
.resizable()
.scaledToFit()
.frame(width: m.status + 2, height: m.status + 2)
.accessibilityLabel(host.osChain ?? "")
}
RoundedRectangle(cornerRadius: 1.5)
.fill(isOnline ? Color.green : Color.secondary.opacity(0.4))
.frame(width: 6, height: 6)
// The state is spelled out in the adjacent text, so the pip is decorative —
// otherwise VoiceOver reads the status twice ("Online, ONLINE …").
.accessibilityHidden(true)
Text(isOnline ? "ONLINE" : "OFFLINE")
if host.pinnedSHA256 != nil {
Text("· PAIRED")
}
}
.font(.geist(m.status, .medium, relativeTo: .caption2))
.tracking(0.8)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
/// The profile a card connects with, as a tinted pill. Quiet on a bound primary card (it only
/// answers "what will a click do?"); prominent on a pinned card, where the profile IS the reason
/// the card exists — which is where the catalog's `accent` earns its keep.
///
/// Prominence is fill and weight only, never TYPE SIZE: the chip sits on the card's title line,
/// and a chip taller than the name would make pinned cards taller than their host's.
struct ProfileChip: View {
let profile: StreamProfile
let size: CGFloat
var prominent = false
var body: some View {
let tint = profile.accentColor
return HStack(spacing: 5) {
Circle()
.fill(tint)
.frame(width: size * 0.55, height: size * 0.55)
.accessibilityHidden(true) // the name is right there
Text(profile.name)
.font(.geist(size, prominent ? .bold : .semibold, relativeTo: .caption2))
.lineLimit(1)
}
.foregroundStyle(tint)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background(Capsule().fill(tint.opacity(prominent ? 0.24 : 0.12)))
.accessibilityLabel("Profile \(profile.name)")
}
}
/// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border
/// distinguish it from saved cards; tapping saves it and connects (or pairs, if required).
struct DiscoveredCardView: View {
let discovered: DiscoveredHost
let isBusy: Bool
let onConnect: () -> Void
var body: some View {
let m = CardMetrics.current
return Button(action: onConnect) {
HStack(spacing: m.spacing) {
monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false)
VStack(alignment: .leading, spacing: 4) {
Text(discovered.name)
.font(.geist(m.name, .bold, relativeTo: .title3))
.foregroundStyle(.primary)
.lineLimit(1)
Text("\(discovered.host):\(String(discovered.port))")
.font(.geist(m.meta, relativeTo: .caption))
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 6) {
// Same leading OS mark as a saved card's status row — live from the advert.
if let mark = osIconImage(for: discovered.osChain) {
mark
.resizable()
.scaledToFit()
.frame(width: m.status + 2, height: m.status + 2)
.accessibilityLabel(discovered.osChain)
}
Image(systemName: discovered.requiresPairing
? "lock.fill" : "antenna.radiowaves.left.and.right")
.font(.system(size: m.status))
.accessibilityHidden(true) // decorative; the adjacent text says the state
Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED")
}
.font(.geist(m.status, .medium, relativeTo: .caption2))
.tracking(0.8)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(m.padding)
.frame(maxWidth: .infinity, alignment: .leading)
#if !os(tvOS)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: m.radius, style: .continuous)
.strokeBorder(
Color.secondary.opacity(0.3),
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
#endif
}
#if os(tvOS)
.buttonStyle(.card)
#elseif os(iOS)
.buttonStyle(HostCardButtonStyle(cornerRadius: m.radius))
#else
.buttonStyle(.plain)
#endif
.disabled(isBusy)
}
}
#if os(iOS)
/// The iOS host-card press/hover treatment, one style for both idioms:
/// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is
/// inert without a pointer.)
/// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the
/// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the
/// press scale doubles as click feedback.)
struct HostCardButtonStyle: ButtonStyle {
var cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed)
// Conform the pointer highlight to the card's rounded rect, not its square bounds.
.contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.hoverEffect(.highlight)
// Light tap on press-down (nil on release so it fires once, on touch). No haptic
// hardware on iPad → silently ignored there.
.sensoryFeedback(trigger: configuration.isPressed) { _, pressed in
pressed ? .impact(weight: .light) : nil
}
}
}
#endif