Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift
T
enricobuehler e9c5030190
apple / swift (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 1m14s
ci / rust (push) Failing after 49s
ci / web (push) Successful in 52s
windows-host / package (push) Failing after 2m58s
ci / docs-site (push) Successful in 1m5s
android / android (push) Successful in 4m7s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m15s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m15s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 48s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 49s
ci / bench (push) Successful in 5m5s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
release / apple (push) Successful in 8m30s
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 4s
deb / build-publish (push) Has been cancelled
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
flatpak / build-publish (push) Has been cancelled
apple / screenshots (push) Has been cancelled
docker / deploy-docs (push) Successful in 19s
feat(clients): Wake-on-LAN in apple/linux/windows/android/decky
Each client learns a host's MAC from the mDNS `mac` TXT while it's awake, persists it on the saved-host record, and — when reconnecting to an offline host — sends a magic packet before connecting, plus an explicit "Wake host" action. Apple wraps the C-ABI; linux/windows call the core fn directly (linux also gains a --wake CLI mode); android via a new nativeWakeOnLan JNI export (the mDNS browse record gains a 7th mac field); decky shells out to the linux client's --wake before launching the stream.

iOS/tvOS need the managed com.apple.developer.networking.multicast entitlement (pending Apple approval), so the wake path + UI are gated off via PunktfunkConnection.wakeOnLANAvailable and the entitlement is commented out — keeping iOS/tvOS releasable. MAC-learning stays active on every platform so it lights up the moment it's ungated. macOS works today.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 13:39:44 +02:00

262 lines
12 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)
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
padding: 18, spacing: 18, radius: 14)
#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)
}
}
}
/// 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.
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
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) {
Text(host.displayName)
.font(.geist(m.name, .bold, relativeTo: .title3))
.foregroundStyle(.primary)
.lineLimit(1)
Text("\(host.address):\(String(host.port))")
.font(.geist(m.meta, relativeTo: .caption))
.foregroundStyle(.secondary)
.lineLimit(1)
statusRow(m)
}
Spacer(minLength: 0)
}
.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 {
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)
}
}
/// 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) {
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)
}
}
/// 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) {
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