Closes the last blocking finding: a pid is only a number, and the same number is a different process in a different pid namespace. Repair running inside a container that can reach the host's Pulse socket saw a live host's modules, asked about that pid in its own namespace, was told nothing existed, and unloaded a running host's audio. No negative signal closes that — `NSpid == 1` does not prove the initial namespace, since its leftmost value is relative to whichever procfs was mounted. So the module now carries the answer with it. Every module a host loads gets `pixelpass.owner=<version>-<machine>-<boot>-<pid_ns>-<nonce>`, and repair only asks about a pid when all three identities match its own. Anything else is reported and left alone, and its pid is never even looked up — asking is the bug, because the answer would be meaningless. **Untagged modules are refused by default.** Everything loaded before tokens existed is unattributable, so `--repair` now lists those and does nothing, with `--repair-legacy-untagged` to opt into the old pid-only heuristic after seeing the candidates. That is a deliberate loss of reach: the failure being optimised against is a false-positive destructive repair, and leaving an old orphan behind is recoverable where destroying live routing is not. A foreign token is refused even with the flag, since the flag speaks to missing evidence, not wrong evidence. The vehicle was verified on the live server before anything was built on it: all three shapes accept a property-list argument (`sink_properties`, `sink_input_properties`, `source_output_properties`), the recorded argument comes back byte-identical — so exact-form matching still holds — and the property really lands on the resulting sink, sink-input and source-output. **Audit gate passed, with the variable isolated.** The token rides on real graph objects that phases 2/3 observe, so the partition had to be re-measured. Running the same fixture with and without tokens gives an identical partition: 2 eligible (FFXIV, Chromium), 2 excluded with the same `tainted-owner-bridge` reason, and the same six-entry taint set. Everything that differs from the empty-graph baseline is the fixture's own doing — a local-monitor loopback genuinely bridges our owned sink into the real default sink — and none of it is the property's. Attributing that to the token without the untokened control would have been the mistake. A side benefit: the per-load nonce narrows the ABA window I previously documented as unclosable. Two loads by the same pid no longer render byte-identical arguments, so a fingerprint taken from one no longer matches the other. Six new tests, three mutation-verified gates: `can_judge` always true, `pid_ns` dropped from the comparison, and untagged treated as judgeable regardless of policy — each killed by its own test. ⚠️ The third "survived" on first run because my mutation script's indentation did not match and the edit silently did nothing; the re-run asserts the file actually changed. A mutation that was never applied proves the same amount as no mutation at all. Field-verified live, three fixtures for one dead pid in one run: tokened with this machine's identity is cleaned, tokened with a foreign pid namespace is left alone and reported (and the legacy flag does not override it), and untagged is refused then cleaned only when asked. The two older field fixtures were tokenised too — without that the A/B test would have failed and the reference-gate test would have passed for the wrong reason, which is a vacuous gate in the harness rather than the code. 253 tests, clippy clean, fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
237 lines
9.5 KiB
Rust
237 lines
9.5 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,
|
|
|
|
/// With `--repair`: also clean up modules that carry no ownership token,
|
|
/// judging them by process id alone.
|
|
///
|
|
/// Modules loaded by pixelpass versions before ownership tokens existed cannot
|
|
/// be attributed to a machine, boot or pid namespace, so `--repair` refuses them
|
|
/// by default: a process id means different processes in different namespaces,
|
|
/// and acting on the wrong one unloads a *running* host's audio. Use this only
|
|
/// on the machine that ran the crashed host, and only when the reported
|
|
/// candidates look right.
|
|
#[arg(long, requires = "repair")]
|
|
pub repair_legacy_untagged: 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()),
|
|
}
|
|
}
|
|
}
|