Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift
T
enricobuehlerandClaude Opus 5 c1d2efe112 fix(client/apple): the Name field didn't say it was the name either
Same cause as the MAC one, and it deserved the same fix rather than another
one-off: on iOS a TextField's title becomes an accessibility label the moment a
prompt exists and nothing is drawn, so "Optional — e.g. Living Room" was the whole
of what the field said about itself.

The prompt is now built per platform. macOS draws the title as a leading label, so
its prompt only hints at the value; iOS has no label to lean on, so the prompt names
the field too. One string for both leaves you either a Mac panel that says
everything twice — which is what the MAC field started doing in the last commit — or
a phone form of unlabelled boxes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:06:41 +02:00

315 lines
14 KiB
Swift

// Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC → a card in the grid.
// The MAC prefills from what we already know — the host's stored MAC, or the live mDNS advert's if
// it hasn't been learned yet — so it's usually already correct; type/paste it for a host we've
// never seen advertise. The first actual connection still runs the trust-on-first-use prompt.
import PunktfunkKit
import SwiftUI
struct AddHostSheet: View {
@Environment(\.dismiss) private var dismiss
/// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved).
let existing: StoredHost?
/// MAC(s) to offer when the host has none stored yet — the live advert's, so the field is
/// prefilled the moment the host is on the network, even before a connect has learned it.
let suggestedMacs: [String]
let onSave: (StoredHost) -> Void
@State private var name: String
@State private var address: String
@State private var port: Int
@State private var mac: String
#if !os(tvOS)
/// This host's DEFAULT settings profile — what a plain click/tap uses. Empty = Default
/// settings (design/client-settings-profiles.md §5.2). Changing it here is the only way the
/// default moves; "Connect with ▸" is deliberately a one-off.
@State private var profileID: String
/// Profiles pinned as their own cards for this host (§5.2a) — presentation only, and
/// independent of the default above.
@State private var pinnedIDs: Set<String>
@ObservedObject private var profiles = ProfileStore.shared
#endif
#if os(macOS)
/// Share the clipboard with this host (macOS sessions only; design
/// clipboard-and-file-transfer.md §5.3). Off by default; honored only when the host
/// advertises the capability at connect.
@State private var clipboardSync: Bool
#endif
#if os(tvOS)
private enum EditField: String, Identifiable {
case name, address, port, mac
var id: String { rawValue }
}
@State private var editingField: EditField?
#endif
/// A field's placeholder, which is not the same job on both platforms.
///
/// macOS draws a `TextField`'s title as a leading label, so the prompt only has to hint at the
/// VALUE. On iOS that title becomes an accessibility label the moment a prompt exists and
/// nothing is drawn — the prompt is the field's entire visible identity, so it has to name the
/// field as well as hint at it. Writing one string for both leaves you a Mac panel that says
/// everything twice or a phone form of unlabelled boxes.
private static func prompt(touch: String, desktop: String) -> Text {
#if os(macOS)
Text(desktop)
#else
Text(touch)
#endif
}
private var isEditing: Bool { existing != nil }
private var actionTitle: String { isEditing ? "Save" : "Add Host" }
private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty }
init(existing: StoredHost? = nil, suggestedMacs: [String] = [], onSave: @escaping (StoredHost) -> Void) {
self.existing = existing
self.suggestedMacs = suggestedMacs
self.onSave = onSave
_name = State(initialValue: existing?.name ?? "")
_address = State(initialValue: existing?.address ?? "")
_port = State(initialValue: Int(existing?.port ?? 9777))
let stored = existing?.macAddresses ?? []
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
#if os(macOS)
_clipboardSync = State(initialValue: existing?.clipboardSync ?? false)
#endif
#if !os(tvOS)
_profileID = State(initialValue: existing?.profileID ?? "")
_pinnedIDs = State(initialValue: Set(existing?.pinnedProfileIDs ?? []))
#endif
}
var body: some View {
#if os(tvOS)
// No inline text editing on tvOS — Settings-style value rows; pressing one
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
VStack(spacing: 24) {
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
TVFieldRow(
label: "MAC address", value: mac,
placeholder: "For Wake-on-LAN, optional") { editingField = .mac }
HStack(spacing: 32) {
Button("Cancel", role: .cancel) { dismiss() }
Button(actionTitle) { save() }.disabled(!canSave)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle(isEditing ? "Edit Host" : "Add Host")
.fullScreenCover(item: $editingField) { field in
switch field {
case .name:
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
name = $0
editingField = nil
}
case .address:
TVTextEntry(title: "IP or hostname", text: address) {
address = $0.trimmingCharacters(in: .whitespaces)
editingField = nil
}
case .port:
TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) {
if let value = Int($0), (1...65535).contains(value) { port = value }
editingField = nil
}
case .mac:
TVTextEntry(title: "MAC address(es), comma-separated — aa:bb:cc:dd:ee:ff", text: mac) {
mac = $0.trimmingCharacters(in: .whitespaces)
editingField = nil
}
}
}
#else
VStack(spacing: 0) {
Form {
TextField(
"Name", text: $name,
prompt: Self.prompt(
touch: "Name (optional, e.g. Living Room)",
desktop: "Optional — e.g. Living Room"))
TextField("Address", text: $address, prompt: Text("IP or hostname"))
TextField("Port", value: $port, format: .number.grouping(.never))
TextField(
"MAC address", text: $mac,
prompt: Self.prompt(
touch: "MAC address (for Wake-on-LAN, optional)",
desktop: "For Wake-on-LAN, optional"))
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
#if os(macOS)
Toggle("Share clipboard with this host", isOn: $clipboardSync)
#endif
profileRows
}
#if !os(tvOS)
.formStyle(.grouped)
#endif
#if os(iOS)
// As before: the sheet is sized to its content, so there is nothing to scroll. Only
// the edit sheet's profile rows can outgrow that, and only they turn it back on.
.scrollDisabled(!showsProfileRows)
#endif
#if os(macOS)
// macOS ONLY: the grouped form's default system text is oversized next to the app's
// Geist typography, so the panel reads out of place at full size. iOS keeps the app's
// body size — a touch target and a field label there are not a Mac panel's, and 12pt
// made this the one sheet in the app you had to squint at.
.font(.geist(12, relativeTo: .callout))
.controlSize(.small)
#endif
#if os(macOS)
HStack {
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button(actionTitle) { save() }
.glassProminentButtonStyle()
.keyboardShortcut(.defaultAction)
.disabled(!canSave)
}
.padding(16)
#else
Button { save() } label: {
Text(actionTitle).frame(maxWidth: .infinity)
}
.glassProminentButtonStyle()
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.disabled(!canSave)
.padding(16)
#endif
}
#if os(iOS)
// Sized to its content (see `sheetHeight`).
.presentationDetents([.height(sheetHeight)])
.presentationDragIndicator(.visible)
#endif
#if os(macOS)
.frame(width: 400)
.fixedSize(horizontal: false, vertical: true)
#endif
#endif
}
#if os(iOS)
/// Four fields + the action row — a touch taller than the 3-field add sheet used to be. The
/// edit sheet's profile rows are the only thing that can outgrow it, and they say by how much;
/// a single fixed number is what clipped them.
private var sheetHeight: CGFloat {
var height: CGFloat = 392
if showsProfileRows {
height += 116 // the Profile picker and its footnote
height += 96 + CGFloat(profiles.profiles.count) * 44 // the pins, their header + footer
}
return height
}
#endif
/// Whether this sheet shows the per-host profile rows at all.
///
/// Only when EDITING, and only once profiles exist. Adding a host is about reaching it — the
/// address, and whether we can wake it; which settings it streams with is a decision for the
/// host you already have, and stacking it onto the add flow made the first thing a new user
/// sees a longer form than the one they came for. Editing is one context-menu item away.
private var showsProfileRows: Bool {
#if os(tvOS)
return false
#else
return isEditing && !profiles.profiles.isEmpty
#endif
}
/// The per-host profile rows: which profile this host uses by default, and which extra ones
/// get their own card in the grid.
///
/// Two plain sections, no disclosure. A collapsed group had to animate its own height AND the
/// sheet's, and got both wrong; with a profile or three these are a couple of rows, and rows
/// that are simply there can't be clipped or fail to expand.
@ViewBuilder private var profileRows: some View {
#if !os(tvOS)
if showsProfileRows {
Section {
Picker("Profile", selection: $profileID) {
Text("Default settings").tag("")
ForEach(profiles.profiles) { profile in
Text(profile.name).tag(profile.id)
}
// A binding whose profile was deleted resolves as Default settings anyway;
// saying so beats an empty picker, and saving cleans the field up.
if !profileID.isEmpty, profiles.profile(id: profileID) == nil {
Text("Default settings (profile deleted)").tag(profileID)
}
}
} footer: {
Text("The settings a plain tap on this host streams with.")
}
Section {
ForEach(profiles.profiles) { profile in
Toggle(profile.name, isOn: Binding(
get: { pinnedIDs.contains(profile.id) },
set: { on in
if on {
pinnedIDs.insert(profile.id)
} else {
pinnedIDs.remove(profile.id)
}
}))
}
} header: {
Text("Pinned cards")
} footer: {
Text("A pinned profile gets its own card next to this host — one tap, no menu.")
}
}
#endif
}
private func save() {
var host = existing ?? StoredHost(name: "", address: "")
host.name = name.trimmingCharacters(in: .whitespaces)
host.address = address.trimmingCharacters(in: .whitespaces)
host.port = UInt16(clamping: port)
host.macAddresses = Self.parseMacs(mac)
#if os(macOS)
// nil when off: the key stays absent from the saved JSON (forward-compat, and "never
// opted in" and "opted out" read the same — off).
host.clipboardSync = clipboardSync ? true : nil
#endif
#if !os(tvOS)
// nil rather than "" for the same forward-compat reason, and a dangling binding is
// cleaned up on this save (§6) instead of lingering as a stale id forever.
host.profileID = profiles.profile(id: profileID) == nil ? nil : profileID
// Keep the stored ORDER (it is card order) and drop what this sheet unpinned; anything
// newly ticked goes on the end.
let kept = (host.pinnedProfileIDs ?? []).filter { pinnedIDs.contains($0) }
let added = pinnedIDs.filter { !kept.contains($0) }.sorted()
let pins = kept + added
host.pinnedProfileIDs = pins.isEmpty ? nil : pins
#endif
onSave(host)
dismiss()
}
/// Split comma/space/newline-separated MACs, keep only well-formed `aa:bb:cc:dd:ee:ff` (six hex
/// octets, normalized lower-case); nil when none are valid, so clearing the field clears the
/// stored MAC.
static func parseMacs(_ s: String) -> [String]? {
let macs = s
.split(whereSeparator: { ",; \n\t".contains($0) })
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { m in
let parts = m.split(separator: ":")
return parts.count == 6 && parts.allSatisfy { $0.count == 2 && UInt8($0, radix: 16) != nil }
}
return macs.isEmpty ? nil : macs
}
}