feat(windows): add viewer-only PixelPass milestone
This commit is contained in:
+9
-4
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
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
@@ -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 ──────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
|
/// 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";
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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