The host surfaces (design §5.2/§5.2a). A bound host wears a tinted chip that says what a click will do. The card menu grows "Connect with ▸" — a ONE-OFF that never rebinds, with a checkmark on the binding and an explicit "Set Default Profile" as its last item for the users who do want to rebind from there — plus "Pin as Card ▸" and "Copy Link". A pinned host+profile combo becomes its own card next to its host: same record, same live status, the profile as the prominent subtitle, one click to connect. It is an extra grid ENTRY, never a duplicated host record — duplicating would fork pairing, Wake-on-LAN and renames. Its menu carries only connect-shaped actions; edit, pair, forget and remove stay on the primary card, where the thing they act on lives. On the gamepad carousel and tvOS the same pins are tiles, which is the whole point: focus-and-press is what those surfaces do well, and menus are not. The edit sheet binds and pins; both rows vanish when no profiles exist, so a user who never makes one sees exactly today's sheet. A dangling binding renders as "Default settings (profile deleted)" and is cleaned up on save. The speed test finally writes where the tested host reads. Unbound: the global, as before. Bound to a profile that overrides bitrate: that override. Bound to one that inherits it: both are offered, because either is defensible and guessing would silently pick one. Every button names its target, and the probe now runs at the mode that host would actually stream — the measurement is the streaming path. Shortcuts gains `ProfileEntity` over the App-Group catalog and a Profile parameter on Connect, still round-tripping through the URL router rather than opening a second connect path. Connect and Wake drop their iOS wall — AppIntents is real on macOS and tvOS, and "Stream Desktop with Work" from Spotlight was the ask; only the phrases provider stays iOS-gated, since it bundles the LiveActivityIntent. The deep-link observer moved out with them: an intent that posts to nobody is a shortcut that silently does nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
283 lines
12 KiB
Swift
283 lines
12 KiB
Swift
// 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.
|
|
/// Five seconds (was 2 s) averages out the scheduler/recv jitter that made a short probe swing
|
|
/// wildly (50 vs 900 Mbps on the same link) — long enough for the host's steady-state send and
|
|
/// the client's recv drain to settle. 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 = 5_000
|
|
|
|
struct SpeedTestSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
let host: StoredHost
|
|
|
|
@AppStorage(DefaultsKey.bitrateKbps) private var bitrateKbps = 0
|
|
/// The catalog, so the Apply button can write to the layer this host actually reads its
|
|
/// bitrate from (design/client-settings-profiles.md §5.3).
|
|
@ObservedObject private var profiles = ProfileStore.shared
|
|
|
|
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(.geist(17, .semibold, relativeTo: .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(.geist(16, relativeTo: .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) {
|
|
applyButtons(rec)
|
|
}
|
|
if case .failed = phase {
|
|
Button("Retry") { run() }
|
|
.glassProminentButtonStyle()
|
|
}
|
|
}
|
|
}
|
|
#if os(tvOS)
|
|
.frame(maxWidth: 1000)
|
|
.padding(60)
|
|
#else
|
|
.padding(24)
|
|
#endif
|
|
#if os(macOS)
|
|
.frame(width: 420)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
#endif
|
|
#if os(iOS)
|
|
// Bottom sheet rather than a full-screen modal; .medium stays put as the result view
|
|
// swaps in (a measured height would resize the sheet mid-probe).
|
|
.presentationDetents([.medium])
|
|
.presentationDragIndicator(.visible)
|
|
#endif
|
|
.onAppear { run() }
|
|
.onDisappear { token.cancelled = true }
|
|
}
|
|
|
|
/// Apply writes to **the layer this host actually resolves its bitrate from** (§5.3) — the
|
|
/// sharpest symptom of the missing feature was a per-host measurement overwriting the global.
|
|
///
|
|
/// • unbound host → the global, exactly as before;
|
|
/// • bound to a profile that already overrides bitrate → that override;
|
|
/// • bound to a profile that INHERITS bitrate → both are offered, because either is a
|
|
/// defensible answer and guessing would silently pick one.
|
|
///
|
|
/// Every button names its target, so what a click changes is never inferred from context.
|
|
@ViewBuilder private func applyButtons(_ recommended: Int) -> some View {
|
|
let bound = profiles.binding(for: host)
|
|
let label = Self.mbpsLabel(kbps: recommended)
|
|
if let bound {
|
|
Button("Apply to “\(bound.name)”") {
|
|
profiles.setOverride(bound.id, \.bitrateKbps, recommended)
|
|
dismiss()
|
|
}
|
|
.glassProminentButtonStyle()
|
|
#if !os(tvOS)
|
|
.keyboardShortcut(.defaultAction)
|
|
#endif
|
|
if bound.overrides.bitrateKbps == nil {
|
|
Button("Set as default (\(label))") {
|
|
bitrateKbps = recommended
|
|
dismiss()
|
|
}
|
|
}
|
|
} else {
|
|
Button("Use \(label)") {
|
|
bitrateKbps = recommended
|
|
dismiss()
|
|
}
|
|
.glassProminentButtonStyle()
|
|
#if !os(tvOS)
|
|
.keyboardShortcut(.defaultAction)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
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(.geist(12, relativeTo: .caption))
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
} else {
|
|
Text("Too little data made it through to recommend a bitrate — "
|
|
+ "check the network and retry.")
|
|
.font(.geist(12, relativeTo: .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
|
|
// Probe at the mode this host would actually stream at — its profile's, if it is bound to
|
|
// one. The measurement IS the streaming path, so it should be the streaming path's mode.
|
|
let mode = EffectiveSettings.resolve(host: host, catalog: profiles.catalog)
|
|
let (w, h, fps) = (
|
|
UInt32(clamping: mode.width), UInt32(clamping: mode.height),
|
|
UInt32(clamping: mode.refreshHz))
|
|
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?")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|