The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as its last item for the users who do want to rebind from there — plus "Pin as Card ▸" and "Copy Link". A pinned host+profile combo becomes its own card next to its host: same record, same live status, the profile as the prominent subtitle, one click to connect. It is an extra grid ENTRY, never a duplicated host record — duplicating would fork pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions; edit, pair, forget and remove stay on the primary card, where the thing they act on lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole point: focus-and-press is what those surfaces do well, and menus are not. The edit sheet binds and pins; both rows vanish when no profiles exist, so a user who never makes one sees exactly today's sheet. A dangling binding renders as "Default settings (profile deleted)" and is cleaned up on save. The speed test finally writes where the tested host reads. Unbound: the global, as before. Bound to a profile that overrides bitrate: that override. Bound to one that inherits it: both are offered, because either is defensible and guessing would silently pick one. Every button names its target, and the probe now runs at the mode that host would actually stream — the measurement is the streaming path. Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter on Connect, still round-tripping through the URL router rather than opening a second connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link observer moved out with them: an intent that posts to nobody is a shortcut that silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
421 lines
18 KiB
Swift
421 lines
18 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) {
|
|
Text(host.displayName)
|
|
.font(.geist(m.name, .bold, relativeTo: .title3))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
if let profile = shownProfile {
|
|
ProfileChip(profile: profile, size: m.status, prominent: pinnedProfile != nil)
|
|
}
|
|
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 { 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: {
|
|
Label(
|
|
"Default settings",
|
|
systemImage: menu.boundID == nil ? "checkmark" : "")
|
|
}
|
|
ForEach(menu.profiles) { profile in
|
|
Button {
|
|
menu.connectWith(.profile(profile.id))
|
|
} label: {
|
|
Label(
|
|
profile.name,
|
|
systemImage: menu.boundID == profile.id ? "checkmark" : "")
|
|
}
|
|
}
|
|
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: {
|
|
Label(
|
|
profile.name,
|
|
systemImage: menu.pinnedIDs.contains(profile.id) ? "checkmark" : "")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
struct ProfileChip: View {
|
|
let profile: StreamProfile
|
|
let size: CGFloat
|
|
var prominent = false
|
|
|
|
var body: some View {
|
|
let tint = profile.accentColor ?? Color.brand
|
|
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(prominent ? size + 2 : size, .semibold, relativeTo: .caption2))
|
|
.lineLimit(1)
|
|
}
|
|
.foregroundStyle(tint)
|
|
.padding(.horizontal, 7)
|
|
.padding(.vertical, 2)
|
|
.background(Capsule().fill(tint.opacity(prominent ? 0.22 : 0.13)))
|
|
.accessibilityLabel("Profile \(profile.name)")
|
|
}
|
|
}
|
|
|
|
extension StreamProfile {
|
|
/// The `#RRGGBB` accent as a colour, or nil when the profile hasn't been given one (the
|
|
/// schema reserves the field; every surface falls back to the brand tint).
|
|
var accentColor: Color? {
|
|
guard let accent, accent.hasPrefix("#"), accent.count == 7,
|
|
let value = UInt32(accent.dropFirst(), radix: 16)
|
|
else { return nil }
|
|
return Color(
|
|
red: Double((value >> 16) & 0xFF) / 255,
|
|
green: Double((value >> 8) & 0xFF) / 255,
|
|
blue: Double(value & 0xFF) / 255)
|
|
}
|
|
}
|
|
|
|
/// 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
|