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:
@@ -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 (first→last 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() {
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)*
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user