pre-flight: bandwidth test + persistent config
First-run host launch now offers a one-time upstream measurement against speed.cloudflare.com/__up via ureq (~5 MB POST, ~5s). The result lives at ~/.config/pixelpass/config.toml under [bandwidth] and feeds the default --max-viewers calculation on subsequent runs. Sticky semantics for the dialog: - Unmeasured: first-run prompt (Run / Skip) - Measured / Skipped: silent — never re-prompts - Failed: ask again on next launch (Retry / give up → Skipped) `pixelpass --reconfigure` re-runs the test unconditionally for users whose connection has changed (new ISP, moved house, etc.). --max-viewers is now Option<u32>. When unset, host startup loads the saved measurement, runs recommended_max_viewers(safe_mbps, bitrate), and surfaces the source in the banner: "max viewers : N (auto: X.X Mbps measured upstream)" — or user-specified / default fallback. User verified end-to-end on 2026-05-21 16:54 EDT: first-run dialog, skip path, run path, --reconfigure refresh, and banner integration all work as expected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+70
-11
@@ -10,7 +10,10 @@ use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::{alpn::ALPN, deps, display::DisplayServer, signal, tunnel};
|
||||
use crate::common::{
|
||||
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, signal,
|
||||
tunnel,
|
||||
};
|
||||
|
||||
use self::capture::CaptureHandle;
|
||||
|
||||
@@ -36,7 +39,8 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
if opts.max_viewers == 0 {
|
||||
let resolution = resolve_max_viewers(&opts);
|
||||
if resolution.value == 0 {
|
||||
bail!("--max-viewers must be at least 1");
|
||||
}
|
||||
|
||||
@@ -50,10 +54,10 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
||||
let addr = endpoint.addr();
|
||||
let ticket = EndpointTicket::new(addr);
|
||||
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string());
|
||||
print_host_banner(&ticket, display, &opts, clipboard_ok);
|
||||
print_host_banner(&ticket, display, &opts, &resolution, clipboard_ok);
|
||||
|
||||
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
|
||||
let supervisor = tokio::spawn(supervise(opts.clone(), display, sup_rx));
|
||||
let supervisor = tokio::spawn(supervise(opts.clone(), display, resolution.value, sup_rx));
|
||||
|
||||
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
|
||||
|
||||
@@ -160,11 +164,12 @@ async fn handle_peer(
|
||||
|
||||
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
|
||||
/// capture lazily on the first AddViewer; tears it down when the count drops
|
||||
/// back to zero. Enforces the --max-viewers cap by refusing AddViewer when
|
||||
/// back to zero. Enforces the max-viewers cap by refusing AddViewer when
|
||||
/// the count is already at the cap.
|
||||
async fn supervise(
|
||||
opts: HostOpts,
|
||||
display: DisplayServer,
|
||||
max_viewers: u32,
|
||||
mut rx: mpsc::Receiver<SupervisorMsg>,
|
||||
) {
|
||||
let mut handle: Option<CaptureHandle> = None;
|
||||
@@ -173,10 +178,9 @@ async fn supervise(
|
||||
while let Some(msg) = rx.recv().await {
|
||||
match msg {
|
||||
SupervisorMsg::AddViewer(reply) => {
|
||||
if count >= opts.max_viewers {
|
||||
if count >= max_viewers {
|
||||
let _ = reply.send(Err(format!(
|
||||
"host is full ({} of {} viewers connected)",
|
||||
count, opts.max_viewers
|
||||
"host is full ({count} of {max_viewers} viewers connected)"
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
@@ -195,11 +199,11 @@ async fn supervise(
|
||||
let port = handle.as_ref().expect("handle was just set").local_port();
|
||||
count += 1;
|
||||
let _ = reply.send(Ok(port));
|
||||
tracing::info!(active = count, cap = opts.max_viewers, "viewer joined");
|
||||
tracing::info!(active = count, cap = max_viewers, "viewer joined");
|
||||
}
|
||||
SupervisorMsg::RemoveViewer => {
|
||||
count = count.saturating_sub(1);
|
||||
tracing::info!(active = count, cap = opts.max_viewers, "viewer left");
|
||||
tracing::info!(active = count, cap = max_viewers, "viewer left");
|
||||
if count == 0
|
||||
&& let Some(h) = handle.take()
|
||||
{
|
||||
@@ -220,6 +224,7 @@ fn print_host_banner(
|
||||
ticket: &EndpointTicket,
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
resolution: &MaxViewersResolution,
|
||||
clipboard_ok: bool,
|
||||
) {
|
||||
eprintln!();
|
||||
@@ -228,7 +233,7 @@ fn print_host_banner(
|
||||
eprintln!("│ capture : {}", capture_summary(opts));
|
||||
eprintln!("│ bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate);
|
||||
eprintln!("│ hw encode : {}", if opts.no_hwencode { "off" } else { "auto (VAAPI if available)" });
|
||||
eprintln!("│ max viewers : {}", opts.max_viewers);
|
||||
eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
|
||||
eprintln!("│");
|
||||
if clipboard_ok {
|
||||
eprintln!("│ Your share code has been copied to your clipboard.");
|
||||
@@ -247,6 +252,60 @@ fn print_host_banner(
|
||||
eprintln!();
|
||||
}
|
||||
|
||||
/// How we arrived at the final viewer cap. Surfaced in the banner so the
|
||||
/// user can tell at a glance whether the number is what they specified,
|
||||
/// what their measured upstream supports, or just the fallback default.
|
||||
struct MaxViewersResolution {
|
||||
value: u32,
|
||||
source: MaxViewersSource,
|
||||
}
|
||||
|
||||
enum MaxViewersSource {
|
||||
/// User passed --max-viewers explicitly.
|
||||
UserFlag,
|
||||
/// Derived from the saved bandwidth measurement.
|
||||
BandwidthMeasurement { safe_mbps: f64 },
|
||||
/// No flag, no measurement — falling back.
|
||||
DefaultFallback,
|
||||
}
|
||||
|
||||
impl MaxViewersSource {
|
||||
fn label(&self) -> String {
|
||||
match self {
|
||||
MaxViewersSource::UserFlag => "user-specified".to_string(),
|
||||
MaxViewersSource::BandwidthMeasurement { safe_mbps } => {
|
||||
format!("auto: {safe_mbps:.1} Mbps measured upstream")
|
||||
}
|
||||
MaxViewersSource::DefaultFallback => {
|
||||
"default — run `pixelpass --reconfigure` for a connection-aware value".to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_max_viewers(opts: &HostOpts) -> MaxViewersResolution {
|
||||
if let Some(n) = opts.max_viewers {
|
||||
return MaxViewersResolution {
|
||||
value: n,
|
||||
source: MaxViewersSource::UserFlag,
|
||||
};
|
||||
}
|
||||
if let Ok(cfg) = config::load()
|
||||
&& cfg.bandwidth.status == BandwidthStatus::Measured
|
||||
&& let Some(upstream) = cfg.bandwidth.upstream_mbps
|
||||
{
|
||||
let n = bandwidth::recommended_max_viewers(upstream, opts.bitrate);
|
||||
return MaxViewersResolution {
|
||||
value: n,
|
||||
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
|
||||
};
|
||||
}
|
||||
MaxViewersResolution {
|
||||
value: 2,
|
||||
source: MaxViewersSource::DefaultFallback,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> bool {
|
||||
match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text.to_owned())) {
|
||||
Ok(()) => true,
|
||||
|
||||
Reference in New Issue
Block a user