Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87de5213fe | ||
|
|
9b6c8bb5c3 | ||
|
|
40604c716c | ||
|
|
3b92bcbe52 |
Generated
+584
-591
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -32,7 +32,7 @@ name = "pixelpass"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
iroh = "1.0.0-rc.0"
|
||||
iroh = "1.0.2"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-util", "net", "signal", "process", "sync", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -48,7 +48,7 @@ ashpd = { version = "0.9", default-features = false, features = ["tokio"] }
|
||||
pipewire = "0.9"
|
||||
x11rb = { version = "0.13", default-features = false, features = ["allow-unsafe-code"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
iroh-tickets = "1.0.0-rc.0"
|
||||
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"] }
|
||||
|
||||
+49
-22
@@ -191,8 +191,7 @@ fn encode_section() -> Section {
|
||||
/// no video — the exact silent failure `--no-hwencode` works around.
|
||||
fn hardware_encode_check() -> Check {
|
||||
if !deps::gst_element_exists("vah264enc") {
|
||||
return Check::warn("hardware H.264", "vah264enc plugin not installed")
|
||||
.with_hint(format!(
|
||||
return Check::warn("hardware H.264", "vah264enc plugin not installed").with_hint(format!(
|
||||
"{} — or just host with `--no-hwencode` (software x264)",
|
||||
deps::install_hint_for_gst_element("vah264enc")
|
||||
));
|
||||
@@ -207,9 +206,10 @@ fn hardware_encode_check() -> Check {
|
||||
}
|
||||
|
||||
match vainfo_output() {
|
||||
Some(out) if vainfo_has_h264_encode(&out) => {
|
||||
Check::ok("hardware H.264", "VA-API H.264 encode available (vah264enc)")
|
||||
}
|
||||
Some(out) if vainfo_has_h264_encode(&out) => Check::ok(
|
||||
"hardware H.264",
|
||||
"VA-API H.264 encode available (vah264enc)",
|
||||
),
|
||||
Some(_) => Check::warn(
|
||||
"hardware H.264",
|
||||
"vah264enc present, but VA-API reports no H.264 encode entrypoint",
|
||||
@@ -237,15 +237,28 @@ fn software_encode_check() -> Check {
|
||||
fn mux_audio_section() -> Section {
|
||||
// These live in plugins-bad/-good/-libav and plugins-base; all are required
|
||||
// for either backend, so a miss here is a hard Fail.
|
||||
let tail = ["h264parse", "mpegtsmux", "aacparse", "avenc_aac", "pulsesrc", "videoscale"];
|
||||
let missing: Vec<&str> = tail.iter().copied().filter(|e| !deps::gst_element_exists(e)).collect();
|
||||
let tail = [
|
||||
"h264parse",
|
||||
"mpegtsmux",
|
||||
"aacparse",
|
||||
"avenc_aac",
|
||||
"pulsesrc",
|
||||
"videoscale",
|
||||
];
|
||||
let missing: Vec<&str> = tail
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|e| !deps::gst_element_exists(e))
|
||||
.collect();
|
||||
|
||||
let tail_check = if missing.is_empty() {
|
||||
Check::ok("mux + audio tail", tail.join(", "))
|
||||
} else {
|
||||
Check::fail("mux + audio tail", format!("missing: {}", missing.join(", "))).with_hint(
|
||||
deps::install_hint_for_gst_element(missing[0]),
|
||||
Check::fail(
|
||||
"mux + audio tail",
|
||||
format!("missing: {}", missing.join(", ")),
|
||||
)
|
||||
.with_hint(deps::install_hint_for_gst_element(missing[0]))
|
||||
};
|
||||
|
||||
Section {
|
||||
@@ -278,7 +291,9 @@ fn viewer_section() -> Section {
|
||||
async fn network_section(relay: Option<&str>) -> Section {
|
||||
let check = match endpoint::bind(relay).await {
|
||||
Ok(ep) => {
|
||||
let online = tokio::time::timeout(Duration::from_secs(8), ep.online()).await.is_ok();
|
||||
let online = tokio::time::timeout(Duration::from_secs(8), ep.online())
|
||||
.await
|
||||
.is_ok();
|
||||
let relay_count = ep.addr().addrs.iter().filter(|a| a.is_relay()).count();
|
||||
let where_ = relay.map(|r| format!(" ({r})")).unwrap_or_default();
|
||||
// Close gracefully so iroh doesn't log a scary "Endpoint dropped
|
||||
@@ -288,11 +303,17 @@ async fn network_section(relay: Option<&str>) -> Section {
|
||||
if online && relay_count > 0 {
|
||||
Check::ok("relay", format!("home relay reachable{where_}"))
|
||||
} else if online {
|
||||
Check::warn("relay", format!("endpoint online but no relay address{where_}"))
|
||||
.with_hint("n0 DNS discovery may still connect peers, but relay fallback is degraded")
|
||||
Check::warn(
|
||||
"relay",
|
||||
format!("endpoint online but no relay address{where_}"),
|
||||
)
|
||||
.with_hint(
|
||||
"n0 DNS discovery may still connect peers, but relay fallback is degraded",
|
||||
)
|
||||
} else {
|
||||
Check::warn("relay", format!("no relay connected within 8s{where_}"))
|
||||
.with_hint("check connectivity/firewall; peers behind NAT rely on the relay to rendezvous")
|
||||
Check::warn("relay", format!("no relay connected within 8s{where_}")).with_hint(
|
||||
"check connectivity/firewall; peers behind NAT rely on the relay to rendezvous",
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => Check::fail("relay", format!("could not bind endpoint: {e}")),
|
||||
@@ -318,7 +339,10 @@ fn gst_check(element: &str, label: &str) -> Check {
|
||||
if deps::gst_element_exists(element) {
|
||||
Check::ok(label, element.to_string())
|
||||
} else {
|
||||
Check::fail(label, format!("GStreamer element `{element}` not available"))
|
||||
Check::fail(
|
||||
label,
|
||||
format!("GStreamer element `{element}` not available"),
|
||||
)
|
||||
.with_hint(deps::install_hint_for_gst_element(element))
|
||||
}
|
||||
}
|
||||
@@ -392,7 +416,12 @@ fn os_release_field(key: &str) -> Option<String> {
|
||||
|
||||
fn display_env_summary() -> String {
|
||||
let mut parts = Vec::new();
|
||||
for var in ["WAYLAND_DISPLAY", "DISPLAY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP"] {
|
||||
for var in [
|
||||
"WAYLAND_DISPLAY",
|
||||
"DISPLAY",
|
||||
"XDG_SESSION_TYPE",
|
||||
"XDG_CURRENT_DESKTOP",
|
||||
] {
|
||||
if let Some(v) = std::env::var_os(var) {
|
||||
parts.push(format!("{var}={}", v.to_string_lossy()));
|
||||
}
|
||||
@@ -408,11 +437,9 @@ fn has_render_node() -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/dev/dri") else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("renderD")
|
||||
})
|
||||
entries
|
||||
.flatten()
|
||||
.any(|e| e.file_name().to_string_lossy().starts_with("renderD"))
|
||||
}
|
||||
|
||||
fn vainfo_output() -> Option<String> {
|
||||
@@ -559,7 +586,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn summarize_counts_fails_and_warns_only() {
|
||||
let checks = vec![
|
||||
let checks = [
|
||||
Check::ok("a", "x"),
|
||||
Check::info("b", "x"),
|
||||
Check::warn("c", "x"),
|
||||
|
||||
+114
-5
@@ -365,6 +365,9 @@ fn load_module(args: &[&str]) -> Result<u32> {
|
||||
.context("pactl returned non-UTF-8")?
|
||||
.trim()
|
||||
.to_string();
|
||||
// Genuinely 32-bit, unlike `object.serial`: this is a PulseAudio module
|
||||
// index (`pa_module.index`, `uint32_t`), which `pactl unload-module` takes
|
||||
// back verbatim. Do not widen it.
|
||||
id_str
|
||||
.parse::<u32>()
|
||||
.with_context(|| format!("pactl returned unexpected module ID: {id_str:?}"))
|
||||
@@ -532,14 +535,22 @@ fn run_router(
|
||||
return;
|
||||
};
|
||||
if props.get("node.name") == Some(sink_name_owned.as_str()) {
|
||||
if let Some(serial) = props
|
||||
.get("object.serial")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
{
|
||||
match props.get("object.serial").and_then(parse_object_serial) {
|
||||
Some(serial) => {
|
||||
state_for_reg.borrow_mut().sink_serial = Some(serial);
|
||||
tracing::info!(serial, "audio routing: pixelpass sink registered");
|
||||
try_flush(&state_for_reg, &event_tx_for_reg);
|
||||
}
|
||||
// Never silently: without a serial `try_flush` can
|
||||
// never route anything, so the whole app-filter mode
|
||||
// is dead and the only symptom is missing audio.
|
||||
None => tracing::warn!(
|
||||
node_id = obj.id,
|
||||
serial = props.get("object.serial").unwrap_or("<absent>"),
|
||||
"audio routing: pixelpass sink has no usable object.serial; \
|
||||
stream rerouting disabled"
|
||||
),
|
||||
}
|
||||
return;
|
||||
}
|
||||
if props.get("media.class") != Some("Stream/Output/Audio") {
|
||||
@@ -591,8 +602,30 @@ fn run_router(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a PipeWire `object.serial` property value.
|
||||
///
|
||||
/// `object.serial` is a **64-bit** monotonically-increasing counter
|
||||
/// (`pw_global`'s serial is `uint64_t`); it is *not* a `pw` object id
|
||||
/// (those are `u32` and get recycled — the serial exists precisely so
|
||||
/// that recycled ids can be disambiguated). Parsing it as `u32` silently
|
||||
/// yields `None` past `u32::MAX`, which on a long-lived daemon means the
|
||||
/// sink is never registered and no stream is ever routed.
|
||||
///
|
||||
/// Strict on purpose: PipeWire emits a bare decimal, so anything else
|
||||
/// (empty, signed, whitespace-padded, non-numeric, overflowing) is a
|
||||
/// property we do not understand and must not guess at. Leading zeroes
|
||||
/// are accepted — they are unambiguous and parse to the same value.
|
||||
fn parse_object_serial(raw: &str) -> Option<u64> {
|
||||
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
raw.parse::<u64>().ok()
|
||||
}
|
||||
|
||||
struct RouterState {
|
||||
sink_serial: Option<u32>,
|
||||
/// See [`parse_object_serial`] — 64-bit, and not interchangeable with
|
||||
/// the `u32` node ids in `routed_node_ids` / `pending`.
|
||||
sink_serial: Option<u64>,
|
||||
default_metadata: Option<pipewire::metadata::Metadata>,
|
||||
routed_node_ids: Vec<u32>,
|
||||
pending: Vec<u32>,
|
||||
@@ -654,3 +687,79 @@ fn try_flush(
|
||||
let _ = event_tx.send(Event::FirstRoutedStream);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn object_serial_parses_past_u32() {
|
||||
// The regression this fix exists for: a serial one past `u32::MAX`
|
||||
// used to parse as `None` and silently disable rerouting.
|
||||
let beyond = u64::from(u32::MAX) + 1;
|
||||
assert_eq!(parse_object_serial(&beyond.to_string()), Some(beyond));
|
||||
assert_eq!(
|
||||
parse_object_serial(&u64::MAX.to_string()),
|
||||
Some(u64::MAX),
|
||||
"the full 64-bit range must round-trip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_accepts_ordinary_serials() {
|
||||
// Without this the valid cases are only 1, 10 and 20 digits long, and
|
||||
// a length-gated mutant (`if (2..10).contains(&raw.len()) { None }`)
|
||||
// survives the whole suite while rejecting every serial a freshly
|
||||
// started daemon actually hands out. (Codex, round 1.)
|
||||
for serial in 0_u64..=1024 {
|
||||
assert_eq!(parse_object_serial(&serial.to_string()), Some(serial));
|
||||
}
|
||||
assert_eq!(parse_object_serial("123456789"), Some(123_456_789));
|
||||
assert_eq!(
|
||||
parse_object_serial("007"),
|
||||
Some(7),
|
||||
"leading zeroes are fine"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_boundary_values() {
|
||||
assert_eq!(parse_object_serial("0"), Some(0));
|
||||
assert_eq!(parse_object_serial("1"), Some(1));
|
||||
let max32 = u64::from(u32::MAX);
|
||||
assert_eq!(parse_object_serial(&max32.to_string()), Some(max32));
|
||||
assert_eq!(
|
||||
parse_object_serial(&(max32 - 1).to_string()),
|
||||
Some(max32 - 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_round_trips_through_the_metadata_string() {
|
||||
// `try_flush` writes the serial back out as a decimal string for
|
||||
// `target.object`; widening must not introduce a formatting change.
|
||||
for raw in ["0", "4294967296", "18446744073709551615"] {
|
||||
let parsed = parse_object_serial(raw).expect("valid serial");
|
||||
assert_eq!(parsed.to_string(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_serial_rejects_malformed() {
|
||||
for raw in [
|
||||
"",
|
||||
" 12",
|
||||
"12 ",
|
||||
"+12",
|
||||
"-1",
|
||||
"1.0",
|
||||
"0x10",
|
||||
"12a",
|
||||
"abc",
|
||||
// u64::MAX + 1 — overflow must be rejected, not wrapped.
|
||||
"18446744073709551616",
|
||||
] {
|
||||
assert_eq!(parse_object_serial(raw), None, "should reject {raw:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-1
@@ -189,9 +189,32 @@ fn build_args(
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
];
|
||||
// Debug A/V-drift tap: when PIXELPASS_TS_DUMP=<path> is set, tee the exact
|
||||
// muxed TS both to fd=1 (normal serve path, unchanged) and to a file, so the
|
||||
// host-side stream can be ffprobe'd for capture-side audio/video PTS drift.
|
||||
// Each tee branch has its own queue so the disk sink can't backpressure the
|
||||
// live serve branch. No effect when unset. (Mirrors PIXELPASS_GST_DEBUG.)
|
||||
if let Some(dump) = std::env::var_os("PIXELPASS_TS_DUMP") {
|
||||
let path = dump.to_string_lossy().into_owned();
|
||||
args.extend([
|
||||
"tee".into(),
|
||||
"name=dbgtee".into(),
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
"fdsink".into(),
|
||||
"fd=1".into(),
|
||||
];
|
||||
"dbgtee.".into(),
|
||||
"!".into(),
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
"filesink".into(),
|
||||
format!("location={path}"),
|
||||
]);
|
||||
} else {
|
||||
args.extend(["fdsink".into(), "fd=1".into()]);
|
||||
}
|
||||
|
||||
// Downscale step for the quality presets. `None` = encode at native size
|
||||
// (the "Source" preset, or a source already at/below the target height — we
|
||||
|
||||
Reference in New Issue
Block a user