diff --git a/clients/apple/Config/Info.plist b/clients/apple/Config/Info.plist index 4ea7cf7..db511d5 100644 --- a/clients/apple/Config/Info.plist +++ b/clients/apple/Config/Info.plist @@ -16,5 +16,10 @@ compliance question. --> ITSAppUsesNonExemptEncryption + + CADisableMinimumFrameDurationOnPhone + diff --git a/clients/apple/README.md b/clients/apple/README.md index 65d697e..c7c4fed 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -84,6 +84,17 @@ PUNKTFUNK_AUTOCONNECT= PUNKTFUNK_MODE=1280x720x60 swift run PunktfunkCli the two trust flows (TOFU prompt + SPAKE2 `PairSheet`), the stream view with the HUD, a tabbed Settings pane (General / Display / Audio / Controllers / Advanced), and the network speed test. A Scene-level **Stream** menu carries Disconnect (⌘D) and the HUD toggle (⌘⇧S). + On iOS/iPadOS **and macOS** a connected controller swaps the whole home for the **gamepad UI** + (`Home/Gamepad*`, `Settings/GamepadSettingsView`): a console-style host carousel (A connect · Y + library · X settings), a controller-navigable settings screen, an add-host flow with an + on-screen controller keyboard (no touch required anywhere), and the coverflow library browser — + all driven by the shared `GamepadMenuInput` poller + `GamepadCarousel`/`GamepadMenuList` focus + machinery, with dual-channel haptics (device Taptic + controller `MenuHaptics`), over an + animated "aurora" backdrop (`GamepadScreenBackground` — TimelineView-driven drifting color + blobs; deliberately pure SwiftUI, since a .metal library only reliably bundles in one of the + two build systems these sources compile under). macOS presents the settings/add-host screens as + sheets (no `fullScreenCover` there); `PUNKTFUNK_FORCE_GAMEPAD_UI=1` forces the mode without a + physical pad (dev/screenshots). - **Tests** (`swift test`) — Annex-B units, a real-codec VideoToolbox round trip, DualSense trigger-effect and gamepad-wire conversions, loopback integration against real local hosts, and the remote first-light test. diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index e238d85..75dc7cb 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -55,9 +55,9 @@ struct ContentView: View { #if !os(macOS) @State private var showSettings = false #endif - #if os(iOS) + #if os(iOS) || os(macOS) // A connected controller (+ the Settings toggle) swaps the whole home screen for - // GamepadHomeView instead of retrofitting HomeView's touch UI — see `home` below. + // GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below. @ObservedObject private var gamepadManager = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true private var gamepadUIActive: Bool { @@ -137,12 +137,16 @@ struct ContentView: View { // The library is a full-screen presentation, not a sheet: on iPad a sheet is a centered page // card, but the gamepad coverflow is meant to be an immersive, full-bleed screen (and the // launcher behind it stops consuming the controller — see GamepadHomeView's `isActive`). - // macOS has no `fullScreenCover`, so it keeps the sheet there. + // macOS has no `fullScreenCover`, so it keeps the sheet there — with an explicit size: a + // macOS sheet takes its content's IDEAL size, and both library layouts are geometry-driven + // (the coverflow is a GeometryReader, ideal ≈ zero), so without a frame it collapses to a + // tiny panel. #if os(macOS) .sheet(item: $libraryTarget) { host in NavigationStack { LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) }) } + .frame(minWidth: 940, minHeight: 620) } #else .fullScreenCover(item: $libraryTarget) { host in @@ -176,6 +180,18 @@ struct ContentView: View { + "device in the host's web console (port 3000 → Pairing) — no PIN needed. Or " + "pair with the 4-digit PIN it can display.") } + // One "Connection failed" surface for every home screen (touch grid, gamepad launcher) and + // platform — SessionModel funnels all connect/session errors into `errorMessage`. + .alert( + "Connection failed", + isPresented: Binding( + get: { model.errorMessage != nil }, + set: { if !$0 { model.errorMessage = nil } }) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(model.errorMessage ?? "") + } // The delegated-approval wait: the host holds the connection open until the operator // approves it. Cancel returns the UI at once; the in-flight connect is left to time out // and its late result is discarded by SessionModel's connect guard (disconnect resets the @@ -197,12 +213,21 @@ struct ContentView: View { 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) + Group { + if gamepadUIActive { + GamepadHomeView( + store: store, model: model, discovery: discovery, + libraryTarget: $libraryTarget, + connect: { connect($0) }, connectDiscovered: connectDiscovered) + } else { + HomeView( + store: store, model: model, discovery: discovery, + showAddHost: $showAddHost, pairingTarget: $pairingTarget, + speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, + connect: { connect($0) }, connectDiscovered: connectDiscovered, + onPaired: handlePaired, onLaunchTitle: launchTitle) + } + } #elseif os(iOS) Group { if gamepadUIActive { @@ -308,7 +333,8 @@ struct ContentView: View { onSessionEnd: { [weak model] in Task { @MainActor in model?.sessionEnded() } }, - presentMeter: model.presentLatency + presentMeter: model.presentLatency, + presentTailMeter: model.presentTail ) .overlay(alignment: placement.alignment) { if captureEnabled && hudEnabled { @@ -565,23 +591,3 @@ private struct ApprovalRequest { let host: StoredHost let advertisedFingerprint: Data? } - -private extension Data { - /// Parse an even-length hex string into bytes; nil on any non-hex character or odd length. - /// Used to turn an mDNS-advertised cert fingerprint into a connect pin. - init?(hexString: String) { - let chars = Array(hexString) - guard chars.count.isMultiple(of: 2) else { return nil } - var bytes = [UInt8]() - bytes.reserveCapacity(chars.count / 2) - var i = 0 - while i < chars.count { - guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else { - return nil - } - bytes.append(UInt8(hi << 4 | lo)) - i += 2 - } - self = Data(bytes) - } -} diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/AddHostSheet.swift rename to clients/apple/Sources/PunktfunkClient/Home/AddHostSheet.swift diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift new file mode 100644 index 0000000..8a37775 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -0,0 +1,234 @@ +// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) — the controller counterpart of +// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address / +// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings; +// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end +// without touching the screen. Field edits are live (the row shows every keystroke); B closes the +// keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) + +struct GamepadAddHostView: View { + @Environment(\.dismiss) private var dismiss + let onAdd: (StoredHost) -> Void + + #if os(iOS) + /// `.compact` in a landscape phone window — tighter chrome so the keyboard tray still fits. + @Environment(\.verticalSizeClass) private var vSizeClass + + private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false // no size classes on macOS; the sheet is sized to fit the tray + #endif + @State private var name = "" + @State private var address = "" + @State private var port = "9777" + @State private var focusID: String? + /// The field row the keyboard tray is editing; nil ⇒ the row list owns the controller. + @State private var editing: String? + + var body: some View { + GamepadMenuList( + items: rows, + focusID: $focusID, + onActivate: { activate(id: $0.id) }, + onBack: { dismiss() }, + isActive: editing == nil + ) { row, focused in + rowView(row, focused: focused) + .frame(maxWidth: 620) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity) + .safeAreaInset(edge: .top, spacing: 0) { + VStack(spacing: 4) { + Text("Add Host") + .font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) + .foregroundStyle(.white) + if !compact { + Text("Hosts on this network appear automatically — add one by address " + + "for everything else.") + .font(.geist(13, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.55)) + .multilineTextAlignment(.center) + .frame(maxWidth: 440) + } + } + .padding(.top, gamepadTitleTopPadding(compact: compact)) + .padding(.bottom, compact ? 4 : 8) + .frame(maxWidth: .infinity) + .overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) } + .background { GamepadTrayScrim(edge: .top) } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + bottomTray + .padding(.horizontal, 22) + .padding(.vertical, compact ? 6 : 10) + .background { GamepadTrayScrim(edge: .bottom) } + } + .background { GamepadScreenBackground() } + // A port can't exceed 5 digits — cap while typing so the row can't grow absurd. + .onChange(of: port) { _, value in + if value.count > 5 { port = String(value.prefix(5)) } + } + } + + /// The keyboard tray while editing, the controls legend otherwise. + @ViewBuilder private var bottomTray: some View { + if let editing { + VStack(spacing: 10) { + GamepadKeyboard( + text: editingBinding(editing), + allowed: allowedCharacters(editing), + onDone: { closeKeyboard() }) + // Fresh keyboard per field: a touch user can retarget the tray by tapping + // another field row, and the keyboard's input wiring captured the previous + // binding on appear — new identity forces a rewire to the new field. + .id(editing) + GamepadHintBar(hints: [ + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Type"), + .init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Delete"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), + ]) + .frame(maxWidth: .infinity, alignment: .leading) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + } else { + GamepadHintBar(hints: [ + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"), + ]) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc + /// rides the cancel action. + private var closeButton: some View { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .glassBackground(Circle(), interactive: true) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .accessibilityLabel("Cancel") + } + + // MARK: - Rows + + private struct Row: Identifiable { + let id: String + let label: String + var value = "" + var placeholder = "" + var isAction = false + } + + private var rows: [Row] { + [ + Row(id: "name", label: "Name", value: name, placeholder: "Optional — e.g. Living Room"), + Row(id: "address", label: "Address", value: address, placeholder: "IP or hostname"), + Row(id: "port", label: "Port", value: port, placeholder: "9777"), + Row(id: "add", label: "Add Host", isAction: true), + ] + } + + private func rowView(_ row: Row, focused: Bool) -> some View { + HStack(spacing: 14) { + if row.isAction { + Label("Add Host", systemImage: "plus.circle.fill") + .font(.geist(16, .semibold, relativeTo: .body)) + .foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35)) + .frame(maxWidth: .infinity) + } else { + Text(row.label) + .font(.geist(16, .semibold, relativeTo: .body)) + .foregroundStyle(.white) + Spacer(minLength: 12) + Text(row.value.isEmpty ? row.placeholder : row.value) + .font(.geistFixed(15, .medium)) + .foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white) + .lineLimit(1) + .truncationMode(.head) // keep the end of a long address visible while typing + if editing == row.id { + // The live-edit caret: this row is what the keyboard tray is typing into. + Rectangle() + .fill(Color.brand) + .frame(width: 2, height: 18) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.white.opacity(focused || editing == row.id ? 0.1 : 0)) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0), + lineWidth: 1) + } + .scaleEffect(focused ? 1.0 : 0.98) + .animation(.smooth(duration: 0.18), value: focused) + } + + // MARK: - Actions + + private func activate(id: String) { + switch id { + case "add": + guard canAdd else { + // Not addable yet — jump straight to what's missing instead of a dead press. + focusID = "address" + openKeyboard("address") + return + } + onAdd(StoredHost( + name: name.trimmingCharacters(in: .whitespaces), + address: address.trimmingCharacters(in: .whitespaces), + port: UInt16(port) ?? 9777)) + dismiss() + default: + openKeyboard(id) + } + } + + private var canAdd: Bool { + !address.trimmingCharacters(in: .whitespaces).isEmpty + && UInt16(port).map { $0 > 0 } == true + } + + private func openKeyboard(_ id: String) { + withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = id } + } + + private func closeKeyboard() { + withAnimation(.spring(response: 0.32, dampingFraction: 0.86)) { editing = nil } + } + + private func editingBinding(_ id: String) -> Binding { + switch id { + case "name": return $name + case "port": return $port + default: return $address + } + } + + /// What the keyboard may type per field: a port is digits, an address never contains spaces; + /// a name is free-form. + private func allowedCharacters(_ id: String) -> CharacterSet? { + switch id { + case "port": return CharacterSet(charactersIn: "0123456789") + case "address": return CharacterSet(charactersIn: " ").inverted + default: return nil + } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/GamepadCarousel.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift similarity index 96% rename from clients/apple/Sources/PunktfunkClient/GamepadCarousel.swift rename to clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift index ba3512a..5e44900 100644 --- a/clients/apple/Sources/PunktfunkClient/GamepadCarousel.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadCarousel.swift @@ -1,6 +1,6 @@ // The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the // library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely -// by a controller (iOS/iPadOS only). +// by a controller (iOS/iPadOS/macOS). // // The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)` // snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live @@ -24,8 +24,7 @@ import PunktfunkKit import SwiftUI -#if os(iOS) -import UIKit +#if os(iOS) || os(macOS) struct GamepadCarousel: View where Item.ID: Hashable { let items: [Item] @@ -40,6 +39,8 @@ struct GamepadCarousel: View where Item.ID: Hash let onActivate: (Item) -> Void /// Y → the screen's secondary action (e.g. open a host's library); nil disables it. var onSecondary: (() -> Void)? + /// X → the screen's tertiary action (e.g. open settings); nil disables it. + var onTertiary: (() -> Void)? /// B → back/dismiss; nil disables it (e.g. the root launcher has nowhere to go back to). var onBack: (() -> Void)? /// L1/R1 → jump this many items at once (clamped to the ends); 0 disables the shoulders. @@ -94,7 +95,9 @@ struct GamepadCarousel: View where Item.ID: Hash } .scrollPosition(id: $scrolledID) .scrollTargetBehavior(.viewAligned) - .scrollIndicators(.hidden) + // .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden + // and paints a scroller across the console strip. + .scrollIndicators(.never) .scrollClipDisabled() // let the focused card scale up past the strip bounds .safeAreaPadding(.horizontal, inset) .offset(x: bumpOffset) @@ -147,6 +150,7 @@ struct GamepadCarousel: View where Item.ID: Hash input.onMove = { move($0) } input.onConfirm = { activate() } input.onSecondary = onSecondary + input.onTertiary = onTertiary input.onBack = onBack input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift new file mode 100644 index 0000000..90b2074 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -0,0 +1,232 @@ +// Chrome shared by the gamepad-driven screens (GamepadHomeView, GamepadSettingsView, +// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the +// controller-glyph hint bar, and the connected-controller status chip. One look across every +// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages. +// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) +import GameController + +/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via +/// `sfSymbolsName`; a generic fallback before a controller profile resolves. +/// @MainActor: GamepadManager is main-actor-bound (inside a View body this was implicit). +@MainActor +func buttonGlyph( + _ button: KeyPath, fallback: String +) -> String { + GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName + ?? fallback +} + +/// Top padding for a gamepad screen's pinned title. macOS gets extra clearance — the launcher +/// title sits right under the window titlebar and the settings/add-host sheets have no titlebar +/// at all, so the iOS value hugs the top edge there. +func gamepadTitleTopPadding(compact: Bool) -> CGFloat { + #if os(macOS) + 26 + #else + compact ? 4 : 10 + #endif +} + +/// One glyph + label cell in a hint bar. +struct GamepadHint: Identifiable { + let glyph: String + let text: String + var id: String { glyph + text } +} + +/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`). +/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration. +struct GamepadHintBar: View { + let hints: [GamepadHint] + + var body: some View { + HStack(spacing: 18) { + ForEach(hints) { hint in + HStack(spacing: 7) { + Image(systemName: hint.glyph) + .font(.system(size: 19)) + .foregroundStyle(.white) + Text(hint.text) + } + .fixedSize() // keep glyph + label together; never truncate a hint mid-word + } + } + .font(.geist(14, .semibold, relativeTo: .subheadline)) + .foregroundStyle(.white.opacity(0.85)) + } +} + +/// The console backdrop: a living "aurora" field in the brand's violet family — soft color blobs +/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player +/// background but calmer (long 30–90 s periods, muted opacities, a legibility scrim on top, so it +/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a +/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode +/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the +/// two — radial gradients driven by a TimelineView give the same look with none of that risk. +/// +/// Applied via `.background { }` — NOT as a ZStack sibling — so the `.ignoresSafeArea()` here +/// can't inflate the caller's layout past the safe area (see the layout discipline note in +/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase. +struct GamepadScreenBackground: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// One drifting color blob: a base position + drift ellipse (unit coordinates), angular + /// speeds (rad/s — periods of 30–90 s), and a radius that slowly breathes. + private struct Blob { + let color: Color + let center: CGPoint + let drift: CGSize + let speed: (x: Double, y: Double) + let phase: (x: Double, y: Double) + /// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed). + let radius: CGFloat + let breathe: (amount: CGFloat, speed: Double) + let opacity: Double + } + + /// The brand violet, a deeper indigo, a warmer plum, and a cool blue — related hues so the + /// field shifts within one temperature instead of strobing through the rainbow. + private static let blobs: [Blob] = [ + Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet + center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10), + speed: (0.111, 0.083), phase: (0.0, 1.9), + radius: 0.52, breathe: (0.07, 0.061), opacity: 0.52), + Blob(color: Color(red: 0.24, green: 0.20, blue: 0.72), // deep indigo + center: CGPoint(x: 0.78, y: 0.66), drift: CGSize(width: 0.13, height: 0.14), + speed: (0.071, 0.096), phase: (2.4, 0.7), + radius: 0.58, breathe: (0.08, 0.049), opacity: 0.55), + Blob(color: Color(red: 0.62, green: 0.30, blue: 0.80), // plum + center: CGPoint(x: 0.16, y: 0.82), drift: CGSize(width: 0.12, height: 0.09), + speed: (0.089, 0.067), phase: (4.1, 3.2), + radius: 0.44, breathe: (0.09, 0.078), opacity: 0.42), + Blob(color: Color(red: 0.22, green: 0.38, blue: 0.86), // cool blue + center: CGPoint(x: 0.70, y: 0.12), drift: CGSize(width: 0.10, height: 0.08), + speed: (0.059, 0.104), phase: (1.2, 5.0), + radius: 0.40, breathe: (0.06, 0.055), opacity: 0.38), + ] + + var body: some View { + Group { + if reduceMotion { + field(at: 0) + } else { + // 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost + // of a battery-fed couch device vs. the default display rate. + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + field(at: context.date.timeIntervalSinceReferenceDate) + } + } + } + .ignoresSafeArea() + } + + private func field(at t: TimeInterval) -> some View { + GeometryReader { geo in + let side = max(geo.size.width, geo.size.height) + ZStack { + Color.black + ZStack { + ForEach(Self.blobs.indices, id: \.self) { i in + blobView(Self.blobs[i], at: t, in: geo.size, side: side) + } + } + // ±10° over ~5 min — the whole field very slowly warms and cools. + .hueRotation(.degrees(sin(t * 0.021) * 10)) + // Composite the additive blobs offscreen once instead of per-layer. + .drawingGroup() + // Legibility scrim: the title (top) and detail/hints (bottom) always sit on + // near-black, whatever the blobs are doing behind them. + LinearGradient( + stops: [ + .init(color: .black.opacity(0.55), location: 0), + .init(color: .black.opacity(0.15), location: 0.35), + .init(color: .black.opacity(0.20), location: 0.65), + .init(color: .black.opacity(0.60), location: 1), + ], + startPoint: .top, endPoint: .bottom) + } + } + } + + private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View { + let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x)) + let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y)) + let r = side * blob.radius + * (1 + blob.breathe.amount * CGFloat(sin(t * blob.breathe.speed + blob.phase.x))) + return Circle() + .fill(RadialGradient( + colors: [blob.color, blob.color.opacity(0)], + center: .center, startRadius: 0, endRadius: r / 2)) + .frame(width: r, height: r) + .position(x: x * size.width, y: y * size.height) + .opacity(blob.opacity) + .blendMode(.plusLighter) + } +} + +/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard +/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row +/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar. +struct GamepadTrayScrim: View { + let edge: VerticalEdge + + var body: some View { + LinearGradient( + stops: [ + .init(color: .black.opacity(0.92), location: 0), + .init(color: .black.opacity(0.85), location: 0.55), + .init(color: .black.opacity(0), location: 1), + ], + startPoint: edge == .top ? .top : .bottom, + endPoint: edge == .top ? .bottom : .top) + // Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own + // text always sits on the near-opaque part, rows dim before they reach it. + .padding(edge == .top ? .bottom : .top, -32) + .ignoresSafeArea() + } +} + +/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet +/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders +/// when the pad or its battery state changes. +struct ControllerStatusChip: View { + let controller: GamepadManager.DiscoveredController + + var body: some View { + HStack(spacing: 7) { + Image(systemName: controller.hasTouchpadAndMotion + ? "playstation.logo" : "gamecontroller.fill") + .font(.system(size: 12)) + Text(controller.name) + .lineLimit(1) + if let level = controller.batteryLevel { + Image(systemName: batterySymbol(level)) + .font(.system(size: 12)) + .foregroundStyle(level <= 0.2 && !controller.isCharging + ? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7))) + } + } + .font(.geist(12, .medium, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.7)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Capsule().fill(.white.opacity(0.08))) + .overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1)) + } + + private func batterySymbol(_ level: Float) -> String { + if controller.isCharging { return "battery.100.bolt" } + switch level { + case ..<0.125: return "battery.0" + case ..<0.375: return "battery.25" + case ..<0.625: return "battery.50" + case ..<0.875: return "battery.75" + default: return "battery.100" + } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift similarity index 65% rename from clients/apple/Sources/PunktfunkClient/GamepadHomeView.swift rename to clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 6019104..4bcf613 100644 --- a/clients/apple/Sources/PunktfunkClient/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -1,8 +1,9 @@ // The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host // launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built // around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is -// required (a tap still works as a fallback). Scope: browse saved + discovered hosts, connect, and -// — when the library flag is on — jump into a saved host's library (Y). +// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the +// gamepad settings screen, and the carousel always ends in an Add Host tile that opens the +// controller-keyboard add flow. (A tap still works as a fallback for all of it.) // // All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's // chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a @@ -11,18 +12,21 @@ // status bar / home indicator. As a background it draws behind without affecting layout, so the // GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with // `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's -// vertical budget — and the card is sized off the remaining height. tvOS/macOS never mount this view. +// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the +// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets +// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead). import PunktfunkKit import SwiftUI -#if os(iOS) +#if os(iOS) || os(macOS) import GameController -/// One navigable tile: a saved host or a discovered-but-unsaved one. Hashable so it can be the -/// carousel's scroll-position identity. +/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host +/// action. Hashable so it can be the carousel's scroll-position identity. private enum GamepadHomeTarget: Hashable { case saved(UUID) case discovered(String) + case addHost } /// A fully-resolved launcher tile — display fields + the activate action, built fresh each render @@ -31,13 +35,17 @@ private struct HomeTile: Identifiable { let id: GamepadHomeTarget let title: String let subtitle: String - let isOnline: Bool - let isPaired: Bool - let isConnecting: Bool - /// Saved (solid monogram) vs. discovered-but-unsaved (tinted outline). - let filled: Bool + var isOnline = false + var isPaired = false + var isConnecting = false + /// Saved (solid monogram) vs. discovered-but-unsaved / action (tinted outline). + var filled = false /// Only saved hosts have a library (matches the touch grid's context-menu gate). - let hasLibrary: Bool + var hasLibrary = false + /// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile). + var icon: String? + /// Whether the detail panel shows the online/paired pill (hosts yes, actions no). + var showsStatus = true let activate: () -> Void } @@ -51,12 +59,18 @@ struct GamepadHomeView: View { /// Same experimental gate the touch grid's "Browse Library…" context-menu item uses. @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false + #if os(iOS) /// `.compact` in a landscape phone window — drives tighter chrome so everything still fits. @Environment(\.verticalSizeClass) private var vSizeClass - @State private var selection: GamepadHomeTarget? - @State private var breathe = false private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false // no size classes on macOS; the window minimum keeps room + #endif + @ObservedObject private var gamepads = GamepadManager.shared + @State private var selection: GamepadHomeTarget? + @State private var showSettings = false + @State private var showAddHost = false var body: some View { GeometryReader { geo in @@ -64,97 +78,70 @@ struct GamepadHomeView: View { } // Pinned inside the safe area, out of the carousel's vertical budget — never clipped. .safeAreaInset(edge: .top, spacing: 0) { - titleView - .padding(.top, compact ? 4 : 10) + titleBar + .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, compact ? 4 : 8) - .frame(maxWidth: .infinity) } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { - if !tiles.isEmpty { - hintBar - .padding(.leading, 22) - .padding(.vertical, compact ? 6 : 10) - } - } - .background { background } - .onAppear { - discovery.start() - withAnimation(.easeInOut(duration: 4).repeatForever(autoreverses: true)) { breathe = true } + GamepadHintBar(hints: hints) + .padding(.leading, 22) + .padding(.vertical, compact ? 6 : 10) } + .background { GamepadScreenBackground() } + .onAppear { discovery.start() } .onDisappear { discovery.stop() } - .alert( - "Connection failed", - isPresented: Binding( - get: { model.errorMessage != nil }, - set: { if !$0 { model.errorMessage = nil } }) - ) { - Button("OK", role: .cancel) {} - } message: { - Text(model.errorMessage ?? "") + // The settings / add-host screens take over the controller (the carousel's `isActive` + // gate above). iOS presents them full screen — the immersive console feel; macOS has no + // fullScreenCover, so they become generously sized sheets over the dimmed launcher. + #if os(macOS) + .sheet(isPresented: $showSettings) { + GamepadSettingsView() + .frame(width: 720, height: 640) } + .sheet(isPresented: $showAddHost) { + GamepadAddHostView { store.add($0) } + .frame(width: 660, height: 620) + } + .frame(minWidth: 640, minHeight: 420) + #else + .fullScreenCover(isPresented: $showSettings) { GamepadSettingsView() } + .fullScreenCover(isPresented: $showAddHost) { + GamepadAddHostView { store.add($0) } + } + #endif } // MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints @ViewBuilder private func hero(for size: CGSize) -> some View { - if tiles.isEmpty { - emptyState.frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - let cardWidth = min(340, size.width * 0.84) - // 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so - // the strip + detail always fit the region the safe-area insets leave. - let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96)) - VStack(spacing: compact ? 8 : 10) { - Spacer(minLength: 0) - carousel(cardWidth: cardWidth, cardHeight: cardHeight) - detailPanel - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + let cardWidth = min(340, size.width * 0.84) + // 96 ≈ the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so + // the strip + detail always fit the region the safe-area insets leave. + let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96)) + VStack(spacing: compact ? 8 : 10) { + Spacer(minLength: 0) + carousel(cardWidth: cardWidth, cardHeight: cardHeight) + detailPanel + Spacer(minLength: 0) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Chrome - private var background: some View { - ZStack { - LinearGradient( - colors: [.black, Color.brand.opacity(0.22), .black], - startPoint: .top, endPoint: .bottom) - // A soft brand orb behind the strip gives the flat gradient depth; it breathes slowly. - Circle() - .fill(RadialGradient( - colors: [Color.brand.opacity(0.55), .clear], - center: .center, startRadius: 0, endRadius: 300)) - .frame(width: 560, height: 560) - .blur(radius: 70) - .scaleEffect(breathe ? 1.08 : 0.92) - .opacity(breathe ? 0.5 : 0.32) - .offset(y: -20) - } - .ignoresSafeArea() - } - - private var titleView: some View { + private var titleBar: some View { Text("Select a Host") .font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) .foregroundStyle(.white) - } - - private var emptyState: some View { - VStack(spacing: 14) { - Image(systemName: "gamecontroller") - .font(.system(size: 46, weight: .light)) - .foregroundStyle(Color.brand) - Text("No hosts yet") - .font(.geist(20, .semibold, relativeTo: .title3)) - .foregroundStyle(.white) - Text("Add one with touch first — it'll show up here for the controller.") - .font(.geist(15, relativeTo: .body)) - .foregroundStyle(.white.opacity(0.6)) - .multilineTextAlignment(.center) - .frame(maxWidth: 320) - } + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { + // Which pad is driving this UI (name + battery) — quiet, and only where there's + // room; a compact-height phone gives the pixels to the carousel instead. + if !compact, let active = gamepads.active { + ControllerStatusChip(controller: active) + .padding(.trailing, 20) + } + } } // MARK: - Carousel @@ -167,9 +154,10 @@ struct GamepadHomeView: View { spacing: 30, onActivate: { $0.activate() }, onSecondary: { openLibraryForSelected() }, - // Stop consuming the controller while the library is presented on top — otherwise the - // launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet). - isActive: libraryTarget == nil + onTertiary: { showSettings = true }, + // Stop consuming the controller while another screen is presented on top — otherwise + // the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet). + isActive: libraryTarget == nil && !showSettings && !showAddHost ) { tile in hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) } @@ -211,7 +199,7 @@ struct GamepadHomeView: View { Text(tile?.subtitle ?? " ") .font(.geist(13, relativeTo: .caption)) .foregroundStyle(.white.opacity(0.6)) - if let tile { + if let tile, tile.showsStatus { statusPill(online: tile.isOnline, paired: tile.isPaired) } } @@ -236,71 +224,52 @@ struct GamepadHomeView: View { // MARK: - Hint bar (pinned bottom-leading via safeAreaInset) - private var hintBar: some View { - HStack(spacing: 18) { - hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Connect") - if showsLibraryHint { - hint(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library") - } + private var hints: [GamepadHint] { + let selected = tiles.first { $0.id == selection } + var hints = [GamepadHint( + glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), + text: selected?.id == .addHost ? "Add Host" : "Connect")] + if libraryEnabled, selected?.hasLibrary == true { + hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")) } - .font(.geist(14, .semibold, relativeTo: .subheadline)) - .foregroundStyle(.white.opacity(0.85)) - } - - private func hint(glyph: String, text: String) -> some View { - HStack(spacing: 7) { - Image(systemName: glyph) - .font(.system(size: 19)) - .foregroundStyle(.white) - Text(text) - } - .fixedSize() // keep glyph + label together; never truncate a hint mid-word - } - - private var showsLibraryHint: Bool { - guard libraryEnabled else { return false } - return tiles.first { $0.id == selection }?.hasLibrary ?? false - } - - /// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via - /// `sfSymbolsName`; a generic fallback before a controller profile resolves. - private func buttonGlyph( - _ button: KeyPath, fallback: String - ) -> String { - GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName - ?? fallback + hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings")) + return hints } // MARK: - Data + actions /// Built fresh each render from the live stores (no stale value capture) — saved hosts first, - /// then discovered-but-unsaved ones. + /// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never + /// empty and manual entry is always one press away). private var tiles: [HomeTile] { let saved = store.hosts.map { host in HomeTile( id: .saved(host.id), title: host.displayName, subtitle: "\(host.address):\(String(host.port))", - isOnline: isOnline(host), + isOnline: discovery.advertises(host), isPaired: host.pinnedSHA256 != nil, isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, filled: true, hasLibrary: true, activate: { connect(host) }) } - let discovered = discoveredUnsaved.map { d in + let discovered = discovery.unsaved(among: store.hosts).map { d in HomeTile( id: .discovered(d.id), title: d.name, subtitle: "\(d.host):\(String(d.port))", isOnline: true, - isPaired: false, - isConnecting: false, - filled: false, - hasLibrary: false, activate: { connectDiscovered(d) }) } - return saved + discovered + let add = HomeTile( + id: .addHost, + title: "Add Host", + subtitle: "Register a host by address", + icon: "plus", + showsStatus: false, + activate: { showAddHost = true }) + return saved + discovered + [add] } /// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a @@ -311,14 +280,6 @@ struct GamepadHomeView: View { else { return } libraryTarget = host } - - private func isOnline(_ host: StoredHost) -> Bool { - discovery.hosts.contains { host.matches($0) } - } - - private var discoveredUnsaved: [DiscoveredHost] { - discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } } - } } /// One "console tile" in the host carousel — a dark-glass landscape card, bigger and bolder than the @@ -381,6 +342,10 @@ private struct GamepadHostTile: View { : AnyShapeStyle(Color.brand.opacity(0.16))) if tile.isConnecting { ProgressView().tint(.white) + } else if let icon = tile.icon { + Image(systemName: icon) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Color.brand) } else { Text(monogram(tile.title)) .font(.geistFixed(25, .bold)) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift new file mode 100644 index 0000000..0588291 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadKeyboard.swift @@ -0,0 +1,182 @@ +// A controller-driven on-screen keyboard for the gamepad UI's text fields (iOS/iPadOS only) — +// iOS has no system keyboard a game controller can drive (the tvOS fullscreen entry doesn't +// exist here), so without this, adding a host from the couch would end with "now touch the +// screen". Dpad/stick moves a key cursor over a fixed grid, A types, X backspaces, B/Y confirms. +// Lowercase + digits + the hostname/address punctuation is deliberately the whole character set: +// these fields hold names, addresses and ports, not prose. +// +// Edits are applied to the binding live (the caller's field row shows every keystroke), so +// closing the keyboard is always "done" — there is no separate cancel/commit step to get wrong. +// Touch stays a fallback: every keycap is tappable. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) + +struct GamepadKeyboard: View { + @Binding var text: String + /// Restricts typed characters (e.g. digits for a port field); backspace always works. + var allowed: CharacterSet? + /// B / Y / the Done key — the binding already holds the final text. + let onDone: () -> Void + + @State private var input = GamepadMenuInput(manager: .shared) + @State private var haptics = MenuHaptics(manager: .shared) + @State private var cursor = GridPos(row: 1, col: 0) // opens on "q" + @State private var pressTick = 0 + @State private var boundaryTick = 0 + #if os(iOS) + /// `.compact` (landscape phone): shorter keycaps so the tray leaves room for the field rows. + @Environment(\.verticalSizeClass) private var vSizeClass + + private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false // no size classes on macOS; the sheet is sized generously + #endif + + private struct GridPos: Hashable { + var row: Int + var col: Int + } + + private enum Key: Hashable { + case char(Character) + case space + case backspace + case done + } + + /// Digits first (addresses/ports), then letters; the last char column carries the + /// hostname/address punctuation. + private static let rows: [[Key]] = [ + Array("1234567890").map(Key.char), + Array("qwertyuiop").map(Key.char), + Array("asdfghjkl-").map(Key.char), + Array("zxcvbnm._:").map(Key.char), + [.space, .backspace, .done], + ] + + var body: some View { + VStack(spacing: compact ? 5 : 7) { + ForEach(Self.rows.indices, id: \.self) { r in + HStack(spacing: compact ? 5 : 7) { + ForEach(Self.rows[r].indices, id: \.self) { c in + keycap(Self.rows[r][c], focused: cursor == GridPos(row: r, col: c)) + .onTapGesture { + cursor = GridPos(row: r, col: c) + press(Self.rows[r][c]) + } + } + } + } + } + .frame(maxWidth: 560) + .padding(compact ? 10 : 14) + .background { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(.ultraThinMaterial) + .environment(\.colorScheme, .dark) + } + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 1) + } + .sensoryFeedback(.selection, trigger: cursor) + .sensoryFeedback(.impact(weight: .light), trigger: pressTick) + .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick) + .onAppear { + wire() + input.start() + } + .onDisappear { + input.stop() + haptics.stop() + } + } + + // MARK: - Keycaps + + @ViewBuilder private func keycap(_ key: Key, focused: Bool) -> some View { + Group { + switch key { + case .char(let c): + Text(String(c)).font(.geistFixed(18, .medium)) + case .space: + Image(systemName: "space") + case .backspace: + Image(systemName: "delete.left") + case .done: + Label("Done", systemImage: "checkmark") + .font(.geist(15, .semibold, relativeTo: .callout)) + } + } + .foregroundStyle(focused ? Color.black : .white) + .frame(maxWidth: .infinity, minHeight: compact ? 34 : 42) + .background { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(focused ? AnyShapeStyle(Color.brand) : AnyShapeStyle(.white.opacity(0.08))) + } + .animation(.smooth(duration: 0.12), value: focused) + .contentShape(Rectangle()) + } + + // MARK: - Input + + private func wire() { + input.onMove = { move($0) } + input.onConfirm = { press(Self.rows[cursor.row][cursor.col]) } + input.onTertiary = { press(.backspace) } + input.onSecondary = onDone + input.onBack = onDone + } + + private func move(_ direction: GamepadMenuInput.Direction) { + var next = cursor + switch direction { + case .left: next.col -= 1 + case .right: next.col += 1 + case .up, .down: + let row = cursor.row + (direction == .down ? 1 : -1) + guard row >= 0, row < Self.rows.count else { return refuse() } + // Map the column proportionally between rows of different widths, so e.g. Done + // (rightmost of 3) goes up to the rightmost letters, not to "e". + let from = max(1, Self.rows[cursor.row].count - 1) + let to = Self.rows[row].count - 1 + next = GridPos( + row: row, + col: Int((Double(cursor.col) * Double(to) / Double(from)).rounded())) + } + guard next.row >= 0, next.row < Self.rows.count, + next.col >= 0, next.col < Self.rows[next.row].count + else { return refuse() } + cursor = next + haptics.move() + } + + private func press(_ key: Key) { + switch key { + case .char(let c): + if let allowed, !c.unicodeScalars.allSatisfy(allowed.contains) { return refuse() } + text.append(c) + case .space: + if let allowed, !allowed.contains(" ") { return refuse() } + text.append(" ") + case .backspace: + guard !text.isEmpty else { return refuse() } + text.removeLast() + case .done: + haptics.confirm() + onDone() + return + } + pressTick &+= 1 + haptics.move() + } + + /// Refused input (edge of the grid, a disallowed character, deleting nothing). + private func refuse() { + boundaryTick &+= 1 + haptics.boundary() + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift new file mode 100644 index 0000000..f007196 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadMenuList.swift @@ -0,0 +1,178 @@ +// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for +// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a +// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs +// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus +// cursor, controller polling, haptics, and keeping the focused row scrolled into view. +// +// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the +// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a +// first-class fallback — tapping a row focuses AND activates it (rows are always fully visible, so +// the carousel's "first tap re-centers" step would only add friction here), and free finger +// scrolling is never hijacked back to the focused row until the next controller move. +// +// Feedback is dual-channel like the carousel: `.sensoryFeedback` ticks the DEVICE Taptic engine, +// `MenuHaptics` ticks the CONTROLLER. Moves and value changes get the crisp detent; a refused +// move at either end gets the dull boundary thud plus a short vertical recoil. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) + +struct GamepadMenuList: View where Item.ID: Hashable { + let items: [Item] + /// Output only: the list WRITES the focused item's id here (e.g. for a caller's hint bar). + @Binding var focusID: Item.ID? + /// Left/right on the focused row. Return whether the value actually changed — true plays the + /// move detent, false the boundary thud (end of a clamped range, or nothing to adjust). + var onAdjust: ((Item, Int) -> Bool)? + /// A → activate the focused row (toggle it, open it, run it — the caller decides). + let onActivate: (Item) -> Void + /// B → back/dismiss; nil disables it. + var onBack: (() -> Void)? + /// Whether this list currently owns controller input — same handoff contract as + /// GamepadCarousel's `isActive` (a covered screen must stop polling the shared pad). + var isActive: Bool = true + @ViewBuilder let row: (Item, _ focused: Bool) -> Row + + @State private var input = GamepadMenuInput(manager: .shared) + @State private var haptics = MenuHaptics(manager: .shared) + /// Authoritative focus cursor (index into `items`). + @State private var cursor = 0 + /// A short vertical recoil when a move is refused at a list end. + @State private var bumpOffset: CGFloat = 0 + /// `.sensoryFeedback` counters (see GamepadCarousel): device ticks for activate / value-change + /// / end-stop events; moves trigger on `cursor` itself. + @State private var activateTick = 0 + @State private var adjustTick = 0 + @State private var boundaryTick = 0 + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(spacing: 6) { + ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in + row(item, idx == cursor && isActive) + .contentShape(Rectangle()) + .onTapGesture { tap(idx) } + .id(item.id) + } + } + .padding(.vertical, 10) + } + // .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden. + .scrollIndicators(.never) + .offset(y: bumpOffset) + .onChange(of: cursor) { _, newValue in + guard newValue >= 0, newValue < items.count else { return } + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(items[newValue].id) + } + } + } + .sensoryFeedback(.selection, trigger: cursor) + .sensoryFeedback(.selection, trigger: adjustTick) + .sensoryFeedback(.impact(weight: .medium), trigger: activateTick) + .sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.7), trigger: boundaryTick) + .onAppear { + reconcile() + wire() + if isActive { input.start() } + } + .onDisappear { + input.stop() + haptics.stop() + } + .onChange(of: isActive) { _, active in + if active { + wire() + input.start() + } else { + input.stop() + haptics.stop() + } + } + // Re-seed a dropped focus AND re-wire the input callbacks so they capture the current + // `items` value (a plain array — it would otherwise go stale in the stored closures). + .onChange(of: items.map(\.id)) { _, _ in + reconcile() + wire() + } + } + + // MARK: - Input wiring + + private func wire() { + input.onMove = { direction in + switch direction { + case .up: step(by: -1) + case .down: step(by: 1) + case .left: adjust(by: -1) + case .right: adjust(by: 1) + } + } + input.onConfirm = { activate() } + input.onBack = onBack + } + + private func step(by delta: Int) { + guard !items.isEmpty else { return } + let target = cursor + delta + guard target >= 0, target < items.count else { return boundaryBump(forward: delta > 0) } + cursor = target + focusID = items[target].id + haptics.move() + } + + private func adjust(by delta: Int) { + guard let onAdjust, cursor >= 0, cursor < items.count else { return } + if onAdjust(items[cursor], delta) { + adjustTick &+= 1 + haptics.move() + } else { + boundaryTick &+= 1 + haptics.boundary() + } + } + + private func activate() { + guard cursor >= 0, cursor < items.count else { return } + activateTick &+= 1 + haptics.confirm() + onActivate(items[cursor]) + } + + /// Touch fallback: a tap focuses the row and activates it in one go. + private func tap(_ idx: Int) { + guard idx >= 0, idx < items.count else { return } + if cursor != idx { + cursor = idx + focusID = items[idx].id + } + activate() + } + + /// Keep `cursor`/`focusID` consistent with `items`: seed on appear; on a list change keep the + /// same focused item when it survives, else clamp the cursor into range. + private func reconcile() { + guard !items.isEmpty else { + cursor = 0 + if focusID != nil { focusID = nil } + return + } + if let id = focusID, let idx = items.firstIndex(where: { $0.id == id }) { + cursor = idx + } else { + cursor = min(max(cursor, 0), items.count - 1) + focusID = items[cursor].id + } + } + + private func boundaryBump(forward: Bool) { + boundaryTick &+= 1 + haptics.boundary() + let recoil: CGFloat = forward ? -14 : 14 + withAnimation(.spring(response: 0.16, dampingFraction: 0.42)) { bumpOffset = recoil } + withAnimation(.spring(response: 0.34, dampingFraction: 0.7).delay(0.1)) { bumpOffset = 0 } + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkClient/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift similarity index 89% rename from clients/apple/Sources/PunktfunkClient/HomeView.swift rename to clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 019949a..13cc9e0 100644 --- a/clients/apple/Sources/PunktfunkClient/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -137,17 +137,6 @@ struct HomeView: View { } #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 @@ -156,7 +145,7 @@ struct HomeView: View { let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil return HostCardView( host: host, - isOnline: isOnline(host), + isOnline: discovery.advertises(host), isConnecting: model.phase == .connecting && model.activeHost?.id == host.id, isMostRecent: host.id == mostRecentHostID, isBusy: model.isBusy, @@ -186,18 +175,10 @@ struct HomeView: View { .padding(.top, store.hosts.isEmpty ? 0 : 8) } - /// A saved host is "online" iff a live mDNS advert currently matches it (see - /// `StoredHost.matches`). Recomputed on every discovery change (the @Published set), so the - /// dot tracks hosts appearing/leaving the network live. - private func isOnline(_ host: StoredHost) -> Bool { - discovery.hosts.contains { host.matches($0) } - } - - /// Discovered hosts not already saved — the saved grid shows the rest, so this section only - /// surfaces genuinely-new hosts on the network. Same match as the online dot, so a saved host - /// whose IP changed (still fingerprint-matched) doesn't also appear here as a stranger. + /// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad + /// launcher so both screens classify hosts identically). private var discoveredUnsaved: [DiscoveredHost] { - discovery.hosts.filter { d in !store.hosts.contains { $0.matches(d) } } + discovery.unsaved(among: store.hosts) } /// The host of the most recent session — its card carries the accent ring. diff --git a/clients/apple/Sources/PunktfunkClient/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/HostCards.swift rename to clients/apple/Sources/PunktfunkClient/Home/HostCards.swift diff --git a/clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift similarity index 80% rename from clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift rename to clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift index 3716b49..53c0266 100644 --- a/clients/apple/Sources/PunktfunkClient/LibraryCoverflowView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryCoverflowView.swift @@ -1,4 +1,4 @@ -// The gamepad-driven presentation of the game library (iOS/iPadOS only — see LibraryView's +// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS — see LibraryView's // `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the // scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card // (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and @@ -15,9 +15,8 @@ import PunktfunkKit import SwiftUI -#if os(iOS) +#if os(iOS) || os(macOS) import GameController -import UIKit struct LibraryCoverflowView: View { let games: [GameEntry] @@ -27,27 +26,26 @@ struct LibraryCoverflowView: View { /// Close button already covers that); this is what makes gamepad-only exit possible. var onDismiss: (() -> Void)? + #if os(iOS) /// `.compact` in a landscape phone window — drives a tighter poster so everything still fits. @Environment(\.verticalSizeClass) private var vSizeClass - @State private var selection: String? private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false // no size classes on macOS + #endif + @State private var selection: String? var body: some View { GeometryReader { geo in content(for: geo.size) } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { - hintBar + GamepadHintBar(hints: hints) .padding(.leading, 22) .padding(.vertical, compact ? 6 : 10) } - .background { - LinearGradient( - colors: [.black, Color.brand.opacity(0.16), .black], - startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - } + .background { GamepadScreenBackground() } } @ViewBuilder private func content(for size: CGSize) -> some View { @@ -138,34 +136,13 @@ struct LibraryCoverflowView: View { // MARK: - Hint bar (pinned bottom-leading via safeAreaInset) - private var hintBar: some View { - HStack(spacing: 18) { - if onLaunch != nil { - hint(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch") - } - hint(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close") + private var hints: [GamepadHint] { + var hints: [GamepadHint] = [] + if onLaunch != nil { + hints.append(.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Launch")) } - .font(.geist(14, .semibold, relativeTo: .subheadline)) - .foregroundStyle(.white.opacity(0.85)) - } - - private func hint(glyph: String, text: String) -> some View { - HStack(spacing: 7) { - Image(systemName: glyph) - .font(.system(size: 19)) - .foregroundStyle(.white) - Text(text) - } - .fixedSize() // keep glyph + label together; never truncate a hint mid-word - } - - /// The active controller's real glyph for a button (Xbox "B", DualSense ◯, …) via - /// `sfSymbolsName`; a generic fallback before a controller profile resolves. - private func buttonGlyph( - _ button: KeyPath, fallback: String - ) -> String { - GamepadManager.shared.active?.controller.extendedGamepad?[keyPath: button].sfSymbolsName - ?? fallback + hints.append(.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Close")) + return hints } } #endif diff --git a/clients/apple/Sources/PunktfunkClient/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift similarity index 69% rename from clients/apple/Sources/PunktfunkClient/LibraryView.swift rename to clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index ae776f2..4ff70cf 100644 --- a/clients/apple/Sources/PunktfunkClient/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -5,11 +5,6 @@ import PunktfunkKit import SwiftUI -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif struct LibraryView: View { @ObservedObject var store: HostStore @@ -26,9 +21,9 @@ struct LibraryView: View { /// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`; /// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session. @State private var imageSession: URLSession? - #if os(iOS) - // Gamepad-driven browsing is iOS/iPadOS-only — see HomeView's identical gate. tvOS keeps its - // existing plain-grid presentation of this same view unchanged. + #if os(iOS) || os(macOS) + // Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps + // its existing plain-grid presentation of this same view unchanged. @ObservedObject private var gamepadManager = GamepadManager.shared @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true private var gamepadUIActive: Bool { @@ -74,7 +69,7 @@ struct LibraryView: View { } else if games.isEmpty { emptyState } else { - #if os(iOS) + #if os(iOS) || os(macOS) if gamepadUIActive { LibraryCoverflowView( games: games, imageSession: imageSession, onLaunch: onLaunch, @@ -202,88 +197,3 @@ private struct GameCard: View { } } } - -/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster — -/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell. -struct StoreBadge: View { - let isCustom: Bool - - var body: some View { - Text(isCustom ? "Custom" : "Steam") - .font(.geist(11, .semibold, relativeTo: .caption2)) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(.ultraThinMaterial, in: Capsule()) - .padding(6) - } -} - -#if canImport(UIKit) -private typealias PlatformImage = UIImage -#elseif canImport(AppKit) -private typealias PlatformImage = NSImage -#endif - -private extension Image { - init(platformImage: PlatformImage) { - #if canImport(UIKit) - self.init(uiImage: platformImage) - #elseif canImport(AppKit) - self.init(nsImage: platformImage) - #endif - } -} - -/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own -/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to -/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame -/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback -/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report -/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` — -/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art. -struct PosterImage: View { - let candidates: [URL] - let title: String - let session: URLSession? - @State private var index = 0 - @State private var image: PlatformImage? - - var body: some View { - Group { - if let image { - Image(platformImage: image) - .resizable() - .scaledToFill() - } else if index < candidates.count { - ZStack { placeholder; ProgressView() } - } else { - placeholder - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .clipped() - .task(id: index) { await loadCurrent() } - } - - private func loadCurrent() async { - guard index < candidates.count else { return } - guard let session, let data = try? await session.data(from: candidates[index]).0, - let loaded = PlatformImage(data: data) - else { - index += 1 // advance to the next candidate (or past the end → placeholder) - return - } - image = loaded - } - - private var placeholder: some View { - ZStack { - Rectangle().fill(.quaternary) - Text(title) - .font(.geist(17, .semibold, relativeTo: .headline)) - .multilineTextAlignment(.center) - .foregroundStyle(.secondary) - .padding(8) - } - } -} diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift new file mode 100644 index 0000000..77548a4 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryWidgets.swift @@ -0,0 +1,95 @@ +// Reusable library widgets, shared by the touch grid (LibraryView's `GameCard`) and the gamepad +// coverflow (LibraryCoverflowView's cover cell). + +import PunktfunkKit +import SwiftUI +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// The store-provenance badge (Steam vs. a user-curated custom entry) overlaid on a poster — +/// shared by the touch grid's `GameCard` and the gamepad coverflow's cover cell. +struct StoreBadge: View { + let isCustom: Bool + + var body: some View { + Text(isCustom ? "Custom" : "Steam") + .font(.geist(11, .semibold, relativeTo: .caption2)) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.ultraThinMaterial, in: Capsule()) + .padding(6) + } +} + +#if canImport(UIKit) +private typealias PlatformImage = UIImage +#elseif canImport(AppKit) +private typealias PlatformImage = NSImage +#endif + +private extension Image { + init(platformImage: PlatformImage) { + #if canImport(UIKit) + self.init(uiImage: platformImage) + #elseif canImport(AppKit) + self.init(nsImage: platformImage) + #endif + } +} + +/// Sequentially tries cover-art URLs over `session` (so a paired client can reach the host's own +/// art proxy, not just public CDNs — see `LibraryImageLoader`), advancing past any that fail to +/// load, then a placeholder. The loaded image is hard-clipped to fill the card's actual frame +/// regardless of its own aspect ratio: a portrait capsule fills it as intended, but a fallback +/// banner (wide hero/header art, used when a title has no portrait capsule) would otherwise report +/// a much wider intrinsic size than the card and overflow into neighboring cards. Not `private` — +/// the gamepad coverflow (`LibraryCoverflowView`) reuses it directly rather than re-fetching art. +struct PosterImage: View { + let candidates: [URL] + let title: String + let session: URLSession? + @State private var index = 0 + @State private var image: PlatformImage? + + var body: some View { + Group { + if let image { + Image(platformImage: image) + .resizable() + .scaledToFill() + } else if index < candidates.count { + ZStack { placeholder; ProgressView() } + } else { + placeholder + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + .task(id: index) { await loadCurrent() } + } + + private func loadCurrent() async { + guard index < candidates.count else { return } + guard let session, let data = try? await session.data(from: candidates[index]).0, + let loaded = PlatformImage(data: data) + else { + index += 1 // advance to the next candidate (or past the end → placeholder) + return + } + image = loaded + } + + private var placeholder: some View { + ZStack { + Rectangle().fill(.quaternary) + Text(title) + .font(.geist(17, .semibold, relativeTo: .headline)) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(8) + } + } +} diff --git a/clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift b/clients/apple/Sources/PunktfunkClient/Home/SpeedTestSheet.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/SpeedTestSheet.swift rename to clients/apple/Sources/PunktfunkClient/Home/SpeedTestSheet.swift diff --git a/clients/apple/Sources/PunktfunkClient/Session/HUDPlacement.swift b/clients/apple/Sources/PunktfunkClient/Session/HUDPlacement.swift new file mode 100644 index 0000000..e6d04f7 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Session/HUDPlacement.swift @@ -0,0 +1,35 @@ +// The HUD-corner model persisted by Settings and read wherever the overlay is placed +// (ContentView, StreamHUDView). + +import SwiftUI + +/// Which corner the HUD overlay occupies (persisted as `DefaultsKey.hudPlacement`). The raw +/// values are stable on disk — rename the cases freely, never the strings. +enum HUDPlacement: String, CaseIterable, Identifiable { + case topLeading, topTrailing, bottomLeading, bottomTrailing + + var id: String { rawValue } + + /// SwiftUI overlay alignment for `.overlay(alignment:)`. + var alignment: Alignment { + switch self { + case .topLeading: return .topLeading + case .topTrailing: return .topTrailing + case .bottomLeading: return .bottomLeading + case .bottomTrailing: return .bottomTrailing + } + } + + /// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward. + var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing } + + /// User-facing corner label. + var label: String { + switch self { + case .topLeading: return "Top Left" + case .topTrailing: return "Top Right" + case .bottomLeading: return "Bottom Left" + case .bottomTrailing: return "Bottom Right" + } + } +} diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift similarity index 95% rename from clients/apple/Sources/PunktfunkClient/SessionModel.swift rename to clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 87a0725..e80847a 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -74,6 +74,11 @@ final class SessionModel: ObservableObject { @Published var presentLatencyP95Ms = 0.0 @Published var presentLatencyValid = false @Published var presentLatencySkewCorrected = false + /// Decode-completion→present (the "present tail": ring wait + render + vsync) — the term the + /// stage-2 presenter exists to shorten. Both instants are client-side, so no skew applies. + @Published var presentTailP50Ms = 0.0 + @Published var presentTailP95Ms = 0.0 + @Published var presentTailValid = false /// Mirrors StreamView's capture state (it owns the input capture; this drives the /// HUD's "click to capture" / "⌘⎋ releases" hint). @Published var mouseCaptured = false @@ -82,6 +87,8 @@ final class SessionModel: ObservableObject { let latency = LatencyMeter() /// Fed by the stage-2 presenter's display link (capture→present). Passed to StreamView. let presentLatency = LatencyMeter() + /// Fed by the same present stamp (decode-completion→present). Passed to StreamView. + let presentTail = LatencyMeter() private var statsTimer: Timer? private var audio: SessionAudio? private var gamepadCapture: GamepadCapture? @@ -337,6 +344,13 @@ final class SessionModel: ObservableObject { } else { self.presentLatencyValid = false } + if let t = self.presentTail.drain() { + self.presentTailP50Ms = t.p50Ms + self.presentTailP95Ms = t.p95Ms + self.presentTailValid = true + } else { + self.presentTailValid = false + } } } // .common so the HUD keeps updating during window drags / menu tracking. diff --git a/clients/apple/Sources/PunktfunkClient/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/StreamCommands.swift rename to clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift diff --git a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift similarity index 72% rename from clients/apple/Sources/PunktfunkClient/StreamHUDView.swift rename to clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index f561386..1ebde7c 100644 --- a/clients/apple/Sources/PunktfunkClient/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -4,37 +4,6 @@ import PunktfunkKit import SwiftUI -/// Which corner the HUD overlay occupies (persisted as `DefaultsKey.hudPlacement`). The raw -/// values are stable on disk — rename the cases freely, never the strings. -enum HUDPlacement: String, CaseIterable, Identifiable { - case topLeading, topTrailing, bottomLeading, bottomTrailing - - var id: String { rawValue } - - /// SwiftUI overlay alignment for `.overlay(alignment:)`. - var alignment: Alignment { - switch self { - case .topLeading: return .topLeading - case .topTrailing: return .topTrailing - case .bottomLeading: return .bottomLeading - case .bottomTrailing: return .bottomTrailing - } - } - - /// The HUD's own stack hugs the screen edge it sits against, so its text aligns outward. - var isTrailing: Bool { self == .topTrailing || self == .bottomTrailing } - - /// User-facing corner label. - var label: String { - switch self { - case .topLeading: return "Top Left" - case .topTrailing: return "Top Right" - case .bottomLeading: return "Bottom Left" - case .bottomTrailing: return "Bottom Right" - } - } -} - struct StreamHUDView: View { @ObservedObject var model: SessionModel let connection: PunktfunkConnection @@ -63,6 +32,13 @@ struct StreamHUDView: View { .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) } + if model.presentTailValid { + // Decode→present (the client-local "present tail": ring wait + render + vsync) — + // the term the stage-2 presenter shortens; no skew applies (one clock). + Text("decode→present \(model.presentTailP50Ms, specifier: "%.1f")/\(model.presentTailP95Ms, specifier: "%.1f") ms p50/p95") + .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) @@ -71,11 +47,6 @@ struct StreamHUDView: View { : "Click the stream to capture input") .font(.geist(11, relativeTo: .caption2)) .foregroundStyle(.secondary) - // The client-side cursor (⌘⇧C) draws the local cursor over the stream instead of - // capturing it — the only accurate cursor for gamescope, whose capture has none. - Text("⌘⇧C toggles the on-screen cursor") - .font(.geist(11, relativeTo: .caption2)) - .foregroundStyle(.secondary) #elseif os(iOS) // Touch always plays directly; ⌘⎋ (hardware keyboard) toggles kb/mouse. Text(model.mouseCaptured diff --git a/clients/apple/Sources/PunktfunkClient/AcknowledgementsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/AcknowledgementsView.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/AcknowledgementsView.swift rename to clients/apple/Sources/PunktfunkClient/Settings/AcknowledgementsView.swift diff --git a/clients/apple/Sources/PunktfunkClient/ControllerTestView.swift b/clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/ControllerTestView.swift rename to clients/apple/Sources/PunktfunkClient/Settings/ControllerTestView.swift diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift new file mode 100644 index 0000000..5536d40 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -0,0 +1,357 @@ +// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView, +// restyled as a console settings page and fully navigable with a controller — up/down moves the +// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the +// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom +// resolutions, the log bitrate slider, debug tools), and both write the same DefaultsKey storage, +// so values round-trip freely between the two. +// +// Rows are rebuilt from live @AppStorage on every render; the focus list dispatches adjust/ +// activate back here BY ROW ID (see `adjust`/`activate`), so a stored input callback can never act +// on stale captured state. Left/right CLAMPS at a choice list's ends (the dull boundary thud tells +// the thumb it's the last option); A always cycles forward, wrapping, so every option is reachable +// with one button. Toggles read left = off, right = on — refusing a no-op with the same thud. + +import PunktfunkKit +import SwiftUI +#if os(iOS) || os(macOS) +import GameController + +struct GamepadSettingsView: View { + @Environment(\.dismiss) private var dismiss + @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.audioChannels) private var audioChannels = 2 + @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true + @AppStorage(DefaultsKey.enable444) private var enable444 = true + @AppStorage(DefaultsKey.codec) private var codec = "auto" + @AppStorage(DefaultsKey.micEnabled) private var micEnabled = true + @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true + @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue + @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false + @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true + @ObservedObject private var gamepads = GamepadManager.shared + + #if os(iOS) + /// `.compact` in a landscape phone window — tighter chrome so more rows fit. + @Environment(\.verticalSizeClass) private var vSizeClass + + private var compact: Bool { vSizeClass == .compact } + #else + private let compact = false // no size classes on macOS; the sheet is sized generously + #endif + @State private var focusID: String? + + var body: some View { + GamepadMenuList( + items: rows, + focusID: $focusID, + onAdjust: { row, delta in adjust(id: row.id, by: delta) }, + onActivate: { activate(id: $0.id) }, + onBack: { dismiss() } + ) { row, focused in + rowView(row, focused: focused) + .frame(maxWidth: 620) + .padding(.horizontal, 24) + } + .frame(maxWidth: .infinity) + .safeAreaInset(edge: .top, spacing: 0) { + Text("Settings") + .font(.geist(compact ? 20 : 30, .bold, relativeTo: .title)) + .foregroundStyle(.white) + .padding(.top, gamepadTitleTopPadding(compact: compact)) + .padding(.bottom, compact ? 4 : 8) + .frame(maxWidth: .infinity) + .overlay(alignment: .trailing) { closeButton.padding(.trailing, 20) } + .background { GamepadTrayScrim(edge: .top) } + } + .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + Text(focusedDetail) + .font(.geist(13, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(2, reservesSpace: true) + .animation(.smooth(duration: 0.2), value: focusID) + GamepadHintBar(hints: [ + .init(glyph: "arrow.left.and.right", text: "Adjust"), + .init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Change"), + .init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"), + ]) + } + .padding(.leading, 22) + .padding(.trailing, 22) + .padding(.vertical, compact ? 6 : 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background { GamepadTrayScrim(edge: .bottom) } + } + .background { GamepadScreenBackground() } + .onAppear { + gamepads.refresh() + gamepads.startDiscovery() + } + .onDisappear { gamepads.stopDiscovery() } + } + + /// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc + /// rides the cancel action. + private var closeButton: some View { + Button { dismiss() } label: { + Image(systemName: "xmark") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 34, height: 34) + .glassBackground(Circle(), interactive: true) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .keyboardShortcut(.cancelAction) + .accessibilityLabel("Close settings") + } + + // MARK: - Row rendering + + private func rowView(_ row: Row, focused: Bool) -> some View { + VStack(alignment: .leading, spacing: 6) { + if let header = row.header { + Text(header) + .font(.geist(12, .semibold, relativeTo: .caption)) + .tracking(1.4) + .foregroundStyle(.white.opacity(0.45)) + .padding(.leading, 16) + .padding(.top, 14) + } + HStack(spacing: 14) { + Image(systemName: row.icon) + .font(.system(size: 17)) + .foregroundStyle(focused ? Color.brand : .white.opacity(0.55)) + .frame(width: 28) + Text(row.label) + .font(.geist(16, .semibold, relativeTo: .body)) + .foregroundStyle(.white) + .lineLimit(1) + Spacer(minLength: 12) + HStack(spacing: 9) { + Image(systemName: "chevron.left") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) + Text(row.value) + .font(.geist(15, .medium, relativeTo: .callout)) + .foregroundStyle(focused ? .white : .white.opacity(0.6)) + .lineLimit(1) + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white.opacity(focused ? 0.6 : 0)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 13) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.white.opacity(focused ? 0.1 : 0)) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1) + } + .scaleEffect(focused ? 1.0 : 0.98) + .animation(.smooth(duration: 0.18), value: focused) + } + } + + private var focusedDetail: String { + rows.first { $0.id == focusID }?.detail ?? " " + } + + // MARK: - Row model + + private struct Row: Identifiable { + let id: String + /// Section header drawn above this row (the first row of each group carries it). + var header: String? + let icon: String + let label: String + let value: String + /// One-line explanation shown near the hint bar while this row is focused. + let detail: String + /// Left/right step; returns whether the value actually changed (false ⇒ boundary thud). + let adjust: (Int) -> Bool + /// A — cycle forward (wrapping) / flip. + let activate: () -> Void + } + + /// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows + /// (never on state captured at wire time). + private func adjust(id: String, by delta: Int) -> Bool { + rows.first { $0.id == id }?.adjust(delta) ?? false + } + + private func activate(id: String) { + rows.first { $0.id == id }?.activate() + } + + private var rows: [Row] { + let resolution = resolutionOptions + let refresh = SettingsOptions.refreshRates(including: hz) + .map { (label: "\($0) Hz", tag: $0) } + let bitrate = SettingsOptions.bitrateOptions(current: bitrateKbps) + let controllers = SettingsOptions.controllerOptions(gamepads) + return [ + choiceRow( + id: "resolution", header: "Stream", icon: "aspectratio", + label: "Resolution", + detail: "The host creates a virtual display at exactly this size — no scaling.", + options: resolution, current: "\(width)x\(height)" + ) { tag in + let parts = tag.split(separator: "x").compactMap { Int($0) } + guard parts.count == 2 else { return } + width = parts[0] + height = parts[1] + }, + choiceRow( + id: "refresh", icon: "gauge.with.needle", label: "Refresh rate", + detail: "Rates this display can actually show.", + options: refresh, current: hz + ) { hz = $0 }, + choiceRow( + id: "bitrate", icon: "speedometer", label: "Bitrate", + detail: "Automatic uses the host's default (20 Mbps). " + + "Run a speed test from the touch UI for an informed value.", + options: bitrate, current: bitrateKbps + ) { bitrateKbps = $0 }, + choiceRow( + id: "compositor", icon: "macwindow", label: "Compositor", + detail: "Which compositor drives the virtual output — honored only if " + + "available on the host.", + options: SettingsOptions.compositors, current: compositor + ) { compositor = $0 }, + + choiceRow( + id: "codec", header: "Video", icon: "film", label: "Video codec", + detail: "A preference — the host falls back if it can't encode this one " + + "(10-bit and 4:4:4 are HEVC-only).", + options: SettingsOptions.codecs, current: codec + ) { codec = $0 }, + toggleRow( + id: "hdr", icon: "sun.max", label: "10-bit HDR", + detail: "HDR10 — engages when the host sends HDR content and this display " + + "supports it.", + value: $hdrEnabled), + toggleRow( + id: "chroma", icon: "textformat", label: "Full chroma (4:4:4)", + detail: "Sharper text and UI at more bandwidth — needs host opt-in and " + + "hardware decode.", + value: $enable444), + + choiceRow( + id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels", + detail: "The speaker layout requested from the host.", + options: SettingsOptions.audioChannels, current: audioChannels + ) { audioChannels = $0 }, + toggleRow( + id: "mic", icon: "mic", label: "Microphone", + detail: "Send this device's microphone to the host's virtual mic.", + value: $micEnabled), + + choiceRow( + id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller", + detail: "Which pad is forwarded to the host, as player 1.", + options: controllers, current: gamepads.preferredID + ) { gamepads.preferredID = $0 }, + choiceRow( + id: "padType", icon: "dpad", label: "Controller type", + detail: "The virtual pad the host creates — Automatic matches this controller.", + options: SettingsOptions.padTypes, current: gamepadType + ) { gamepadType = $0 }, + + toggleRow( + id: "hud", header: "Interface", icon: "chart.bar", label: "Statistics overlay", + detail: "Resolution, frame rate, throughput and latency while streaming.", + value: $hudEnabled), + choiceRow( + id: "hudPlacement", icon: "rectangle.inset.topright.filled", label: "Overlay position", + detail: "Which corner the statistics overlay sits in.", + options: SettingsOptions.hudPlacements, current: hudPlacement + ) { hudPlacement = $0 }, + toggleRow( + id: "library", icon: "square.grid.2x2", label: "Game library", + detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) " + + "(experimental).", + value: $libraryEnabled), + toggleRow( + id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI", + detail: "Turn off to use the touch interface even with a controller connected.", + value: $gamepadUIEnabled), + ] + } + + /// Resolution choices as "WxH" tags — the current size is inserted when it's a custom mode + /// (set via the touch settings), so cycling starts from it instead of jumping. + private var resolutionOptions: [(label: String, tag: String)] { + var options = SettingsOptions.resolutionModes() + .map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") } + let current = "\(width)x\(height)" + if !options.contains(where: { $0.tag == current }) { + options.insert((label: "Custom · \(width) × \(height)", tag: current), at: 0) + } + return options + } + + /// The active controller's user-facing name for a button (for detail strings). + private func buttonName( + _ button: KeyPath, _ fallback: String + ) -> String { + gamepads.active?.controller.extendedGamepad?[keyPath: button].localizedName ?? fallback + } + + // MARK: - Row builders + + private func choiceRow( + id: String, header: String? = nil, icon: String, label: String, detail: String, + options: [(label: String, tag: T)], current: T, write: @escaping (T) -> Void + ) -> Row { + let index = options.firstIndex { $0.tag == current } + return Row( + id: id, header: header, icon: icon, label: label, + value: index.map { options[$0].label } ?? "—", + detail: detail, + adjust: { delta in + // Unknown current value: snap to the first option on any step. + guard let index else { + guard let first = options.first else { return false } + write(first.tag) + return true + } + let target = index + delta + guard target >= 0, target < options.count else { return false } + write(options[target].tag) + return true + }, + activate: { + guard let index else { return write(options.first?.tag ?? current) } + write(options[(index + 1) % options.count].tag) + }) + } + + private func toggleRow( + id: String, header: String? = nil, icon: String, label: String, detail: String, + value: Binding + ) -> Row { + Row( + id: id, header: header, icon: icon, label: label, + value: value.wrappedValue ? "On" : "Off", + detail: detail, + adjust: { delta in + // Directional semantics: left = off, right = on; a no-op reads as a boundary. + let target = delta > 0 + guard value.wrappedValue != target else { return false } + value.wrappedValue = target + return true + }, + activate: { value.wrappedValue.toggle() }) + } +} + +#endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsCategory.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsCategory.swift new file mode 100644 index 0000000..5874637 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsCategory.swift @@ -0,0 +1,60 @@ +// SettingsView's navigation and presentation helpers: the iOS settings categories, the iPad +// sheet sizing, and the bounded-slider clamp. + +import SwiftUI + +#if os(iOS) +/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that +/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not +/// private) so the screenshot harness can open SettingsView on a specific category. +enum SettingsCategory: String, CaseIterable, Identifiable { + case general, display, audio, controllers, advanced, about + + var id: Self { self } + + var title: String { + switch self { + case .general: return "General" + case .display: return "Display" + case .audio: return "Audio" + case .controllers: return "Controllers" + case .advanced: return "Advanced" + case .about: return "About" + } + } + + var symbol: String { + switch self { + case .general: return "gearshape" + case .display: return "display" + case .audio: return "speaker.wave.2" + case .controllers: return "gamecontroller" + case .advanced: return "slider.horizontal.3" + case .about: return "info.circle" + } + } +} + +extension View { + /// Present the settings sheet large on iPad so the NavigationSplitView has room for its + /// sidebar + detail — a default form sheet is too narrow and the split view would collapse to + /// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17 + /// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly + /// to the push list). + @ViewBuilder + func settingsSheetSizing() -> some View { + if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) { + presentationSizing(.page) + } else { + self + } + } +} +#endif + +extension Double { + /// The log-scale slider mapping needs a bounded input (Automatic stores 0). + func clamped(_ lo: Double, _ hi: Double) -> Double { + Swift.min(Swift.max(self, lo), hi) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift new file mode 100644 index 0000000..9855e5a --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsOptions.swift @@ -0,0 +1,147 @@ +// The option lists every settings surface renders from — one source of truth shared by the +// touch/desktop SettingsView (Pickers), the tvOS pushed selection rows, and the gamepad settings +// screen (GamepadSettingsView's left/right cycling). Pure data + small pure helpers; anything that +// reads live view state (e.g. the bitrate slider mapping) stays on SettingsView. + +#if os(macOS) +import AppKit +#endif +import PunktfunkKit +import SwiftUI + +enum SettingsOptions { + /// Compositor choices — the `tag` is the wire value (`PunktfunkConnection.Compositor` raw). + static let compositors: [(label: String, tag: Int)] = [ + ("Automatic", 0), + ("KWin (KDE Plasma)", 1), + ("wlroots (Sway / Hyprland)", 2), + ("Mutter (GNOME)", 3), + ("gamescope", 4), + ] + + static let audioChannels: [(label: String, tag: Int)] = [ + ("Stereo", 2), + ("5.1 Surround", 6), + ("7.1 Surround", 8), + ] + + /// Virtual-pad types — the `tag` is the wire value (`PunktfunkConnection.GamepadType` raw). + static let padTypes: [(label: String, tag: Int)] = [ + ("Automatic", 0), + ("Xbox 360", 1), + ("Xbox One", 3), + ("DualSense", 2), + ("DualShock 4", 4), + ] + + static let hudPlacements: [(label: String, tag: String)] = + HUDPlacement.allCases.map { ($0.label, $0.rawValue) } + + /// Video-codec preference (`DefaultsKey.codec`) — a soft preference the host falls back from. + /// No AV1: this client's VideoToolbox path decodes H.264/HEVC only (hosts don't emit AV1 on + /// the native path yet). + static let codecs: [(label: String, tag: String)] = [ + ("Automatic", "auto"), + ("HEVC (H.265)", "hevc"), + ("H.264 (AVC)", "h264"), + ] + + // MARK: - Bitrate + + /// Discrete bitrate steps for the surfaces with no Slider (tvOS pushed pickers, the gamepad + /// settings' left/right cycling), up to the same 3 Gbps ceiling the slider has. + static let bitratePresets: [(label: String, tag: Int)] = [ + ("Automatic", 0), + ("10 Mbps", 10_000), + ("20 Mbps", 20_000), + ("40 Mbps", 40_000), + ("80 Mbps", 80_000), + ("150 Mbps", 150_000), + ("300 Mbps", 300_000), + ("500 Mbps", 500_000), + ("1 Gbps", 1_000_000), + ("1.5 Gbps", 1_500_000), + ("2 Gbps", 2_000_000), + ("3 Gbps", 3_000_000), + ] + + /// The presets plus the currently stored value when it isn't one of them (set via the touch + /// slider or a synced device) — so the current choice stays visible/selectable. + static func bitrateOptions(current: Int) -> [(label: String, tag: Int)] { + var options = bitratePresets + if !options.contains(where: { $0.tag == current }) { + options.insert( + (SpeedTestSheet.mbpsLabel(kbps: current) + " (custom)", current), at: 1) + } + return options + } + + // MARK: - Controllers + + /// "Use controller" choices: Automatic, every forwardable controller, and — so a stale pin + /// stays visible instead of leaving the selection tag-less — any pinned id that is NOT among + /// the selectable (extended) entries, present-but-unusable included. + @MainActor + static func controllerOptions(_ gamepads: GamepadManager) -> [(label: String, tag: String)] { + let selectable = gamepads.controllers.filter(\.isExtended) + var options: [(label: String, tag: String)] = [("Automatic", "")] + options += selectable.map { ($0.name, $0.id) } + if !gamepads.preferredID.isEmpty, + !selectable.contains(where: { $0.id == gamepads.preferredID }) { + options.append(("Unavailable controller", gamepads.preferredID)) + } + return options + } + + #if os(iOS) || os(macOS) + // MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list) + + /// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`. + static let resolutionPresets: [(name: String, w: Int, h: Int)] = [ + ("720p", 1280, 720), + ("1080p", 1920, 1080), + ("1440p", 2560, 1440), + ("4K", 3840, 2160), + ("Ultrawide 1080p", 2560, 1080), + ("Ultrawide 1440p", 3440, 1440), + ("Super ultrawide", 5120, 1440), + ] + + /// This device's native mode first, then the presets, deduped by dimensions (native wins a + /// tie). + @MainActor + static func resolutionModes() -> [(name: String, w: Int, h: Int)] { + var native: [(name: String, w: Int, h: Int)] = [] + #if os(iOS) + let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels + native = [("This device", + Int(max(bounds.width, bounds.height)), + Int(min(bounds.width, bounds.height)))] + #else + if let screen = NSScreen.main { + let scale = screen.backingScaleFactor + native = [("This display", + Int(screen.frame.width * scale), + Int(screen.frame.height * scale))] + } + #endif + var seen = Set() + return (native + resolutionPresets).filter { seen.insert("\($0.w)x\($0.h)").inserted } + } + + /// Refresh rates the device can actually display (no point asking the host to render frames + /// the screen can't show), plus any stored custom value so it stays selectable. + @MainActor + static func refreshRates(including current: Int) -> [Int] { + #if os(iOS) + let maxHz = UIScreen.main.maximumFramesPerSecond + #else + let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60 + #endif + var rates = [60, 120, 240].filter { $0 <= maxHz } + if rates.isEmpty { rates = [maxHz] } + if !rates.contains(current) { rates.append(current) } + return rates.sorted() + } + #endif +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift new file mode 100644 index 0000000..218d77f --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -0,0 +1,385 @@ +// SettingsView's shared sections — each setting's Section is defined exactly once here and +// composed by the per-platform bodies in SettingsView.swift. + +import PunktfunkKit +import SwiftUI + +extension SettingsView { + // MARK: - Sections (shared) + + @ViewBuilder var streamModeSection: some View { + Section { + #if os(iOS) + // Touch-first: a rotating wheel of common resolutions (this device's own mode first) and + // a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host + // renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The + // last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode. + VStack(alignment: .leading, spacing: 4) { + Text("Resolution") + .font(.geist(15, relativeTo: .subheadline)) + .foregroundStyle(.secondary) + Picker("Resolution", selection: resolutionSelection) { + ForEach(resolutionChoices, id: \.tag) { choice in + Text(choice.label).tag(choice.tag) + } + } + .labelsHidden() + .pickerStyle(.wheel) + .frame(maxHeight: 140) + } + if isCustomResolution { + // Arbitrary entry: type the exact width × height (and refresh) the host should drive. + HStack { + TextField("Width", value: $width, format: .number.grouping(.never)) + .keyboardType(.numberPad) + Text("×") + TextField("Height", value: $height, format: .number.grouping(.never)) + .labelsHidden() + .keyboardType(.numberPad) + } + // A row built from an HStack of TextFields otherwise insets its bottom separator to + // the inner content, clipping the hairline under "Width"; pin it to the cell edge. + .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } + LabeledContent("Refresh rate") { + TextField("Hz", value: $hz, format: .number.grouping(.never)) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + } + } else if refreshChoices.count > 1 { + VStack(alignment: .leading, spacing: 6) { + Text("Refresh rate") + .font(.geist(15, relativeTo: .subheadline)) + .foregroundStyle(.secondary) + Picker("Refresh rate", selection: $hz) { + ForEach(refreshChoices, id: \.self) { rate in + Text("\(rate) Hz").tag(rate) + } + } + .labelsHidden() + .pickerStyle(.segmented) + } + } else { + // A device with a single supported rate (e.g. 60 Hz) has nothing to pick. + LabeledContent("Refresh rate") { + Text("\(hz) Hz").foregroundStyle(.secondary) + } + } + Button("Use this display's mode") { fillFromMainScreen() } + #elseif os(macOS) + HStack { + TextField("Resolution", value: $width, format: .number.grouping(.never)) + Text("×") + TextField("", value: $height, format: .number.grouping(.never)) + .labelsHidden() + } + TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never)) + LabeledContent("") { + Button("Use this display's mode") { fillFromMainScreen() } + } + #endif + #if !os(tvOS) + Toggle("Automatic bitrate", isOn: automaticBitrate) + if bitrateKbps != 0 { + HStack(spacing: 12) { + Slider(value: bitrateSlider, in: 0...1) { + Text("Bitrate") + } + Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps)) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(minWidth: 76, alignment: .trailing) + } + if bitrateKbps > 1_000_000 { + Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.orange) + } + } + #endif + } header: { + Text("Stream mode") + } footer: { + Text("The host creates a virtual output at exactly this mode — " + + "native resolution, no scaling. \(Self.bitrateFooter)") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + + #if os(iOS) + // MARK: - Stream mode (iOS wheel) + + /// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't + /// collide with a resolution. + private static let customResolutionTag = "custom" + + /// Wheel rows: the resolution modes (device native first — see `SettingsOptions`), then a + /// "Custom…" row that reveals the numeric fields. + private var resolutionChoices: [(label: String, tag: String)] { + SettingsOptions.resolutionModes() + .map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") } + + [(label: "Custom…", tag: Self.customResolutionTag)] + } + + private var presetResolutionTags: Set { + Set(SettingsOptions.resolutionModes().map { "\($0.w)x\($0.h)" }) + } + + /// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky), + /// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a + /// non-preset mode stays editable across relaunches without a persisted flag. + private var isCustomResolution: Bool { + customMode || !presetResolutionTags.contains("\(width)x\(height)") + } + + /// The wheel works in "WxH" tags so one selection drives both width and height; the custom + /// sentinel toggles `customMode` instead of writing a size. + private var resolutionSelection: Binding { + Binding( + get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" }, + set: { tag in + if tag == Self.customResolutionTag { + customMode = true + return + } + customMode = false + let parts = tag.split(separator: "x").compactMap { Int($0) } + guard parts.count == 2 else { return } + width = parts[0] + height = parts[1] + }) + } + + /// Refresh rates this device can display, plus any stored custom value (see `SettingsOptions`). + private var refreshChoices: [Int] { + SettingsOptions.refreshRates(including: hz) + } + #endif + + @ViewBuilder var audioSection: some View { + Section { + Picker("Audio channels", selection: $audioChannels) { + ForEach(SettingsOptions.audioChannels, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + #if os(macOS) + Picker("Speaker", selection: $speakerUID) { + Text("System default").tag("") + ForEach(outputDevices) { device in + Text(device.name).tag(device.uid) + } + if !speakerUID.isEmpty, + !outputDevices.contains(where: { $0.uid == speakerUID }) { + Text("Unavailable device").tag(speakerUID) + } + } + #endif + Toggle("Send microphone to the host", isOn: $micEnabled) + #if os(macOS) + Picker("Microphone", selection: $micUID) { + Text("System default").tag("") + ForEach(inputDevices) { device in + Text(device.name).tag(device.uid) + } + if !micUID.isEmpty, + !inputDevices.contains(where: { $0.uid == micUID }) { + Text("Unavailable device").tag(micUID) + } + } + .disabled(!micEnabled) + #endif + } header: { + Text("Audio") + } footer: { + Text("Host audio plays through the speaker; the microphone feeds the " + + "host's virtual mic. System default follows macOS device changes. " + + "Applies from the next session.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + + #if os(iOS) + /// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs + /// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock — + /// the mouse path there is always the absolute fallback). + @ViewBuilder var pointerSection: some View { + if UIDevice.current.userInterfaceIdiom == .pad { + Section { + Toggle("Capture pointer for games", isOn: $pointerCapture) + } header: { + Text("Pointer") + } footer: { + Text("With a mouse or trackpad connected, lock the pointer and send relative " + + "movement — the expected behavior for games (mouse-look). Turn this off for " + + "desktop use to keep the pointer free and send its absolute position instead. " + + "The lock needs the stream full-screen and frontmost; it falls back to the " + + "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is " + + "unaffected. Applies from the next session.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + } + #endif + + @ViewBuilder var compositorSection: some View { + Section { + Picker("Compositor", selection: $compositor) { + ForEach(SettingsOptions.compositors, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + } header: { + Text("Host compositor") + } footer: { + Text("Which compositor drives the virtual output on the host. A specific " + + "choice is honored only if that backend is available there — " + + "otherwise the host falls back to auto-detection.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder var windowSection: some View { + #if os(macOS) + Section { + Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming) + } header: { + Text("Window") + } footer: { + Text("Take the window fullscreen when a session starts and restore it on the host " + + "list, so only the stream is fullscreen — not the picker.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + #endif + } + + // Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it + // recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a + // lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like + // the controller test. Empty in release builds (no presenter UI; stage-2 always). + @ViewBuilder var presenterSection: some View { + #if DEBUG + Section { + Picker("Presenter", selection: $presenter) { + Text("Stage 2 (default)").tag("stage2") + Text("Stage 1 (debug)").tag("stage1") + } + } header: { + Text("Video presenter · debug") + } footer: { + Text("Stage 2 (default) decodes explicitly and presents through Metal with a display " + + "link — it adds a capture→present (glass-to-glass) latency line in the HUD and " + + "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the " + + "system display layer; it freezes on a lost HEVC reference frame, so it's a debug " + + "fallback only. Applies from the next session.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + #endif + } + + @ViewBuilder var hdrSection: some View { + Section { + Picker("Video codec", selection: $codec) { + ForEach(SettingsOptions.codecs, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + Toggle("10-bit HDR", isOn: $hdrEnabled) + Toggle("Full chroma (4:4:4)", isOn: $enable444) + } header: { + Text("Video quality") + } footer: { + Text("Codec is a preference — the host falls back if it can't encode the one you pick " + + "(and 10-bit/4:4:4 are HEVC-only). HDR requests a 10-bit BT.2020 PQ (HDR10) stream — " + + "it only engages when the host is sending HDR content AND this display supports HDR. " + + "4:4:4 requests full chroma (sharper text/UI, more bandwidth) — it only engages when " + + "this device can hardware-decode it AND the host opted in. Otherwise the stream stays " + + "8-bit 4:2:0 SDR. Applies from the next session.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder var statisticsSection: some View { + Section { + Toggle("Show statistics overlay", isOn: $hudEnabled) + Picker("Position", selection: $hudPlacement) { + ForEach(HUDPlacement.allCases) { placement in + Text(placement.label).tag(placement.rawValue) + } + } + .disabled(!hudEnabled) + } header: { + Text("Statistics") + } footer: { + Text(Self.statisticsFooter) + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder var experimentalSection: some View { + Section { + Toggle("Show game library", isOn: $libraryEnabled) + } header: { + Text("Experimental") + } footer: { + Text("Adds a “Browse Library…” action to each host that lists its games " + + "(Steam + custom) via the host's management API; tap a title to launch it. " + + "Works once you've paired with the host — the library is authorized by this " + + "device's certificate, with no extra host setup.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder var controllersSection: some View { + Section { + if gamepads.controllers.isEmpty { + Text("No controllers detected") + .foregroundStyle(.secondary) + } else { + ForEach(gamepads.controllers) { controller in + controllerRow(controller) + } + } + Picker("Use controller", selection: $gamepads.preferredID) { + ForEach(controllerOptions, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + Picker("Controller type", selection: $gamepadType) { + ForEach(SettingsOptions.padTypes, id: \.tag) { option in + Text(option.label).tag(option.tag) + } + } + #if !os(tvOS) + Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) + #endif + #if DEBUG && !os(tvOS) + Button("Test Controller…") { showControllerTest = true } + .disabled(gamepads.active == nil) + .sheet(isPresented: $showControllerTest) { ControllerTestView() } + #endif + } header: { + Text("Controllers") + } footer: { + // The gamepad-UI blurb is appended here, not merged into the shared + // `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348) + // for its own footer and has no such toggle to describe. + VStack(alignment: .leading, spacing: 6) { + Text(Self.controllersFooter) + #if !os(tvOS) + Text(Self.gamepadUIFooter) + #endif + } + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + } + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift new file mode 100644 index 0000000..1530de2 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Support.swift @@ -0,0 +1,153 @@ +// SettingsView's footers and stateful helpers, used by both the section builders +// (SettingsView+Sections.swift) and the per-platform bodies (SettingsView.swift). The option +// LISTS live in SettingsOptions — they're shared with the gamepad settings screen too. + +#if os(macOS) +import AppKit +#endif +import PunktfunkKit +import SwiftUI + +extension SettingsView { + // MARK: - Bitrate + + /// Slider domain, log-scale: the useful range spans three orders of magnitude + /// (a few Mbps … 3 Gbps) — linear would cram everything below 100 Mbps into the + /// first pixels. + private static let minSliderKbps = 2_000.0 + private static let maxSliderKbps = 3_000_000.0 + + static let bitrateFooter = + "Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice " + + "to its supported range. Run a speed test from a host card's context menu to " + + "pick an informed value. Applies from the next session." + + static let gigabitWarning = + "Above 1 Gbps — test the network speed first (a host card's context menu → " + + "Test Network Speed…). A bitrate beyond what the link sustains causes loss " + + "and stutter." + + /// `bitrateKbps == 0` is Automatic; switching to manual lands on the host default. + var automaticBitrate: Binding { + Binding( + get: { bitrateKbps == 0 }, + set: { bitrateKbps = $0 ? 0 : 20_000 }) + } + + /// Slider position 0...1 ↔ kbps on the log scale, snapped to two significant figures + /// so the readout shows round numbers instead of 47_322. + var bitrateSlider: Binding { + Binding( + get: { + let v = Double(bitrateKbps).clamped(Self.minSliderKbps, Self.maxSliderKbps) + return log(v / Self.minSliderKbps) + / log(Self.maxSliderKbps / Self.minSliderKbps) + }, + set: { pos in + let raw = Self.minSliderKbps + * pow(Self.maxSliderKbps / Self.minSliderKbps, pos) + let mag = pow(10, floor(log10(raw)) - 1) + bitrateKbps = Int((raw / mag).rounded() * mag) + }) + } + + // MARK: - Statistics + + static var statisticsFooter: String { + let base = "The overlay shows resolution, frame rate, throughput and latency while " + + "streaming, in the chosen corner." + #if os(macOS) || os(iOS) + return base + " Toggle it any time with ⌘⇧S." + #else + return base + #endif + } + + // MARK: - Controllers + + static let controllersFooter = + "One controller is forwarded to the host, as player 1 — Automatic picks the most " + + "recently connected one. The type is the virtual pad the host creates: Automatic " + + "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad " + + "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply " + + "from the next session. Two identical controllers may swap a manual selection " + + "after reconnecting." + + #if !os(tvOS) + static let gamepadUIFooter = + "When a controller is connected, the host list and game library switch to a " + + "controller-friendly layout — larger focus targets, controller-navigable settings, " + + "and a swipeable cover browser for the library. Turn this off to always use the " + + "standard layout. (The system may still move basic focus with a controller " + + "connected even with this off — that's outside the app's control.)" + #endif + + /// "Use controller" choices for this view's manager (see `SettingsOptions.controllerOptions`). + var controllerOptions: [(label: String, tag: String)] { + SettingsOptions.controllerOptions(gamepads) + } + + func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View { + HStack(spacing: 10) { + Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill") + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(controller.name) + HStack(spacing: 8) { + if !controller.isExtended { + Text(controller.productCategory) + } + if controller.hasAdaptiveTriggers { + Image(systemName: "r2.button.roundedtop.horizontal") + } + if controller.hasLight { + Image(systemName: "lightbulb.fill") + } + if controller.hasMotion { + Image(systemName: "gyroscope") + } + if controller.hasHaptics { + Image(systemName: "waveform") + } + if let level = controller.batteryLevel { + Text("\(Int(level * 100))%") + if controller.isCharging { + Image(systemName: "bolt.fill") + } + } + } + .font(.geist(11, relativeTo: .caption2)) + .foregroundStyle(.secondary) + } + Spacer() + if gamepads.active?.id == controller.id { + Text("In use") + .font(.geist(11, .semibold, relativeTo: .caption2)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Capsule().fill(.green.opacity(0.2))) + .foregroundStyle(.green) + } + } + } + + func fillFromMainScreen() { + #if os(macOS) + guard let screen = NSScreen.main else { return } + let scale = screen.backingScaleFactor + width = Int(screen.frame.width * scale) + height = Int(screen.frame.height * scale) + hz = screen.maximumFramesPerSecond + #else + // nativeBounds is portrait-oriented pixels — streams are landscape. + let bounds = UIScreen.main.nativeBounds + width = Int(max(bounds.width, bounds.height)) + height = Int(min(bounds.width, bounds.height)) + hz = UIScreen.main.maximumFramesPerSecond + #if os(iOS) + // The native mode is the "This device" wheel row, so leave Custom mode if it was on. + customMode = false + #endif + #endif + } +} diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift new file mode 100644 index 0000000..9f11794 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -0,0 +1,369 @@ +// App settings. The host creates a native virtual output at exactly the chosen size/refresh — +// there is no scaling anywhere in the pipeline. +// +// Navigation differs per platform, but all three group the same categories (General, Display, +// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses +// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to +// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a +// focus-native pushed-picker layout. The individual sections (`streamModeSection`, +// `audioSection`, …) are shared across all three so a setting is defined exactly once — they +// live in SettingsView+Sections.swift, with their helpers in SettingsView+Support.swift. + +#if os(macOS) +import AppKit +#endif +import PunktfunkKit +import SwiftUI + +@MainActor +struct SettingsView: View { + @Environment(\.dismiss) private var dismiss + @AppStorage(DefaultsKey.streamWidth) var width = 1920 + @AppStorage(DefaultsKey.streamHeight) var height = 1080 + @AppStorage(DefaultsKey.streamHz) var hz = 60 + @AppStorage(DefaultsKey.compositor) var compositor = 0 + @AppStorage(DefaultsKey.gamepadType) var gamepadType = 0 + @AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0 + @AppStorage(DefaultsKey.presenter) var presenter = "stage2" + @AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true + @AppStorage(DefaultsKey.enable444) var enable444 = true + @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false + @AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true + @AppStorage(DefaultsKey.micEnabled) var micEnabled = true + @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 + @AppStorage(DefaultsKey.codec) var codec = "auto" + @AppStorage(DefaultsKey.hudEnabled) var hudEnabled = true + @AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue + @ObservedObject var gamepads = GamepadManager.shared + #if !os(tvOS) + @AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true + #endif + #if DEBUG && !os(tvOS) + @State var showControllerTest = false + #endif + #if os(iOS) + @AppStorage(DefaultsKey.pointerCapture) var pointerCapture = true + // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. + // Width class decides the initial value: nil on iPhone (show the category list first), + // General on iPad (a two-column layout should never open with an empty detail). + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var settingsSelection: SettingsCategory? + // Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen + // — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts + // .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible. + @State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn + // Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a + // preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even + // when this is false (see `isCustomResolution`), so it survives relaunches without persisting. + @State var customMode = false + #endif + #if os(macOS) + @AppStorage(DefaultsKey.speakerUID) var speakerUID = "" + @AppStorage(DefaultsKey.micUID) var micUID = "" + @State var outputDevices: [AudioDevice] = [] + @State var inputDevices: [AudioDevice] = [] + #endif + + #if os(iOS) + /// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on + /// General via `onAppear`). The screenshot harness passes an explicit category so the captured + /// shot opens on a real settings page (a populated detail) rather than the bare category list. + init(initialCategory: SettingsCategory? = nil) { + _settingsSelection = State(initialValue: initialCategory) + } + #endif + + var body: some View { + #if os(tvOS) + // Native tv pattern: no inline text entry (typing numbers with a remote is + // miserable and the inline field chrome fights the focus system). Modes are + // preset pickers that push selection lists like the system Settings app. + tvBody + #elseif os(macOS) + macBody + #else + iosBody + #endif + } + + // MARK: - macOS: tabbed preferences + + #if os(macOS) + private var macBody: some View { + TabView { + Form { + streamModeSection + compositorSection + } + .formStyle(.grouped) + .tabItem { Label("General", systemImage: "gearshape") } + + Form { + presenterSection + hdrSection + windowSection + statisticsSection + } + .formStyle(.grouped) + .tabItem { Label("Display", systemImage: "display") } + + Form { + audioSection + } + .formStyle(.grouped) + .onAppear { + outputDevices = AudioDevices.outputs() + inputDevices = AudioDevices.inputs() + } + .tabItem { Label("Audio", systemImage: "speaker.wave.2") } + + Form { + controllersSection + } + .formStyle(.grouped) + .onAppear { + gamepads.refresh() + gamepads.startDiscovery() + } + .onDisappear { gamepads.stopDiscovery() } + .tabItem { Label("Controllers", systemImage: "gamecontroller") } + + Form { + experimentalSection + } + .formStyle(.grouped) + .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } + + AcknowledgementsView() + .tabItem { Label("About", systemImage: "info.circle") } + } + .frame(width: 480, height: 460) + } + #endif + + // MARK: - iOS / iPadOS: adaptive split view + + #if os(iOS) + private var iosBody: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + List(selection: $settingsSelection) { + ForEach(SettingsCategory.allCases) { category in + // On iPhone the split view collapses to a push list, but a selection List + // draws no disclosure indicator of its own — add one in compact width for the + // expected drill-in affordance. On iPad the selected row highlights instead, so + // the chevron is omitted there. + HStack { + Label(category.title, systemImage: category.symbol) + if horizontalSizeClass == .compact { + Spacer() + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) + // Purely a drill-in affordance — the row's button trait already + // conveys "opens"; keep it out of the VoiceOver announcement. + .accessibilityHidden(true) + } + } + .tag(category) + } + } + .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } detail: { + // NavigationSplitView hosts the detail in its own navigation context (its title bar), + // so no inner NavigationStack — that would double the bar on iPad. On iPhone the split + // view collapses to one stack and pushes this when a row is tapped. `?? .general` only + // backs the brief pre-selection window; the list never auto-pushes on a nil selection. + settingsDetail(settingsSelection ?? .general) + // Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the + // iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the + // sidebar is showing, its Done is the only one — so this stays hidden to avoid two. + .toolbar { + if horizontalSizeClass == .compact || columnVisibility == .detailOnly { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + } + } + } + } + .onAppear { + if horizontalSizeClass == .regular, settingsSelection == nil { + settingsSelection = .general + } + gamepads.refresh() + gamepads.startDiscovery() + } + // A regular→regular launch sets the default above; this catches a compact→regular change + // (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in. + .onChange(of: horizontalSizeClass) { _, newValue in + if newValue == .regular, settingsSelection == nil { + settingsSelection = .general + } + } + .onDisappear { gamepads.stopDiscovery() } + } + + @ViewBuilder + private func settingsDetail(_ category: SettingsCategory) -> some View { + switch category { + case .general: + Form { + streamModeSection + pointerSection + compositorSection + } + .formStyle(.grouped) + .navigationTitle("General") + .navigationBarTitleDisplayMode(.inline) + case .display: + Form { + presenterSection + hdrSection + statisticsSection + } + .formStyle(.grouped) + .navigationTitle("Display") + .navigationBarTitleDisplayMode(.inline) + case .audio: + Form { audioSection } + .formStyle(.grouped) + .navigationTitle("Audio") + .navigationBarTitleDisplayMode(.inline) + case .controllers: + Form { controllersSection } + .formStyle(.grouped) + .navigationTitle("Controllers") + .navigationBarTitleDisplayMode(.inline) + case .advanced: + Form { experimentalSection } + .formStyle(.grouped) + .navigationTitle("Advanced") + .navigationBarTitleDisplayMode(.inline) + case .about: + // Already a full scrollable view that sets its own "Acknowledgements" title; pin the + // display mode inline to match the five sibling detail pages (it would otherwise inherit + // the large title from the "Settings" sidebar root). + AcknowledgementsView() + .navigationBarTitleDisplayMode(.inline) + } + } + #endif + + // MARK: - tvOS + + #if os(tvOS) + private static let presets: [(label: String, tag: String)] = [ + ("720p @ 60", "1280x720x60"), + ("1080p @ 60", "1920x1080x60"), + ("4K @ 60", "3840x2160x60"), + ] + + private var modeTag: Binding { + Binding( + get: { "\(width)x\(height)x\(hz)" }, + set: { tag in + let parts = tag.split(separator: "x").compactMap { Int($0) } + guard parts.count == 3 else { return } + width = parts[0] + height = parts[1] + hz = parts[2] + }) + } + + private var hudEnabledTag: Binding { + Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" }) + } + + private var hdrEnabledTag: Binding { + Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) + } + + private var tvBody: some View { + let currentTag = "\(width)x\(height)x\(hz)" + let bounds = UIScreen.main.nativeBounds + let nativeTag = "\(Int(max(bounds.width, bounds.height)))x" + + "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)" + var options = Self.presets + if !options.contains(where: { $0.tag == nativeTag }) { + options.insert(("This TV (native)", nativeTag), at: 0) + } + if !options.contains(where: { $0.tag == currentTag }) { + options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0) + } + return ScrollView { + VStack(spacing: 16) { + TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) + TVSelectionRow( + title: "Bitrate", + options: SettingsOptions.bitrateOptions(current: bitrateKbps), + selection: $bitrateKbps) + TVSelectionRow( + title: "Audio channels", + options: SettingsOptions.audioChannels, + selection: $audioChannels) + if bitrateKbps > 1_000_000 { + Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.orange) + .multilineTextAlignment(.center) + } + TVSelectionRow( + title: "Compositor", options: SettingsOptions.compositors, + selection: $compositor) + #if DEBUG + TVSelectionRow( + title: "Presenter (debug)", + options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")], + selection: $presenter) + #endif + TVSelectionRow( + title: "10-bit HDR", + options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag) + Text("The host creates a virtual output at exactly this mode — native " + + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " + + "is honored only if available on the host.") + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.top, 8) + TVSelectionRow( + title: "Statistics overlay", + options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag) + TVSelectionRow( + title: "Statistics position", options: SettingsOptions.hudPlacements, + selection: $hudPlacement) + ForEach(gamepads.controllers) { controller in + controllerRow(controller) + .padding(.horizontal, 24) + } + TVSelectionRow( + title: "Use controller", options: controllerOptions, + selection: $gamepads.preferredID) + TVSelectionRow( + title: "Controller type", options: SettingsOptions.padTypes, + selection: $gamepadType) + Text(Self.controllersFooter) + .font(.geist(12, relativeTo: .caption)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.top, 8) + NavigationLink("Acknowledgements") { AcknowledgementsView() } + .padding(.top, 8) + } + .frame(maxWidth: 1000) + .frame(maxWidth: .infinity) + .padding(60) + } + .navigationTitle("Settings") + .onAppear { + gamepads.refresh() + gamepads.startDiscovery() + } + .onDisappear { gamepads.stopDiscovery() } + } + #endif +} diff --git a/clients/apple/Sources/PunktfunkClient/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/SettingsView.swift deleted file mode 100644 index a57fb21..0000000 --- a/clients/apple/Sources/PunktfunkClient/SettingsView.swift +++ /dev/null @@ -1,1020 +0,0 @@ -// App settings. The host creates a native virtual output at exactly the chosen size/refresh — -// there is no scaling anywhere in the pipeline. -// -// Navigation differs per platform, but all three group the same categories (General, Display, -// Audio, Controllers, Advanced, About): macOS uses a tabbed preferences window; iOS/iPadOS uses -// an adaptive NavigationSplitView — a category sidebar + detail pane on iPad, auto-collapsing to -// a hierarchical push list on iPhone (the system Settings idiom on each); tvOS uses a -// focus-native pushed-picker layout. The individual sections (`streamModeSection`, -// `audioSection`, …) are shared across all three so a setting is defined exactly once. - -#if os(macOS) -import AppKit -#endif -import PunktfunkKit -import SwiftUI - -@MainActor -struct SettingsView: View { - @Environment(\.dismiss) private var dismiss - @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.presenter) private var presenter = "stage2" - @AppStorage(DefaultsKey.hdrEnabled) private var hdrEnabled = true - @AppStorage(DefaultsKey.enable444) private var enable444 = true - @AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false - @AppStorage(DefaultsKey.fullscreenWhileStreaming) private var fullscreenWhileStreaming = true - @AppStorage(DefaultsKey.micEnabled) private var micEnabled = true - @AppStorage(DefaultsKey.audioChannels) private var audioChannels = 2 - @AppStorage(DefaultsKey.codec) private var codec = "auto" - @AppStorage(DefaultsKey.hudEnabled) private var hudEnabled = true - @AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue - @ObservedObject private var gamepads = GamepadManager.shared - #if DEBUG && !os(tvOS) - @State private var showControllerTest = false - #endif - #if os(iOS) - @AppStorage(DefaultsKey.pointerCapture) private var pointerCapture = true - @AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true - // The sidebar selection drives the detail pane on iPad and the pushed sub-page on iPhone. - // Width class decides the initial value: nil on iPhone (show the category list first), - // General on iPad (a two-column layout should never open with an empty detail). - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @State private var settingsSelection: SettingsCategory? - // Tracked so the detail can show its own Done whenever the sidebar (and its Done) is off screen - // — not just on iPhone, but on any iPad layout that collapses the sidebar to an overlay. Starts - // .doubleColumn so iPad reliably opens with the sidebar (and its Done) visible. - @State private var columnVisibility: NavigationSplitViewVisibility = .doubleColumn - // Sticky once the wheel lands on "Custom…", so editing a width/height that briefly equals a - // preset doesn't snap the wheel back off Custom. A stored non-preset value reads as custom even - // when this is false (see `isCustomResolution`), so it survives relaunches without persisting. - @State private var customMode = false - #endif - #if os(macOS) - @AppStorage(DefaultsKey.speakerUID) private var speakerUID = "" - @AppStorage(DefaultsKey.micUID) private var micUID = "" - @State private var outputDevices: [AudioDevice] = [] - @State private var inputDevices: [AudioDevice] = [] - #endif - - #if os(iOS) - /// `initialCategory` is nil in the app (the list opens un-selected on iPhone; iPad lands on - /// General via `onAppear`). The screenshot harness passes an explicit category so the captured - /// shot opens on a real settings page (a populated detail) rather than the bare category list. - init(initialCategory: SettingsCategory? = nil) { - _settingsSelection = State(initialValue: initialCategory) - } - #endif - - var body: some View { - #if os(tvOS) - // Native tv pattern: no inline text entry (typing numbers with a remote is - // miserable and the inline field chrome fights the focus system). Modes are - // preset pickers that push selection lists like the system Settings app. - tvBody - #elseif os(macOS) - macBody - #else - iosBody - #endif - } - - // MARK: - macOS: tabbed preferences - - #if os(macOS) - private var macBody: some View { - TabView { - Form { - streamModeSection - compositorSection - } - .formStyle(.grouped) - .tabItem { Label("General", systemImage: "gearshape") } - - Form { - presenterSection - hdrSection - windowSection - statisticsSection - } - .formStyle(.grouped) - .tabItem { Label("Display", systemImage: "display") } - - Form { - audioSection - } - .formStyle(.grouped) - .onAppear { - outputDevices = AudioDevices.outputs() - inputDevices = AudioDevices.inputs() - } - .tabItem { Label("Audio", systemImage: "speaker.wave.2") } - - Form { - controllersSection - } - .formStyle(.grouped) - .onAppear { - gamepads.refresh() - gamepads.startDiscovery() - } - .onDisappear { gamepads.stopDiscovery() } - .tabItem { Label("Controllers", systemImage: "gamecontroller") } - - Form { - experimentalSection - } - .formStyle(.grouped) - .tabItem { Label("Advanced", systemImage: "slider.horizontal.3") } - - AcknowledgementsView() - .tabItem { Label("About", systemImage: "info.circle") } - } - .frame(width: 480, height: 460) - } - #endif - - // MARK: - iOS / iPadOS: adaptive split view - - #if os(iOS) - private var iosBody: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - List(selection: $settingsSelection) { - ForEach(SettingsCategory.allCases) { category in - // On iPhone the split view collapses to a push list, but a selection List - // draws no disclosure indicator of its own — add one in compact width for the - // expected drill-in affordance. On iPad the selected row highlights instead, so - // the chevron is omitted there. - HStack { - Label(category.title, systemImage: category.symbol) - if horizontalSizeClass == .compact { - Spacer() - Image(systemName: "chevron.forward") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) - // Purely a drill-in affordance — the row's button trait already - // conveys "opens"; keep it out of the VoiceOver announcement. - .accessibilityHidden(true) - } - } - .tag(category) - } - } - .navigationTitle("Settings") - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - } - } - } detail: { - // NavigationSplitView hosts the detail in its own navigation context (its title bar), - // so no inner NavigationStack — that would double the bar on iPad. On iPhone the split - // view collapses to one stack and pushes this when a row is tapped. `?? .general` only - // backs the brief pre-selection window; the list never auto-pushes on a nil selection. - settingsDetail(settingsSelection ?? .general) - // Keep a Done on the detail whenever the sidebar (and its Done) isn't on screen: the - // iPhone push, or any iPad layout that collapsed the sidebar to an overlay. When the - // sidebar is showing, its Done is the only one — so this stays hidden to avoid two. - .toolbar { - if horizontalSizeClass == .compact || columnVisibility == .detailOnly { - ToolbarItem(placement: .confirmationAction) { - Button("Done") { dismiss() } - } - } - } - } - .onAppear { - if horizontalSizeClass == .regular, settingsSelection == nil { - settingsSelection = .general - } - gamepads.refresh() - gamepads.startDiscovery() - } - // A regular→regular launch sets the default above; this catches a compact→regular change - // (e.g. an iPad leaving narrow split-screen multitasking) so the detail pane fills in. - .onChange(of: horizontalSizeClass) { _, newValue in - if newValue == .regular, settingsSelection == nil { - settingsSelection = .general - } - } - .onDisappear { gamepads.stopDiscovery() } - } - - @ViewBuilder - private func settingsDetail(_ category: SettingsCategory) -> some View { - switch category { - case .general: - Form { - streamModeSection - pointerSection - compositorSection - } - .formStyle(.grouped) - .navigationTitle("General") - .navigationBarTitleDisplayMode(.inline) - case .display: - Form { - presenterSection - hdrSection - statisticsSection - } - .formStyle(.grouped) - .navigationTitle("Display") - .navigationBarTitleDisplayMode(.inline) - case .audio: - Form { audioSection } - .formStyle(.grouped) - .navigationTitle("Audio") - .navigationBarTitleDisplayMode(.inline) - case .controllers: - Form { controllersSection } - .formStyle(.grouped) - .navigationTitle("Controllers") - .navigationBarTitleDisplayMode(.inline) - case .advanced: - Form { experimentalSection } - .formStyle(.grouped) - .navigationTitle("Advanced") - .navigationBarTitleDisplayMode(.inline) - case .about: - // Already a full scrollable view that sets its own "Acknowledgements" title; pin the - // display mode inline to match the five sibling detail pages (it would otherwise inherit - // the large title from the "Settings" sidebar root). - AcknowledgementsView() - .navigationBarTitleDisplayMode(.inline) - } - } - #endif - - // MARK: - tvOS - - #if os(tvOS) - private static let presets: [(label: String, tag: String)] = [ - ("720p @ 60", "1280x720x60"), - ("1080p @ 60", "1920x1080x60"), - ("4K @ 60", "3840x2160x60"), - ] - - private var modeTag: Binding { - Binding( - get: { "\(width)x\(height)x\(hz)" }, - set: { tag in - let parts = tag.split(separator: "x").compactMap { Int($0) } - guard parts.count == 3 else { return } - width = parts[0] - height = parts[1] - hz = parts[2] - }) - } - - private var hudEnabledTag: Binding { - Binding(get: { hudEnabled ? "on" : "off" }, set: { hudEnabled = $0 == "on" }) - } - - private var hdrEnabledTag: Binding { - Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" }) - } - - private var tvBody: some View { - let currentTag = "\(width)x\(height)x\(hz)" - let bounds = UIScreen.main.nativeBounds - let nativeTag = "\(Int(max(bounds.width, bounds.height)))x" - + "\(Int(min(bounds.width, bounds.height)))x\(UIScreen.main.maximumFramesPerSecond)" - var options = Self.presets - if !options.contains(where: { $0.tag == nativeTag }) { - options.insert(("This TV (native)", nativeTag), at: 0) - } - if !options.contains(where: { $0.tag == currentTag }) { - options.insert(("Custom (\(width)×\(height) @ \(hz))", currentTag), at: 0) - } - let compositors: [(label: String, tag: Int)] = [ - ("Automatic", 0), - ("KWin (KDE Plasma)", 1), - ("wlroots (Sway / Hyprland)", 2), - ("Mutter (GNOME)", 3), - ("gamescope", 4), - ] - return ScrollView { - VStack(spacing: 16) { - TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) - TVSelectionRow( - title: "Bitrate", options: bitrateOptions, selection: $bitrateKbps) - TVSelectionRow( - title: "Audio channels", - options: [("Stereo", 2), ("5.1 Surround", 6), ("7.1 Surround", 8)], - selection: $audioChannels) - if bitrateKbps > 1_000_000 { - Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.orange) - .multilineTextAlignment(.center) - } - TVSelectionRow( - title: "Compositor", options: compositors, selection: $compositor) - #if DEBUG - TVSelectionRow( - title: "Presenter (debug)", - options: [("Stage 2 (default)", "stage2"), ("Stage 1 (debug)", "stage1")], - selection: $presenter) - #endif - TVSelectionRow( - title: "10-bit HDR", - options: [("On", "on"), ("Off", "off")], selection: hdrEnabledTag) - Text("The host creates a virtual output at exactly this mode — native " - + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor " - + "is honored only if available on the host.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.top, 8) - TVSelectionRow( - title: "Statistics overlay", - options: [("On", "on"), ("Off", "off")], selection: hudEnabledTag) - TVSelectionRow( - title: "Statistics position", options: Self.placementOptions, - selection: $hudPlacement) - ForEach(gamepads.controllers) { controller in - controllerRow(controller) - .padding(.horizontal, 24) - } - TVSelectionRow( - title: "Use controller", options: controllerOptions, - selection: $gamepads.preferredID) - TVSelectionRow( - title: "Controller type", options: Self.padTypes, selection: $gamepadType) - Text(Self.controllersFooter) - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.top, 8) - NavigationLink("Acknowledgements") { AcknowledgementsView() } - .padding(.top, 8) - } - .frame(maxWidth: 1000) - .frame(maxWidth: .infinity) - .padding(60) - } - .navigationTitle("Settings") - .onAppear { - gamepads.refresh() - gamepads.startDiscovery() - } - .onDisappear { gamepads.stopDiscovery() } - } - #endif - - // MARK: - Sections (shared) - - @ViewBuilder private var streamModeSection: some View { - Section { - #if os(iOS) - // Touch-first: a rotating wheel of common resolutions (this device's own mode first) and - // a segmented refresh-rate control — the same family as the Clock/Timer pickers. The host - // renders a virtual output at exactly the chosen mode, so these are real pixel sizes. The - // last wheel row, "Custom…", reveals width/height/refresh fields for an arbitrary mode. - VStack(alignment: .leading, spacing: 4) { - Text("Resolution") - .font(.geist(15, relativeTo: .subheadline)) - .foregroundStyle(.secondary) - Picker("Resolution", selection: resolutionSelection) { - ForEach(resolutionChoices, id: \.tag) { choice in - Text(choice.label).tag(choice.tag) - } - } - .labelsHidden() - .pickerStyle(.wheel) - .frame(maxHeight: 140) - } - if isCustomResolution { - // Arbitrary entry: type the exact width × height (and refresh) the host should drive. - HStack { - TextField("Width", value: $width, format: .number.grouping(.never)) - .keyboardType(.numberPad) - Text("×") - TextField("Height", value: $height, format: .number.grouping(.never)) - .labelsHidden() - .keyboardType(.numberPad) - } - // A row built from an HStack of TextFields otherwise insets its bottom separator to - // the inner content, clipping the hairline under "Width"; pin it to the cell edge. - .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } - LabeledContent("Refresh rate") { - TextField("Hz", value: $hz, format: .number.grouping(.never)) - .keyboardType(.numberPad) - .multilineTextAlignment(.trailing) - } - } else if refreshChoices.count > 1 { - VStack(alignment: .leading, spacing: 6) { - Text("Refresh rate") - .font(.geist(15, relativeTo: .subheadline)) - .foregroundStyle(.secondary) - Picker("Refresh rate", selection: $hz) { - ForEach(refreshChoices, id: \.self) { rate in - Text("\(rate) Hz").tag(rate) - } - } - .labelsHidden() - .pickerStyle(.segmented) - } - } else { - // A device with a single supported rate (e.g. 60 Hz) has nothing to pick. - LabeledContent("Refresh rate") { - Text("\(hz) Hz").foregroundStyle(.secondary) - } - } - Button("Use this display's mode") { fillFromMainScreen() } - #elseif os(macOS) - HStack { - TextField("Resolution", value: $width, format: .number.grouping(.never)) - Text("×") - TextField("", value: $height, format: .number.grouping(.never)) - .labelsHidden() - } - TextField("Refresh rate (Hz)", value: $hz, format: .number.grouping(.never)) - LabeledContent("") { - Button("Use this display's mode") { fillFromMainScreen() } - } - #endif - #if !os(tvOS) - Toggle("Automatic bitrate", isOn: automaticBitrate) - if bitrateKbps != 0 { - HStack(spacing: 12) { - Slider(value: bitrateSlider, in: 0...1) { - Text("Bitrate") - } - Text(SpeedTestSheet.mbpsLabel(kbps: bitrateKbps)) - .monospacedDigit() - .foregroundStyle(.secondary) - .frame(minWidth: 76, alignment: .trailing) - } - if bitrateKbps > 1_000_000 { - Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.orange) - } - } - #endif - } header: { - Text("Stream mode") - } footer: { - Text("The host creates a virtual output at exactly this mode — " - + "native resolution, no scaling. \(Self.bitrateFooter)") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - - #if os(iOS) - // MARK: - Stream mode (iOS wheel) - - /// Sentinel wheel tag for the "Custom…" row. Real tags are "WxH" (digits + "x"), so this can't - /// collide with a resolution. - private static let customResolutionTag = "custom" - - /// 16:9 then ultrawide presets; the device's native mode is prepended at runtime. - private static let resolutionPresets: [(name: String, w: Int, h: Int)] = [ - ("720p", 1280, 720), - ("1080p", 1920, 1080), - ("1440p", 2560, 1440), - ("4K", 3840, 2160), - ("Ultrawide 1080p", 2560, 1080), - ("Ultrawide 1440p", 3440, 1440), - ("Super ultrawide", 5120, 1440), - ] - - /// The non-custom wheel rows: this device's native mode first, then the presets, deduped by - /// dimensions (native wins a tie). - private var resolutionModes: [(name: String, w: Int, h: Int)] { - let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels - let native = (w: Int(max(bounds.width, bounds.height)), h: Int(min(bounds.width, bounds.height))) - let all = [(name: "This device", w: native.w, h: native.h)] + Self.resolutionPresets - var seen = Set() - return all.filter { seen.insert("\($0.w)x\($0.h)").inserted } - } - - /// Wheel rows: the resolution modes, then a "Custom…" row that reveals the numeric fields. - private var resolutionChoices: [(label: String, tag: String)] { - resolutionModes.map { (label: "\($0.name) · \($0.w) × \($0.h)", tag: "\($0.w)x\($0.h)") } - + [(label: "Custom…", tag: Self.customResolutionTag)] - } - - private var presetResolutionTags: Set { - Set(resolutionModes.map { "\($0.w)x\($0.h)" }) - } - - /// True when the editable custom fields should show: the wheel is parked on "Custom…" (sticky), - /// or the stored size simply isn't one of the presets (e.g. a value synced from a Mac) — so a - /// non-preset mode stays editable across relaunches without a persisted flag. - private var isCustomResolution: Bool { - customMode || !presetResolutionTags.contains("\(width)x\(height)") - } - - /// The wheel works in "WxH" tags so one selection drives both width and height; the custom - /// sentinel toggles `customMode` instead of writing a size. - private var resolutionSelection: Binding { - Binding( - get: { isCustomResolution ? Self.customResolutionTag : "\(width)x\(height)" }, - set: { tag in - if tag == Self.customResolutionTag { - customMode = true - return - } - customMode = false - let parts = tag.split(separator: "x").compactMap { Int($0) } - guard parts.count == 2 else { return } - width = parts[0] - height = parts[1] - }) - } - - /// Refresh rates the device can actually display (no point asking the host to render frames the - /// screen can't show), plus any stored custom value so it stays selectable. - private var refreshChoices: [Int] { - let maxHz = UIScreen.main.maximumFramesPerSecond - var rates = [60, 120, 240].filter { $0 <= maxHz } - if rates.isEmpty { rates = [maxHz] } - if !rates.contains(hz) { rates.append(hz) } - return rates.sorted() - } - #endif - - @ViewBuilder private var audioSection: some View { - Section { - Picker("Audio channels", selection: $audioChannels) { - Text("Stereo").tag(2) - Text("5.1 Surround").tag(6) - Text("7.1 Surround").tag(8) - } - #if os(macOS) - Picker("Speaker", selection: $speakerUID) { - Text("System default").tag("") - ForEach(outputDevices) { device in - Text(device.name).tag(device.uid) - } - if !speakerUID.isEmpty, - !outputDevices.contains(where: { $0.uid == speakerUID }) { - Text("Unavailable device").tag(speakerUID) - } - } - #endif - Toggle("Send microphone to the host", isOn: $micEnabled) - #if os(macOS) - Picker("Microphone", selection: $micUID) { - Text("System default").tag("") - ForEach(inputDevices) { device in - Text(device.name).tag(device.uid) - } - if !micUID.isEmpty, - !inputDevices.contains(where: { $0.uid == micUID }) { - Text("Unavailable device").tag(micUID) - } - } - .disabled(!micEnabled) - #endif - } header: { - Text("Audio") - } footer: { - Text("Host audio plays through the speaker; the microphone feeds the " - + "host's virtual mic. System default follows macOS device changes. " - + "Applies from the next session.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - - #if os(iOS) - /// iPad-only pointer-capture toggle: lock the mouse/trackpad for relative movement (games) vs - /// forward an absolute cursor position (desktop). Empty on iPhone (no hardware-pointer lock — - /// the mouse path there is always the absolute fallback). - @ViewBuilder private var pointerSection: some View { - if UIDevice.current.userInterfaceIdiom == .pad { - Section { - Toggle("Capture pointer for games", isOn: $pointerCapture) - } header: { - Text("Pointer") - } footer: { - Text("With a mouse or trackpad connected, lock the pointer and send relative " - + "movement — the expected behavior for games (mouse-look). Turn this off for " - + "desktop use to keep the pointer free and send its absolute position instead. " - + "The lock needs the stream full-screen and frontmost; it falls back to the " - + "absolute pointer automatically (Stage Manager, Slide Over). Finger touch is " - + "unaffected. Applies from the next session.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - } - #endif - - @ViewBuilder private var compositorSection: some View { - Section { - Picker("Compositor", selection: $compositor) { - Text("Automatic").tag(0) - Text("KWin (KDE Plasma)").tag(1) - Text("wlroots (Sway / Hyprland)").tag(2) - Text("Mutter (GNOME)").tag(3) - Text("gamescope").tag(4) - } - } header: { - Text("Host compositor") - } footer: { - Text("Which compositor drives the virtual output on the host. A specific " - + "choice is honored only if that backend is available there — " - + "otherwise the host falls back to auto-detection.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder private var windowSection: some View { - #if os(macOS) - Section { - Toggle("Fullscreen while streaming", isOn: $fullscreenWhileStreaming) - } header: { - Text("Window") - } footer: { - Text("Take the window fullscreen when a session starts and restore it on the host " - + "list, so only the stream is fullscreen — not the picker.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - #endif - } - - // Stage-2 (Metal/VTDecompressionSession) is the default and only user-visible presenter — it - // recovers from a wedged decoder, where stage-1's AVSampleBufferDisplayLayer freezes hard on a - // lost HEVC reference. Stage-1 is kept reachable as a DEBUG-only override for diagnostics, like - // the controller test. Empty in release builds (no presenter UI; stage-2 always). - @ViewBuilder private var presenterSection: some View { - #if DEBUG - Section { - Picker("Presenter", selection: $presenter) { - Text("Stage 2 (default)").tag("stage2") - Text("Stage 1 (debug)").tag("stage1") - } - } header: { - Text("Video presenter · debug") - } footer: { - Text("Stage 2 (default) decodes explicitly and presents through Metal with a display " - + "link — it adds a capture→present (glass-to-glass) latency line in the HUD and " - + "self-recovers from decode stalls. Stage 1 feeds compressed video straight to the " - + "system display layer; it freezes on a lost HEVC reference frame, so it's a debug " - + "fallback only. Applies from the next session.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - #endif - } - - @ViewBuilder private var hdrSection: some View { - Section { - Picker("Video codec", selection: $codec) { - Text("Automatic").tag("auto") - Text("HEVC (H.265)").tag("hevc") - Text("H.264 (AVC)").tag("h264") - } - Toggle("10-bit HDR", isOn: $hdrEnabled) - Toggle("Full chroma (4:4:4)", isOn: $enable444) - } header: { - Text("Video quality") - } footer: { - Text("Codec is a preference — the host falls back if it can't encode the one you pick " - + "(and 10-bit/4:4:4 are HEVC-only). HDR requests a 10-bit BT.2020 PQ (HDR10) stream — " - + "it only engages when the host is sending HDR content AND this display supports HDR. " - + "4:4:4 requests full chroma (sharper text/UI, more bandwidth) — it only engages when " - + "this device can hardware-decode it AND the host opted in. Otherwise the stream stays " - + "8-bit 4:2:0 SDR. Applies from the next session.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder private var statisticsSection: some View { - Section { - Toggle("Show statistics overlay", isOn: $hudEnabled) - Picker("Position", selection: $hudPlacement) { - ForEach(HUDPlacement.allCases) { placement in - Text(placement.label).tag(placement.rawValue) - } - } - .disabled(!hudEnabled) - } header: { - Text("Statistics") - } footer: { - Text(Self.statisticsFooter) - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder private var experimentalSection: some View { - Section { - Toggle("Show game library", isOn: $libraryEnabled) - } header: { - Text("Experimental") - } footer: { - Text("Adds a “Browse Library…” action to each host that lists its games " - + "(Steam + custom) via the host's management API; tap a title to launch it. " - + "Works once you've paired with the host — the library is authorized by this " - + "device's certificate, with no extra host setup.") - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - - @ViewBuilder private var controllersSection: some View { - Section { - if gamepads.controllers.isEmpty { - Text("No controllers detected") - .foregroundStyle(.secondary) - } else { - ForEach(gamepads.controllers) { controller in - controllerRow(controller) - } - } - Picker("Use controller", selection: $gamepads.preferredID) { - ForEach(controllerOptions, id: \.tag) { option in - Text(option.label).tag(option.tag) - } - } - Picker("Controller type", selection: $gamepadType) { - ForEach(Self.padTypes, id: \.tag) { option in - Text(option.label).tag(option.tag) - } - } - #if os(iOS) - Toggle("Gamepad-optimized browsing", isOn: $gamepadUIEnabled) - #endif - #if DEBUG && !os(tvOS) - Button("Test Controller…") { showControllerTest = true } - .disabled(gamepads.active == nil) - .sheet(isPresented: $showControllerTest) { ControllerTestView() } - #endif - } header: { - Text("Controllers") - } footer: { - // The iOS-only gamepad-UI blurb is appended here, not merged into the shared - // `controllersFooter` constant — tvOS's `tvBody` reuses that exact string (line ~348) - // for its own footer and has no such toggle to describe. - VStack(alignment: .leading, spacing: 6) { - Text(Self.controllersFooter) - #if os(iOS) - Text(Self.gamepadUIFooter) - #endif - } - .font(.geist(12, relativeTo: .caption)) - .foregroundStyle(.secondary) - } - } - - // MARK: - Bitrate - - /// Slider domain, log-scale: the useful range spans three orders of magnitude - /// (a few Mbps … 3 Gbps) — linear would cram everything below 100 Mbps into the - /// first pixels. - private static let minSliderKbps = 2_000.0 - private static let maxSliderKbps = 3_000_000.0 - - private static let bitrateFooter = - "Automatic uses the host's default bitrate (20 Mbps); the host clamps any choice " - + "to its supported range. Run a speed test from a host card's context menu to " - + "pick an informed value. Applies from the next session." - - private static let gigabitWarning = - "Above 1 Gbps — test the network speed first (a host card's context menu → " - + "Test Network Speed…). A bitrate beyond what the link sustains causes loss " - + "and stutter." - - /// `bitrateKbps == 0` is Automatic; switching to manual lands on the host default. - private var automaticBitrate: Binding { - Binding( - get: { bitrateKbps == 0 }, - set: { bitrateKbps = $0 ? 0 : 20_000 }) - } - - /// Slider position 0...1 ↔ kbps on the log scale, snapped to two significant figures - /// so the readout shows round numbers instead of 47_322. - private var bitrateSlider: Binding { - Binding( - get: { - let v = Double(bitrateKbps).clamped(Self.minSliderKbps, Self.maxSliderKbps) - return log(v / Self.minSliderKbps) - / log(Self.maxSliderKbps / Self.minSliderKbps) - }, - set: { pos in - let raw = Self.minSliderKbps - * pow(Self.maxSliderKbps / Self.minSliderKbps, pos) - let mag = pow(10, floor(log10(raw)) - 1) - bitrateKbps = Int((raw / mag).rounded() * mag) - }) - } - - #if os(tvOS) - /// tvOS has no Slider — the focus-native control is the pushed picker (the same - /// pattern as the stream mode), so the rates are presets here, up to the same 3 Gbps - /// ceiling, plus a custom entry so a non-preset stored value stays visible. - private static let bitratePresets: [(label: String, tag: Int)] = [ - ("Automatic", 0), - ("10 Mbps", 10_000), - ("20 Mbps", 20_000), - ("40 Mbps", 40_000), - ("80 Mbps", 80_000), - ("150 Mbps", 150_000), - ("300 Mbps", 300_000), - ("500 Mbps", 500_000), - ("1 Gbps", 1_000_000), - ("1.5 Gbps", 1_500_000), - ("2 Gbps", 2_000_000), - ("3 Gbps", 3_000_000), - ] - - private var bitrateOptions: [(label: String, tag: Int)] { - var options = Self.bitratePresets - if !options.contains(where: { $0.tag == bitrateKbps }) { - options.insert( - (SpeedTestSheet.mbpsLabel(kbps: bitrateKbps) + " (custom)", bitrateKbps), at: 1) - } - return options - } - - private static let placementOptions: [(label: String, tag: String)] = - HUDPlacement.allCases.map { ($0.label, $0.rawValue) } - #endif - - // MARK: - Statistics - - private static var statisticsFooter: String { - let base = "The overlay shows resolution, frame rate, throughput and latency while " - + "streaming, in the chosen corner." - #if os(macOS) || os(iOS) - return base + " Toggle it any time with ⌘⇧S." - #else - return base - #endif - } - - // MARK: - Controllers - - private static let padTypes: [(label: String, tag: Int)] = [ - ("Automatic", 0), - ("Xbox 360", 1), - ("Xbox One", 3), - ("DualSense", 2), - ("DualShock 4", 4), - ] - - private static let controllersFooter = - "One controller is forwarded to the host, as player 1 — Automatic picks the most " - + "recently connected one. The type is the virtual pad the host creates: Automatic " - + "matches the controller (a DualSense gets adaptive triggers, lightbar, touchpad " - + "and motion; a DualShock 4 the same minus adaptive triggers), and changes apply " - + "from the next session. Two identical controllers may swap a manual selection " - + "after reconnecting." - - #if os(iOS) - private static let gamepadUIFooter = - "When a controller is connected, the host list and game library switch to a " - + "controller-friendly layout — larger focus targets and a swipeable cover browser " - + "for the library. Turn this off to always use the touch layout. (The system may " - + "still move basic focus with a controller connected even with this off — that's " - + "outside the app's control.)" - #endif - - /// "Use controller" choices: Automatic, every forwardable controller, and — so a stale - /// pin stays visible instead of leaving the Picker selection tag-less — any pinned id - /// that is NOT among the selectable (extended) entries, present-but-unusable included. - private var controllerOptions: [(label: String, tag: String)] { - let selectable = gamepads.controllers.filter(\.isExtended) - var options: [(label: String, tag: String)] = [("Automatic", "")] - options += selectable.map { ($0.name, $0.id) } - if !gamepads.preferredID.isEmpty, - !selectable.contains(where: { $0.id == gamepads.preferredID }) { - options.append(("Unavailable controller", gamepads.preferredID)) - } - return options - } - - private func controllerRow(_ controller: GamepadManager.DiscoveredController) -> some View { - HStack(spacing: 10) { - Image(systemName: controller.hasTouchpadAndMotion ? "playstation.logo" : "gamecontroller.fill") - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 2) { - Text(controller.name) - HStack(spacing: 8) { - if !controller.isExtended { - Text(controller.productCategory) - } - if controller.hasAdaptiveTriggers { - Image(systemName: "r2.button.roundedtop.horizontal") - } - if controller.hasLight { - Image(systemName: "lightbulb.fill") - } - if controller.hasMotion { - Image(systemName: "gyroscope") - } - if controller.hasHaptics { - Image(systemName: "waveform") - } - if let level = controller.batteryLevel { - Text("\(Int(level * 100))%") - if controller.isCharging { - Image(systemName: "bolt.fill") - } - } - } - .font(.geist(11, relativeTo: .caption2)) - .foregroundStyle(.secondary) - } - Spacer() - if gamepads.active?.id == controller.id { - Text("In use") - .font(.geist(11, .semibold, relativeTo: .caption2)) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(Capsule().fill(.green.opacity(0.2))) - .foregroundStyle(.green) - } - } - } - - private func fillFromMainScreen() { - #if os(macOS) - guard let screen = NSScreen.main else { return } - let scale = screen.backingScaleFactor - width = Int(screen.frame.width * scale) - height = Int(screen.frame.height * scale) - hz = screen.maximumFramesPerSecond - #else - // nativeBounds is portrait-oriented pixels — streams are landscape. - let bounds = UIScreen.main.nativeBounds - width = Int(max(bounds.width, bounds.height)) - height = Int(min(bounds.width, bounds.height)) - hz = UIScreen.main.maximumFramesPerSecond - #if os(iOS) - // The native mode is the "This device" wheel row, so leave Custom mode if it was on. - customMode = false - #endif - #endif - } -} - -extension Double { - /// The log-scale slider mapping needs a bounded input (Automatic stores 0). - fileprivate func clamped(_ lo: Double, _ hi: Double) -> Double { - Swift.min(Swift.max(self, lo), hi) - } -} - -#if os(iOS) -/// The settings groups, mirroring the macOS preference tabs. On iPad each is a sidebar row that -/// drives the detail pane; on iPhone the same list collapses to pushed sub-pages. Internal (not -/// private) so the screenshot harness can open SettingsView on a specific category. -enum SettingsCategory: String, CaseIterable, Identifiable { - case general, display, audio, controllers, advanced, about - - var id: Self { self } - - var title: String { - switch self { - case .general: return "General" - case .display: return "Display" - case .audio: return "Audio" - case .controllers: return "Controllers" - case .advanced: return "Advanced" - case .about: return "About" - } - } - - var symbol: String { - switch self { - case .general: return "gearshape" - case .display: return "display" - case .audio: return "speaker.wave.2" - case .controllers: return "gamecontroller" - case .advanced: return "slider.horizontal.3" - case .about: return "info.circle" - } - } -} - -extension View { - /// Present the settings sheet large on iPad so the NavigationSplitView has room for its - /// sidebar + detail — a default form sheet is too narrow and the split view would collapse to - /// the iPhone push list. No-op on iPhone (the standard sheet is already right) and on iOS 17 - /// (no `presentationSizing` — it falls back to the default sheet, which still degrades cleanly - /// to the push list). - @ViewBuilder - func settingsSheetSizing() -> some View { - if UIDevice.current.userInterfaceIdiom == .pad, #available(iOS 18, *) { - presentationSizing(.page) - } else { - self - } - } -} -#endif diff --git a/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/ClientIdentityStore.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift rename to clients/apple/Sources/PunktfunkClient/Stores/ClientIdentityStore.swift diff --git a/clients/apple/Sources/PunktfunkClient/HostStore.swift b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift similarity index 78% rename from clients/apple/Sources/PunktfunkClient/HostStore.swift rename to clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift index 4698d56..9701398 100644 --- a/clients/apple/Sources/PunktfunkClient/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/Stores/HostStore.swift @@ -46,9 +46,24 @@ extension StoredHost { } } -private extension Data { - /// Lowercase hex, no separators — to compare a pinned fingerprint against the mDNS `fp`. - var hexLower: String { map { String(format: "%02x", $0) }.joined() } +/// The two joins of live mDNS discovery against the saved-host store, shared by the touch grid +/// (HomeView) and the gamepad launcher (GamepadHomeView) so both screens classify hosts the same +/// way. LAN-scoped like the underlying match: a host that isn't advertising here is "not seen", +/// not proven off. +extension HostDiscovery { + /// A saved host is "online" iff a live advert currently matches it (see `StoredHost.matches`). + /// Recomputed on every discovery change (the @Published set), so it tracks hosts + /// appearing/leaving the network live. + func advertises(_ host: StoredHost) -> Bool { + hosts.contains { host.matches($0) } + } + + /// Discovered hosts not already saved — the saved list shows the rest, so this only surfaces + /// genuinely-new hosts on the network. Same match as `advertises`, so a saved host whose IP + /// changed (still fingerprint-matched) doesn't also appear as a stranger. + func unsaved(among saved: [StoredHost]) -> [DiscoveredHost] { + hosts.filter { d in !saved.contains { $0.matches(d) } } + } } @MainActor diff --git a/clients/apple/Sources/PunktfunkClient/BrandTheme.swift b/clients/apple/Sources/PunktfunkClient/Support/BrandTheme.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/BrandTheme.swift rename to clients/apple/Sources/PunktfunkClient/Support/BrandTheme.swift diff --git a/clients/apple/Sources/PunktfunkClient/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/GlassStyle.swift rename to clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift diff --git a/clients/apple/Sources/PunktfunkClient/Support/HexData.swift b/clients/apple/Sources/PunktfunkClient/Support/HexData.swift new file mode 100644 index 0000000..752a29e --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/Support/HexData.swift @@ -0,0 +1,27 @@ +// Hex encode/decode for the trust surface — pinned certificate fingerprints and the mDNS `fp` +// TXT value travel as lowercase hex. + +import Foundation + +extension Data { + /// Lowercase hex, no separators — to compare a pinned fingerprint against the mDNS `fp`. + var hexLower: String { map { String(format: "%02x", $0) }.joined() } + + /// Parse an even-length hex string into bytes; nil on any non-hex character or odd length. + /// Used to turn an mDNS-advertised cert fingerprint into a connect pin. + init?(hexString: String) { + let chars = Array(hexString) + guard chars.count.isMultiple(of: 2) else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(chars.count / 2) + var i = 0 + while i < chars.count { + guard let hi = chars[i].hexDigitValue, let lo = chars[i + 1].hexDigitValue else { + return nil + } + bytes.append(UInt8(hi << 4 | lo)) + i += 2 + } + self = Data(bytes) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/TVTextEntry.swift b/clients/apple/Sources/PunktfunkClient/Support/TVTextEntry.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/TVTextEntry.swift rename to clients/apple/Sources/PunktfunkClient/Support/TVTextEntry.swift diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift similarity index 100% rename from clients/apple/Sources/PunktfunkClient/PairSheet.swift rename to clients/apple/Sources/PunktfunkClient/Trust/PairSheet.swift diff --git a/clients/apple/Sources/PunktfunkClient/TrustCardView.swift b/clients/apple/Sources/PunktfunkClient/Trust/TrustCardView.swift similarity index 98% rename from clients/apple/Sources/PunktfunkClient/TrustCardView.swift rename to clients/apple/Sources/PunktfunkClient/Trust/TrustCardView.swift index 82ebf12..2d60e3d 100644 --- a/clients/apple/Sources/PunktfunkClient/TrustCardView.swift +++ b/clients/apple/Sources/PunktfunkClient/Trust/TrustCardView.swift @@ -70,7 +70,7 @@ struct TrustCardView: View { /// 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 hex = fingerprint.hexLower 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)) diff --git a/clients/apple/Sources/PunktfunkKit/AnnexB.swift b/clients/apple/Sources/PunktfunkKit/AnnexB.swift deleted file mode 100644 index 9dfdd09..0000000 --- a/clients/apple/Sources/PunktfunkKit/AnnexB.swift +++ /dev/null @@ -1,202 +0,0 @@ -// Annex-B HEVC → CoreMedia plumbing. -// -// The punktfunk host emits Annex-B access units with in-band VPS/SPS/PPS on every IDR -// (deliberately — the client needs no out-of-band extradata). VideoToolbox wants the AVCC -// flavor instead: a CMVideoFormatDescription built from the parameter sets, and sample -// buffers whose NALs are 4-byte-length-prefixed. This file converts between the two. -// -// SCAFFOLD: written on the Linux host, not yet compiled against Xcode. - -import CoreMedia -import Foundation - -/// The video codec of the host's elementary stream — negotiated in the Welcome and read via -/// `punktfunk_connection_codec`. Both are Annex-B with in-band parameter sets on every IDR; they -/// differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1 is not an -/// Annex-B/NAL codec and isn't handled here (hosts don't emit it on the native path yet). -public enum VideoCodec: Equatable { - case h264 - case hevc - - /// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC). - public init(wire: UInt8) { - self = wire == 0x01 ? .h264 : .hevc // 0x01 = PUNKTFUNK_CODEC_H264 - } -} - -public enum AnnexB { - /// Split an Annex-B stream into NAL units (start codes 00 00 01 / 00 00 00 01 stripped). - /// All zeros immediately preceding a start code are dropped: they're either the - /// 4-byte-code prefix or `trailing_zero_8bits` padding, never NAL payload (emulation - /// prevention keeps 00 00 0x out of conforming NAL bytes) — same policy as ffmpeg. - public static func nalUnits(in data: Data) -> [Data] { - var nals: [Data] = [] - let bytes = [UInt8](data) - var i = 0 - var start = -1 - while i + 2 < bytes.count { - if bytes[i] == 0, bytes[i + 1] == 0, bytes[i + 2] == 1 { - var codeStart = i - while codeStart > 0, bytes[codeStart - 1] == 0 { - codeStart -= 1 - } - if start >= 0, start < codeStart { - nals.append(Data(bytes[start..= 0, start < bytes.count { - nals.append(Data(bytes[start...])) - } - return nals - } - - /// HEVC NAL unit type (bits 1..6 of the first byte). - public static func hevcNalType(_ nal: Data) -> UInt8 { - guard let first = nal.first else { return 0xFF } - return (first >> 1) & 0x3F - } - - /// H.264 NAL unit type (bits 0..4 of the first byte). - public static func h264NalType(_ nal: Data) -> UInt8 { - guard let first = nal.first else { return 0xFF } - return first & 0x1F - } - - /// True if this NAL is a parameter set for `codec` (dropped from AVCC; kept for the format desc). - /// HEVC: VPS 32 / SPS 33 / PPS 34. H.264: SPS 7 / PPS 8 (no VPS). - private static func isParameterSet(_ nal: Data, _ codec: VideoCodec) -> Bool { - switch codec { - case .hevc: let t = hevcNalType(nal); return t == 32 || t == 33 || t == 34 - case .h264: let t = h264NalType(nal); return t == 7 || t == 8 - } - } - - /// Build a format description from an IDR AU's in-band parameter sets (HEVC: VPS/SPS/PPS; - /// H.264: SPS/PPS). Returns nil when the AU carries no parameter sets (non-IDR). - public static func formatDescription(fromIDR au: Data, codec: VideoCodec) - -> CMVideoFormatDescription? - { - // Collect the parameter-set NALs in the order VideoToolbox wants them (HEVC: VPS,SPS,PPS; - // H.264: SPS,PPS). - var vps: Data?, sps: Data?, pps: Data? - for nal in nalUnits(in: au) { - switch codec { - case .hevc: - switch hevcNalType(nal) { - case 32: vps = nal - case 33: sps = nal - case 34: pps = nal - default: break - } - case .h264: - switch h264NalType(nal) { - case 7: sps = nal - case 8: pps = nal - default: break - } - } - } - guard let sps, let pps else { return nil } - let sets: [Data] = codec == .hevc ? [vps, sps, pps].compactMap { $0 } : [sps, pps] - guard codec == .h264 || sets.count == 3 else { return nil } // HEVC needs the VPS too - - var format: CMVideoFormatDescription? - // Pin every parameter set's bytes for the duration of the create call, then hand - // VideoToolbox parallel pointer/size arrays. - var pointers: [UnsafePointer] = [] - var sizes: [Int] = [] - func withAll(_ i: Int, _ body: () -> Void) { - if i == sets.count { body(); return } - sets[i].withUnsafeBytes { raw in - pointers.append(raw.bindMemory(to: UInt8.self).baseAddress!) - sizes.append(sets[i].count) - withAll(i + 1, body) - } - } - var status: OSStatus = -1 - withAll(0) { - switch codec { - case .hevc: - status = CMVideoFormatDescriptionCreateFromHEVCParameterSets( - allocator: kCFAllocatorDefault, - parameterSetCount: pointers.count, - parameterSetPointers: pointers, - parameterSetSizes: sizes, - nalUnitHeaderLength: 4, - extensions: nil, - formatDescriptionOut: &format) - case .h264: - status = CMVideoFormatDescriptionCreateFromH264ParameterSets( - allocator: kCFAllocatorDefault, - parameterSetCount: pointers.count, - parameterSetPointers: pointers, - parameterSetSizes: sizes, - nalUnitHeaderLength: 4, - formatDescriptionOut: &format) - } - } - return status == noErr ? format : nil - } - - /// Re-pack an Annex-B AU as AVCC (4-byte big-endian length before each NAL), dropping - /// the parameter-set NALs (they live in the format description). - public static func avcc(from au: Data, codec: VideoCodec) -> Data { - var out = Data(capacity: au.count + 16) - for nal in nalUnits(in: au) { - if isParameterSet(nal, codec) { continue } - var len = UInt32(nal.count).bigEndian - withUnsafeBytes(of: &len) { out.append(contentsOf: $0) } - out.append(nal) - } - return out - } - - /// Wrap one AU as a decode-ready CMSampleBuffer. - public static func sampleBuffer( - au: AccessUnit, format: CMVideoFormatDescription, codec: VideoCodec - ) -> CMSampleBuffer? { - let avccData = avcc(from: au.data, codec: codec) - var blockBuffer: CMBlockBuffer? - guard CMBlockBufferCreateWithMemoryBlock( - allocator: kCFAllocatorDefault, memoryBlock: nil, - blockLength: avccData.count, blockAllocator: kCFAllocatorDefault, - customBlockSource: nil, offsetToData: 0, dataLength: avccData.count, - flags: 0, blockBufferOut: &blockBuffer) == noErr, - let block = blockBuffer - else { return nil } - let copied = avccData.withUnsafeBytes { raw in - CMBlockBufferReplaceDataBytes( - with: raw.baseAddress!, blockBuffer: block, - offsetIntoDestination: 0, dataLength: avccData.count) - } - guard copied == noErr else { return nil } - - var timing = CMSampleTimingInfo( - duration: .invalid, - presentationTimeStamp: CMTime(value: Int64(au.ptsNs), timescale: 1_000_000_000), - decodeTimeStamp: .invalid) - var sampleSize = avccData.count - var sample: CMSampleBuffer? - guard CMSampleBufferCreate( - allocator: kCFAllocatorDefault, dataBuffer: block, dataReady: true, - makeDataReadyCallback: nil, refcon: nil, formatDescription: format, - sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing, - sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize, - sampleBufferOut: &sample) == noErr - else { return nil } - // Low-latency display: render on arrival, don't wait for a clock. - if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample!, createIfNecessary: true) { - let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self) - CFDictionarySetValue( - dict, - Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), - Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()) - } - return sample - } -} diff --git a/clients/apple/Sources/PunktfunkKit/AudioDevices.swift b/clients/apple/Sources/PunktfunkKit/Audio/AudioDevices.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/AudioDevices.swift rename to clients/apple/Sources/PunktfunkKit/Audio/AudioDevices.swift diff --git a/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift new file mode 100644 index 0000000..bca90d9 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Audio/AudioRing.swift @@ -0,0 +1,129 @@ +import AVFoundation +import os + +/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render +/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming: +/// reads return silence until enough is buffered (at least `prefill`, and at least one +/// packet more than the device's render quantum — large-buffer devices would otherwise +/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an +/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle. +/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip. +final class AudioRing: @unchecked Sendable { + private var buf: [Float] + private var readIdx = 0 + private var writeIdx = 0 + private var primed = false + private var renderQuantum = 0 + private let prefill: Int + private let highWater: Int + private let channels: Int + private let lock = OSAllocatedUnfairLock() + + /// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames). + init(capacity: Int, prefill: Int, channels: Int) { + buf = [Float](repeating: 0, count: capacity) + self.prefill = prefill + self.channels = channels + highWater = prefill * 4 + } + + func write(_ samples: UnsafePointer, count: Int) { + lock.lock() + defer { lock.unlock() } + 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 { + readIdx = writeIdx + count - capacity // overflow: drop oldest + } + for i in 0.. highWater { + readIdx = writeIdx - prefill * 2 + } + } + + /// Fills `out` completely (silence beyond what's buffered). + func read(into out: UnsafeMutablePointer, count: Int) { + lock.lock() + defer { lock.unlock() } + renderQuantum = max(renderQuantum, count) + let available = writeIdx - readIdx + if !primed { + // One 5 ms host packet (240 frames × channels) of slack beyond the device's demand. + if available >= max(prefill, renderQuantum + 240 * channels) { + primed = true + } else { + for i in 0.. AVAudioChannelLayout? { + let labels: [AudioChannelLabel] + switch channels { + case 6: + labels = [ + kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center, + kAudioChannelLabel_LFEScreen, kAudioChannelLabel_LeftSurround, + kAudioChannelLabel_RightSurround, + ] + case 8: + labels = [ + kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center, + kAudioChannelLabel_LFEScreen, + kAudioChannelLabel_LeftSurround, kAudioChannelLabel_RightSurround, // wire RL/RR (back) + kAudioChannelLabel_LeftSurroundDirect, kAudioChannelLabel_RightSurroundDirect, // wire SL/SR (side) + ] + default: + return nil + } + let size = MemoryLayout.size + + (labels.count - 1) * MemoryLayout.stride + let raw = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: 16) + defer { raw.deallocate() } + let layout = raw.bindMemory(to: AudioChannelLayout.self, capacity: 1) + layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions + layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0) + layout.pointee.mNumberChannelDescriptions = UInt32(labels.count) + // `mChannelDescriptions` is the C variable-length tail array (declared `[1]`, over-allocated + // above). Scope the pointer with `withUnsafeMutablePointer` — taking `&…mChannelDescriptions` + // inline yields a pointer valid only for that expression, so building a buffer from it that + // outlives the call is a dangling-pointer bug. Inside the closure it stays valid while we fill it. + withUnsafeMutablePointer(to: &layout.pointee.mChannelDescriptions) { tail in + let descs = UnsafeMutableBufferPointer(start: tail, count: labels.count) + for (i, lbl) in labels.enumerated() { + descs[i] = AudioChannelDescription( + mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0), + mCoordinates: (0, 0, 0)) + } + } + return AVAudioChannelLayout(layout: layout) +} diff --git a/clients/apple/Sources/PunktfunkKit/OpusCodec.swift b/clients/apple/Sources/PunktfunkKit/Audio/OpusCodec.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/OpusCodec.swift rename to clients/apple/Sources/PunktfunkKit/Audio/OpusCodec.swift diff --git a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift similarity index 73% rename from clients/apple/Sources/PunktfunkKit/SessionAudio.swift rename to clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index 6268248..327e785 100644 --- a/clients/apple/Sources/PunktfunkKit/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -19,99 +19,6 @@ import os private let log = Logger(subsystem: "io.unom.punktfunk", category: "audio") -/// SPSC-ish jitter ring (interleaved float, `channels` per frame), drain thread → render -/// callback. The unfair lock is held for microseconds; fine at render-callback rates. Priming: -/// reads return silence until enough is buffered (at least `prefill`, and at least one -/// packet more than the device's render quantum — large-buffer devices would otherwise -/// chronically out-demand the prefill and oscillate prime → dropout → re-prime), and an -/// underrun re-primes, concealing jitter as one short dip instead of sustained crackle. -/// All counts stay whole frames (multiples of `channels`), so the interleave can never slip. -final class AudioRing: @unchecked Sendable { - private var buf: [Float] - private var readIdx = 0 - private var writeIdx = 0 - private var primed = false - private var renderQuantum = 0 - private let prefill: Int - private let highWater: Int - private let channels: Int - private let lock = OSAllocatedUnfairLock() - - /// `capacity`/`prefill` in samples (interleaved — `channels` per frame, both whole frames). - init(capacity: Int, prefill: Int, channels: Int) { - buf = [Float](repeating: 0, count: capacity) - self.prefill = prefill - self.channels = channels - highWater = prefill * 4 - } - - func write(_ samples: UnsafePointer, count: Int) { - lock.lock() - defer { lock.unlock() } - 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 { - readIdx = writeIdx + count - capacity // overflow: drop oldest - } - for i in 0.. highWater { - readIdx = writeIdx - prefill * 2 - } - } - - /// Fills `out` completely (silence beyond what's buffered). - func read(into out: UnsafeMutablePointer, count: Int) { - lock.lock() - defer { lock.unlock() } - renderQuantum = max(renderQuantum, count) - let available = writeIdx - readIdx - if !primed { - // One 5 ms host packet (240 frames × channels) of slack beyond the device's demand. - if available >= max(prefill, renderQuantum + 240 * channels) { - primed = true - } else { - for i in 0.. AVAudioChannelLayout? { - let labels: [AudioChannelLabel] - switch channels { - case 6: - labels = [ - kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center, - kAudioChannelLabel_LFEScreen, kAudioChannelLabel_LeftSurround, - kAudioChannelLabel_RightSurround, - ] - case 8: - labels = [ - kAudioChannelLabel_Left, kAudioChannelLabel_Right, kAudioChannelLabel_Center, - kAudioChannelLabel_LFEScreen, - kAudioChannelLabel_LeftSurround, kAudioChannelLabel_RightSurround, // wire RL/RR (back) - kAudioChannelLabel_LeftSurroundDirect, kAudioChannelLabel_RightSurroundDirect, // wire SL/SR (side) - ] - default: - return nil - } - let size = MemoryLayout.size - + (labels.count - 1) * MemoryLayout.stride - let raw = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: 16) - defer { raw.deallocate() } - let layout = raw.bindMemory(to: AudioChannelLayout.self, capacity: 1) - layout.pointee.mChannelLayoutTag = kAudioChannelLayoutTag_UseChannelDescriptions - layout.pointee.mChannelBitmap = AudioChannelBitmap(rawValue: 0) - layout.pointee.mNumberChannelDescriptions = UInt32(labels.count) - // `mChannelDescriptions` is the C variable-length tail array (declared `[1]`, over-allocated - // above). Scope the pointer with `withUnsafeMutablePointer` — taking `&…mChannelDescriptions` - // inline yields a pointer valid only for that expression, so building a buffer from it that - // outlives the call is a dangling-pointer bug. Inside the closure it stays valid while we fill it. - withUnsafeMutablePointer(to: &layout.pointee.mChannelDescriptions) { tail in - let descs = UnsafeMutableBufferPointer(start: tail, count: labels.count) - for (i, lbl) in labels.enumerated() { - descs[i] = AudioChannelDescription( - mChannelLabel: lbl, mChannelFlags: AudioChannelFlags(rawValue: 0), - mCoordinates: (0, 0, 0)) - } - } - return AVAudioChannelLayout(layout: layout) -} - public final class SessionAudio { private let connection: PunktfunkConnection private let flag = StopFlag() diff --git a/clients/apple/Sources/PunktfunkKit/Connection/ClientIdentity.swift b/clients/apple/Sources/PunktfunkKit/Connection/ClientIdentity.swift new file mode 100644 index 0000000..4d20724 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Connection/ClientIdentity.swift @@ -0,0 +1,59 @@ +// The client's persistent identity + the SPAKE2 PIN pairing ceremony — the trust +// bootstrap that precedes any pinned PunktfunkConnection. + +import Foundation +import PunktfunkCore + +/// This client's persistent self-signed identity. Generate ONCE with `generateIdentity()`, +/// store both PEMs (Keychain), present on every connect — the certificate's fingerprint is +/// how hosts recognize this client after pairing. +public struct ClientIdentity: Sendable { + public let certPEM: String + public let keyPEM: String + public init(certPEM: String, keyPEM: String) { + self.certPEM = certPEM + self.keyPEM = keyPEM + } +} + +/// Generate a fresh client identity (self-signed cert + key, PEM). +public func generateIdentity() throws -> ClientIdentity { + var cert = [CChar](repeating: 0, count: 4096) + var key = [CChar](repeating: 0, count: 4096) + let rc = punktfunk_generate_identity(&cert, UInt(cert.count), &key, UInt(key.count)) + guard rc == PUNKTFUNK_STATUS_OK.rawValue else { + throw PunktfunkClientError.status(rc) + } + return ClientIdentity(certPEM: String(cString: cert), keyPEM: String(cString: key)) +} + +/// Run the PIN pairing ceremony: the host displays a 4-digit PIN (its log/UI), the user +/// types it here. On success the host stores this client's identity and the returned +/// fingerprint is the host's now-VERIFIED identity — persist it and pass it as `pinSHA256` +/// to every later connect. Throws `.wrongPIN` when the proof is rejected. +public func pair( + host: String, port: UInt16 = 9777, + identity: ClientIdentity, pin: String, name: String, + timeoutMs: UInt32 = 90_000 +) throws -> Data { + var observed = [UInt8](repeating: 0, count: 32) + // The C header types PunktfunkStatus as a bare int32 (C17, no enum import), so the ABI + // functions return Int32 directly — compare against the enum constants' rawValue, the + // same bridging the connection methods use (statusOK etc.). + let rc = host.withCString { cs in + identity.certPEM.withCString { cert in + identity.keyPEM.withCString { key in + pin.withCString { p in + name.withCString { n in + punktfunk_pair(cs, port, cert, key, p, n, &observed, timeoutMs) + } + } + } + } + } + switch rc { + case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed) + case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN + default: throw PunktfunkClientError.status(rc) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/ClientTLS.swift b/clients/apple/Sources/PunktfunkKit/Connection/ClientTLS.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/ClientTLS.swift rename to clients/apple/Sources/PunktfunkKit/Connection/ClientTLS.swift diff --git a/clients/apple/Sources/PunktfunkKit/HostDiscovery.swift b/clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/HostDiscovery.swift rename to clients/apple/Sources/PunktfunkKit/Connection/HostDiscovery.swift diff --git a/clients/apple/Sources/PunktfunkKit/Connection/InputEvents.swift b/clients/apple/Sources/PunktfunkKit/Connection/InputEvents.swift new file mode 100644 index 0000000..aafda2d --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Connection/InputEvents.swift @@ -0,0 +1,87 @@ +// Convenience constructors for the wire input events (field semantics match +// punktfunk_core::input::InputEvent; see punktfunk_core.h). + +import Foundation +import PunktfunkCore + +public extension PunktfunkInputEvent { + private static func make( + _ kind: UInt32, code: UInt32, x: Int32, y: Int32, flags: UInt32 = 0 + ) -> PunktfunkInputEvent { + PunktfunkInputEvent(kind: UInt8(kind), _pad: (0, 0, 0), code: code, x: x, y: y, flags: flags) + } + static func mouseMove(dx: Int32, dy: Int32) -> PunktfunkInputEvent { + make(PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue, code: 0, x: dx, y: dy) + } + /// Absolute cursor position in client-surface pixels — the host places its cursor + /// there (same letterbox mapping and `flags` surface-dims packing as the touch events). + /// Used by the iPad pointer fallback when the scene can't pointer-lock and GCMouse's + /// relative deltas aren't available; the surface dimensions must each fit in 16 bits. + static func mouseMoveAbs( + x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32 + ) -> PunktfunkInputEvent { + make( + PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS.rawValue, code: 0, x: x, y: y, + flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF)) + } + /// GameStream button ids: 1=left 2=middle 3=right 4=X1 5=X2 (host maps to evdev BTN_*). + static func mouseButton(_ button: UInt32, down: Bool) -> PunktfunkInputEvent { + make( + (down ? PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN : PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP).rawValue, + code: button, x: 0, y: 0) + } + /// `vk` is a Windows virtual-key code (the host's vk_to_evdev table consumes these). + static func key(_ vk: UInt32, down: Bool) -> PunktfunkInputEvent { + make((down ? PUNKTFUNK_INPUT_KIND_KEY_DOWN : PUNKTFUNK_INPUT_KIND_KEY_UP).rawValue, code: vk, x: 0, y: 0) + } + /// WHEEL_DELTA(120)-scaled; positive = up (vertical) / right (horizontal) — the + /// convention Moonlight/SDL use; the host maps onto the ei/wl axes. + static func scroll(_ delta: Int32, horizontal: Bool = false) -> PunktfunkInputEvent { + make(PUNKTFUNK_INPUT_KIND_MOUSE_SCROLL.rawValue, code: horizontal ? 1 : 0, x: delta, y: 0) + } + + // Gamepad (wire contract in punktfunk_core::input::gamepad): one transition per event, + // `pad` = controller index, accumulated host-side into a virtual Xbox 360 or DualSense + // pad (the session's negotiated `GamepadType`). + + /// `button` is a GameStream buttonFlags bit (A=0x1000 B=0x2000 X=0x4000 Y=0x8000, + /// dpad=0x1/2/4/8, start=0x10 back=0x20 LS=0x40 RS=0x80 LB=0x100 RB=0x200 guide=0x400, + /// touchpad click=0x100000 — DualSense sessions only, the xpad has no such button). + static func gamepadButton(_ button: UInt32, down: Bool, pad: UInt32 = 0) -> PunktfunkInputEvent { + make( + PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue, + code: button, x: down ? 1 : 0, y: 0, flags: pad) + } + + /// Axis ids: 0=LSX 1=LSY 2=RSX 3=RSY (−32768...32767, XInput convention: +y = UP — + /// `GCControllerDirectionPad.yAxis` already matches, no flip), 4=LT 5=RT (0...255). + static func gamepadAxis(_ axis: UInt32, value: Int32, pad: UInt32 = 0) -> PunktfunkInputEvent { + make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad) + } + + // Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes + // fingers and is reusable after touchUp; coordinates are absolute pixels on the + // client's touch surface, whose size rides in `flags` so the host can rescale — + // the surface dimensions must each fit in 16 bits. Built for the iOS variant + // (UITouch → these); nothing on macOS emits them yet. + + static func touchDown( + id: UInt32, x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32 + ) -> PunktfunkInputEvent { + make( + PUNKTFUNK_INPUT_KIND_TOUCH_DOWN.rawValue, code: id, x: x, y: y, + flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF)) + } + + static func touchMove( + id: UInt32, x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32 + ) -> PunktfunkInputEvent { + make( + PUNKTFUNK_INPUT_KIND_TOUCH_MOVE.rawValue, code: id, x: x, y: y, + flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF)) + } + + static func touchUp(id: UInt32) -> PunktfunkInputEvent { + make(PUNKTFUNK_INPUT_KIND_TOUCH_UP.rawValue, code: id, x: 0, y: 0) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/LibraryClient.swift rename to clients/apple/Sources/PunktfunkKit/Connection/LibraryClient.swift diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift similarity index 84% rename from clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift rename to clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 172a5ed..76c39d3 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -57,60 +57,6 @@ public enum PunktfunkClientError: Error { case status(Int32) } -/// This client's persistent self-signed identity. Generate ONCE with `generateIdentity()`, -/// store both PEMs (Keychain), present on every connect — the certificate's fingerprint is -/// how hosts recognize this client after pairing. -public struct ClientIdentity: Sendable { - public let certPEM: String - public let keyPEM: String - public init(certPEM: String, keyPEM: String) { - self.certPEM = certPEM - self.keyPEM = keyPEM - } -} - -/// Generate a fresh client identity (self-signed cert + key, PEM). -public func generateIdentity() throws -> ClientIdentity { - var cert = [CChar](repeating: 0, count: 4096) - var key = [CChar](repeating: 0, count: 4096) - let rc = punktfunk_generate_identity(&cert, UInt(cert.count), &key, UInt(key.count)) - guard rc == PUNKTFUNK_STATUS_OK.rawValue else { - throw PunktfunkClientError.status(rc) - } - return ClientIdentity(certPEM: String(cString: cert), keyPEM: String(cString: key)) -} - -/// Run the PIN pairing ceremony: the host displays a 4-digit PIN (its log/UI), the user -/// types it here. On success the host stores this client's identity and the returned -/// fingerprint is the host's now-VERIFIED identity — persist it and pass it as `pinSHA256` -/// to every later connect. Throws `.wrongPIN` when the proof is rejected. -public func pair( - host: String, port: UInt16 = 9777, - identity: ClientIdentity, pin: String, name: String, - timeoutMs: UInt32 = 90_000 -) throws -> Data { - var observed = [UInt8](repeating: 0, count: 32) - // The C header types PunktfunkStatus as a bare int32 (C17, no enum import), so the ABI - // functions return Int32 directly — compare against the enum constants' rawValue, the - // same bridging the connection methods use (statusOK etc.). - let rc = host.withCString { cs in - identity.certPEM.withCString { cert in - identity.keyPEM.withCString { key in - pin.withCString { p in - name.withCString { n in - punktfunk_pair(cs, port, cert, key, p, n, &observed, timeoutMs) - } - } - } - } - } - switch rc { - case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed) - case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN - default: throw PunktfunkClientError.status(rc) - } -} - /// `withCString` over an optional — nil maps to a NULL C pointer. func withOptionalCString(_ s: String?, _ body: (UnsafePointer?) -> R) -> R { guard let s else { return body(nil) } @@ -803,87 +749,3 @@ public final class PunktfunkConnection { return closeRequested ? nil : handle } } - -// Convenience constructors for the wire input events (field semantics match -// punktfunk_core::input::InputEvent; see punktfunk_core.h). -public extension PunktfunkInputEvent { - private static func make( - _ kind: UInt32, code: UInt32, x: Int32, y: Int32, flags: UInt32 = 0 - ) -> PunktfunkInputEvent { - PunktfunkInputEvent(kind: UInt8(kind), _pad: (0, 0, 0), code: code, x: x, y: y, flags: flags) - } - static func mouseMove(dx: Int32, dy: Int32) -> PunktfunkInputEvent { - make(PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue, code: 0, x: dx, y: dy) - } - /// Absolute cursor position in client-surface pixels — the host places its cursor - /// there (same letterbox mapping and `flags` surface-dims packing as the touch events). - /// Used by the iPad pointer fallback when the scene can't pointer-lock and GCMouse's - /// relative deltas aren't available; the surface dimensions must each fit in 16 bits. - static func mouseMoveAbs( - x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32 - ) -> PunktfunkInputEvent { - make( - PUNKTFUNK_INPUT_KIND_MOUSE_MOVE_ABS.rawValue, code: 0, x: x, y: y, - flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF)) - } - /// GameStream button ids: 1=left 2=middle 3=right 4=X1 5=X2 (host maps to evdev BTN_*). - static func mouseButton(_ button: UInt32, down: Bool) -> PunktfunkInputEvent { - make( - (down ? PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_DOWN : PUNKTFUNK_INPUT_KIND_MOUSE_BUTTON_UP).rawValue, - code: button, x: 0, y: 0) - } - /// `vk` is a Windows virtual-key code (the host's vk_to_evdev table consumes these). - static func key(_ vk: UInt32, down: Bool) -> PunktfunkInputEvent { - make((down ? PUNKTFUNK_INPUT_KIND_KEY_DOWN : PUNKTFUNK_INPUT_KIND_KEY_UP).rawValue, code: vk, x: 0, y: 0) - } - /// WHEEL_DELTA(120)-scaled; positive = up (vertical) / right (horizontal) — the - /// convention Moonlight/SDL use; the host maps onto the ei/wl axes. - static func scroll(_ delta: Int32, horizontal: Bool = false) -> PunktfunkInputEvent { - make(PUNKTFUNK_INPUT_KIND_MOUSE_SCROLL.rawValue, code: horizontal ? 1 : 0, x: delta, y: 0) - } - - // Gamepad (wire contract in punktfunk_core::input::gamepad): one transition per event, - // `pad` = controller index, accumulated host-side into a virtual Xbox 360 or DualSense - // pad (the session's negotiated `GamepadType`). - - /// `button` is a GameStream buttonFlags bit (A=0x1000 B=0x2000 X=0x4000 Y=0x8000, - /// dpad=0x1/2/4/8, start=0x10 back=0x20 LS=0x40 RS=0x80 LB=0x100 RB=0x200 guide=0x400, - /// touchpad click=0x100000 — DualSense sessions only, the xpad has no such button). - static func gamepadButton(_ button: UInt32, down: Bool, pad: UInt32 = 0) -> PunktfunkInputEvent { - make( - PUNKTFUNK_INPUT_KIND_GAMEPAD_BUTTON.rawValue, - code: button, x: down ? 1 : 0, y: 0, flags: pad) - } - - /// Axis ids: 0=LSX 1=LSY 2=RSX 3=RSY (−32768...32767, XInput convention: +y = UP — - /// `GCControllerDirectionPad.yAxis` already matches, no flip), 4=LT 5=RT (0...255). - static func gamepadAxis(_ axis: UInt32, value: Int32, pad: UInt32 = 0) -> PunktfunkInputEvent { - make(PUNKTFUNK_INPUT_KIND_GAMEPAD_AXIS.rawValue, code: axis, x: value, y: 0, flags: pad) - } - - // Touch (host-side: libei ei_touchscreen on the virtual output). `id` distinguishes - // fingers and is reusable after touchUp; coordinates are absolute pixels on the - // client's touch surface, whose size rides in `flags` so the host can rescale — - // the surface dimensions must each fit in 16 bits. Built for the iOS variant - // (UITouch → these); nothing on macOS emits them yet. - - static func touchDown( - id: UInt32, x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32 - ) -> PunktfunkInputEvent { - make( - PUNKTFUNK_INPUT_KIND_TOUCH_DOWN.rawValue, code: id, x: x, y: y, - flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF)) - } - - static func touchMove( - id: UInt32, x: Int32, y: Int32, surfaceWidth: UInt32, surfaceHeight: UInt32 - ) -> PunktfunkInputEvent { - make( - PUNKTFUNK_INPUT_KIND_TOUCH_MOVE.rawValue, code: id, x: x, y: y, - flags: ((surfaceWidth & 0xFFFF) << 16) | (surfaceHeight & 0xFFFF)) - } - - static func touchUp(id: UInt32) -> PunktfunkInputEvent { - make(PUNKTFUNK_INPUT_KIND_TOUCH_UP.rawValue, code: id, x: 0, y: 0) - } -} diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift new file mode 100644 index 0000000..9ea4689 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/ControllerTester.swift @@ -0,0 +1,73 @@ +#if DEBUG +import Combine +import GameController + +/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds +/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a +/// live session uses — just aimed at the physically-connected controller instead of the +/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player +/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point: +/// a passing test exercises the exact code a session runs. +@MainActor +public final class ControllerTester: ObservableObject { + private let renderer = RumbleRenderer() + private weak var controller: GCController? + + /// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" — + /// for the test panel to display so it's obvious which path a given pad takes. + @Published public private(set) var rumbleBackend = "—" + + public init() {} + + /// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every + /// active-controller change. + public func target(_ c: GCController?) { + guard c !== controller else { return } + controller = c + renderer.retarget(c) { [weak self] note in + Task { @MainActor in self?.rumbleBackend = note } + } + } + + /// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to + /// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`. + public func rumble(low: Float, high: Float) { + func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) } + renderer.apply(low: u16(low), high: u16(high)) + } + + public func stopRumble() { renderer.apply(low: 0, high: 0) } + + /// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect` + /// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad. + public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) { + guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return } + effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger) + } + + public func resetTriggers() { + guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return } + ds.leftTrigger.setModeOff() + ds.rightTrigger.setModeOff() + } + + /// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light. + public func setLight(_ color: GCColor?) { + controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0) + } + + /// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear). + public func setPlayerIndex(_ index: GCControllerPlayerIndex) { + controller?.playerIndex = index + } + + /// Silence every channel and release the controller — call on the panel's disappear. + public func stop() { + resetTriggers() + setPlayerIndex(.indexUnset) + setLight(nil) + renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref + controller = nil + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/DualSenseHID.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseHID.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/DualSenseHID.swift rename to clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseHID.swift diff --git a/clients/apple/Sources/PunktfunkKit/DualSenseTriggerEffect.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseTriggerEffect.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/DualSenseTriggerEffect.swift rename to clients/apple/Sources/PunktfunkKit/Gamepad/DualSenseTriggerEffect.swift diff --git a/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift similarity index 84% rename from clients/apple/Sources/PunktfunkKit/GamepadCapture.swift rename to clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift index e8cc190..dbc0273 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadCapture.swift @@ -29,64 +29,6 @@ import Combine import Foundation import GameController -/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`). -public enum GamepadWire { - public static let dpadUp: UInt32 = 0x0001 - public static let dpadDown: UInt32 = 0x0002 - public static let dpadLeft: UInt32 = 0x0004 - public static let dpadRight: UInt32 = 0x0008 - public static let start: UInt32 = 0x0010 - public static let back: UInt32 = 0x0020 - public static let leftStickClick: UInt32 = 0x0040 - public static let rightStickClick: UInt32 = 0x0080 - public static let leftShoulder: UInt32 = 0x0100 - public static let rightShoulder: UInt32 = 0x0200 - public static let guide: UInt32 = 0x0400 - public static let a: UInt32 = 0x1000 - public static let b: UInt32 = 0x2000 - public static let x: UInt32 = 0x4000 - public static let y: UInt32 = 0x8000 - /// DualSense touchpad click (Moonlight's extended-button bit position). - public static let touchpadClick: UInt32 = 0x10_0000 - - public static let allButtons: [UInt32] = [ - dpadUp, dpadDown, dpadLeft, dpadRight, start, back, - leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, - a, b, x, y, touchpadClick, - ] - - public static let axisLSX: UInt32 = 0 - public static let axisLSY: UInt32 = 1 - public static let axisRSX: UInt32 = 2 - public static let axisRSY: UInt32 = 3 - public static let axisLT: UInt32 = 4 - public static let axisRT: UInt32 = 5 - - /// Raw DualSense gyro units per rad/s: hid-playstation's calibration over the host's - /// fixed blob resolves to 20 LSB per deg/s. - public static let gyroLSBPerRadS: Float = 20 * 180 / .pi - /// Raw DualSense accelerometer units per g (same derivation). - public static let accelLSBPerG: Float = 10_000 - - /// GC touchpad coordinates (±1, +y up) → wire (0...65535, origin top-left, +y down). - public static func touchpad(x: Float, y: Float) -> (x: UInt16, y: UInt16) { - let wx = ((x.clamped(to: -1...1) + 1) / 2 * 65535).rounded() - let wy = ((1 - y.clamped(to: -1...1)) / 2 * 65535).rounded() - return (UInt16(wx), UInt16(wy)) - } - - /// Scale + clamp one motion component into the raw signed-16 sensor domain. - public static func motionRaw(_ value: Float, scale: Float) -> Int16 { - Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max))) - } -} - -extension Float { - fileprivate func clamped(to range: ClosedRange) -> Float { - Swift.min(Swift.max(self, range.lowerBound), range.upperBound) - } -} - @MainActor public final class GamepadCapture { private let connection: PunktfunkConnection diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift new file mode 100644 index 0000000..786489f --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadFeedback.swift @@ -0,0 +1,195 @@ +// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and +// HID-output (0xCD) planes and replays them on the active physical controller — +// +// rumble → CHHapticEngine players (per-handle localities when the pad has them, +// one combined engine otherwise), +// lightbar → GCDeviceLight, +// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4), +// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger. +// +// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists +// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the +// drain always polls both planes with short timeouts and never spins, so an Xbox session +// just renders rumble. GameController profile mutation +// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself +// touches neither. When GamepadManager switches the active controller mid-session, the +// old pad is reset (triggers off, player index unset) and the last known feedback state +// is replayed onto the new one. + +import Combine +import Foundation +import GameController + +public final class GamepadFeedback { + private let connection: PunktfunkConnection + private let flag = StopFlag() + private let drainDone = DispatchSemaphore(value: 0) + private var drainStarted = false + private let rumble = RumbleRenderer() + private var activeSub: AnyCancellable? + + // Last applied feedback (main-actor) — replayed when the active controller changes. + @MainActor private var target: GCController? + @MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)? + @MainActor private var lastPlayerBits: UInt8? + @MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil] + + public init(connection: PunktfunkConnection, manager: GamepadManager) { + self.connection = connection + // Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing + // an implicit strong one — and the subscription (stored on self) never retain-cycles. + Task { @MainActor [weak self] in + guard let self else { return } + self.activeSub = manager.$active.sink { [weak self] dc in + MainActor.assumeIsolated { self?.retarget(dc?.controller) } + } + } + } + + /// 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 + /// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit + /// count, clamped to the four indices GC offers. + public static func playerIndex(forBits bits: UInt8) -> GCControllerPlayerIndex { + switch bits & 0x1F { + case 0: return .indexUnset + case 0b00100: return .index1 + case 0b01010: return .index2 + case 0b10101: return .index3 + case 0b11011: return .index4 + default: + let lit = (bits & 0x1F).nonzeroBitCount + return GCControllerPlayerIndex(rawValue: min(lit, 4) - 1) ?? .index1 + } + } + + public func start() { + guard !drainStarted else { return } + drainStarted = true + // Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad + // session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and + // let rumble own the wait elsewhere; on an Xbox session it stays nonblocking. + let thread = Thread { [connection, flag, drainDone, weak self] in + while !flag.isStopped { + do { + // Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds + // the connection's shared feedback lock for its whole wait; the video pump drains + // HDR mastering metadata (nextHdrMeta) on the SAME lock every frame, so a blocking + // poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR + // meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps + // rumble/HID latency low while leaving the lock free between polls. + if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 { + self?.rumble.apply(low: r.low, high: r.high) + } + // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing + // per-frame LED/trigger reports) can't spin here or block stop() past one cycle. + var burst = 0 + while burst < 64, !flag.isStopped, + let ev = try connection.nextHidOutput(timeoutMs: 0) { + self?.render(ev) + burst += 1 + } + } catch { + break // .closed (or fatal) — the session is over + } + // ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID + // latency without holding the lock the HDR-meta drain needs. + if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) } + } + drainDone.signal() + } + thread.name = "punktfunk-feedback" + thread.qualityOfService = .userInteractive + thread.start() + } + + /// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one + /// poll cycle) — call off the main actor, before `connection.close()`. + public func stop() { + flag.stop() + if drainStarted { + drainDone.wait() + drainStarted = false + } + rumble.stop() + // Drop the retarget subscription and the dead session's cached feedback — a + // controller change after teardown must not replay this session's triggers/LEDs. + Task { @MainActor in + self.activeSub = nil + self.lastLight = nil + self.lastPlayerBits = nil + self.lastTrigger = [nil, nil] + self.reset(self.target) + self.target = nil + } + } + + private func render(_ ev: PunktfunkConnection.HidOutputEvent) { + DispatchQueue.main.async { + MainActor.assumeIsolated { self.apply(ev) } + } + } + + @MainActor + private func apply(_ ev: PunktfunkConnection.HidOutputEvent) { + switch ev { + case let .led(pad, r, g, b): + guard pad == 0 else { return } + lastLight = (r, g, b) + target?.light?.color = GCColor( + red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255) + case let .playerLEDs(pad, bits): + guard pad == 0 else { return } + lastPlayerBits = bits + target?.playerIndex = Self.playerIndex(forBits: bits) + case let .triggerEffect(pad, which, effect): + guard pad == 0, which < 2 else { return } + let parsed = DualSenseTriggerEffect.parse(effect) + lastTrigger[Int(which)] = parsed + if let trigger = adaptiveTrigger(which) { + parsed.apply(to: trigger) + } + } + } + + @MainActor + private func retarget(_ controller: GCController?) { + guard controller !== target else { return } + reset(target) + target = controller + rumble.retarget(controller) + // Replay the session's feedback state so a swapped-in controller looks the same. + if let (r, g, b) = lastLight { + controller?.light?.color = GCColor( + red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255) + } + if let bits = lastPlayerBits { + controller?.playerIndex = Self.playerIndex(forBits: bits) + } + for which in 0..<2 { + if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) { + effect.apply(to: trigger) + } + } + } + + @MainActor + private func reset(_ controller: GCController?) { + guard let c = controller else { return } + c.playerIndex = .indexUnset + if let ds = c.extendedGamepad as? GCDualSenseGamepad { + ds.leftTrigger.setModeOff() + ds.rightTrigger.setModeOff() + } + } + + @MainActor + private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? { + guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil } + return which == 0 ? ds.leftTrigger : ds.rightTrigger + } +} diff --git a/clients/apple/Sources/PunktfunkKit/GamepadManager.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/GamepadManager.swift rename to clients/apple/Sources/PunktfunkKit/Gamepad/GamepadManager.swift diff --git a/clients/apple/Sources/PunktfunkKit/GamepadMenuInput.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift similarity index 78% rename from clients/apple/Sources/PunktfunkKit/GamepadMenuInput.swift rename to clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift index 212652e..fb6d21e 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadMenuInput.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadMenuInput.swift @@ -14,8 +14,9 @@ // off or race over. // // The button set mirrors a console launcher: A confirms, B backs out, Y is a screen's secondary -// action, and the shoulders (L1/R1) are optional fast "jump" steps. Directional moves auto-repeat -// on a held stick/dpad after an initial delay; every button is edge-triggered (fires once per press). +// action, X a tertiary one, and the shoulders (L1/R1) are optional fast "jump" steps. Directional +// moves auto-repeat on a held stick/dpad after an initial delay; every button is edge-triggered +// (fires once per press). import Foundation import GameController @@ -29,10 +30,18 @@ public final class GamepadMenuInput { private let manager: GamepadManager private var pollTimer: Timer? private var isActive = false + /// Seed the pressed-state trackers from the LIVE controller on the first poll after a + /// (re)start, firing nothing. Screens hand the controller off (a keyboard closes, a cover + /// dismisses) while the user is still holding the very button that triggered the handoff — + /// without this, the next screen's first poll would read that held button as a fresh edge + /// and act on the same press twice (e.g. the B that closed the keyboard also backing out + /// of the screen underneath). + private var needsSnapshot = false private var currentDirection: Direction? private var repeatTimer: Timer? private var wasConfirmPressed = false private var wasSecondaryPressed = false + private var wasTertiaryPressed = false private var wasBackPressed = false private var wasLeftShoulderPressed = false private var wasRightShoulderPressed = false @@ -44,6 +53,8 @@ public final class GamepadMenuInput { public var onConfirm: (() -> Void)? /// Button Y (or equivalent secondary action, e.g. "open library") — edge-triggered. public var onSecondary: (() -> Void)? + /// Button X (or equivalent tertiary action, e.g. "settings" / "delete") — edge-triggered. + public var onTertiary: (() -> Void)? /// Button B (or equivalent back/dismiss) — edge-triggered. public var onBack: (() -> Void)? /// Shoulder buttons (L1 `false` / R1 `true`) — edge-triggered fast-jump steps, optional per @@ -63,6 +74,7 @@ public final class GamepadMenuInput { public func start() { guard !isActive else { return } isActive = true + needsSnapshot = true let timer = Timer(timeInterval: pollInterval, repeats: true) { [weak self] _ in Task { @MainActor in self?.poll() } } @@ -79,6 +91,7 @@ public final class GamepadMenuInput { currentDirection = nil wasConfirmPressed = false wasSecondaryPressed = false + wasTertiaryPressed = false wasBackPressed = false wasLeftShoulderPressed = false wasRightShoulderPressed = false @@ -89,8 +102,24 @@ public final class GamepadMenuInput { private func poll() { guard isActive, let gamepad = manager.active?.controller.extendedGamepad else { return } + if needsSnapshot { + // Adopt whatever is held right now without firing (see `needsSnapshot`): a button + // must be RELEASED after a handoff before it can act here, and a held direction only + // keeps moving once it changes or re-engages. + needsSnapshot = false + wasConfirmPressed = gamepad.buttonA.isPressed + wasSecondaryPressed = gamepad.buttonY.isPressed + wasTertiaryPressed = gamepad.buttonX.isPressed + wasBackPressed = gamepad.buttonB.isPressed + wasLeftShoulderPressed = gamepad.leftShoulder.isPressed + wasRightShoulderPressed = gamepad.rightShoulder.isPressed + currentDirection = directionFrom(gamepad) + return + } + edge(gamepad.buttonA.isPressed, &wasConfirmPressed) { onConfirm?() } edge(gamepad.buttonY.isPressed, &wasSecondaryPressed) { onSecondary?() } + edge(gamepad.buttonX.isPressed, &wasTertiaryPressed) { onTertiary?() } edge(gamepad.buttonB.isPressed, &wasBackPressed) { onBack?() } edge(gamepad.leftShoulder.isPressed, &wasLeftShoulderPressed) { onShoulder?(false) } edge(gamepad.rightShoulder.isPressed, &wasRightShoulderPressed) { onShoulder?(true) } diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift new file mode 100644 index 0000000..b76cf4b --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadUIEnvironment.swift @@ -0,0 +1,26 @@ +// Whether the iOS/iPadOS/macOS UI should be in its controller-friendly mode (the console-style +// host launcher, gamepad settings, and the coverflow library browser instead of the touch/desktop +// layouts). A pure function, not a singleton: the reactivity comes from callers already observing +// `GamepadManager.shared` and the `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same +// local-read pattern SettingsView already uses for GamepadManager), so this stays the single place +// the two combine without adding a second ObservableObject or an environment key nobody else needs. + +import Foundation + +public enum GamepadUIEnvironment { + /// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`); + /// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable + /// controller is actually attached (a non-extended-profile device leaves `active` nil, which + /// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's + /// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable + /// without a real `GCController` (which XCTest can't construct). + public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool { + enabledSetting && (gamepadConnected || forced) + } + + /// Dev-only escape hatch (like ContentView's `PUNKTFUNK_AUTOCONNECT`): pretend a controller is + /// attached so the gamepad UI can be exercised/screenshotted without physical hardware — + /// essential on a headless CI Mac and for `swift run` UI work. Never set in production. + private static let forced = + ProcessInfo.processInfo.environment["PUNKTFUNK_FORCE_GAMEPAD_UI"] == "1" +} diff --git a/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift new file mode 100644 index 0000000..fc043ec --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/GamepadWire.swift @@ -0,0 +1,62 @@ +// The gamepad wire contract shared by capture (GamepadCapture), feedback (GamepadFeedback), +// and the tests — button bits, axis ids, and the touchpad/motion unit conversions. + +import Foundation + +/// The gamepad wire contract (mirrors `punktfunk_core::input::gamepad`). +public enum GamepadWire { + public static let dpadUp: UInt32 = 0x0001 + public static let dpadDown: UInt32 = 0x0002 + public static let dpadLeft: UInt32 = 0x0004 + public static let dpadRight: UInt32 = 0x0008 + public static let start: UInt32 = 0x0010 + public static let back: UInt32 = 0x0020 + public static let leftStickClick: UInt32 = 0x0040 + public static let rightStickClick: UInt32 = 0x0080 + public static let leftShoulder: UInt32 = 0x0100 + public static let rightShoulder: UInt32 = 0x0200 + public static let guide: UInt32 = 0x0400 + public static let a: UInt32 = 0x1000 + public static let b: UInt32 = 0x2000 + public static let x: UInt32 = 0x4000 + public static let y: UInt32 = 0x8000 + /// DualSense touchpad click (Moonlight's extended-button bit position). + public static let touchpadClick: UInt32 = 0x10_0000 + + public static let allButtons: [UInt32] = [ + dpadUp, dpadDown, dpadLeft, dpadRight, start, back, + leftStickClick, rightStickClick, leftShoulder, rightShoulder, guide, + a, b, x, y, touchpadClick, + ] + + public static let axisLSX: UInt32 = 0 + public static let axisLSY: UInt32 = 1 + public static let axisRSX: UInt32 = 2 + public static let axisRSY: UInt32 = 3 + public static let axisLT: UInt32 = 4 + public static let axisRT: UInt32 = 5 + + /// Raw DualSense gyro units per rad/s: hid-playstation's calibration over the host's + /// fixed blob resolves to 20 LSB per deg/s. + public static let gyroLSBPerRadS: Float = 20 * 180 / .pi + /// Raw DualSense accelerometer units per g (same derivation). + public static let accelLSBPerG: Float = 10_000 + + /// GC touchpad coordinates (±1, +y up) → wire (0...65535, origin top-left, +y down). + public static func touchpad(x: Float, y: Float) -> (x: UInt16, y: UInt16) { + let wx = ((x.clamped(to: -1...1) + 1) / 2 * 65535).rounded() + let wy = ((1 - y.clamped(to: -1...1)) / 2 * 65535).rounded() + return (UInt16(wx), UInt16(wy)) + } + + /// Scale + clamp one motion component into the raw signed-16 sensor domain. + public static func motionRaw(_ value: Float, scale: Float) -> Int16 { + Int16((value * scale).rounded().clamped(to: Float(Int16.min)...Float(Int16.max))) + } +} + +extension Float { + fileprivate func clamped(to range: ClosedRange) -> Float { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/MenuHaptics.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/MenuHaptics.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/MenuHaptics.swift rename to clients/apple/Sources/PunktfunkKit/Gamepad/MenuHaptics.swift diff --git a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift similarity index 53% rename from clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift rename to clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift index ee1880f..66e5540 100644 --- a/clients/apple/Sources/PunktfunkKit/GamepadFeedback.swift +++ b/clients/apple/Sources/PunktfunkKit/Gamepad/RumbleRenderer.swift @@ -1,22 +1,3 @@ -// Host→client gamepad feedback rendering: one drain thread polls the rumble (0xCA) and -// HID-output (0xCD) planes and replays them on the active physical controller — -// -// rumble → CHHapticEngine players (per-handle localities when the pad has them, -// one combined engine otherwise), -// lightbar → GCDeviceLight, -// player LEDs → GCController.playerIndex (the DS bit patterns map to player 1–4), -// trigger FX → DualSenseTriggerEffect.parse → GCDualSenseAdaptiveTrigger. -// -// Only pad 0 is rendered (exactly one controller is forwarded). HID-output traffic exists -// only on PlayStation-pad sessions (a DualSense, or a DualShock 4 = lightbar only) — the -// drain always polls both planes with short timeouts and never spins, so an Xbox session -// just renders rumble. GameController profile mutation -// happens on main; CHHapticEngine work on its own serial queue; the drain thread itself -// touches neither. When GamepadManager switches the active controller mid-session, the -// old pad is reset (triggers off, player index unset) and the last known feedback state -// is replayed onto the new one. - -import Combine import CoreHaptics import Foundation import GameController @@ -24,21 +5,6 @@ import os private let log = Logger(subsystem: "io.unom.punktfunk", category: "gamepad") -private final class FeedbackStopFlag: @unchecked Sendable { - private let lock = NSLock() - private var stopped = false - var isStopped: Bool { - lock.lock() - defer { lock.unlock() } - return stopped - } - func stop() { - lock.lock() - stopped = true - lock.unlock() - } -} - /// Rumble → CoreHaptics, isolated on one serial queue (CHHapticEngine is not main-bound, /// but it isn't a free-for-all either). Engines are created lazily on the first nonzero /// amplitude and torn down on retarget; players run only while their motor is on, so an @@ -47,7 +13,7 @@ private final class FeedbackStopFlag: @unchecked Sendable { /// /// `@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 { +final class RumbleRenderer: @unchecked Sendable { private let queue = DispatchQueue(label: "io.unom.punktfunk.haptics", qos: .userInteractive) /// One actuator's started engine plus the player currently driving it (nil = idle). The @@ -316,248 +282,3 @@ private final class RumbleRenderer: @unchecked Sendable { return c == nil ? "—" : "CoreHaptics" } } - -public final class GamepadFeedback { - private let connection: PunktfunkConnection - private let flag = FeedbackStopFlag() - private let drainDone = DispatchSemaphore(value: 0) - private var drainStarted = false - private let rumble = RumbleRenderer() - private var activeSub: AnyCancellable? - - // Last applied feedback (main-actor) — replayed when the active controller changes. - @MainActor private var target: GCController? - @MainActor private var lastLight: (r: UInt8, g: UInt8, b: UInt8)? - @MainActor private var lastPlayerBits: UInt8? - @MainActor private var lastTrigger: [DualSenseTriggerEffect?] = [nil, nil] - - public init(connection: PunktfunkConnection, manager: GamepadManager) { - self.connection = connection - // Capture self weakly in the hop too, so the inner sink's weak capture isn't shadowing - // an implicit strong one — and the subscription (stored on self) never retain-cycles. - Task { @MainActor [weak self] in - guard let self else { return } - self.activeSub = manager.$active.sink { [weak self] dc in - MainActor.assumeIsolated { self?.retarget(dc?.controller) } - } - } - } - - /// 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 - /// conventions) onto GCControllerPlayerIndex. Unknown patterns fall back to the lit - /// count, clamped to the four indices GC offers. - public static func playerIndex(forBits bits: UInt8) -> GCControllerPlayerIndex { - switch bits & 0x1F { - case 0: return .indexUnset - case 0b00100: return .index1 - case 0b01010: return .index2 - case 0b10101: return .index3 - case 0b11011: return .index4 - default: - let lit = (bits & 0x1F).nonzeroBitCount - return GCControllerPlayerIndex(rawValue: min(lit, 4) - 1) ?? .index1 - } - } - - public func start() { - guard !drainStarted else { return } - drainStarted = true - // Hidout traffic (lightbar / player LEDs / triggers) only exists on a PlayStation-pad - // session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and - // let rumble own the wait elsewhere; on an Xbox session it stays nonblocking. - let thread = Thread { [connection, flag, drainDone, weak self] in - while !flag.isStopped { - do { - // Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds - // the connection's shared feedback lock for its whole wait; the video pump drains - // HDR mastering metadata (nextHdrMeta) on the SAME lock every frame, so a blocking - // poll here starved it and throttled HDR to ~1 fps (SDR, which never drains HDR - // meta, was unaffected). Pacing with a short sleep OUTSIDE the lock (below) keeps - // rumble/HID latency low while leaving the lock free between polls. - if let r = try connection.nextRumble(timeoutMs: 0), r.pad == 0 { - self?.rumble.apply(low: r.low, high: r.high) - } - // Drain a BOUNDED burst of hidout events so sustained 0xCD traffic (a game writing - // per-frame LED/trigger reports) can't spin here or block stop() past one cycle. - var burst = 0 - while burst < 64, !flag.isStopped, - let ev = try connection.nextHidOutput(timeoutMs: 0) { - self?.render(ev) - burst += 1 - } - } catch { - break // .closed (or fatal) — the session is over - } - // ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID - // latency without holding the lock the HDR-meta drain needs. - if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) } - } - drainDone.signal() - } - thread.name = "punktfunk-feedback" - thread.qualityOfService = .userInteractive - thread.start() - } - - /// Stop the drain and silence the motors. Blocks until the drain thread exits (≤ one - /// poll cycle) — call off the main actor, before `connection.close()`. - public func stop() { - flag.stop() - if drainStarted { - drainDone.wait() - drainStarted = false - } - rumble.stop() - // Drop the retarget subscription and the dead session's cached feedback — a - // controller change after teardown must not replay this session's triggers/LEDs. - Task { @MainActor in - self.activeSub = nil - self.lastLight = nil - self.lastPlayerBits = nil - self.lastTrigger = [nil, nil] - self.reset(self.target) - self.target = nil - } - } - - private func render(_ ev: PunktfunkConnection.HidOutputEvent) { - DispatchQueue.main.async { - MainActor.assumeIsolated { self.apply(ev) } - } - } - - @MainActor - private func apply(_ ev: PunktfunkConnection.HidOutputEvent) { - switch ev { - case let .led(pad, r, g, b): - guard pad == 0 else { return } - lastLight = (r, g, b) - target?.light?.color = GCColor( - red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255) - case let .playerLEDs(pad, bits): - guard pad == 0 else { return } - lastPlayerBits = bits - target?.playerIndex = Self.playerIndex(forBits: bits) - case let .triggerEffect(pad, which, effect): - guard pad == 0, which < 2 else { return } - let parsed = DualSenseTriggerEffect.parse(effect) - lastTrigger[Int(which)] = parsed - if let trigger = adaptiveTrigger(which) { - parsed.apply(to: trigger) - } - } - } - - @MainActor - private func retarget(_ controller: GCController?) { - guard controller !== target else { return } - reset(target) - target = controller - rumble.retarget(controller) - // Replay the session's feedback state so a swapped-in controller looks the same. - if let (r, g, b) = lastLight { - controller?.light?.color = GCColor( - red: Float(r) / 255, green: Float(g) / 255, blue: Float(b) / 255) - } - if let bits = lastPlayerBits { - controller?.playerIndex = Self.playerIndex(forBits: bits) - } - for which in 0..<2 { - if let effect = lastTrigger[which], let trigger = adaptiveTrigger(UInt8(which)) { - effect.apply(to: trigger) - } - } - } - - @MainActor - private func reset(_ controller: GCController?) { - guard let c = controller else { return } - c.playerIndex = .indexUnset - if let ds = c.extendedGamepad as? GCDualSenseGamepad { - ds.leftTrigger.setModeOff() - ds.rightTrigger.setModeOff() - } - } - - @MainActor - private func adaptiveTrigger(_ which: UInt8) -> GCDualSenseAdaptiveTrigger? { - guard let ds = target?.extendedGamepad as? GCDualSenseGamepad else { return nil } - return which == 0 ? ds.leftTrigger : ds.rightTrigger - } -} - -#if DEBUG -/// Local feedback driver for the Settings → Controllers "Test Controller" panel (DEBUG builds -/// only). It drives the SAME CoreHaptics rumble renderer and `DualSenseTriggerEffect` path a -/// live session uses — just aimed at the physically-connected controller instead of the -/// host→client feedback planes — so rumble, the adaptive triggers, the lightbar and the player -/// LEDs can be confirmed on-device without a host. Reusing the real renderers is the point: -/// a passing test exercises the exact code a session runs. -@MainActor -public final class ControllerTester: ObservableObject { - private let renderer = RumbleRenderer() - private weak var controller: GCController? - - /// The rumble backend now in use — "DualSense HID · USB/Bluetooth", "CoreHaptics", or "—" — - /// for the test panel to display so it's obvious which path a given pad takes. - @Published public private(set) var rumbleBackend = "—" - - public init() {} - - /// Aim the feedback at a controller (nil releases it). Idempotent — safe to call on every - /// active-controller change. - public func target(_ c: GCController?) { - guard c !== controller else { return } - controller = c - renderer.retarget(c) { [weak self] note in - Task { @MainActor in self?.rumbleBackend = note } - } - } - - /// Drive both motors at 0...1 amplitudes — low = left/heavy, high = right/light — mapped to - /// the 0...0xFFFF wire range the session carries, through the real `RumbleRenderer`. - public func rumble(low: Float, high: Float) { - func u16(_ v: Float) -> UInt16 { UInt16((min(max(v, 0), 1) * 65535).rounded()) } - renderer.apply(low: u16(low), high: u16(high)) - } - - public func stopRumble() { renderer.apply(low: 0, high: 0) } - - /// Replay an adaptive-trigger effect on a DualSense via the real `DualSenseTriggerEffect` - /// renderer. `right == false` → L2, `true` → R2. No-op on a non-DualSense pad. - public func applyTrigger(_ effect: DualSenseTriggerEffect, right: Bool) { - guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return } - effect.apply(to: right ? ds.rightTrigger : ds.leftTrigger) - } - - public func resetTriggers() { - guard let ds = controller?.extendedGamepad as? GCDualSenseGamepad else { return } - ds.leftTrigger.setModeOff() - ds.rightTrigger.setModeOff() - } - - /// Lightbar colour (DualSense / DualShock 4); nil turns it off. No-op without a light. - public func setLight(_ color: GCColor?) { - controller?.light?.color = color ?? GCColor(red: 0, green: 0, blue: 0) - } - - /// Player-indicator LEDs (`.index1`...`.index4`, or `.indexUnset` to clear). - public func setPlayerIndex(_ index: GCControllerPlayerIndex) { - controller?.playerIndex = index - } - - /// Silence every channel and release the controller — call on the panel's disappear. - public func stop() { - resetTriggers() - setPlayerIndex(.indexUnset) - setLight(nil) - renderer.retarget(nil) // async teardown: stops the motors + drops the controller ref - controller = nil - } -} -#endif diff --git a/clients/apple/Sources/PunktfunkKit/GamepadUIEnvironment.swift b/clients/apple/Sources/PunktfunkKit/GamepadUIEnvironment.swift deleted file mode 100644 index 753a804..0000000 --- a/clients/apple/Sources/PunktfunkKit/GamepadUIEnvironment.swift +++ /dev/null @@ -1,20 +0,0 @@ -// Whether the iOS/iPadOS UI should be in its controller-friendly mode (larger focus targets on -// the host grid, the coverflow library browser instead of the plain grid). A pure function, not a -// singleton: the reactivity comes from callers already observing `GamepadManager.shared` and the -// `DefaultsKey.gamepadUIEnabled` @AppStorage themselves (the same local-read pattern SettingsView -// already uses for GamepadManager), so this stays the single place the two combine without adding -// a second ObservableObject or an environment key nobody else needs. - -import Foundation - -public enum GamepadUIEnvironment { - /// `enabledSetting` is the user's Settings toggle (`DefaultsKey.gamepadUIEnabled`); - /// `gamepadConnected` is `GamepadManager.shared.active != nil` — active only once a usable - /// controller is actually attached (a non-extended-profile device leaves `active` nil, which - /// keeps the touch UI). A `Bool` rather than the `DiscoveredController` itself: this function's - /// whole job is the AND, so there's nothing else to inspect, and it keeps the helper testable - /// without a real `GCController` (which XCTest can't construct). - public static func isActive(gamepadConnected: Bool, enabledSetting: Bool) -> Bool { - enabledSetting && gamepadConnected - } -} diff --git a/clients/apple/Sources/PunktfunkKit/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift similarity index 82% rename from clients/apple/Sources/PunktfunkKit/InputCapture.swift rename to clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index 3379787..0f4dca7 100644 --- a/clients/apple/Sources/PunktfunkKit/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift @@ -571,102 +571,4 @@ public final class InputCapture { } #endif } - - /// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted - /// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together). - static let hidToVK: [Int: UInt32] = { - var m: [Int: UInt32] = [:] - // a–z: HID 0x04..0x1D → VK 'A'..'Z'. - for i in 0..<26 { m[0x04 + i] = UInt32(0x41 + i) } - // 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) } - m[0x27] = 0x30 - m[0x28] = 0x0D // return - m[0x29] = 0x1B // escape - m[0x2A] = 0x08 // backspace - m[0x2B] = 0x09 // tab - m[0x2C] = 0x20 // space - m[0x2D] = 0xBD; m[0x2E] = 0xBB // - = - m[0x2F] = 0xDB; m[0x30] = 0xDD; m[0x31] = 0xDC // [ ] backslash - m[0x33] = 0xBA; m[0x34] = 0xDE; m[0x35] = 0xC0 // ; ' ` - m[0x36] = 0xBC; m[0x37] = 0xBE; m[0x38] = 0xBF // , . / - m[0x39] = 0x14 // caps lock - // F1..F12: HID 0x3A..0x45 → VK 0x70..0x7B. - for i in 0..<12 { m[0x3A + i] = UInt32(0x70 + i) } - m[0x46] = 0x2C; m[0x47] = 0x91; m[0x48] = 0x13 // printscreen scrolllock pause - m[0x4F] = 0x27; m[0x50] = 0x25; m[0x51] = 0x28; m[0x52] = 0x26 // arrows R L D U - m[0x49] = 0x2D; m[0x4A] = 0x24; m[0x4B] = 0x21 // insert home pageup - m[0x4C] = 0x2E; m[0x4D] = 0x23; m[0x4E] = 0x22 // delete end pagedown - // Keypad: NumLock, / * - +, Enter, 1..9, 0, decimal. KP Enter goes as - // VK_SEPARATOR (0x6C) — this host maps it to KEY_KPENTER (Windows itself would - // send VK_RETURN+extended, which vk_to_evdev can't distinguish). - m[0x53] = 0x90 - m[0x54] = 0x6F; m[0x55] = 0x6A; m[0x56] = 0x6D; m[0x57] = 0x6B - m[0x58] = 0x6C - for i in 0..<9 { m[0x59 + i] = UInt32(0x61 + i) } - m[0x62] = 0x60; m[0x63] = 0x6E - m[0x64] = 0xE2 // ISO 102nd key (<> next to left shift on ISO layouts) - m[0x65] = 0x5D // menu/application - m[0xE0] = 0xA2; m[0xE1] = 0xA0; m[0xE2] = 0xA4; m[0xE3] = 0x5B // Lctrl Lshift Lalt Lcmd - m[0xE4] = 0xA3; m[0xE5] = 0xA1; m[0xE6] = 0xA5; m[0xE7] = 0x5C // Rctrl Rshift Ralt Rcmd - return m - }() - - #if os(macOS) - /// NSEvent.keyCode (Carbon virtual keycode, kVK_*) → Windows VK. The macOS NSEvent key - /// path is keyed by keyCode (a layout-independent hardware position), NOT by HID usage, - /// so it needs its own table — but it emits the EXACT SAME Windows VK integers `hidToVK` - /// already produces for each physical key (A→0x41, Return→0x0D, KeypadEnter→0x6C, …), so - /// the host's vk_to_evdev (inject.rs) accepts both with zero change. Modifier keys come - /// via flagsChanged (handleFlagsChanged), not keyDown, so they're absent here. Keys with - /// no host evdev arm (F13–F20, KeypadEquals, the Fn key) are omitted → nil → swallowed. - static let keyCodeToVK: [UInt16: UInt32] = { - var m: [UInt16: UInt32] = [:] - // Letters — kVK_ANSI_A..Z (scattered keycodes) → VK 'A'..'Z'. - m[0x00] = 0x41; m[0x01] = 0x53; m[0x02] = 0x44; m[0x03] = 0x46 // A S D F - m[0x04] = 0x48; m[0x05] = 0x47; m[0x06] = 0x5A; m[0x07] = 0x58 // H G Z X - m[0x08] = 0x43; m[0x09] = 0x56; m[0x0B] = 0x42; m[0x0C] = 0x51 // C V B Q - m[0x0D] = 0x57; m[0x0E] = 0x45; m[0x0F] = 0x52; m[0x10] = 0x59 // W E R Y - m[0x11] = 0x54; m[0x1F] = 0x4F; m[0x20] = 0x55; m[0x22] = 0x49 // T O U I - m[0x23] = 0x50; m[0x25] = 0x4C; m[0x26] = 0x4A; m[0x28] = 0x4B // P L J K - m[0x2D] = 0x4E; m[0x2E] = 0x4D // N M - // Digit row — kVK_ANSI_1..0 (scattered) → VK '1'..'9','0'. - m[0x12] = 0x31; m[0x13] = 0x32; m[0x14] = 0x33; m[0x15] = 0x34 // 1 2 3 4 - m[0x16] = 0x36; m[0x17] = 0x35; m[0x19] = 0x39; m[0x1A] = 0x37 // 6 5 9 7 - m[0x1C] = 0x38; m[0x1D] = 0x30 // 8 0 - // Whitespace / control. - m[0x24] = 0x0D // return - m[0x30] = 0x09 // tab - m[0x31] = 0x20 // space - m[0x33] = 0x08 // delete (backspace) - m[0x35] = 0x1B // escape - m[0x75] = 0x2E // forward delete (VK_DELETE) - m[0x39] = 0x14 // caps lock - // Punctuation (US ANSI) + ISO 102nd key. - m[0x1B] = 0xBD; m[0x18] = 0xBB // - = (OEM_MINUS OEM_PLUS) - m[0x21] = 0xDB; m[0x1E] = 0xDD; m[0x2A] = 0xDC // [ ] backslash (OEM_4 6 5) - m[0x29] = 0xBA; m[0x27] = 0xDE; m[0x32] = 0xC0 // ; ' ` (OEM_1 7 3) - m[0x2B] = 0xBC; m[0x2F] = 0xBE; m[0x2C] = 0xBF // , . / (OEM_COMMA PERIOD 2) - m[0x0A] = 0xE2 // ISO 102nd key (<> next to left shift; OEM_102) - // Function keys F1..F12 (scattered) → VK 0x70..0x7B. F13+ omitted (no host arm). - m[0x7A] = 0x70; m[0x78] = 0x71; m[0x63] = 0x72; m[0x76] = 0x73 // F1 F2 F3 F4 - m[0x60] = 0x74; m[0x61] = 0x75; m[0x62] = 0x76; m[0x64] = 0x77 // F5 F6 F7 F8 - m[0x65] = 0x78; m[0x6D] = 0x79; m[0x67] = 0x7A; m[0x6F] = 0x7B // F9 F10 F11 F12 - // Arrows. - m[0x7B] = 0x25; m[0x7C] = 0x27; m[0x7D] = 0x28; m[0x7E] = 0x26 // left right down up - // Nav cluster (Apple keycodes; Help sits where Insert is). - m[0x72] = 0x2D; m[0x73] = 0x24; m[0x74] = 0x21 // insert home pageup - m[0x77] = 0x23; m[0x79] = 0x22 // end pagedown (forward-delete handled above) - // Keypad — kVK_ANSI_Keypad0..9 (scattered) → VK_NUMPAD0..9, plus the operators. - m[0x52] = 0x60; m[0x53] = 0x61; m[0x54] = 0x62; m[0x55] = 0x63 // KP0 KP1 KP2 KP3 - m[0x56] = 0x64; m[0x57] = 0x65; m[0x58] = 0x66; m[0x59] = 0x67 // KP4 KP5 KP6 KP7 - m[0x5B] = 0x68; m[0x5C] = 0x69 // KP8 KP9 - m[0x41] = 0x6E; m[0x43] = 0x6A; m[0x45] = 0x6B // KP decimal multiply plus - m[0x4E] = 0x6D; m[0x4B] = 0x6F // KP minus divide - m[0x4C] = 0x6C // KP enter → VK_SEPARATOR (host maps to KEY_KPENTER, matching hidToVK) - m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.) - return m - }() - #endif } diff --git a/clients/apple/Sources/PunktfunkKit/Input/KeyMaps.swift b/clients/apple/Sources/PunktfunkKit/Input/KeyMaps.swift new file mode 100644 index 0000000..6ccceab --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Input/KeyMaps.swift @@ -0,0 +1,102 @@ +// InputCapture's static keymap tables: HID usage → Windows VK (the GCKeyboard path on all +// platforms) and, on macOS, NSEvent.keyCode → Windows VK (the NSEvent key path). + +extension InputCapture { + /// HID usage (GCKeyCode raw) → Windows VK (the host maps VK → evdev; every VK emitted + /// here exists in punktfunk-host/src/inject.rs::vk_to_evdev — extend the two together). + static let hidToVK: [Int: UInt32] = { + var m: [Int: UInt32] = [:] + // a–z: HID 0x04..0x1D → VK 'A'..'Z'. + for i in 0..<26 { m[0x04 + i] = UInt32(0x41 + i) } + // 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) } + m[0x27] = 0x30 + m[0x28] = 0x0D // return + m[0x29] = 0x1B // escape + m[0x2A] = 0x08 // backspace + m[0x2B] = 0x09 // tab + m[0x2C] = 0x20 // space + m[0x2D] = 0xBD; m[0x2E] = 0xBB // - = + m[0x2F] = 0xDB; m[0x30] = 0xDD; m[0x31] = 0xDC // [ ] backslash + m[0x33] = 0xBA; m[0x34] = 0xDE; m[0x35] = 0xC0 // ; ' ` + m[0x36] = 0xBC; m[0x37] = 0xBE; m[0x38] = 0xBF // , . / + m[0x39] = 0x14 // caps lock + // F1..F12: HID 0x3A..0x45 → VK 0x70..0x7B. + for i in 0..<12 { m[0x3A + i] = UInt32(0x70 + i) } + m[0x46] = 0x2C; m[0x47] = 0x91; m[0x48] = 0x13 // printscreen scrolllock pause + m[0x4F] = 0x27; m[0x50] = 0x25; m[0x51] = 0x28; m[0x52] = 0x26 // arrows R L D U + m[0x49] = 0x2D; m[0x4A] = 0x24; m[0x4B] = 0x21 // insert home pageup + m[0x4C] = 0x2E; m[0x4D] = 0x23; m[0x4E] = 0x22 // delete end pagedown + // Keypad: NumLock, / * - +, Enter, 1..9, 0, decimal. KP Enter goes as + // VK_SEPARATOR (0x6C) — this host maps it to KEY_KPENTER (Windows itself would + // send VK_RETURN+extended, which vk_to_evdev can't distinguish). + m[0x53] = 0x90 + m[0x54] = 0x6F; m[0x55] = 0x6A; m[0x56] = 0x6D; m[0x57] = 0x6B + m[0x58] = 0x6C + for i in 0..<9 { m[0x59 + i] = UInt32(0x61 + i) } + m[0x62] = 0x60; m[0x63] = 0x6E + m[0x64] = 0xE2 // ISO 102nd key (<> next to left shift on ISO layouts) + m[0x65] = 0x5D // menu/application + m[0xE0] = 0xA2; m[0xE1] = 0xA0; m[0xE2] = 0xA4; m[0xE3] = 0x5B // Lctrl Lshift Lalt Lcmd + m[0xE4] = 0xA3; m[0xE5] = 0xA1; m[0xE6] = 0xA5; m[0xE7] = 0x5C // Rctrl Rshift Ralt Rcmd + return m + }() + + #if os(macOS) + /// NSEvent.keyCode (Carbon virtual keycode, kVK_*) → Windows VK. The macOS NSEvent key + /// path is keyed by keyCode (a layout-independent hardware position), NOT by HID usage, + /// so it needs its own table — but it emits the EXACT SAME Windows VK integers `hidToVK` + /// already produces for each physical key (A→0x41, Return→0x0D, KeypadEnter→0x6C, …), so + /// the host's vk_to_evdev (inject.rs) accepts both with zero change. Modifier keys come + /// via flagsChanged (handleFlagsChanged), not keyDown, so they're absent here. Keys with + /// no host evdev arm (F13–F20, KeypadEquals, the Fn key) are omitted → nil → swallowed. + static let keyCodeToVK: [UInt16: UInt32] = { + var m: [UInt16: UInt32] = [:] + // Letters — kVK_ANSI_A..Z (scattered keycodes) → VK 'A'..'Z'. + m[0x00] = 0x41; m[0x01] = 0x53; m[0x02] = 0x44; m[0x03] = 0x46 // A S D F + m[0x04] = 0x48; m[0x05] = 0x47; m[0x06] = 0x5A; m[0x07] = 0x58 // H G Z X + m[0x08] = 0x43; m[0x09] = 0x56; m[0x0B] = 0x42; m[0x0C] = 0x51 // C V B Q + m[0x0D] = 0x57; m[0x0E] = 0x45; m[0x0F] = 0x52; m[0x10] = 0x59 // W E R Y + m[0x11] = 0x54; m[0x1F] = 0x4F; m[0x20] = 0x55; m[0x22] = 0x49 // T O U I + m[0x23] = 0x50; m[0x25] = 0x4C; m[0x26] = 0x4A; m[0x28] = 0x4B // P L J K + m[0x2D] = 0x4E; m[0x2E] = 0x4D // N M + // Digit row — kVK_ANSI_1..0 (scattered) → VK '1'..'9','0'. + m[0x12] = 0x31; m[0x13] = 0x32; m[0x14] = 0x33; m[0x15] = 0x34 // 1 2 3 4 + m[0x16] = 0x36; m[0x17] = 0x35; m[0x19] = 0x39; m[0x1A] = 0x37 // 6 5 9 7 + m[0x1C] = 0x38; m[0x1D] = 0x30 // 8 0 + // Whitespace / control. + m[0x24] = 0x0D // return + m[0x30] = 0x09 // tab + m[0x31] = 0x20 // space + m[0x33] = 0x08 // delete (backspace) + m[0x35] = 0x1B // escape + m[0x75] = 0x2E // forward delete (VK_DELETE) + m[0x39] = 0x14 // caps lock + // Punctuation (US ANSI) + ISO 102nd key. + m[0x1B] = 0xBD; m[0x18] = 0xBB // - = (OEM_MINUS OEM_PLUS) + m[0x21] = 0xDB; m[0x1E] = 0xDD; m[0x2A] = 0xDC // [ ] backslash (OEM_4 6 5) + m[0x29] = 0xBA; m[0x27] = 0xDE; m[0x32] = 0xC0 // ; ' ` (OEM_1 7 3) + m[0x2B] = 0xBC; m[0x2F] = 0xBE; m[0x2C] = 0xBF // , . / (OEM_COMMA PERIOD 2) + m[0x0A] = 0xE2 // ISO 102nd key (<> next to left shift; OEM_102) + // Function keys F1..F12 (scattered) → VK 0x70..0x7B. F13+ omitted (no host arm). + m[0x7A] = 0x70; m[0x78] = 0x71; m[0x63] = 0x72; m[0x76] = 0x73 // F1 F2 F3 F4 + m[0x60] = 0x74; m[0x61] = 0x75; m[0x62] = 0x76; m[0x64] = 0x77 // F5 F6 F7 F8 + m[0x65] = 0x78; m[0x6D] = 0x79; m[0x67] = 0x7A; m[0x6F] = 0x7B // F9 F10 F11 F12 + // Arrows. + m[0x7B] = 0x25; m[0x7C] = 0x27; m[0x7D] = 0x28; m[0x7E] = 0x26 // left right down up + // Nav cluster (Apple keycodes; Help sits where Insert is). + m[0x72] = 0x2D; m[0x73] = 0x24; m[0x74] = 0x21 // insert home pageup + m[0x77] = 0x23; m[0x79] = 0x22 // end pagedown (forward-delete handled above) + // Keypad — kVK_ANSI_Keypad0..9 (scattered) → VK_NUMPAD0..9, plus the operators. + m[0x52] = 0x60; m[0x53] = 0x61; m[0x54] = 0x62; m[0x55] = 0x63 // KP0 KP1 KP2 KP3 + m[0x56] = 0x64; m[0x57] = 0x65; m[0x58] = 0x66; m[0x59] = 0x67 // KP4 KP5 KP6 KP7 + m[0x5B] = 0x68; m[0x5C] = 0x69 // KP8 KP9 + m[0x41] = 0x6E; m[0x43] = 0x6A; m[0x45] = 0x6B // KP decimal multiply plus + m[0x4E] = 0x6D; m[0x4B] = 0x6F // KP minus divide + m[0x4C] = 0x6C // KP enter → VK_SEPARATOR (host maps to KEY_KPENTER, matching hidToVK) + m[0x47] = 0x90 // KP clear sits where NumLock is → VK_NUMLOCK. (KP equals 0x51 dropped.) + return m + }() + #endif +} diff --git a/clients/apple/Sources/PunktfunkKit/PointerLockChain.swift b/clients/apple/Sources/PunktfunkKit/Input/PointerLockChain.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/PointerLockChain.swift rename to clients/apple/Sources/PunktfunkKit/Input/PointerLockChain.swift diff --git a/clients/apple/Sources/PunktfunkKit/BrandFont.swift b/clients/apple/Sources/PunktfunkKit/Support/BrandFont.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/BrandFont.swift rename to clients/apple/Sources/PunktfunkKit/Support/BrandFont.swift diff --git a/clients/apple/Sources/PunktfunkKit/Support/Clamped.swift b/clients/apple/Sources/PunktfunkKit/Support/Clamped.swift new file mode 100644 index 0000000..23fc1f2 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Support/Clamped.swift @@ -0,0 +1,9 @@ +import CoreGraphics + +extension CGFloat { + /// Clamp into `range` — keeps the absolute-cursor mapping inside the host's pixel + /// bounds even if a stray event reports a point a hair past the video rect. + func clamped(to range: ClosedRange) -> CGFloat { + Swift.min(Swift.max(self, range.lowerBound), range.upperBound) + } +} diff --git a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift similarity index 93% rename from clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift rename to clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift index 370ac57..59aff1d 100644 --- a/clients/apple/Sources/PunktfunkKit/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkKit/Support/DefaultsKeys.swift @@ -51,8 +51,8 @@ public enum DefaultsKey { /// Which corner the statistics overlay sits in — a `HUDPlacement` raw value /// ("topLeading"/"topTrailing"/"bottomLeading"/"bottomTrailing"). Default top-trailing. public static let hudPlacement = "punktfunk.hudPlacement" - /// iOS/iPadOS: switch the host list and game library to a controller-friendly layout - /// (larger focus targets, a coverflow-style library) whenever a gamepad is connected. On by - /// default; see `GamepadUIEnvironment.isActive`. + /// iOS/iPadOS/macOS: switch the host list, settings and game library to a controller-friendly + /// layout (the console launcher, gamepad-navigable settings, a coverflow-style library) + /// whenever a gamepad is connected. On by default; see `GamepadUIEnvironment.isActive`. public static let gamepadUIEnabled = "punktfunk.gamepadUIEnabled" } diff --git a/clients/apple/Sources/PunktfunkKit/Licenses.swift b/clients/apple/Sources/PunktfunkKit/Support/Licenses.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/Licenses.swift rename to clients/apple/Sources/PunktfunkKit/Support/Licenses.swift diff --git a/clients/apple/Sources/PunktfunkKit/Support/StopFlag.swift b/clients/apple/Sources/PunktfunkKit/Support/StopFlag.swift new file mode 100644 index 0000000..f9c9311 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Support/StopFlag.swift @@ -0,0 +1,21 @@ +// One NSLock-guarded boolean, set once: the cancellation handle shared by the session services +// (the two video pumps, audio playback/mic, gamepad feedback). Each start() creates a fresh flag +// and hands it to its worker thread(s); stop() sets it — permanently, so a stale worker can never +// be revived by a newer start. + +import Foundation + +final class StopFlag: @unchecked Sendable { + private let lock = NSLock() + private var stopped = false + var isStopped: Bool { + lock.lock() + defer { lock.unlock() } + return stopped + } + func stop() { + lock.lock() + stopped = true + lock.unlock() + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift b/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift new file mode 100644 index 0000000..1c2cb0e --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/AnnexB.swift @@ -0,0 +1,265 @@ +// Annex-B (HEVC / H.264) → CoreMedia plumbing. +// +// The punktfunk host emits Annex-B access units with in-band parameter sets on every IDR +// (deliberately — the client needs no out-of-band extradata). VideoToolbox wants the AVCC +// flavor instead: a CMVideoFormatDescription built from the parameter sets, and sample +// buffers whose NALs are 4-byte-length-prefixed. This file converts between the two, for +// the codec the host resolved in the Welcome (`connection.videoCodec`) — HEVC and H.264 +// differ only in NAL-header layout and which parameter sets exist (HEVC adds a VPS). AV1 +// is not an Annex-B/NAL codec and isn't handled here (hosts don't emit it on the native +// path yet). +// +// HOT PATH: both pumps run `formatDescription(fromIDR:codec:)` + `sampleBuffer(au:format:codec:)` +// once per AU, so the conversion is built on `forEachNAL` — a zero-copy scan over the AU's bytes +// (ranges, not materialized Datas) — and `sampleBuffer` packs the AVCC form straight into +// the CMBlockBuffer's own allocation. Per AU that leaves exactly one copy here (source → +// block buffer) instead of the naive scan-copy-slice-repack chain. + +import CoreMedia +import Foundation + +/// The video codec of the host's elementary stream — negotiated in the Welcome and read via +/// `punktfunk_connection_codec`. +public enum VideoCodec: Equatable { + case h264 + case hevc + + /// Resolve from the wire `Welcome.codec` byte (`PUNKTFUNK_CODEC_*`; unknown → HEVC). + public init(wire: UInt8) { + self = wire == 0x01 ? .h264 : .hevc // 0x01 = PUNKTFUNK_CODEC_H264 + } + + /// NAL unit type from a NAL's first byte. HEVC: bits 1..6; H.264: bits 0..4. + fileprivate func nalType(_ first: UInt8) -> UInt8 { + self == .hevc ? (first >> 1) & 0x3F : first & 0x1F + } + + /// True for a parameter-set NAL (dropped from AVCC; kept for the format description). + /// HEVC: VPS 32 / SPS 33 / PPS 34. H.264: SPS 7 / PPS 8 (no VPS). + fileprivate func isParameterSet(_ first: UInt8) -> Bool { + let t = nalType(first) + return self == .hevc ? (32...34).contains(t) : t == 7 || t == 8 + } + + /// True for a VCL (slice) NAL — in a conforming AU no parameter set follows the first one, + /// so the format-description scan can stop there. + fileprivate func isVCL(_ first: UInt8) -> Bool { + let t = nalType(first) + return self == .hevc ? t <= 31 : (1...5).contains(t) + } +} + +public enum AnnexB { + /// Walk the NAL units of `data` without copying: `body` receives the buffer base and each + /// NAL's byte range (start codes 00 00 01 / 00 00 00 01 excluded), and returns false to + /// stop the walk early (e.g. at the first VCL NAL). All zeros immediately preceding a + /// start code are dropped: they're either the 4-byte-code prefix or `trailing_zero_8bits` + /// padding, never NAL payload (emulation prevention keeps 00 00 0x out of conforming NAL + /// bytes) — same policy as ffmpeg. The base pointer is only valid inside `body`. + static func forEachNAL( + in data: Data, _ body: (_ base: UnsafePointer, _ range: Range) -> Bool + ) { + data.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in + guard let base = raw.bindMemory(to: UInt8.self).baseAddress else { return } + let count = raw.count + var i = 0 + var start = -1 + while i + 2 < count { + if base[i] == 0, base[i + 1] == 0, base[i + 2] == 1 { + var codeStart = i + while codeStart > 0, base[codeStart - 1] == 0 { + codeStart -= 1 + } + if start >= 0, start < codeStart, !body(base, start..= 0, start < count { + _ = body(base, start.. [Data] { + var nals: [Data] = [] + forEachNAL(in: data) { base, range in + nals.append(Data(bytes: base + range.lowerBound, count: range.count)) + return true + } + return nals + } + + /// HEVC NAL unit type (bits 1..6 of the first byte). + public static func hevcNalType(_ nal: Data) -> UInt8 { + guard let first = nal.first else { return 0xFF } + return (first >> 1) & 0x3F + } + + /// H.264 NAL unit type (bits 0..4 of the first byte). + public static func h264NalType(_ nal: Data) -> UInt8 { + guard let first = nal.first else { return 0xFF } + return first & 0x1F + } + + /// Build a format description from an IDR AU's in-band parameter sets (HEVC: VPS/SPS/PPS; + /// H.264: SPS/PPS). Returns nil when the AU carries no parameter sets (non-IDR). Runs per + /// AU on the pump thread: parameter sets precede the first VCL NAL in a conforming AU, so + /// the scan stops there — a delta frame (no leading parameter sets) costs a few byte + /// compares, no copies. + public static func formatDescription( + fromIDR au: Data, codec: VideoCodec + ) -> CMVideoFormatDescription? { + var vps: Data?, sps: Data?, pps: Data? + forEachNAL(in: au) { base, range in + let first = base[range.lowerBound] + switch codec.nalType(first) { + case 32 where codec == .hevc: + vps = Data(bytes: base + range.lowerBound, count: range.count) + case 33 where codec == .hevc, 7 where codec == .h264: + sps = Data(bytes: base + range.lowerBound, count: range.count) + case 34 where codec == .hevc, 8 where codec == .h264: + pps = Data(bytes: base + range.lowerBound, count: range.count) + default: + if codec.isVCL(first) { return false } // no parameter sets can follow + // AUD/SEI/… may precede the slices; keep scanning. + } + return true + } + guard let sps, let pps else { return nil } + // In the order VideoToolbox wants them: HEVC VPS,SPS,PPS (VPS required); H.264 SPS,PPS. + let sets: [Data] + switch codec { + case .hevc: + guard let vps else { return nil } + sets = [vps, sps, pps] + case .h264: + sets = [sps, pps] + } + + var format: CMVideoFormatDescription? + // Pin every parameter set's bytes for the duration of the create call, then hand + // VideoToolbox parallel pointer/size arrays. + var pointers: [UnsafePointer] = [] + var sizes: [Int] = [] + func withAll(_ i: Int, _ body: () -> Void) { + if i == sets.count { body(); return } + sets[i].withUnsafeBytes { raw in + pointers.append(raw.bindMemory(to: UInt8.self).baseAddress!) + sizes.append(sets[i].count) + withAll(i + 1, body) + } + } + var status: OSStatus = -1 + withAll(0) { + switch codec { + case .hevc: + status = CMVideoFormatDescriptionCreateFromHEVCParameterSets( + allocator: kCFAllocatorDefault, + parameterSetCount: pointers.count, + parameterSetPointers: pointers, + parameterSetSizes: sizes, + nalUnitHeaderLength: 4, + extensions: nil, + formatDescriptionOut: &format) + case .h264: + status = CMVideoFormatDescriptionCreateFromH264ParameterSets( + allocator: kCFAllocatorDefault, + parameterSetCount: pointers.count, + parameterSetPointers: pointers, + parameterSetSizes: sizes, + nalUnitHeaderLength: 4, + formatDescriptionOut: &format) + } + } + return status == noErr ? format : nil + } + + /// Re-pack an Annex-B AU as AVCC (4-byte big-endian length before each NAL), dropping + /// the parameter-set NALs (they live in the format description). + public static func avcc(from au: Data, codec: VideoCodec) -> Data { + var out = Data(capacity: au.count + 16) + forEachNAL(in: au) { base, range in + if codec.isParameterSet(base[range.lowerBound]) { return true } + var len = UInt32(range.count).bigEndian + withUnsafeBytes(of: &len) { out.append(contentsOf: $0) } + out.append(UnsafeBufferPointer(start: base + range.lowerBound, count: range.count)) + return true + } + return out + } + + /// Wrap one AU as a decode-ready CMSampleBuffer. The AVCC form is packed directly into + /// the CMBlockBuffer's allocation (sized by a first cheap scan) — no intermediate Data. + public static func sampleBuffer( + au: AccessUnit, format: CMVideoFormatDescription, codec: VideoCodec + ) -> CMSampleBuffer? { + // Pass 1: byte scan only — total AVCC size of the payload (non-parameter-set) NALs. + var total = 0 + forEachNAL(in: au.data) { base, range in + if !codec.isParameterSet(base[range.lowerBound]) { total += 4 + range.count } + return true + } + // Nothing decodable (a parameter-set-only AU — our host never sends one): drop it + // rather than hand the decoder an empty sample. + guard total > 0 else { return nil } + + var blockBuffer: CMBlockBuffer? + guard CMBlockBufferCreateWithMemoryBlock( + allocator: kCFAllocatorDefault, memoryBlock: nil, + blockLength: total, blockAllocator: kCFAllocatorDefault, + customBlockSource: nil, offsetToData: 0, dataLength: total, + flags: kCMBlockBufferAssureMemoryNowFlag, blockBufferOut: &blockBuffer) == noErr, + let block = blockBuffer + else { return nil } + var dstLen = 0 + var dstPtr: UnsafeMutablePointer? + guard CMBlockBufferGetDataPointer( + block, atOffset: 0, lengthAtOffsetOut: nil, totalLengthOut: &dstLen, + dataPointerOut: &dstPtr) == noErr, + dstLen == total, let dstPtr + else { return nil } + // Pass 2: the single copy — length prefix + payload per NAL, straight into the block. + let dst = UnsafeMutableRawPointer(dstPtr) + var off = 0 + forEachNAL(in: au.data) { base, range in + if codec.isParameterSet(base[range.lowerBound]) { return true } + var len = UInt32(range.count).bigEndian + withUnsafeBytes(of: &len) { + dst.advanced(by: off).copyMemory(from: $0.baseAddress!, byteCount: 4) + } + dst.advanced(by: off + 4) + .copyMemory(from: base + range.lowerBound, byteCount: range.count) + off += 4 + range.count + return true + } + + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: CMTime(value: Int64(au.ptsNs), timescale: 1_000_000_000), + decodeTimeStamp: .invalid) + var sampleSize = total + var sample: CMSampleBuffer? + guard CMSampleBufferCreate( + allocator: kCFAllocatorDefault, dataBuffer: block, dataReady: true, + makeDataReadyCallback: nil, refcon: nil, formatDescription: format, + sampleCount: 1, sampleTimingEntryCount: 1, sampleTimingArray: &timing, + sampleSizeEntryCount: 1, sampleSizeArray: &sampleSize, + sampleBufferOut: &sample) == noErr + else { return nil } + // Low-latency display: render on arrival, don't wait for a clock. + if let attachments = CMSampleBufferGetSampleAttachmentsArray(sample!, createIfNecessary: true) { + let dict = unsafeBitCast(CFArrayGetValueAtIndex(attachments, 0), to: CFMutableDictionary.self) + CFDictionarySetValue( + dict, + Unmanaged.passUnretained(kCMSampleAttachmentKey_DisplayImmediately).toOpaque(), + Unmanaged.passUnretained(kCFBooleanTrue).toOpaque()) + } + return sample + } +} diff --git a/clients/apple/Sources/PunktfunkKit/Video/KeyframeRecovery.swift b/clients/apple/Sources/PunktfunkKit/Video/KeyframeRecovery.swift new file mode 100644 index 0000000..4d0e6a6 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/KeyframeRecovery.swift @@ -0,0 +1,29 @@ +// Throttled host keyframe requests for decode recovery, shared by both pumps (StreamPump / +// Stage2Pipeline). Wedge signals arrive from several threads — the decoder's async error callback +// (a VT thread), a submit failure on the pump thread, the framesDropped poll — and the decode stays +// stalled for several frames until the requested IDR lands, so requests are coalesced (100 ms, the +// throttle the working Android path uses: fast enough that a lost recovery IDR is re-requested +// promptly, bounded so a sustained freeze can't flood the control stream). Bound to the live +// connection at pump start, unbound on stop. + +import Foundation + +final class KeyframeRecovery: @unchecked Sendable { + private let lock = NSLock() + private var connection: PunktfunkConnection? + private var lastNs: UInt64 = 0 + + func bind(_ c: PunktfunkConnection?) { + lock.lock(); connection = c; lastNs = 0; lock.unlock() + } + + func request() { + lock.lock() + let now = DispatchTime.now().uptimeNanoseconds + let due = lastNs == 0 || now &- lastNs > 100_000_000 // ≥ 100 ms since the last request + if due { lastNs = now } + let conn = due ? connection : nil + lock.unlock() + conn?.requestKeyframe() + } +} diff --git a/clients/apple/Sources/PunktfunkKit/LatencyMeter.swift b/clients/apple/Sources/PunktfunkKit/Video/LatencyMeter.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/LatencyMeter.swift rename to clients/apple/Sources/PunktfunkKit/Video/LatencyMeter.swift diff --git a/clients/apple/Sources/PunktfunkKit/MetalVideoPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift similarity index 78% rename from clients/apple/Sources/PunktfunkKit/MetalVideoPresenter.swift rename to clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift index 6b6b0d9..b16e9b4 100644 --- a/clients/apple/Sources/PunktfunkKit/MetalVideoPresenter.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/MetalVideoPresenter.swift @@ -44,10 +44,11 @@ vertex VOut pf_vtx(uint vid [[vertex_id]]) { return o; } -// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. When the drawable is larger -// than the decoded frame (a window/view bigger than the host's fixed mode), a bilinear upscale -// looks soft; Catmull-Rom keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1) -// scaler — and reduces to the exact texel at 1:1, so a native-resolution present stays pixel-exact. +// Bicubic (Catmull-Rom) sampling of the single-channel luma plane. The drawable is sized to the +// LAYER's pixels (see `render`), so this kernel performs the decoded→on-screen scale: when the +// window/view is bigger than the host's fixed mode a bilinear upscale looks soft; Catmull-Rom +// keeps edges crisp — matching AVSampleBufferDisplayLayer's (stage-1) scaler — and reduces to the +// exact texel at 1:1, so a native-resolution present stays pixel-exact. // Nine bilinear taps (TheRealMJP's optimisation of the 16-tap kernel); `s` MUST be a linear // sampler. Luma carries the perceived detail, so only it gets bicubic; chroma stays bilinear. float catmullRomLuma(texture2d tex, sampler s, float2 uv) { @@ -77,14 +78,27 @@ float catmullRomLuma(texture2d tex, sampler s, float2 uv) { return r; } +// 4:2:0 chroma is left-cosited horizontally (H.273 chroma_loc type 0 — the MPEG convention the +// host encodes and VideoToolbox decodes as-is), but sampling the half-res plane at the luma UV +// assumes CENTER siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset the +// sample by +0.25 chroma texels to re-align (libplacebo/mpv's correction). Vertical siting for +// type 0 is centered, which plain sampling already matches. A full-size 4:4:4 plane has no +// subsampling to correct — the offset self-disables when the plane widths match. +float2 chromaUV(texture2d lumaTex, texture2d chromaTex, float2 uv) { + if (chromaTex.get_width() < lumaTex.get_width()) { + uv.x += 0.25 / float(chromaTex.get_width()); + } + return uv; +} + // SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) → full-range RGB. Chroma is sampled at the -// same UV as luma, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0. +// (siting-corrected) luma UV, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0. fragment float4 pf_frag(VOut in [[stage_in]], texture2d lumaTex [[texture(0)]], texture2d chromaTex [[texture(1)]]) { constexpr sampler s(filter::linear, address::clamp_to_edge); float y = catmullRomLuma(lumaTex, s, in.uv); - float2 c = chromaTex.sample(s, in.uv).rg; + float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg; // BT.709, 8-bit limited (video) range → full-range RGB. y = (y - 16.0/255.0) * (255.0/219.0); float u = (c.x - 128.0/255.0) * (255.0/224.0); @@ -105,7 +119,7 @@ fragment float4 pf_frag_hdr(VOut in [[stage_in]], texture2d chromaTex [[texture(1)]]) { constexpr sampler s(filter::linear, address::clamp_to_edge); float y = catmullRomLuma(lumaTex, s, in.uv); - float2 c = chromaTex.sample(s, in.uv).rg; + float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg; // BT.2020 10-bit limited (video) range → full-range PQ R′G′B′. y = (y - 64.0/1023.0) * (1023.0/876.0); float u = (c.x - 512.0/1023.0) * (1023.0/896.0); @@ -185,10 +199,11 @@ public final class MetalVideoPresenter { // (the display link is the pacing source) — the fix for the fullscreen stutter. macOS-only. layer.displaySyncEnabled = false #endif - // Render the drawable at the DECODED frame's resolution (set per-frame in `render`) and let the - // system compositor scale it to the layer's bounds — the same `.resizeAspect` path stage-1's - // AVSampleBufferDisplayLayer uses. A native-resolution present is then pixel-exact (1:1, no - // shader scaling); a resized window rescales via the system's scaler. + // The drawable is rendered at the LAYER's pixel size (set per-frame in `render`), so the + // shader — not the compositor — performs the decoded→on-screen scale (bicubic luma; the + // compositor's contentsGravity path is plain bilinear). The gravity stays aspect-fit as a + // transient fallback: during a live resize the compositor may composite a drawable from + // the previous layout before the next render catches up. layer.contentsGravity = .resizeAspect // Triple-buffer: more in-flight drawables before `nextDrawable()` (called on the display-link / // MAIN thread) has to block waiting for one to free. @@ -277,9 +292,15 @@ public final class MetalVideoPresenter { /// Draw one decoded frame to the next drawable and present it. MAIN THREAD (the display link). /// `isHDR` selects the 10-bit BT.2020 PQ path vs the 8-bit BT.709 path and is reconciled with the /// layer config via `configure`. Returns true on success; false when there's no drawable yet, a - /// texture couldn't be made, or Metal errored — the caller then doesn't stamp a present. + /// texture couldn't be made, or Metal errored — the caller then doesn't stamp a present (and can + /// requeue the frame). `onPresented` fires once the drawable actually reached glass, with the + /// `CLOCK_REALTIME` instant from the drawable's `presentedTime` — or nil when the system reports + /// none (a dropped drawable). It runs on a Metal callback thread; keep the handler thread-safe. @discardableResult - public func render(_ pixelBuffer: CVPixelBuffer, isHDR: Bool = false) -> Bool { + public func render( + _ pixelBuffer: CVPixelBuffer, isHDR: Bool = false, + onPresented: ((Int64?) -> Void)? = nil + ) -> Bool { // Reconcile the layer with the decoded frame's HDR-ness (handles a mid-session SDR↔HDR flip). configure(hdr: isHDR) @@ -298,15 +319,25 @@ public final class MetalVideoPresenter { pixelBuffer, plane: 1, format: tenBit ? .rg16Unorm : .rg8Unorm, cache: textureCache) else { return false } - // Size the drawable to the decoded frame so the fullscreen triangle samples 1:1 (pixel-exact); - // the layer's contentsGravity then scales it to the on-screen bounds via the system compositor - // (matching stage-1). drawableSize does NOT track bounds (defaults to 0), so set it BEFORE - // nextDrawable; re-set only on a change (first frame / Reconfigure / HDR flip). + // Size the drawable to the LAYER's pixels (bounds × contentsScale, both set by the hosting + // view's layout) so the Catmull-Rom shader performs the decoded→on-screen scale in one pass: + // a native-mode session stays exactly 1:1 (the kernel reduces to the identity texel), and a + // window bigger than the host's mode gets bicubic luma instead of the compositor's bilinear. + // Before the first layout (empty bounds) fall back to the decoded size. drawableSize does NOT + // track bounds (defaults to 0), so set it BEFORE nextDrawable; re-set only on a change + // (layout / Reconfigure / HDR flip — and every frame of a live resize, which is fine). let decodedSize = CGSize( width: CVPixelBufferGetWidth(pixelBuffer), height: CVPixelBufferGetHeight(pixelBuffer)) - if layer.drawableSize != decodedSize { layer.drawableSize = decodedSize } + let scale = layer.contentsScale + let boundsSize = layer.bounds.size + let targetSize = (boundsSize.width > 0 && boundsSize.height > 0) + ? CGSize( + width: (boundsSize.width * scale).rounded(), + height: (boundsSize.height * scale).rounded()) + : decodedSize + if layer.drawableSize != targetSize { layer.drawableSize = targetSize } #if DEBUG - logSizeIfChanged(decoded: decodedSize) + logSizeIfChanged(decoded: decodedSize, drawable: targetSize) #endif guard let drawable = layer.nextDrawable(), let commandBuffer = queue.makeCommandBuffer() @@ -325,6 +356,24 @@ public final class MetalVideoPresenter { encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1) encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3) encoder.endEncoding() + if let onPresented { + #if targetEnvironment(simulator) + // The simulator SDK exposes neither addPresentedHandler nor presentedTime — report + // nil so the caller stamps with its display-link estimate (the pre-presentedTime + // behavior; simulator numbers are indicative only anyway). + onPresented(nil) + #else + // Registered BEFORE present. presentedTime is CACurrentMediaTime-based; 0 means the + // system never put this drawable on glass (dropped) — report nil, the caller falls + // back to its display-link estimate. + drawable.addPresentedHandler { d in + onPresented( + d.presentedTime > 0 + ? Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: d.presentedTime) + : nil) + } + #endif + } commandBuffer.present(drawable) // present at the next vsync — lowest latency // Hold the CVMetalTextures + source pixel buffer (its IOSurface) alive until the GPU finishes // sampling — releasing them at scope exit could free the backing mid-read. @@ -350,11 +399,12 @@ public final class MetalVideoPresenter { } #if DEBUG - private func logSizeIfChanged(decoded: CGSize) { - let sig = "\(Int(decoded.width))x\(Int(decoded.height))|hdr\(hdrActive ? 1 : 0)" + private func logSizeIfChanged(decoded: CGSize, drawable: CGSize) { + let sig = "\(Int(decoded.width))x\(Int(decoded.height))→\(Int(drawable.width))x\(Int(drawable.height))|hdr\(hdrActive ? 1 : 0)" if sig != lastSizeSig { lastSizeSig = sig - let msg = "stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) hdr=\(hdrActive)" + let msg = + "stage2: decoded \(Int(decoded.width))x\(Int(decoded.height)) → drawable \(Int(drawable.width))x\(Int(drawable.height)) hdr=\(hdrActive)" presenterLog.info("\(msg, privacy: .public)") } } diff --git a/clients/apple/Sources/PunktfunkKit/Probe444Blobs.swift b/clients/apple/Sources/PunktfunkKit/Video/Probe444Blobs.swift similarity index 100% rename from clients/apple/Sources/PunktfunkKit/Probe444Blobs.swift rename to clients/apple/Sources/PunktfunkKit/Video/Probe444Blobs.swift diff --git a/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift new file mode 100644 index 0000000..f8b2166 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/Video/SessionPresenter.swift @@ -0,0 +1,153 @@ +// Per-session presenter stack shared by the macOS and iOS/tvOS stream views: stage-2 (explicit +// VTDecompressionSession decode → CAMetalLayer, driven by the hosting view's CADisplayLink) is the +// default; stage-1 (StreamPump → AVSampleBufferDisplayLayer) is the Metal-unavailable / DEBUG +// fallback. The views own the platform bits — capture, window/scale tracking, and constructing the +// display link — and delegate the shared presenter lifecycle here. +// +// Main-thread only: start/layout/stop and the display-link tick all run on the main runloop. + +#if canImport(Metal) && canImport(QuartzCore) +import AVFoundation +import Foundation +import QuartzCore + +/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or +/// presenter directly makes a `owner → link → owner` cycle that only `invalidate()` breaks — if a +/// teardown is ever missed the owner leaks and keeps ticking. The proxy is what the link retains; +/// the handler closure captures the owner `[weak]`, so the owner 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) } +} + +final class SessionPresenter { + private var pump: StreamPump? + private var stage2: Stage2Pipeline? + private var stage2Link: CADisplayLink? + private var metalLayer: CAMetalLayer? + private var connection: PunktfunkConnection? + + /// Start the presenter for `connection`. `baseLayer` is the view's AVSampleBufferDisplayLayer: + /// stage-1 enqueues into it; stage-2 leaves it idle and composites an opaque CAMetalLayer + /// sublayer over it. `makeDisplayLink` supplies the platform link (macOS `NSView.displayLink` + /// tracks the view's display; iOS/tvOS uses the plain `CADisplayLink` init) — only called when + /// stage-2 engages. Call `layout(in:contentsScale:)` right after so the sublayer has a frame + /// before the first tick. + func start( + connection: PunktfunkConnection, + baseLayer: AVSampleBufferDisplayLayer, + presentMeter: LatencyMeter?, + presentTailMeter: LatencyMeter? = nil, + makeDisplayLink: (AnyObject, Selector) -> CADisplayLink, + onFrame: (@Sendable (AccessUnit) -> Void)?, + onSessionEnd: (@Sendable () -> Void)? + ) { + stop() + self.connection = connection + + // Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a + // CAMetalLayer/display-link present): it can detect + recover a wedged decoder where + // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is + // reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1 + // pump below stays the automatic fallback if Metal is missing). + #if DEBUG + let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1" + #else + let forceStage1 = false + #endif + if !forceStage1, + let pipeline = Stage2Pipeline( + presentMeter: presentMeter, presentTailMeter: presentTailMeter) { + let metal = pipeline.layer + // The opaque metal layer composites OVER the AVSampleBufferDisplayLayer base, which + // sits idle (un-enqueued) in stage-2. contentsScale + frame are set in layout(). + baseLayer.addSublayer(metal) + metalLayer = metal + stage2 = pipeline + let proxy = DisplayLinkProxy { [weak self] link in + self?.stage2?.renderTick( + targetPresentNs: Stage2Pipeline.realtimeNs( + forDisplayLinkTimestamp: link.targetTimestamp)) + } + let link = makeDisplayLink(proxy, #selector(DisplayLinkProxy.tick(_:))) + link.add(to: .main, forMode: .common) + stage2Link = link + syncFrameRate(hz: connection.currentMode().refreshHz) + pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) + } else { + let pump = StreamPump() + pump.start( + connection: connection, layer: baseLayer, + onFrame: onFrame, onSessionEnd: onSessionEnd) + self.pump = pump + } + } + + /// Ask the display link for the stream's own cadence. iOS/tvOS-only: without an explicit + /// range, ProMotion devices cap CADisplayLink at 60 Hz (iPhones additionally need + /// `CADisableMinimumFrameDurationOnPhone` in Info.plist), so a 120 fps stream would present + /// at half rate with the ring silently dropping every other frame. `maximum` allows up to + /// 120 so the system MAY tick faster than a sub-120 stream (each extra tick is a near-free + /// empty `renderTick`, and presenting on a denser grid shortens the decode→glass wait); the + /// macOS NSView link already tracks its display and must NOT be capped to the stream rate. + /// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh. + private func syncFrameRate(hz: UInt32) { + #if !os(macOS) + guard hz > 0, let link = stage2Link else { return } + let hzF = Float(hz) + if link.preferredFrameRateRange.preferred != hzF { + link.preferredFrameRateRange = CAFrameRateRange( + minimum: min(30, hzF), maximum: max(hzF, 120), preferred: hzF) + } + #endif + } + + /// Position the stage-2 metal sublayer aspect-fit in the hosting view (the host streams at the + /// client's native mode, so this is usually the full bounds; it letterboxes a resized window). + /// The layer FRAME + contentsScale set here are what the presenter sizes its drawable from + /// (frame × scale) — the shader then performs the decoded→on-screen scale (bicubic luma), so a + /// native-mode session stays pixel-exact 1:1 and a mismatched window beats the compositor's + /// bilinear. No-op for stage-1 or before start. + func layout(in bounds: CGRect, contentsScale: CGFloat) { + guard let metalLayer, let connection else { return } + let mode = connection.currentMode() + syncFrameRate(hz: mode.refreshHz) // track a mid-session Reconfigure's new refresh + let fit: CGRect = (mode.width > 0 && mode.height > 0) + ? AVMakeRect( + aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), + insideRect: bounds) + : bounds + // No implicit resize animation; contentsScale tracks the view's backing/display scale. + CATransaction.begin() + CATransaction.setDisableActions(true) + metalLayer.contentsScale = contentsScale + metalLayer.frame = fit + CATransaction.commit() + } + + /// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the + /// stage-2 layer + link. Does not close the connection — that stays with whoever owns it. + /// Idempotent. + func stop() { + pump?.stop() + pump = nil + stage2Link?.invalidate() + stage2Link = nil + stage2?.stop() // stops the pump (synchronous join) + drops the decode session + stage2 = nil + metalLayer?.removeFromSuperlayer() + metalLayer = nil + connection = nil + } + + deinit { + // The owning view's stop() normally ran already; this covers a missed teardown so the + // display link can't keep ticking a deallocated pipeline. + stage2Link?.invalidate() + stage2?.stop() + pump?.stop() + } +} +#endif diff --git a/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift similarity index 77% rename from clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift rename to clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift index 436d4ee..9ed89ca 100644 --- a/clients/apple/Sources/PunktfunkKit/Stage2Pipeline.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage2Pipeline.swift @@ -12,16 +12,6 @@ import AVFoundation import Foundation 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 /// latency, no smoothing buffer), the display link takes-and-clears. Sendable; lock-guarded. private final class ReadyRing: @unchecked Sendable { @@ -34,37 +24,15 @@ private final class ReadyRing: @unchecked Sendable { lock.lock(); defer { lock.unlock() } let f = frame; frame = nil; return f } -} - -/// Cancellation handle owned by one pump thread (same pattern as StreamPump). -private final class PumpToken: @unchecked Sendable { - private let lock = NSLock() - private var live = true - var isLive: Bool { lock.lock(); defer { lock.unlock() }; return live } - func cancel() { lock.lock(); live = false; lock.unlock() } -} - -/// Throttled host keyframe requests for decode recovery. The decoder's async error callback (a VT -/// thread) and the pump thread (a submit failure) both signal a wedge; this coalesces them so the -/// control stream isn't flooded while the decode stays stalled for several frames until the requested -/// IDR lands. Bound to the live connection in `start`, unbound in `stop`. -private final class KeyframeRecovery: @unchecked Sendable { - private let lock = NSLock() - private var connection: PunktfunkConnection? - private var lastNs: UInt64 = 0 - - func bind(_ c: PunktfunkConnection?) { - lock.lock(); connection = c; lastNs = 0; lock.unlock() - } - - func request() { + /// Return a frame the display link took but could not present (a transient `nextDrawable` + /// failure). Kept only while the slot is still empty — a newer decoded frame wins, so + /// newest-ready ordering is preserved. Without this, a failed render silently LOSES the + /// frame, and under the host's infinite GOP a static scene sends no replacement until the + /// next damage — the stale picture would persist. + func putBack(_ f: ReadyFrame) { lock.lock() - let now = DispatchTime.now().uptimeNanoseconds - let due = lastNs == 0 || now &- lastNs > 100_000_000 // ≥ 100 ms since the last request - if due { lastNs = now } - let conn = due ? connection : nil + if frame == nil { frame = f } lock.unlock() - conn?.requestKeyframe() } } @@ -72,12 +40,13 @@ public final class Stage2Pipeline { private let ring = ReadyRing() private let presenter: MetalVideoPresenter private let decoder: VideoDecoder - private let presentMeter: LatencyMeter + private let presentMeter: LatencyMeter? + private let presentTailMeter: LatencyMeter? private let recovery = KeyframeRecovery() - private var token = PumpToken() + private var token = StopFlag() private var offsetNs: Int64 = 0 /// Signalled when the pump thread exits, so `stop()` can join it (bounded) before `decoder.reset()` - /// — otherwise a pump iteration already past its `token.isLive` check can rebuild a decode session + /// — otherwise a pump iteration already past its `token.isStopped` check can rebuild a decode session /// right after the reset (a brief orphan session). `pumpJoinable` is armed by `start`, consumed by /// the first `stop` (so the idempotent second `stop`/deinit doesn't block on an already-drained /// semaphore). start/stop are sequential lifecycle calls, so the plain flag is safe. @@ -87,12 +56,15 @@ public final class Stage2Pipeline { /// The Metal layer the hosting view installs + sizes. public var layer: CAMetalLayer { presenter.layer } - /// `presentMeter` records capture→present (the glass-to-glass term). Returns nil if Metal can't be - /// set up (headless / no GPU) — caller falls back to the stage-1 presenter. - public init?(presentMeter: LatencyMeter) { + /// `presentMeter` records capture→present (the glass-to-glass term); `presentTailMeter` + /// records decode-completion→present (the ring wait + render — the tail stage-2 exists to + /// shorten). Both optional: metering never gates the presenter choice. Returns nil if Metal + /// can't be set up (headless / no GPU) — caller falls back to the stage-1 presenter. + public init?(presentMeter: LatencyMeter?, presentTailMeter: LatencyMeter? = nil) { guard let presenter = MetalVideoPresenter.make() else { return nil } self.presenter = presenter self.presentMeter = presentMeter + self.presentTailMeter = presentTailMeter let ring = ring let recovery = recovery self.decoder = VideoDecoder( @@ -113,7 +85,7 @@ public final class Stage2Pipeline { ) { offsetNs = connection.clockOffsetNs recovery.bind(connection) // arm host-keyframe recovery for this session - token = PumpToken() // fresh token per start — cancel is permanent (like StreamPump) + token = StopFlag() // fresh token per start — a stop is permanent (like StreamPump) // Configure the decoder's chroma + the layer's initial colorimetry before the first frame. The // chroma subsampling drives only the decode pixel format (orthogonal to HDR/depth); the HDR @@ -138,7 +110,7 @@ public final class Stage2Pipeline { // decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a // resolution-ceiling miss). End cleanly instead of looping on a black screen. var decodeFailRun = 0 - while token.isLive { + while !token.isStopped { do { // Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the // decoder conceals the reference-missing deltas — often WITHOUT an error callback — @@ -164,7 +136,7 @@ public final class Stage2Pipeline { format = f // refreshed on every IDR (mode changes included) awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete } - guard let f = format, token.isLive else { continue } + guard let f = format, !token.isStopped else { continue } if decoder.decode(au: au, format: f) { decodeFailRun = 0 } else { @@ -176,12 +148,12 @@ public final class Stage2Pipeline { // ~3 s of solid failure in a 4:4:4 session (and only there — a 4:2:0 loss // recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session. if connection.isChroma444, decodeFailRun >= 180 { - if token.isLive { onSessionEnd?() } + if !token.isStopped { onSessionEnd?() } break } } } catch { - if token.isLive { onSessionEnd?() } + if !token.isStopped { onSessionEnd?() } break // session closed } } @@ -192,19 +164,32 @@ public final class Stage2Pipeline { thread.start() } - /// MAIN thread, once per vsync. Present the newest ready frame (if any) and stamp capture→present at + /// MAIN thread, once per vsync. Present the newest ready frame (if any). The latency stamps + /// use the drawable's ACTUAL on-glass instant (`addPresentedHandler`/`presentedTime` — the + /// handler fires on a Metal callback thread; the meters are thread-safe), falling back to /// `targetPresentNs` — the display link's target present instant, already converted to - /// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`). + /// `CLOCK_REALTIME` (see `realtimeNs(forDisplayLinkTimestamp:)`) — when the system reports + /// no presented time (a dropped drawable). A frame that could not be rendered (no drawable + /// yet) goes back into the ring so the next tick retries it. public func renderTick(targetPresentNs: Int64) { guard let frame = ring.take() else { return } - guard presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) else { return } - presentMeter.record(ptsNs: frame.ptsNs, atNs: targetPresentNs, offsetNs: offsetNs) + let offsetNs = offsetNs + let presentMeter = presentMeter + let presentTailMeter = presentTailMeter + let rendered = presenter.render(frame.pixelBuffer, isHDR: frame.isHDR) { presentedNs in + let atNs = presentedNs ?? targetPresentNs + presentMeter?.record(ptsNs: frame.ptsNs, atNs: atNs, offsetNs: offsetNs) + // Present tail = decode-completion → on-glass. Both instants are client + // CLOCK_REALTIME, so no skew offset applies. + presentTailMeter?.record(ptsNs: UInt64(frame.decodedNs), atNs: atNs, offsetNs: 0) + } + if !rendered { ring.putBack(frame) } } /// Stop the pump (≤ one poll timeout) and drop the decode session. MAIN THREAD; idempotent. Does not - /// close the connection. A restart needs a fresh Stage2Pipeline (cancel is permanent). + /// close the connection. A restart needs a fresh Stage2Pipeline (the stop is permanent). public func stop() { - token.cancel() + token.stop() // Join the pump (bounded: ≤ one nextAU poll + an in-flight decode) before resetting the decoder, // so the pump can't rebuild a session right after the reset. Only the first stop joins; a // repeat/deinit stop skips the already-drained semaphore. @@ -216,7 +201,7 @@ public final class Stage2Pipeline { recovery.bind(nil) // stop requesting keyframes once the session is torn down } - deinit { token.cancel() } + deinit { token.stop() } /// Convert a `CADisplayLink.targetTimestamp` (CACurrentMediaTime basis) to a `CLOCK_REALTIME` /// nanosecond instant — the present clock the AU pts + skew offset live in. Projects to the target diff --git a/clients/apple/Sources/PunktfunkKit/Stage444Probe.swift b/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift similarity index 98% rename from clients/apple/Sources/PunktfunkKit/Stage444Probe.swift rename to clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift index 474189c..18bb8b4 100644 --- a/clients/apple/Sources/PunktfunkKit/Stage444Probe.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/Stage444Probe.swift @@ -43,7 +43,7 @@ public enum Stage444Probe { au auBytes: [UInt8], want: OSType, fullRangeSibling: OSType ) -> Bool { let data = Data(auBytes) - guard let format = AnnexB.formatDescription(fromIDR: data) else { return false } + guard let format = AnnexB.formatDescription(fromIDR: data, codec: .hevc) else { return false } // Require a hardware decoder — a software false-positive would make us advertise 4:4:4 and // then decode every real frame on the CPU, blowing the latency budget. let spec: [CFString: Any] = [ @@ -62,7 +62,7 @@ public enum Stage444Probe { defer { VTDecompressionSessionInvalidate(session) } let au = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0) - guard let sample = AnnexB.sampleBuffer(au: au, format: format) else { return false } + guard let sample = AnnexB.sampleBuffer(au: au, format: format, codec: .hevc) else { return false } var produced: OSType = 0 let done = DispatchSemaphore(value: 0) diff --git a/clients/apple/Sources/PunktfunkKit/StreamPump.swift b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift similarity index 81% rename from clients/apple/Sources/PunktfunkKit/StreamPump.swift rename to clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift index cecae03..9e04f48 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamPump.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/StreamPump.swift @@ -10,26 +10,10 @@ import os private let pumpLog = Logger(subsystem: "io.unom.punktfunk", category: "video") -/// Cancellation handle owned by exactly one pump thread — a restart hands the old pump -/// its own token, so it can never be revived by a newer start(). -private final class PumpToken: @unchecked Sendable { - private let lock = NSLock() - private var live = true - var isLive: Bool { - lock.lock() - defer { lock.unlock() } - return live - } - func cancel() { - lock.lock() - live = false - lock.unlock() - } -} - -/// One pump per instance; create a fresh StreamPump per start (cancel is permanent). +/// One pump per instance; create a fresh StreamPump per start (the stop is permanent — +/// a restart hands the old pump its own token, so it can never be revived by a newer start()). final class StreamPump { - private let token = PumpToken() + private let token = StopFlag() /// Pump thread: pull AUs, wrap, enqueue. Non-IDR AUs before the first format /// description are dropped. `onFrame`/`onSessionEnd` fire on the pump thread. @@ -40,6 +24,9 @@ final class StreamPump { onSessionEnd: (@Sendable () -> Void)? ) { let token = token + // Coalesced host keyframe requests (100 ms throttle — see KeyframeRecovery). + let recovery = KeyframeRecovery() + recovery.bind(connection) // The layer is non-Sendable but its enqueue/flush are documented thread-safe, and after // this point only the pump thread drives it — assert that so the @Sendable Thread closure // may capture it. @@ -48,7 +35,6 @@ final class StreamPump { let thread = Thread { var format: CMVideoFormatDescription? - var lastKeyframeRequest = Date.distantPast var lastFramesDropped = connection.framesDropped() // Recovery is a persistent WANT, not a one-shot edge: set it on detected loss (or a // decoder reset), retry the throttled request EVERY iteration, and clear it only when a @@ -61,17 +47,7 @@ final class StreamPump { var awaitingIDR = false var awaitingSince = Date.distantPast // when the current recovery began (for the resume log) var wasFailed = false - // Coalesced host keyframe request. 100 ms throttle (matches the working Android path): - // fast enough that a lost recovery IDR is re-requested promptly, bounded so a sustained - // freeze can't flood the control stream. - func requestKeyframeThrottled() { - let now = Date() - if now.timeIntervalSince(lastKeyframeRequest) > 0.1 { - connection.requestKeyframe() - lastKeyframeRequest = now - } - } - while token.isLive { + while !token.isStopped { do { // Loss recovery (the primary path). Under the host's infinite GOP the only // recovery keyframe is one we request. The reassembler drops unrecoverable AUs @@ -91,7 +67,7 @@ final class StreamPump { lastFramesDropped = dropped awaitingIDR = true } - if awaitingIDR { requestKeyframeThrottled() } + if awaitingIDR { recovery.request() } guard let au = try connection.nextAU(timeoutMs: 100) else { continue } onFrame?(au) @@ -120,11 +96,11 @@ final class StreamPump { wasFailed = failed guard let f = format, let sample = AnnexB.sampleBuffer(au: au, format: f, codec: connection.videoCodec), - token.isLive // don't enqueue a stale frame after a restart + !token.isStopped // don't enqueue a stale frame after a restart else { continue } layer.enqueue(sample) } catch { - if token.isLive { + if !token.isStopped { onSessionEnd?() } break // session closed @@ -138,8 +114,8 @@ final class StreamPump { /// Stop pumping (≤ one poll timeout). Does not close the connection. func stop() { - token.cancel() + token.stop() } - deinit { token.cancel() } + deinit { token.stop() } } diff --git a/clients/apple/Sources/PunktfunkKit/VideoDecoder.swift b/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift similarity index 94% rename from clients/apple/Sources/PunktfunkKit/VideoDecoder.swift rename to clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift index e8e6761..1327d89 100644 --- a/clients/apple/Sources/PunktfunkKit/VideoDecoder.swift +++ b/clients/apple/Sources/PunktfunkKit/Video/VideoDecoder.swift @@ -148,9 +148,9 @@ public final class VideoDecoder: @unchecked Sendable { /// True when `newFormat` carries a PQ (SMPTE ST 2084) or HLG transfer function — i.e. the host /// is sending HDR (BT.2020). VideoToolbox populates the transfer-function extension from the /// HEVC VUI, so this picks the decode bit depth (10-bit P010/x444 vs 8-bit NV12/444v) from the - /// stream. The present-side HDR config (colorspace/EDR/shader) is latched once per session from - /// the Welcome (`connection.isHDR`), which the host does NOT flip mid-session — so this predicate - /// and that config agree for the session (a `#if DEBUG` assert in the presenter guards it). + /// stream — and can flip mid-session (a game entering HDR re-inits the host encoder). The + /// presenter follows the decoded frame's resulting `isHDR`, not the Welcome's latched flag + /// (`render` reconciles the layer per frame via the idempotent `configure(hdr:)`). static func isHDRFormat(_ format: CMVideoFormatDescription) -> Bool { guard let tf = CMFormatDescriptionGetExtension( @@ -208,6 +208,11 @@ public final class VideoDecoder: @unchecked Sendable { outputCallback: &callback, decompressionSessionOut: &newSession) guard status == noErr, let newSession else { return false } + // Real-time hint: schedule this session for live-streaming latency rather than + // throughput/efficiency. Best-effort — decoders that don't support the property + // return an error, which is fine to ignore. + VTSessionSetProperty( + newSession, key: kVTDecompressionPropertyKey_RealTime, value: kCFBooleanTrue) session = newSession format = newFormat return true diff --git a/clients/apple/Sources/PunktfunkKit/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift similarity index 83% rename from clients/apple/Sources/PunktfunkKit/StreamView.swift rename to clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 5d6768d..c6964ef 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -86,20 +86,22 @@ public struct StreamView: NSViewRepresentable { private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onSessionEnd: (@Sendable () -> Void)? private let presentMeter: LatencyMeter? + private let presentTailMeter: LatencyMeter? /// `onFrame`/`onSessionEnd` fire on the pump thread — hop to the main actor for UI. /// `captureEnabled: false` disables input capture entirely while UI (e.g. a trust /// prompt) is layered over the stream; flipping it to true auto-engages capture /// once. `onCaptureChange` (main thread) reports engage/release — drive the HUD's /// "click to capture" / "⌘⎋ releases" hint with it. `presentMeter` records capture→present - /// when the stage-2 presenter is active (`punktfunk.presenter == "stage2"`). + /// and `presentTailMeter` decode→present when the stage-2 presenter is active. public init( connection: PunktfunkConnection, captureEnabled: Bool = true, onCaptureChange: ((Bool) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil, - presentMeter: LatencyMeter? = nil + presentMeter: LatencyMeter? = nil, + presentTailMeter: LatencyMeter? = nil ) { self.connection = connection self.captureEnabled = captureEnabled @@ -107,6 +109,7 @@ public struct StreamView: NSViewRepresentable { self.onFrame = onFrame self.onSessionEnd = onSessionEnd self.presentMeter = presentMeter + self.presentTailMeter = presentTailMeter } public func makeNSView(context: Context) -> StreamLayerView { @@ -114,6 +117,7 @@ public struct StreamView: NSViewRepresentable { view.onCaptureChange = onCaptureChange view.captureEnabled = captureEnabled view.presentMeter = presentMeter + view.presentTailMeter = presentTailMeter view.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) return view } @@ -122,6 +126,7 @@ public struct StreamView: NSViewRepresentable { view.onCaptureChange = onCaptureChange view.captureEnabled = captureEnabled view.presentMeter = presentMeter + view.presentTailMeter = presentTailMeter // SwiftUI reuses the NSView across state changes — repoint the pump only when the // connection identity actually changed. if view.connection !== connection { @@ -136,13 +141,13 @@ public struct StreamView: NSViewRepresentable { public final class StreamLayerView: NSView { private let displayLayer = AVSampleBufferDisplayLayer() - private var pump: StreamPump? - /// Stage-2 presenter (default): a CAMetalLayer sublayer driven by a display link instead of the - /// StreamPump → displayLayer path. nil = stage-1 (Metal-unavailable fallback / DEBUG toggle). + /// Record capture→present / decode→present when the stage-2 presenter is active. + /// Consulted at start(). var presentMeter: LatencyMeter? - private var stage2: Stage2Pipeline? - private var stage2Link: CADisplayLink? - private var metalLayer: CAMetalLayer? + var presentTailMeter: LatencyMeter? + /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the + /// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback. + private let presenter = SessionPresenter() public private(set) var connection: PunktfunkConnection? private let cursorCapture = CursorCapture() private var inputCapture: InputCapture? @@ -242,16 +247,16 @@ public final class StreamLayerView: NSView { public override func layout() { super.layout() attemptPendingCapture() // bounds become real here on first presentation - layoutMetalLayer() // keep the stage-2 sublayer aspect-fit to the view + layoutPresenter() // keep the stage-2 sublayer aspect-fit to the view } public override func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) // `layout()` isn't guaranteed on a manual-frame (no-Auto-Layout) live resize, so the - // stage-2 metal sublayer's drawableSize could stay at the old size while the view grows — - // the compositor then upscales a too-small drawable and the video turns blocky. Resize the - // drawable here too so it always tracks the window's pixel size (no stale upscale). - layoutMetalLayer() + // stage-2 metal sublayer's frame could stay at the old size while the view grows — + // the compositor then upscales a too-small layer and the video turns blocky. Re-fit + // here too so it always tracks the window's size (no stale upscale). + layoutPresenter() } // MARK: - Capture state machine @@ -362,8 +367,7 @@ public final class StreamLayerView: NSView { // A click is explicit intent AND may arrive mid-activation (acceptsFirstMouse: // NSApp.isActive / isKeyWindow are still false for the click coming in from // another app) — only the auto-engage paths require already-held key status. - // `connection != nil` (not `pump`) is the session-active gate — the stage-2 presenter - // runs without a StreamPump, and capture must still engage there. + // `connection != nil` is the session-active gate (presenter internals are opaque here). guard captureEnabled, !captured, connection != nil, window != nil, fromClick || (NSApp.isActive && window?.isKeyWindow == true) else { return } @@ -483,8 +487,10 @@ public final class StreamLayerView: NSView { let u = (p.x - fit.minX) / fit.width let v = (p.y - videoMinYTop) / fit.height guard u >= 0, u <= 1, v >= 0, v <= 1 else { return nil } // letterbox bars - let hx = Int32((u * CGFloat(mode.width)).rounded().clamped(0, CGFloat(mode.width - 1))) - let hy = Int32((v * CGFloat(mode.height)).rounded().clamped(0, CGFloat(mode.height - 1))) + let hx = Int32((u * CGFloat(mode.width)).rounded() + .clamped(to: 0...CGFloat(mode.width - 1))) + let hy = Int32((v * CGFloat(mode.height)).rounded() + .clamped(to: 0...CGFloat(mode.height - 1))) return HostPoint(x: hx, y: hy, w: mode.width, h: mode.height) } @@ -507,10 +513,10 @@ public final class StreamLayerView: NSView { DispatchQueue.main.async { onCaptureChange(captured) } } - // MARK: - Pump + // MARK: - Session start/stop - /// Pump thread: pull AUs from the connection, wrap, enqueue. The first IDR yields the - /// format description; non-IDR AUs before it are dropped (the host opens with an IDR). + /// Wire up input capture and start the presenter (see SessionPresenter for the + /// stage-2/stage-1 choice). `onFrame` fires per AU at receipt; `onSessionEnd` on close. public func start( connection: PunktfunkConnection, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, @@ -558,90 +564,31 @@ public final class StreamLayerView: NSView { cursorVisible = false _ = connection.resolvedCompositor // (was: Auto → gamescope; kept to document intent) - // Presenter choice — stage-2 is the DEFAULT (explicit VTDecompressionSession decode + a - // CAMetalLayer/display-link present): it can detect + recover a wedged decoder where - // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference. Stage-1 is - // reachable only via the DEBUG presenter toggle; release always takes stage-2 (the stage-1 - // pump below stays the automatic fallback if Metal is missing). - #if DEBUG - let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1" - #else - let forceStage1 = false - #endif - if !forceStage1, - let meter = presentMeter, - let pipeline = Stage2Pipeline(presentMeter: meter) { - startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) - } else { - let pump = StreamPump() - pump.start( - connection: connection, layer: displayLayer, - onFrame: onFrame, onSessionEnd: onSessionEnd) - self.pump = pump - } + // Presenter choice + lifecycle live in SessionPresenter (shared with iOS/tvOS): stage-2 + // (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by + // default, the stage-1 pump as the Metal-missing / DEBUG fallback. The link comes from + // NSView.displayLink so it tracks the display this view is on. + presenter.start( + connection: connection, + baseLayer: displayLayer, + presentMeter: presentMeter, + presentTailMeter: presentTailMeter, + makeDisplayLink: { displayLink(target: $0, selector: $1) }, + onFrame: onFrame, + onSessionEnd: onSessionEnd) + layoutPresenter() requestAutoCapture() // entering a session is the deliberate "capture me" moment } - // MARK: - Stage-2 presenter (VTDecompressionSession → CAMetalLayer + display link) - - private func startStage2( - _ pipeline: Stage2Pipeline, connection: PunktfunkConnection, - onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)? - ) { - let metal = pipeline.layer - // 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 - stage2 = pipeline - layoutMetalLayer() - // 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) - stage2Link = link - pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) - } - - private func stage2Tick(_ link: CADisplayLink) { - stage2?.renderTick( - targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp)) - } - - /// Position the metal sublayer aspect-fit in the view (the host streams at the client's native - /// mode, so this is usually the full bounds; it letterboxes a resized window). Only the layer - /// FRAME is set here — the presenter sizes the drawable to the decoded frame and the layer's - /// contentsGravity (.resizeAspect) scales it to this frame via the system compositor, so a - /// resized window rescales through the system's filter (matching stage-1) instead of the shader. - private func layoutMetalLayer() { - guard let metalLayer, let connection else { return } - let mode = connection.currentMode() - let fit: NSRect = (mode.width > 0 && mode.height > 0) - ? AVMakeRect( - aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), - insideRect: bounds) - : bounds - // No implicit resize animation; refresh contentsScale on a retina↔non-retina move. - CATransaction.begin() - CATransaction.setDisableActions(true) - metalLayer.contentsScale = window?.backingScaleFactor ?? 1 - metalLayer.frame = fit - CATransaction.commit() + /// Aspect-fit the stage-2 metal sublayer to the view; refresh contentsScale on a + /// retina↔non-retina move (see SessionPresenter.layout). + private func layoutPresenter() { + presenter.layout(in: bounds, contentsScale: window?.backingScaleFactor ?? 1) } public override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() - layoutMetalLayer() // backing scale changed (e.g. moved to a non-retina display) - } - - private func teardownStage2() { - stage2Link?.invalidate() - stage2Link = nil - stage2?.stop() // stops the pump (synchronous join) + drops the decode session - stage2 = nil - metalLayer?.removeFromSuperlayer() - metalLayer = nil + layoutPresenter() // backing scale changed (e.g. moved to a non-retina display) } /// Stop pumping (≤ one poll timeout). Does not close the connection — that stays with @@ -651,9 +598,7 @@ public final class StreamLayerView: NSView { removeMouseMonitor() // belt-and-suspenders: releaseCapture no-ops if not captured inputCapture?.stop() inputCapture = nil - pump?.stop() - pump = nil - teardownStage2() + presenter.stop() connection = nil } @@ -661,16 +606,7 @@ public final class StreamLayerView: NSView { removeMouseMonitor() appObservers.forEach(NotificationCenter.default.removeObserver(_:)) windowObservers.forEach(NotificationCenter.default.removeObserver(_:)) - pump?.stop() - teardownStage2() // invalidate the display link + stop the pipeline if stop() was missed - } -} - -extension CGFloat { - /// Clamp into a [lo, hi] range — keeps the absolute-cursor mapping inside the host's - /// pixel bounds even if a stray event reports a point a hair past the video rect. - fileprivate func clamped(_ lo: CGFloat, _ hi: CGFloat) -> CGFloat { - Swift.min(Swift.max(self, lo), hi) + presenter.stop() // invalidate the display link + stop the pipeline if stop() was missed } } #endif diff --git a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift similarity index 86% rename from clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift rename to clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 1ceb8f0..fc57b67 100644 --- a/clients/apple/Sources/PunktfunkKit/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -51,6 +51,7 @@ public struct StreamView: UIViewControllerRepresentable { private let onFrame: (@Sendable (AccessUnit) -> Void)? private let onSessionEnd: (@Sendable () -> Void)? private let presentMeter: LatencyMeter? + private let presentTailMeter: LatencyMeter? public init( connection: PunktfunkConnection, @@ -58,7 +59,8 @@ public struct StreamView: UIViewControllerRepresentable { onCaptureChange: ((Bool) -> Void)? = nil, onFrame: (@Sendable (AccessUnit) -> Void)? = nil, onSessionEnd: (@Sendable () -> Void)? = nil, - presentMeter: LatencyMeter? = nil + presentMeter: LatencyMeter? = nil, + presentTailMeter: LatencyMeter? = nil ) { self.connection = connection self.captureEnabled = captureEnabled @@ -66,6 +68,7 @@ public struct StreamView: UIViewControllerRepresentable { self.onFrame = onFrame self.onSessionEnd = onSessionEnd self.presentMeter = presentMeter + self.presentTailMeter = presentTailMeter } public func makeUIViewController(context: Context) -> StreamViewController { @@ -73,6 +76,7 @@ public struct StreamView: UIViewControllerRepresentable { controller.onCaptureChange = onCaptureChange controller.captureEnabled = captureEnabled controller.presentMeter = presentMeter + controller.presentTailMeter = presentTailMeter controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) return controller } @@ -81,6 +85,7 @@ public struct StreamView: UIViewControllerRepresentable { controller.onCaptureChange = onCaptureChange controller.captureEnabled = captureEnabled controller.presentMeter = presentMeter + controller.presentTailMeter = presentTailMeter if controller.connection !== connection { controller.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) } @@ -95,14 +100,14 @@ public struct StreamView: UIViewControllerRepresentable { public final class StreamViewController: UIViewController { public private(set) var connection: PunktfunkConnection? - private var pump: StreamPump? private var observers: [NSObjectProtocol] = [] - /// Stage-2 presenter (default): a CAMetalLayer sublayer driven by a CADisplayLink instead of the - /// StreamPump → displayLayer path. nil = stage-1 (Metal-unavailable fallback / DEBUG toggle). + /// Record capture→present / decode→present when the stage-2 presenter is active. + /// Consulted at start(). var presentMeter: LatencyMeter? - private var stage2: Stage2Pipeline? - private var stage2Link: CADisplayLink? - private var metalLayer: CAMetalLayer? + var presentTailMeter: LatencyMeter? + /// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the + /// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback. + private let presenter = SessionPresenter() #if os(iOS) private var inputCapture: InputCapture? fileprivate var captured = false @@ -274,27 +279,18 @@ public final class StreamViewController: UIViewController { inputCapture = capture #endif - // Presenter choice — stage-2 is the DEFAULT (VTDecompressionSession decode + a - // CAMetalLayer/display-link present): it can detect + recover a wedged decoder, where - // stage-1's AVSampleBufferDisplayLayer freezes hard on a lost HEVC reference frame with no - // way to recover. Stage-1 is reachable only via the DEBUG presenter toggle; release always - // takes stage-2 (the stage-1 pump below stays the automatic fallback if Metal is missing). - #if DEBUG - let forceStage1 = UserDefaults.standard.string(forKey: DefaultsKey.presenter) == "stage1" - #else - let forceStage1 = false - #endif - if !forceStage1, - let meter = presentMeter, - let pipeline = Stage2Pipeline(presentMeter: meter) { - startStage2(pipeline, connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) - } else { - let pump = StreamPump() - pump.start( - connection: connection, layer: streamView.displayLayer, - onFrame: onFrame, onSessionEnd: onSessionEnd) - self.pump = pump - } + // Presenter choice + lifecycle live in SessionPresenter (shared with macOS): stage-2 + // (explicit VTDecompressionSession decode + a CAMetalLayer/display-link present) by + // default, the stage-1 pump as the Metal-missing / DEBUG fallback. + presenter.start( + connection: connection, + baseLayer: streamView.displayLayer, + presentMeter: presentMeter, + presentTailMeter: presentTailMeter, + makeDisplayLink: { CADisplayLink(target: $0, selector: $1) }, + onFrame: onFrame, + onSessionEnd: onSessionEnd) + layoutMetalLayer() #if os(iOS) // GC only delivers while active; everything held is flushed by InputCapture's @@ -349,39 +345,10 @@ public final class StreamViewController: UIViewController { streamView.onScroll = nil streamView.currentHostMode = nil #endif - pump?.stop() - pump = nil - teardownStage2() + presenter.stop() connection = nil } - // MARK: - Stage-2 presenter (VTDecompressionSession → CAMetalLayer + display link) - - private func startStage2( - _ pipeline: Stage2Pipeline, connection: PunktfunkConnection, - onFrame: (@Sendable (AccessUnit) -> Void)?, onSessionEnd: (@Sendable () -> Void)? - ) { - let metal = pipeline.layer - // Composites OVER the idle (un-enqueued in stage-2) AVSampleBufferDisplayLayer base. - // (contentsScale + frame are set by layoutMetalLayer() just below.) - streamView.layer.addSublayer(metal) - metalLayer = metal - stage2 = pipeline - layoutMetalLayer() - // 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) - stage2Link = link - pipeline.start(connection: connection, onFrame: onFrame, onSessionEnd: onSessionEnd) - } - - private func stage2Tick(_ link: CADisplayLink) { - stage2?.renderTick( - targetPresentNs: Stage2Pipeline.realtimeNs(forDisplayLinkTimestamp: link.targetTimestamp)) - } - public override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() layoutMetalLayer() @@ -397,40 +364,16 @@ public final class StreamViewController: UIViewController { return s > 0 ? s : UIScreen.main.scale } - /// Position the metal sublayer aspect-fit in the view (the host streams at the client's native - /// mode, so this is usually the full bounds). Only the layer FRAME is set here — the presenter - /// sizes the drawable to the decoded frame and the layer's contentsGravity (.resizeAspect) - /// scales it to this frame via the system compositor (matching stage-1's videoGravity). + /// Aspect-fit the stage-2 metal sublayer to the view at the canonical render scale + /// (see SessionPresenter.layout). private func layoutMetalLayer() { - guard let metalLayer, let connection else { return } - let mode = connection.currentMode() - let bounds = streamView.bounds - let fit: CGRect = (mode.width > 0 && mode.height > 0) - ? AVMakeRect( - aspectRatio: CGSize(width: Int(mode.width), height: Int(mode.height)), - insideRect: bounds) - : bounds - CATransaction.begin() - CATransaction.setDisableActions(true) // don't animate the resize - metalLayer.contentsScale = renderScale - metalLayer.frame = fit - CATransaction.commit() - } - - private func teardownStage2() { - stage2Link?.invalidate() - stage2Link = nil - stage2?.stop() // stops the pump (synchronous join) + drops the decode session - stage2 = nil - metalLayer?.removeFromSuperlayer() - metalLayer = nil + presenter.layout(in: streamView.bounds, contentsScale: renderScale) } #if os(iOS) private func setCaptured(_ on: Bool) { if on { - // `connection != nil` (not `pump`) is the session-active gate — the stage-2 presenter - // runs without a StreamPump. + // `connection != nil` is the session-active gate (presenter internals are opaque here). guard captureEnabled, !captured, connection != nil else { return } inputCapture?.setForwarding(true) captured = true @@ -476,8 +419,7 @@ public final class StreamViewController: UIViewController { deinit { observers.forEach(NotificationCenter.default.removeObserver(_:)) - pump?.stop() - teardownStage2() // invalidate the display link + stop the pipeline if stop() was missed + presenter.stop() // invalidate the display link + stop the pipeline if stop() was missed } } @@ -675,12 +617,4 @@ final class StreamLayerUIView: UIView { } #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/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift index 9cec9a8..5d22c93 100644 --- a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift @@ -131,40 +131,49 @@ final class LoopbackIntegrationTests: XCTestCase { } /// The PIN pairing ceremony + the --require-pairing gate through the Swift wrapper: - /// anonymous rejection, the single wrong-PIN online guess, the real ceremony, and a - /// paired + pinned session. Driven by test-loopback.sh, which arms a second host with - /// --require-pairing and parses its random PIN out of the log. + /// no session while unpaired, the single wrong-PIN online guess, the real ceremony, and a + /// paired + pinned session. Driven by test-loopback.sh, which arms TWO --require-pairing + /// hosts and parses their random PINs out of the logs: a pairing attempt — right or wrong — + /// consumes the host's one-shot arming window (SPAKE2's "one online guess"), so the wrong-PIN + /// assertion burns the GUESS host's window and the real ceremony runs against the PAIRING + /// host's untouched one. func testPairingCeremonyAndRequirePairingGate() throws { let env = ProcessInfo.processInfo.environment guard let portStr = env["PUNKTFUNK_PAIRING_PORT"], let port = UInt16(portStr), - let pin = env["PUNKTFUNK_PAIRING_PIN"] + let pin = env["PUNKTFUNK_PAIRING_PIN"], + let guessPortStr = env["PUNKTFUNK_GUESS_PORT"], let guessPort = UInt16(guessPortStr), + let guessPin = env["PUNKTFUNK_GUESS_PIN"] else { - throw XCTSkip("needs an armed punktfunk1-host — use clients/apple/test-loopback.sh") + throw XCTSkip("needs armed punktfunk1-hosts — use clients/apple/test-loopback.sh") } let identity = try generateIdentity() - // 1. Unpaired clients don't get sessions from a --require-pairing host. + // 1. Unpaired clients don't get sessions from a require-pairing host. The host PARKS the + // identified knock for delegated console approval (§8b-1) rather than rejecting it + // outright — nobody approves here, so the connect times out client-side. Either way: + // no session while unpaired. XCTAssertThrowsError( try PunktfunkConnection( host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60, identity: identity, timeoutMs: 5000), - "unpaired client must be rejected") + "unpaired client must not get a session") - // 2. A wrong PIN is exactly one failed online guess — distinguishable from - // transport errors so the UI can say "try again". + // 2. A wrong PIN is exactly one failed online guess — distinguishable from transport + // errors so the UI can say "try again". The attempt consumes the GUESS host's arming + // window (that is the point of the one-guess design), which is why it gets its own host. XCTAssertThrowsError( try pair( - host: "127.0.0.1", port: port, identity: identity, - pin: pin == "0000" ? "9999" : "0000", name: "wrong-pin", timeoutMs: 5000) + host: "127.0.0.1", port: guessPort, identity: identity, + pin: guessPin == "0000" ? "9999" : "0000", name: "wrong-pin", timeoutMs: 5000) ) { error in guard case PunktfunkClientError.wrongPIN = error else { return XCTFail("expected .wrongPIN, got \(error)") } } - // 3. The real ceremony (after the host's 2 s pairing cooldown). - Thread.sleep(forTimeInterval: 2.2) + // 3. The real ceremony — the PAIRING host's first attempt, so neither its one-shot window + // nor the per-host pairing cooldown has been touched. let fingerprint = try pair( host: "127.0.0.1", port: port, identity: identity, pin: pin, name: "loopback-test", timeoutMs: 5000) diff --git a/clients/apple/test-loopback.sh b/clients/apple/test-loopback.sh index ac444bb..b4adcbd 100755 --- a/clients/apple/test-loopback.sh +++ b/clients/apple/test-loopback.sh @@ -1,47 +1,64 @@ #!/usr/bin/env bash # Loopback integration: real punktfunk/1 hosts (synthetic source — pure protocol, runs fine on # macOS) on 127.0.0.1, then the Swift integration tests against them through the xcframework. -# Two hosts: an open one (stream round trip) and one armed with --require-pairing (the PIN -# ceremony + pairing gate — its random PIN is parsed out of its log). +# Three hosts: an OPEN one (--allow-tofu; the anonymous stream round trip — bare punktfunk1-host +# now defaults to require-pairing), one armed with --require-pairing (the PIN ceremony + pairing +# gate — its random PIN is parsed out of its log), and a GUESS host whose one-shot arming window +# the wrong-PIN test deliberately burns (a pairing attempt — right or wrong — consumes the armed +# PIN, the SPAKE2 "one online guess", so the real ceremony needs a window of its own). set -euo pipefail cd "$(dirname "$0")/../.." PORT="${PUNKTFUNK_LOOPBACK_PORT:-19778}" PAIR_PORT="${PUNKTFUNK_PAIRING_PORT:-19779}" +GUESS_PORT="${PUNKTFUNK_GUESS_PORT:-19780}" cargo build --release -p punktfunk-host -# Each host gets a throwaway config home: the pairing host persists a trust store -# (punktfunk1-paired.json, resolved from $HOME) and both mint an identity cert on first +# Each host gets a throwaway config home: the pairing hosts persist a trust store +# (punktfunk1-paired.json, resolved from $HOME) and all mint an identity cert on first # run — none of that belongs in the user's real ~/.config/punktfunk, and separate homes -# also keep the two first runs from racing on the same cert.pem. +# also keep the first runs from racing on the same cert.pem. CFG="$(mktemp -d "${TMPDIR:-/tmp}/punktfunk-loopback.XXXXXX")" PAIR_LOG="$CFG/pairing-host.log" -mkdir -p "$CFG/open" "$CFG/paired" -trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" 2>/dev/null || true' EXIT +GUESS_LOG="$CFG/guess-host.log" +mkdir -p "$CFG/open" "$CFG/paired" "$CFG/guess" +trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" "${GUESS_PID:-}" 2>/dev/null || true' EXIT # The open host also scripts a feedback burst (rumble + DualSense hidout) right after the # handshake, so the Swift test can assert the host→client feedback planes end to end. HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" PUNKTFUNK_TEST_FEEDBACK=1 \ - target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 & + target/release/punktfunk-host punktfunk1-host --port "$PORT" --source synthetic --frames 300 \ + --allow-tofu & HOST_PID=$! HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \ target/release/punktfunk-host punktfunk1-host --port "$PAIR_PORT" --source synthetic --frames 300 \ --require-pairing >"$PAIR_LOG" 2>&1 & PAIR_PID=$! +HOME="$CFG/guess" XDG_CONFIG_HOME="$CFG/guess/.config" \ + target/release/punktfunk-host punktfunk1-host --port "$GUESS_PORT" --source synthetic --frames 300 \ + --require-pairing >"$GUESS_LOG" 2>&1 & +GUESS_PID=$! sleep 1 -PIN="" -for _ in $(seq 50); do - PIN="$(grep -oE 'pair: [0-9]+' "$PAIR_LOG" | head -1 | cut -d' ' -f2 || true)" - [ -n "$PIN" ] && break - sleep 0.2 -done -if [ -z "$PIN" ]; then - echo "no arming PIN in the pairing host's log ($PAIR_LOG)" >&2 - exit 1 -fi +# Parse each pairing host's random arming PIN out of its startup log. +pin_from_log() { + local log="$1" pin="" + for _ in $(seq 50); do + pin="$(grep -oE 'pair: [0-9]+' "$log" | head -1 | cut -d' ' -f2 || true)" + [ -n "$pin" ] && break + sleep 0.2 + done + if [ -z "$pin" ]; then + echo "no arming PIN in the pairing host's log ($log)" >&2 + exit 1 + fi + echo "$pin" +} +PIN="$(pin_from_log "$PAIR_LOG")" +GUESS_PIN="$(pin_from_log "$GUESS_LOG")" cd clients/apple PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \ + PUNKTFUNK_GUESS_PORT="$GUESS_PORT" PUNKTFUNK_GUESS_PIN="$GUESS_PIN" \ PUNKTFUNK_TEST_FEEDBACK=1 \ swift test --filter LoopbackIntegrationTests diff --git a/crates/punktfunk-host/src/encode.rs b/crates/punktfunk-host/src/encode.rs index 913099b..a011a61 100644 --- a/crates/punktfunk-host/src/encode.rs +++ b/crates/punktfunk-host/src/encode.rs @@ -623,6 +623,13 @@ pub fn can_encode_444(codec: Codec) -> bool { }) } +/// Non-Linux/Windows (the macOS dev/test build of the host — synthetic-source loopback only): +/// no GPU encode backend exists here, so 4:4:4 is never advertised. +#[cfg(not(any(target_os = "linux", target_os = "windows")))] +pub fn can_encode_444(_codec: Codec) -> bool { + false +} + // --------------------------------------------------------------------------------------------- // Windows backend selection (the analogue of the Linux nvidia_present / linux_zero_copy_is_vaapi // logic). NVIDIA → NVENC, AMD → AMF, Intel → QSV; `auto` (default) reads the DXGI adapter vendor. diff --git a/design/apple-stage2-presenter.md b/design/apple-stage2-presenter.md index e84ad2c..505e1a2 100644 --- a/design/apple-stage2-presenter.md +++ b/design/apple-stage2-presenter.md @@ -6,7 +6,7 @@ description: "Design rationale + open items for the explicit VTDecompressionSess > **Status:** SHIPPED as the **default** presenter (stage-1 `AVSampleBufferDisplayLayer` is the > Metal-unavailable / DEBUG fallback). HDR corrected and **4:4:4** added on top of the proven > main-thread present path (the hosting view's `CADisplayLink` drives `render` per vsync). Code: -> `clients/apple/Sources/PunktfunkKit/{Stage2Pipeline,MetalVideoPresenter,VideoDecoder,Stage444Probe,LatencyMeter}.swift`. +> `clients/apple/Sources/PunktfunkKit/Video/{Stage2Pipeline,MetalVideoPresenter,VideoDecoder,SessionPresenter,Stage444Probe,LatencyMeter}.swift`. > This doc is trimmed to design rationale + open items — the shipped `.swift` code is the source of > truth for the decode/present/measurement walkthrough. > @@ -34,17 +34,29 @@ description: "Design rationale + open items for the explicit VTDecompressionSess > metadata when it goes HDR. A ≤2-frame transition flash on the rare flip is accepted. > > **Pacing.** The hosting view owns a **main-runloop `CADisplayLink`** (a weak `DisplayLinkProxy` -> breaks the retain cycle) that calls `renderTick` once per vsync. `renderTick` pops the **newest** -> ready frame from the 1-slot ring (older undisplayed frames dropped — lowest latency, no smoothing -> buffer) and, if there is one, draws it via **manual `layer.nextDrawable()`** and presents at the next -> vsync; on an idle vsync (no new frame) it does nothing and the compositor holds the last presented +> breaks the retain cycle; the shared per-session lifecycle lives in `SessionPresenter`) that calls +> `renderTick` once per vsync. `renderTick` pops the **newest** ready frame from the 1-slot ring +> (older undisplayed frames dropped — lowest latency, no smoothing buffer) and, if there is one, +> draws it via **manual `layer.nextDrawable()`** and presents at the next vsync; a frame that could +> not be rendered (no drawable yet) is **put back** into the still-empty ring so the next tick +> retries it (under the infinite GOP a static scene sends no replacement — losing the frame would +> freeze stale content). On an idle vsync it does nothing and the compositor holds the last presented > drawable (no idle re-render — matters at 5K). `drawableSize` is set **before** `nextDrawable` (it -> doesn't track bounds, defaults to 0), so allocation always uses the decoded size. `maximumDrawableCount -> = 3`. macOS `displaySyncEnabled = **false**`: the display link is the single pacing source, so leaving -> the layer's own vsync wait on would *also* block `present`/`nextDrawable` on the main thread and +> doesn't track bounds, defaults to 0) to the **layer's pixel size** (bounds × contentsScale), so the +> shader — not the compositor's bilinear — performs the decoded→on-screen scale (bicubic Catmull-Rom +> luma + siting-corrected bilinear chroma); a native-mode session is exactly 1:1 (the kernel reduces +> to the identity texel). `maximumDrawableCount = 3`. On iOS/tvOS `SessionPresenter` sets the link's +> `preferredFrameRateRange` to the negotiated refresh (+ `CADisableMinimumFrameDurationOnPhone` in +> Info.plist) — without it ProMotion devices cap the link at 60 Hz and a 120 fps stream presents at +> half rate; macOS's `NSView.displayLink` already tracks its display and is left alone. macOS +> `displaySyncEnabled = **false**`: the display link is the single pacing source, so leaving the +> layer's own vsync wait on would *also* block `present`/`nextDrawable` on the main thread and > serialize it to the display — the cause of the fullscreen judder; disabling it lets present return -> promptly. Present is stamped at the display link's `targetTimestamp` projected to `CLOCK_REALTIME` -> (the actual on-glass instant, <1 vsync after the draw — accurate for the HUD). +> promptly. Present is stamped at the drawable's **actual `presentedTime`** (`addPresentedHandler`, +> converted to `CLOCK_REALTIME`), falling back to the display link's `targetTimestamp` projection +> when the system reports none (a dropped drawable) — so the HUD numbers reflect glass, and a missed +> vsync shows up instead of being assumed away. The same stamp feeds **decode→present** +> (`presentTailMeter` → the HUD's "decode→present" line), closing the third instant promised below. > > *(History: an off-main `CAMetalDisplayLink` variant and an off-main blocking-render present thread > were both tried and **reverted** — both measured slower on macOS *and* iPad than this main-thread @@ -105,7 +117,14 @@ Async `VTDecompressionSession` callback → **1-slot newest-ready ring** → dis forcing a too-large 4:4:4 mode. - **Glass-to-glass numbers via `tools/latency-probe`** — close the still-unmeasured host render→capture term and confirm the main-thread display-link present p50 holds at ~11 ms (and isn't regressed by the - per-frame `configure` / HDR-anchor work). + per-frame `configure` / HDR-anchor work). The HUD's new decode→present line + the `presentedTime`-based + stamp make the client-side share directly visible now. +- **On-glass validation of the 2026-07 presenter batch** — the shader-side scale (drawable at layer + pixel size; bicubic luma + chroma-siting offset — compare a resized/fullscreen-on-larger-panel + window against stage-1 for sharpness, and check GPU headroom at 5K HDR), the iOS/tvOS + `preferredFrameRateRange` (a 120 fps stream on a ProMotion iPhone/iPad should now present at ~120 — + watch the HUD fps), `kVTDecompressionPropertyKey_RealTime`, and the zero-copy AnnexB → CMBlockBuffer + packing (unit/round-trip tested; confirm live). - **Smoothing / pacing policy** — present newest-ready for lowest latency today; an optional even-pacing policy (`present(_:afterMinimumDuration:)`) can come later if frames look uneven. - **4:4:4 runtime downgrade-reconnect** — today a persistently-undecodable 4:4:4 session ends cleanly