feat(apple): tvOS client — third app target, first-lit in the Apple TV simulator
ci / rust (push) Has been cancelled
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:
@@ -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)
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
#if os(iOS)
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
import Foundation
|
||||
|
||||
@@ -161,8 +161,18 @@ public final class SessionAudio {
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#elseif os(tvOS)
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
} catch {
|
||||
log.warning("AVAudioSession setup failed: \(error.localizedDescription)")
|
||||
}
|
||||
#endif
|
||||
startPlayback(speakerUID: speakerUID)
|
||||
#if os(tvOS)
|
||||
// No app-accessible microphone input on tvOS — playback only.
|
||||
#else
|
||||
guard micEnabled else { return }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
@@ -177,6 +187,7 @@ public final class SessionAudio {
|
||||
default:
|
||||
log.warning("microphone access denied — mic uplink disabled (System Settings → Privacy)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Stop both directions. Safe from any thread; waits the drain thread out (≤ its
|
||||
@@ -199,7 +210,7 @@ public final class SessionAudio {
|
||||
if wasDraining {
|
||||
_ = drainDone.wait(timeout: .now() + .milliseconds(400))
|
||||
}
|
||||
#if os(iOS)
|
||||
#if !os(macOS)
|
||||
// Release the session so audio we interrupted (Music, podcasts) gets its
|
||||
// resume cue.
|
||||
do {
|
||||
@@ -310,6 +321,7 @@ public final class SessionAudio {
|
||||
|
||||
// MARK: - Mic (mic → host)
|
||||
|
||||
#if !os(tvOS)
|
||||
private func startCapture(micUID: String) {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
@@ -408,6 +420,7 @@ public final class SessionAudio {
|
||||
stateLock.unlock()
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private static func setDevice(_ id: AudioDeviceID, on unit: AudioUnit) -> Bool {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// The public type is named StreamView like its macOS twin (each is platform-gated), so
|
||||
// the SwiftUI app layer is identical on both platforms.
|
||||
|
||||
#if os(iOS)
|
||||
#if os(iOS) || os(tvOS)
|
||||
import AVFoundation
|
||||
import GameController
|
||||
import PunktfunkCore
|
||||
@@ -66,20 +66,24 @@ public struct StreamView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
public final class StreamViewController: UIViewController, UIPointerInteractionDelegate {
|
||||
public final class StreamViewController: UIViewController {
|
||||
public private(set) var connection: PunktfunkConnection?
|
||||
private var pump: StreamPump?
|
||||
private var inputCapture: InputCapture?
|
||||
private var captured = false
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
#if os(iOS)
|
||||
private var inputCapture: InputCapture?
|
||||
fileprivate var captured = false
|
||||
private var pointerInteraction: UIPointerInteraction?
|
||||
#endif
|
||||
|
||||
var onCaptureChange: ((Bool) -> Void)?
|
||||
|
||||
var captureEnabled = true {
|
||||
didSet {
|
||||
guard captureEnabled != oldValue else { return }
|
||||
#if os(iOS)
|
||||
setCaptured(captureEnabled)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +94,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
|
||||
public override func loadView() {
|
||||
view = StreamLayerUIView()
|
||||
#if os(iOS)
|
||||
// Hide the iPadOS cursor while it hovers the video: the host renders its own
|
||||
// cursor from our raw deltas, so the local one only diverges from it. (True
|
||||
// pointer LOCK — prefersPointerLocked — isn't consulted through
|
||||
@@ -97,16 +102,13 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
let interaction = UIPointerInteraction(delegate: self)
|
||||
view.addInteraction(interaction)
|
||||
pointerInteraction = interaction
|
||||
#endif
|
||||
}
|
||||
|
||||
public func pointerInteraction(
|
||||
_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion
|
||||
) -> UIPointerStyle? {
|
||||
captured ? .hidden() : nil
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
public override var prefersPointerLocked: Bool { captured }
|
||||
public override var prefersHomeIndicatorAutoHidden: Bool { true }
|
||||
#endif
|
||||
|
||||
func start(
|
||||
connection: PunktfunkConnection,
|
||||
@@ -116,6 +118,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
stop()
|
||||
self.connection = connection
|
||||
loadViewIfNeeded()
|
||||
#if os(iOS)
|
||||
// Read the LIVE mode per touch batch — an accepted requestMode() mid-stream
|
||||
// changes the letterbox, and touches must follow it.
|
||||
streamView.currentHostMode = { [weak connection] in
|
||||
@@ -137,6 +140,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
}
|
||||
capture.start()
|
||||
inputCapture = capture
|
||||
#endif
|
||||
|
||||
let pump = StreamPump()
|
||||
pump.start(
|
||||
@@ -144,6 +148,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||
self.pump = pump
|
||||
|
||||
#if os(iOS)
|
||||
// GC only delivers while active; everything held is flushed by InputCapture's
|
||||
// own resign observer — here we just mirror the capture state for the HUD and
|
||||
// the pointer lock.
|
||||
@@ -156,21 +161,25 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
if captureEnabled {
|
||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func stop() {
|
||||
setCaptured(false)
|
||||
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||
observers.removeAll()
|
||||
#if os(iOS)
|
||||
setCaptured(false)
|
||||
inputCapture?.stop()
|
||||
inputCapture = nil
|
||||
streamView.onTouchEvent = nil
|
||||
streamView.currentHostMode = nil
|
||||
#endif
|
||||
pump?.stop()
|
||||
pump = nil
|
||||
connection = nil
|
||||
streamView.onTouchEvent = nil
|
||||
streamView.currentHostMode = nil
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func setCaptured(_ on: Bool) {
|
||||
if on {
|
||||
guard captureEnabled, !captured, pump != nil else { return }
|
||||
@@ -187,6 +196,7 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
let captured = captured
|
||||
DispatchQueue.main.async { onCaptureChange?(captured) }
|
||||
}
|
||||
#endif
|
||||
|
||||
deinit {
|
||||
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||
@@ -194,6 +204,16 @@ public final class StreamViewController: UIViewController, UIPointerInteractionD
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension StreamViewController: UIPointerInteractionDelegate {
|
||||
public func pointerInteraction(
|
||||
_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion
|
||||
) -> UIPointerStyle? {
|
||||
captured ? .hidden() : nil
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// The layer-backed video surface + touch source. Touches are mapped through the
|
||||
/// aspect-fit letterbox into host-mode pixels (surface == host mode, so the host-side
|
||||
/// rescale is the identity); touches outside the video area are clamped onto its edge.
|
||||
@@ -204,23 +224,28 @@ final class StreamLayerUIView: UIView {
|
||||
layer as! AVSampleBufferDisplayLayer
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// Reads the LIVE negotiated mode in pixels (the touch coordinate space).
|
||||
var currentHostMode: (() -> CGSize)?
|
||||
var onTouchEvent: ((PunktfunkInputEvent) -> Void)?
|
||||
|
||||
/// Wire touch ids per active UITouch; ids are reused after the touch ends.
|
||||
private var touchIDs: [ObjectIdentifier: UInt32] = [:]
|
||||
#endif
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
displayLayer.videoGravity = .resizeAspect
|
||||
#if os(iOS)
|
||||
isMultipleTouchEnabled = true
|
||||
#endif
|
||||
backgroundColor = .black
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("not used") }
|
||||
|
||||
#if os(iOS)
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
forward(touches, kind: .down)
|
||||
}
|
||||
@@ -277,11 +302,14 @@ final class StreamLayerUIView: UIView {
|
||||
while touchIDs.values.contains(id) { id += 1 }
|
||||
return id
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
extension CGFloat {
|
||||
fileprivate func clamped(to range: ClosedRange<CGFloat>) -> CGFloat {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user