diff --git a/Cargo.toml b/Cargo.toml index 7632e8d..016b258 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index ebe505e..c2501ff 100644 --- a/README.md +++ b/README.md @@ -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 `) +- 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 ``` diff --git a/docs/WINDOWS.md b/docs/WINDOWS.md new file mode 100644 index 0000000..30cb425 --- /dev/null +++ b/docs/WINDOWS.md @@ -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. diff --git a/src/capabilities.rs b/src/capabilities.rs index 8296484..cd01a4c 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -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); } } diff --git a/src/cli.rs b/src/cli.rs index 8d97c61..4627e06 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, // ── host options ────────────────────────────────────────────────── diff --git a/src/common/aec.rs b/src/common/aec.rs new file mode 100644 index 0000000..9d1fb31 --- /dev/null +++ b/src/common/aec.rs @@ -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:` 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:` 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 { + 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::() + .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)); + } +} diff --git a/src/common/alpn.rs b/src/common/alpn.rs index f708d5c..06cec6a 100644 --- a/src/common/alpn.rs +++ b/src/common/alpn.rs @@ -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"; diff --git a/src/common/endpoint.rs b/src/common/endpoint.rs index 49caaeb..c6e9a19 100644 --- a/src/common/endpoint.rs +++ b/src/common/endpoint.rs @@ -53,7 +53,7 @@ pub async fn bind(relay: Option<&str>) -> Result { /// 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 { let secret_key = super::identity::load_or_create()?; bind_with(relay, Some(secret_key), super::alpn::CONTROL_ALPN).await diff --git a/src/common/mod.rs b/src/common/mod.rs index b4691c6..56edfbb 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -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; diff --git a/src/common/output.rs b/src/common/output.rs index 5f1a6b2..7c469a0 100644 --- a/src/common/output.rs +++ b/src/common/output.rs @@ -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}; diff --git a/src/common/process.rs b/src/common/process.rs index 2bb6bb8..b054e2a 100644 --- a/src/common/process.rs +++ b/src/common/process.rs @@ -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 /// `` 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; diff --git a/src/common/signal.rs b/src/common/signal.rs index be48693..863b102 100644 --- a/src/common/signal.rs +++ b/src/common/signal.rs @@ -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 { tokio::signal::unix::signal(SignalKind::terminate()) .context("could not install a SIGTERM handler") diff --git a/src/host/aec/mod.rs b/src/host/aec/mod.rs index 398843c..01bb2e5 100644 --- a/src/host/aec/mod.rs +++ b/src/host/aec/mod.rs @@ -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:` 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:` — 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:` 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 { - 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::() - .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 { diff --git a/src/main.rs b/src/main.rs index 7857d1d..caf4203 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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" diff --git a/src/windows/doctor.rs b/src/windows/doctor.rs new file mode 100644 index 0000000..8a4c00e --- /dev/null +++ b/src/windows/doctor.rs @@ -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) -> 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 { + 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()); + } +} diff --git a/src/windows/interactive.rs b/src/windows/interactive.rs new file mode 100644 index 0000000..2d7899e --- /dev/null +++ b/src/windows/interactive.rs @@ -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 { + 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 { + 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 + }) +}