feat(client): cross-target input handling + LAN mDNS discovery
Input handling, building on macOS/iOS/tvOS: - macOS recapture after navigating out: engageCapture no longer latches captured=true when the cursor grab is refused mid app-activation (which left a free cursor that no later click could re-grab); cursorCapture.capture() now reports success. + canBecomeKeyView. - iOS/iPadOS recapture: restore the prior capture on didBecomeActive (nothing re-grabbed mouse/keyboard on return before). - iPad indirect pointer (no lock) is forwarded as an absolute MOUSE (move + buttons + scroll via hover / UITouch.indirectPointer), not as touch, with the local cursor visible; GCMouse owns the locked regime, gated so the two never double-send. Adds the MouseMoveAbs wire helper. - Trackpad scroll on iOS (was entirely missing): GCMouse scroll dpad when locked + a scroll-only UIPanGestureRecognizer otherwise. - tvOS: no focusable control during play (a focusable Disconnect button ate the controller's A in the focus engine); Siri Remote Menu disconnects. - Don't leak touch to the host under the TOFU trust prompt (gate on captureEnabled). LAN discovery: HostDiscovery (NWBrowser over _punktfunk._udp, the host's crate::discovery advert) resolves each service to IP:port and parses the TXT (fp advisory, pair, id); an "On this network" section in the grid (tap to save + connect, or pair if required). iOS/tvOS get NSBonjourServices via a merged Config/Info.plist. Integration-tested end to end against a fake NWListener advert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,13 +20,16 @@ import SwiftUINavigationTransitions
|
||||
struct ContentView: View {
|
||||
@StateObject private var model = SessionModel()
|
||||
@StateObject private var store = HostStore()
|
||||
@StateObject private var discovery = HostDiscovery()
|
||||
@AppStorage("punktfunk.width") private var width = 1920
|
||||
@AppStorage("punktfunk.height") private var height = 1080
|
||||
@AppStorage("punktfunk.hz") private var hz = 60
|
||||
@AppStorage("punktfunk.compositor") private var compositor = 0
|
||||
@AppStorage("punktfunk.gamepadType") private var gamepadType = 0
|
||||
@AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0
|
||||
@State private var showAddHost = false
|
||||
@State private var pairingTarget: StoredHost?
|
||||
@State private var speedTestTarget: StoredHost?
|
||||
#if !os(macOS)
|
||||
@State private var showSettings = false
|
||||
#endif
|
||||
@@ -71,6 +74,9 @@ struct ContentView: View {
|
||||
connect(pinned)
|
||||
}
|
||||
}
|
||||
.sheet(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -104,6 +110,11 @@ struct ContentView: View {
|
||||
#else
|
||||
.background(Color.black)
|
||||
.ignoresSafeArea()
|
||||
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
|
||||
// disconnect control during play, the controller's buttons flow to the host instead of
|
||||
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
|
||||
// host as Start — the Siri Remote is the intended disconnect path.
|
||||
.onExitCommand { model.disconnect() }
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -112,16 +123,21 @@ struct ContentView: View {
|
||||
private var home: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if store.hosts.isEmpty {
|
||||
if store.hosts.isEmpty && discoveredUnsaved.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(store.hosts) { host in
|
||||
hostCard(host)
|
||||
if !store.hosts.isEmpty {
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(store.hosts) { host in
|
||||
hostCard(host)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
if !discoveredUnsaved.isEmpty {
|
||||
discoveredSection
|
||||
}
|
||||
.padding()
|
||||
#if os(tvOS)
|
||||
// Actions live below the hosts, not between them.
|
||||
HStack(spacing: 32) {
|
||||
@@ -142,6 +158,10 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Punktfunkempfänger")
|
||||
// Browse the LAN for advertised hosts only while the grid is up — not during a
|
||||
// session. The home appears/disappears as the stream swaps in and out.
|
||||
.onAppear { discovery.start() }
|
||||
.onDisappear { discovery.stop() }
|
||||
#if os(tvOS)
|
||||
// Pushed routes — the Settings-app navigation feel (push animation, Menu
|
||||
// pops) instead of modal overlays.
|
||||
@@ -160,6 +180,9 @@ struct ContentView: View {
|
||||
connect(pinned)
|
||||
}
|
||||
}
|
||||
.navigationDestination(item: $speedTestTarget) { host in
|
||||
SpeedTestSheet(host: host)
|
||||
}
|
||||
#endif
|
||||
#if !os(tvOS)
|
||||
.toolbar {
|
||||
@@ -354,6 +377,10 @@ struct ContentView: View {
|
||||
guard !model.isBusy else { return }
|
||||
pairingTarget = host
|
||||
}
|
||||
Button("Test Network Speed…") {
|
||||
guard !model.isBusy else { return }
|
||||
speedTestTarget = host
|
||||
}
|
||||
if host.pinnedSHA256 != nil {
|
||||
Button("Forget Identity") { store.forgetIdentity(host) }
|
||||
}
|
||||
@@ -394,7 +421,106 @@ struct ContentView: View {
|
||||
rawValue: UInt32(clamping: compositor)) ?? .auto,
|
||||
gamepad: GamepadManager.shared.resolveType(
|
||||
setting: PunktfunkConnection.GamepadType(
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto))
|
||||
rawValue: UInt32(clamping: gamepadType)) ?? .auto),
|
||||
bitrateKbps: UInt32(clamping: bitrateKbps))
|
||||
}
|
||||
|
||||
// MARK: - LAN discovery (mDNS)
|
||||
|
||||
/// Discovered hosts not already saved (matched by address+port) — the saved grid shows
|
||||
/// the rest, so this section only surfaces genuinely-new hosts on the network.
|
||||
private var discoveredUnsaved: [DiscoveredHost] {
|
||||
discovery.hosts.filter { d in
|
||||
!store.hosts.contains { $0.address == d.host && $0.port == d.port }
|
||||
}
|
||||
}
|
||||
|
||||
private var discoveredSection: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("On this network", systemImage: "antenna.radiowaves.left.and.right")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
|
||||
ForEach(discoveredUnsaved) { discoveredCard($0) }
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .bottom])
|
||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||
}
|
||||
|
||||
private func discoveredCard(_ d: DiscoveredHost) -> some View {
|
||||
#if os(iOS)
|
||||
let iconSize: CGFloat = 56
|
||||
let iconBox: CGFloat = 76
|
||||
let cardPadding: CGFloat = 28
|
||||
let nameFont = Font.title3.weight(.semibold)
|
||||
#else
|
||||
let iconSize: CGFloat = 42
|
||||
let iconBox: CGFloat = 56
|
||||
let cardPadding: CGFloat = 18
|
||||
let nameFont = Font.headline
|
||||
#endif
|
||||
return Button {
|
||||
connectDiscovered(d)
|
||||
} label: {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "play.display")
|
||||
.font(.system(size: iconSize, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
.frame(height: iconBox)
|
||||
VStack(spacing: 2) {
|
||||
Text(d.name)
|
||||
.font(nameFont)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: d.requiresPairing ? "lock.fill" : "wifi")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(d.host):\(String(d.port))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(d.requiresPairing ? "Pairing required" : "Discovered")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, cardPadding)
|
||||
.padding(.horizontal, 12)
|
||||
#if !os(tvOS)
|
||||
// A dashed ring distinguishes a not-yet-saved discovered host from saved cards.
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.strokeBorder(
|
||||
Color.secondary.opacity(0.25),
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(tvOS)
|
||||
.buttonStyle(.card)
|
||||
#else
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
.disabled(model.isBusy)
|
||||
}
|
||||
|
||||
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
|
||||
/// persists), then connect — TOFU shows the fingerprint, which should match the advertised
|
||||
/// `fp`. A `pair=required` host goes straight to the pairing ceremony instead.
|
||||
private func connectDiscovered(_ d: DiscoveredHost) {
|
||||
guard !model.isBusy else { return }
|
||||
let host = StoredHost(name: d.name, address: d.host, port: d.port)
|
||||
store.add(host)
|
||||
if d.requiresPairing {
|
||||
pairingTarget = host
|
||||
} else {
|
||||
connect(host)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trust on first use
|
||||
@@ -526,11 +652,18 @@ struct ContentView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
// No focusable control during play: a focusable button steals the controller's
|
||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
||||
Text("Press Menu to disconnect")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
#else
|
||||
Button("Disconnect (⌘D)") { model.disconnect() }
|
||||
.font(.caption)
|
||||
#if !os(tvOS)
|
||||
.keyboardShortcut("d", modifiers: .command)
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
.padding(10)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
||||
@@ -572,12 +705,18 @@ struct ContentView: View {
|
||||
let g = PunktfunkConnection.GamepadType(name: name) {
|
||||
pad = g
|
||||
}
|
||||
var bitrate = UInt32(clamping: bitrateKbps)
|
||||
if let kbps = ProcessInfo.processInfo.environment["PUNKTFUNK_BITRATE_KBPS"],
|
||||
let v = UInt32(kbps) {
|
||||
bitrate = v
|
||||
}
|
||||
model.connect(
|
||||
to: host,
|
||||
width: UInt32(clamping: width), height: UInt32(clamping: height),
|
||||
hz: UInt32(clamping: hz),
|
||||
compositor: pref,
|
||||
gamepad: pad,
|
||||
bitrateKbps: bitrate,
|
||||
autoTrust: true)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user