3ba19f28a2
apple / swift (push) Waiting to run
apple / screenshots (push) Blocked by required conditions
release / apple (push) Waiting to run
ci / rust (push) Failing after 50s
ci / web (push) Successful in 57s
decky / build-publish (push) Successful in 14s
ci / docs-site (push) Successful in 1m2s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
flatpak / build-publish (push) Failing after 2m14s
ci / bench (push) Successful in 5m34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5m48s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5m46s
docker / deploy-docs (push) Successful in 23s
windows-host / package (push) Successful in 11m40s
arch / build-publish (push) Successful in 18m10s
deb / build-publish (push) Successful in 18m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m21s
android / android (push) Successful in 21m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 5m38s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m3s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 1m2s
The console UI now runs on tvOS through the NATIVE focus engine: carousel cards and settings rows are focusable Buttons (Siri Remote and pads both navigate; imperative scrollTo replaces the drop-prone scrollPosition binding), while iOS/macOS keep the 60 Hz poll untouched - on tvOS it carries only what focus has no concept of: X/Y screen actions and left/right value adjust with the poll's dominant-axis feel (onMoveCommand proved input-source-dependent: keyboard intercepted, pad dpad not -> double steps). Text entry uses the system fullscreen keyboard (TVTextEntry); pairing + library present as covers under the launcher; the game library defaults ON; settings values slide a quiet 14 pt in the step's direction. Session controls: controller/remote input routes EXCLUSIVELY through GameController during a stream (GCEventViewController, interaction disabled) - a pad's B no longer doubles as a UIKit menu press that ended sessions mid-game. Deliberate exits only: the cross-client escape chord (hold L1+R1+Start+Select 1.5 s - pf-client-core's contract, now implemented on all Apple platforms) and holding the remote's Back >= 1 s; the start-of-stream banner (now also on tvOS) teaches both. The Siri Remote's touch surface drives the host pointer - press = left click, Play/Pause = right click, release-tail jumps gated so motion stays truly relative. tvOS 26 regressions fixed at the root: the app-wide brand tint rendered every unfocused control as a blank pill (tint dropped on tvOS) and the 17 pt root font shrank the whole platform (29 pt there), plus 10-foot sizing across host cards, the gamepad screens, and the stats HUD (whose misleading "Press Menu" hint is gone). Acknowledgements scrolls by focus-sized chunks and Menu pops instead of suspending; full-width focusSections make the home actions reachable from any column. The presenter defaults to stage-3 glass pacing on tvOS (a 60 Hz panel fed a 60 fps stream is the sticky-FIFO worst case behind the 50 ms display stage) and is pickable from the gamepad settings; HDR capability advertises from AVPlayer.eligibleForHDRPlayback instead of the current mode's EDR headroom, so an SDR home screen no longer hides an HDR TV. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
9.5 KiB
Swift
228 lines
9.5 KiB
Swift
// 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
|
|
// client's identity in return.
|
|
|
|
import Foundation
|
|
import PunktfunkKit
|
|
import SwiftUI
|
|
|
|
/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call
|
|
/// can't be interrupted, so its completion checks this flag and self-discards — a late
|
|
/// success must NOT pin and auto-connect to a host the user cancelled out of. Only
|
|
/// touched on the main actor.
|
|
private final class CeremonyToken: @unchecked Sendable {
|
|
var cancelled = false
|
|
}
|
|
|
|
struct PairSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let host: StoredHost
|
|
/// Called with the verified host fingerprint after a successful ceremony.
|
|
let onPaired: (Data) -> Void
|
|
|
|
@State private var pin = ""
|
|
#if os(macOS)
|
|
@State private var clientName = Host.current().localizedName ?? "Mac"
|
|
#else
|
|
@State private var clientName = UIDevice.current.name
|
|
#endif
|
|
@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(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
|
.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(.geist(22, relativeTo: .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 web console"))
|
|
.font(.geistFixed(16)) // prominent, but on-brand mono (not oversized title3)
|
|
#if os(iOS)
|
|
.keyboardType(.numberPad)
|
|
#endif
|
|
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 PIN is shown in the host's web console "
|
|
+ "(http://<host>:3000 → Pairing). "
|
|
+ "Pairing verifies both sides at once — no fingerprint "
|
|
+ "comparison needed.")
|
|
.font(.geist(12, relativeTo: .caption))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let errorText {
|
|
Section {
|
|
Text(errorText)
|
|
.font(.geist(16, relativeTo: .callout))
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
}
|
|
#if !os(tvOS)
|
|
.formStyle(.grouped)
|
|
// Bring the grouped form's default system text down to the app's Geist scale so the sheet
|
|
// doesn't read oversized / out of place (matches AddHostSheet). The PIN field keeps its own
|
|
// explicit Geist Mono font.
|
|
.font(.geist(12, relativeTo: .callout))
|
|
.controlSize(.small)
|
|
#endif
|
|
HStack {
|
|
Button("Cancel", role: .cancel) {
|
|
token.cancelled = true
|
|
dismiss()
|
|
}
|
|
#if !os(tvOS)
|
|
.keyboardShortcut(.cancelAction)
|
|
#endif
|
|
Spacer()
|
|
if busy {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.padding(.trailing, 8)
|
|
}
|
|
Button("Pair & Connect") { runCeremony() }
|
|
.glassProminentButtonStyle()
|
|
#if !os(tvOS)
|
|
.keyboardShortcut(.defaultAction)
|
|
#endif
|
|
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
}
|
|
#if os(iOS)
|
|
.controlSize(.large)
|
|
#endif
|
|
.padding(16)
|
|
}
|
|
#if os(macOS)
|
|
.frame(width: 400)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
#endif
|
|
#if os(iOS)
|
|
// Bottom sheet instead of a full-screen modal (Liquid Glass background on iOS 26).
|
|
// .medium rests; .large is included so the sheet grows to keep the Pair/Cancel row
|
|
// above the keyboard when the PIN field is focused. Hide the grabber while the ceremony
|
|
// is in flight — dismissal is disabled then (interactiveDismissDisabled), so a drag
|
|
// would only rubber-band; the always-enabled Cancel button is the exit.
|
|
.presentationDetents([.medium, .large])
|
|
.presentationDragIndicator(busy ? .hidden : .visible)
|
|
#endif
|
|
.interactiveDismissDisabled(busy)
|
|
.onDisappear { token.cancelled = true } // any other dismissal path
|
|
#endif
|
|
}
|
|
|
|
private func runCeremony() {
|
|
busy = true
|
|
errorText = nil
|
|
let pin = pin.trimmingCharacters(in: .whitespaces)
|
|
let name = clientName.trimmingCharacters(in: .whitespaces)
|
|
let address = host.address
|
|
let port = host.port
|
|
let token = token
|
|
Task.detached(priority: .userInitiated) {
|
|
// Identity load + the ceremony both block — keep them off the main actor.
|
|
// loadForPairing is the strict variant: the host durably trusts this
|
|
// identity, so it must have made it into the Keychain.
|
|
let result = Result {
|
|
let identity = try ClientIdentityStore.shared.loadForPairing()
|
|
return try PunktfunkKit.pair(
|
|
host: address, port: port, identity: identity,
|
|
pin: pin, name: name.isEmpty ? "Mac" : name)
|
|
}
|
|
await MainActor.run {
|
|
guard !token.cancelled else { return } // sheet dismissed mid-ceremony
|
|
busy = false
|
|
switch result {
|
|
case .success(let fingerprint):
|
|
onPaired(fingerprint)
|
|
dismiss()
|
|
case .failure(PunktfunkClientError.wrongPIN):
|
|
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, pairing armed "
|
|
+ "(web console → Pairing), and not mid-session? Retries are "
|
|
+ "rate-limited to one per 2 seconds."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|