Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e39251189 | ||
|
|
c2d04b35ba | ||
|
|
b4be8deb46 | ||
|
|
e8f86b0ac2 | ||
|
|
5d519ede78 | ||
|
|
ccb183219f | ||
|
|
d23848decc | ||
|
|
48f5510699 | ||
|
|
125e44c033 | ||
|
|
57328f740c | ||
|
|
0187bc9bcf | ||
|
|
90e0dc8621 | ||
|
|
0be92f36a5 | ||
|
|
6f0fd088f6 | ||
|
|
e7ded10db8 | ||
|
|
6619bc9b0f |
Generated
+2636
-181
File diff suppressed because it is too large
Load Diff
@@ -33,8 +33,17 @@ arboard = { version = "3", default-features = false, features = ["wayland-data-c
|
|||||||
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
ureq = { version = "3", default-features = false, features = ["rustls"] }
|
||||||
toml = "1"
|
toml = "1"
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
|
eframe = { version = "0.34.2", default-features = false, features = ["glow", "default_fonts", "wayland", "x11"], optional = true }
|
||||||
|
tray-icon = { version = "0.24.0", optional = true }
|
||||||
|
notify-rust = { version = "4.17.0", optional = true }
|
||||||
|
gtk = { version = "0.18.2", optional = true }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
strip = "symbols"
|
strip = "symbols"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
# Opt-in graphical front-end (pixelpass --gui). Default-off so the headless
|
||||||
|
# build never pulls the GUI toolkit tree.
|
||||||
|
gui = ["dep:eframe", "dep:tray-icon", "dep:notify-rust", "dep:gtk"]
|
||||||
|
|||||||
@@ -73,6 +73,24 @@ pixelpass <ticket>
|
|||||||
# then run the printed mpv command in another terminal
|
# then run the printed mpv command in another terminal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Graphical (optional)
|
||||||
|
|
||||||
|
A small window front-end is available in builds compiled with the `gui`
|
||||||
|
feature (see [Build](#build)):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pixelpass --gui
|
||||||
|
```
|
||||||
|
|
||||||
|
Host: pick quality / max-viewers / options, click **Start hosting**, and the
|
||||||
|
share code appears with a copy button alongside a live viewer count. View:
|
||||||
|
paste a code, pick mpv or VLC, click **Connect** and the player launches.
|
||||||
|
|
||||||
|
The window is a thin driver — it runs the same headless `pixelpass` as a
|
||||||
|
child process and reads its event stream, so the GUI is purely additive and
|
||||||
|
the capture machinery is untouched by it. On a build without the feature,
|
||||||
|
`--gui` prints a hint to rebuild with it.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Linux (Wayland or X11; the backend is autodetected)
|
- Linux (Wayland or X11; the backend is autodetected)
|
||||||
@@ -119,6 +137,14 @@ cargo build --release
|
|||||||
|
|
||||||
`rustc` 1.95+ / edition 2024.
|
`rustc` 1.95+ / edition 2024.
|
||||||
|
|
||||||
|
The optional graphical front-end (`pixelpass --gui`) is behind a default-off
|
||||||
|
cargo feature so the headless build stays lean (it pulls the egui/eframe
|
||||||
|
windowing stack). Build it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build --release --features gui
|
||||||
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+23
@@ -13,6 +13,12 @@ pub struct Cli {
|
|||||||
pub ticket: Option<String>,
|
pub ticket: Option<String>,
|
||||||
|
|
||||||
// ── host options ──────────────────────────────────────────────────
|
// ── host options ──────────────────────────────────────────────────
|
||||||
|
/// Run as host without the interactive menu. Equivalent to picking
|
||||||
|
/// "Host" in the menu, but headless — for scripting and the --gui
|
||||||
|
/// front-end, which drives this binary as a child process.
|
||||||
|
#[arg(long)]
|
||||||
|
pub host: bool,
|
||||||
|
|
||||||
/// Pick a single window instead of the whole screen.
|
/// Pick a single window instead of the whole screen.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub window: bool,
|
pub window: bool,
|
||||||
@@ -62,6 +68,17 @@ pub struct Cli {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
|
||||||
// ── global ────────────────────────────────────────────────────────
|
// ── global ────────────────────────────────────────────────────────
|
||||||
|
/// Launch the graphical front-end (a window with Host/View controls)
|
||||||
|
/// instead of the terminal menu. Requires a build with `--features gui`.
|
||||||
|
#[arg(long)]
|
||||||
|
pub gui: bool,
|
||||||
|
|
||||||
|
/// Emit machine-readable events on stdout (one JSON object per line)
|
||||||
|
/// alongside the human banner on stderr. For scripts and the --gui
|
||||||
|
/// front-end. Currently only `json` is supported.
|
||||||
|
#[arg(long, value_enum, value_name = "FORMAT")]
|
||||||
|
pub output: Option<OutputFormat>,
|
||||||
|
|
||||||
/// Trace-level logging.
|
/// Trace-level logging.
|
||||||
#[arg(long, short)]
|
#[arg(long, short)]
|
||||||
pub verbose: bool,
|
pub verbose: bool,
|
||||||
@@ -83,6 +100,12 @@ pub enum DisplayServerArg {
|
|||||||
X11,
|
X11,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum OutputFormat {
|
||||||
|
/// One JSON object per line on stdout.
|
||||||
|
Json,
|
||||||
|
}
|
||||||
|
|
||||||
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
|
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
|
||||||
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
|
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
|
||||||
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
|
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod bandwidth;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod display;
|
pub mod display;
|
||||||
|
pub mod output;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod signal;
|
pub mod signal;
|
||||||
pub mod tunnel;
|
pub mod tunnel;
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Machine-readable event stream for non-interactive front-ends.
|
||||||
|
//!
|
||||||
|
//! When enabled with `--output json`, the host and viewer emit one JSON
|
||||||
|
//! object per line on **stdout**. The human banner and `tracing` logs stay
|
||||||
|
//! on **stderr**, so the two streams never interleave and a parser reading
|
||||||
|
//! stdout sees only events. Each line is flushed immediately so a front-end
|
||||||
|
//! reading the pipe gets events live rather than in block-buffered chunks.
|
||||||
|
//!
|
||||||
|
//! This is the shell-out counterpart to an in-process event channel: the
|
||||||
|
//! `--gui` front-end re-execs this binary as `pixelpass --host --output json`
|
||||||
|
//! and parses these lines to drive its window.
|
||||||
|
|
||||||
|
use std::io::Write;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
static JSON_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
/// Turn JSON event output on. Called once at startup from `--output json`.
|
||||||
|
pub fn set_json(enabled: bool) {
|
||||||
|
JSON_ENABLED.store(enabled, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_enabled() -> bool {
|
||||||
|
JSON_ENABLED.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One event in the stdout stream. Serialized as `{"event":"<tag>", ...}`.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(tag = "event", rename_all = "snake_case")]
|
||||||
|
pub enum Event<'a> {
|
||||||
|
/// The relay-only ticket the viewer needs. Emitted once at host startup.
|
||||||
|
Ticket { value: &'a str },
|
||||||
|
/// One-shot host configuration summary, mirroring the banner fields.
|
||||||
|
HostInfo {
|
||||||
|
display_server: &'a str,
|
||||||
|
capture: &'a str,
|
||||||
|
quality: &'a str,
|
||||||
|
dimensions: &'a str,
|
||||||
|
hw_encode: bool,
|
||||||
|
max_viewers: u32,
|
||||||
|
max_viewers_source: &'a str,
|
||||||
|
},
|
||||||
|
/// A new viewer joined.
|
||||||
|
ViewerJoined { id: &'a str, active: u32, max: u32 },
|
||||||
|
/// A viewer disconnected.
|
||||||
|
ViewerLeft { id: &'a str, active: u32, max: u32 },
|
||||||
|
/// Capture pipeline lifecycle (spawned on first viewer, torn down on last).
|
||||||
|
Capture { state: CaptureState },
|
||||||
|
/// A viewer was turned away (host full, or capture spawn failed).
|
||||||
|
ViewerRefused { reason: &'a str },
|
||||||
|
/// Viewer-side: the local player URL is ready to open.
|
||||||
|
Connected { url: &'a str },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CaptureState {
|
||||||
|
Started,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit one event as a JSON line on stdout, flushed. No-op unless JSON
|
||||||
|
/// output was enabled with [`set_json`], so call sites can sprinkle these
|
||||||
|
/// unconditionally without branching.
|
||||||
|
pub fn emit(event: Event) {
|
||||||
|
if !json_enabled() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match serde_json::to_string(&event) {
|
||||||
|
Ok(line) => {
|
||||||
|
let mut out = std::io::stdout().lock();
|
||||||
|
// Best-effort: a closed pipe (front-end gone) shouldn't crash the
|
||||||
|
// host — it keeps streaming to any viewers already connected.
|
||||||
|
let _ = writeln!(out, "{line}");
|
||||||
|
let _ = out.flush();
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!("failed to serialize event: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
//! Drives a headless `pixelpass` child process for the GUI.
|
||||||
|
//!
|
||||||
|
//! The GUI re-execs this same binary (via [`std::env::current_exe`]) in
|
||||||
|
//! headless mode with `--output json`, then reads the child's JSON event
|
||||||
|
//! stream on a background thread and forwards parsed events over a channel the
|
||||||
|
//! egui app drains each frame. stderr is captured into a small ring so a
|
||||||
|
//! failed launch (e.g. a missing gst plugin) can be surfaced in the window.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::mpsc::Receiver;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
use nix::sys::signal::{Signal, kill};
|
||||||
|
use nix::unistd::Pid;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// One parsed event from the child's stdout. Owned mirror of
|
||||||
|
/// [`crate::common::output::Event`] (which borrows for emit); kept separate so
|
||||||
|
/// the wire format and the parser can evolve independently.
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
#[serde(tag = "event", rename_all = "snake_case")]
|
||||||
|
pub enum ChildEvent {
|
||||||
|
Ticket {
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
|
HostInfo {
|
||||||
|
display_server: String,
|
||||||
|
capture: String,
|
||||||
|
quality: String,
|
||||||
|
dimensions: String,
|
||||||
|
hw_encode: bool,
|
||||||
|
max_viewers: u32,
|
||||||
|
max_viewers_source: String,
|
||||||
|
},
|
||||||
|
ViewerJoined {
|
||||||
|
id: String,
|
||||||
|
active: u32,
|
||||||
|
max: u32,
|
||||||
|
},
|
||||||
|
ViewerLeft {
|
||||||
|
id: String,
|
||||||
|
active: u32,
|
||||||
|
max: u32,
|
||||||
|
},
|
||||||
|
Capture {
|
||||||
|
state: CaptureState,
|
||||||
|
},
|
||||||
|
ViewerRefused {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
Connected {
|
||||||
|
url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CaptureState {
|
||||||
|
Started,
|
||||||
|
Stopped,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STDERR_TAIL_MAX: usize = 60;
|
||||||
|
|
||||||
|
pub struct ChildProc {
|
||||||
|
child: Child,
|
||||||
|
pub rx: Receiver<ChildEvent>,
|
||||||
|
stderr_tail: Arc<Mutex<Vec<String>>>,
|
||||||
|
stdin: std::process::ChildStdin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChildProc {
|
||||||
|
/// Spawn `pixelpass <args>` as a child, wiring up the event reader. `ctx`
|
||||||
|
/// is repainted whenever an event arrives so the UI updates live.
|
||||||
|
pub fn spawn(args: &[String], ctx: egui::Context) -> std::io::Result<Self> {
|
||||||
|
let exe = std::env::current_exe()?;
|
||||||
|
let mut child = Command::new(exe)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
let stdin = child.stdin.take().expect("stdin piped");
|
||||||
|
let stdout = child.stdout.take().expect("stdout piped");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(stdout);
|
||||||
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Ignore any non-event line rather than dropping the stream.
|
||||||
|
if let Ok(ev) = serde_json::from_str::<ChildEvent>(&line) {
|
||||||
|
if tx.send(ev).is_err() {
|
||||||
|
break; // app gone
|
||||||
|
}
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderr_tail = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let stderr = child.stderr.take().expect("stderr piped");
|
||||||
|
let tail = stderr_tail.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let reader = BufReader::new(stderr);
|
||||||
|
for line in reader.lines().map_while(Result::ok) {
|
||||||
|
let mut t = tail.lock().unwrap();
|
||||||
|
t.push(line);
|
||||||
|
let overflow = t.len().saturating_sub(STDERR_TAIL_MAX);
|
||||||
|
if overflow > 0 {
|
||||||
|
t.drain(0..overflow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
child,
|
||||||
|
rx,
|
||||||
|
stderr_tail,
|
||||||
|
stdin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the child is still running.
|
||||||
|
pub fn is_alive(&mut self) -> bool {
|
||||||
|
matches!(self.child.try_wait(), Ok(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last captured stderr lines, joined — for error display.
|
||||||
|
pub fn stderr_tail(&self) -> String {
|
||||||
|
self.stderr_tail.lock().unwrap().join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a newline-terminated command to the child.
|
||||||
|
pub fn send_command(&mut self, cmd: &str) {
|
||||||
|
use std::io::Write;
|
||||||
|
if let Err(e) = writeln!(self.stdin, "{cmd}") {
|
||||||
|
tracing::warn!("failed to send command to child: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gracefully stop the child: SIGINT (so the host runs its ctrl-c teardown
|
||||||
|
/// — tears down capture, closes the endpoint), with a ~2 s grace period
|
||||||
|
/// before a hard kill. Idempotent.
|
||||||
|
pub fn stop(&mut self) {
|
||||||
|
if matches!(self.child.try_wait(), Ok(Some(_))) {
|
||||||
|
return; // already exited
|
||||||
|
}
|
||||||
|
let _ = kill(Pid::from_raw(self.child.id() as i32), Signal::SIGINT);
|
||||||
|
for _ in 0..40 {
|
||||||
|
if matches!(self.child.try_wait(), Ok(Some(_))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ChildProc {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Closing the window (dropping the app, hence the session) must not
|
||||||
|
// orphan a live host child streaming to viewers.
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::common::output::{CaptureState as EmitState, Event};
|
||||||
|
|
||||||
|
// The GUI parses what the headless child emits. These round-trip the
|
||||||
|
// emitter's own types (`common::output::Event`) through the parser
|
||||||
|
// (`ChildEvent`) so a rename on either side of the wire fails here rather
|
||||||
|
// than silently breaking the GUI at runtime.
|
||||||
|
fn parse(emit: Event) -> ChildEvent {
|
||||||
|
let line = serde_json::to_string(&emit).unwrap();
|
||||||
|
serde_json::from_str::<ChildEvent>(&line).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ticket_round_trips() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Ticket { value: "endpointXYZ" }),
|
||||||
|
ChildEvent::Ticket { value } if value == "endpointXYZ"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_info_round_trips() {
|
||||||
|
let ev = parse(Event::HostInfo {
|
||||||
|
display_server: "Wayland",
|
||||||
|
capture: "fullscreen + system-audio",
|
||||||
|
quality: "Medium",
|
||||||
|
dimensions: "≤720p / 2500 kbps / 30 fps",
|
||||||
|
hw_encode: true,
|
||||||
|
max_viewers: 3,
|
||||||
|
max_viewers_source: "user-specified",
|
||||||
|
});
|
||||||
|
match ev {
|
||||||
|
ChildEvent::HostInfo {
|
||||||
|
display_server,
|
||||||
|
quality,
|
||||||
|
hw_encode,
|
||||||
|
max_viewers,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(display_server, "Wayland");
|
||||||
|
assert_eq!(quality, "Medium");
|
||||||
|
assert!(hw_encode);
|
||||||
|
assert_eq!(max_viewers, 3);
|
||||||
|
}
|
||||||
|
other => panic!("expected HostInfo, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn viewer_events_round_trips() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::ViewerJoined { id: "nodeXYZ", active: 2, max: 4 }),
|
||||||
|
ChildEvent::ViewerJoined { id, active: 2, max: 4 } if id == "nodeXYZ"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::ViewerLeft { id: "nodeXYZ", active: 1, max: 4 }),
|
||||||
|
ChildEvent::ViewerLeft { id, active: 1, max: 4 } if id == "nodeXYZ"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_state_round_trips() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Capture { state: EmitState::Started }),
|
||||||
|
ChildEvent::Capture { state: CaptureState::Started }
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Capture { state: EmitState::Stopped }),
|
||||||
|
ChildEvent::Capture { state: CaptureState::Stopped }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refused_and_connected_round_trip() {
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::ViewerRefused { reason: "host is full" }),
|
||||||
|
ChildEvent::ViewerRefused { reason } if reason == "host is full"
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
parse(Event::Connected { url: "http://127.0.0.1:5000" }),
|
||||||
|
ChildEvent::Connected { url } if url == "http://127.0.0.1:5000"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
+881
@@ -0,0 +1,881 @@
|
|||||||
|
//! Graphical front-end (`pixelpass --gui`), compiled only with the `gui`
|
||||||
|
//! feature.
|
||||||
|
//!
|
||||||
|
//! Architecture: this window is a thin **shell-out** driver. It never touches
|
||||||
|
//! the capture / portal / gst / iroh machinery directly — instead it re-execs
|
||||||
|
//! this same binary in headless mode (`pixelpass --host --output json …` or
|
||||||
|
//! `pixelpass <ticket> --output json`) as a child process and parses the
|
||||||
|
//! child's JSON event stream (see [`crate::common::output`]) to drive what it
|
||||||
|
//! shows. That keeps the fragile capture stack sealed in a separate process:
|
||||||
|
//! the GUI can be closed or crash without taking a live stream down.
|
||||||
|
|
||||||
|
mod child;
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use self::child::{ChildEvent, ChildProc};
|
||||||
|
|
||||||
|
/// Launch the GUI event loop. Blocks until the window is closed. Runs on the
|
||||||
|
/// main thread (a winit requirement), which is where `main` calls it from.
|
||||||
|
pub fn run() -> anyhow::Result<()> {
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([520.0, 480.0])
|
||||||
|
.with_min_inner_size([460.0, 380.0])
|
||||||
|
.with_title("PixelPass"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"PixelPass",
|
||||||
|
options,
|
||||||
|
Box::new(|_cc| Ok(Box::new(PixelPassApp::default()))),
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("GUI failed to start: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort clipboard write. Returns whether it succeeded so callers can
|
||||||
|
/// show an honest "✓ Copied" / fallback hint (the clipboard can be flaky on
|
||||||
|
/// Wayland, and a silent miss is what left users pasting stale tickets).
|
||||||
|
fn set_clipboard(text: &str) -> bool {
|
||||||
|
arboard::Clipboard::new()
|
||||||
|
.and_then(|mut cb| cb.set_text(text.to_owned()))
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort clipboard read, backing the viewer Paste button and the
|
||||||
|
/// View-screen prefill. `None` on a flaky/empty clipboard — callers just leave
|
||||||
|
/// the field untouched.
|
||||||
|
fn get_clipboard() -> Option<String> {
|
||||||
|
arboard::Clipboard::new()
|
||||||
|
.and_then(|mut cb| cb.get_text())
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the host endpoint id from a ticket string, using the exact same
|
||||||
|
/// parse the viewer does (`EndpointTicket::from_str`), so the GUI agrees with
|
||||||
|
/// the child about what's a valid code. `None` means it isn't a pixelpass
|
||||||
|
/// ticket at all. Used to surface a stale/garbage paste *before* the 15s
|
||||||
|
/// connect timeout, and to show which host the viewer is dialing — the missing
|
||||||
|
/// signal that let people repeatedly dial a long-dead host.
|
||||||
|
fn ticket_endpoint_id(ticket: &str) -> Option<String> {
|
||||||
|
ticket
|
||||||
|
.trim()
|
||||||
|
.parse::<iroh_tickets::endpoint::EndpointTicket>()
|
||||||
|
.ok()
|
||||||
|
.map(|t| t.endpoint_addr().id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eyeball-comparable short form of an endpoint id (full ids are long). Enough
|
||||||
|
/// to spot that two ids differ; the host and viewer screens show the same
|
||||||
|
/// truncation so a stale ticket reads as an obvious mismatch.
|
||||||
|
fn short_id(id: &str) -> String {
|
||||||
|
let prefix: String = id.chars().take(12).collect();
|
||||||
|
if prefix.len() < id.len() {
|
||||||
|
format!("{prefix}…")
|
||||||
|
} else {
|
||||||
|
prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which screen the single window is currently showing.
|
||||||
|
#[derive(Default, PartialEq)]
|
||||||
|
enum Screen {
|
||||||
|
#[default]
|
||||||
|
Menu,
|
||||||
|
Host,
|
||||||
|
Viewer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quality preset choices, mirroring `cli::Quality`. Map to the `--quality`
|
||||||
|
/// argument value passed to the child.
|
||||||
|
#[derive(Default, PartialEq, Clone, Copy)]
|
||||||
|
enum QualitySel {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Source,
|
||||||
|
High,
|
||||||
|
Medium,
|
||||||
|
Low,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QualitySel {
|
||||||
|
fn as_arg(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
QualitySel::Auto => "auto",
|
||||||
|
QualitySel::Source => "source",
|
||||||
|
QualitySel::High => "high",
|
||||||
|
QualitySel::Medium => "medium",
|
||||||
|
QualitySel::Low => "low",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
QualitySel::Auto => "Auto — pick from my upload speed",
|
||||||
|
QualitySel::Source => "Source — native resolution",
|
||||||
|
QualitySel::High => "High — up to 1080p",
|
||||||
|
QualitySel::Medium => "Medium — up to 720p",
|
||||||
|
QualitySel::Low => "Low — up to 480p",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL: [QualitySel; 5] = [
|
||||||
|
QualitySel::Auto,
|
||||||
|
QualitySel::Source,
|
||||||
|
QualitySel::High,
|
||||||
|
QualitySel::Medium,
|
||||||
|
QualitySel::Low,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Player choices for the viewer screen.
|
||||||
|
#[derive(Default, PartialEq, Clone, Copy)]
|
||||||
|
enum PlayerSel {
|
||||||
|
#[default]
|
||||||
|
Mpv,
|
||||||
|
Vlc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlayerSel {
|
||||||
|
fn to_player(self) -> crate::interactive::Player {
|
||||||
|
match self {
|
||||||
|
PlayerSel::Mpv => crate::interactive::Player::Mpv,
|
||||||
|
PlayerSel::Vlc => crate::interactive::Player::Vlc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-screen state: the config form fields plus, once started, the running
|
||||||
|
/// child and the latest values parsed from its event stream.
|
||||||
|
struct HostState {
|
||||||
|
// form
|
||||||
|
quality: QualitySel,
|
||||||
|
max_viewers: u32, // 0 = let the host auto-size from the bandwidth preflight
|
||||||
|
no_hwencode: bool,
|
||||||
|
window: bool,
|
||||||
|
// running session + accumulated live state
|
||||||
|
proc: Option<ChildProc>,
|
||||||
|
ticket: Option<String>,
|
||||||
|
info: Option<HostInfo>,
|
||||||
|
active: u32,
|
||||||
|
max: u32,
|
||||||
|
capturing: bool,
|
||||||
|
/// Whether the current ticket made it onto the clipboard (auto-copy on
|
||||||
|
/// arrival, or a manual Copy click). Drives the "✓ Copied" hint so the
|
||||||
|
/// user isn't left guessing whether to click Copy — the trap that had
|
||||||
|
/// people pasting a stale clipboard ticket.
|
||||||
|
copied: bool,
|
||||||
|
last_refusal: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
viewers: Vec<String>,
|
||||||
|
tray_icon: Option<tray_icon::TrayIcon>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HostState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
quality: QualitySel::default(),
|
||||||
|
max_viewers: 0,
|
||||||
|
no_hwencode: false,
|
||||||
|
window: false,
|
||||||
|
proc: None,
|
||||||
|
ticket: None,
|
||||||
|
info: None,
|
||||||
|
active: 0,
|
||||||
|
max: 0,
|
||||||
|
capturing: false,
|
||||||
|
copied: false,
|
||||||
|
last_refusal: None,
|
||||||
|
error: None,
|
||||||
|
viewers: Vec::new(),
|
||||||
|
tray_icon: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host config summary echoed back by the child's `host_info` event.
|
||||||
|
struct HostInfo {
|
||||||
|
display: String,
|
||||||
|
capture: String,
|
||||||
|
quality: String,
|
||||||
|
dimensions: String,
|
||||||
|
hw_encode: bool,
|
||||||
|
cap_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Viewer-screen state.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ViewerState {
|
||||||
|
ticket_input: String,
|
||||||
|
player: PlayerSel,
|
||||||
|
proc: Option<ChildProc>,
|
||||||
|
url: Option<String>,
|
||||||
|
launched: bool,
|
||||||
|
/// Short endpoint id we're dialing, decoded from the ticket at Connect.
|
||||||
|
/// Shown in the "Connecting to …" line so a dead host is identifiable.
|
||||||
|
connecting_to: Option<String>,
|
||||||
|
/// Set when the View screen opens so the code field grabs focus once
|
||||||
|
/// (cleared on use, so it doesn't steal focus every frame).
|
||||||
|
focus_ticket: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct PixelPassApp {
|
||||||
|
screen: Screen,
|
||||||
|
host: HostState,
|
||||||
|
viewer: ViewerState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for PixelPassApp {
|
||||||
|
// eframe 0.34 hands us the central-panel `ui` directly.
|
||||||
|
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||||
|
// Drain any pending child events before drawing this frame.
|
||||||
|
self.pump_host_events();
|
||||||
|
self.pump_viewer_events();
|
||||||
|
|
||||||
|
if let Ok(_event) = tray_icon::menu::MenuEvent::receiver().try_recv() {
|
||||||
|
// Only one menu item right now: "Stop Hosting"
|
||||||
|
self.stop_host();
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.screen {
|
||||||
|
Screen::Menu => self.menu(ui),
|
||||||
|
Screen::Host => self.host(ui),
|
||||||
|
Screen::Viewer => self.viewer(ui),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PixelPassApp {
|
||||||
|
fn menu(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(24.0);
|
||||||
|
ui.heading("PixelPass");
|
||||||
|
ui.label("P2P screen sharing");
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(concat!("v", env!("CARGO_PKG_VERSION")))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.add_space(32.0);
|
||||||
|
if ui
|
||||||
|
.add_sized([260.0, 40.0], egui::Button::new("Host — share my screen"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.screen = Screen::Host;
|
||||||
|
}
|
||||||
|
ui.add_space(8.0);
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
[260.0, 40.0],
|
||||||
|
egui::Button::new("View — watch someone's screen"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.screen = Screen::Viewer;
|
||||||
|
self.prefill_viewer_ticket();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Host screen ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn host(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let running = self.host.proc.is_some();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// Leaving the host screen stops the session (Drop on the child).
|
||||||
|
if ui.button("← Menu").clicked() {
|
||||||
|
self.stop_host();
|
||||||
|
self.screen = Screen::Menu;
|
||||||
|
}
|
||||||
|
ui.heading("Host");
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if running {
|
||||||
|
self.host_running(ui);
|
||||||
|
} else {
|
||||||
|
self.host_form(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_form(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if let Some(err) = &self.host.error {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
egui::Grid::new("host_form")
|
||||||
|
.num_columns(2)
|
||||||
|
.spacing([12.0, 10.0])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label("Quality");
|
||||||
|
egui::ComboBox::from_id_salt("quality")
|
||||||
|
.selected_text(self.host.quality.label())
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for q in QualitySel::ALL {
|
||||||
|
ui.selectable_value(&mut self.host.quality, q, q.label());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("Max viewers");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(egui::DragValue::new(&mut self.host.max_viewers).range(0..=16));
|
||||||
|
if self.host.max_viewers == 0 {
|
||||||
|
ui.label("(auto from upload speed)");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
|
||||||
|
ui.label("Options");
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.checkbox(&mut self.host.window, "Share a single window");
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.host.no_hwencode,
|
||||||
|
"Software encoding (no GPU / VAAPI)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
if ui
|
||||||
|
.add_sized([160.0, 36.0], egui::Button::new("Start hosting"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.start_host(ui.ctx().clone());
|
||||||
|
}
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"On Wayland a \"Share Screen?\" dialog appears when the first \
|
||||||
|
viewer connects.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_running(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if self.host.capturing {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
||||||
|
} else if self.host.ticket.is_some() {
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, "● Waiting for viewers…");
|
||||||
|
} else {
|
||||||
|
ui.label("Starting…");
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(6.0);
|
||||||
|
ui.label(format!("Viewers: {} / {}", self.host.active, self.host.max));
|
||||||
|
if !self.host.viewers.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
for id in &self.host.viewers.clone() {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(format!("• {}", short_id(id)));
|
||||||
|
if ui.button("Kick").clicked() {
|
||||||
|
if let Some(p) = &mut self.host.proc {
|
||||||
|
p.send_command(&format!("kick {id}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(info) = &self.host.info {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("{} · {}", info.display, info.capture))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!(
|
||||||
|
"{} · {} · {} · cap {}",
|
||||||
|
info.quality,
|
||||||
|
info.dimensions,
|
||||||
|
if info.hw_encode {
|
||||||
|
"HW encode"
|
||||||
|
} else {
|
||||||
|
"software encode"
|
||||||
|
},
|
||||||
|
info.cap_source
|
||||||
|
))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
if let Some(ticket) = self.host.ticket.clone() {
|
||||||
|
ui.label("Share this code with your viewer(s):");
|
||||||
|
if let Some(id) = ticket_endpoint_id(&ticket) {
|
||||||
|
// The viewer shows "Connecting to <id>…" with this same
|
||||||
|
// truncation, so the two ends can be eyeballed for a match.
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(format!("This host: endpoint {}", short_id(&id)))
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.add_space(4.0);
|
||||||
|
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(egui::RichText::new(&ticket).monospace().small())
|
||||||
|
.wrap()
|
||||||
|
.selectable(true),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("📋 Copy code").clicked() {
|
||||||
|
self.copy_to_clipboard(&ticket);
|
||||||
|
}
|
||||||
|
if self.host.copied {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "✓ Copied to clipboard");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !self.host.copied {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(
|
||||||
|
"Couldn't auto-copy — click Copy, or select the code above.",
|
||||||
|
)
|
||||||
|
.small()
|
||||||
|
.weak(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reason) = &self.host.last_refusal {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.colored_label(
|
||||||
|
egui::Color32::from_rgb(220, 160, 60),
|
||||||
|
format!("⚠ {reason}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
if ui
|
||||||
|
.add_sized([140.0, 36.0], egui::Button::new("Stop hosting"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.stop_host();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_host(&mut self, ctx: egui::Context) {
|
||||||
|
self.host.error = None;
|
||||||
|
self.host.last_refusal = None;
|
||||||
|
self.host.ticket = None;
|
||||||
|
self.host.info = None;
|
||||||
|
self.host.active = 0;
|
||||||
|
self.host.max = 0;
|
||||||
|
self.host.capturing = false;
|
||||||
|
self.host.copied = false;
|
||||||
|
|
||||||
|
let mut args = vec![
|
||||||
|
"--host".to_string(),
|
||||||
|
"--output".to_string(),
|
||||||
|
"json".to_string(),
|
||||||
|
"--quality".to_string(),
|
||||||
|
self.host.quality.as_arg().to_string(),
|
||||||
|
];
|
||||||
|
if self.host.max_viewers > 0 {
|
||||||
|
args.push("--max-viewers".to_string());
|
||||||
|
args.push(self.host.max_viewers.to_string());
|
||||||
|
}
|
||||||
|
if self.host.no_hwencode {
|
||||||
|
args.push("--no-hwencode".to_string());
|
||||||
|
}
|
||||||
|
if self.host.window {
|
||||||
|
args.push("--window".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
match ChildProc::spawn(&args, ctx) {
|
||||||
|
Ok(p) => {
|
||||||
|
self.host.proc = Some(p);
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if let Err(e) = gtk::init() {
|
||||||
|
tracing::warn!("Failed to initialize GTK for system tray: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
use tray_icon::{TrayIconBuilder, menu::{Menu, MenuItem}};
|
||||||
|
let menu = Menu::new();
|
||||||
|
let _ = menu.append(&MenuItem::new("Stop Hosting", true, None));
|
||||||
|
let icon = tray_icon::Icon::from_rgba(vec![255, 0, 0, 255], 1, 1).unwrap();
|
||||||
|
if let Ok(tray) = TrayIconBuilder::new()
|
||||||
|
.with_title("PixelPass")
|
||||||
|
.with_tooltip("PixelPass Screen Sharing")
|
||||||
|
.with_icon(icon)
|
||||||
|
.with_menu(Box::new(menu))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
self.host.tray_icon = Some(tray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.host.error = Some(format!("Couldn't start host: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_host(&mut self) {
|
||||||
|
// Dropping the ChildProc SIGINTs the child and reaps it.
|
||||||
|
self.host.proc = None;
|
||||||
|
self.host.capturing = false;
|
||||||
|
self.host.ticket = None;
|
||||||
|
self.host.copied = false;
|
||||||
|
self.host.viewers.clear();
|
||||||
|
self.host.tray_icon = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain the host child's event channel into state, and detect an
|
||||||
|
/// unexpected child exit (e.g. a failed dependency check) so it surfaces
|
||||||
|
/// in the form instead of leaving a dead "running" view.
|
||||||
|
fn pump_host_events(&mut self) {
|
||||||
|
let events: Vec<ChildEvent> = match &self.host.proc {
|
||||||
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
for ev in events {
|
||||||
|
self.apply_host_event(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(p) = &mut self.host.proc
|
||||||
|
&& !p.is_alive()
|
||||||
|
{
|
||||||
|
if self.host.ticket.is_none() {
|
||||||
|
let tail = p.stderr_tail();
|
||||||
|
self.host.error = Some(if tail.trim().is_empty() {
|
||||||
|
"Host exited before it could start.".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Host exited before it could start:\n{tail}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.host.proc = None;
|
||||||
|
self.host.capturing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_host_event(&mut self, ev: ChildEvent) {
|
||||||
|
match ev {
|
||||||
|
ChildEvent::Ticket { value } => {
|
||||||
|
// Auto-copy on arrival, mirroring the CLI/interactive host
|
||||||
|
// (which copies the ticket and prints "copied to your
|
||||||
|
// clipboard"). A failure here is non-fatal: the ticket stays
|
||||||
|
// visible for manual copy, and `copied` stays false so the UI
|
||||||
|
// doesn't falsely claim success.
|
||||||
|
self.host.copied = set_clipboard(&value);
|
||||||
|
self.host.ticket = Some(value);
|
||||||
|
}
|
||||||
|
ChildEvent::HostInfo {
|
||||||
|
display_server,
|
||||||
|
capture,
|
||||||
|
quality,
|
||||||
|
dimensions,
|
||||||
|
hw_encode,
|
||||||
|
max_viewers,
|
||||||
|
max_viewers_source,
|
||||||
|
} => {
|
||||||
|
self.host.max = max_viewers;
|
||||||
|
self.host.info = Some(HostInfo {
|
||||||
|
display: display_server,
|
||||||
|
capture,
|
||||||
|
quality,
|
||||||
|
dimensions,
|
||||||
|
hw_encode,
|
||||||
|
cap_source: max_viewers_source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ChildEvent::ViewerJoined { id, active, max } => {
|
||||||
|
self.host.active = active;
|
||||||
|
self.host.max = max;
|
||||||
|
if !self.host.viewers.contains(&id) {
|
||||||
|
self.host.viewers.push(id.clone());
|
||||||
|
let _ = notify_rust::Notification::new()
|
||||||
|
.summary("PixelPass Viewer Connected")
|
||||||
|
.body(&format!("Viewer {} joined the stream.", short_id(&id)))
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChildEvent::ViewerLeft { id, active, max } => {
|
||||||
|
self.host.active = active;
|
||||||
|
self.host.max = max;
|
||||||
|
self.host.viewers.retain(|v| v != &id);
|
||||||
|
let _ = notify_rust::Notification::new()
|
||||||
|
.summary("PixelPass Viewer Disconnected")
|
||||||
|
.body(&format!("Viewer {} left the stream.", short_id(&id)))
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
ChildEvent::Capture { state } => {
|
||||||
|
self.host.capturing = matches!(state, child::CaptureState::Started);
|
||||||
|
}
|
||||||
|
ChildEvent::ViewerRefused { reason } => self.host.last_refusal = Some(reason),
|
||||||
|
ChildEvent::Connected { .. } => {} // viewer-side; not used on the host screen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manual Copy-button handler: copy and reflect success in the UI, or
|
||||||
|
/// surface a clear error if the clipboard rejected it.
|
||||||
|
fn copy_to_clipboard(&mut self, text: &str) {
|
||||||
|
if set_clipboard(text) {
|
||||||
|
self.host.copied = true;
|
||||||
|
self.host.error = None;
|
||||||
|
} else {
|
||||||
|
self.host.copied = false;
|
||||||
|
self.host.error = Some(
|
||||||
|
"Couldn't write to the clipboard. Select the code above and copy it manually."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Viewer screen ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn viewer(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let running = self.viewer.proc.is_some();
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("← Menu").clicked() {
|
||||||
|
self.stop_viewer();
|
||||||
|
self.screen = Screen::Menu;
|
||||||
|
}
|
||||||
|
ui.heading("View");
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if running {
|
||||||
|
self.viewer_running(ui);
|
||||||
|
} else {
|
||||||
|
self.viewer_form(ui);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewer_form(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if let Some(err) = &self.viewer.error {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_RED, err);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.label("Paste the share code you received:");
|
||||||
|
ui.add_space(4.0);
|
||||||
|
// A Paste button (read-side mirror of the host's Copy button — one
|
||||||
|
// click grabs the code the host just put on the clipboard) with the
|
||||||
|
// field filling the rest of the row. A single horizontal row, so it
|
||||||
|
// doesn't grab the panel's full height.
|
||||||
|
let ticket_resp = ui
|
||||||
|
.horizontal(|ui| {
|
||||||
|
if ui.button("📋 Paste").clicked()
|
||||||
|
&& let Some(text) = get_clipboard()
|
||||||
|
{
|
||||||
|
self.viewer.ticket_input = text.trim().to_string();
|
||||||
|
}
|
||||||
|
ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.viewer.ticket_input)
|
||||||
|
.desired_width(f32::INFINITY)
|
||||||
|
.hint_text("endpoint…"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
// Grab focus once on screen entry so the user can paste/type straight
|
||||||
|
// away without first clicking into the field.
|
||||||
|
if std::mem::take(&mut self.viewer.focus_ticket) {
|
||||||
|
ticket_resp.request_focus();
|
||||||
|
}
|
||||||
|
// Enter in the field connects (gated on a decodable code below).
|
||||||
|
let enter_pressed =
|
||||||
|
ticket_resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||||
|
|
||||||
|
// Decode the pasted code live: confirms it's a real ticket and shows
|
||||||
|
// which host it points at, so a stale clipboard paste is caught here
|
||||||
|
// instead of after the 15s connect timeout.
|
||||||
|
let trimmed = self.viewer.ticket_input.trim().to_string();
|
||||||
|
let decoded_id = ticket_endpoint_id(&trimmed);
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
match &decoded_id {
|
||||||
|
Some(id) => ui.colored_label(
|
||||||
|
egui::Color32::LIGHT_GREEN,
|
||||||
|
format!("→ endpoint {}", short_id(id)),
|
||||||
|
),
|
||||||
|
None => ui.colored_label(
|
||||||
|
egui::Color32::from_rgb(220, 160, 60),
|
||||||
|
"⚠ This doesn't look like a share code.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(10.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Player");
|
||||||
|
egui::ComboBox::from_id_salt("player")
|
||||||
|
.selected_text(match self.viewer.player {
|
||||||
|
PlayerSel::Mpv => "mpv",
|
||||||
|
PlayerSel::Vlc => "VLC",
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Mpv, "mpv");
|
||||||
|
ui.selectable_value(&mut self.viewer.player, PlayerSel::Vlc, "VLC");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
// Only dial a code that actually decodes — no point spawning a child to
|
||||||
|
// spend 15s timing out against garbage. Enter takes the same path.
|
||||||
|
let connect_clicked = ui
|
||||||
|
.add_enabled(
|
||||||
|
decoded_id.is_some(),
|
||||||
|
egui::Button::new("Connect").min_size(egui::vec2(140.0, 36.0)),
|
||||||
|
)
|
||||||
|
.on_disabled_hover_text("Paste a valid share code first.")
|
||||||
|
.clicked();
|
||||||
|
if decoded_id.is_some() && (connect_clicked || enter_pressed) {
|
||||||
|
self.start_viewer(ui.ctx().clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn viewer_running(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if self.viewer.launched {
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_GREEN, "● Streaming");
|
||||||
|
ui.label("Player launched. Close it or disconnect to stop.");
|
||||||
|
} else if self.viewer.url.is_some() {
|
||||||
|
ui.label("Connected — launching player…");
|
||||||
|
} else {
|
||||||
|
let msg = match &self.viewer.connecting_to {
|
||||||
|
Some(id) => format!("● Connecting to {id}…"),
|
||||||
|
None => "● Connecting…".to_string(),
|
||||||
|
};
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
if ui
|
||||||
|
.add_sized([140.0, 36.0], egui::Button::new("Disconnect"))
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.stop_viewer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On entering the View screen, drop a clipboard ticket straight into the
|
||||||
|
/// field — the host's Copy button (and our auto-copy) usually leaves the
|
||||||
|
/// freshly-shared code right there. Guarded so it only fires when the field
|
||||||
|
/// is empty and the clipboard holds a *decodable* ticket, so stray
|
||||||
|
/// clipboard text never lands in the box; the live decode still shows the
|
||||||
|
/// id for the user to verify.
|
||||||
|
fn prefill_viewer_ticket(&mut self) {
|
||||||
|
if self.viewer.ticket_input.trim().is_empty()
|
||||||
|
&& self.viewer.proc.is_none()
|
||||||
|
&& let Some(text) = get_clipboard()
|
||||||
|
&& ticket_endpoint_id(&text).is_some()
|
||||||
|
{
|
||||||
|
self.viewer.ticket_input = text.trim().to_string();
|
||||||
|
}
|
||||||
|
self.viewer.focus_ticket = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_viewer(&mut self, ctx: egui::Context) {
|
||||||
|
self.viewer.error = None;
|
||||||
|
self.viewer.url = None;
|
||||||
|
self.viewer.launched = false;
|
||||||
|
|
||||||
|
let ticket = self.viewer.ticket_input.trim().to_string();
|
||||||
|
self.viewer.connecting_to = ticket_endpoint_id(&ticket).map(|id| short_id(&id));
|
||||||
|
let args = vec![ticket, "--output".to_string(), "json".to_string()];
|
||||||
|
match ChildProc::spawn(&args, ctx) {
|
||||||
|
Ok(p) => self.viewer.proc = Some(p),
|
||||||
|
Err(e) => self.viewer.error = Some(format!("Couldn't connect: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_viewer(&mut self) {
|
||||||
|
self.viewer.proc = None;
|
||||||
|
self.viewer.url = None;
|
||||||
|
self.viewer.launched = false;
|
||||||
|
self.viewer.connecting_to = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pump_viewer_events(&mut self) {
|
||||||
|
let events: Vec<ChildEvent> = match &self.viewer.proc {
|
||||||
|
Some(p) => std::iter::from_fn(|| p.rx.try_recv().ok()).collect(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
for ev in events {
|
||||||
|
if let ChildEvent::Connected { url } = ev {
|
||||||
|
self.viewer.url = Some(url.clone());
|
||||||
|
if !self.viewer.launched {
|
||||||
|
match self.viewer.player.to_player().spawn(&url) {
|
||||||
|
Ok(()) => self.viewer.launched = true,
|
||||||
|
Err(e) => self.viewer.error = Some(format!("Couldn't launch player: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(p) = &mut self.viewer.proc
|
||||||
|
&& !p.is_alive()
|
||||||
|
{
|
||||||
|
// Bridge ended (player closed) or the connection failed.
|
||||||
|
if self.viewer.url.is_none() {
|
||||||
|
let tail = p.stderr_tail();
|
||||||
|
self.viewer.error = Some(if tail.trim().is_empty() {
|
||||||
|
"Couldn't connect to the host (check the code).".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Connection ended:\n{tail}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.viewer.proc = None;
|
||||||
|
self.viewer.launched = false;
|
||||||
|
self.viewer.url = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A real, parseable ticket built the same way the host builds one
|
||||||
|
/// (`EndpointTicket::new`), from a deterministic key so the id is stable.
|
||||||
|
fn sample_ticket() -> (String, String) {
|
||||||
|
let sk = iroh::SecretKey::from_bytes(&[7u8; 32]);
|
||||||
|
let id = sk.public().to_string();
|
||||||
|
let ticket = iroh_tickets::endpoint::EndpointTicket::new(iroh::EndpointAddr::new(
|
||||||
|
sk.public(),
|
||||||
|
))
|
||||||
|
.to_string();
|
||||||
|
(ticket, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decodes_endpoint_id_from_a_valid_ticket() {
|
||||||
|
let (ticket, id) = sample_ticket();
|
||||||
|
assert_eq!(ticket_endpoint_id(&ticket).as_deref(), Some(id.as_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tolerates_surrounding_whitespace() {
|
||||||
|
let (ticket, _) = sample_ticket();
|
||||||
|
assert!(ticket_endpoint_id(&format!(" \n{ticket}\t ")).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_ticket_input() {
|
||||||
|
// The empty/whitespace/garbage cases that gate the Connect button off.
|
||||||
|
assert_eq!(ticket_endpoint_id(""), None);
|
||||||
|
assert_eq!(ticket_endpoint_id(" "), None);
|
||||||
|
assert_eq!(ticket_endpoint_id("hello world"), None);
|
||||||
|
assert_eq!(ticket_endpoint_id("endpointbutnotreally"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_id_truncates_long_ids() {
|
||||||
|
assert_eq!(short_id("0123456789abcdefghij"), "0123456789ab…");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_id_leaves_short_or_exact_ids_intact() {
|
||||||
|
assert_eq!(short_id("abc"), "abc");
|
||||||
|
assert_eq!(short_id("0123456789ab"), "0123456789ab"); // exactly 12, no ellipsis
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-15
@@ -10,14 +10,16 @@ use anyhow::{Result, bail};
|
|||||||
use iroh::endpoint::{Connection, presets};
|
use iroh::endpoint::{Connection, presets};
|
||||||
use iroh::{Endpoint, EndpointAddr};
|
use iroh::{Endpoint, EndpointAddr};
|
||||||
use iroh_tickets::endpoint::EndpointTicket;
|
use iroh_tickets::endpoint::EndpointTicket;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::cli::HostOpts;
|
use crate::cli::HostOpts;
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, signal,
|
alpn::ALPN, bandwidth, config, config::BandwidthStatus, deps, display::DisplayServer, output,
|
||||||
tunnel,
|
signal, tunnel,
|
||||||
};
|
};
|
||||||
|
|
||||||
use self::pipeline::CaptureHandle;
|
use self::pipeline::CaptureHandle;
|
||||||
@@ -28,10 +30,16 @@ enum SupervisorMsg {
|
|||||||
/// A new viewer wants in. Supervisor replies with the local capture
|
/// A new viewer wants in. Supervisor replies with the local capture
|
||||||
/// HTTP port to connect to, or an error string if the host is full or
|
/// HTTP port to connect to, or an error string if the host is full or
|
||||||
/// capture spawn failed.
|
/// capture spawn failed.
|
||||||
AddViewer(oneshot::Sender<Result<u16, String>>),
|
AddViewer {
|
||||||
|
id: String,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
reply: oneshot::Sender<Result<u16, String>>,
|
||||||
|
},
|
||||||
/// A viewer's session ended. Supervisor decrements the count and tears
|
/// A viewer's session ended. Supervisor decrements the count and tears
|
||||||
/// down capture if it just hit zero.
|
/// down capture if it just hit zero.
|
||||||
RemoveViewer,
|
RemoveViewer { id: String },
|
||||||
|
/// Request to kick a specific viewer.
|
||||||
|
KickViewer { id: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run(opts: HostOpts) -> Result<()> {
|
pub async fn run(opts: HostOpts) -> Result<()> {
|
||||||
@@ -85,9 +93,25 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
|||||||
let relay_only =
|
let relay_only =
|
||||||
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
|
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
|
||||||
let ticket = EndpointTicket::new(relay_only);
|
let ticket = EndpointTicket::new(relay_only);
|
||||||
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string());
|
let ticket_str = ticket.to_string();
|
||||||
|
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket_str);
|
||||||
print_host_banner(&ticket, display, &opts, &quality, &resolution, clipboard_ok);
|
print_host_banner(&ticket, display, &opts, &quality, &resolution, clipboard_ok);
|
||||||
|
|
||||||
|
output::emit(output::Event::Ticket { value: &ticket_str });
|
||||||
|
let display_str = format!("{display:?}");
|
||||||
|
let capture = capture_summary(&opts);
|
||||||
|
let dims = quality.dimensions_summary();
|
||||||
|
let cap_source = resolution.source.label();
|
||||||
|
output::emit(output::Event::HostInfo {
|
||||||
|
display_server: &display_str,
|
||||||
|
capture: &capture,
|
||||||
|
quality: &quality.label,
|
||||||
|
dimensions: &dims,
|
||||||
|
hw_encode: !opts.no_hwencode,
|
||||||
|
max_viewers: resolution.value,
|
||||||
|
max_viewers_source: &cap_source,
|
||||||
|
});
|
||||||
|
|
||||||
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
|
let (sup_tx, sup_rx) = mpsc::channel::<SupervisorMsg>(16);
|
||||||
let supervisor = tokio::spawn(supervise(
|
let supervisor = tokio::spawn(supervise(
|
||||||
opts.clone(),
|
opts.clone(),
|
||||||
@@ -97,6 +121,17 @@ pub async fn run(opts: HostOpts) -> Result<()> {
|
|||||||
sup_rx,
|
sup_rx,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Stdin listener for "kick <id>"
|
||||||
|
let stdin_sup_tx = sup_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||||
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
|
if let Some(id) = line.strip_prefix("kick ") {
|
||||||
|
let _ = stdin_sup_tx.send(SupervisorMsg::KickViewer { id: id.trim().to_string() }).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
|
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
|
||||||
|
|
||||||
drop(sup_tx);
|
drop(sup_tx);
|
||||||
@@ -143,9 +178,11 @@ async fn handle_peer(
|
|||||||
cancel: CancellationToken,
|
cancel: CancellationToken,
|
||||||
) {
|
) {
|
||||||
let remote = conn.remote_id();
|
let remote = conn.remote_id();
|
||||||
|
let id_str = remote.to_string();
|
||||||
|
let peer_cancel = CancellationToken::new();
|
||||||
|
|
||||||
let (reply_tx, reply_rx) = oneshot::channel();
|
let (reply_tx, reply_rx) = oneshot::channel();
|
||||||
if sup_tx.send(SupervisorMsg::AddViewer(reply_tx)).await.is_err() {
|
if sup_tx.send(SupervisorMsg::AddViewer { id: id_str.clone(), cancel: peer_cancel.clone(), reply: reply_tx }).await.is_err() {
|
||||||
tracing::warn!(%remote, "supervisor channel closed; dropping peer");
|
tracing::warn!(%remote, "supervisor channel closed; dropping peer");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -166,7 +203,7 @@ async fn handle_peer(
|
|||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%remote, "accept_bi failed: {e:#}");
|
tracing::warn!(%remote, "accept_bi failed: {e:#}");
|
||||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
|
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -177,7 +214,7 @@ async fn handle_peer(
|
|||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
|
tracing::warn!(%remote, "connect_to_capture failed: {e:#}");
|
||||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
|
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -191,10 +228,13 @@ async fn handle_peer(
|
|||||||
_ = cancel.cancelled() => {
|
_ = cancel.cancelled() => {
|
||||||
tracing::info!(%remote, "cancellation during stream");
|
tracing::info!(%remote, "cancellation during stream");
|
||||||
}
|
}
|
||||||
|
_ = peer_cancel.cancelled() => {
|
||||||
|
tracing::info!(%remote, "kicked by host");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("[pixelpass] viewer disconnected: {remote}");
|
eprintln!("[pixelpass] viewer disconnected: {remote}");
|
||||||
let _ = sup_tx.send(SupervisorMsg::RemoveViewer).await;
|
let _ = sup_tx.send(SupervisorMsg::RemoveViewer { id: id_str }).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
|
/// Owns the single shared CaptureHandle and the active viewer count. Spawns
|
||||||
@@ -210,21 +250,28 @@ async fn supervise(
|
|||||||
) {
|
) {
|
||||||
let mut handle: Option<CaptureHandle> = None;
|
let mut handle: Option<CaptureHandle> = None;
|
||||||
let mut count: u32 = 0;
|
let mut count: u32 = 0;
|
||||||
|
let mut viewers: HashMap<String, CancellationToken> = HashMap::new();
|
||||||
|
|
||||||
while let Some(msg) = rx.recv().await {
|
while let Some(msg) = rx.recv().await {
|
||||||
match msg {
|
match msg {
|
||||||
SupervisorMsg::AddViewer(reply) => {
|
SupervisorMsg::AddViewer { id, cancel, reply } => {
|
||||||
if count >= max_viewers {
|
if count >= max_viewers {
|
||||||
let _ = reply.send(Err(format!(
|
let reason =
|
||||||
"host is full ({count} of {max_viewers} viewers connected)"
|
format!("host is full ({count} of {max_viewers} viewers connected)");
|
||||||
)));
|
output::emit(output::Event::ViewerRefused { reason: &reason });
|
||||||
|
let _ = reply.send(Err(reason));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if handle.is_none() {
|
if handle.is_none() {
|
||||||
tracing::info!("first viewer arriving — spawning capture");
|
tracing::info!("first viewer arriving — spawning capture");
|
||||||
match capture::spawn(display, &opts, &quality).await {
|
match capture::spawn(display, &opts, &quality).await {
|
||||||
Ok(h) => handle = Some(h),
|
Ok(h) => {
|
||||||
|
handle = Some(h);
|
||||||
|
output::emit(output::Event::Capture {
|
||||||
|
state: output::CaptureState::Started,
|
||||||
|
});
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
|
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
|
||||||
continue;
|
continue;
|
||||||
@@ -234,17 +281,30 @@ async fn supervise(
|
|||||||
|
|
||||||
let port = handle.as_ref().expect("handle was just set").local_port();
|
let port = handle.as_ref().expect("handle was just set").local_port();
|
||||||
count += 1;
|
count += 1;
|
||||||
|
viewers.insert(id.clone(), cancel);
|
||||||
let _ = reply.send(Ok(port));
|
let _ = reply.send(Ok(port));
|
||||||
|
output::emit(output::Event::ViewerJoined { id: &id, active: count, max: max_viewers });
|
||||||
tracing::info!(active = count, cap = max_viewers, "viewer joined");
|
tracing::info!(active = count, cap = max_viewers, "viewer joined");
|
||||||
}
|
}
|
||||||
SupervisorMsg::RemoveViewer => {
|
SupervisorMsg::RemoveViewer { id } => {
|
||||||
|
viewers.remove(&id);
|
||||||
count = count.saturating_sub(1);
|
count = count.saturating_sub(1);
|
||||||
|
output::emit(output::Event::ViewerLeft { id: &id, active: count, max: max_viewers });
|
||||||
tracing::info!(active = count, cap = max_viewers, "viewer left");
|
tracing::info!(active = count, cap = max_viewers, "viewer left");
|
||||||
if count == 0
|
if count == 0
|
||||||
&& let Some(h) = handle.take()
|
&& let Some(h) = handle.take()
|
||||||
{
|
{
|
||||||
tracing::info!("last viewer left — tearing down capture");
|
tracing::info!("last viewer left — tearing down capture");
|
||||||
h.shutdown().await;
|
h.shutdown().await;
|
||||||
|
output::emit(output::Event::Capture {
|
||||||
|
state: output::CaptureState::Stopped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SupervisorMsg::KickViewer { id } => {
|
||||||
|
if let Some(cancel) = viewers.get(&id) {
|
||||||
|
tracing::info!(%id, "kicking viewer");
|
||||||
|
cancel.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,6 +313,9 @@ async fn supervise(
|
|||||||
if let Some(h) = handle.take() {
|
if let Some(h) = handle.take() {
|
||||||
tracing::info!("host shutdown — tearing down capture");
|
tracing::info!("host shutdown — tearing down capture");
|
||||||
h.shutdown().await;
|
h.shutdown().await;
|
||||||
|
output::emit(output::Event::Capture {
|
||||||
|
state: output::CaptureState::Stopped,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+40
-1
@@ -1,5 +1,7 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod common;
|
mod common;
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
mod gui;
|
||||||
mod host;
|
mod host;
|
||||||
mod interactive;
|
mod interactive;
|
||||||
mod repair;
|
mod repair;
|
||||||
@@ -16,6 +18,24 @@ async fn main() -> Result<()> {
|
|||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
init_tracing(cli.verbose);
|
init_tracing(cli.verbose);
|
||||||
|
|
||||||
|
if matches!(cli.output, Some(cli::OutputFormat::Json)) {
|
||||||
|
common::output::set_json(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cli.gui {
|
||||||
|
#[cfg(feature = "gui")]
|
||||||
|
{
|
||||||
|
return gui::run();
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "gui"))]
|
||||||
|
{
|
||||||
|
anyhow::bail!(
|
||||||
|
"this binary was built without GUI support. Rebuild with \
|
||||||
|
`cargo build --release --features gui` to use --gui."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// libpipewire requires global init before any pw_* call. Idempotent;
|
// libpipewire requires global init before any pw_* call. Idempotent;
|
||||||
// safe to call even when the per-app audio thread never spawns.
|
// safe to call even when the per-app audio thread never spawns.
|
||||||
pipewire::init();
|
pipewire::init();
|
||||||
@@ -28,6 +48,16 @@ async fn main() -> Result<()> {
|
|||||||
return interactive::run_reconfigure().await;
|
return interactive::run_reconfigure().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cli.host {
|
||||||
|
if cli.ticket.is_some() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"--host and a ticket argument are mutually exclusive: --host shares your \
|
||||||
|
screen, a ticket views someone else's."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return host::run(cli.into_host_opts(false)).await;
|
||||||
|
}
|
||||||
|
|
||||||
match cli.ticket.as_deref() {
|
match cli.ticket.as_deref() {
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
let ticket: EndpointTicket = s.parse().map_err(|e| {
|
let ticket: EndpointTicket = s.parse().map_err(|e| {
|
||||||
@@ -45,5 +75,14 @@ async fn main() -> Result<()> {
|
|||||||
fn init_tracing(verbose: bool) {
|
fn init_tracing(verbose: bool) {
|
||||||
let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
|
let default = if verbose { "pixelpass=trace,iroh=info" } else { "pixelpass=info,iroh=warn" };
|
||||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
|
||||||
tracing_subscriber::fmt().with_env_filter(filter).with_target(false).init();
|
// Tracing MUST write to stderr. `tracing_subscriber::fmt()` defaults its
|
||||||
|
// writer to stdout, but with `--output json` stdout carries the JSON event
|
||||||
|
// stream the `--gui` front-end parses (see `common::output`) — logging there
|
||||||
|
// interleaves log lines into that stream (corrupting events and starving the
|
||||||
|
// GUI's stderr-tail diagnostics). Pin it to stderr to honor that contract.
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_writer(std::io::stderr)
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.init();
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-3
@@ -1,11 +1,18 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result, bail};
|
||||||
use iroh::Endpoint;
|
use iroh::Endpoint;
|
||||||
use iroh::endpoint::presets;
|
use iroh::endpoint::presets;
|
||||||
use iroh_tickets::endpoint::EndpointTicket;
|
use iroh_tickets::endpoint::EndpointTicket;
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
use crate::cli::ViewerOpts;
|
use crate::cli::ViewerOpts;
|
||||||
use crate::common::{alpn::ALPN, signal};
|
use crate::common::{alpn::ALPN, output, signal};
|
||||||
|
|
||||||
|
/// Cap on the initial QUIC connect. `endpoint.connect()` has no built-in
|
||||||
|
/// deadline, so an offline host / stale code / unreachable relay otherwise
|
||||||
|
/// hangs forever with no feedback (the silent "connecting…" failure mode).
|
||||||
|
/// Matches the host's 15s `online()` cap.
|
||||||
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
||||||
let cancel = signal::install_ctrl_c();
|
let cancel = signal::install_ctrl_c();
|
||||||
@@ -17,12 +24,38 @@ pub async fn run(ticket: EndpointTicket, opts: ViewerOpts) -> Result<()> {
|
|||||||
|
|
||||||
let addr = ticket.endpoint_addr().clone();
|
let addr = ticket.endpoint_addr().clone();
|
||||||
tracing::info!(remote = %addr.id, "connecting to host");
|
tracing::info!(remote = %addr.id, "connecting to host");
|
||||||
let conn = endpoint.connect(addr, ALPN).await?;
|
|
||||||
|
// Bound the connect attempt and let ctrl-c abort it, so the viewer fails
|
||||||
|
// loud (and the GUI surfaces the error) instead of spinning indefinitely.
|
||||||
|
let conn = tokio::select! {
|
||||||
|
_ = cancel.cancelled() => {
|
||||||
|
tracing::info!("ctrl-c received before the connection was established");
|
||||||
|
endpoint.close().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
result = tokio::time::timeout(CONNECT_TIMEOUT, endpoint.connect(addr, ALPN)) => match result {
|
||||||
|
Ok(Ok(conn)) => conn,
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
endpoint.close().await;
|
||||||
|
bail!("failed to connect to the host: {e:#}");
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
endpoint.close().await;
|
||||||
|
bail!(
|
||||||
|
"couldn't reach the host within {}s — it may be offline, the share \
|
||||||
|
code may be stale, or the relay may be unreachable. Check that the \
|
||||||
|
host is running, then re-copy the code and try again.",
|
||||||
|
CONNECT_TIMEOUT.as_secs()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
let (quic_send, quic_recv) = conn.open_bi().await?;
|
let (quic_send, quic_recv) = conn.open_bi().await?;
|
||||||
|
|
||||||
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
|
let listener = TcpListener::bind(("127.0.0.1", opts.port)).await?;
|
||||||
let port = listener.local_addr()?.port();
|
let port = listener.local_addr()?.port();
|
||||||
let url = format!("http://127.0.0.1:{port}");
|
let url = format!("http://127.0.0.1:{port}");
|
||||||
|
output::emit(output::Event::Connected { url: &url });
|
||||||
|
|
||||||
if opts.interactive {
|
if opts.interactive {
|
||||||
let player = crate::interactive::prompt_player()?;
|
let player = crate::interactive::prompt_player()?;
|
||||||
|
|||||||
Reference in New Issue
Block a user