Runs phases 2-4 against the live PipeWire graph on every registry event and
reports the complete eligible/excluded candidate partition with stable reason
codes. Creates no links, loads no modules, changes no routing.
Impl plan §5. Two entry points behind the hidden PIXELPASS_AUDIO_AUDIT=1
trigger: inside a real `pixelpass host` run (the plan-literal reading, proves
the path phase 6 will mutate), and a hidden `--audit-audio` standalone mode
with no iroh endpoint or capture pipeline, which is what drives the §5.1
matrix.
The recompute runs inline on the observer thread via a new ProjectionSink
hook, once per applied event. Polling `latest()` was rejected: it coalesces,
and phase 4 detects a module unload by observing the empty gap before the next
module appears — with indices reused verbatim (v3.4 §5.2 correction 3), a
missed gap aliases a fresh module onto a dead identity. Running inline is what
makes phase 4's "one observe per graph event" contract true, and it puts the
cost where O5 can measure it.
Split as usual: the auditor and the metrics are pure and unit-tested; the
clock, the writer and the env parsing are the thin edge in `sink`/`run`.
- audit/mod.rs Auditor: AEC validator + taint engine + record building.
The AEC gate and the engine's own reasons stay
distinguishable — a shut gate must not erase the reason codes
the §5.1 rows assert.
- audit/metrics.rs O5: event rate, bucketed recompute distribution + exact
max, busy fraction, and a documented lower-bound queueing
proxy (libpipewire exposes no queue depth).
- audit/sink.rs JSON Lines to stderr, or PIXELPASS_AUDIO_AUDIT_FILE. Never
stdout — peerspeak parses that stream.
- audit/run.rs Env parsing; a malformed AEC value is fatal, matching phase
4's rule that it must not silently become "no AEC".
Observer gains `EventKind` (derived from RegEvent, so a consumer's view of
"was this a real graph change?" cannot disagree with the model's) and
`Projection::readiness`, which distinguishes the three ways graph_ready can be
false. taint::fixture is now pub(crate) so audit tests share one graph
vocabulary with the taint tests.
33 new tests, 178 green, clippy -D warnings and fmt clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
8.8 KiB
Rust
225 lines
8.8 KiB
Rust
use clap::{Parser, ValueEnum};
|
|
|
|
#[derive(Parser, Debug)]
|
|
#[command(
|
|
name = "pixelpass",
|
|
version,
|
|
about = "P2P screen sharing over iroh",
|
|
long_about = "Run with no arguments for an interactive Host/View menu. \
|
|
Pass a ticket positionally to skip the menu and view headlessly."
|
|
)]
|
|
pub struct Cli {
|
|
/// iroh ticket. If present, runs as viewer. If absent, runs as host.
|
|
pub ticket: Option<String>,
|
|
|
|
// ── host options ──────────────────────────────────────────────────
|
|
/// Run as host without the interactive menu. Equivalent to picking
|
|
/// "Host" in the menu, but headless — for scripting and the --gui
|
|
/// front-end, which drives this binary as a child process.
|
|
#[arg(long)]
|
|
pub host: bool,
|
|
|
|
/// Pick a single window instead of the whole screen.
|
|
#[arg(long)]
|
|
pub window: bool,
|
|
|
|
/// Capture only this app's audio (per-app PipeWire routing).
|
|
#[arg(long, value_name = "NAME")]
|
|
pub app: Option<String>,
|
|
|
|
/// With `--app`, never fall back to whole-desktop audio. By default an
|
|
/// app-filtered host mirrors the default sink's monitor until (and again
|
|
/// after) the chosen app's streams route, so the viewer isn't left in
|
|
/// silence. That fallback also captures everything else playing — including
|
|
/// a voice call the sharer is in — so a caller can hear themselves echoed.
|
|
/// `--strict-audio` suppresses the fallback entirely: the viewer hears only
|
|
/// the chosen app, and silence when it isn't producing audio. Ignored
|
|
/// without `--app`.
|
|
#[arg(long)]
|
|
pub strict_audio: bool,
|
|
|
|
/// Override display server autodetection.
|
|
#[arg(long, value_enum)]
|
|
pub display_server: Option<DisplayServerArg>,
|
|
|
|
/// Quality preset. Bundles a max video height, bitrate, and framerate.
|
|
/// `auto` derives them from the saved bandwidth pre-flight (falls back to
|
|
/// `medium` when no measurement exists). Defaults to `auto`; in the
|
|
/// interactive menu, omitting this shows a picker instead.
|
|
#[arg(long, value_enum)]
|
|
pub quality: Option<Quality>,
|
|
|
|
/// Cap the encoded video height (px); width follows the source aspect.
|
|
/// Power-user override — takes precedence over the preset's height.
|
|
#[arg(long, value_name = "N")]
|
|
pub max_height: Option<u32>,
|
|
|
|
/// Encode bitrate in kbps. Overrides the quality preset's bitrate.
|
|
#[arg(long)]
|
|
pub bitrate: Option<u32>,
|
|
|
|
/// Capture framerate. Overrides the quality preset's framerate.
|
|
#[arg(long)]
|
|
pub framerate: Option<u32>,
|
|
|
|
/// Disable VAAPI HW encode; force software x264.
|
|
#[arg(long)]
|
|
pub no_hwencode: bool,
|
|
|
|
/// Maximum number of concurrent viewers. Additional connections are
|
|
/// politely refused with a "host full" message. Defaults to the
|
|
/// connection-aware recommendation from the bandwidth pre-flight if
|
|
/// available, otherwise 2.
|
|
#[arg(long)]
|
|
pub max_viewers: Option<u32>,
|
|
|
|
// ── viewer options ────────────────────────────────────────────────
|
|
/// Local TCP port for the viewer to expose (default: random).
|
|
#[arg(long, default_value_t = 0)]
|
|
pub port: u16,
|
|
|
|
// ── global ────────────────────────────────────────────────────────
|
|
/// Relay server URL to use instead of the bundled defaults, e.g.
|
|
/// `https://relay.example/`. Applies to both host and viewer. Falls back
|
|
/// to the `PIXELPASS_RELAY` environment variable. Use this to get off the
|
|
/// pre-release default relays or to point at a self-hosted relay.
|
|
#[arg(long, value_name = "URL")]
|
|
pub relay: Option<String>,
|
|
|
|
/// Launch the graphical front-end (a window with Host/View controls)
|
|
/// instead of the terminal menu. Requires a build with `--features gui`.
|
|
#[arg(long)]
|
|
pub gui: bool,
|
|
|
|
/// Emit machine-readable events on stdout (one JSON object per line)
|
|
/// alongside the human banner on stderr. For scripts and the --gui
|
|
/// front-end. Currently only `json` is supported.
|
|
#[arg(long, value_enum, value_name = "FORMAT")]
|
|
pub output: Option<OutputFormat>,
|
|
|
|
/// Trace-level logging.
|
|
#[arg(long, short)]
|
|
pub verbose: bool,
|
|
|
|
/// Clean up orphaned PipeWire state from a crashed host run, then exit.
|
|
#[arg(long)]
|
|
pub repair: bool,
|
|
|
|
/// Print an environment diagnostic report (display server, capture/encode
|
|
/// dependencies, VA-API H.264 support, viewer player, relay reachability),
|
|
/// then exit. Use this to check a machine can host or view before a real
|
|
/// session — especially to confirm hardware H.264 encode works, since a GPU
|
|
/// without it silently produces no video under the default encoder.
|
|
#[arg(long)]
|
|
pub doctor: bool,
|
|
|
|
/// Re-run the bandwidth pre-flight test, save the result, then exit.
|
|
/// Use this if your connection has changed (new ISP, moved house, etc.)
|
|
/// or if the previously saved test result is stale.
|
|
#[arg(long)]
|
|
pub reconfigure: bool,
|
|
|
|
/// Run the read-only audio-exclusion dry-run audit against the live
|
|
/// PipeWire graph, then exit on ctrl-c. Emits one JSON object per line to
|
|
/// stderr (or to `PIXELPASS_AUDIO_AUDIT_FILE`) describing which audio
|
|
/// streams would be eligible for a screen share and why the rest would not.
|
|
/// Creates no links and changes no routing.
|
|
///
|
|
/// Hidden: this is development instrumentation for the screen-share audio
|
|
/// exclusion work (impl plan phase 5), not a user-facing feature, and the
|
|
/// record schema is free to change until phase 6 fixes it.
|
|
#[arg(long, hide = true)]
|
|
pub audit_audio: bool,
|
|
}
|
|
|
|
#[derive(ValueEnum, Clone, Copy, Debug)]
|
|
pub enum DisplayServerArg {
|
|
Wayland,
|
|
X11,
|
|
}
|
|
|
|
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum OutputFormat {
|
|
/// One JSON object per line on stdout.
|
|
Json,
|
|
}
|
|
|
|
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
|
|
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
|
|
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
|
|
/// the bandwidth pre-flight at host startup. See `host::quality`.
|
|
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Quality {
|
|
/// Native source resolution, 6000 kbps, 30 fps (no downscale).
|
|
Source,
|
|
/// Up to 1080p, 4000 kbps, 30 fps.
|
|
High,
|
|
/// Up to 720p, 2500 kbps, 30 fps.
|
|
Medium,
|
|
/// Up to 480p, 1000 kbps, 30 fps.
|
|
Low,
|
|
/// Derive from the measured upstream; falls back to `medium` when unmeasured.
|
|
Auto,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct HostOpts {
|
|
pub window: bool,
|
|
pub app: Option<String>,
|
|
/// With `app` set, suppress the whole-desktop loopback fallback so the
|
|
/// viewer only ever hears the chosen app (silence when it's quiet). No
|
|
/// effect when `app` is None.
|
|
pub strict_audio: bool,
|
|
pub display_server: Option<DisplayServerArg>,
|
|
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
|
|
pub quality: Quality,
|
|
/// Raw `--bitrate` override (kbps); None = use the preset's bitrate.
|
|
pub bitrate: Option<u32>,
|
|
/// Raw `--framerate` override; None = use the preset's framerate.
|
|
pub framerate: Option<u32>,
|
|
/// Raw `--max-height` override (px); None = use the preset's height.
|
|
pub max_height: Option<u32>,
|
|
pub no_hwencode: bool,
|
|
pub max_viewers: Option<u32>,
|
|
pub interactive: bool,
|
|
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
|
|
pub relay: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct ViewerOpts {
|
|
pub port: u16,
|
|
pub interactive: bool,
|
|
/// Relay override (resolved from `--relay` / `PIXELPASS_RELAY`); None = defaults.
|
|
pub relay: Option<String>,
|
|
}
|
|
|
|
impl Cli {
|
|
pub fn into_host_opts(self, interactive: bool) -> HostOpts {
|
|
HostOpts {
|
|
window: self.window,
|
|
app: self.app,
|
|
strict_audio: self.strict_audio,
|
|
display_server: self.display_server,
|
|
// No `--quality` and nothing picked interactively → the documented
|
|
// default, Auto.
|
|
quality: self.quality.unwrap_or(Quality::Auto),
|
|
bitrate: self.bitrate,
|
|
framerate: self.framerate,
|
|
max_height: self.max_height,
|
|
no_hwencode: self.no_hwencode,
|
|
max_viewers: self.max_viewers,
|
|
interactive,
|
|
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
|
|
}
|
|
}
|
|
|
|
pub fn into_viewer_opts(self, interactive: bool) -> ViewerOpts {
|
|
ViewerOpts {
|
|
port: self.port,
|
|
interactive,
|
|
relay: crate::common::endpoint::relay_override(self.relay.as_deref()),
|
|
}
|
|
}
|
|
}
|