feat(windows): add viewer-only PixelPass milestone

This commit is contained in:
2026-08-22 20:42:23 -04:00
parent 2f00df758d
commit ca3122b92f
16 changed files with 502 additions and 93 deletions
+9 -4
View File
@@ -40,9 +40,17 @@ anyhow = "1"
thiserror = "2" thiserror = "2"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
nix = { version = "0.30", features = ["signal", "process"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
[target.'cfg(target_os = "linux")'.dependencies]
# The host/capture stack is intentionally Linux-only. Keeping it out of the
# Windows dependency graph lets the same binary provide the transport/viewer
# half without trying to cross-link PipeWire, PulseAudio, Wayland, or X11.
nix = { version = "0.30", features = ["signal", "process"] }
directories = "5" directories = "5"
ashpd = { version = "0.9", default-features = false, features = ["tokio"] } ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
pipewire = "0.9" pipewire = "0.9"
@@ -57,9 +65,6 @@ pipewire = "0.9"
# RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0. # RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0.
libpulse-binding = "2.30" libpulse-binding = "2.30"
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] } x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
uuid = { version = "1", features = ["v4"] }
iroh-tickets = "1.0.0"
dialoguer = { version = "0.12", default-features = false }
arboard = { version = "3", default-features = false, features = ["wayland-data-control"] } arboard = { version = "3", default-features = false, features = ["wayland-data-control"] }
ureq = { version = "3", default-features = false, features = ["rustls"] } ureq = { version = "3", default-features = false, features = ["rustls"] }
toml = "1" toml = "1"
+34 -1
View File
@@ -1,6 +1,7 @@
# pixelpass # pixelpass
P2P screen sharing CLI for Linux. Single binary, hole-punched over P2P screen sharing CLI. Linux can host or view; the first Windows milestone
can view a Linux-hosted share. A single binary, hole-punched over
[iroh](https://www.iroh.computer/) — no port forwarding, no signup, no [iroh](https://www.iroh.computer/) — no port forwarding, no signup, no
server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in
mpv or VLC. mpv or VLC.
@@ -29,6 +30,9 @@ Working:
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified - iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker - Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
- Headless mode for scripts (`pixelpass <ticket>`) - Headless mode for scripts (`pixelpass <ticket>`)
- Headless Windows 10 viewer: consumes the same ticket and JSON event protocol,
tunnels the stream to localhost, and works with PeerSpeak's external VLC/mpv
launcher
- Multi-viewer fanout (default 2, configurable via `--max-viewers`; - Multi-viewer fanout (default 2, configurable via `--max-viewers`;
shared gst pipeline, one broadcast channel per host) shared gst pipeline, one broadcast channel per host)
- First-run upstream bandwidth pre-flight, persisted to - First-run upstream bandwidth pre-flight, persisted to
@@ -39,6 +43,9 @@ Working:
derives quality from the bandwidth pre-flight derives quality from the bandwidth pre-flight
Not yet built (deferred, not blocking): Not yet built (deferred, not blocking):
- Windows hosting/capture, including desktop capture, WASAPI system audio, and
PeerSpeak voice exclusion. The current Windows binary is deliberately
viewer-only and reports Linux host-audio capabilities as unavailable.
- Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the - Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
whole root canvas; single-monitor cropping needs xrandr region coords whole root canvas; single-monitor cropping needs xrandr region coords
- `use-damage=true` CPU optimization for the X11 capture path - `use-damage=true` CPU optimization for the X11 capture path
@@ -103,6 +110,8 @@ the capture machinery is untouched by it. On a build without the feature,
## Requirements ## Requirements
### Linux host or viewer
- Linux (Wayland or X11; the backend is autodetected) - Linux (Wayland or X11; the backend is autodetected)
- A VAAPI-capable GPU and the right driver: - A VAAPI-capable GPU and the right driver:
- AMD: `libva-mesa-driver` - AMD: `libva-mesa-driver`
@@ -124,6 +133,13 @@ the capture machinery is untouched by it. On a build without the feature,
mpv ships its own decoder stack and doesn't share either dependency. mpv ships its own decoder stack and doesn't share either dependency.
- PipeWire (for screencast portal + audio capture) - PipeWire (for screencast portal + audio capture)
### Windows viewer
- Windows 10 build 19045 or newer
- VLC or mpv on `PATH`; VLC is available as `VideoLAN.VLC` through `winget`
- A share ticket from a PixelPass host (hosting remains Linux-only in this
milestone)
On Arch / CachyOS / EndeavourOS: On Arch / CachyOS / EndeavourOS:
```sh ```sh
@@ -184,6 +200,23 @@ windowing stack). Build it with:
cargo build --release --features gui cargo build --release --features gui
``` ```
### Windows viewer
Cross-build the headless viewer with MinGW; the Linux-only capture dependencies
are excluded from this target:
```sh
rustup target add x86_64-pc-windows-gnu
# Install the MinGW-w64 compiler using your distro's package manager.
cargo build --release --target x86_64-pc-windows-gnu
```
The result is
`target/x86_64-pc-windows-gnu/release/pixelpass.exe`. Validate it on Windows
with `pixelpass.exe --doctor`, then pass a ticket directly or let PeerSpeak
drive it with `--output json`. See [Windows support](docs/WINDOWS.md) for the
support boundary and smoke-test gates.
## How it works ## How it works
``` ```
+102
View File
@@ -0,0 +1,102 @@
# Windows support
## Current milestone
PixelPass on Windows is a **viewer**, not a screen-share host. It preserves the
same viewer-side architecture used on Linux:
1. parse an iroh endpoint ticket;
2. connect to the Linux host over the existing `pixelpass/0` ALPN;
3. expose the tunneled MPEG-TS stream on a random loopback HTTP port;
4. emit `{"event":"connected","url":"http://127.0.0.1:..."}` when
`--output json` is enabled;
5. let PeerSpeak launch VLC/mpv, or launch one from PixelPass's interactive
viewer prompt.
No codec, container, ticket, ALPN, or JSON protocol fork was introduced for
Windows.
## Support matrix
| Capability | Linux | Windows 10 |
| --- | --- | --- |
| View a share | Yes | Yes |
| Headless `--output json` viewer | Yes | Yes |
| Interactive viewer/player launch | Yes | Yes |
| Host Wayland/X11 capture | Yes | No |
| Host desktop/system audio | PipeWire/Pulse | No |
| PeerSpeak voice exclusion | Yes | No |
| PixelPass GUI | Yes (feature build) | No |
Unsupported host operations fail with an explicit viewer-only error. On
Windows, `--capabilities` reports both host-audio capability flags as `false`,
so parent integrations cannot mistake this milestone for full hosting parity.
## Build
From Linux with Rust 1.95+ and MinGW-w64 installed:
```sh
rustup target add x86_64-pc-windows-gnu
cargo build --release --target x86_64-pc-windows-gnu
```
The artifact is:
```text
target/x86_64-pc-windows-gnu/release/pixelpass.exe
```
The Windows target does not compile or link PipeWire, PulseAudio, Wayland, X11,
or the Linux GUI stack. Keep the build headless; `--gui` is intentionally
rejected on Windows.
## Validation gates
Before bundling a Windows binary with PeerSpeak:
```sh
cargo fmt -- --check
cargo clippy --target x86_64-pc-windows-gnu -- -D warnings
cargo test -- --test-threads=1
cargo build --release --target x86_64-pc-windows-gnu
```
Then on an actual Windows 10 build 19045 VM:
1. verify the transferred SHA-256;
2. run `pixelpass.exe --version` and `pixelpass.exe --capabilities`;
3. run `pixelpass.exe --doctor` with VLC or mpv installed;
4. start a real Linux host, pass its fresh ticket to the Windows executable
with `--output json`, and verify the `connected` event;
5. open the emitted loopback URL in the player and confirm moving video and
audio;
6. stop the player/viewer and confirm both sides terminate cleanly.
### Verified baseline
On 2026-08-22 this gate passed on Windows 10 Enterprise Evaluation build 19045
against a Wayland Linux host using software x264 at the Low preset. The Windows
viewer emitted a loopback URL, VLC 3.0.23 rendered the live desktop, closing VLC
terminated the viewer, and the host emitted `viewer_left` followed by
`capture: stopped`. The final release artifact was 13,165,056 bytes with
SHA-256:
```text
3eabd9f0dcd8565136a5c87a79470eceedd426cea5708904d7492a6fa37b3c49
```
The run deliberately declined Windows Defender's one-off public-network allow
prompt; relay viewing succeeded without it. A packaged application should own
an explicit, idempotent firewall rule for the final installed path instead of
depending on that prompt.
## Next Windows phases
Windows hosting should be added behind a native capture boundary rather than by
weakening the Linux implementation:
1. desktop/window video capture and H.264 encode;
2. WASAPI system-audio capture;
3. PeerSpeak-owned voice/AEC exclusion with a fail-closed contract;
4. dependency packaging, firewall rules, and installer upgrade coverage.
+13 -5
View File
@@ -27,8 +27,11 @@ fn response() -> CapabilityResponse {
CapabilityResponse { CapabilityResponse {
schema_version: CAPABILITY_SCHEMA_VERSION, schema_version: CAPABILITY_SCHEMA_VERSION,
capabilities: CapabilitySet { capabilities: CapabilitySet {
strict_app_audio: true, // These are host-side audio features. The Windows milestone is a
desktop_audio_exclusion: true, // viewer only, so advertising either one there would falsely
// imply that its Linux capture/audio stack is available.
strict_app_audio: cfg!(target_os = "linux"),
desktop_audio_exclusion: cfg!(target_os = "linux"),
}, },
} }
} }
@@ -50,10 +53,15 @@ mod tests {
fn version_one_wire_shape_is_exact_and_capabilities_are_independent() { fn version_one_wire_shape_is_exact_and_capabilities_are_independent() {
let mut output = Vec::new(); let mut output = Vec::new();
write_response(&mut output).expect("serialize the capability golden"); write_response(&mut output).expect("serialize the capability golden");
assert_eq!( let expected = if cfg!(target_os = "linux") {
output,
br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}} br#"{"schema_version":1,"capabilities":{"strict_app_audio":true,"desktop_audio_exclusion":true}}
"# "#
); .as_slice()
} else {
br#"{"schema_version":1,"capabilities":{"strict_app_audio":false,"desktop_audio_exclusion":false}}
"#
.as_slice()
};
assert_eq!(output, expected);
} }
} }
+5 -3
View File
@@ -1,18 +1,20 @@
#![cfg_attr(target_os = "windows", allow(dead_code))]
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use crate::host::aec::{AecConfig, parse_aec_arg}; use crate::common::aec::{AecConfig, parse_aec_arg};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command( #[command(
name = "pixelpass", name = "pixelpass",
version, version,
about = "P2P screen sharing over iroh", about = "P2P screen sharing over iroh",
long_about = "Run with no arguments for an interactive Host/View menu. \ long_about = "Run with no arguments for the platform's interactive mode. \
Pass a ticket positionally to skip the menu and view headlessly." Pass a ticket positionally to skip the menu and view headlessly."
)] )]
pub struct Cli { pub struct Cli {
/// iroh ticket. If present, runs as viewer. If absent, runs as host. /// iroh ticket. If present, runs as viewer. If absent, enters interactive mode.
pub ticket: Option<String>, pub ticket: Option<String>,
// ── host options ────────────────────────────────────────────────── // ── host options ──────────────────────────────────────────────────
+86
View File
@@ -0,0 +1,86 @@
//! Platform-neutral parsing for PixelPass's host audio/AEC command-line contract.
//!
//! Hosting currently remains Linux-only, but the CLI is shared by the Windows
//! viewer build so unsupported host invocations can be parsed and rejected with
//! a direct platform message instead of looking like unknown arguments.
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecConfig {
/// `--aec=off` — PeerSpeak's AEC is not in play. This is distinct from an
/// absent argument; the CLI decides when explicit state is required.
Off,
/// Validate this live Pulse module index before trusting it. The index is
/// compared as `u64`, never `u32`.
PulseModule(u64),
}
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge:
/// a malformed identity must never silently become "no AEC".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecParseError {
Empty,
UnknownForm,
MissingIndex,
InvalidIndex,
}
/// Parse one exact `off` or `pulse-module:<bare u64 decimal>` value.
///
/// The grammar deliberately rejects signs, whitespace, non-decimal digits,
/// and overflow while accepting indices beyond `u32::MAX`. PeerSpeak produces
/// this machine argument from `pactl load-module`, so widening the grammar is
/// less safe than requiring its canonical unsigned decimal.
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
if value.is_empty() {
return Err(AecParseError::Empty);
}
if value == "off" {
return Ok(AecConfig::Off);
}
if let Some(index) = value.strip_prefix("pulse-module:") {
if index.is_empty() {
return Err(AecParseError::MissingIndex);
}
if !index.bytes().all(|b| b.is_ascii_digit()) {
return Err(AecParseError::InvalidIndex);
}
return index
.parse::<u64>()
.map(AecConfig::PulseModule)
.map_err(|_| AecParseError::InvalidIndex);
}
Err(AecParseError::UnknownForm)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_the_cross_platform_cli_contract() {
assert_eq!(parse_aec_arg("off"), Ok(AecConfig::Off));
assert_eq!(
parse_aec_arg("pulse-module:536870919"),
Ok(AecConfig::PulseModule(536_870_919))
);
assert_eq!(
parse_aec_arg(&format!("pulse-module:{}", u64::MAX)),
Ok(AecConfig::PulseModule(u64::MAX))
);
}
#[test]
fn rejects_noncanonical_or_incomplete_values() {
assert_eq!(parse_aec_arg(""), Err(AecParseError::Empty));
assert_eq!(
parse_aec_arg("pulse-module:"),
Err(AecParseError::MissingIndex)
);
assert_eq!(
parse_aec_arg("pulse-module:+7"),
Err(AecParseError::InvalidIndex)
);
assert_eq!(parse_aec_arg("on"), Err(AecParseError::UnknownForm));
}
}
+1 -1
View File
@@ -10,5 +10,5 @@ pub const ALPN: &[u8] = b"pixelpass/0";
/// without their accept loops colliding, and so a control dial never lands on a /// without their accept loops colliding, and so a control dial never lands on a
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest /// bare video host (which doesn't speak this protocol). GUI-only, like the rest
/// of the friends stack. /// of the friends stack.
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0"; pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
+1 -1
View File
@@ -53,7 +53,7 @@ pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
/// Bind the **control-plane** endpoint with the machine's persistent identity /// Bind the **control-plane** endpoint with the machine's persistent identity
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its /// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
/// `EndpointId` is the stable id friends know you by. /// `EndpointId` is the stable id friends know you by.
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> { pub async fn bind_control(relay: Option<&str>) -> Result<Endpoint> {
let secret_key = super::identity::load_or_create()?; let secret_key = super::identity::load_or_create()?;
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
+9 -3
View File
@@ -1,18 +1,24 @@
pub mod aec;
pub mod alpn; pub mod alpn;
#[cfg(target_os = "linux")]
pub mod bandwidth; pub mod bandwidth;
#[cfg(target_os = "linux")]
pub mod config; pub mod config;
#[cfg(target_os = "linux")]
pub mod contained; pub mod contained;
// The friends stack (persistent identity + control plane) is GUI-only — a // The friends stack (persistent identity + control plane) is GUI-only — a
// headless CLI host runs no presence service — so it's gated with the feature // headless CLI host runs no presence service — so it's gated with the feature
// that pulls the rest of the GUI, keeping the headless build lean. // that pulls the rest of the GUI, keeping the headless build lean.
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub mod control; pub mod control;
#[cfg(target_os = "linux")]
pub mod deps; pub mod deps;
#[cfg(target_os = "linux")]
pub mod display; pub mod display;
pub mod endpoint; pub mod endpoint;
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub mod friends; pub mod friends;
#[cfg(feature = "gui")] #[cfg(all(feature = "gui", target_os = "linux"))]
pub mod identity; pub mod identity;
pub mod output; pub mod output;
pub mod process; pub mod process;
+2
View File
@@ -10,6 +10,8 @@
//! `--gui` front-end re-execs this binary as `pixelpass --host --output json` //! `--gui` front-end re-execs this binary as `pixelpass --host --output json`
//! and parses these lines to drive its window. //! and parses these lines to drive its window.
#![cfg_attr(target_os = "windows", allow(dead_code))]
use std::io::Write; use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
+24 -4
View File
@@ -1,12 +1,15 @@
use std::io; use std::io;
#[cfg(unix)]
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
/// Spawn a child process fully detached from this process group. /// Spawn a player detached from PixelPass's process group and console.
/// ///
/// The child gets its own session via `setsid(2)` and null stdio, so it /// On Unix the child gets its own session via `setsid(2)`. On Windows it gets a
/// survives the parent exiting and doesn't take a SIGKILL cascade when /// new process group with no console window. Both paths use null stdio, so the
/// pixelpass dies. /// player can survive PixelPass exiting without holding its terminal open.
/// ///
/// A detached reaper thread `wait()`s the child so it doesn't linger as a /// A detached reaper thread `wait()`s the child so it doesn't linger as a
/// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches /// `<defunct>` zombie under a long-lived parent — the `--gui` front-end launches
@@ -18,6 +21,7 @@ use std::process::{Command, Stdio};
/// `fork(2)` followed by non-trivial work in this multithreaded process is /// `fork(2)` followed by non-trivial work in this multithreaded process is
/// unsound — the reaper thread is the safe equivalent.) /// unsound — the reaper thread is the safe equivalent.)
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> { pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
#[cfg(unix)]
let child = unsafe { let child = unsafe {
Command::new(prog) Command::new(prog)
.args(args) .args(args)
@@ -30,9 +34,25 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
}) })
.spawn()? .spawn()?
}; };
#[cfg(windows)]
let child = Command::new(prog)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
// Keep a console player out of PeerSpeak's process group and prevent a
// stray console window. GUI players ignore CREATE_NO_WINDOW and still
// show their normal window.
.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW)
.spawn()?;
std::thread::spawn(move || { std::thread::spawn(move || {
let mut child = child; let mut child = child;
let _ = child.wait(); let _ = child.wait();
}); });
Ok(()) Ok(())
} }
#[cfg(windows)]
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+3
View File
@@ -1,10 +1,13 @@
#[cfg(unix)]
use anyhow::{Context, Result}; use anyhow::{Context, Result};
#[cfg(unix)]
use tokio::signal::unix::{Signal, SignalKind}; use tokio::signal::unix::{Signal, SignalKind};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
/// A stream of SIGTERMs, for the callers that need to shut down cleanly when /// A stream of SIGTERMs, for the callers that need to shut down cleanly when
/// something other than a human at a terminal asks them to (`timeout`, a test /// something other than a human at a terminal asks them to (`timeout`, a test
/// harness, a service manager). Ctrl-c alone covers only the interactive case. /// harness, a service manager). Ctrl-c alone covers only the interactive case.
#[cfg(unix)]
pub fn terminate_stream() -> Result<Signal> { pub fn terminate_stream() -> Result<Signal> {
tokio::signal::unix::signal(SignalKind::terminate()) tokio::signal::unix::signal(SignalKind::terminate())
.context("could not install a SIGTERM handler") .context("could not install a SIGTERM handler")
+1 -70
View File
@@ -49,79 +49,10 @@
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
pub use crate::common::aec::{AecConfig, AecParseError, parse_aec_arg};
use crate::host::observer::Millis; use crate::host::observer::Millis;
use crate::host::taint::snapshot::GraphSnapshot; use crate::host::taint::snapshot::GraphSnapshot;
/// The parsed `--aec=off|pulse-module:<idx>` argument (decision D5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecConfig {
/// `--aec=off` — peerspeak's AEC is not in play, so there is nothing to
/// exclude and fan-out proceeds with no AEC identity. Not the same as an
/// *absent* argument (that default is the caller's; see [`parse_aec_arg`]).
Off,
/// `--aec=pulse-module:<idx>` — validate this live module index before
/// trusting it. The index is compared as `u64`, never `u32` (v3.4 §5.2).
PulseModule(u64),
}
/// Why an `--aec` argument was rejected. Rejection is fatal at the CLI edge —
/// there is no fail-closed *default* index, because a wrong index would exclude
/// the wrong node (or nothing), so a malformed value must not silently become
/// "no AEC".
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecParseError {
/// The value was empty.
Empty,
/// Not `off` and not `pulse-module:...`.
UnknownForm,
/// `pulse-module:` with nothing after the colon.
MissingIndex,
/// The index was not a bare `u64` decimal (sign, whitespace, non-digit, or
/// `> u64::MAX`).
InvalidIndex,
}
/// Parse one `--aec` value. `off` and `pulse-module:<idx>` are the only forms.
///
/// The index accepts values `> u32::MAX` (v3.4 §5.2: `pulse.module.id` sits
/// next to the `object.serial` u32-truncation bug, so it is only ever compared
/// as `u64`) and requires a **bare decimal** — stricter than Rust's [`u64`]
/// parser, which also accepts a leading `+`. Rejected: any sign, surrounding or
/// interior whitespace, non-decimal digits, and overflow. Matching is exact and
/// case-sensitive: the argument is machine-generated by peerspeak from
/// `EchoCancelGuard::module_index`, not typed by a user.
///
/// ⚠️ **Producer contract** (Codex phase-4 review, finding 5): because the
/// grammar is narrower than Rust's parser, peerspeak must emit a bare decimal.
/// `pactl load-module` returns an unsigned decimal, so the stored index is
/// already canonical and no reachable value is rejected; if peerspeak ever
/// changes how it formats the index it must canonicalize (`value.to_string()`),
/// not widen this parser — the narrow grammar is the point.
pub fn parse_aec_arg(value: &str) -> Result<AecConfig, AecParseError> {
if value.is_empty() {
return Err(AecParseError::Empty);
}
if value == "off" {
return Ok(AecConfig::Off);
}
if let Some(index) = value.strip_prefix("pulse-module:") {
if index.is_empty() {
return Err(AecParseError::MissingIndex);
}
// A bare decimal only: reject a leading sign (Rust's `u64` parser
// accepts `+7`), interior/surrounding whitespace, and any non-digit,
// before letting the parser catch overflow. Leading zeros are harmless.
if !index.bytes().all(|b| b.is_ascii_digit()) {
return Err(AecParseError::InvalidIndex);
}
return index
.parse::<u64>()
.map(AecConfig::PulseModule)
.map_err(|_| AecParseError::InvalidIndex);
}
Err(AecParseError::UnknownForm)
}
/// The validation epoch (v3.4 §5.3, verbatim). /// The validation epoch (v3.4 §5.3, verbatim).
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AecState { pub enum AecState {
+63 -1
View File
@@ -1,11 +1,21 @@
mod capabilities; mod capabilities;
mod cli; mod cli;
mod common; mod common;
#[cfg(target_os = "linux")]
mod doctor; mod doctor;
#[cfg(feature = "gui")] #[cfg(target_os = "windows")]
#[path = "windows/doctor.rs"]
mod doctor;
#[cfg(all(feature = "gui", target_os = "linux"))]
mod gui; mod gui;
#[cfg(target_os = "linux")]
mod host; mod host;
#[cfg(target_os = "linux")]
mod interactive; mod interactive;
#[cfg(target_os = "windows")]
#[path = "windows/interactive.rs"]
mod interactive;
#[cfg(target_os = "linux")]
mod repair; mod repair;
mod viewer; mod viewer;
@@ -20,6 +30,11 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
init_tracing(cli.verbose); init_tracing(cli.verbose);
run(cli).await
}
#[cfg(target_os = "linux")]
async fn run(cli: Cli) -> Result<()> {
if cli.capabilities { if cli.capabilities {
return capabilities::run(); return capabilities::run();
} }
@@ -92,6 +107,53 @@ async fn main() -> Result<()> {
} }
} }
#[cfg(target_os = "windows")]
async fn run(cli: Cli) -> Result<()> {
if cli.capabilities {
return capabilities::run();
}
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
common::output::set_json(true);
}
if cli.gui {
anyhow::bail!(
"the Windows PixelPass milestone is viewer-only and headless; use it through \
PeerSpeak or pass a ticket directly"
);
}
if cli.doctor {
let relay = common::endpoint::relay_override(cli.relay.as_deref());
return doctor::run(relay).await;
}
if cli.host {
anyhow::bail!(
"hosting a screen share is not available in this Windows PixelPass milestone; \
this build can view shares hosted by Linux"
);
}
if cli.repair || cli.audit_audio || cli.reconfigure {
anyhow::bail!("this operation belongs to PixelPass's Linux host/capture stack");
}
match cli.ticket.as_deref() {
Some(s) => {
let ticket: EndpointTicket = s.parse().map_err(|e| {
anyhow::anyhow!(
"argument doesn't look like a pixelpass ticket ({e}).\n\
Run with no arguments for the viewer prompt, or pass a ticket to view."
)
})?;
viewer::run(ticket, cli.into_viewer_opts(false)).await
}
None => interactive::run(cli).await,
}
}
fn init_tracing(verbose: bool) { fn init_tracing(verbose: bool) {
let default = if verbose { let default = if verbose {
"pixelpass=trace,iroh=info" "pixelpass=trace,iroh=info"
+76
View File
@@ -0,0 +1,76 @@
//! Viewer-only diagnostics for the first Windows PixelPass milestone.
use anyhow::{Result, bail};
use std::path::PathBuf;
use std::time::Duration;
use crate::common::endpoint;
pub async fn run(relay: Option<String>) -> Result<()> {
eprintln!();
eprintln!("PixelPass doctor — Windows viewer");
eprintln!("─────────────────────────────────");
eprintln!("· pixelpass : {}", env!("CARGO_PKG_VERSION"));
eprintln!("· hosting : unavailable in this viewer-only milestone");
let player = find_program("mpv")
.map(|path| ("mpv", path))
.or_else(|| find_program("vlc").map(|path| ("vlc", path)));
match &player {
Some((name, path)) => eprintln!("✓ player : {name} ({})", path.display()),
None => eprintln!("✗ player : neither mpv nor VLC was found"),
}
let relay_ok = match endpoint::bind(relay.as_deref()).await {
Ok(ep) => {
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
.await
.is_ok();
let has_relay = ep.addr().addrs.iter().any(|addr| addr.is_relay());
ep.close().await;
if online && has_relay {
eprintln!("✓ network : relay reachable");
} else if online {
eprintln!("✗ network : endpoint online, but no relay address is available");
} else {
eprintln!("✗ network : no relay reached within 8 seconds");
}
online && has_relay
}
Err(error) => {
eprintln!("✗ network : could not bind an iroh endpoint ({error:#})");
false
}
};
eprintln!();
if player.is_none() || !relay_ok {
bail!("Windows viewer prerequisites are incomplete");
}
eprintln!("Ready to view a PixelPass share hosted by Linux.");
Ok(())
}
fn find_program(name: &str) -> Option<PathBuf> {
let file = format!("{name}.exe");
if let Some(path) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&path) {
let candidate = dir.join(&file);
if candidate.is_file() {
return Some(candidate);
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_program_is_reported_without_panicking() {
assert!(find_program("pixelpass-definitely-not-installed").is_none());
}
}
+73
View File
@@ -0,0 +1,73 @@
//! Minimal interactive wrapper for the Windows viewer milestone.
use anyhow::Result;
use dialoguer::{Input, Select, theme::ColorfulTheme};
use iroh_tickets::endpoint::EndpointTicket;
use std::str::FromStr;
use crate::cli::Cli;
use crate::viewer;
pub async fn run(cli: Cli) -> Result<()> {
eprintln!();
eprintln!("Welcome to PixelPass for Windows (viewer milestone).");
eprintln!("This build can watch a share hosted by PixelPass on Linux.");
eprintln!();
let theme = ColorfulTheme::default();
let ticket = prompt_ticket(&theme)?;
viewer::run(ticket, cli.into_viewer_opts(true)).await
}
fn prompt_ticket(theme: &ColorfulTheme) -> Result<EndpointTicket> {
loop {
let raw: String = Input::with_theme(theme)
.with_prompt("Paste the share code you received")
.interact_text()?;
match EndpointTicket::from_str(raw.trim()) {
Ok(ticket) => return Ok(ticket),
Err(_) => eprintln!("That doesn't look like a share code. Try again."),
}
}
}
#[derive(Clone, Copy)]
pub enum Player {
Mpv,
Vlc,
}
impl Player {
pub fn spawn(self, url: &str) -> std::io::Result<()> {
match self {
Self::Mpv => crate::common::process::spawn_detached(
"mpv",
&[
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
url,
],
),
Self::Vlc => crate::common::process::spawn_detached(
"vlc",
&["--network-caching=200", "--live-caching=200", url],
),
}
}
}
pub fn prompt_player() -> Result<Player> {
let theme = ColorfulTheme::default();
let choice = Select::with_theme(&theme)
.with_prompt("Open stream with")
.items(["mpv (recommended)", "VLC"])
.default(0)
.interact()?;
Ok(if choice == 0 {
Player::Mpv
} else {
Player::Vlc
})
}