Files
punktfunk/clients/apple/Sources/PunktfunkClient/ContentView.swift
T
enricobuehler 36a04e667c
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 30s
apple / swift (push) Successful in 1m16s
ci / bench (push) Successful in 1m34s
ci / rust (push) Successful in 2m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
deb / build-publish (push) Successful in 2m26s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 4m53s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m21s
fix(apple): capture the PS/Home button + fullscreen only while streaming
Two issues from live Mac testing, plus a requested fullscreen option:

- PS button: the Home/PS button (→ guide; the host maps it to the DualSense PS bit)
  does not reliably fire GCExtendedGamepad.valueChangedHandler on macOS, so its presses
  were dropped. Add a dedicated buttonHome.pressedChangedHandler that re-syncs. The host
  already maps BTN_GUIDE→PS, so this is the missing client half.
- Fullscreen: a macOS FullscreenController (NSViewRepresentable) takes the window
  fullscreen while a session is up (incl. the trust prompt over the blurred stream) and
  restores it on the host list — so only the stream is fullscreen, not the picker. New
  `fullscreenWhileStreaming` setting (default on) + a Settings "Window" toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 16:14:37 +00:00

317 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Hosts grid trust prompt live stream. ContentView is the coordinator: it owns the session
// model, host store, and LAN discovery; switches between the home grid (HomeView) and the live
// session; and holds the connect logic (it reads the @AppStorage stream mode). The grid + cards
// (HomeView/HostCards), the trust prompt (TrustCardView), and the HUD (StreamHUDView) live in
// their own files.
//
// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony pairing
// verifies both sides at once and is the only way into hosts running --require-pairing. Once
// pinned, reconnects are silent and a changed host identity refuses to connect.
#if os(macOS)
import AppKit
#endif
import PunktfunkKit
import SwiftUI
struct ContentView: View {
@StateObject private var model = SessionModel()
@StateObject private var store = HostStore()
@StateObject private var discovery = HostDiscovery()
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
@AppStorage(DefaultsKey.streamHz) private var hz = 60
@AppStorage(DefaultsKey.compositor) private var compositor = 0
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
@AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true
@State private var showAddHost = false
@State private var pairingTarget: StoredHost?
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
#if !os(macOS)
@State private var showSettings = false
#endif
var body: some View {
Group {
// The stream view's structural identity MUST be stable across the
// awaiting-trust streaming transition: recreating it restarts the pump,
// which has then already missed the opening IDR (infinite GOP no other
// keyframe ever comes) and decodes nothing. So: one branch per connection,
// trust prompt as an overlay.
if model.connection != nil {
sessionView
} else {
home
}
}
.onAppear {
seedDefaultModeIfNeeded()
autoConnectIfAsked()
}
.onChange(of: model.phase) { _, phase in
// A session actually started remember it on the card ("Connected ago"
// plus the accent ring on the most recent host).
if case .streaming = phase, let host = model.activeHost {
store.markConnected(host.id)
}
}
.onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more)
#if os(macOS)
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
// windowed on the host list so the picker isn't forced fullscreen. Opt-out in Settings.
.background(FullscreenController(active: fullscreenWhileStreaming && model.connection != nil))
#endif
// 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 handlePaired(host, fingerprint: fingerprint) }
}
.sheet(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.sheet(item: $libraryTarget) { host in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
}
}
#endif
}
private var home: some View {
#if os(macOS)
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
#else
HomeView(
store: store, model: model, discovery: discovery,
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
#endif
}
// MARK: - Session
private var sessionView: some View {
let pendingFingerprint: Data? = {
if case .awaitingTrust(let fp) = model.phase { return fp }
return nil
}()
return ZStack {
stream(captureEnabled: pendingFingerprint == nil)
.blur(radius: pendingFingerprint != nil ? 32 : 0)
.overlay {
if pendingFingerprint != nil {
Color.black.opacity(0.45)
}
}
if let fp = pendingFingerprint {
TrustCardView(
fingerprint: fp,
hostName: model.activeHost?.displayName ?? "host",
onCancel: { model.rejectTrust() },
onTrust: {
if let fp = model.confirmTrust(), let host = model.activeHost {
store.pin(host.id, fingerprint: fp)
}
},
onPairInstead: {
let host = model.activeHost
model.rejectTrust()
pairingTarget = host
})
}
}
#if os(macOS)
.frame(minWidth: 640, minHeight: 360)
.background(Color.black)
#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()
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
// disconnect control during play, the controller's buttons flow to the host instead of
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
// host as Start the Siri Remote is the intended disconnect path.
.onExitCommand { model.disconnect() }
#endif
}
private func stream(captureEnabled: Bool) -> some View {
Group {
if let conn = model.connection {
StreamView(
connection: conn,
captureEnabled: captureEnabled,
onCaptureChange: { [weak model] captured in
model?.mouseCaptured = captured
},
onFrame: { [meter = model.meter, latency = model.latency, offset = conn.clockOffsetNs] au in
meter.note(byteCount: au.data.count)
latency.record(ptsNs: au.ptsNs, offsetNs: offset)
},
onSessionEnd: { [weak model] in
Task { @MainActor in model?.sessionEnded() }
},
presentMeter: model.presentLatency
)
.overlay(alignment: .topTrailing) {
if captureEnabled { StreamHUDView(model: model, connection: conn) }
}
}
}
}
// MARK: - Connect
private func connect(_ host: StoredHost, launchID: String? = nil) {
// The gamepad-type setting resolves NOW (Automatic match the active physical
// controller): the host's virtual pad backend is fixed per session.
model.connect(
to: host,
width: UInt32(clamping: width), height: UInt32(clamping: height),
hz: UInt32(clamping: hz),
compositor: PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: compositor)) ?? .auto,
gamepad: GamepadManager.shared.resolveType(
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
bitrateKbps: UInt32(clamping: bitrateKbps),
launchID: launchID)
}
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
/// asks the host to launch it.
private func launchTitle(_ host: StoredHost, _ id: String) {
libraryTarget = nil
connect(host, launchID: id)
}
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
/// persists), then connect TOFU shows the fingerprint, which should match the advertised
/// `fp`. A `pair=required` host goes straight to the pairing ceremony instead.
private func connectDiscovered(_ d: DiscoveredHost) {
guard !model.isBusy else { return }
let host = StoredHost(name: d.name, address: d.host, port: d.port)
store.add(host)
if d.requiresPairing {
pairingTarget = host
} else {
connect(host)
}
}
/// Pairing ceremony succeeded pin the host and connect. The guard backstops a stale
/// ceremony surfacing after dismissal (PairSheet also self-discards those).
private func handlePaired(_ host: StoredHost, fingerprint: Data) {
guard pairingTarget?.id == host.id else { return }
store.pin(host.id, fingerprint: fingerprint)
var pinned = host
pinned.pinnedSHA256 = fingerprint
connect(pinned)
}
// MARK: - First-run + dev hooks
/// First run on iOS: default the stream mode to this device's native screen so the
/// video fills the display instead of letterboxing 1920×1080 onto a 4:3 iPad. (The
/// 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(macOS)
let defaults = UserDefaults.standard
guard defaults.object(forKey: DefaultsKey.streamWidth) == nil else { return }
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
defaults.set(Int(max(bounds.width, bounds.height)), forKey: DefaultsKey.streamWidth)
defaults.set(Int(min(bounds.width, bounds.height)), forKey: DefaultsKey.streamHeight)
defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: DefaultsKey.streamHz)
#endif
}
/// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
/// auto-confirmed dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
/// touching the saved host list. PUNKTFUNK_COMPOSITOR=kwin|gamescope| overrides the
/// compositor preference and PUNKTFUNK_REMOTE_GAMEPAD=xbox360|dualsense the virtual
/// pad type (same names as the host env knobs). (IPv4/hostname only.)
private func autoConnectIfAsked() {
guard let target = ProcessInfo.processInfo.environment["PUNKTFUNK_AUTOCONNECT"],
!target.isEmpty, model.phase == .idle
else { return }
let parts = target.split(separator: ":")
var host = StoredHost(name: "", address: String(parts[0]))
if parts.count == 2, let p = UInt16(parts[1]) { host.port = p }
if let mode = ProcessInfo.processInfo.environment["PUNKTFUNK_MODE"] {
let dims = mode.split(separator: "x").compactMap { Int($0) }
if dims.count == 3 {
width = dims[0]
height = dims[1]
hz = dims[2]
}
}
var pref = PunktfunkConnection.Compositor(
rawValue: UInt32(clamping: compositor)) ?? .auto
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_COMPOSITOR"],
let c = PunktfunkConnection.Compositor(name: name) {
pref = c
}
var pad = GamepadManager.shared.resolveType(
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: gamepadType)) ?? .auto)
if let name = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_GAMEPAD"],
let g = PunktfunkConnection.GamepadType(name: name) {
pad = g
}
var bitrate = UInt32(clamping: bitrateKbps)
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
let v = UInt32(kbps) {
bitrate = v
}
model.connect(
to: host,
width: UInt32(clamping: width), height: UInt32(clamping: height),
hz: UInt32(clamping: hz),
compositor: pref,
gamepad: pad,
bitrateKbps: bitrate,
autoTrust: true)
}
}
#if os(macOS)
/// Drives the hosting window in/out of native fullscreen from SwiftUI state. Mounted invisibly in
/// the view tree; on each `active` change it captures the window and toggles fullscreen only when
/// the current state differs (so it never fights a toggle already in flight, and never touches a
/// window the user fullscreened manually unless `active` says otherwise).
private struct FullscreenController: NSViewRepresentable {
let active: Bool
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) {
let want = active
DispatchQueue.main.async {
guard let window = view.window else { return }
let isFull = window.styleMask.contains(.fullScreen)
if want != isFull { window.toggleFullScreen(nil) }
}
}
}
#endif