Files
enricobuehler 6d3ff37d9e 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>
2026-06-12 14:08:19 +02:00

55 lines
2.4 KiB
Swift

// Advertise a fake punktfunk/1 host over real mDNS (NWListener) and assert HostDiscovery's
// NWBrowser finds it, resolves an address+port, and parses the TXT (id / pair / fp). This
// exercises the whole client discovery path on the loopback/LAN; it self-skips if the test
// environment blocks Bonjour (sandboxed CI without local-network access).
import Network
import PunktfunkKit
import XCTest
final class HostDiscoveryTests: XCTestCase {
func testFindsAdvertisedHost() async throws {
let serviceName = "PunktfunkTest-\(UUID().uuidString.prefix(8))"
let uniqueid = "test-\(UUID().uuidString)"
var txt = NWTXTRecord()
txt["proto"] = "punktfunk/1"
txt["fp"] = String(repeating: "ab", count: 32) // 64 hex chars, like a real cert SHA-256
txt["pair"] = "required"
txt["id"] = uniqueid
let listener = try NWListener(using: .udp)
listener.service = NWListener.Service(
name: String(serviceName), type: "_punktfunk._udp", txtRecord: txt)
// The resolver opens a throwaway UDP flow to read the resolved endpoint accept and
// drop it so it doesn't linger.
listener.newConnectionHandler = { connection in connection.cancel() }
listener.start(queue: .global())
defer { listener.cancel() }
let discovery = await HostDiscovery()
await discovery.start()
defer { Task { await discovery.stop() } }
// Poll up to ~10s for the advert to be browsed AND resolved.
var found: DiscoveredHost?
let deadline = Date().addingTimeInterval(10)
while Date() < deadline {
if let host = await discovery.hosts.first(where: { $0.name == String(serviceName) }) {
found = host
break
}
try await Task.sleep(nanoseconds: 200_000_000)
}
guard let host = found else {
throw XCTSkip("mDNS discovery unavailable in this environment (no local network).")
}
XCTAssertEqual(host.id, uniqueid, "the stable mDNS id should key the host")
XCTAssertTrue(host.requiresPairing, "pair=required must surface as requiresPairing")
XCTAssertEqual(host.fingerprintHex, String(repeating: "ab", count: 32))
XCTAssertFalse(host.host.isEmpty, "a resolved address is required to connect")
XCTAssertGreaterThan(host.port, 0, "a resolved port is required to connect")
}
}