feat(windows): add viewer-only PixelPass milestone
This commit is contained in:
+9
-4
@@ -40,9 +40,17 @@ anyhow = "1"
|
||||
thiserror = "2"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
nix = { version = "0.30", features = ["signal", "process"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
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"
|
||||
ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
|
||||
pipewire = "0.9"
|
||||
@@ -57,9 +65,6 @@ pipewire = "0.9"
|
||||
# RustSec advisories (2018-0020, 2018-0021, 2019-0038) fixed by 2.6.0.
|
||||
libpulse-binding = "2.30"
|
||||
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"] }
|
||||
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||
toml = "1"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# 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
|
||||
server-side accounts. Hardware-encoded H.264 + AAC audio, viewed in
|
||||
mpv or VLC.
|
||||
@@ -29,6 +30,9 @@ Working:
|
||||
- iroh QUIC bi-stream tunnel, direct-UDP and relay paths both verified
|
||||
- Interactive Host/View menu with clipboard auto-copy and mpv/VLC picker
|
||||
- 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`;
|
||||
shared gst pipeline, one broadcast channel per host)
|
||||
- First-run upstream bandwidth pre-flight, persisted to
|
||||
@@ -39,6 +43,9 @@ Working:
|
||||
derives quality from the bandwidth pre-flight
|
||||
|
||||
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
|
||||
whole root canvas; single-monitor cropping needs xrandr region coords
|
||||
- `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
|
||||
|
||||
### Linux host or viewer
|
||||
|
||||
- Linux (Wayland or X11; the backend is autodetected)
|
||||
- A VAAPI-capable GPU and the right 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.
|
||||
- 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:
|
||||
|
||||
```sh
|
||||
@@ -184,6 +200,23 @@ windowing stack). Build it with:
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
+102
@@ -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
@@ -27,8 +27,11 @@ fn response() -> CapabilityResponse {
|
||||
CapabilityResponse {
|
||||
schema_version: CAPABILITY_SCHEMA_VERSION,
|
||||
capabilities: CapabilitySet {
|
||||
strict_app_audio: true,
|
||||
desktop_audio_exclusion: true,
|
||||
// These are host-side audio features. The Windows milestone is a
|
||||
// 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() {
|
||||
let mut output = Vec::new();
|
||||
write_response(&mut output).expect("serialize the capability golden");
|
||||
assert_eq!(
|
||||
output,
|
||||
let expected = if cfg!(target_os = "linux") {
|
||||
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
@@ -1,18 +1,20 @@
|
||||
#![cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Parser, ValueEnum};
|
||||
|
||||
use crate::host::aec::{AecConfig, parse_aec_arg};
|
||||
use crate::common::aec::{AecConfig, parse_aec_arg};
|
||||
|
||||
#[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. \
|
||||
long_about = "Run with no arguments for the platform's interactive mode. \
|
||||
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.
|
||||
/// iroh ticket. If present, runs as viewer. If absent, enters interactive mode.
|
||||
pub ticket: Option<String>,
|
||||
|
||||
// ── host options ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
|
||||
/// bare video host (which doesn't speak this protocol). GUI-only, like the rest
|
||||
/// of the friends stack.
|
||||
#[cfg(feature = "gui")]
|
||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||
pub const CONTROL_ALPN: &[u8] = b"pixelpass/ctrl/0";
|
||||
|
||||
@@ -53,7 +53,7 @@ pub async fn bind(relay: Option<&str>) -> Result<Endpoint> {
|
||||
/// Bind the **control-plane** endpoint with the machine's persistent identity
|
||||
/// (see [`super::identity`]) and the friends [`super::alpn::CONTROL_ALPN`]. Its
|
||||
/// `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> {
|
||||
let secret_key = super::identity::load_or_create()?;
|
||||
bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await
|
||||
|
||||
+9
-3
@@ -1,18 +1,24 @@
|
||||
pub mod aec;
|
||||
pub mod alpn;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod bandwidth;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod config;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod contained;
|
||||
// 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
|
||||
// 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;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod deps;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod display;
|
||||
pub mod endpoint;
|
||||
#[cfg(feature = "gui")]
|
||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||
pub mod friends;
|
||||
#[cfg(feature = "gui")]
|
||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||
pub mod identity;
|
||||
pub mod output;
|
||||
pub mod process;
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//! `--gui` front-end re-execs this binary as `pixelpass --host --output json`
|
||||
//! and parses these lines to drive its window.
|
||||
|
||||
#![cfg_attr(target_os = "windows", allow(dead_code))]
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
|
||||
+24
-4
@@ -1,12 +1,15 @@
|
||||
use std::io;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
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
|
||||
/// survives the parent exiting and doesn't take a SIGKILL cascade when
|
||||
/// pixelpass dies.
|
||||
/// On Unix the child gets its own session via `setsid(2)`. On Windows it gets a
|
||||
/// new process group with no console window. Both paths use null stdio, so the
|
||||
/// 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
|
||||
/// `<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
|
||||
/// unsound — the reaper thread is the safe equivalent.)
|
||||
pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
let child = unsafe {
|
||||
Command::new(prog)
|
||||
.args(args)
|
||||
@@ -30,9 +34,25 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> {
|
||||
})
|
||||
.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 || {
|
||||
let mut child = child;
|
||||
let _ = child.wait();
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
#[cfg(windows)]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
#[cfg(unix)]
|
||||
use anyhow::{Context, Result};
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::{Signal, SignalKind};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// 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
|
||||
/// harness, a service manager). Ctrl-c alone covers only the interactive case.
|
||||
#[cfg(unix)]
|
||||
pub fn terminate_stream() -> Result<Signal> {
|
||||
tokio::signal::unix::signal(SignalKind::terminate())
|
||||
.context("could not install a SIGTERM handler")
|
||||
|
||||
+1
-70
@@ -49,79 +49,10 @@
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use crate::common::aec::{AecConfig, AecParseError, parse_aec_arg};
|
||||
use crate::host::observer::Millis;
|
||||
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).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AecState {
|
||||
|
||||
+63
-1
@@ -1,11 +1,21 @@
|
||||
mod capabilities;
|
||||
mod cli;
|
||||
mod common;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod doctor;
|
||||
#[cfg(feature = "gui")]
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/doctor.rs"]
|
||||
mod doctor;
|
||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||
mod gui;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod host;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod interactive;
|
||||
#[cfg(target_os = "windows")]
|
||||
#[path = "windows/interactive.rs"]
|
||||
mod interactive;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod repair;
|
||||
mod viewer;
|
||||
|
||||
@@ -20,6 +30,11 @@ async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
init_tracing(cli.verbose);
|
||||
|
||||
run(cli).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn run(cli: Cli) -> Result<()> {
|
||||
if cli.capabilities {
|
||||
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) {
|
||||
let default = if verbose {
|
||||
"pixelpass=trace,iroh=info"
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user