About was a bare "Punktfunk / Version x" header over 885 KB of license text — it answered the one question nobody opens About to ask. It is now the shape every Mac user already knows: the app's icon, its name, the version (selectable, because a bug report is worth more with it), the tagline, and then the ways out — documentation, community, source, licenses. The license wall is still `AcknowledgementsView`, one push in on iOS/tvOS and a sheet on macOS, where a preferences tab has no navigation stack to push onto. Every platform hides the app icon somewhere different and tvOS can't hand it over at all (layered assets, no single image), so there's a drawn fallback in the same brand gradient and Geist monogram as the host cards — where the real icon is unavailable it reads as a mark, not as a broken image. tvOS also has no browser: the addresses are text to read off the screen rather than links to nowhere. And profile colours were half-built by me: the catalog stored `accent`, the chips tinted from it, and nothing in the app ever SET one. The scope menu grows "Color ▸" over a fixed palette — the chip is small tinted text on a tinted capsule, and a colour picked freehand lands somewhere unreadable often enough to matter. The palette is what this client OFFERS, not what it accepts: any `#RRGGBB` another platform writes still renders, which is what Android needs from this field. The colour now shows wherever the profile is named — the scope switcher's dot, the card chips, and the HUD's session line, which tints to the same colour the card that launched it wore. A colour is an identifier, and an identifier that changes between surfaces isn't one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
430 lines
20 KiB
Swift
430 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) {
|
|
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) {
|
|
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
|