Merge origin/main (tvOS client work) with host EIS/attach + macOS-input fixes
ci / rust (push) Has been cancelled

This commit is contained in:
2026-06-11 13:05:02 +00:00
47 changed files with 911 additions and 33 deletions
@@ -8,30 +8,86 @@ struct AddHostSheet: View {
@State private var name = ""
@State private var address = ""
@State private var port = 9777
#if os(tvOS)
private enum EditField: String, Identifiable {
case name, address, port
var id: String { rawValue }
}
@State private var editing: EditField?
#endif
let onAdd: (StoredHost) -> Void
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"
) { editing = .name }
TVFieldRow(
label: "Address", value: address, placeholder: "IP or hostname"
) { editing = .address }
TVFieldRow(
label: "Port", value: String(port), placeholder: ""
) { editing = .port }
HStack(spacing: 32) {
Button("Cancel", role: .cancel) { dismiss() }
Button("Add Host") { add() }
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Add Host")
.fullScreenCover(item: $editing) { field in
switch field {
case .name:
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
name = $0
editing = nil
}
case .address:
TVTextEntry(title: "IP or hostname", text: address) {
address = $0.trimmingCharacters(in: .whitespaces)
editing = nil
}
case .port:
TVTextEntry(
title: "Port", text: String(port), keyboardType: .numberPad
) {
if let value = Int($0), (1...65535).contains(value) {
port = value
}
editing = nil
}
}
}
#else
VStack(spacing: 0) {
Form {
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
TextField("Address", text: $address, prompt: Text("IP or hostname"))
TextField("Port", value: $port, format: .number.grouping(.never))
#if os(tvOS)
// tvOS floats the label above a non-empty field INSIDE the pill,
// shoving the value off-center the field is always prefilled
// here, so drop the label there.
.labelsHidden()
#endif
}
.formStyle(.grouped)
#if !os(tvOS)
.formStyle(.grouped)
#endif
HStack {
Button("Cancel", role: .cancel) { dismiss() }
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Spacer()
Button("Add Host") {
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
dismiss()
}
Button("Add Host") { add() }
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
@@ -47,5 +103,14 @@ struct AddHostSheet: View {
.frame(width: 380)
.fixedSize(horizontal: false, vertical: true)
#endif
#endif
}
private func add() {
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
dismiss()
}
}
@@ -13,6 +13,9 @@ import AppKit
#endif
import PunktfunkKit
import SwiftUI
#if os(tvOS)
import SwiftUINavigationTransitions
#endif
struct ContentView: View {
@StateObject private var model = SessionModel()
@@ -55,6 +58,7 @@ struct ContentView: View {
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
#if !os(tvOS)
.sheet(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in
// Backstop against a stale ceremony surfacing after dismissal (PairSheet
@@ -66,6 +70,7 @@ struct ContentView: View {
connect(pinned)
}
}
#endif
}
private var sessionView: some View {
@@ -110,18 +115,54 @@ struct ContentView: View {
emptyState
} else {
ScrollView {
LazyVGrid(columns: gridColumns, spacing: 16) {
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(store.hosts) { host in
hostCard(host)
}
}
.padding()
#if os(tvOS)
// Actions live below the hosts, not between them.
HStack(spacing: 32) {
Button {
showAddHost = true
} label: {
Label("Add Host", systemImage: "plus")
}
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
}
.padding(.top, 24)
#endif
}
}
}
.navigationTitle("Punktfunkempfänger")
#if os(tvOS)
// Pushed routes the Settings-app navigation feel (push animation, Menu
// pops) instead of modal overlays.
.navigationDestination(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.navigationDestination(isPresented: $showSettings) {
SettingsView()
}
.navigationDestination(item: $pairingTarget) { host in
PairSheet(host: host) { fingerprint in
guard pairingTarget?.id == host.id else { return }
store.pin(host.id, fingerprint: fingerprint)
var pinned = host
pinned.pinnedSHA256 = fingerprint
connect(pinned)
}
}
#endif
#if !os(tvOS)
.toolbar {
#if !os(macOS)
#if os(iOS)
// Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton }
@@ -138,14 +179,24 @@ struct ContentView: View {
}
#endif
}
#endif
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 360)
#endif
#if os(tvOS)
// The Settings-app slide for every push in this stack (top-level routes AND
// the pickers' drill-ins) SwiftUI's default on tvOS is a bare crossfade.
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio settles fast
// with just a hint of life, no visible overshoot ping-pong.
.customNavigationTransition(
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
#endif
#if !os(tvOS)
.sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
#if !os(macOS)
#if os(iOS)
.sheet(isPresented: $showSettings) {
NavigationStack {
SettingsView()
@@ -156,6 +207,7 @@ struct ContentView: View {
}
}
#endif
#endif
.alert(
"Connection failed",
isPresented: Binding(
@@ -175,11 +227,21 @@ struct ContentView: View {
private var gridColumns: [GridItem] {
#if os(macOS)
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
#elseif os(tvOS)
[GridItem(.adaptive(minimum: 320), spacing: 48)]
#else
[GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif
}
private var gridSpacing: CGFloat {
#if os(tvOS)
48 // the focused card scales up give it room instead of overlapping siblings
#else
16
#endif
}
private var addHostButton: some View {
Button {
showAddHost = true
@@ -209,6 +271,9 @@ struct ContentView: View {
#if os(iOS)
.controlSize(.large)
#endif
#if os(tvOS)
Button("Settings") { showSettings = true }
#endif
}
}
@@ -265,6 +330,9 @@ struct ContentView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, cardPadding)
.padding(.horizontal, 12)
#if !os(tvOS)
// tvOS: the .card button style owns platter + focus motion extra chrome
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay {
if host.id == mostRecentHostID {
@@ -272,6 +340,7 @@ struct ContentView: View {
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
}
}
#endif
}
#if os(tvOS)
.buttonStyle(.card)
@@ -1,6 +1,6 @@
// PIN pairing sheet. The host, started with --allow-pairing (or --require-pairing),
// prints a short PIN at startup ("PAIRING ARMED enter this PIN on the client to
// pair"); the user types it here. The ceremony is SPAKE2, so a wrong PIN buys an
// PIN pairing sheet. The host shows the pairing PIN in its web console (port 3000
// Pairing; also printed in the host's log when armed via --allow-pairing); the user
// types it here. The ceremony is SPAKE2, so a wrong PIN buys an
// attacker exactly one online guess for the user a typo just means "try again" (the
// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED
// fingerprint: the caller pins it, no manual comparison needed, and the host stores this
@@ -33,12 +33,76 @@ struct PairSheet: View {
@State private var busy = false
@State private var errorText: String?
@State private var token = CeremonyToken()
#if os(tvOS)
private enum EditField: String, Identifiable {
case pin, clientName
var id: String { rawValue }
}
@State private var editing: EditField?
#endif
var body: some View {
#if os(tvOS)
VStack(spacing: 24) {
Text("The PIN is shown in the host's web console "
+ "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TVFieldRow(
label: "PIN", value: pin, placeholder: "Shown in the host's web console"
) { editing = .pin }
TVFieldRow(
label: "Device name", value: clientName, placeholder: "Apple TV"
) { editing = .clientName }
if let errorText {
Text(errorText)
.font(.callout)
.foregroundStyle(.red)
}
HStack(spacing: 32) {
Button("Cancel", role: .cancel) {
token.cancelled = true
dismiss()
}
if busy {
ProgressView()
}
Button("Pair & Connect") { runCeremony() }
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Pair with \(host.displayName)")
.onDisappear { token.cancelled = true }
.fullScreenCover(item: $editing) { field in
switch field {
case .pin:
TVTextEntry(
title: "PIN (shown in the host's web console)", text: pin,
keyboardType: .numberPad
) {
pin = $0.trimmingCharacters(in: .whitespaces)
editing = nil
}
case .clientName:
TVTextEntry(title: "Device name", text: clientName) {
clientName = $0
editing = nil
}
}
}
#else
VStack(spacing: 0) {
Form {
Section {
TextField("PIN", text: $pin, prompt: Text("Shown in the host's log"))
TextField(
"PIN", text: $pin,
prompt: Text("Shown in the host's web console"))
.font(.system(.title3, design: .monospaced))
#if os(iOS)
.keyboardType(.numberPad)
@@ -46,12 +110,15 @@ struct PairSheet: View {
TextField(
"Client name", text: $clientName,
prompt: Text("How the host lists this Mac"))
#if os(tvOS)
.labelsHidden() // prefilled tvOS floats the label off-center
#endif
} header: {
Label("Pair with \(host.displayName)", systemImage: "lock.shield")
.foregroundStyle(.tint)
} footer: {
Text("The host prints the PIN when pairing is armed "
+ "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). "
Text("The PIN is shown in the host's web console "
+ "(http://<host>:3000 → Pairing). "
+ "Pairing verifies both sides at once — no fingerprint "
+ "comparison needed.")
.font(.caption)
@@ -65,7 +132,9 @@ struct PairSheet: View {
}
}
}
.formStyle(.grouped)
#if !os(tvOS)
.formStyle(.grouped)
#endif
HStack {
Button("Cancel", role: .cancel) {
token.cancelled = true
@@ -98,6 +167,7 @@ struct PairSheet: View {
#endif
.interactiveDismissDisabled(busy)
.onDisappear { token.cancelled = true } // any other dismissal path
#endif
}
private func runCeremony() {
@@ -126,16 +196,16 @@ struct PairSheet: View {
onPaired(fingerprint)
dismiss()
case .failure(PunktfunkClientError.wrongPIN):
errorText = "Wrong PIN — check the host's \u{201C}PAIRING ARMED\u{201D} "
+ "line and try again."
errorText = "Wrong PIN — check the host's web console (port 3000) "
+ "and try again."
case .failure(is ClientIdentityStore.IdentityError):
errorText = "Can't store this Mac's identity in the Keychain, so the "
+ "pairing would not survive a relaunch. Unlock the login "
+ "keychain and try again."
case .failure:
errorText = "Pairing failed. Is the host reachable, armed with "
+ "--allow-pairing, and not mid-session? Retries are rate-limited "
+ "to one per 2 seconds."
errorText = "Pairing failed. Is the host reachable, pairing armed "
+ "(web console → Pairing), and not mid-session? Retries are "
+ "rate-limited to one per 2 seconds."
}
}
}
@@ -9,6 +9,7 @@ import PunktfunkKit
import SwiftUI
struct SettingsView: View {
@Environment(\.dismiss) private var dismiss
@AppStorage("punktfunk.width") private var width = 1920
@AppStorage("punktfunk.height") private var height = 1080
@AppStorage("punktfunk.hz") private var hz = 60
@@ -22,6 +23,76 @@ struct SettingsView: View {
#endif
var body: some View {
#if os(tvOS)
// Native tv pattern: no inline text entry (typing numbers with a remote is
// miserable and the inline field chrome fights the focus system). The mode is
// a preset picker; pickers push selection lists like the system Settings app.
tvBody
#else
sharedBody
#endif
}
#if os(tvOS)
private static let presets: [(label: String, tag: String)] = [
("720p @ 60", "1280x720x60"),
("1080p @ 60", "1920x1080x60"),
("4K @ 60", "3840x2160x60"),
]
private var modeTag: Binding<String> {
Binding(
get: { "\(width)x\(height)x\(hz)" },
set: { tag in
let parts = tag.split(separator: "x").compactMap { Int($0) }
guard parts.count == 3 else { return }
width = parts[0]
height = parts[1]
hz = parts[2]
})
}
private var tvBody: some View {
let currentTag = "\(width)x\(height)x\(hz)"
let bounds = UIScreen.main.nativeBounds
let nativeTag = "\(Int(max(bounds.width, bounds.height)))x"
+ "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)"
var options = Self.presets
if !options.contains(where: { $0.tag == nativeTag }) {
options.insert(("This TV (native)", nativeTag), at: 0)
}
if !options.contains(where: { $0.tag == currentTag }) {
options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0)
}
let compositors: [(label: String, tag: Int)] = [
("Automatic", 0),
("KWin (KDE Plasma)", 1),
("wlroots (Sway / Hyprland)", 2),
("Mutter (GNOME)", 3),
("gamescope", 4),
]
return ScrollView {
VStack(spacing: 16) {
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
TVSelectionRow(
title: "Compositor", options: compositors, selection: $compositor)
Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. A specific compositor is honored only if "
+ "available on the host.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
}
.frame(maxWidth: 1000)
.frame(maxWidth: .infinity)
.padding(60)
}
.navigationTitle("Settings")
}
#endif
private var sharedBody: some View {
Form {
Section {
HStack {
@@ -0,0 +1,144 @@
// The native tvOS text-entry experience: real tvOS apps never edit text inline
// selecting a field presents the SYSTEM full-screen keyboard (Apple's "Designing the
// Keyboard Input Experience"). UIKit gives that for free: a UITextField that becomes
// first responder presents the fullscreen keyboard UI with the field's placeholder as
// the prompt. SwiftUI's inline TextField on tvOS is an expanding pill with stray
// chrome this bridge replaces it everywhere on tvOS.
#if os(tvOS)
import SwiftUI
import UIKit
/// Present inside a fullScreenCover: immediately raises the system keyboard for one
/// value, then calls `onDone` with the result (also on Menu-button dismissal, with
/// whatever was typed so far match the system apps' "edits stick" behavior).
struct TVTextEntry: UIViewControllerRepresentable {
let title: String
let text: String
var keyboardType: UIKeyboardType = .default
let onDone: (String) -> Void
func makeUIViewController(context: Context) -> TVTextEntryController {
let controller = TVTextEntryController()
controller.configure(
title: title, text: text, keyboardType: keyboardType, onDone: onDone)
return controller
}
func updateUIViewController(_ controller: TVTextEntryController, context: Context) {}
}
final class TVTextEntryController: UIViewController, UITextFieldDelegate {
private let field = UITextField()
private var onDone: ((String) -> Void)?
private var finished = false
func configure(
title: String, text: String, keyboardType: UIKeyboardType,
onDone: @escaping (String) -> Void
) {
field.placeholder = title
field.text = text
field.keyboardType = keyboardType
field.returnKeyType = .done
self.onDone = onDone
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
field.delegate = self
view.addSubview(field) // must be in a window to become first responder
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
field.becomeFirstResponder() // presents the tvOS fullscreen keyboard
}
func textFieldShouldReturn(_ textField: UITextField) -> Bool {
textField.resignFirstResponder()
return true
}
func textFieldDidEndEditing(_ textField: UITextField) {
guard !finished else { return }
finished = true
onDone?(textField.text ?? "")
}
}
/// A Settings-app-style value row: label leading, current value trailing the whole
/// row is one system lozenge, and pressing it opens the fullscreen keyboard.
struct TVFieldRow: View {
let label: String
let value: String
let placeholder: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Text(label)
Spacer()
Text(value.isEmpty ? placeholder : value)
.foregroundStyle(.secondary)
}
}
}
}
/// A Settings-app-style selection screen: pushed list of option rows, checkmark on the
/// current value, selecting pops back. Replaces Picker(.navigationLink), whose internal
/// list renders rows in the focused (dark-text) style while the push animates.
struct TVSelectionList<Tag: Hashable>: View {
let title: String
let options: [(label: String, tag: Tag)]
@Binding var selection: Tag
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(options, id: \.tag) { option in
Button {
selection = option.tag
dismiss()
} label: {
HStack {
Text(option.label)
Spacer()
if option.tag == selection {
Image(systemName: "checkmark")
}
}
}
}
}
.frame(maxWidth: 900)
.frame(maxWidth: .infinity)
.padding(60)
}
.navigationTitle(title)
}
}
/// The pushing row for a TVSelectionList: label leading, current value trailing.
struct TVSelectionRow<Tag: Hashable>: View {
let title: String
let options: [(label: String, tag: Tag)]
@Binding var selection: Tag
var body: some View {
NavigationLink {
TVSelectionList(title: title, options: options, selection: $selection)
} label: {
HStack {
Text(title)
Spacer()
Text(options.first { $0.tag == selection }?.label ?? "")
.foregroundStyle(.secondary)
}
}
}
}
#endif