Files
punktfunk/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift
T
enricobuehler 3678c182d5
apple / swift (push) Failing after 52s
windows-drivers / probe-and-proto (push) Successful in 50s
windows-drivers / driver-build (push) Successful in 1m20s
android / android (push) Failing after 2m55s
ci / web (push) Successful in 1m5s
release / apple (push) Successful in 3m38s
apple / screenshots (push) Has been skipped
ci / rust (push) Successful in 4m47s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 2m49s
decky / build-publish (push) Successful in 21s
windows-host / package (push) Successful in 7m35s
ci / bench (push) Successful in 5m10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m22s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m59s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
flatpak / build-publish (push) Successful in 4m20s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m58s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m45s
feat(clients): codec preference on Windows/Apple/Android clients (Phase 2b)
Rounds out codec negotiation across the last three clients — each advertises what it can decode,
builds its decoder from the resolved Welcome.codec, and exposes a "Video codec" preference picker.

**Windows** (Rust, mirrors Linux): `decodable_codecs()` + `ffmpeg_codec_id()`; the D3D11VA and
software FFmpeg decoders (and the mid-session D3D11VA→software demotion) open the negotiated codec
instead of hardcoding HEVC; settings gain a `codec` field + reactor ComboBox; `--codec` CLI flag.

**Apple** (Swift/C-ABI): AnnexB is now codec-aware — a `VideoCodec` enum drives H.264 vs HEVC NAL
parsing / parameter-set extraction (`CMVideoFormatDescriptionCreateFromH264ParameterSets` for H.264,
no VPS) and AVCC repacking; `PunktfunkConnection` advertises H264|HEVC via `punktfunk_connect_ex7`,
reads `resolvedCodec` (`punktfunk_connection_codec`), and threads `videoCodec` into the stage-1/2
pipelines + `VideoDecoder`; SettingsView "Video codec" Picker (auto/HEVC/H.264). AV1 is left out
(hosts don't emit it on the native path, and it's not an AnnexB codec). Test call sites updated.

**Android** (Kotlin + Rust JNI): the JNI `nativeConnect` gains `preferredCodec`; the native decode
loop picks the AMediaCodec MIME (`video/hevc`|`video/avc`) from `connector.codec` and advertises
H264|HEVC; Settings `codec` field + Compose dropdown.

Core/host/probe/Linux clippy + tests green (unchanged from 2a). Windows/Apple/Android compile on
their platform CI (this Linux box can't build them — Windows toolchain / Xcode / the Android NDK's
opus-cmake toolchain). All follow the Linux client's validated pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 00:29:38 +00:00

