From bfd8c7be9306e04f83229a9dd43c42744ade7280 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 11 Jun 2026 13:10:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(apple):=20tvOS=20client=20=E2=80=94=20thir?= =?UTF-8?q?d=20app=20target,=20first-lit=20in=20the=20Apple=20TV=20simulat?= =?UTF-8?q?or?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- clients/apple/Package.swift | 2 +- .../apple/Punktfunk.xcodeproj/project.pbxproj | 115 ++++++++++++++++++ .../xcschemes/Punktfunk-tvOS.xcscheme | 77 ++++++++++++ .../PunktfunkClient/AddHostSheet.swift | 4 + .../Sources/PunktfunkClient/ContentView.swift | 29 +++-- .../Sources/PunktfunkClient/PairSheet.swift | 4 + .../PunktfunkClient/SettingsView.swift | 4 + .../Sources/PunktfunkKit/InputCapture.swift | 2 +- .../Sources/PunktfunkKit/SessionAudio.swift | 15 ++- .../Sources/PunktfunkKit/StreamViewIOS.swift | 54 ++++++-- scripts/build-xcframework.sh | 19 +++ 11 files changed, 302 insertions(+), 23 deletions(-) create mode 100644 clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-tvOS.xcscheme diff --git a/clients/apple/Package.swift b/clients/apple/Package.swift index 52125ba..aa4a389 100644 --- a/clients/apple/Package.swift +++ b/clients/apple/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "PunktfunkKit", - platforms: [.macOS(.v14), .iOS(.v17)], + platforms: [.macOS(.v14), .iOS(.v17), .tvOS(.v17)], products: [ .library(name: "PunktfunkKit", targets: ["PunktfunkKit"]), .executable(name: "PunktfunkClient", targets: ["PunktfunkClient"]), diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index 518f5a4..13f9d69 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -9,11 +9,13 @@ /* Begin PBXBuildFile section */ AA0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = AA0000000000000000000006 /* PunktfunkKit */; }; BB0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = BB0000000000000000000006 /* PunktfunkKit */; }; + CC0000000000000000000005 /* PunktfunkKit in Frameworks */ = {isa = PBXBuildFile; productRef = CC0000000000000000000006 /* PunktfunkKit */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ AA0000000000000000000001 /* Punktfunk.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Punktfunk.app; sourceTree = BUILT_PRODUCTS_DIR; }; BB0000000000000000000001 /* Punktfunk-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + CC0000000000000000000001 /* Punktfunk-tvOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Punktfunk-tvOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -46,6 +48,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CC0000000000000000000004 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CC0000000000000000000005 /* PunktfunkKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -63,6 +73,7 @@ children = ( AA0000000000000000000001 /* Punktfunk.app */, BB0000000000000000000001 /* Punktfunk-iOS.app */, + CC0000000000000000000001 /* Punktfunk-tvOS.app */, ); name = Products; sourceTree = ""; @@ -118,6 +129,30 @@ productReference = BB0000000000000000000001 /* Punktfunk-iOS.app */; productType = "com.apple.product-type.application"; }; + CC0000000000000000000009 /* Punktfunk-tvOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = CC000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-tvOS" */; + buildPhases = ( + CC000000000000000000000B /* Sources */, + CC0000000000000000000004 /* Frameworks */, + CC000000000000000000000C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + AA0000000000000000000002 /* App */, + AA0000000000000000000003 /* Sources/PunktfunkClient */, + ); + name = "Punktfunk-tvOS"; + packageProductDependencies = ( + CC0000000000000000000006 /* PunktfunkKit */, + ); + productName = "Punktfunk-tvOS"; + productReference = CC0000000000000000000001 /* Punktfunk-tvOS.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -150,6 +185,7 @@ targets = ( AA0000000000000000000009 /* Punktfunk */, BB0000000000000000000009 /* Punktfunk-iOS */, + CC0000000000000000000009 /* Punktfunk-tvOS */, ); }; /* End PBXProject section */ @@ -169,6 +205,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CC000000000000000000000C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -186,6 +229,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CC000000000000000000000B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin XCBuildConfiguration section */ @@ -434,9 +484,70 @@ }; name = Release; }; + CC0000000000000000000012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Debug; + }; + CC0000000000000000000013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Punktfunkempfänger"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Punktfunk connects directly to your punktfunk host on the local network to stream video, audio, and input."; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1; + PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk.tvos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = appletvos; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 3; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + CC000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk-tvOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CC0000000000000000000012 /* Debug */, + CC0000000000000000000013 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Punktfunk" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -482,6 +593,10 @@ isa = XCSwiftPackageProductDependency; productName = PunktfunkKit; }; + CC0000000000000000000006 /* PunktfunkKit */ = { + isa = XCSwiftPackageProductDependency; + productName = PunktfunkKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = AA000000000000000000000D /* Project object */; diff --git a/clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-tvOS.xcscheme b/clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-tvOS.xcscheme new file mode 100644 index 0000000..391602d --- /dev/null +++ b/clients/apple/Punktfunk.xcodeproj/xcshareddata/xcschemes/Punktfunk-tvOS.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift index d6f12df..16fe4a3 100644 --- a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index e5731a4..3adabc6 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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)) diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index 9d9f5f3..3a37dd0 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift index 16aed9a..06f3201 100644 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/SettingsView.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkKit/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/InputCapture.swift index 0c77103..1b6e663 100644 --- a/clients/apple/Sources/PunktfunkKit/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/InputCapture.swift @@ -26,7 +26,7 @@ #if os(macOS) import AppKit #endif -#if os(iOS) +#if canImport(UIKit) import UIKit #endif import Foundation diff --git a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/SessionAudio.swift index c870e19..0634dcc 100644 --- a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/SessionAudio.swift @@ -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 { diff --git a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift index 90b9a21..6188506 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift @@ -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, 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 { Swift.min(Swift.max(self, range.lowerBound), range.upperBound) } } #endif +#endif diff --git a/scripts/build-xcframework.sh b/scripts/build-xcframework.sh index ecf4837..8f763fd 100644 --- a/scripts/build-xcframework.sh +++ b/scripts/build-xcframework.sh @@ -12,6 +12,7 @@ cd "$(dirname "$0")/.." TARGETS_MAC=(aarch64-apple-darwin x86_64-apple-darwin) BUILD_IOS="${BUILD_IOS:-0}" # BUILD_IOS=1 adds iOS device + simulator slices (rustup targets aarch64-apple-ios{,-sim}) +BUILD_TVOS="${BUILD_TVOS:-0}" # BUILD_TVOS=1 adds tvOS slices — TIER-3 Rust targets: needs `rustup toolchain install nightly` + `rustup component add rust-src --toolchain nightly` # Deployment targets must match Package.swift's platforms, or every consumer link emits # "object file was built for newer macOS version" warnings. @@ -23,6 +24,15 @@ if [[ "$BUILD_IOS" == "1" ]]; then IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build --release -p punktfunk-core --features quic --target aarch64-apple-ios-sim IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo build --release -p punktfunk-core --features quic --target x86_64-apple-ios fi +if [[ "$BUILD_TVOS" == "1" ]]; then + # Tier-3 targets: no prebuilt std — nightly + -Zbuild-std compiles it from rust-src. + TVOS_DEPLOYMENT_TARGET=17.0 cargo +nightly build --release -p punktfunk-core --features quic \ + -Z build-std=std,panic_abort --target aarch64-apple-tvos + TVOS_DEPLOYMENT_TARGET=17.0 cargo +nightly build --release -p punktfunk-core --features quic \ + -Z build-std=std,panic_abort --target aarch64-apple-tvos-sim + TVOS_DEPLOYMENT_TARGET=17.0 cargo +nightly build --release -p punktfunk-core --features quic \ + -Z build-std=std,panic_abort --target x86_64-apple-tvos +fi STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT @@ -59,6 +69,15 @@ if [[ "$BUILD_IOS" == "1" ]]; then ARGS+=(-library target/aarch64-apple-ios/release/libpunktfunk_core.a -headers "$STAGE/include") ARGS+=(-library "$STAGE/iossim/libpunktfunk_core.a" -headers "$STAGE/include") fi +if [[ "$BUILD_TVOS" == "1" ]]; then + mkdir -p "$STAGE/tvossim" + lipo -create \ + target/aarch64-apple-tvos-sim/release/libpunktfunk_core.a \ + target/x86_64-apple-tvos/release/libpunktfunk_core.a \ + -output "$STAGE/tvossim/libpunktfunk_core.a" + ARGS+=(-library target/aarch64-apple-tvos/release/libpunktfunk_core.a -headers "$STAGE/include") + ARGS+=(-library "$STAGE/tvossim/libpunktfunk_core.a" -headers "$STAGE/include") +fi # Cargo does NOT fingerprint MACOSX_DEPLOYMENT_TARGET — units cached from a build without # it keep their old minos forever. Refuse to ship anything newer than the package floor