feat(apple): tvOS client — third app target, first-lit in the Apple TV simulator
ci / rust (push) Has been cancelled

The same app now runs on tvOS (target Punktfunk-tvOS, bundle io.unom.punktfunk.tvos),
validated live against the box: vkcube at 1280x720@60, 60 fps in the Apple TV 4K
simulator, glass HUD with a focusable Disconnect button.

- PunktfunkCore.xcframework grows tvOS device + universal-simulator slices. These are
  TIER-3 Rust targets (no prebuilt std): BUILD_TVOS=1 builds them with nightly and
  -Zbuild-std from rust-src — the full quic stack (quinn/rustls-ring/tokio) compiles
  for tvOS unchanged.
- The UIKit stream view covers iOS AND tvOS, with pointer interaction, pointer lock,
  touch forwarding and InputCapture gated to iOS — tvOS is view-only until gamepad
  capture lands (the natural tvOS input).
- SessionAudio on tvOS: .playback session, no mic (no app-accessible microphone).
- App chrome gates: keyboardShortcut/textSelection/controlSize/statusBarHidden are
  iOS/macOS-only; host cards use the focus-native .card button style on tvOS; the
  Audio settings section hides (system-routed); mode seeding works from the TV screen
  (1920x1080@60).
- Package platforms += .tvOS(.v17); new Xcode target + shared scheme
  (TARGETED_DEVICE_FAMILY 3, local-network usage description included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:10:40 +02:00
parent ee12e535ee
commit bfd8c7be93
11 changed files with 302 additions and 23 deletions
@@ -21,7 +21,9 @@ struct AddHostSheet: View {
.formStyle(.grouped)
HStack {
Button("Cancel", role: .cancel) { dismiss() }
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Spacer()
Button("Add Host") {
onAdd(StoredHost(
@@ -31,7 +33,9 @@ struct AddHostSheet: View {
dismiss()
}
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
}
#if os(iOS)
@@ -23,7 +23,7 @@ struct ContentView: View {
@AppStorage("punktfunk.compositor") private var compositor = 0
@State private var showAddHost = false
@State private var pairingTarget: StoredHost?
#if os(iOS)
#if !os(macOS)
@State private var showSettings = false
#endif
@@ -88,13 +88,16 @@ struct ContentView: View {
#if os(macOS)
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
#else
#elseif os(iOS)
// Streaming is immersive: edge-to-edge under the status bar and home
// indicator, both hidden for the session (they return with the hosts grid).
.background(Color.black)
.ignoresSafeArea()
.statusBarHidden(true)
.persistentSystemOverlays(.hidden)
#else
.background(Color.black)
.ignoresSafeArea()
#endif
}
@@ -118,7 +121,7 @@ struct ContentView: View {
}
.navigationTitle("Punktfunkempfänger")
.toolbar {
#if os(iOS)
#if !os(macOS)
// Adjacent trailing items share one glass pill (the system default).
ToolbarItem(placement: .topBarTrailing) { settingsButton }
ToolbarItem(placement: .topBarTrailing) { addHostButton }
@@ -142,7 +145,7 @@ struct ContentView: View {
.sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
#if os(iOS)
#if !os(macOS)
.sheet(isPresented: $showSettings) {
NavigationStack {
SettingsView()
@@ -185,7 +188,7 @@ struct ContentView: View {
}
}
#if os(iOS)
#if !os(macOS)
private var settingsButton: some View {
Button {
showSettings = true
@@ -270,7 +273,11 @@ struct ContentView: View {
}
}
}
#if os(tvOS)
.buttonStyle(.card)
#else
.buttonStyle(.plain)
#endif
.disabled(model.isBusy)
.contextMenu {
Button("Pair with PIN…") {
@@ -289,7 +296,7 @@ struct ContentView: View {
/// compiled-in AppStorage defaults only apply until any value is saved; macOS keeps
/// 1080p a desktop window is not the screen.)
private func seedDefaultModeIfNeeded() {
#if os(iOS)
#if !os(macOS)
let defaults = UserDefaults.standard
guard defaults.object(forKey: "punktfunk.width") == nil else { return }
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
@@ -332,19 +339,25 @@ struct ContentView: View {
.multilineTextAlignment(.center)
Text(Self.format(fingerprint: fingerprint))
.font(.system(.callout, design: .monospaced))
#if !os(tvOS)
.textSelection(.enabled)
#endif
.padding(10)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: 12) {
Button("Cancel", role: .cancel) { model.rejectTrust() }
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Button("Trust & Connect") {
if let fp = model.confirmTrust(), let host = model.activeHost {
store.pin(host.id, fingerprint: fp)
}
}
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
#if os(iOS)
.controlSize(.large)
@@ -419,7 +432,7 @@ struct ContentView: View {
: "Click the stream to capture input")
.font(.caption2)
.foregroundStyle(.secondary)
#else
#elseif os(iOS)
// Touch always plays directly; (hardware keyboard) toggles kb/mouse.
Text(model.mouseCaptured
? "⌘⎋ releases keyboard & mouse"
@@ -429,7 +442,9 @@ struct ContentView: View {
#endif
Button("Disconnect (⌘D)") { model.disconnect() }
.font(.caption)
#if !os(tvOS)
.keyboardShortcut("d", modifiers: .command)
#endif
}
.padding(10)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
@@ -71,7 +71,9 @@ struct PairSheet: View {
token.cancelled = true
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
Spacer()
if busy {
ProgressView()
@@ -80,7 +82,9 @@ struct PairSheet: View {
}
Button("Pair & Connect") { runCeremony() }
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
}
#if os(iOS)
@@ -42,6 +42,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
#if !os(tvOS)
Section {
#if os(macOS)
Picker("Speaker", selection: $speakerUID) {
@@ -55,7 +56,9 @@ struct SettingsView: View {
}
}
#endif
#if !os(tvOS)
Toggle("Send microphone to the host", isOn: $micEnabled)
#endif
#if os(macOS)
Picker("Microphone", selection: $micUID) {
Text("System default").tag("")
@@ -78,6 +81,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundStyle(.secondary)
}
#endif
Section {
Picker("Compositor", selection: $compositor) {
Text("Automatic").tag(0)