176 lines
8.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// First light, headless: the full client pipeline against a REAL remote host QUIC
// handshake over the LAN, NVENC HEVC AUs through FEC + AES-GCM, AnnexB conversion, and a
// real VTDecompressionSession turning them into pixels. Everything the GUI does except
// putting the layer on glass.
//
// Run (host side, on the Linux box):
// PUNKTFUNK_COMPOSITOR=gamescope PUNKTFUNK_GAMESCOPE_APP=vkcube PUNKTFUNK_ZEROCOPY=1 \
// punktfunk-host punktfunk1-host --source virtual --seconds 120
// Then here:
// PUNKTFUNK_REMOTE_HOST=192.168.1.70 swift test --filter RemoteFirstLightTests
import AVFoundation
import CoreMedia
import VideoToolbox
import XCTest
@testable import PunktfunkKit
final class RemoteFirstLightTests: XCTestCase {
/// The pairing ceremony over the real LAN, exactly as the app runs it: fresh identity,
/// SPAKE2 with the host's arming PIN, then a pinned + identified session. Needs the
/// host armed (--allow-pairing) and its logged PIN in PUNKTFUNK_REMOTE_PIN. Heads-up:
/// every run durably adds one throwaway "remote-test" identity to the host's
/// ~/.config/punktfunk/punktfunk1-paired.json prune those entries at will.
func testRemotePairingThenPinnedStream() throws {
let env = ProcessInfo.processInfo.environment
guard let host = env["PUNKTFUNK_REMOTE_HOST"], let pin = env["PUNKTFUNK_REMOTE_PIN"]
else {
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST + PUNKTFUNK_REMOTE_PIN "
+ "(host armed with --allow-pairing)")
}
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
let identity = try generateIdentity()
let fingerprint = try pair(
host: host, port: port, identity: identity, pin: pin, name: "remote-test")
XCTAssertEqual(fingerprint.count, 32)
let conn = try PunktfunkConnection(
host: host, port: port, width: 1280, height: 720, refreshHz: 60,
pinSHA256: fingerprint, identity: identity)
defer { conn.close() }
XCTAssertEqual(conn.hostFingerprint, fingerprint)
var got = 0
let deadline = Date().addingTimeInterval(20)
while got < 10, Date() < deadline {
if try conn.nextAU(timeoutMs: 2000) != nil { got += 1 }
}
XCTAssertGreaterThanOrEqual(got, 10, "paired + pinned session must stream")
}
/// Audio both ways against the real host: drain the Opus plane and decode it to PCM
/// (host speaker path minus the speaker), and uplink an encoded tone (mic path
/// minus the mic) the host logs "punktfunk/1 virtual mic ready" on first frame.
func testRemoteAudioBothDirections() throws {
let env = ProcessInfo.processInfo.environment
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
}
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
let conn = try PunktfunkConnection(
host: host, port: port, width: 1280, height: 720, refreshHz: 60)
defer { conn.close() }
// Mic uplink: 2 s of 440 Hz tone (the host's mic service opens its virtual
// source on the first frame check its log).
let encoder = try OpusEncoder()
let chunk = AVAudioPCMBuffer(
pcmFormat: encoder.pcmFormat, frameCapacity: OpusEncoder.framesPerPacket)!
var phase: Float = 0
let step = 2 * Float.pi * 440 / 48_000
var seq: UInt32 = 0
for _ in 0..<100 {
chunk.frameLength = OpusEncoder.framesPerPacket
let p = chunk.floatChannelData![0]
for f in 0..<Int(OpusEncoder.framesPerPacket) {
let s = sin(phase) * 0.25
phase += step
p[f * 2] = s
p[f * 2 + 1] = s
}
for packet in try encoder.encode(chunk) {
conn.sendMic(packet, seq: seq, ptsNs: UInt64(seq) * 20_000_000)
seq &+= 1
}
}
XCTAssertGreaterThanOrEqual(seq, 95, "mic encoder must emit ~one packet per chunk")
// Downlink: pull host audio packets and decode them (the host streams its sink
// monitor silence still produces packets).
let decoder = try OpusDecoder(framesPerPacket: 240)
let pcm = AVAudioPCMBuffer(pcmFormat: decoder.pcmFormat, frameCapacity: 5760)!
var packets = 0
var decodedFrames = 0
let deadline = Date().addingTimeInterval(10)
while packets < 100, Date() < deadline {
guard let pkt = try conn.nextAudio(timeoutMs: 1000) else { continue }
packets += 1
decodedFrames += Int(try decoder.decode(pkt.data, into: pcm))
}
XCTAssertGreaterThanOrEqual(packets, 100, "host audio plane must deliver")
// 100 packets × 5 ms × 48 kHz = 24000 frames.
XCTAssertGreaterThan(decodedFrames, 20_000, "host packets must decode to PCM")
}
func testRemoteStreamDecodesToPixels() throws {
let env = ProcessInfo.processInfo.environment
guard let host = env["PUNKTFUNK_REMOTE_HOST"] else {
throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start punktfunk1-host --source virtual there)")
}
let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777
// PUNKTFUNK_REMOTE_COMPOSITOR=kwin|gamescope| asks the host for a specific
// backend (verify in its log: "punktfunk/1 virtual display compositor=").
let compositor = env["PUNKTFUNK_REMOTE_COMPOSITOR"]
.flatMap(PunktfunkConnection.Compositor.init(name:)) ?? .auto
let width: UInt32 = 1280
let height: UInt32 = 720
let conn = try PunktfunkConnection(
host: host, port: port, width: width, height: height, refreshHz: 60,
compositor: compositor)
defer { conn.close() }
XCTAssertEqual(conn.width, width)
XCTAssertEqual(conn.height, height)
var format: CMVideoFormatDescription?
var decoder: VTDecompressionSession?
defer { decoder.map { VTDecompressionSessionInvalidate($0) } }
var received = 0
var decoded = 0
var firstPtsNs: UInt64 = 0
var lastPtsNs: UInt64 = 0
let deadline = Date().addingTimeInterval(30)
while decoded < 60, Date() < deadline {
guard let au = try conn.nextAU(timeoutMs: 2000) else { continue }
received += 1
if firstPtsNs == 0 { firstPtsNs = au.ptsNs }
lastPtsNs = au.ptsNs
if let f = AnnexB.formatDescription(fromIDR: au.data, codec: .hevc) {
format = f
if decoder == nil {
let dims = CMVideoFormatDescriptionGetDimensions(f)
XCTAssertEqual(UInt32(dims.width), width)
XCTAssertEqual(UInt32(dims.height), height)
var session: VTDecompressionSession?
XCTAssertEqual(
VTDecompressionSessionCreate(
allocator: nil, formatDescription: f, decoderSpecification: nil,
imageBufferAttributes: nil, outputCallback: nil,
decompressionSessionOut: &session),
noErr)
decoder = session
}
}
guard let f = format, let dec = decoder,
let sample = AnnexB.sampleBuffer(au: au, format: f, codec: .hevc)
else { continue }
var gotPixels = false
VTDecompressionSessionDecodeFrame(
dec, sampleBuffer: sample, flags: [], infoFlagsOut: nil
) { status, _, imageBuffer, _, _ in
gotPixels = status == noErr && imageBuffer != nil
}
if gotPixels { decoded += 1 }
}
XCTAssertGreaterThanOrEqual(decoded, 60, "decoded \(decoded)/\(received) received AUs")
// The host stamps pts with its capture wall clock 60 frames should span ~1 s.
let spanMs = Double(lastPtsNs &- firstPtsNs) / 1_000_000
print("first light: \(decoded) frames decoded, \(received) received, pts span \(Int(spanMs)) ms")
}
}