feat(client/speedtest): request the host's full 3 Gbps probe ceiling

The Apple speed test asked for only 400 Mbps, capping the measured throughput
there and hiding the link's real headroom. Request the host's full
MAX_PROBE_KBPS (3 Gbps) instead, and raise the recommended-bitrate clamp from
500 Mbps to the host's 2 Gbps session ceiling so a fast measurement yields a
usable recommendation.

Also fix the stale caps left when the host clamps were raised (b8a33e2): the
resolved-bitrate range and the probe doc comments (abi.rs, client.rs,
regenerated header), plus the section 9 roadmap copy, now read 3 Gbps probe /
2 Gbps session.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 14:04:49 +02:00
parent 1c94f46be8
commit 6b4de5d738
9 changed files with 467 additions and 18 deletions
@@ -77,6 +77,7 @@ final class SessionModel: ObservableObject {
func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32, func connect(to host: StoredHost, width: UInt32, height: UInt32, hz: UInt32,
compositor: PunktfunkConnection.Compositor = .auto, compositor: PunktfunkConnection.Compositor = .auto,
gamepad: PunktfunkConnection.GamepadType = .auto, gamepad: PunktfunkConnection.GamepadType = .auto,
bitrateKbps: UInt32 = 0,
autoTrust: Bool = false) { autoTrust: Bool = false) {
guard phase == .idle else { return } guard phase == .idle else { return }
phase = .connecting phase = .connecting
@@ -93,7 +94,7 @@ final class SessionModel: ObservableObject {
host: host.address, port: host.port, host: host.address, port: host.port,
width: width, height: height, refreshHz: hz, width: width, height: height, refreshHz: hz,
pinSHA256: pin, identity: identity, compositor: compositor, pinSHA256: pin, identity: identity, compositor: compositor,
gamepad: gamepad) } gamepad: gamepad, bitrateKbps: bitrateKbps) }
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
// The user may have abandoned this attempt (window closed, another host // The user may have abandoned this attempt (window closed, another host
@@ -16,6 +16,7 @@ struct SettingsView: View {
@AppStorage("punktfunk.hz") private var hz = 60 @AppStorage("punktfunk.hz") private var hz = 60
@AppStorage("punktfunk.compositor") private var compositor = 0 @AppStorage("punktfunk.compositor") private var compositor = 0
@AppStorage("punktfunk.gamepadType") private var gamepadType = 0 @AppStorage("punktfunk.gamepadType") private var gamepadType = 0
@AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0
@AppStorage("punktfunk.micEnabled") private var micEnabled = true @AppStorage("punktfunk.micEnabled") private var micEnabled = true
@ObservedObject private var gamepads = GamepadManager.shared @ObservedObject private var gamepads = GamepadManager.shared
#if os(macOS) #if os(macOS)
@@ -77,11 +78,19 @@ struct SettingsView: View {
return ScrollView { return ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
TVSelectionRow(title: "Stream mode", options: options, selection: modeTag) TVSelectionRow(title: "Stream mode", options: options, selection: modeTag)
TVSelectionRow(
title: "Bitrate", options: bitrateOptions, selection: $bitrateKbps)
if bitrateKbps > 1_000_000 {
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
.multilineTextAlignment(.center)
}
TVSelectionRow( TVSelectionRow(
title: "Compositor", options: compositors, selection: $compositor) title: "Compositor", options: compositors, selection: $compositor)
Text("The host creates a virtual output at exactly this mode — native " Text("The host creates a virtual output at exactly this mode — native "
+ "resolution, no scaling. A specific compositor is honored only if " + "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
+ "available on the host.") + "is honored only if available on the host.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -114,6 +123,77 @@ struct SettingsView: View {
} }
#endif #endif
// 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<Bool> {
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<Double> {
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
}
#endif
// MARK: - Controllers // MARK: - Controllers
private static let padTypes: [(label: String, tag: Int)] = [ private static let padTypes: [(label: String, tag: Int)] = [
@@ -200,11 +280,32 @@ struct SettingsView: View {
LabeledContent("") { LabeledContent("") {
Button("Use this display's mode") { fillFromMainScreen() } Button("Use this display's mode") { fillFromMainScreen() }
} }
// (sharedBody is unused on tvOS its body still compiles there, and
// Slider doesn't exist on tvOS; the tv path has its own preset picker.)
#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(.caption)
.foregroundStyle(.orange)
}
}
#endif
} header: { } header: {
Text("Stream mode") Text("Stream mode")
} footer: { } footer: {
Text("The host creates a virtual output at exactly this mode — " Text("The host creates a virtual output at exactly this mode — "
+ "native resolution, no scaling.") + "native resolution, no scaling. \(Self.bitrateFooter)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -324,3 +425,10 @@ struct SettingsView: View {
#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)
}
}
@@ -0,0 +1,237 @@
// Network speed-test sheet (roadmap §9): connect to the host, ask it to burst probe
// filler over the real data plane (FEC-encoded UDP, video paused the measurement IS the
// streaming path), poll the measurement, and recommend a bitrate (~70% of the measured
// goodput, headroom for encoder burstiness). "Use N Mbps" writes the bitrate setting; it
// applies from the next session.
//
// Runs only while idle (the host serves one session at a time, so it can't share the wire
// with a live stream the host-card grid is the idle UI anyway). Trust: a pinned host is
// verified as usual; an unpinned one is probed trust-on-first-use WITHOUT persisting
// anything a bandwidth number doesn't justify a trust decision.
import Foundation
import PunktfunkKit
import SwiftUI
/// Dismissal must abandon the in-flight probe: the connect/poll loop runs detached and
/// checks this flag, closing the connection itself. Only the flag is shared; it is safe
/// to read/write from the loop and the main actor (single Bool, torn reads harmless).
private final class ProbeToken: @unchecked Sendable {
var cancelled = false
}
/// What the host is asked to burst: the host's full probe ceiling (it clamps to 3 Gbps),
/// so the measurement surfaces the link's real ceiling instead of an artificial cap
/// bursting ABOVE what the link can carry is how the probe finds where delivery falls off.
/// Two seconds rides out scheduler jitter. File-scope so the detached probe task reads them
/// without crossing into the view's main actor.
private let probeTargetKbps: UInt32 = 3_000_000
private let probeDurationMs: UInt32 = 2_000
struct SpeedTestSheet: View {
@Environment(\.dismiss) private var dismiss
let host: StoredHost
@AppStorage("punktfunk.width") private var width = 1920
@AppStorage("punktfunk.height") private var height = 1080
@AppStorage("punktfunk.hz") private var hz = 60
@AppStorage("punktfunk.bitrateKbps") private var bitrateKbps = 0
private enum Phase: Equatable {
case connecting
case probing(partial: PunktfunkConnection.ProbeResult?)
case done(PunktfunkConnection.ProbeResult)
case failed(String)
}
@State private var phase: Phase = .connecting
@State private var token = ProbeToken()
var body: some View {
VStack(spacing: 20) {
Label("Speed test — \(host.displayName)", systemImage: "gauge.with.needle")
.font(.headline)
.foregroundStyle(.tint)
switch phase {
case .connecting:
ProgressView("Connecting…")
.padding(.vertical, 12)
case .probing(let partial):
VStack(spacing: 8) {
ProgressView("Measuring — the host is bursting probe data…")
if let partial, partial.throughputKbps > 0 {
Text("~\(Self.mbpsLabel(kbps: Int(partial.throughputKbps))) so far")
.font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 12)
case .done(let result):
resultView(result)
case .failed(let message):
Text(message)
.font(.callout)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
HStack(spacing: 24) {
Button(phaseIsFinal ? "Close" : "Cancel", role: .cancel) {
token.cancelled = true
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
if case .done(let result) = phase, let rec = Self.recommendedKbps(result) {
Button("Use \(Self.mbpsLabel(kbps: rec))") {
bitrateKbps = rec
dismiss()
}
.buttonStyle(.borderedProminent)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
if case .failed = phase {
Button("Retry") { run() }
.buttonStyle(.borderedProminent)
}
}
}
#if os(tvOS)
.frame(maxWidth: 1000)
.padding(60)
#else
.padding(24)
#endif
#if os(macOS)
.frame(width: 420)
.fixedSize(horizontal: false, vertical: true)
#endif
.onAppear { run() }
.onDisappear { token.cancelled = true }
}
private var phaseIsFinal: Bool {
switch phase {
case .done, .failed: return true
case .connecting, .probing: return false
}
}
private func resultView(_ result: PunktfunkConnection.ProbeResult) -> some View {
VStack(spacing: 10) {
Text(Self.mbpsLabel(kbps: Int(result.throughputKbps)))
.font(.system(.largeTitle, design: .rounded).weight(.semibold))
.monospacedDigit()
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
GridRow {
Text("Loss").foregroundStyle(.secondary)
Text(String(format: "%.1f %%", result.lossPct)).monospacedDigit()
}
GridRow {
Text("Received").foregroundStyle(.secondary)
Text("\(ByteCountFormatter.string(fromByteCount: Int64(result.recvBytes), countStyle: .binary)) in \(result.elapsedMs) ms")
.monospacedDigit()
}
}
.font(.callout)
if let rec = Self.recommendedKbps(result) {
Text("Recommended bitrate: \(Self.mbpsLabel(kbps: rec)) "
+ "(~70% of measured, headroom for encoder bursts).")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
} else {
Text("Too little data made it through to recommend a bitrate — "
+ "check the network and retry.")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
}
/// ~70% of the measured goodput, whole Mbps, clamped to the host's session bitrate
/// ceiling (2 Gbps it clamps any session request above that, so recommending more is
/// pointless). nil when the measurement carried too little signal to recommend anything.
static func recommendedKbps(_ result: PunktfunkConnection.ProbeResult) -> Int? {
guard result.throughputKbps >= 2_000 else { return nil }
let raw = Int(result.throughputKbps) * 7 / 10
let wholeMbps = max(raw / 1_000, 2)
return min(wholeMbps, 2_000) * 1_000
}
static func mbpsLabel(kbps: Int) -> String {
if kbps >= 1_000_000 {
let gbps = Double(kbps) / 1_000_000
return gbps == gbps.rounded()
? "\(Int(gbps)) Gbps"
: String(format: "%.1f Gbps", gbps)
}
return kbps % 1_000 == 0
? "\(kbps / 1_000) Mbps"
: String(format: "%.1f Mbps", Double(kbps) / 1_000)
}
private func run() {
phase = .connecting
let token = token
let address = host.address
let port = host.port
let pin = host.pinnedSHA256
let (w, h, fps) = (UInt32(clamping: width), UInt32(clamping: height), UInt32(clamping: hz))
Task.detached(priority: .userInitiated) {
// Connect (blocking) same identity/trust as a session, but TOFU results are
// NOT persisted from here.
let identity = (try? ClientIdentityStore.shared.load())?.identity
let conn: PunktfunkConnection
do {
conn = try PunktfunkConnection(
host: address, port: port, width: w, height: h, refreshHz: fps,
pinSHA256: pin, identity: identity)
} catch {
await MainActor.run {
guard !token.cancelled else { return }
phase = .failed(
"Could not connect to \(address):\(port) — is punktfunk-host "
+ "running and not mid-session?")
}
return
}
defer { conn.close() }
conn.startSpeedTest(targetKbps: probeTargetKbps, durationMs: probeDurationMs)
await MainActor.run { if !token.cancelled { phase = .probing(partial: nil) } }
// Poll until the host's end-of-burst report lands (or a generous deadline
// the host clamps the burst to 5 s).
let deadline = Date().addingTimeInterval(Double(probeDurationMs) / 1000 + 8)
var final: PunktfunkConnection.ProbeResult?
while !token.cancelled, Date() < deadline {
try? await Task.sleep(nanoseconds: 200_000_000)
guard let r = conn.probeResult() else { break } // closed underneath us
if r.done {
final = r
break
}
await MainActor.run {
if !token.cancelled { phase = .probing(partial: r) }
}
}
let result = final
await MainActor.run {
guard !token.cancelled else { return }
if let result {
phase = .done(result)
} else {
phase = .failed(
"The measurement never completed — the connection may have "
+ "dropped mid-probe. Retry?")
}
}
}
}
}
@@ -202,6 +202,11 @@ public final class PunktfunkConnection {
/// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks). /// machines. `0` = no correction (an older host that didn't answer, or synchronized clocks).
public private(set) var clockOffsetNs: Int64 = 0 public private(set) var clockOffsetNs: Int64 = 0
/// The video encoder bitrate (kbps) the host actually configured the requested
/// `bitrateKbps` clamped to the host's range ([500, 2 000 000] kbps), or its default
/// (20 000) when 0 was requested. `0` = an older host that didn't report it.
public private(set) var resolvedBitrateKbps: UInt32 = 0
/// Connect and start a session at the requested mode (the host creates a native virtual /// Connect and start a session at the requested mode (the host creates a native virtual
/// output at exactly this size/refresh). Blocks up to `timeoutMs`. /// output at exactly this size/refresh). Blocks up to `timeoutMs`.
/// ///
@@ -218,6 +223,10 @@ public final class PunktfunkConnection {
/// ///
/// `gamepad`: which virtual pad the host creates for this session's controllers (see /// `gamepad`: which virtual pad the host creates for this session's controllers (see
/// `GamepadType`; `.auto` = host decides). Check `resolvedGamepad` afterwards. /// `GamepadType`; `.auto` = host decides). Check `resolvedGamepad` afterwards.
///
/// `bitrateKbps`: requested video encoder bitrate (0 = host default; the host clamps
/// to its supported range). Check `resolvedBitrateKbps` afterwards a speed test
/// (`startSpeedTest`) is how a client picks an informed value.
public init( public init(
host: String, port: UInt16 = 9777, host: String, port: UInt16 = 9777,
width: UInt32, height: UInt32, refreshHz: UInt32, width: UInt32, height: UInt32, refreshHz: UInt32,
@@ -225,6 +234,7 @@ public final class PunktfunkConnection {
identity: ClientIdentity? = nil, identity: ClientIdentity? = nil,
compositor: Compositor = .auto, compositor: Compositor = .auto,
gamepad: GamepadType = .auto, gamepad: GamepadType = .auto,
bitrateKbps: UInt32 = 0,
timeoutMs: UInt32 = 10_000 timeoutMs: UInt32 = 10_000
) throws { ) throws {
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin } if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
@@ -234,16 +244,16 @@ public final class PunktfunkConnection {
withOptionalCString(identity?.keyPEM) { key in withOptionalCString(identity?.keyPEM) { key in
if let pin = pinSHA256 { if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in return pin.withUnsafeBytes { p in
punktfunk_connect_ex2( punktfunk_connect_ex3(
cs, port, width, height, refreshHz, compositor.rawValue, cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, gamepad.rawValue, bitrateKbps,
p.bindMemory(to: UInt8.self).baseAddress, &observed, p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, timeoutMs) cert, key, timeoutMs)
} }
} }
return punktfunk_connect_ex2( return punktfunk_connect_ex3(
cs, port, width, height, refreshHz, compositor.rawValue, cs, port, width, height, refreshHz, compositor.rawValue,
gamepad.rawValue, gamepad.rawValue, bitrateKbps,
nil, &observed, cert, key, timeoutMs) nil, &observed, cert, key, timeoutMs)
} }
} }
@@ -261,6 +271,54 @@ public final class PunktfunkConnection {
var offset: Int64 = 0 var offset: Int64 = 0
_ = punktfunk_connection_clock_offset_ns(handle, &offset) _ = punktfunk_connection_clock_offset_ns(handle, &offset)
clockOffsetNs = offset clockOffsetNs = offset
var br: UInt32 = 0
_ = punktfunk_connection_bitrate(handle, &br)
resolvedBitrateKbps = br
}
/// A bandwidth speed-test measurement (see `startSpeedTest`). Partial until `done`.
public struct ProbeResult: Sendable, Equatable {
/// The host's end-of-burst report arrived the numbers are final.
public let done: Bool
/// Probe payload bytes / packets the client received.
public let recvBytes: UInt64
public let recvPackets: UInt32
/// Probe payload bytes / packets the host reported sending.
public let hostBytes: UInt64
public let hostPackets: UInt32
/// Client-measured receive window (firstlast probe AU), milliseconds.
public let elapsedMs: UInt32
/// Measured goodput, kilobits per second.
public let throughputKbps: UInt32
/// Delivery loss `(hostBytes recvBytes) / hostBytes`, percent (0 if unknown).
public let lossPct: Float
}
/// Start a bandwidth speed test: the host bursts filler over the data plane at
/// `targetKbps` of goodput for `durationMs` (clamped host-side to 3 Gbps / 5 s),
/// briefly pausing video. Non-blocking poll `probeResult()` until `done`. Starting
/// a probe resets any prior measurement. Silently dropped after close.
public func startSpeedTest(targetKbps: UInt32, durationMs: UInt32) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = punktfunk_connection_speed_test(h, targetKbps, durationMs)
}
/// The current speed-test measurement (zeros before any probe; partial until `done`).
/// Safe to poll from any thread; nil after close.
public func probeResult() -> ProbeResult? {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return nil }
var out = PunktfunkProbeResult()
guard punktfunk_connection_probe_result(h, &out) == statusOK else { return nil }
return ProbeResult(
done: out.done != 0,
recvBytes: out.recv_bytes, recvPackets: out.recv_packets,
hostBytes: out.host_bytes, hostPackets: out.host_packets,
elapsedMs: out.elapsed_ms, throughputKbps: out.throughput_kbps,
lossPct: out.loss_pct)
} }
/// Ask the host to switch the live session to a new mode (window resized) no /// Ask the host to switch the live session to a new mode (window resized) no
@@ -509,6 +567,17 @@ public extension PunktfunkInputEvent {
static func mouseMove(dx: Int32, dy: Int32) -> PunktfunkInputEvent { static func mouseMove(dx: Int32, dy: Int32) -> PunktfunkInputEvent {
make(PUNKTFUNK_INPUT_KIND_MOUSE_MOVE.rawValue, code: 0, x: dx, y: dy) 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_*). /// 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 { static func mouseButton(_ button: UInt32, down: Bool) -> PunktfunkInputEvent {
make( make(
@@ -15,10 +15,14 @@ final class LoopbackIntegrationTests: XCTestCase {
} }
let conn = try PunktfunkConnection( let conn = try PunktfunkConnection(
host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60) host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60,
bitrateKbps: 50_000)
XCTAssertEqual(conn.width, 1280) XCTAssertEqual(conn.width, 1280)
XCTAssertEqual(conn.height, 720) XCTAssertEqual(conn.height, 720)
XCTAssertEqual(conn.refreshHz, 60) XCTAssertEqual(conn.refreshHz, 60)
// The Welcome echoes the negotiated encoder bitrate (50 Mbps is within the
// host's accepted range, so it comes back unclamped).
XCTAssertEqual(conn.resolvedBitrateKbps, 50_000)
// Pull 25 synthetic frames and byte-verify the documented pattern: // Pull 25 synthetic frames and byte-verify the documented pattern:
// u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8). // u32 LE frame index, then data[i] = (idx as u8) &+ (i as u8).
@@ -88,12 +92,34 @@ final class LoopbackIntegrationTests: XCTestCase {
"missing the scripted trigger event: \(hidout)") "missing the scripted trigger event: \(hidout)")
} }
// Speed test against the synthetic host: a short 20 Mbps burst over the real
// data plane. Probe filler is diverted from the frame queue (the 25-frame
// verification above stays byte-exact), the host's end-of-burst report flips
// `done`, and the measurement carries real numbers.
conn.startSpeedTest(targetKbps: 20_000, durationMs: 500)
var probe: PunktfunkConnection.ProbeResult?
let probeDeadline = Date().addingTimeInterval(10)
while Date() < probeDeadline {
if let r = conn.probeResult(), r.done {
probe = r
break
}
Thread.sleep(forTimeInterval: 0.1)
}
let result = try XCTUnwrap(probe, "the probe never completed")
XCTAssertGreaterThan(result.recvBytes, 0)
XCTAssertGreaterThan(result.hostBytes, 0)
XCTAssertGreaterThan(result.throughputKbps, 0)
XCTAssertGreaterThan(result.elapsedMs, 0)
XCTAssertGreaterThanOrEqual(result.lossPct, 0)
conn.close() conn.close()
XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in XCTAssertThrowsError(try conn.nextAU(timeoutMs: 10)) { error in
guard case PunktfunkClientError.closed = error else { guard case PunktfunkClientError.closed = error else {
return XCTFail("expected .closed, got \(error)") return XCTFail("expected .closed, got \(error)")
} }
} }
XCTAssertNil(conn.probeResult())
} }
func testConnectFailureThrows() { func testConnectFailureThrows() {
+1 -1
View File
@@ -1406,7 +1406,7 @@ pub struct PunktfunkProbeResult {
} }
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at /// Start a bandwidth speed test: ask the host to burst filler over the data plane at
/// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 1 Gbps / ≤ 5 s), /// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
/// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its /// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
/// `done` field is 1. Starting a probe resets any prior measurement. /// `done` field is 1. Starting a probe resets any prior measurement.
/// ///
+1 -1
View File
@@ -366,7 +366,7 @@ impl NativeClient {
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the /// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its /// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
/// `done` flag is set. Starting a probe resets any prior measurement. The host clamps both /// `done` flag is set. Starting a probe resets any prior measurement. The host clamps both
/// fields (≤ 1 Gbps, ≤ 5 s). /// fields (≤ 3 Gbps, ≤ 5 s).
pub fn request_probe(&self, target_kbps: u32, duration_ms: u32) -> Result<()> { pub fn request_probe(&self, target_kbps: u32, duration_ms: u32) -> Result<()> {
// Reset the accumulator so a fresh run doesn't blend into the previous one. // Reset the accumulator so a fresh run doesn't blend into the previous one.
*self.probe.lock().unwrap() = ProbeState { *self.probe.lock().unwrap() = ProbeState {
+14 -6
View File
@@ -175,20 +175,20 @@ client) is built and live. Two changes harden it from "works" to "secure by defa
PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online. PIN pairing (§8a) stays the bootstrap — the first device, or when no approver is online.
## 9. Client→host network speed test + settable bitrate *(host side done — client UI remaining)* ## 9. Client→host network speed test + settable bitrate *(host + Apple client done — web console remaining)*
Measure what the network actually sustains so the bitrate picker is informed (suggest/cap a safe Measure what the network actually sustains so the bitrate picker is informed (suggest/cap a safe
value) instead of guesswork that ends in a stuttering stream. value) instead of guesswork that ends in a stuttering stream.
**Done & live (host + protocol + connector + C ABI, `74819b1`):** **Done & live (host + protocol + connector + C ABI, `74819b1`):**
- **Bitrate negotiation**: `bitrate_kbps` rides Hello/Welcome (trailing-byte back-compat). The - **Bitrate negotiation**: `bitrate_kbps` rides Hello/Welcome (trailing-byte back-compat). The
client requests a rate; the host clamps to [500 kbps, 500 Mbps] (or its 20 Mbps default on 0), client requests a rate; the host clamps to [500 kbps, 2 Gbps] (or its 20 Mbps default on 0),
applies it to NVENC (replacing the old hardcoded 20 Mbps) on the initial mode + every reconfigure, applies it to NVENC (replacing the old hardcoded 20 Mbps) on the initial mode + every reconfigure,
and echoes the resolved value. C ABI: `punktfunk_connect_ex3(…, bitrate_kbps, …)` + and echoes the resolved value. C ABI: `punktfunk_connect_ex3(…, bitrate_kbps, …)` +
`punktfunk_connection_bitrate()`. `punktfunk_connection_bitrate()`.
- **Bandwidth probe over the punktfunk/1 data path**: `ProbeRequest{target_kbps,duration_ms}` / - **Bandwidth probe over the punktfunk/1 data path**: `ProbeRequest{target_kbps,duration_ms}` /
`ProbeResult{bytes_sent,…}` control messages + a `FLAG_PROBE` packet flag. The host bursts `ProbeResult{bytes_sent,…}` control messages + a `FLAG_PROBE` packet flag. The host bursts
zero-filled FEC-encoded AUs at the target goodput for the duration (clamped ≤ 1 Gbps / ≤ 5 s, zero-filled FEC-encoded AUs at the target goodput for the duration (clamped ≤ 3 Gbps / ≤ 5 s,
video paused), reports what it sent; the connector measures received bytes/window → goodput + loss video paused), reports what it sent; the connector measures received bytes/window → goodput + loss
and exposes it (`punktfunk_connection_speed_test()` + `punktfunk_connection_probe_result()` → and exposes it (`punktfunk_connection_speed_test()` + `punktfunk_connection_probe_result()` →
`PunktfunkProbeResult{throughput_kbps, loss_pct, …}`). Probe filler is diverted from the decoder. `PunktfunkProbeResult{throughput_kbps, loss_pct, …}`). Probe filler is diverted from the decoder.
@@ -196,9 +196,17 @@ value) instead of guesswork that ends in a stuttering stream.
interleaved probe AUs excluded from frame verification. `punktfunk-client-rs` gains `--bitrate` + interleaved probe AUs excluded from frame verification. `punktfunk-client-rs` gains `--bitrate` +
`--speed-test KBPS:MS` as the reference/loopback driver. `--speed-test KBPS:MS` as the reference/loopback driver.
**Remaining (client UI):** wire the C ABI into the Apple client — a "Test network" action **Done (Apple client UI):** Settings grows a Bitrate control (Automatic = host default; manual is
(`speed_test` → poll `probe_result` → "~XXX Mbps · recommended bitrate YYY") feeding a bitrate a log-scale slider up to 3 Gbps with an above-1-Gbps "test the speed first" warning — tvOS keeps
control (`connect_ex3`), and surface both in the web console. a focus-native preset picker; rides `connect_ex3` on every connect, `PUNKTFUNK_BITRATE_KBPS` dev
override), and each host card's context menu gets
"Test Network Speed…" — a sheet that connects, runs `speed_test` (up to the host's 3 Gbps
probe ceiling for 2 s), polls `probe_result` with a live readout, and shows measured
goodput · loss · recommended bitrate (≈70% of measured, capped at the 2 Gbps session
ceiling) with a one-tap "Use N Mbps" writing the setting. Loopback-tested through the
xcframework: bitrate echo (50 000 → 50 000) + a 20 Mbps/500 ms probe completing with real numbers.
**Remaining:** surface both in the web console.
## 10. HDR + 10-bit color *(parked — blocked upstream at the compositor producer)* ## 10. HDR + 10-bit color *(parked — blocked upstream at the compositor producer)*
+1 -1
View File
@@ -830,7 +830,7 @@ PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c,
#if defined(PUNKTFUNK_FEATURE_QUIC) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Start a bandwidth speed test: ask the host to burst filler over the data plane at // Start a bandwidth speed test: ask the host to burst filler over the data plane at
// `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 1 Gbps / ≤ 5 s), // `target_kbps` of goodput for `duration_ms` (each clamped host-side to ≤ 3 Gbps / ≤ 5 s),
// *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its // *briefly pausing video*. Non-blocking — poll [`punktfunk_connection_probe_result`] until its
// `done` field is 1. Starting a probe resets any prior measurement. // `done` field is 1. Starting a probe resets any prior measurement.
// //