Merge remote-tracking branch 'origin/main'
ci / web (push) Failing after 37s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m18s
ci / web (push) Failing after 37s
ci / rust (push) Successful in 54s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
ci / docs-site (push) Failing after 36s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m18s
This commit is contained in:
@@ -1,32 +1,30 @@
|
|||||||
// Hosts grid ⇄ trust prompt ⇄ live stream.
|
// 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.
|
||||||
//
|
//
|
||||||
// Home is a grid of saved hosts (click to connect); "+" in the toolbar adds one; the
|
// Two ways to establish trust on first contact: the TOFU prompt (host fingerprint over the
|
||||||
// stream mode lives in Settings (⌘,). Two ways to establish trust on first contact:
|
// live-but-blurred stream, compared with the host's log) or the PIN pairing ceremony — pairing
|
||||||
// the TOFU prompt (host fingerprint over the live-but-blurred stream, user compares it
|
// verifies both sides at once and is the only way into hosts running --require-pairing. Once
|
||||||
// with the host's log) or the PIN pairing ceremony (right-click a card → "Pair with
|
// pinned, reconnects are silent and a changed host identity refuses to connect.
|
||||||
// PIN…", or from the trust prompt itself) — 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)
|
#if os(macOS)
|
||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(tvOS)
|
|
||||||
import SwiftUINavigationTransitions
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@StateObject private var model = SessionModel()
|
@StateObject private var model = SessionModel()
|
||||||
@StateObject private var store = HostStore()
|
@StateObject private var store = HostStore()
|
||||||
@StateObject private var discovery = HostDiscovery()
|
@StateObject private var discovery = HostDiscovery()
|
||||||
@AppStorage("punktfunk.width") private var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||||
@AppStorage("punktfunk.height") private var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||||
@AppStorage("punktfunk.hz") private var hz = 60
|
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||||
@AppStorage("punktfunk.compositor") private var compositor = 0
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
@AppStorage("punktfunk.gamepadType") private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@State private var showAddHost = false
|
@State private var showAddHost = false
|
||||||
@State private var pairingTarget: StoredHost?
|
@State private var pairingTarget: StoredHost?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@State private var speedTestTarget: StoredHost?
|
||||||
@@ -64,15 +62,7 @@ struct ContentView: View {
|
|||||||
// is sequential, a pairing connection would queue behind the live session).
|
// is sequential, a pairing connection would queue behind the live session).
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
.sheet(item: $pairingTarget) { host in
|
.sheet(item: $pairingTarget) { host in
|
||||||
PairSheet(host: host) { fingerprint in
|
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||||
// Backstop against a stale ceremony surfacing after dismissal (PairSheet
|
|
||||||
// also self-discards those): only act while this host's sheet is up.
|
|
||||||
guard pairingTarget?.id == host.id else { return }
|
|
||||||
store.pin(host.id, fingerprint: fingerprint)
|
|
||||||
var pinned = host
|
|
||||||
pinned.pinnedSHA256 = fingerprint
|
|
||||||
connect(pinned)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.sheet(item: $speedTestTarget) { host in
|
.sheet(item: $speedTestTarget) { host in
|
||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
@@ -80,6 +70,24 @@ struct ContentView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var home: some View {
|
||||||
|
#if os(macOS)
|
||||||
|
HomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
|
speedTestTarget: $speedTestTarget,
|
||||||
|
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
||||||
|
#else
|
||||||
|
HomeView(
|
||||||
|
store: store, model: model, discovery: discovery,
|
||||||
|
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
||||||
|
speedTestTarget: $speedTestTarget, showSettings: $showSettings,
|
||||||
|
connect: connect, connectDiscovered: connectDiscovered, onPaired: handlePaired)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session
|
||||||
|
|
||||||
private var sessionView: some View {
|
private var sessionView: some View {
|
||||||
let pendingFingerprint: Data? = {
|
let pendingFingerprint: Data? = {
|
||||||
if case .awaitingTrust(let fp) = model.phase { return fp }
|
if case .awaitingTrust(let fp) = model.phase { return fp }
|
||||||
@@ -94,7 +102,20 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let fp = pendingFingerprint {
|
if let fp = pendingFingerprint {
|
||||||
trustCard(fp)
|
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)
|
#if os(macOS)
|
||||||
@@ -118,483 +139,6 @@ struct ContentView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Home (hosts grid)
|
|
||||||
|
|
||||||
private var home: some View {
|
|
||||||
NavigationStack {
|
|
||||||
Group {
|
|
||||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
|
||||||
emptyState
|
|
||||||
} else {
|
|
||||||
ScrollView {
|
|
||||||
if !store.hosts.isEmpty {
|
|
||||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
|
||||||
ForEach(store.hosts) { host in
|
|
||||||
hostCard(host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
if !discoveredUnsaved.isEmpty {
|
|
||||||
discoveredSection
|
|
||||||
}
|
|
||||||
#if os(tvOS)
|
|
||||||
// Actions live below the hosts, not between them.
|
|
||||||
HStack(spacing: 32) {
|
|
||||||
Button {
|
|
||||||
showAddHost = true
|
|
||||||
} label: {
|
|
||||||
Label("Add Host", systemImage: "plus")
|
|
||||||
}
|
|
||||||
Button {
|
|
||||||
showSettings = true
|
|
||||||
} label: {
|
|
||||||
Label("Settings", systemImage: "gearshape")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.top, 24)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationTitle("Punktfunkempfänger")
|
|
||||||
// Browse the LAN for advertised hosts only while the grid is up — not during a
|
|
||||||
// session. The home appears/disappears as the stream swaps in and out.
|
|
||||||
.onAppear { discovery.start() }
|
|
||||||
.onDisappear { discovery.stop() }
|
|
||||||
#if os(tvOS)
|
|
||||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
|
||||||
// pops) instead of modal overlays.
|
|
||||||
.navigationDestination(isPresented: $showAddHost) {
|
|
||||||
AddHostSheet { store.add($0) }
|
|
||||||
}
|
|
||||||
.navigationDestination(isPresented: $showSettings) {
|
|
||||||
SettingsView()
|
|
||||||
}
|
|
||||||
.navigationDestination(item: $pairingTarget) { host in
|
|
||||||
PairSheet(host: host) { fingerprint in
|
|
||||||
guard pairingTarget?.id == host.id else { return }
|
|
||||||
store.pin(host.id, fingerprint: fingerprint)
|
|
||||||
var pinned = host
|
|
||||||
pinned.pinnedSHA256 = fingerprint
|
|
||||||
connect(pinned)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.navigationDestination(item: $speedTestTarget) { host in
|
|
||||||
SpeedTestSheet(host: host)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#if !os(tvOS)
|
|
||||||
.toolbar {
|
|
||||||
#if os(iOS)
|
|
||||||
// Adjacent trailing items share one glass pill (the system default).
|
|
||||||
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
|
||||||
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
|
||||||
#else
|
|
||||||
ToolbarItem(placement: .primaryAction) {
|
|
||||||
addHostButton
|
|
||||||
.help("Add a host")
|
|
||||||
}
|
|
||||||
ToolbarItem {
|
|
||||||
SettingsLink {
|
|
||||||
Label("Settings", systemImage: "gearshape")
|
|
||||||
}
|
|
||||||
.help("Stream mode and settings")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.frame(minWidth: 480, minHeight: 360)
|
|
||||||
#endif
|
|
||||||
#if os(tvOS)
|
|
||||||
// The Settings-app slide for every push in this stack (top-level routes AND
|
|
||||||
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
|
|
||||||
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
|
|
||||||
// with just a hint of life, no visible overshoot ping-pong.
|
|
||||||
.customNavigationTransition(
|
|
||||||
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
|
|
||||||
#endif
|
|
||||||
#if !os(tvOS)
|
|
||||||
.sheet(isPresented: $showAddHost) {
|
|
||||||
AddHostSheet { store.add($0) }
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
.sheet(isPresented: $showSettings) {
|
|
||||||
NavigationStack {
|
|
||||||
SettingsView()
|
|
||||||
.navigationTitle("Settings")
|
|
||||||
.toolbar {
|
|
||||||
Button("Done") { showSettings = false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
#endif
|
|
||||||
.alert(
|
|
||||||
"Connection failed",
|
|
||||||
isPresented: Binding(
|
|
||||||
get: { model.errorMessage != nil },
|
|
||||||
set: { if !$0 { model.errorMessage = nil } }
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Button("OK", role: .cancel) {}
|
|
||||||
} message: {
|
|
||||||
Text(model.errorMessage ?? "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the
|
|
||||||
/// columns FILL the width so the cards stay edge-aligned with the title and bars —
|
|
||||||
/// sized touch-first: one column on iPhone portrait, 3–4 generous cards on iPad.
|
|
||||||
private var gridColumns: [GridItem] {
|
|
||||||
#if os(macOS)
|
|
||||||
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
|
||||||
#elseif os(tvOS)
|
|
||||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
|
||||||
#else
|
|
||||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var gridSpacing: CGFloat {
|
|
||||||
#if os(tvOS)
|
|
||||||
48 // the focused card scales up — give it room instead of overlapping siblings
|
|
||||||
#else
|
|
||||||
16
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
private var addHostButton: some View {
|
|
||||||
Button {
|
|
||||||
showAddHost = true
|
|
||||||
} label: {
|
|
||||||
Label("Add Host", systemImage: "plus")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#if !os(macOS)
|
|
||||||
private var settingsButton: some View {
|
|
||||||
Button {
|
|
||||||
showSettings = true
|
|
||||||
} label: {
|
|
||||||
Label("Settings", systemImage: "gearshape")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private var emptyState: some View {
|
|
||||||
ContentUnavailableView {
|
|
||||||
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
|
||||||
} description: {
|
|
||||||
Text("Add your punktfunk host with the + button.")
|
|
||||||
} actions: {
|
|
||||||
Button("Add Host") { showAddHost = true }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
#if os(iOS)
|
|
||||||
.controlSize(.large)
|
|
||||||
#endif
|
|
||||||
#if os(tvOS)
|
|
||||||
Button("Settings") { showSettings = true }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hostCard(_ host: StoredHost) -> some View {
|
|
||||||
let isConnecting = model.phase == .connecting && model.activeHost?.id == host.id
|
|
||||||
#if os(iOS)
|
|
||||||
let iconSize: CGFloat = 56
|
|
||||||
let iconBox: CGFloat = 76
|
|
||||||
let cardPadding: CGFloat = 28
|
|
||||||
let nameFont = Font.title3.weight(.semibold)
|
|
||||||
#else
|
|
||||||
let iconSize: CGFloat = 42
|
|
||||||
let iconBox: CGFloat = 56
|
|
||||||
let cardPadding: CGFloat = 18
|
|
||||||
let nameFont = Font.headline
|
|
||||||
#endif
|
|
||||||
return Button {
|
|
||||||
connect(host)
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
ZStack {
|
|
||||||
Image(systemName: "play.display")
|
|
||||||
.font(.system(size: iconSize, weight: .light))
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.opacity(isConnecting ? 0.3 : 1)
|
|
||||||
if isConnecting {
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: iconBox)
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(host.displayName)
|
|
||||||
.font(nameFont)
|
|
||||||
.lineLimit(1)
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
if host.pinnedSHA256 != nil {
|
|
||||||
Image(systemName: "lock.fill")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Text("\(host.address):\(String(host.port))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
if let last = host.lastConnected {
|
|
||||||
Text("Connected \(last, format: .relative(presentation: .named))")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, cardPadding)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
#if !os(tvOS)
|
|
||||||
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
|
||||||
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
.overlay {
|
|
||||||
if host.id == mostRecentHostID {
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(tvOS)
|
|
||||||
.buttonStyle(.card)
|
|
||||||
#else
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
#endif
|
|
||||||
.disabled(model.isBusy)
|
|
||||||
.contextMenu {
|
|
||||||
Button("Pair with PIN…") {
|
|
||||||
guard !model.isBusy else { return }
|
|
||||||
pairingTarget = host
|
|
||||||
}
|
|
||||||
Button("Test Network Speed…") {
|
|
||||||
guard !model.isBusy else { return }
|
|
||||||
speedTestTarget = host
|
|
||||||
}
|
|
||||||
if host.pinnedSHA256 != nil {
|
|
||||||
Button("Forget Identity") { store.forgetIdentity(host) }
|
|
||||||
}
|
|
||||||
Button("Remove", role: .destructive) { store.remove(host) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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: "punktfunk.width") == nil else { return }
|
|
||||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
|
||||||
defaults.set(Int(max(bounds.width, bounds.height)), forKey: "punktfunk.width")
|
|
||||||
defaults.set(Int(min(bounds.width, bounds.height)), forKey: "punktfunk.height")
|
|
||||||
defaults.set(UIScreen.main.maximumFramesPerSecond, forKey: "punktfunk.hz")
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The host of the most recent session — its card carries the accent ring.
|
|
||||||
private var mostRecentHostID: UUID? {
|
|
||||||
store.hosts
|
|
||||||
.compactMap { host in host.lastConnected.map { (host.id, $0) } }
|
|
||||||
.max { $0.1 < $1.1 }?.0
|
|
||||||
}
|
|
||||||
|
|
||||||
private func connect(_ host: StoredHost) {
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - LAN discovery (mDNS)
|
|
||||||
|
|
||||||
/// Discovered hosts not already saved (matched by address+port) — the saved grid shows
|
|
||||||
/// the rest, so this section only surfaces genuinely-new hosts on the network.
|
|
||||||
private var discoveredUnsaved: [DiscoveredHost] {
|
|
||||||
discovery.hosts.filter { d in
|
|
||||||
!store.hosts.contains { $0.address == d.host && $0.port == d.port }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var discoveredSection: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
|
||||||
.font(.headline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.padding(.horizontal)
|
|
||||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
|
||||||
ForEach(discoveredUnsaved) { discoveredCard($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding([.horizontal, .bottom])
|
|
||||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func discoveredCard(_ d: DiscoveredHost) -> some View {
|
|
||||||
#if os(iOS)
|
|
||||||
let iconSize: CGFloat = 56
|
|
||||||
let iconBox: CGFloat = 76
|
|
||||||
let cardPadding: CGFloat = 28
|
|
||||||
let nameFont = Font.title3.weight(.semibold)
|
|
||||||
#else
|
|
||||||
let iconSize: CGFloat = 42
|
|
||||||
let iconBox: CGFloat = 56
|
|
||||||
let cardPadding: CGFloat = 18
|
|
||||||
let nameFont = Font.headline
|
|
||||||
#endif
|
|
||||||
return Button {
|
|
||||||
connectDiscovered(d)
|
|
||||||
} label: {
|
|
||||||
VStack(spacing: 10) {
|
|
||||||
Image(systemName: "play.display")
|
|
||||||
.font(.system(size: iconSize, weight: .light))
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
.frame(height: iconBox)
|
|
||||||
VStack(spacing: 2) {
|
|
||||||
Text(d.name)
|
|
||||||
.font(nameFont)
|
|
||||||
.lineLimit(1)
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: d.requiresPairing ? "lock.fill" : "wifi")
|
|
||||||
.font(.system(size: 9))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text("\(d.host):\(String(d.port))")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
Text(d.requiresPairing ? "Pairing required" : "Discovered")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, cardPadding)
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
#if !os(tvOS)
|
|
||||||
// A dashed ring distinguishes a not-yet-saved discovered host from saved cards.
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
|
||||||
.overlay {
|
|
||||||
RoundedRectangle(cornerRadius: 14)
|
|
||||||
.strokeBorder(
|
|
||||||
Color.secondary.opacity(0.25),
|
|
||||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
#if os(tvOS)
|
|
||||||
.buttonStyle(.card)
|
|
||||||
#else
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
#endif
|
|
||||||
.disabled(model.isBusy)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Trust on first use
|
|
||||||
|
|
||||||
private func trustCard(_ fingerprint: Data) -> some View {
|
|
||||||
VStack(spacing: 14) {
|
|
||||||
Image(systemName: "lock.shield")
|
|
||||||
.font(.system(size: 36, weight: .light))
|
|
||||||
.foregroundStyle(.tint)
|
|
||||||
Text("Verify \(model.activeHost?.displayName ?? "host")")
|
|
||||||
.font(.title3.weight(.semibold))
|
|
||||||
Text("First connection. Compare this fingerprint with the one "
|
|
||||||
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
|
||||||
+ "fingerprint\u{201D}):")
|
|
||||||
.font(.callout)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.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)
|
|
||||||
#endif
|
|
||||||
// The verified alternative to eyeballing hex: drop this session (the host
|
|
||||||
// serves one connection at a time) and run the SPAKE2 PIN ceremony instead.
|
|
||||||
Button("Pair with PIN instead…") {
|
|
||||||
let host = model.activeHost
|
|
||||||
model.rejectTrust()
|
|
||||||
pairingTarget = host
|
|
||||||
}
|
|
||||||
#if os(macOS)
|
|
||||||
.buttonStyle(.link)
|
|
||||||
#else
|
|
||||||
.buttonStyle(.borderless)
|
|
||||||
#endif
|
|
||||||
.font(.callout)
|
|
||||||
}
|
|
||||||
.padding(28)
|
|
||||||
.frame(maxWidth: 440)
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
|
||||||
private static func format(fingerprint: Data) -> String {
|
|
||||||
let hex = fingerprint.map { String(format: "%02x", $0) }.joined()
|
|
||||||
let groups = stride(from: 0, to: hex.count, by: 8).map { i -> String in
|
|
||||||
let start = hex.index(hex.startIndex, offsetBy: i)
|
|
||||||
let end = hex.index(start, offsetBy: min(8, hex.count - i))
|
|
||||||
return String(hex[start..<end])
|
|
||||||
}
|
|
||||||
return groups.chunks(of: 4).map { $0.joined(separator: " ") }.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Stream
|
|
||||||
|
|
||||||
private func stream(captureEnabled: Bool) -> some View {
|
private func stream(captureEnabled: Bool) -> some View {
|
||||||
Group {
|
Group {
|
||||||
if let conn = model.connection {
|
if let conn = model.connection {
|
||||||
@@ -614,70 +158,69 @@ struct ContentView: View {
|
|||||||
presentMeter: model.presentLatency
|
presentMeter: model.presentLatency
|
||||||
)
|
)
|
||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if captureEnabled { hud(conn) }
|
if captureEnabled { StreamHUDView(model: model, connection: conn) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hud(_ conn: PunktfunkConnection) -> some View {
|
// MARK: - Connect
|
||||||
VStack(alignment: .trailing, spacing: 4) {
|
|
||||||
HStack(spacing: 6) {
|
private func connect(_ host: StoredHost) {
|
||||||
Circle()
|
// The gamepad-type setting resolves NOW (Automatic → match the active physical
|
||||||
.fill(Color.accentColor)
|
// controller): the host's virtual pad backend is fixed per session.
|
||||||
.frame(width: 7, height: 7)
|
model.connect(
|
||||||
Text("\(conn.width)×\(conn.height)@\(conn.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
to: host,
|
||||||
.font(.system(.caption, design: .monospaced))
|
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||||
}
|
hz: UInt32(clamping: hz),
|
||||||
if model.latencyValid {
|
compositor: PunktfunkConnection.Compositor(
|
||||||
// Capture→client-receipt (skew-corrected); excludes the layer's decode+present —
|
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||||
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake.
|
gamepad: GamepadManager.shared.resolveType(
|
||||||
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")")
|
setting: PunktfunkConnection.GamepadType(
|
||||||
.font(.system(.caption2, design: .monospaced))
|
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||||
.foregroundStyle(.secondary)
|
bitrateKbps: UInt32(clamping: bitrateKbps))
|
||||||
}
|
|
||||||
if model.presentLatencyValid {
|
|
||||||
// Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter
|
|
||||||
// only; stage-1's layer presents internally with no per-frame stamp.
|
|
||||||
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")")
|
|
||||||
.font(.system(.caption2, design: .monospaced))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
|
||||||
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
|
||||||
#if os(macOS)
|
|
||||||
Text(model.mouseCaptured
|
|
||||||
? "⌘⎋ releases the mouse"
|
|
||||||
: "Click the stream to capture input")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
#elseif os(iOS)
|
|
||||||
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
|
||||||
Text(model.mouseCaptured
|
|
||||||
? "⌘⎋ releases keyboard & mouse"
|
|
||||||
: "⌘⎋ captures keyboard & mouse")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
#endif
|
|
||||||
#if os(tvOS)
|
|
||||||
// No focusable control during play: a focusable button steals the controller's
|
|
||||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
|
||||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
|
||||||
Text("Press Menu to disconnect")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
#else
|
|
||||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
|
||||||
.font(.caption)
|
|
||||||
.keyboardShortcut("d", modifiers: .command)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
.padding(10)
|
|
||||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
|
||||||
.padding(10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dev hook
|
/// 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,
|
/// PUNKTFUNK_AUTOCONNECT=host[:port] connects immediately (trust-on-first-use,
|
||||||
/// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
|
/// auto-confirmed — dev only) at the saved or PUNKTFUNK_MODE=WxHxHz mode, without
|
||||||
@@ -727,9 +270,3 @@ struct ContentView: View {
|
|||||||
autoTrust: true)
|
autoTrust: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension Array {
|
|
||||||
func chunks(of size: Int) -> [[Element]] {
|
|
||||||
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// The home screen: a grid of saved hosts + an "On this network" section of mDNS-discovered
|
||||||
|
// hosts, with the add/settings toolbar and the pairing / speed-test / add / settings
|
||||||
|
// navigation. The connect logic lives in ContentView (it reads the @AppStorage stream mode) and
|
||||||
|
// is passed in as closures.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
#if os(tvOS)
|
||||||
|
import SwiftUINavigationTransitions
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct HomeView: View {
|
||||||
|
@ObservedObject var store: HostStore
|
||||||
|
@ObservedObject var model: SessionModel
|
||||||
|
@ObservedObject var discovery: HostDiscovery
|
||||||
|
@Binding var showAddHost: Bool
|
||||||
|
@Binding var pairingTarget: StoredHost?
|
||||||
|
@Binding var speedTestTarget: StoredHost?
|
||||||
|
#if !os(macOS)
|
||||||
|
@Binding var showSettings: Bool
|
||||||
|
#endif
|
||||||
|
let connect: (StoredHost) -> Void
|
||||||
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
|
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||||
|
let onPaired: (StoredHost, Data) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
if !store.hosts.isEmpty {
|
||||||
|
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||||
|
ForEach(store.hosts) { host in
|
||||||
|
hostCard(host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
if !discoveredUnsaved.isEmpty {
|
||||||
|
discoveredSection
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
// Actions live below the hosts, not between them.
|
||||||
|
HStack(spacing: 32) {
|
||||||
|
Button {
|
||||||
|
showAddHost = true
|
||||||
|
} label: {
|
||||||
|
Label("Add Host", systemImage: "plus")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
showSettings = true
|
||||||
|
} label: {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 24)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Punktfunkempfänger")
|
||||||
|
// Browse the LAN for advertised hosts only while the grid is up — not during a
|
||||||
|
// session. The home appears/disappears as the stream swaps in and out.
|
||||||
|
.onAppear { discovery.start() }
|
||||||
|
.onDisappear { discovery.stop() }
|
||||||
|
#if os(tvOS)
|
||||||
|
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||||
|
// pops) instead of modal overlays.
|
||||||
|
.navigationDestination(isPresented: $showAddHost) {
|
||||||
|
AddHostSheet { store.add($0) }
|
||||||
|
}
|
||||||
|
.navigationDestination(isPresented: $showSettings) {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
.navigationDestination(item: $pairingTarget) { host in
|
||||||
|
PairSheet(host: host) { fingerprint in onPaired(host, fingerprint) }
|
||||||
|
}
|
||||||
|
.navigationDestination(item: $speedTestTarget) { host in
|
||||||
|
SpeedTestSheet(host: host)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
.toolbar {
|
||||||
|
#if os(iOS)
|
||||||
|
// Adjacent trailing items share one glass pill (the system default).
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { settingsButton }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) { addHostButton }
|
||||||
|
#else
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
addHostButton
|
||||||
|
.help("Add a host")
|
||||||
|
}
|
||||||
|
ToolbarItem {
|
||||||
|
SettingsLink {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
.help("Stream mode and settings")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 480, minHeight: 360)
|
||||||
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
// The Settings-app slide for every push in this stack (top-level routes AND
|
||||||
|
// the pickers' drill-ins) — SwiftUI's default on tvOS is a bare crossfade.
|
||||||
|
// Spring-driven (UISpringTimingParameters): ~0.87 damping ratio — settles fast
|
||||||
|
// with just a hint of life, no visible overshoot ping-pong.
|
||||||
|
.customNavigationTransition(
|
||||||
|
.slide.animation(.interpolatingSpring(stiffness: 300, damping: 30)))
|
||||||
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
|
.sheet(isPresented: $showAddHost) {
|
||||||
|
AddHostSheet { store.add($0) }
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
NavigationStack {
|
||||||
|
SettingsView()
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.toolbar {
|
||||||
|
Button("Done") { showSettings = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif
|
||||||
|
.alert(
|
||||||
|
"Connection failed",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { model.errorMessage != nil },
|
||||||
|
set: { if !$0 { model.errorMessage = nil } }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Button("OK", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text(model.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cards
|
||||||
|
|
||||||
|
private func hostCard(_ host: StoredHost) -> some View {
|
||||||
|
HostCardView(
|
||||||
|
host: host,
|
||||||
|
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
|
||||||
|
isMostRecent: host.id == mostRecentHostID,
|
||||||
|
isBusy: model.isBusy,
|
||||||
|
onConnect: { connect(host) },
|
||||||
|
onPair: { if !model.isBusy { pairingTarget = host } },
|
||||||
|
onSpeedTest: { if !model.isBusy { speedTestTarget = host } },
|
||||||
|
onForget: { store.forgetIdentity(host) },
|
||||||
|
onRemove: { store.remove(host) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var discoveredSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal)
|
||||||
|
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||||
|
ForEach(discoveredUnsaved) { discovered in
|
||||||
|
DiscoveredCardView(
|
||||||
|
discovered: discovered, isBusy: model.isBusy,
|
||||||
|
onConnect: { connectDiscovered(discovered) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding([.horizontal, .bottom])
|
||||||
|
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discovered hosts not already saved (matched by address+port) — the saved grid shows the
|
||||||
|
/// rest, so this section only surfaces genuinely-new hosts on the network.
|
||||||
|
private var discoveredUnsaved: [DiscoveredHost] {
|
||||||
|
discovery.hosts.filter { d in
|
||||||
|
!store.hosts.contains { $0.address == d.host && $0.port == d.port }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host of the most recent session — its card carries the accent ring.
|
||||||
|
private var mostRecentHostID: UUID? {
|
||||||
|
store.hosts
|
||||||
|
.compactMap { host in host.lastConnected.map { (host.id, $0) } }
|
||||||
|
.max { $0.1 < $1.1 }?.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Chrome
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
ContentUnavailableView {
|
||||||
|
Label("No Hosts", systemImage: "rectangle.connected.to.line.below")
|
||||||
|
} description: {
|
||||||
|
Text("Add your punktfunk host with the + button.")
|
||||||
|
} actions: {
|
||||||
|
Button("Add Host") { showAddHost = true }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
#if os(iOS)
|
||||||
|
.controlSize(.large)
|
||||||
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
Button("Settings") { showSettings = true }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var addHostButton: some View {
|
||||||
|
Button {
|
||||||
|
showAddHost = true
|
||||||
|
} label: {
|
||||||
|
Label("Add Host", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(macOS)
|
||||||
|
private var settingsButton: some View {
|
||||||
|
Button {
|
||||||
|
showSettings = true
|
||||||
|
} label: {
|
||||||
|
Label("Settings", systemImage: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// macOS caps card width (a huge window shouldn't yield huge cards); on iOS the columns FILL
|
||||||
|
/// the width so the cards stay edge-aligned with the title and bars — sized touch-first: one
|
||||||
|
/// column on iPhone portrait, 3–4 generous cards on iPad.
|
||||||
|
private var gridColumns: [GridItem] {
|
||||||
|
#if os(macOS)
|
||||||
|
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
|
||||||
|
#elseif os(tvOS)
|
||||||
|
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
||||||
|
#else
|
||||||
|
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gridSpacing: CGFloat {
|
||||||
|
#if os(tvOS)
|
||||||
|
48 // the focused card scales up — give it room instead of overlapping siblings
|
||||||
|
#else
|
||||||
|
16
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered
|
||||||
|
// host (tap to save + connect). Both share the same platform-tuned sizing.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Shared host-card sizing — touch-first on iOS, compact on macOS/tvOS.
|
||||||
|
private struct CardMetrics {
|
||||||
|
let iconSize: CGFloat
|
||||||
|
let iconBox: CGFloat
|
||||||
|
let cardPadding: CGFloat
|
||||||
|
let nameFont: Font
|
||||||
|
|
||||||
|
static var current: CardMetrics {
|
||||||
|
#if os(iOS)
|
||||||
|
CardMetrics(iconSize: 56, iconBox: 76, cardPadding: 28, nameFont: .title3.weight(.semibold))
|
||||||
|
#else
|
||||||
|
CardMetrics(iconSize: 42, iconBox: 56, cardPadding: 18, nameFont: .headline)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved host. The accent ring marks the most-recently-connected one; the context menu
|
||||||
|
/// pairs / speed-tests / forgets / removes. Disabled while a session is busy.
|
||||||
|
struct HostCardView: View {
|
||||||
|
let host: StoredHost
|
||||||
|
let isConnecting: Bool
|
||||||
|
let isMostRecent: Bool
|
||||||
|
let isBusy: Bool
|
||||||
|
let onConnect: () -> Void
|
||||||
|
let onPair: () -> Void
|
||||||
|
let onSpeedTest: () -> Void
|
||||||
|
let onForget: () -> Void
|
||||||
|
let onRemove: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let m = CardMetrics.current
|
||||||
|
return Button(action: onConnect) {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ZStack {
|
||||||
|
Image(systemName: "play.display")
|
||||||
|
.font(.system(size: m.iconSize, weight: .light))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.opacity(isConnecting ? 0.3 : 1)
|
||||||
|
if isConnecting {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: m.iconBox)
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(host.displayName)
|
||||||
|
.font(m.nameFont)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if host.pinnedSHA256 != nil {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("\(host.address):\(String(host.port))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
if let last = host.lastConnected {
|
||||||
|
Text("Connected \(last, format: .relative(presentation: .named))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, m.cardPadding)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
#if !os(tvOS)
|
||||||
|
// tvOS: the .card button style owns platter + focus motion — extra chrome
|
||||||
|
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay {
|
||||||
|
if isMostRecent {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(.card)
|
||||||
|
#else
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
.disabled(isBusy)
|
||||||
|
.contextMenu {
|
||||||
|
Button("Pair with PIN…", action: onPair)
|
||||||
|
Button("Test Network Speed…", action: onSpeedTest)
|
||||||
|
if host.pinnedSHA256 != nil {
|
||||||
|
Button("Forget Identity", action: onForget)
|
||||||
|
}
|
||||||
|
Button("Remove", role: .destructive, action: onRemove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A host found on the LAN but not yet saved. A dashed ring distinguishes it from saved cards;
|
||||||
|
/// tapping saves it and connects (or pairs, if the host requires it).
|
||||||
|
struct DiscoveredCardView: View {
|
||||||
|
let discovered: DiscoveredHost
|
||||||
|
let isBusy: Bool
|
||||||
|
let onConnect: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let m = CardMetrics.current
|
||||||
|
return Button(action: onConnect) {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
Image(systemName: "play.display")
|
||||||
|
.font(.system(size: m.iconSize, weight: .light))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.frame(height: m.iconBox)
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(discovered.name)
|
||||||
|
.font(m.nameFont)
|
||||||
|
.lineLimit(1)
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: discovered.requiresPairing ? "lock.fill" : "wifi")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("\(discovered.host):\(String(discovered.port))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Text(discovered.requiresPairing ? "Pairing required" : "Discovered")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, m.cardPadding)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
#if !os(tvOS)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.strokeBorder(
|
||||||
|
Color.secondary.opacity(0.25),
|
||||||
|
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.buttonStyle(.card)
|
||||||
|
#else
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
.disabled(isBusy)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
// --require-pairing only admit paired clients, so for them pairing is the only way in.
|
// --require-pairing only admit paired clients, so for them pairing is the only way in.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct StoredHost: Identifiable, Codable, Hashable {
|
struct StoredHost: Identifiable, Codable, Hashable {
|
||||||
@@ -26,7 +27,7 @@ struct StoredHost: Identifiable, Codable, Hashable {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class HostStore: ObservableObject {
|
final class HostStore: ObservableObject {
|
||||||
private static let key = "punktfunk.hosts"
|
private static let key = DefaultsKey.hosts
|
||||||
|
|
||||||
@Published var hosts: [StoredHost] {
|
@Published var hosts: [StoredHost] {
|
||||||
didSet { persist() }
|
didSet { persist() }
|
||||||
|
|||||||
@@ -207,9 +207,9 @@ final class SessionModel: ObservableObject {
|
|||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
let audio = SessionAudio(connection: conn)
|
let audio = SessionAudio(connection: conn)
|
||||||
audio.start(
|
audio.start(
|
||||||
speakerUID: defaults.string(forKey: "punktfunk.speakerUID") ?? "",
|
speakerUID: defaults.string(forKey: DefaultsKey.speakerUID) ?? "",
|
||||||
micUID: defaults.string(forKey: "punktfunk.micUID") ?? "",
|
micUID: defaults.string(forKey: DefaultsKey.micUID) ?? "",
|
||||||
micEnabled: defaults.object(forKey: "punktfunk.micEnabled") as? Bool ?? true)
|
micEnabled: defaults.object(forKey: DefaultsKey.micEnabled) as? Bool ?? true)
|
||||||
self.audio = audio
|
self.audio = audio
|
||||||
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
// Gamepads: forward GamepadManager's active controller as pad 0 and render the
|
||||||
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
|
// host's feedback (rumble always; lightbar/player-LEDs/adaptive-triggers when the
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@AppStorage("punktfunk.width") private var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||||
@AppStorage("punktfunk.height") private var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||||
@AppStorage("punktfunk.hz") private var hz = 60
|
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||||
@AppStorage("punktfunk.compositor") private var compositor = 0
|
@AppStorage(DefaultsKey.compositor) private var compositor = 0
|
||||||
@AppStorage("punktfunk.gamepadType") private var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) private var gamepadType = 0
|
||||||
@AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
@AppStorage("punktfunk.presenter") private var presenter = "stage1"
|
@AppStorage(DefaultsKey.presenter) private var presenter = "stage1"
|
||||||
@AppStorage("punktfunk.micEnabled") private var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) private var micEnabled = true
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage("punktfunk.speakerUID") private var speakerUID = ""
|
@AppStorage(DefaultsKey.speakerUID) private var speakerUID = ""
|
||||||
@AppStorage("punktfunk.micUID") private var micUID = ""
|
@AppStorage(DefaultsKey.micUID) private var micUID = ""
|
||||||
@State private var outputDevices: [AudioDevice] = []
|
@State private var outputDevices: [AudioDevice] = []
|
||||||
@State private var inputDevices: [AudioDevice] = []
|
@State private var inputDevices: [AudioDevice] = []
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ struct SpeedTestSheet: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
let host: StoredHost
|
let host: StoredHost
|
||||||
|
|
||||||
@AppStorage("punktfunk.width") private var width = 1920
|
@AppStorage(DefaultsKey.streamWidth) private var width = 1920
|
||||||
@AppStorage("punktfunk.height") private var height = 1080
|
@AppStorage(DefaultsKey.streamHeight) private var height = 1080
|
||||||
@AppStorage("punktfunk.hz") private var hz = 60
|
@AppStorage(DefaultsKey.streamHz) private var hz = 60
|
||||||
@AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
||||||
|
|
||||||
private enum Phase: Equatable {
|
private enum Phase: Equatable {
|
||||||
case connecting
|
case connecting
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// The streaming overlay HUD: mode + fps/throughput, the capture→client (and, under the stage-2
|
||||||
|
// presenter, capture→present) latency lines, the platform input hint, and disconnect.
|
||||||
|
|
||||||
|
import PunktfunkKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StreamHUDView: View {
|
||||||
|
@ObservedObject var model: SessionModel
|
||||||
|
let connection: PunktfunkConnection
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text("\(connection.width)×\(connection.height)@\(connection.refreshHz) \(model.fps) fps \(model.mbps, specifier: "%.1f") Mb/s")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
}
|
||||||
|
if model.latencyValid {
|
||||||
|
// Capture→client-receipt (skew-corrected); excludes the layer's decode+present —
|
||||||
|
// see LatencyMeter. "(same-host)" when the host didn't answer the skew handshake.
|
||||||
|
Text("capture→client \(model.latencyP50Ms, specifier: "%.1f")/\(model.latencyP95Ms, specifier: "%.1f") ms p50/p95\(model.latencySkewCorrected ? "" : " (same-host)")")
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if model.presentLatencyValid {
|
||||||
|
// Capture→present (glass-to-glass, modulo host render→capture) — stage-2 presenter
|
||||||
|
// only; stage-1's layer presents internally with no per-frame stamp.
|
||||||
|
Text("capture→present \(model.presentLatencyP50Ms, specifier: "%.1f")/\(model.presentLatencyP95Ms, specifier: "%.1f") ms p50/p95\(model.presentLatencySkewCorrected ? "" : " (same-host)")")
|
||||||
|
.font(.system(.caption2, design: .monospaced))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
// While captured the cursor is hidden+frozen, so the button is keyboard-only
|
||||||
|
// (⌘⎋ or Cmd+Tab release the cursor; released, it's clickable again).
|
||||||
|
#if os(macOS)
|
||||||
|
Text(model.mouseCaptured
|
||||||
|
? "⌘⎋ releases the mouse"
|
||||||
|
: "Click the stream to capture input")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
#elseif os(iOS)
|
||||||
|
// Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse.
|
||||||
|
Text(model.mouseCaptured
|
||||||
|
? "⌘⎋ releases keyboard & mouse"
|
||||||
|
: "⌘⎋ captures keyboard & mouse")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
// No focusable control during play: a focusable button steals the controller's
|
||||||
|
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||||
|
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||||
|
Text("Press Menu to disconnect")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
#else
|
||||||
|
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||||
|
.font(.caption)
|
||||||
|
.keyboardShortcut("d", modifiers: .command)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||||
|
.padding(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Trust-on-first-use prompt: shown over the live-but-blurred stream when connecting to an
|
||||||
|
// unpinned host. The user compares the fingerprint with the one the host logged at startup,
|
||||||
|
// or drops this and runs the PIN pairing ceremony instead.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TrustCardView: View {
|
||||||
|
let fingerprint: Data
|
||||||
|
let hostName: String
|
||||||
|
let onCancel: () -> Void
|
||||||
|
let onTrust: () -> Void
|
||||||
|
let onPairInstead: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
Image(systemName: "lock.shield")
|
||||||
|
.font(.system(size: 36, weight: .light))
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
Text("Verify \(hostName)")
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
Text("First connection. Compare this fingerprint with the one "
|
||||||
|
+ "punktfunk-host logged at startup (\u{201C}clients pin this "
|
||||||
|
+ "fingerprint\u{201D}):")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.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, action: onCancel)
|
||||||
|
#if !os(tvOS)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
#endif
|
||||||
|
Button("Trust & Connect", action: onTrust)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
#if !os(tvOS)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.controlSize(.large)
|
||||||
|
#endif
|
||||||
|
// The verified alternative to eyeballing hex: drop this session (the host
|
||||||
|
// serves one connection at a time) and run the SPAKE2 PIN ceremony instead.
|
||||||
|
Button("Pair with PIN instead…", action: onPairInstead)
|
||||||
|
#if os(macOS)
|
||||||
|
.buttonStyle(.link)
|
||||||
|
#else
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
#endif
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
.padding(28)
|
||||||
|
.frame(maxWidth: 440)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 64 hex chars → four groups per line, two lines — easy to eyeball against the log.
|
||||||
|
private static func format(fingerprint: Data) -> String {
|
||||||
|
let hex = fingerprint.map { String(format: "%02x", $0) }.joined()
|
||||||
|
let groups = stride(from: 0, to: hex.count, by: 8).map { i -> String in
|
||||||
|
let start = hex.index(hex.startIndex, offsetBy: i)
|
||||||
|
let end = hex.index(start, offsetBy: min(8, hex.count - i))
|
||||||
|
return String(hex[start..<end])
|
||||||
|
}
|
||||||
|
return groups.chunks(of: 4).map { $0.joined(separator: " ") }.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Array {
|
||||||
|
func chunks(of size: Int) -> [[Element]] {
|
||||||
|
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// One source of truth for the client's UserDefaults / @AppStorage keys. A magic-string key
|
||||||
|
// duplicated across a setting's writer (a Settings @AppStorage) and reader (e.g. a stream view
|
||||||
|
// reading UserDefaults) splits silently on a typo — the setting just stops taking effect. These
|
||||||
|
// live in PunktfunkKit because both the app and the kit's views read them.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Persisted-setting keys. The string VALUES are stable on disk — rename the symbol freely, but
|
||||||
|
/// never the string (it would orphan everyone's saved value).
|
||||||
|
public enum DefaultsKey {
|
||||||
|
public static let streamWidth = "punktfunk.width"
|
||||||
|
public static let streamHeight = "punktfunk.height"
|
||||||
|
public static let streamHz = "punktfunk.hz"
|
||||||
|
public static let compositor = "punktfunk.compositor"
|
||||||
|
public static let gamepadType = "punktfunk.gamepadType"
|
||||||
|
public static let gamepadID = "punktfunk.gamepadID"
|
||||||
|
public static let bitrateKbps = "punktfunk.bitrateKbps"
|
||||||
|
public static let micEnabled = "punktfunk.micEnabled"
|
||||||
|
public static let speakerUID = "punktfunk.speakerUID"
|
||||||
|
public static let micUID = "punktfunk.micUID"
|
||||||
|
public static let presenter = "punktfunk.presenter"
|
||||||
|
public static let hosts = "punktfunk.hosts"
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ private final class FeedbackStopFlag: @unchecked Sendable {
|
|||||||
/// amplitude and torn down on retarget; players run only while their motor is on, so an
|
/// amplitude and torn down on retarget; players run only while their motor is on, so an
|
||||||
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
|
/// idle controller costs no radio traffic. Failures (pads without haptics, engine resets)
|
||||||
/// downgrade to silence — rumble is best-effort by design.
|
/// downgrade to silence — rumble is best-effort by design.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable` is sound because every property (`controller`/`low`/`high`/`broken`) is
|
||||||
|
/// read and written only inside `queue` closures — the serial queue is the synchronization.
|
||||||
private final class RumbleRenderer: @unchecked Sendable {
|
private final class RumbleRenderer: @unchecked Sendable {
|
||||||
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive)
|
||||||
|
|
||||||
@@ -177,6 +180,11 @@ public final class GamepadFeedback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Safety net: the drain thread captures `connection` strongly and only `self` weakly, so if
|
||||||
|
/// this is dropped without `stop()` (an abrupt teardown) the thread would poll forever and
|
||||||
|
/// leak the connection — signal it to exit. (`stop()` is the normal path and also joins it.)
|
||||||
|
deinit { flag.stop() }
|
||||||
|
|
||||||
/// Map the DualSense player-LED bit patterns (5 LEDs, hid-playstation's player
|
/// Map the DualSense player-LED bit patterns (5 LEDs, hid-playstation's player
|
||||||
/// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit
|
/// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit
|
||||||
/// count, clamped to the four indices GC offers.
|
/// count, clamped to the four indices GC offers.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0.
|
// follow `active` — exactly ONE physical controller is forwarded to the host, as pad 0.
|
||||||
//
|
//
|
||||||
// Selection: the user can pin a controller in Settings (persisted under
|
// Selection: the user can pin a controller in Settings (persisted under
|
||||||
// "punktfunk.gamepadID"); with no pin — or the pinned one absent — the most recently
|
// DefaultsKey.gamepadID); with no pin — or the pinned one absent — the most recently
|
||||||
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
|
// connected extended gamepad wins. GCController has no stable hardware serial, so the pin
|
||||||
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
|
// is a fingerprint of vendorName|productCategory (+ a connect-order suffix for twins);
|
||||||
// identical twin controllers may swap a pin across reconnects, which the Settings footer
|
// identical twin controllers may swap a pin across reconnects, which the Settings footer
|
||||||
@@ -61,7 +61,7 @@ public final class GamepadManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let preferredKey = "punktfunk.gamepadID"
|
private static let preferredKey = DefaultsKey.gamepadID
|
||||||
/// Connect order (identity-keyed) — drives both twin de-dup suffixes and auto-pick.
|
/// Connect order (identity-keyed) — drives both twin de-dup suffixes and auto-pick.
|
||||||
private var connectOrder: [ObjectIdentifier] = []
|
private var connectOrder: [ObjectIdentifier] = []
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
|||||||
@@ -557,7 +557,8 @@ public final class InputCapture {
|
|||||||
var m: [Int: UInt32] = [:]
|
var m: [Int: UInt32] = [:]
|
||||||
// a–z: HID 0x04..0x1D → VK 'A'..'Z'.
|
// a–z: HID 0x04..0x1D → VK 'A'..'Z'.
|
||||||
for i in 0..<26 { m[0x04 + i] = UInt32(0x41 + i) }
|
for i in 0..<26 { m[0x04 + i] = UInt32(0x41 + i) }
|
||||||
// 1–9, 0: HID 0x1E..0x27 → VK '1'..'9','0'.
|
// 1–9: HID 0x1E..0x26 → VK '1'..'9'; then 0: HID 0x27 → VK '0' (set separately —
|
||||||
|
// the '0' key sits AFTER '9' in HID but its VK 0x30 sits BEFORE '1' (0x31)).
|
||||||
for i in 0..<9 { m[0x1E + i] = UInt32(0x31 + i) }
|
for i in 0..<9 { m[0x1E + i] = UInt32(0x31 + i) }
|
||||||
m[0x27] = 0x30
|
m[0x27] = 0x30
|
||||||
m[0x28] = 0x0D // return
|
m[0x28] = 0x0D // return
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ final class AudioRing: @unchecked Sendable {
|
|||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
let capacity = buf.count
|
let capacity = buf.count
|
||||||
|
// A single write larger than the whole ring would push readIdx PAST writeIdx below
|
||||||
|
// (inverting the valid range — corruption). It never happens (one decoded packet is far
|
||||||
|
// under capacity), but guard rather than corrupt.
|
||||||
|
guard count <= capacity else { return }
|
||||||
if writeIdx + count - readIdx > capacity {
|
if writeIdx + count - readIdx > capacity {
|
||||||
readIdx = writeIdx + count - capacity // overflow: drop oldest
|
readIdx = writeIdx + count - capacity // overflow: drop oldest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import AVFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
|
||||||
|
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view
|
||||||
|
/// directly makes a `view → link → view` cycle that only `invalidate()` breaks — if a teardown
|
||||||
|
/// is ever missed the view leaks and keeps ticking. This proxy holds the handler weakly, so the
|
||||||
|
/// view can deallocate and its `deinit` invalidate the link.
|
||||||
|
public final class DisplayLinkProxy: NSObject {
|
||||||
|
private let onTick: (CADisplayLink) -> Void
|
||||||
|
public init(_ onTick: @escaping (CADisplayLink) -> Void) { self.onTick = onTick }
|
||||||
|
@objc public func tick(_ link: CADisplayLink) { onTick(link) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
/// Newest-ready 1-slot ring: the decoder overwrites (drops the older undisplayed frame — lowest
|
||||||
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
/// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded.
|
||||||
private final class ReadyRing: @unchecked Sendable {
|
private final class ReadyRing: @unchecked Sendable {
|
||||||
|
|||||||
@@ -434,7 +434,7 @@ public final class StreamLayerView: NSView {
|
|||||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||||
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a
|
// (`punktfunk.presenter == "stage2"`) takes explicit VTDecompressionSession decode + a
|
||||||
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up.
|
// CAMetalLayer/display-link present; it falls back here if Metal can't be set up.
|
||||||
if UserDefaults.standard.string(forKey: "punktfunk.presenter") == "stage2",
|
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||||
let meter = presentMeter,
|
let meter = presentMeter,
|
||||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -455,17 +455,22 @@ public final class StreamLayerView: NSView {
|
|||||||
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)?
|
||||||
) {
|
) {
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
displayLayer.addSublayer(metal) // contentsScale + frame set in layoutMetalLayer()
|
// The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which sits
|
||||||
|
// idle (un-enqueued) in stage-2. contentsScale + frame are set in layoutMetalLayer().
|
||||||
|
displayLayer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
let link = displayLink(target: self, selector: #selector(stage2Tick(_:)))
|
// Weak-proxy target so the link doesn't form a retain cycle with the view (see
|
||||||
|
// DisplayLinkProxy) — the link retains the proxy; the proxy holds the view weakly.
|
||||||
|
let proxy = DisplayLinkProxy { [weak self] link in self?.stage2Tick(link) }
|
||||||
|
let link = displayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:)))
|
||||||
link.add(to: .main, forMode: .common)
|
link.add(to: .main, forMode: .common)
|
||||||
stage2Link = link
|
stage2Link = link
|
||||||
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func stage2Tick(_ link: CADisplayLink) {
|
private func stage2Tick(_ link: CADisplayLink) {
|
||||||
stage2?.renderTick(
|
stage2?.renderTick(
|
||||||
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
||||||
}
|
}
|
||||||
@@ -523,6 +528,7 @@ public final class StreamLayerView: NSView {
|
|||||||
appObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
appObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
windowObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
windowObservers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
pump?.stop()
|
pump?.stop()
|
||||||
|
teardownStage2() // invalidate the display link + stop the pipeline if stop() was missed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -218,7 +218,7 @@ public final class StreamViewController: UIViewController {
|
|||||||
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
// Presenter choice — default stage-1 (the known-good AVSampleBufferDisplayLayer). Stage-2
|
||||||
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
|
// (`punktfunk.presenter == "stage2"`) takes VTDecompressionSession decode + a
|
||||||
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
|
// CAMetalLayer/display-link present; falls back here if Metal can't be set up.
|
||||||
if UserDefaults.standard.string(forKey: "punktfunk.presenter") == "stage2",
|
if UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage2",
|
||||||
let meter = presentMeter,
|
let meter = presentMeter,
|
||||||
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
let pipeline = Stage2Pipeline(presentMeter: meter) {
|
||||||
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
@@ -294,17 +294,21 @@ public final class StreamViewController: UIViewController {
|
|||||||
) {
|
) {
|
||||||
let metal = pipeline.layer
|
let metal = pipeline.layer
|
||||||
metal.contentsScale = streamView.contentScaleFactor
|
metal.contentsScale = streamView.contentScaleFactor
|
||||||
|
// Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base.
|
||||||
streamView.layer.addSublayer(metal)
|
streamView.layer.addSublayer(metal)
|
||||||
metalLayer = metal
|
metalLayer = metal
|
||||||
stage2 = pipeline
|
stage2 = pipeline
|
||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
let link = CADisplayLink(target: self, selector: #selector(stage2Tick(_:)))
|
// Weak-proxy target so the link doesn't retain-cycle with the controller (see
|
||||||
|
// DisplayLinkProxy) — the link retains the proxy; the proxy holds self weakly.
|
||||||
|
let proxy = DisplayLinkProxy { [weak self] link in self?.stage2Tick(link) }
|
||||||
|
let link = CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:)))
|
||||||
link.add(to: .main, forMode: .common)
|
link.add(to: .main, forMode: .common)
|
||||||
stage2Link = link
|
stage2Link = link
|
||||||
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func stage2Tick(_ link: CADisplayLink) {
|
private func stage2Tick(_ link: CADisplayLink) {
|
||||||
stage2?.renderTick(
|
stage2?.renderTick(
|
||||||
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp))
|
||||||
}
|
}
|
||||||
@@ -394,6 +398,7 @@ public final class StreamViewController: UIViewController {
|
|||||||
deinit {
|
deinit {
|
||||||
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
pump?.stop()
|
pump?.stop()
|
||||||
|
teardownStage2() // invalidate the display link + stop the pipeline if stop() was missed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user