style: apply cargo fmt across the crate (A20)

The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 02:11:44 -04:00
co-authored by Claude Opus 4.8
parent e0325d4590
commit d0a16cb8b9
54 changed files with 3761 additions and 1789 deletions
+1519 -866
View File
File diff suppressed because it is too large Load Diff
+53 -12
View File
@@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result<cpal::SupportedStreamC
let pick = |channels: Option<u16>| { let pick = |channels: Option<u16>| {
ranges ranges
.iter() .iter()
.find(|r| usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)) .find(|r| {
usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)
})
.cloned() .cloned()
}; };
@@ -666,15 +668,30 @@ fn run_capture(
let device_rate = config.sample_rate.0; let device_rate = config.sample_rate.0;
let stream = match sample_format { let stream = match sample_format {
SampleFormat::F32 => build_input::<f32, _>( SampleFormat::F32 => build_input::<f32, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(), &device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(), err_code.clone(),
), ),
SampleFormat::I16 => build_input::<i16, _>( SampleFormat::I16 => build_input::<i16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(), &device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(), err_code.clone(),
), ),
SampleFormat::U16 => build_input::<u16, _>( SampleFormat::U16 => build_input::<u16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(), &device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(), err_code.clone(),
), ),
other => Err(AudioError::Stream(format!( other => Err(AudioError::Stream(format!(
@@ -764,7 +781,10 @@ fn run_capture(
// Surface a stream error the RT callback flagged (it can't log itself). // Surface a stream error the RT callback flagged (it can't log itself).
let ec = err_code.load(Ordering::Relaxed); let ec = err_code.load(Ordering::Relaxed);
if ec != STREAM_ERR_NONE && ec != last_err { if ec != STREAM_ERR_NONE && ec != last_err {
crate::log_msg(&format!("cpal capture stream error: {}", stream_err_text(ec))); crate::log_msg(&format!(
"cpal capture stream error: {}",
stream_err_text(ec)
));
last_err = ec; last_err = ec;
} }
if !drained { if !drained {
@@ -914,16 +934,34 @@ fn run_playback(
let device_rate = config.sample_rate.0; let device_rate = config.sample_rate.0;
let stream = match sample_format { let stream = match sample_format {
SampleFormat::F32 => build_output::<f32, _>( SampleFormat::F32 => build_output::<f32, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(), &device,
max_cb.clone(), callbacks.clone(), err_code.clone(), &config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
), ),
SampleFormat::I16 => build_output::<i16, _>( SampleFormat::I16 => build_output::<i16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(), &device,
max_cb.clone(), callbacks.clone(), err_code.clone(), &config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
), ),
SampleFormat::U16 => build_output::<u16, _>( SampleFormat::U16 => build_output::<u16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(), &device,
max_cb.clone(), callbacks.clone(), err_code.clone(), &config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
), ),
other => Err(AudioError::Stream(format!( other => Err(AudioError::Stream(format!(
"unsupported playback sample format: {other:?}" "unsupported playback sample format: {other:?}"
@@ -1199,7 +1237,10 @@ fn spawn_health_logger(
// Surface a stream error the RT callback flagged (it can't log itself). // Surface a stream error the RT callback flagged (it can't log itself).
let ec = err_code.load(Ordering::Relaxed); let ec = err_code.load(Ordering::Relaxed);
if ec != STREAM_ERR_NONE && ec != last_err { if ec != STREAM_ERR_NONE && ec != last_err {
crate::log_msg(&format!("cpal playback stream error: {}", stream_err_text(ec))); crate::log_msg(&format!(
"cpal playback stream error: {}",
stream_err_text(ec)
));
last_err = ec; last_err = ec;
} }
// Report the device's per-cycle demand (in internal 48 kHz-stereo // Report the device's per-cycle demand (in internal 48 kHz-stereo
+49 -11
View File
@@ -54,7 +54,10 @@ impl Drop for EchoCancelGuard {
.arg("unload-module") .arg("unload-module")
.arg(&self.module_index) .arg(&self.module_index)
.output(); .output();
crate::log_msg(&format!("Echo cancel: unloaded module {}", self.module_index)); crate::log_msg(&format!(
"Echo cancel: unloaded module {}",
self.module_index
));
} }
} }
@@ -65,7 +68,10 @@ impl Drop for EchoCancelGuard {
/// `None` (or an empty string) to bind to the system defaults. Returns `Err` with /// `None` (or an empty string) to bind to the system defaults. Returns `Err` with
/// a human-readable reason if `pactl` is missing, the load fails, or the nodes /// a human-readable reason if `pactl` is missing, the load fails, or the nodes
/// don't appear — the caller should fall back to the direct devices. /// don't appear — the caller should fall back to the direct devices.
pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<EchoCancelGuard, String> { pub fn enable(
real_source: Option<&str>,
real_sink: Option<&str>,
) -> Result<EchoCancelGuard, String> {
// Best-effort: clear any stale instance left by a crashed prior run so we // Best-effort: clear any stale instance left by a crashed prior run so we
// don't stack duplicate modules / fight over the virtual node names. // don't stack duplicate modules / fight over the virtual node names.
unload_stale(); unload_stale();
@@ -101,7 +107,11 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
if module_index.parse::<u64>().is_err() { if module_index.parse::<u64>().is_err() {
return Err(format!("unexpected pactl output: {module_index:?}")); return Err(format!("unexpected pactl output: {module_index:?}"));
} }
let guard = EchoCancelGuard { module_index, source_name, sink_name }; let guard = EchoCancelGuard {
module_index,
source_name,
sink_name,
};
// The virtual nodes appear shortly after the module loads; wait for both so // The virtual nodes appear shortly after the module loads; wait for both so
// the subsequent capture/playback streams can actually target them. If they // the subsequent capture/playback streams can actually target them. If they
@@ -134,7 +144,12 @@ fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
/// Whether `pactl list <kind> short` lists a node named `name`. /// Whether `pactl list <kind> short` lists a node named `name`.
/// `kind` is "sources" or "sinks". /// `kind` is "sources" or "sinks".
fn node_present(kind: &str, name: &str) -> bool { fn node_present(kind: &str, name: &str) -> bool {
let Ok(out) = Command::new("pactl").arg("list").arg(kind).arg("short").output() else { let Ok(out) = Command::new("pactl")
.arg("list")
.arg(kind)
.arg("short")
.output()
else {
return false; return false;
}; };
String::from_utf8_lossy(&out.stdout) String::from_utf8_lossy(&out.stdout)
@@ -167,7 +182,12 @@ fn process_is_alive(_pid: u32) -> bool {
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their /// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
/// owning process is gone. Best-effort and conservative on non-Linux platforms. /// owning process is gone. Best-effort and conservative on non-Linux platforms.
fn unload_stale() { fn unload_stale() {
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else { let Ok(out) = Command::new("pactl")
.arg("list")
.arg("modules")
.arg("short")
.output()
else {
return; return;
}; };
for line in String::from_utf8_lossy(&out.stdout).lines() { for line in String::from_utf8_lossy(&out.stdout).lines() {
@@ -179,7 +199,10 @@ fn unload_stale() {
&& ec_module_is_stale(args, process_is_alive) && ec_module_is_stale(args, process_is_alive)
&& index.parse::<u64>().is_ok() && index.parse::<u64>().is_ok()
{ {
let _ = Command::new("pactl").arg("unload-module").arg(index).output(); let _ = Command::new("pactl")
.arg("unload-module")
.arg(index)
.output();
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}")); crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
} }
} }
@@ -198,13 +221,25 @@ mod tests {
let guard = enable(None, None).expect("module-echo-cancel should load"); let guard = enable(None, None).expect("module-echo-cancel should load");
let source_name = guard.source_name().to_string(); let source_name = guard.source_name().to_string();
let sink_name = guard.sink_name().to_string(); let sink_name = guard.sink_name().to_string();
assert!(node_present("sources", &source_name), "cleaned source must exist"); assert!(
assert!(node_present("sinks", &sink_name), "reference sink must exist"); node_present("sources", &source_name),
"cleaned source must exist"
);
assert!(
node_present("sinks", &sink_name),
"reference sink must exist"
);
drop(guard); drop(guard);
// Give pactl a moment to tear the nodes down. // Give pactl a moment to tear the nodes down.
std::thread::sleep(Duration::from_millis(300)); std::thread::sleep(Duration::from_millis(300));
assert!(!node_present("sources", &source_name), "source must be gone after unload"); assert!(
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload"); !node_present("sources", &source_name),
"source must be gone after unload"
);
assert!(
!node_present("sinks", &sink_name),
"sink must be gone after unload"
);
} }
#[test] #[test]
@@ -220,7 +255,10 @@ mod tests {
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"), pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
None None
); );
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None); assert_eq!(
pid_from_ec_args("source_name=someone_elses_source.4242"),
None
);
} }
#[test] #[test]
+17 -9
View File
@@ -251,7 +251,10 @@ mod tests {
let before = rms(&low); let before = rms(&low);
eq.process_frame(&mut low); eq.process_frame(&mut low);
let after = rms(&low); let after = rms(&low);
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}"); assert!(
after > before * 1.6,
"low shelf should boost low RMS: {before} -> {after}"
);
} }
#[test] #[test]
@@ -264,7 +267,10 @@ mod tests {
let before = rms(&high); let before = rms(&high);
eq.process_frame(&mut high); eq.process_frame(&mut high);
let after = rms(&high); let after = rms(&high);
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}"); assert!(
after > before * 1.6,
"high shelf should boost high RMS: {before} -> {after}"
);
} }
#[test] #[test]
@@ -275,7 +281,10 @@ mod tests {
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q), Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q), Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
] { ] {
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB"); assert!(
b.coeffs.all_finite(),
"coefficients must be finite at {gain} dB"
);
} }
} }
} }
@@ -289,12 +298,11 @@ mod tests {
}); });
let mut frame = sine(1_000.0, 48_000, 30_000.0); let mut frame = sine(1_000.0, 48_000, 30_000.0);
eq.process_frame(&mut frame); eq.process_frame(&mut frame);
let peak = frame let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0);
.iter() assert!(
.map(|&s| i32::from(s).abs()) peak > 1_000,
.max() "processed signal should retain audible energy"
.unwrap_or(0); );
assert!(peak > 1_000, "processed signal should retain audible energy");
assert!( assert!(
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0), frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
"a boosted sine should retain both polarities" "a boosted sine should retain both polarities"
+51 -12
View File
@@ -169,7 +169,10 @@ mod tests {
assert!(g.process(&mut f, 0.05), "loud frame must transmit"); assert!(g.process(&mut f, 0.05), "loud frame must transmit");
last = peak(&f); last = peak(&f);
} }
assert!(last >= 9900, "gain should reach ~1.0 on sustained loud input, got peak {last}"); assert!(
last >= 9900,
"gain should reach ~1.0 on sustained loud input, got peak {last}"
);
} }
#[test] #[test]
@@ -179,8 +182,15 @@ mod tests {
g.process(&mut f, 0.05); g.process(&mut f, 0.05);
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps // 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
// 0->1, so the early samples are well below full scale (no instant click). // 0->1, so the early samples are well below full scale (no instant click).
assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]); assert!(
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame"); f[0].abs() < 5000,
"attack should start near zero, got {}",
f[0]
);
assert!(
f[FRAME - 1].abs() > 9000,
"attack should complete within the frame"
);
} }
#[test] #[test]
@@ -193,8 +203,14 @@ mod tests {
} }
// First quiet frame right after speech: hold keeps it open (not chopped). // First quiet frame right after speech: hold keeps it open (not chopped).
let mut q = frame(50); // rms ~0.0015, below close (0.03) let mut q = frame(50); // rms ~0.0015, below close (0.03)
assert!(g.process(&mut q, 0.05), "first quiet frame must stay open (hangover)"); assert!(
assert!(peak(&q) > 0, "held-open frame must not be silenced immediately"); g.process(&mut q, 0.05),
"first quiet frame must stay open (hangover)"
);
assert!(
peak(&q) > 0,
"held-open frame must not be silenced immediately"
);
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes. // Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
let mut closed = false; let mut closed = false;
@@ -205,7 +221,10 @@ mod tests {
break; break;
} }
} }
assert!(closed, "gate must eventually close and stop transmitting after sustained silence"); assert!(
closed,
"gate must eventually close and stop transmitting after sustained silence"
);
} }
#[test] #[test]
@@ -216,8 +235,14 @@ mod tests {
g.process(&mut f, 0.05); // open=0.05, close=0.03 g.process(&mut f, 0.05); // open=0.05, close=0.03
// A frame between close and open thresholds: rms ~0.04 (amp ~1310). // A frame between close and open thresholds: rms ~0.04 (amp ~1310).
let mut mid = frame(1310); let mut mid = frame(1310);
assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open"); assert!(
assert!(g.open, "hysteresis: gate stays open above the close threshold"); g.process(&mut mid, 0.05),
"between-threshold frame must keep an open gate open"
);
assert!(
g.open,
"hysteresis: gate stays open above the close threshold"
);
} }
#[test] #[test]
@@ -225,7 +250,10 @@ mod tests {
let mut g = NoiseGate::new(SR); let mut g = NoiseGate::new(SR);
// Never opened; feed silence — should report don't-transmit promptly. // Never opened; feed silence — should report don't-transmit promptly.
let mut f = frame(0); let mut f = frame(0);
assert!(!g.process(&mut f, 0.05), "an unopened gate on silence must not transmit"); assert!(
!g.process(&mut f, 0.05),
"an unopened gate on silence must not transmit"
);
} }
#[test] #[test]
@@ -266,7 +294,11 @@ mod tests {
let mut f2 = frame(10000); let mut f2 = frame(10000);
assert!(g.process(&mut f2, 0.05)); // enabled assert!(g.process(&mut f2, 0.05)); // enabled
assert!(f2[0].abs() > 9000, "expected first sample of enabled frame to have no fade-in, got {}", f2[0]); assert!(
f2[0].abs() > 9000,
"expected first sample of enabled frame to have no fade-in, got {}",
f2[0]
);
} }
#[test] #[test]
@@ -302,7 +334,10 @@ mod tests {
let mut f = frame(1310); let mut f = frame(1310);
assert!(g.process(&mut f, 0.05)); assert!(g.process(&mut f, 0.05));
} }
assert!(g.open, "gate must stay open (hold refreshed by mid-level input)"); assert!(
g.open,
"gate must stay open (hold refreshed by mid-level input)"
);
} }
#[test] #[test]
@@ -333,6 +368,10 @@ mod tests {
last_peak = peak(&f); last_peak = peak(&f);
} }
assert!(g.open); assert!(g.open);
assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak); assert!(
last_peak >= 9900,
"peak of the 3rd reopened frame must be >= 9900, got {}",
last_peak
);
} }
} }
+69 -15
View File
@@ -123,7 +123,10 @@ mod tests {
let out = lim.process(&loud, 1.0); let out = lim.process(&loud, 1.0);
let ceiling = lim.ceiling().ceil() as i16; let ceiling = lim.ceiling().ceil() as i16;
for &s in &out { for &s in &out {
assert!(s > 0, "positive loud input stays positive (no wrap), got {s}"); assert!(
s > 0,
"positive loud input stays positive (no wrap), got {s}"
);
assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}"); assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}");
} }
} }
@@ -175,7 +178,10 @@ mod tests {
let out_pos = lim.process(&pos_loud, 1.0); let out_pos = lim.process(&pos_loud, 1.0);
for &s in &out_pos { for &s in &out_pos {
assert!(s > 0, "positive input stays positive, got {s}"); assert!(s > 0, "positive input stays positive, got {s}");
assert!(s <= ceiling_ceil, "positive sample {s} exceeded ceiling {ceiling_ceil}"); assert!(
s <= ceiling_ceil,
"positive sample {s} exceeded ceiling {ceiling_ceil}"
);
} }
// Sustained negative loud sum // Sustained negative loud sum
@@ -185,7 +191,10 @@ mod tests {
let neg_ceiling = -ceiling_ceil; let neg_ceiling = -ceiling_ceil;
for &s in &out_neg { for &s in &out_neg {
assert!(s < 0, "negative input stays negative, got {s}"); assert!(s < 0, "negative input stays negative, got {s}");
assert!(s >= neg_ceiling, "negative sample {s} exceeded negative ceiling {neg_ceiling}"); assert!(
s >= neg_ceiling,
"negative sample {s} exceeded negative ceiling {neg_ceiling}"
);
} }
} }
@@ -200,8 +209,14 @@ mod tests {
let out = lim.process(&input, 8.0); let out = lim.process(&input, 8.0);
for &s in &out { for &s in &out {
assert!(s > 0, "positive stays positive"); assert!(s > 0, "positive stays positive");
assert!(s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}"); assert!(
assert!((s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}"); s <= ceiling_ceil,
"sample {s} must be limited to ceiling {ceiling_ceil}"
);
assert!(
(s - ceiling_ceil).abs() <= 2,
"sample {s} should ride the ceiling {ceiling_ceil}"
);
} }
} }
@@ -213,7 +228,10 @@ mod tests {
let out = lim.process(&input, 0.5); let out = lim.process(&input, 0.5);
for (i, &s) in out.iter().enumerate() { for (i, &s) in out.iter().enumerate() {
let expected = (input[i] as f32 * 0.5).round() as i16; let expected = (input[i] as f32 * 0.5).round() as i16;
assert!((s - expected).abs() <= 1, "sample {s} should be close to expected {expected}"); assert!(
(s - expected).abs() <= 1,
"sample {s} should be close to expected {expected}"
);
} }
// Subsequently feed a new sample at unity gain. It must be transparent, // Subsequently feed a new sample at unity gain. It must be transparent,
@@ -230,7 +248,12 @@ mod tests {
let loud = vec![200_000i32; 10]; let loud = vec![200_000i32; 10];
let out = lim.process(&loud, 1.0); let out = lim.process(&loud, 1.0);
assert!(out[0] <= ceiling_ceil, "first sample {} must not overshoot ceiling {}", out[0], ceiling_ceil); assert!(
out[0] <= ceiling_ceil,
"first sample {} must not overshoot ceiling {}",
out[0],
ceiling_ceil
);
} }
/// 5. Release direction & monotonicity. /// 5. Release direction & monotonicity.
@@ -247,13 +270,23 @@ mod tests {
// Output should be monotonic (non-decreasing) // Output should be monotonic (non-decreasing)
for i in 1..out.len() { for i in 1..out.len() {
assert!(out[i] >= out[i - 1], "output must be monotonic; index {} was {}, index {} was {}", i - 1, out[i - 1], i, out[i]); assert!(
out[i] >= out[i - 1],
"output must be monotonic; index {} was {}, index {} was {}",
i - 1,
out[i - 1],
i,
out[i]
);
} }
// The end sample should be closer to the original input than the start sample // The end sample should be closer to the original input than the start sample
let start_diff = (mid_val as i16 - out[0]).abs(); let start_diff = (mid_val as i16 - out[0]).abs();
let end_diff = (mid_val as i16 - *out.last().unwrap()).abs(); let end_diff = (mid_val as i16 - *out.last().unwrap()).abs();
assert!(end_diff < start_diff, "end diff {end_diff} should be smaller than start diff {start_diff}"); assert!(
end_diff < start_diff,
"end diff {end_diff} should be smaller than start diff {start_diff}"
);
} }
/// 6. Release is gradual, not instantaneous. /// 6. Release is gradual, not instantaneous.
@@ -265,7 +298,11 @@ mod tests {
// Immediately follow with a sub-ceiling sample // Immediately follow with a sub-ceiling sample
let out = lim.process(&[10_000i32], 1.0); let out = lim.process(&[10_000i32], 1.0);
assert!(out[0] < 10_000, "first quiet sample should still be attenuated (got {})", out[0]); assert!(
out[0] < 10_000,
"first quiet sample should still be attenuated (got {})",
out[0]
);
} }
/// 7. State carries across process calls. /// 7. State carries across process calls.
@@ -287,7 +324,10 @@ mod tests {
let mut out_split = out_split1; let mut out_split = out_split1;
out_split.extend(&out_split2); out_split.extend(&out_split2);
assert_eq!(out_single, out_split, "splitting process calls must produce identical output to a single call"); assert_eq!(
out_single, out_split,
"splitting process calls must produce identical output to a single call"
);
// Test 2: Pre-loaded limiter vs fresh limiter on the same input // Test 2: Pre-loaded limiter vs fresh limiter on the same input
let mut lim_preloaded = SoftLimiter::new(SR); let mut lim_preloaded = SoftLimiter::new(SR);
@@ -299,8 +339,16 @@ mod tests {
let out_preloaded = lim_preloaded.process(&test_input, 1.0); let out_preloaded = lim_preloaded.process(&test_input, 1.0);
let out_fresh = lim_fresh.process(&test_input, 1.0); let out_fresh = lim_fresh.process(&test_input, 1.0);
assert_ne!(out_preloaded, out_fresh, "pre-loaded and fresh limiter outputs should differ"); assert_ne!(
assert!(out_preloaded[0] < out_fresh[0], "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", out_preloaded[0], out_fresh[0]); out_preloaded, out_fresh,
"pre-loaded and fresh limiter outputs should differ"
);
assert!(
out_preloaded[0] < out_fresh[0],
"pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}",
out_preloaded[0],
out_fresh[0]
);
} }
/// 8. Empty input. /// 8. Empty input.
@@ -320,7 +368,10 @@ mod tests {
// Gain 0.0 // Gain 0.0
let out_zero = lim.process(&input, 0.0); let out_zero = lim.process(&input, 0.0);
assert_eq!(out_zero.len(), input.len()); assert_eq!(out_zero.len(), input.len());
assert!(out_zero.iter().all(|&s| s == 0), "0.0 gain should result in all zeros"); assert!(
out_zero.iter().all(|&s| s == 0),
"0.0 gain should result in all zeros"
);
// Gain 1.0 // Gain 1.0
let out_unity = lim.process(&input, 1.0); let out_unity = lim.process(&input, 1.0);
@@ -354,6 +405,9 @@ mod tests {
let out = lim.process(&input, 1.0); let out = lim.process(&input, 1.0);
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect(); let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
assert_eq!(out, expected, "below ceiling input must be bit-exact at unity gain"); assert_eq!(
out, expected,
"below ceiling input must be bit-exact at unity gain"
);
} }
} }
+11 -7
View File
@@ -1,6 +1,6 @@
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::mpsc::{Receiver, Sender};
use thiserror::Error; use thiserror::Error;
/// Playback output channel count. Capture/encode/network remain mono; only the /// Playback output channel count. Capture/encode/network remain mono; only the
@@ -35,7 +35,11 @@ pub enum AudioError {
pub trait AudioBackend: Send + Sync { pub trait AudioBackend: Send + Sync {
/// Starts capturing raw PCM audio from the input device (microphone), /// Starts capturing raw PCM audio from the input device (microphone),
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender. /// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError>; fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError>;
/// Starts playing back raw PCM audio to the output device (speaker), /// Starts playing back raw PCM audio to the output device (speaker),
/// reading mixed/incoming chunks of samples from the provided Receiver. /// reading mixed/incoming chunks of samples from the provided Receiver.
@@ -65,16 +69,16 @@ pub mod pan;
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and // Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
// pure, so it builds (and its tests run) everywhere even though only the cpal // pure, so it builds (and its tests run) everywhere even though only the cpal
// backend wires it in. // backend wires it in.
pub mod resample; #[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod echo_cancel; pub mod echo_cancel;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod pipewire_impl; pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod pw_cli; pub mod pw_cli;
pub mod recorder; pub mod recorder;
pub mod resample;
/// A selectable audio device for the input/output pickers. `name` is the stable /// A selectable audio device for the input/output pickers. `name` is the stable
/// identifier the backend uses to request the device (`target_node`); /// identifier the backend uses to request the device (`target_node`);
@@ -96,10 +100,10 @@ impl std::fmt::Display for AudioDevice {
// Enumerate audio input/output devices for the pickers (sorted by description), // Enumerate audio input/output devices for the pickers (sorted by description),
// returning the same `AudioDevice` shape regardless of platform: PipeWire // returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows. // (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)] #[cfg(windows)]
pub use cpal_impl::enumerate_audio_devices; pub use cpal_impl::enumerate_audio_devices;
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
/// The audio backend implementation for the current platform. /// The audio backend implementation for the current platform.
/// ///
+33 -7
View File
@@ -108,7 +108,13 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
let clean = crate::sanitize::sanitize_name(name); let clean = crate::sanitize::sanitize_name(name);
let mut slug: String = clean let mut slug: String = clean
.chars() .chars()
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' }) .map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect(); .collect();
// Collapse runs of '-' and trim them off the ends. // Collapse runs of '-' and trim them off the ends.
while slug.contains("--") { while slug.contains("--") {
@@ -284,7 +290,10 @@ mod tests {
let short: String = id.to_string().chars().take(8).collect(); let short: String = id.to_string().chars().take(8).collect();
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav")); assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
// Spaces / punctuation collapse to single dashes, trimmed. // Spaces / punctuation collapse to single dashes, trimmed.
assert_eq!(track_filename(" Bob the Builder! ", &id), format!("bob-the-builder-{short}.wav")); assert_eq!(
track_filename(" Bob the Builder! ", &id),
format!("bob-the-builder-{short}.wav")
);
// A name that sanitizes/slugs to nothing falls back to "peer". // A name that sanitizes/slugs to nothing falls back to "peer".
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav")); assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
} }
@@ -327,10 +336,18 @@ mod tests {
rec.finalize().unwrap(); rec.finalize().unwrap();
let expected = 3 * frame; let expected = 3 * frame;
assert_eq!(wav_samples(&dir.join("me.wav")), expected, "mic padded to full length"); assert_eq!(
wav_samples(&dir.join("me.wav")),
expected,
"mic padded to full length"
);
assert_eq!(wav_samples(&dir.join("mix.wav")), expected); assert_eq!(wav_samples(&dir.join("mix.wav")), expected);
assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected); assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected);
assert_eq!(wav_samples(&dir.join(track_filename("p2", &p2))), expected, "silent peer still full length"); assert_eq!(
wav_samples(&dir.join(track_filename("p2", &p2))),
expected,
"silent peer still full length"
);
} }
#[test] #[test]
@@ -357,8 +374,14 @@ mod tests {
rec.finalize().unwrap(); rec.finalize().unwrap();
// Both tracks are the full 5 cycles long (late one was back-padded). // Both tracks are the full 5 cycles long (late one was back-padded).
assert_eq!(wav_samples(&dir.join(track_filename("early", &early))), 5 * frame); assert_eq!(
assert_eq!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame); wav_samples(&dir.join(track_filename("early", &early))),
5 * frame
);
assert_eq!(
wav_samples(&dir.join(track_filename("late", &late))),
5 * frame
);
// The late track's first 2 cycles are silence, then the real audio. // The late track's first 2 cycles are silence, then the real audio.
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap(); let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
@@ -378,6 +401,9 @@ mod tests {
rec.end_cycle().unwrap(); rec.end_cycle().unwrap();
rec.finalize().unwrap(); rec.finalize().unwrap();
assert!(dir.join("me.wav").exists()); assert!(dir.join("me.wav").exists());
assert!(!dir.join("mix.wav").exists(), "no mix track in stems-only mode"); assert!(
!dir.join("mix.wav").exists(),
"no mix track in stems-only mode"
);
} }
} }
+20 -5
View File
@@ -23,7 +23,10 @@ pub fn pan_gains(pan: f32) -> (f32, f32) {
/// still following the same equal-power curve as a peer is moved away from center. /// still following the same equal-power curve as a peer is moved away from center.
pub fn playback_pan_gains(pan: f32) -> (f32, f32) { pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
let (left, right) = pan_gains(pan); let (left, right) = pan_gains(pan);
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2) (
left * std::f32::consts::SQRT_2,
right * std::f32::consts::SQRT_2,
)
} }
#[cfg(test)] #[cfg(test)]
@@ -36,8 +39,14 @@ mod tests {
fn hard_left_and_right_are_endpoints() { fn hard_left_and_right_are_endpoints() {
assert_eq!(pan_gains(-1.0), (1.0, 0.0)); assert_eq!(pan_gains(-1.0), (1.0, 0.0));
let (l, r) = pan_gains(1.0); let (l, r) = pan_gains(1.0);
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}"); assert!(
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}"); l.abs() < EPS,
"left at hard-right should be zero-ish, got {l}"
);
assert!(
(r - 1.0).abs() < EPS,
"right at hard-right should be one, got {r}"
);
} }
#[test] #[test]
@@ -55,8 +64,14 @@ mod tests {
let mut prev_r = f32::NEG_INFINITY; let mut prev_r = f32::NEG_INFINITY;
for pan in pans { for pan in pans {
let (l, r) = pan_gains(pan); let (l, r) = pan_gains(pan);
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right"); assert!(
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right"); l <= prev_l + EPS,
"left gain must not rise as pan moves right"
);
assert!(
r >= prev_r - EPS,
"right gain must not fall as pan moves right"
);
prev_l = l; prev_l = l;
prev_r = r; prev_r = r;
} }
+66 -44
View File
@@ -1,13 +1,16 @@
use crate::audio::{AudioBackend, AudioError}; use crate::audio::{AudioBackend, AudioError};
use std::sync::mpsc::{Sender, Receiver, RecvTimeoutError};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use pipewire as pw; use pipewire as pw;
use pw::{properties::properties, spa}; use pw::{properties::properties, spa};
use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
use spa::pod::Pod; use spa::pod::Pod;
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
pub struct PipeWireBackend { pub struct PipeWireBackend {
capture_state: Mutex<Option<CaptureState>>, capture_state: Mutex<Option<CaptureState>>,
@@ -41,7 +44,11 @@ impl PipeWireBackend {
} }
impl AudioBackend for PipeWireBackend { impl AudioBackend for PipeWireBackend {
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> { fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let mut capture_guard = self.capture_state.lock().unwrap(); let mut capture_guard = self.capture_state.lock().unwrap();
if capture_guard.is_some() { if capture_guard.is_some() {
return Err(AudioError::Stream("Capture already started".to_string())); return Err(AudioError::Stream("Capture already started".to_string()));
@@ -108,12 +115,17 @@ impl AudioBackend for PipeWireBackend {
} }
} }
fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> { fn run_capture(
let mainloop = pw::main_loop::MainLoopRc::new(None) cmd_rx: pw::channel::Receiver<()>,
.map_err(|e| AudioError::Init(e.to_string()))?; tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let mainloop =
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
let context = pw::context::ContextRc::new(&mainloop, None) let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
let core = context.connect_rc(None) let core = context
.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz) // Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
@@ -181,15 +193,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
let mut params = [Pod::from_bytes(&values).unwrap()]; let mut params = [Pod::from_bytes(&values).unwrap()];
stream.connect( stream
spa::utils::Direction::Input, .connect(
None, spa::utils::Direction::Input,
pw::stream::StreamFlags::AUTOCONNECT None,
| pw::stream::StreamFlags::MAP_BUFFERS pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::RT_PROCESS, | pw::stream::StreamFlags::MAP_BUFFERS
&mut params, | pw::stream::StreamFlags::RT_PROCESS,
) &mut params,
.map_err(|e| AudioError::Stream(e.to_string()))?; )
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn the worker thread to pop from consumer and send Vec<i16> frames // Spawn the worker thread to pop from consumer and send Vec<i16> frames
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
@@ -257,11 +270,7 @@ const WORKER_POLL: Duration = Duration::from_millis(100);
/// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()` /// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()`
/// join the worker promptly instead of hanging on a parked blocking `recv()` /// join the worker promptly instead of hanging on a parked blocking `recv()`
/// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable. /// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable.
fn drain_loop( fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
rx: &Receiver<Vec<i16>>,
running: &AtomicBool,
mut on_frame: impl FnMut(Vec<i16>),
) {
while running.load(Ordering::Relaxed) { while running.load(Ordering::Relaxed) {
match rx.recv_timeout(WORKER_POLL) { match rx.recv_timeout(WORKER_POLL) {
Ok(frame) => on_frame(frame), Ok(frame) => on_frame(frame),
@@ -293,7 +302,11 @@ fn publish_frame<P: Producer<Item = i16>>(
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize { fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
/// Safe per-cycle fallback when the graph doesn't report a quantum. /// Safe per-cycle fallback when the graph doesn't report a quantum.
const FALLBACK_FRAMES: usize = 1024; const FALLBACK_FRAMES: usize = 1024;
let want = if requested > 0 { requested } else { FALLBACK_FRAMES }; let want = if requested > 0 {
requested
} else {
FALLBACK_FRAMES
};
want.min(mapped_frames) want.min(mapped_frames)
} }
@@ -303,11 +316,12 @@ fn run_playback(
target_node: Option<String>, target_node: Option<String>,
fill_gauge: Arc<AtomicUsize>, fill_gauge: Arc<AtomicUsize>,
) -> Result<(), AudioError> { ) -> Result<(), AudioError> {
let mainloop = pw::main_loop::MainLoopRc::new(None) let mainloop =
.map_err(|e| AudioError::Init(e.to_string()))?; pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
let context = pw::context::ContextRc::new(&mainloop, None) let context = pw::context::ContextRc::new(&mainloop, None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
let core = context.connect_rc(None) let core = context
.connect_rc(None)
.map_err(|e| AudioError::Init(e.to_string()))?; .map_err(|e| AudioError::Init(e.to_string()))?;
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo // Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
@@ -428,7 +442,9 @@ fn run_playback(
} }
if starved > 0 { if starved > 0 {
// One wait-free atomic add per quantum — RT-safe. // One wait-free atomic add per quantum — RT-safe.
user_data.underrun_samples.fetch_add(starved, Ordering::Relaxed); user_data
.underrun_samples
.fetch_add(starved, Ordering::Relaxed);
} }
// Decrement the exact occupancy counter by the samples we // Decrement the exact occupancy counter by the samples we
// actually pulled (excluding underruns, which removed // actually pulled (excluding underruns, which removed
@@ -493,7 +509,11 @@ fn run_playback(
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int( pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
pw::spa::utils::Choice( pw::spa::utils::Choice(
pw::spa::utils::ChoiceFlags::empty(), pw::spa::utils::ChoiceFlags::empty(),
pw::spa::utils::ChoiceEnum::Range { default: 8, min: 2, max: 64 }, pw::spa::utils::ChoiceEnum::Range {
default: 8,
min: 2,
max: 64,
},
), ),
)), )),
), ),
@@ -524,15 +544,16 @@ fn run_playback(
Pod::from_bytes(&buffers_values).unwrap(), Pod::from_bytes(&buffers_values).unwrap(),
]; ];
stream.connect( stream
spa::utils::Direction::Output, .connect(
None, spa::utils::Direction::Output,
pw::stream::StreamFlags::AUTOCONNECT None,
| pw::stream::StreamFlags::MAP_BUFFERS pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::RT_PROCESS, | pw::stream::StreamFlags::MAP_BUFFERS
&mut params, | pw::stream::StreamFlags::RT_PROCESS,
) &mut params,
.map_err(|e| AudioError::Stream(e.to_string()))?; )
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn a worker thread to read from rx and push to producer // Spawn a worker thread to read from rx and push to producer
let running = Arc::new(AtomicBool::new(true)); let running = Arc::new(AtomicBool::new(true));
@@ -607,7 +628,10 @@ fn run_playback(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame}; use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}}; use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
@@ -647,9 +671,7 @@ mod tests {
#[test] #[test]
fn capture_size_larger_than_mapping_is_clamped() { fn capture_size_larger_than_mapping_is_clamped() {
let mut samples = Vec::new(); let mut samples = Vec::new();
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| { for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample));
samples.push(sample)
});
assert_eq!(samples, vec![1, 2]); assert_eq!(samples, vec![1, 2]);
} }
+31 -6
View File
@@ -16,11 +16,20 @@ pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/* /// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/*
/// node, then resets the accumulators for the next block. Non-audio or /// node, then resets the accumulators for the next block. Non-audio or
/// incomplete blocks are dropped (but still reset). /// incomplete blocks are dropped (but still reset).
fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec<AudioDevice>) { fn push_device(
name: &mut String,
desc: &mut String,
class: &mut String,
out: &mut Vec<AudioDevice>,
) {
if !name.is_empty() && class.starts_with("Audio/") { if !name.is_empty() && class.starts_with("Audio/") {
out.push(AudioDevice { out.push(AudioDevice {
name: name.clone(), name: name.clone(),
description: if desc.is_empty() { name.clone() } else { desc.clone() }, description: if desc.is_empty() {
name.clone()
} else {
desc.clone()
},
is_input: class == "Audio/Source", is_input: class == "Audio/Source",
}); });
} }
@@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
for line in text.lines() { for line in text.lines() {
let line = line.trim(); let line = line.trim();
if line.starts_with("id ") { if line.starts_with("id ") {
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); push_device(
&mut current_name,
&mut current_desc,
&mut current_class,
&mut devices,
);
} else if let Some(val) = line.strip_prefix("node.name = \"") { } else if let Some(val) = line.strip_prefix("node.name = \"") {
current_name = val.trim_end_matches('"').to_string(); current_name = val.trim_end_matches('"').to_string();
} else if let Some(val) = line.strip_prefix("node.description = \"") { } else if let Some(val) = line.strip_prefix("node.description = \"") {
@@ -53,7 +67,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
current_class = val.trim_end_matches('"').to_string(); current_class = val.trim_end_matches('"').to_string();
} }
} }
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices); push_device(
&mut current_name,
&mut current_desc,
&mut current_class,
&mut devices,
);
devices.sort_by(|a, b| a.description.cmp(&b.description)); devices.sort_by(|a, b| a.description.cmp(&b.description));
devices devices
@@ -108,8 +127,14 @@ mod tests {
fn source_is_input_sink_is_output() { fn source_is_input_sink_is_output() {
let devices = parse_pw_nodes(SAMPLE_NODES); let devices = parse_pw_nodes(SAMPLE_NODES);
// Find devices by name or description to verify is_input // Find devices by name or description to verify is_input
let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap(); let mic = devices
let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap(); .iter()
.find(|d| d.name == "alsa_input.builtin")
.unwrap();
let speakers = devices
.iter()
.find(|d| d.name == "alsa_output.builtin")
.unwrap();
let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap(); let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap();
assert!(mic.is_input); assert!(mic.is_input);
+8 -2
View File
@@ -145,7 +145,10 @@ impl StereoPullResampler {
self.frac -= 1.0; self.frac -= 1.0;
} }
let f = self.frac as f32; let f = self.frac as f32;
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f)); let out = (
lerp(self.prev.0, self.cur.0, f),
lerp(self.prev.1, self.cur.1, f),
);
self.frac += self.step; self.frac += self.step;
Some(out) Some(out)
} }
@@ -273,7 +276,10 @@ mod tests {
} }
} }
// At step 2.0 we consume ~2 input frames per output frame. // At step 2.0 we consume ~2 input frames per output frame.
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output"); assert!(
idx > emitted,
"consumed {idx} input, emitted {emitted} output"
);
} }
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner /// A zero rate must not produce a zero `step` (which would spin `push`'s inner
+12 -3
View File
@@ -206,7 +206,10 @@ pub struct ByteLru<V> {
impl<V: Clone> ByteLru<V> { impl<V: Clone> ByteLru<V> {
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1). /// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
pub fn new(cap: usize) -> Self { pub fn new(cap: usize) -> Self {
Self { cap: cap.max(1), entries: Vec::new() } Self {
cap: cap.max(1),
entries: Vec::new(),
}
} }
/// Return the cached value for these exact `bytes`, building and inserting it /// Return the cached value for these exact `bytes`, building and inserting it
@@ -349,7 +352,10 @@ mod tests {
fn preset_png_in_range_and_out_of_range() { fn preset_png_in_range_and_out_of_range() {
// Every declared preset index resolves to embedded bytes. // Every declared preset index resolves to embedded bytes.
for i in 0..PRESET_COUNT { for i in 0..PRESET_COUNT {
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing"); assert!(
Avatar::Preset(i).preset_png().is_some(),
"preset {i} missing"
);
} }
// Out-of-range index gracefully yields None (→ monogram fallback). // Out-of-range index gracefully yields None (→ monogram fallback).
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none()); assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
@@ -406,7 +412,10 @@ mod tests {
#[test] #[test]
fn sanitize_incoming_rejects_junk_and_oversize() { fn sanitize_incoming_rejects_junk_and_oversize() {
// Not valid base64 / not a PNG → downgraded to monogram. // Not valid base64 / not a PNG → downgraded to monogram.
assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram); assert_eq!(
Avatar::Custom("not base64!!!".into()).sanitize_incoming(),
Avatar::Monogram
);
// Over the byte cap → downgraded without even decoding. // Over the byte cap → downgraded without even decoding.
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1)); let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram); assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
+4 -1
View File
@@ -66,7 +66,10 @@ pub fn game_background_filename(game_id: &str) -> String {
/// recedes the image so body text and panel chrome stay readable, and it re-tints /// recedes the image so body text and panel chrome stay readable, and it re-tints
/// per theme since `base` comes from the active palette. /// per theme since `base` comes from the active palette.
pub fn scrim_color(base: Color, dim: f32) -> Color { pub fn scrim_color(base: Color, dim: f32) -> Color {
Color { a: dim.clamp(0.0, 1.0), ..base } Color {
a: dim.clamp(0.0, 1.0),
..base
}
} }
#[cfg(test)] #[cfg(test)]
+58 -12
View File
@@ -105,7 +105,11 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
"pink" => generators::pink_noise(amp, len, seed), "pink" => generators::pink_noise(amp, len, seed),
"impulse" => generators::impulse(amp, len), "impulse" => generators::impulse(amp, len),
"silence" => generators::silence(len), "silence" => generators::silence(len),
other => return Err(format!("unknown kind {other:?} (sine sweep white pink impulse silence)")), other => {
return Err(format!(
"unknown kind {other:?} (sine sweep white pink impulse silence)"
));
}
}; };
wav::write(Path::new(out), &samples, SAMPLE_RATE)?; wav::write(Path::new(out), &samples, SAMPLE_RATE)?;
@@ -125,8 +129,12 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
/// in which frequency range any residual lives. /// in which frequency range any residual lives.
fn cmd_erle(args: &[String]) -> Result<(), String> { fn cmd_erle(args: &[String]) -> Result<(), String> {
let (positional, flags) = parse_args(args); let (positional, flags) = parse_args(args);
let before = positional.first().ok_or("erle needs <before.wav> <after.wav>")?; let before = positional
let after = positional.get(1).ok_or("erle needs <before.wav> <after.wav>")?; .first()
.ok_or("erle needs <before.wav> <after.wav>")?;
let after = positional
.get(1)
.ok_or("erle needs <before.wav> <after.wav>")?;
let b = wav::read(Path::new(before))?; let b = wav::read(Path::new(before))?;
let a = wav::read(Path::new(after))?; let a = wav::read(Path::new(after))?;
@@ -239,17 +247,34 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
1000.0 * tail as f32 / sr as f32, 1000.0 * tail as f32 / sr as f32,
metrics::dbfs(atten), metrics::dbfs(atten),
); );
println!(" filter: {taps} taps, mu {mu}{}", if has_near { " (with near-end / double-talk)" } else { "" }); println!(
" filter: {taps} taps, mu {mu}{}",
if has_near {
" (with near-end / double-talk)"
} else {
""
}
);
if has_near { if has_near {
let dtd = if flags.present("no-dtd") { "off" } else { "on" }; let dtd = if flags.present("no-dtd") { "off" } else { "on" };
println!( println!(
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}", " double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
100.0 * canceller.double_talk_rate(), 100.0 * canceller.double_talk_rate(),
if onset > 0 { format!(", near-end onset {:.1}s", onset as f32 / sr as f32) } else { String::new() }, if onset > 0 {
format!(", near-end onset {:.1}s", onset as f32 / sr as f32)
} else {
String::new()
},
); );
} }
println!(" mic before: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&mic))); println!(
println!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual))); " mic before: {:.1} dBFS rms",
metrics::dbfs(metrics::rms(&mic))
);
println!(
" residual echo after: {:.1} dBFS rms",
metrics::dbfs(metrics::rms(&residual))
);
println!(" ERLE broadband: {broadband:+.1} dB"); println!(" ERLE broadband: {broadband:+.1} dB");
println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)"); println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)");
@@ -278,9 +303,21 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
} }
if flags.present("show") { if flags.present("show") {
println!("\n--- mic (echo present) ---"); println!("\n--- mic (echo present) ---");
print!("{}", render::render(&stft::analyze(&mic, sr, 2048, 512), &render::RenderOpts::default())); print!(
"{}",
render::render(
&stft::analyze(&mic, sr, 2048, 512),
&render::RenderOpts::default()
)
);
println!("\n--- cleaned (post-AEC) ---"); println!("\n--- cleaned (post-AEC) ---");
print!("{}", render::render(&stft::analyze(&cleaned, sr, 2048, 512), &render::RenderOpts::default())); print!(
"{}",
render::render(
&stft::analyze(&cleaned, sr, 2048, 512),
&render::RenderOpts::default()
)
);
} }
Ok(()) Ok(())
} }
@@ -339,13 +376,22 @@ impl Flags {
self.bools.iter().any(|b| b == key) || self.map.contains_key(key) self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
} }
fn f32_or(&self, key: &str, default: f32) -> f32 { fn f32_or(&self, key: &str, default: f32) -> f32 {
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) self.map
.get(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
fn usize_or(&self, key: &str, default: usize) -> usize { fn usize_or(&self, key: &str, default: usize) -> usize {
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) self.map
.get(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
fn u64_or(&self, key: &str, default: u64) -> u64 { fn u64_or(&self, key: &str, default: u64) -> u64 {
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default) self.map
.get(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
} }
} }
+14 -7
View File
@@ -1,9 +1,6 @@
use peerspeak::network::{
gossip::IrohGossipState,
RoomState, PeerState,
};
use iroh::{Endpoint, endpoint::presets}; use iroh::{Endpoint, endpoint::presets};
use iroh_gossip::net::Gossip; use iroh_gossip::net::Gossip;
use peerspeak::network::{PeerState, RoomState, gossip::IrohGossipState};
use tokio::time::{self, Duration}; use tokio::time::{self, Duration};
#[tokio::main] #[tokio::main]
@@ -18,7 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.address_lookup(lookup_a.clone()) .address_lookup(lookup_a.clone())
.bind() .bind()
.await?; .await?;
endpoint_a.online().await; endpoint_a.online().await;
println!("Node A online. ID: {}", endpoint_a.id()); println!("Node A online. ID: {}", endpoint_a.id());
@@ -27,7 +24,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone()) .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
.spawn(); .spawn();
let room_a = IrohGossipState::new(endpoint_a.clone(), gossip_a.clone(), lookup_a.clone(), secret_a); let room_a = IrohGossipState::new(
endpoint_a.clone(),
gossip_a.clone(),
lookup_a.clone(),
secret_a,
);
// 2. Node B (Client) Setup // 2. Node B (Client) Setup
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new(); let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
@@ -46,7 +48,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone()) .accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
.spawn(); .spawn();
let room_b = IrohGossipState::new(endpoint_b.clone(), gossip_b.clone(), lookup_b.clone(), secret_b); let room_b = IrohGossipState::new(
endpoint_b.clone(),
gossip_b.clone(),
lookup_b.clone(),
secret_b,
);
// 3. Create room on Node A // 3. Create room on Node A
let topic_id = rand::random(); let topic_id = rand::random();
+63 -17
View File
@@ -1,5 +1,5 @@
use crate::codec::{AudioEncoder, AudioDecoder, CodecError}; use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
use opus::{Encoder, Decoder, Application, Channels}; use opus::{Application, Channels, Decoder, Encoder};
pub struct OpusEncoder { pub struct OpusEncoder {
encoder: Encoder, encoder: Encoder,
@@ -8,7 +8,11 @@ pub struct OpusEncoder {
impl OpusEncoder { impl OpusEncoder {
/// Creates a new Opus encoder. /// Creates a new Opus encoder.
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip /// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip
pub fn new(sample_rate: u32, channels: Channels, application: Application) -> Result<Self, CodecError> { pub fn new(
sample_rate: u32,
channels: Channels,
application: Application,
) -> Result<Self, CodecError> {
let encoder = Encoder::new(sample_rate, channels, application) let encoder = Encoder::new(sample_rate, channels, application)
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?; .map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
Ok(Self { encoder }) Ok(Self { encoder })
@@ -20,9 +24,11 @@ impl AudioEncoder for OpusEncoder {
// We allocate a buffer for the compressed output. // We allocate a buffer for the compressed output.
// A maximum packet size of 4000 bytes is more than enough for a single voice frame. // A maximum packet size of 4000 bytes is more than enough for a single voice frame.
let mut compressed = vec![0u8; 4000]; let mut compressed = vec![0u8; 4000];
let len = self.encoder.encode(pcm, &mut compressed) let len = self
.encoder
.encode(pcm, &mut compressed)
.map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?; .map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?;
compressed.truncate(len); compressed.truncate(len);
Ok(compressed) Ok(compressed)
} }
@@ -42,10 +48,18 @@ impl OpusDecoder {
/// Creates a new Opus decoder. /// Creates a new Opus decoder.
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono. /// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
/// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960). /// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960).
pub fn new(sample_rate: u32, channels: Channels, frame_samples: usize) -> Result<Self, CodecError> { pub fn new(
sample_rate: u32,
channels: Channels,
frame_samples: usize,
) -> Result<Self, CodecError> {
let decoder = Decoder::new(sample_rate, channels) let decoder = Decoder::new(sample_rate, channels)
.map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?; .map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?;
Ok(Self { decoder, channels, frame_samples }) Ok(Self {
decoder,
channels,
frame_samples,
})
} }
fn channels_count(&self) -> usize { fn channels_count(&self) -> usize {
@@ -73,7 +87,9 @@ impl AudioDecoder for OpusDecoder {
} }
}; };
let decoded_per_channel = self.decoder.decode(input, &mut pcm, false) let decoded_per_channel = self
.decoder
.decode(input, &mut pcm, false)
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?; .map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
pcm.truncate(decoded_per_channel * channels_count); pcm.truncate(decoded_per_channel * channels_count);
@@ -100,7 +116,10 @@ mod tests {
// encode it // encode it
let compressed = encoder.encode(&pcm).unwrap(); let compressed = encoder.encode(&pcm).unwrap();
assert!(!compressed.is_empty(), "Compressed buffer should not be empty"); assert!(
!compressed.is_empty(),
"Compressed buffer should not be empty"
);
assert!( assert!(
compressed.len() < pcm.len() * std::mem::size_of::<i16>(), compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
"Compressed size ({}) should be smaller than raw PCM size ({})", "Compressed size ({}) should be smaller than raw PCM size ({})",
@@ -110,13 +129,21 @@ mod tests {
// decode it // decode it
let decoded = decoder.decode(Some(&compressed)).unwrap(); let decoded = decoder.decode(Some(&compressed)).unwrap();
assert_eq!(decoded.len(), 960, "Decoded sample count should be exactly 960"); assert_eq!(
decoded.len(),
960,
"Decoded sample count should be exactly 960"
);
// 2. Round-trip carries signal energy (not silence) // 2. Round-trip carries signal energy (not silence)
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum(); let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
let rms = (sum_sq / decoded.len() as f64).sqrt(); let rms = (sum_sq / decoded.len() as f64).sqrt();
// Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0) // Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0)
assert!(rms > 100.0, "Decoded signal should carry energy (RMS was {})", rms); assert!(
rms > 100.0,
"Decoded signal should carry energy (RMS was {})",
rms
);
} }
#[test] #[test]
@@ -125,11 +152,19 @@ mod tests {
// decode(None) returns exactly frame_samples (960) samples // decode(None) returns exactly frame_samples (960) samples
let plc_none = decoder.decode(None).unwrap(); let plc_none = decoder.decode(None).unwrap();
assert_eq!(plc_none.len(), 960, "decode(None) should yield exactly 960 samples"); assert_eq!(
plc_none.len(),
960,
"decode(None) should yield exactly 960 samples"
);
// decode(Some(&[])) (empty slice) does the same // decode(Some(&[])) (empty slice) does the same
let plc_empty = decoder.decode(Some(&[])).unwrap(); let plc_empty = decoder.decode(Some(&[])).unwrap();
assert_eq!(plc_empty.len(), 960, "decode(Some(&[])) should yield exactly 960 samples"); assert_eq!(
plc_empty.len(),
960,
"decode(Some(&[])) should yield exactly 960 samples"
);
} }
#[test] #[test]
@@ -140,7 +175,11 @@ mod tests {
let pcm = vec![0i16; 960]; let pcm = vec![0i16; 960];
let compressed = encoder.encode(&pcm).unwrap(); let compressed = encoder.encode(&pcm).unwrap();
let decoded = decoder.decode(Some(&compressed)).unwrap(); let decoded = decoder.decode(Some(&compressed)).unwrap();
assert_eq!(decoded.len(), 960, "Decoded sample count should match packet duration"); assert_eq!(
decoded.len(),
960,
"Decoded sample count should match packet duration"
);
} }
#[test] #[test]
@@ -149,11 +188,18 @@ mod tests {
// decode(None) returns exactly frame_samples * 2 (1920) samples // decode(None) returns exactly frame_samples * 2 (1920) samples
let plc_none = decoder.decode(None).unwrap(); let plc_none = decoder.decode(None).unwrap();
assert_eq!(plc_none.len(), 960 * 2, "Stereo decode(None) should yield exactly 1920 samples"); assert_eq!(
plc_none.len(),
960 * 2,
"Stereo decode(None) should yield exactly 1920 samples"
);
// decode(Some(&[])) (empty slice) does the same // decode(Some(&[])) (empty slice) does the same
let plc_empty = decoder.decode(Some(&[])).unwrap(); let plc_empty = decoder.decode(Some(&[])).unwrap();
assert_eq!(plc_empty.len(), 960 * 2, "Stereo decode(Some(&[])) should yield exactly 1920 samples"); assert_eq!(
plc_empty.len(),
960 * 2,
"Stereo decode(Some(&[])) should yield exactly 1920 samples"
);
} }
} }
+44 -17
View File
@@ -27,8 +27,11 @@ pub enum NetworkMode {
impl NetworkMode { impl NetworkMode {
/// All variants, for presentation in a picker. /// All variants, for presentation in a picker.
pub const ALL: [NetworkMode; 3] = pub const ALL: [NetworkMode; 3] = [
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly]; NetworkMode::RelayNoDiscovery,
NetworkMode::N0Full,
NetworkMode::DirectOnly,
];
} }
/// Arrangement of the in-call room screen, chosen via the layout picker. /// Arrangement of the in-call room screen, chosen via the layout picker.
@@ -45,8 +48,11 @@ pub enum RoomLayout {
impl RoomLayout { impl RoomLayout {
/// All variants, in picker display order. /// All variants, in picker display order.
pub const ALL: [RoomLayout; 3] = pub const ALL: [RoomLayout; 3] = [
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer]; RoomLayout::ThreeColumn,
RoomLayout::BottomDock,
RoomLayout::Drawer,
];
} }
/// What a call recording captures. `Mixed` is the original single-file behaviour; /// What a call recording captures. `Mixed` is the original single-file behaviour;
@@ -65,8 +71,11 @@ pub enum RecordingMode {
impl RecordingMode { impl RecordingMode {
/// All variants, in picker display order. /// All variants, in picker display order.
pub const ALL: [RecordingMode; 3] = pub const ALL: [RecordingMode; 3] = [
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both]; RecordingMode::Mixed,
RecordingMode::Multitrack,
RecordingMode::Both,
];
/// True when this mode writes per-peer stem tracks (Multitrack or Both). /// True when this mode writes per-peer stem tracks (Multitrack or Both).
pub fn is_multitrack(self) -> bool { pub fn is_multitrack(self) -> bool {
@@ -707,7 +716,10 @@ mod tests {
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery); assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
// Configs predating the presence posture load as friends-only (no beacon). // Configs predating the presence posture load as friends-only (no beacon).
assert_eq!(deserialized.presence_mode, crate::presence::PresenceMode::Normal); assert_eq!(
deserialized.presence_mode,
crate::presence::PresenceMode::Normal
);
assert!(!deserialized.echo_cancellation_enabled); assert!(!deserialized.echo_cancellation_enabled);
assert!(deserialized.notifications_enabled); assert!(deserialized.notifications_enabled);
// Configs predating the volume sliders must load at unity gain. // Configs predating the volume sliders must load at unity gain.
@@ -735,7 +747,10 @@ mod tests {
// Configs predating the per-sound flags (W6) enable every chime, so an // Configs predating the per-sound flags (W6) enable every chime, so an
// upgrade is silent-change-free. // upgrade is silent-change-free.
for sound in Sound::ALL { for sound in Sound::ALL {
assert!(deserialized.sound_enabled(sound), "{sound:?} should default on"); assert!(
deserialized.sound_enabled(sound),
"{sound:?} should default on"
);
} }
// The accessor and mutator agree round-trip. // The accessor and mutator agree round-trip.
let mut cfg = AppConfig::default(); let mut cfg = AppConfig::default();
@@ -782,7 +797,10 @@ mod tests {
}"#; }"#;
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap(); let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
// The pre-existing single background survives untouched (still Option<String>). // The pre-existing single background survives untouched (still Option<String>).
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png")); assert_eq!(
cfg.background.as_deref(),
Some("/home/eric/.config/peerspeak/background.png")
);
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON); assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
// The new game-detection fields default to off/empty → silent, opt-in upgrade. // The new game-detection fields default to off/empty → silent, opt-in upgrade.
assert!(!cfg.game_presence_enabled); assert!(!cfg.game_presence_enabled);
@@ -794,9 +812,12 @@ mod tests {
fn test_game_maps_serialize_deterministically() { fn test_game_maps_serialize_deterministically() {
// BTreeMap ordering makes the serialized config stable across runs. // BTreeMap ordering makes the serialized config stable across runs.
let mut cfg = AppConfig::default(); let mut cfg = AppConfig::default();
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into()); cfg.game_backgrounds
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into()); .insert("steam:730".into(), "/a.png".into());
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into()); cfg.game_backgrounds
.insert("exe:hl2_linux".into(), "/b.png".into());
cfg.game_process_map
.insert("hl2_linux".into(), "Half-Life 2".into());
let json = serde_json::to_string(&cfg).unwrap(); let json = serde_json::to_string(&cfg).unwrap();
// Keys appear in sorted order (exe: before steam:). // Keys appear in sorted order (exe: before steam:).
let bg = json.find("game_backgrounds").unwrap(); let bg = json.find("game_backgrounds").unwrap();
@@ -854,8 +875,7 @@ mod tests {
recording_mode: RecordingMode::Both, recording_mode: RecordingMode::Both,
..AppConfig::default() ..AppConfig::default()
}; };
let back: AppConfig = let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(back.recording_mode, RecordingMode::Both); assert_eq!(back.recording_mode, RecordingMode::Both);
// is_multitrack() classifies correctly. // is_multitrack() classifies correctly.
assert!(!RecordingMode::Mixed.is_multitrack()); assert!(!RecordingMode::Mixed.is_multitrack());
@@ -941,7 +961,10 @@ mod tests {
assert_eq!(round_tripped.input_volume, 1.5); assert_eq!(round_tripped.input_volume, 1.5);
assert_eq!(round_tripped.output_volume, 0.25); assert_eq!(round_tripped.output_volume, 0.25);
assert_eq!(round_tripped.clip_volume, 0.7); assert_eq!(round_tripped.clip_volume, 0.7);
assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]); assert_eq!(
round_tripped.music_playlist,
vec!["/tmp/song.ogg".to_string()]
);
assert_eq!(round_tripped.music_volume, 0.6); assert_eq!(round_tripped.music_volume, 0.6);
assert!(round_tripped.music_broadcast); assert!(round_tripped.music_broadcast);
assert!(!round_tripped.show_player_bar); assert!(!round_tripped.show_player_bar);
@@ -950,7 +973,8 @@ mod tests {
#[test] #[test]
fn test_notifications_enabled_specifically() { fn test_notifications_enabled_specifically() {
let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; let missing_notifications =
r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap(); let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
assert!(config_missing.notifications_enabled); assert!(config_missing.notifications_enabled);
@@ -1009,7 +1033,10 @@ mod tests {
// Assert that deserialization succeeds even with unrecognized/unknown fields. // Assert that deserialization succeeds even with unrecognized/unknown fields.
// This confirms that serde does not reject unknown fields (i.e. default behavior). // This confirms that serde does not reject unknown fields (i.e. default behavior).
assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present"); assert!(
deserialized_res.is_ok(),
"Config deserialization failed when an unknown field was present"
);
let config = deserialized_res.unwrap(); let config = deserialized_res.unwrap();
assert_eq!(config.input_device, ""); assert_eq!(config.input_device, "");
+135 -40
View File
@@ -9,7 +9,15 @@ pub enum CoreCommand {
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a /// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
/// share ticket to join. `room_name` is the creator's chosen cosmetic label /// share ticket to join. `room_name` is the creator's chosen cosmetic label
/// for a NEW room; it's ignored when joining (the label rides in the ticket). /// for a NEW room; it's ignored when joining (the label rides in the ticket).
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar }, Join {
name: String,
ticket: String,
room_name: String,
input_device: Option<String>,
output_device: Option<String>,
echo_cancellation: bool,
avatar: crate::avatar::Avatar,
},
Leave, Leave,
/// Orderly app shutdown: finalize recordings, leave any active room, stop local /// Orderly app shutdown: finalize recordings, leave any active room, stop local
/// audio/screen-share work, close the persistent network stack, then ack with /// audio/screen-share work, close the persistent network stack, then ack with
@@ -41,7 +49,10 @@ pub enum CoreCommand {
/// Start/stop a standalone capture-only stream that reports the raw mic /// Start/stop a standalone capture-only stream that reports the raw mic
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call. /// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
/// Ignored while a room session is active (the in-call meter covers that). /// Ignored while a room session is active (the in-call meter covers that).
SetMicMonitor { enabled: bool, input_device: Option<String> }, SetMicMonitor {
enabled: bool,
input_device: Option<String>,
},
/// Set the relay/discovery posture. Takes effect on the next room join, /// Set the relay/discovery posture. Takes effect on the next room join,
/// since the endpoint is (re)built then. /// since the endpoint is (re)built then.
SetNetworkMode(NetworkMode), SetNetworkMode(NetworkMode),
@@ -56,22 +67,40 @@ pub enum CoreCommand {
/// Send a chat message carrying a file attachment. The app has already read + /// Send a chat message carrying a file attachment. The app has already read +
/// capped the file and built the descriptor; core makes the bytes available /// capped the file and built the descriptor; core makes the bytes available
/// on the file plane and broadcasts the descriptor. /// on the file plane and broadcasts the descriptor.
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> }, SendChatFile {
text: String,
attachment: crate::files::ChatAttachment,
data: Vec<u8>,
},
/// Fetch a received attachment's bytes from its sender over the file plane /// Fetch a received attachment's bytes from its sender over the file plane
/// (used for on-demand file/chip downloads; images are auto-fetched on /// (used for on-demand file/chip downloads; images are auto-fetched on
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`. /// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment }, FetchAttachment {
from: EndpointId,
attachment: crate::files::ChatAttachment,
},
/// Register `data` as fetchable under `id` for room members (the current /// Register `data` as fetchable under `id` for room members (the current
/// broadcast track). Called once per track when broadcasting. /// broadcast track). Called once per track when broadcasting.
ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc<Vec<u8>> }, ServeMusicTrack {
id: crate::files::AttachmentId,
data: std::sync::Arc<Vec<u8>>,
},
/// Drop a music blob that is no longer current-or-next. /// Drop a music blob that is no longer current-or-next.
ForgetMusicTrack(crate::files::AttachmentId), ForgetMusicTrack(crate::files::AttachmentId),
/// Set (or clear) our broadcast music timeline and re-announce presence. /// Set (or clear) our broadcast music timeline and re-announce presence.
SetMusicPresence(Option<crate::network::MusicPresence>), SetMusicPresence(Option<crate::network::MusicPresence>),
/// Fetch a source peer's current track bytes after tuning into them. /// Fetch a source peer's current track bytes after tuning into them.
FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, FetchMusic {
from: EndpointId,
id: crate::files::AttachmentId,
size: u64,
},
/// Fetch a source peer's advertised next track bytes before it becomes current. /// Fetch a source peer's advertised next track bytes before it becomes current.
PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 }, PrefetchMusic {
from: EndpointId,
id: crate::files::AttachmentId,
size: u64,
},
/// Set the pixelpass binary location (config override, empty = use `$PATH`). /// Set the pixelpass binary location (config override, empty = use `$PATH`).
/// Sent at startup so screen-share can resolve the binary. /// Sent at startup so screen-share can resolve the binary.
SetPixelpassPath(Option<String>), SetPixelpassPath(Option<String>),
@@ -84,7 +113,9 @@ pub enum CoreCommand {
/// `audio_app` selects which app's audio to capture: `Some(name)` captures /// `audio_app` selects which app's audio to capture: `Some(name)` captures
/// only that app (avoiding the call-loopback echo, A23); `None` shares the /// only that app (avoiding the call-loopback echo, A23); `None` shares the
/// whole desktop audio (the legacy behavior). /// whole desktop audio (the legacy behavior).
StartScreenShare { audio_app: Option<String> }, StartScreenShare {
audio_app: Option<String>,
},
/// Stop sharing our screen: kill the pixelpass host and clear the presence /// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing. /// ticket. No-op when not sharing.
StopScreenShare, StopScreenShare,
@@ -98,7 +129,11 @@ pub enum CoreCommand {
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and /// Add a friend (W7). Core owns the friends store: it mutates + persists it and
/// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known /// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known
/// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op. /// (e.g. added from a room). Idempotent — re-adding an existing id is a no-op.
AddFriend { id: EndpointId, name: String, addr: Option<EndpointAddr> }, AddFriend {
id: EndpointId,
name: String,
addr: Option<EndpointAddr>,
},
/// Remove a friend by id (W7). /// Remove a friend by id (W7).
RemoveFriend(EndpointId), RemoveFriend(EndpointId),
/// Locally rename a friend (W7). /// Locally rename a friend (W7).
@@ -189,10 +224,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
from: _, from: _,
attachment: _, attachment: _,
} }
| CoreCommand::ServeMusicTrack { | CoreCommand::ServeMusicTrack { id: _, data: _ }
id: _,
data: _,
}
| CoreCommand::ForgetMusicTrack(_) | CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_) | CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic { | CoreCommand::FetchMusic {
@@ -272,10 +304,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
from: _, from: _,
attachment: _, attachment: _,
} }
| CoreCommand::ServeMusicTrack { | CoreCommand::ServeMusicTrack { id: _, data: _ }
id: _,
data: _,
}
| CoreCommand::ForgetMusicTrack(_) | CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_) | CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic { | CoreCommand::FetchMusic {
@@ -311,55 +340,106 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum UiEvent { pub enum UiEvent {
RoomJoined { ticket: String, self_id: String }, RoomJoined {
ticket: String,
self_id: String,
},
RoomLeft, RoomLeft,
/// Clear room-scoped UI state after a failed in-call room switch, without a /// Clear room-scoped UI state after a failed in-call room switch, without a
/// leave chime. The persistent identity remains unchanged. /// leave chime. The persistent identity remains unchanged.
RoomReset, RoomReset,
PeerJoined { id: EndpointId, state: PeerState }, PeerJoined {
PeerLeft { id: EndpointId }, id: EndpointId,
state: PeerState,
},
PeerLeft {
id: EndpointId,
},
/// The fixed reconnect grace expired and bounded background gossip recovery /// The fixed reconnect grace expired and bounded background gossip recovery
/// has started. This is non-terminal and must not play the failure chime. /// has started. This is non-terminal and must not play the failure chime.
PeerRecoveryStarted { id: EndpointId }, PeerRecoveryStarted {
PeerConnectionFailed { id: EndpointId }, id: EndpointId,
PeerUpdated { id: EndpointId, state: PeerState }, },
PeerConnectionFailed {
id: EndpointId,
},
PeerUpdated {
id: EndpointId,
state: PeerState,
},
/// Audio link to a peer is being (re)established — show a connecting state. /// Audio link to a peer is being (re)established — show a connecting state.
PeerConnecting { id: EndpointId }, PeerConnecting {
id: EndpointId,
},
/// Audio link to a peer is up and carrying audio. /// Audio link to a peer is up and carrying audio.
PeerConnected { id: EndpointId }, PeerConnected {
id: EndpointId,
},
AudioLevels(Vec<(EndpointId, f32)>), AudioLevels(Vec<(EndpointId, f32)>),
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`, /// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
/// for the settings level meter. Throttled to ~10/sec. /// for the settings level meter. Throttled to ~10/sec.
MicLevel(f32), MicLevel(f32),
/// Call recording started; carries the absolute WAV path being written. /// Call recording started; carries the absolute WAV path being written.
RecordingStarted { path: String }, RecordingStarted {
path: String,
},
/// Call recording stopped; carries the finished WAV path. /// Call recording stopped; carries the finished WAV path.
RecordingStopped { path: String }, RecordingStopped {
path: String,
},
/// A room text-chat message arrived from a peer (never our own — local /// A room text-chat message arrived from a peer (never our own — local
/// messages are echoed by the UI on send). `from` is the sender's node id /// messages are echoed by the UI on send). `from` is the sender's node id
/// string, used to key their avatar (W4). /// string, used to key their avatar (W4).
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> }, ChatMessage {
from: String,
name: String,
text: String,
attachment: Option<crate::files::ChatAttachment>,
},
/// An attachment's bytes are now available (auto-fetched for images, or /// An attachment's bytes are now available (auto-fetched for images, or
/// fetched on demand for files). Keyed by `(from, id)`: the id is /// fetched on demand for files). Keyed by `(from, id)`: the id is
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author /// attacker-chosen, so a malicious peer can reuse a victim's id — the author
/// disambiguates whose bytes these are and stops content aliasing (Tier C /// disambiguates whose bytes these are and stops content aliasing (Tier C
/// F-12). /// F-12).
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> }, AttachmentReady {
from: EndpointId,
id: crate::files::AttachmentId,
data: Vec<u8>,
},
/// An attachment fetch failed (sender gone, too large, decode error, etc.). /// An attachment fetch failed (sender gone, too large, decode error, etc.).
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, AttachmentFailed {
from: EndpointId,
id: crate::files::AttachmentId,
error: String,
},
/// A tuned-in source's track bytes arrived; play them in the music sink. /// A tuned-in source's track bytes arrived; play them in the music sink.
MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> }, MusicReady {
from: EndpointId,
id: crate::files::AttachmentId,
data: Vec<u8>,
},
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap. /// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> }, MusicPrefetched {
from: EndpointId,
id: crate::files::AttachmentId,
data: Vec<u8>,
},
/// A music-track fetch failed (source gone, too large, etc.). /// A music-track fetch failed (source gone, too large, etc.).
MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String }, MusicFetchFailed {
from: EndpointId,
id: crate::files::AttachmentId,
error: String,
},
/// The apps currently producing audio, for the screen-share audio picker /// The apps currently producing audio, for the screen-share audio picker
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is /// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
/// playing or enumeration isn't available. `app_audio_supported` reports /// playing or enumeration isn't available. `app_audio_supported` reports
/// whether the resolved pixelpass understands `--strict-audio`: when `false` /// whether the resolved pixelpass understands `--strict-audio`: when `false`
/// (an older pixelpass) the picker must offer whole-desktop audio only, since /// (an older pixelpass) the picker must offer whole-desktop audio only, since
/// a per-app share would pass a flag that older binary rejects (audit P2). /// a per-app share would pass a flag that older binary rejects (audit P2).
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool }, AudioAppsListed {
apps: Vec<String>,
app_audio_supported: bool,
},
/// Our own screen share started; the UI flips the Share button to "Stop". /// Our own screen share started; the UI flips the Share button to "Stop".
ScreenShareStarted, ScreenShareStarted,
/// Our own screen share stopped (or failed to start). /// Our own screen share stopped (or failed to start).
@@ -372,24 +452,37 @@ pub enum UiEvent {
/// A validly signed peer cannot be admitted because its gossip timestamp is /// A validly signed peer cannot be admitted because its gossip timestamp is
/// outside the replay freshness window. `peer_ahead` describes the peer's /// outside the replay freshness window. `peer_ahead` describes the peer's
/// sender-stamped timestamp relative to this machine's clock. /// sender-stamped timestamp relative to this machine's clock.
ClockSkewWarning { skew_secs: u64, peer_ahead: bool }, ClockSkewWarning {
skew_secs: u64,
peer_ahead: bool,
},
/// Our node identity (W7): the current node id string, and whether it is /// Our node identity (W7): the current node id string, and whether it is
/// PERSISTED to disk. Sent once at startup and again after a regenerate. /// PERSISTED to disk. Sent once at startup and again after a regenerate.
/// `persisted = false` means the key file couldn't be read/written and we're /// `persisted = false` means the key file couldn't be read/written and we're
/// running on an ephemeral fallback — a degraded state the UI must surface, /// running on an ephemeral fallback — a degraded state the UI must surface,
/// since the id (and thus friend recognition) won't survive the next launch. /// since the id (and thus friend recognition) won't survive the next launch.
/// `error` carries the reason when degraded, for the UI explainer. /// `error` carries the reason when degraded, for the UI explainer.
IdentityStatus { node_id: String, persisted: bool, error: Option<String> }, IdentityStatus {
node_id: String,
persisted: bool,
error: Option<String>,
},
/// The friends list (W7), now owned by core. Sent at startup (after load) and /// The friends list (W7), now owned by core. Sent at startup (after load) and
/// after every add/remove/rename so the GUI renders from this snapshot instead /// after every add/remove/rename so the GUI renders from this snapshot instead
/// of owning the store. `read_only` is true when `friends.json` failed to load /// of owning the store. `read_only` is true when `friends.json` failed to load
/// (malformed) — the GUI shows a degraded warning and disables edits so we never /// (malformed) — the GUI shows a degraded warning and disables edits so we never
/// overwrite the damaged file (backlog A16). /// overwrite the damaged file (backlog A16).
FriendsUpdated { friends: Vec<Friend>, read_only: bool }, FriendsUpdated {
friends: Vec<Friend>,
read_only: bool,
},
/// A friend's live presence from a successful ping reply (W7): online, or in a /// A friend's live presence from a successful ping reply (W7): online, or in a
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping /// joinable gathering (with a one-click ticket). Emitted by the outbound ping
/// scheduler; absence of a recent event = treat as offline. /// scheduler; absence of a recent event = treat as offline.
FriendPresence { id: EndpointId, presence: FriendPresence }, FriendPresence {
id: EndpointId,
presence: FriendPresence,
},
/// A manual "Rescan" pass finished (every friend has been probed and its /// A manual "Rescan" pass finished (every friend has been probed and its
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the /// per-friend `FriendPresence` already emitted). Lets the GUI clear the
/// transient "Rescanning…" status. Sent only for the on-demand button, not the /// transient "Rescanning…" status. Sent only for the on-demand button, not the
@@ -399,7 +492,9 @@ pub enum UiEvent {
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply /// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
/// failure, this carries the previous truthful mode. The GUI must mirror + /// failure, this carries the previous truthful mode. The GUI must mirror +
/// persist this so its presence picker matches the endpoint's discovery state. /// persist this so its presence picker matches the endpoint's discovery state.
PresenceModeReverted { mode: PresenceMode }, PresenceModeReverted {
mode: PresenceMode,
},
/// The locally-detected running game changed (game detection). Carries the /// The locally-detected running game changed (game detection). Carries the
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing /// debounced `DetectedGame` (id + display name + source) or `None` when nothing
/// is detected. The GUI uses the stable `id` to switch the per-game background /// is detected. The GUI uses the stable `id` to switch the per-game background
@@ -413,7 +508,7 @@ pub enum UiEvent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{coalesce_key, delivery_class, CoalesceKey, CoreCommand, DeliveryClass}; use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class};
use crate::audio::eq::EqSettings; use crate::audio::eq::EqSettings;
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
use iroh::{EndpointId, SecretKey}; use iroh::{EndpointId, SecretKey};
+344 -145
View File
@@ -1,31 +1,33 @@
pub mod messages;
pub mod jitter; pub mod jitter;
pub mod messages;
mod recovery; mod recovery;
use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::eq::{Eq, EqSettings}; use crate::audio::eq::{Eq, EqSettings};
use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; use crate::core::jitter::{FRAME_SAMPLES, JitterBuffer};
use crate::network::{
NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket,
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
gossip::IrohGossipState,
};
use crate::core::messages::{ use crate::core::messages::{
CoalesceKey, CoreCommand, DeliveryClass, UiEvent, coalesce_key, delivery_class, CoalesceKey, CoreCommand, DeliveryClass, UiEvent, coalesce_key, delivery_class,
}; };
use crate::core::recovery::RecoveryCoordinator; use crate::core::recovery::RecoveryCoordinator;
use crate::network::{
ConnEvent, NetworkTransport, PeerSpeakTicket, RoomEvent, RoomState, SelfPresence,
gossip::IrohGossipState,
iroh_impl::{AudioRouter, FileRouter, IrohTransport},
};
use crate::audio::multitrack::MultitrackRecorder;
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{NetworkMode, RecordingMode};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
use crate::audio::multitrack::MultitrackRecorder; use iroh::{
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router}; Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router,
};
use iroh_gossip::net::Gossip; use iroh_gossip::net::Gossip;
use tokio::sync::{mpsc, Mutex};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex as StdMutex};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration; use std::time::Duration;
use tokio::sync::{Mutex, mpsc};
type CoalesceStore = Arc<StdMutex<HashMap<CoalesceKey, CoreCommand>>>; type CoalesceStore = Arc<StdMutex<HashMap<CoalesceKey, CoreCommand>>>;
@@ -205,8 +207,7 @@ type GraceTimers = Arc<std::sync::Mutex<HashMap<EndpointId, tokio::task::JoinHan
/// Scrubbed whenever a peer is evicted or leaves so a later rejoin starts clean. /// Scrubbed whenever a peer is evicted or leaves so a later rejoin starts clean.
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>; type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
type KnownPeers = type KnownPeers = Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
/// Per-topic cap on the retained rejoin-bootstrap / recovery target table /// Per-topic cap on the retained rejoin-bootstrap / recovery target table
/// (Tier C recovery-identity cap). Set comfortably above the live-roster cap /// (Tier C recovery-identity cap). Set comfortably above the live-roster cap
@@ -322,7 +323,9 @@ fn arm_grace_timer(
seen_evict.lock().unwrap().remove(&peer_id); seen_evict.lock().unwrap().remove(&peer_id);
let Some(recovery) = recovery_evict else { let Some(recovery) = recovery_evict else {
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; let _ = ui_evict
.send(UiEvent::PeerConnectionFailed { id: peer_id })
.await;
return; return;
}; };
if !recovery.coordinator.is_active(&peer_id) { if !recovery.coordinator.is_active(&peer_id) {
@@ -335,13 +338,17 @@ fn arm_grace_timer(
peer_id peer_id
)); ));
recovery.cancel(peer_id); recovery.cancel(peer_id);
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; let _ = ui_evict
.send(UiEvent::PeerConnectionFailed { id: peer_id })
.await;
return; return;
}; };
match recovery.coordinator.activate(peer_id, addr) { match recovery.coordinator.activate(peer_id, addr) {
Ok(true) => { Ok(true) => {
let _ = ui_evict.send(UiEvent::PeerRecoveryStarted { id: peer_id }).await; let _ = ui_evict
.send(UiEvent::PeerRecoveryStarted { id: peer_id })
.await;
} }
Ok(false) => {} Ok(false) => {}
Err(()) => { Err(()) => {
@@ -349,7 +356,9 @@ fn arm_grace_timer(
"Cannot recover peer {:?}: recovery coordinator unavailable", "Cannot recover peer {:?}: recovery coordinator unavailable",
peer_id peer_id
)); ));
let _ = ui_evict.send(UiEvent::PeerConnectionFailed { id: peer_id }).await; let _ = ui_evict
.send(UiEvent::PeerConnectionFailed { id: peer_id })
.await;
} }
} }
}); });
@@ -370,11 +379,7 @@ fn apply_volume(frame: &mut [i16], vol: f32) {
/// Apply the listener's per-peer volume for the audio sender id currently being /// Apply the listener's per-peer volume for the audio sender id currently being
/// mixed. The map key must be the same `EndpointId` used for the jitter buffer. /// mixed. The map key must be the same `EndpointId` used for the jitter buffer.
fn apply_peer_volume( fn apply_peer_volume(frame: &mut [i16], peer_id: EndpointId, volumes: &HashMap<EndpointId, f32>) {
frame: &mut [i16],
peer_id: EndpointId,
volumes: &HashMap<EndpointId, f32>,
) {
let vol = volumes.get(&peer_id).copied().unwrap_or(1.0); let vol = volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(frame, vol); apply_volume(frame, vol);
} }
@@ -670,7 +675,8 @@ impl ActiveSession {
crate::log_msg("Stopping audio backend..."); crate::log_msg("Stopping audio backend...");
let _ = audio_backend_clone.stop(); let _ = audio_backend_clone.stop();
crate::log_msg("Audio backend stopped"); crate::log_msg("Audio backend stopped");
}).await; })
.await;
// Unload the echo-cancel module now that the audio streams releasing its // Unload the echo-cancel module now that the audio streams releasing its
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.) // virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
@@ -744,7 +750,8 @@ fn apply_discovery(
plan: crate::discovery::LookupPlan, plan: crate::discovery::LookupPlan,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
use iroh::address_lookup::{ use iroh::address_lookup::{
AddressLookupBuilder, dns::DnsAddressLookup, AddressLookupBuilder,
dns::DnsAddressLookup,
pkarr::{PkarrPublisher, PkarrResolver}, pkarr::{PkarrPublisher, PkarrResolver},
}; };
let services = endpoint.address_lookup()?; let services = endpoint.address_lookup()?;
@@ -810,7 +817,11 @@ async fn build_net_stack(
.await?; .await?;
// Install the n0 DNS services for the current (mode, publish) posture. // Install the n0 DNS services for the current (mode, publish) posture.
apply_discovery(&endpoint, &memory_lookup, crate::discovery::lookup_plan(network_mode, publish))?; apply_discovery(
&endpoint,
&memory_lookup,
crate::discovery::lookup_plan(network_mode, publish),
)?;
// Bring the endpoint online in the background so launch isn't blocked on the // Bring the endpoint online in the background so launch isn't blocked on the
// relay handshake; joins/sends just work once it's up (online() is idempotent). // relay handshake; joins/sends just work once it's up (online() is idempotent).
@@ -862,8 +873,7 @@ const MAX_INFLIGHT_ATTACHMENT_FETCHES: usize = 4;
/// In-flight `(author, attachment_id)` markers for bounded, deduplicated auto- /// In-flight `(author, attachment_id)` markers for bounded, deduplicated auto-
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`]. /// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
type InflightAttachments = type InflightAttachments = Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
/// RAII bookkeeping for one bounded auto-fetch: holds the concurrency permit for /// RAII bookkeeping for one bounded auto-fetch: holds the concurrency permit for
/// the task's lifetime and clears the in-flight `(author, id)` marker when the /// the task's lifetime and clears the in-flight `(author, id)` marker when the
@@ -921,12 +931,20 @@ fn spawn_attachment_fetch(
return; return;
} }
let _ = ui_tx let _ = ui_tx
.send(UiEvent::AttachmentReady { from, id: att.id, data }) .send(UiEvent::AttachmentReady {
from,
id: att.id,
data,
})
.await; .await;
} }
Err(e) => { Err(e) => {
let _ = ui_tx let _ = ui_tx
.send(UiEvent::AttachmentFailed { from, id: att.id, error: e.to_string() }) .send(UiEvent::AttachmentFailed {
from,
id: att.id,
error: e.to_string(),
})
.await; .await;
} }
} }
@@ -943,13 +961,15 @@ fn spawn_music_fetch(
tokio::spawn(async move { tokio::spawn(async move {
match transport.fetch_blob(from, id, size).await { match transport.fetch_blob(from, id, size).await {
Ok(data) => { Ok(data) => {
let _ = ui_tx let _ = ui_tx.send(UiEvent::MusicReady { from, id, data }).await;
.send(UiEvent::MusicReady { from, id, data })
.await;
} }
Err(e) => { Err(e) => {
let _ = ui_tx let _ = ui_tx
.send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() }) .send(UiEvent::MusicFetchFailed {
from,
id,
error: e.to_string(),
})
.await; .await;
} }
} }
@@ -972,7 +992,11 @@ fn spawn_music_prefetch(
} }
Err(e) => { Err(e) => {
let _ = ui_tx let _ = ui_tx
.send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() }) .send(UiEvent::MusicFetchFailed {
from,
id,
error: e.to_string(),
})
.await; .await;
} }
} }
@@ -1024,13 +1048,14 @@ async fn persist_and_emit_friends(
ui_tx: &mpsc::Sender<UiEvent>, ui_tx: &mpsc::Sender<UiEvent>,
) { ) {
let store = friends.lock().unwrap().clone(); let store = friends.lock().unwrap().clone();
if !read_only if !read_only && let Err(e) = crate::friends::save(&store) {
&& let Err(e) = crate::friends::save(&store)
{
crate::log_msg(&format!("friends: save failed: {e:#}")); crate::log_msg(&format!("friends: save failed: {e:#}"));
} }
let _ = ui_tx let _ = ui_tx
.send(UiEvent::FriendsUpdated { friends: store.friends, read_only }) .send(UiEvent::FriendsUpdated {
friends: store.friends,
read_only,
})
.await; .await;
} }
@@ -1063,7 +1088,10 @@ async fn probe_friends_once(
let Some(addr) = f.last_addr.clone() else { let Some(addr) = f.last_addr.clone() else {
// Nothing to dial yet — report Offline so a prior status can't stick. // Nothing to dial yet — report Offline so a prior status can't stick.
let _ = ui_tx let _ = ui_tx
.send(UiEvent::FriendPresence { id, presence: crate::presence::FriendPresence::Offline }) .send(UiEvent::FriendPresence {
id,
presence: crate::presence::FriendPresence::Offline,
})
.await; .await;
continue; continue;
}; };
@@ -1215,7 +1243,9 @@ async fn run_core_loop(
let friends = Arc::new(std::sync::Mutex::new(initial_friends)); let friends = Arc::new(std::sync::Mutex::new(initial_friends));
// Our presence posture, shared with the listener + scheduler. The GUI pushes it // Our presence posture, shared with the listener + scheduler. The GUI pushes it
// at startup + on change via SetPresenceMode; persistence stays in AppConfig. // at startup + on change via SetPresenceMode; persistence stays in AppConfig.
let presence_mode = Arc::new(std::sync::Mutex::new(crate::presence::PresenceMode::default())); let presence_mode = Arc::new(std::sync::Mutex::new(
crate::presence::PresenceMode::default(),
));
// The gathering we're currently in (its restamped member ticket + label), so the // The gathering we're currently in (its restamped member ticket + label), so the
// listener can offer friends a one-click Join. `None` when not in a call. Set on // listener can offer friends a one-click Join. `None` when not in a call. Set on
// Join, cleared on Leave. // Join, cleared on Leave.
@@ -1248,7 +1278,15 @@ async fn run_core_loop(
// rebuilt only when the network mode or identity changes. Moving `memory_lookup` // rebuilt only when the network mode or identity changes. Moving `memory_lookup`
// in — all later access is via `net.memory_lookup`. // in — all later access is via `net.memory_lookup`.
let initial_publish = presence_mode.lock().unwrap().publishes_to_discovery(); let initial_publish = presence_mode.lock().unwrap().publishes_to_discovery();
let mut net = match build_net_stack(secret_key.clone(), network_mode, memory_lookup, friends_handler.clone(), initial_publish).await { let mut net = match build_net_stack(
secret_key.clone(),
network_mode,
memory_lookup,
friends_handler.clone(),
initial_publish,
)
.await
{
Ok(stack) => stack, Ok(stack) => stack,
Err(e) => { Err(e) => {
// Only a local socket bind can fail here (the relay handshake is // Only a local socket bind can fail here (the relay handshake is
@@ -1421,7 +1459,14 @@ async fn run_core_loop(
CoreCommand::Shutdown => { CoreCommand::Shutdown => {
crate::log_msg("Core shutdown requested"); crate::log_msg("Core shutdown requested");
// Finalize recordings while capture/mixer feeders are still alive. // Finalize recordings while capture/mixer feeders are still alive.
stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; stop_recording(
&recorder,
&is_recording,
&multitrack,
&is_multitrack,
&ui_tx,
)
.await;
stop_mic_monitor(&audio_backend, mic_monitor.take()); stop_mic_monitor(&audio_backend, mic_monitor.take());
if let Some(session) = active_session.take() { if let Some(session) = active_session.take() {
@@ -1436,14 +1481,29 @@ async fn run_core_loop(
break; break;
} }
CoreCommand::Join { name, ticket, room_name, input_device, output_device, echo_cancellation, avatar } => { CoreCommand::Join {
name,
ticket,
room_name,
input_device,
output_device,
echo_cancellation,
avatar,
} => {
presence.name = name.clone(); presence.name = name.clone();
presence.avatar = avatar; presence.avatar = avatar;
let was_in_room = active_session.is_some(); let was_in_room = active_session.is_some();
// Finalize any recording before tearing down the old session — its // Finalize any recording before tearing down the old session — its
// capture/mixer feeders are about to stop. // capture/mixer feeders are about to stop.
stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; stop_recording(
&recorder,
&is_recording,
&multitrack,
&is_multitrack,
&ui_tx,
)
.await;
// Clean up any existing session FIRST (this calls `transport.leave()` // Clean up any existing session FIRST (this calls `transport.leave()`
// on the current endpoint), before any stack rebuild closes it. // on the current endpoint), before any stack rebuild closes it.
@@ -1462,7 +1522,14 @@ async fn run_core_loop(
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery(); let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; net = build_net_stack(
secret_key.clone(),
network_mode,
lookup,
friends_handler.clone(),
publish,
)
.await?;
net_rebuild_pending = false; net_rebuild_pending = false;
} }
@@ -1492,7 +1559,11 @@ async fn run_core_loop(
// The creator's chosen cosmetic label rides in the ticket so // The creator's chosen cosmetic label rides in the ticket so
// every joiner inherits it; sanitize it before it leaves here. // every joiner inherits it; sanitize it before it leaves here.
let label = crate::sanitize::sanitize_name(&room_name); let label = crate::sanitize::sanitize_name(&room_name);
let ticket = PeerSpeakTicket { host_addr, topic_id, name: label }; let ticket = PeerSpeakTicket {
host_addr,
topic_id,
name: label,
};
ticket.to_string() ticket.to_string()
} else { } else {
let ticket_str = ticket.trim().to_string(); let ticket_str = ticket.trim().to_string();
@@ -1509,7 +1580,9 @@ async fn run_core_loop(
if was_in_room { if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await; let _ = ui_tx.send(UiEvent::RoomReset).await;
} }
let _ = ui_tx.send(UiEvent::Error("invalid room ticket".to_string())).await; let _ = ui_tx
.send(UiEvent::Error("invalid room ticket".to_string()))
.await;
continue; continue;
} }
}; };
@@ -1531,11 +1604,8 @@ async fn run_core_loop(
// Fresh join starts not sharing; clear any stale share ticket. // Fresh join starts not sharing; clear any stale share ticket.
current_sharing = None; current_sharing = None;
let self_state = presence.to_state( let self_state =
is_muted.load(Ordering::Relaxed), presence.to_state(is_muted.load(Ordering::Relaxed), endpoint.addr(), None);
endpoint.addr(),
None,
);
// Snapshot THIS room's retained peers (by topic) as extra bootstrap // Snapshot THIS room's retained peers (by topic) as extra bootstrap
// targets so a rejoin can dial them (A8) — including after a detour // targets so a rejoin can dial them (A8) — including after a detour
@@ -1559,12 +1629,17 @@ async fn run_core_loop(
self_state.sharing.is_some(), self_state.sharing.is_some(),
extra_bootstrap_ids extra_bootstrap_ids
)); ));
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await { if let Err(e) = room_state
.join(&ticket_str, self_state.clone(), extra_bootstrap)
.await
{
crate::log_msg(&format!("Error room_state.join failed: {:?}", e)); crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
if was_in_room { if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await; let _ = ui_tx.send(UiEvent::RoomReset).await;
} }
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await; let _ = ui_tx
.send(UiEvent::Error(format!("Failed to join room: {}", e)))
.await;
net.audio_router.clear(); net.audio_router.clear();
net.file_router.clear(); net.file_router.clear();
continue; continue;
@@ -1601,7 +1676,9 @@ async fn run_core_loop(
"Echo cancellation unavailable, using direct devices: {e}" "Echo cancellation unavailable, using direct devices: {e}"
)); ));
let _ = ui_tx let _ = ui_tx
.send(UiEvent::Error(format!("Echo cancellation unavailable: {e}"))) .send(UiEvent::Error(format!(
"Echo cancellation unavailable: {e}"
)))
.await; .await;
(input_device.clone(), output_device.clone()) (input_device.clone(), output_device.clone())
} }
@@ -1612,13 +1689,16 @@ async fn run_core_loop(
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let _ = echo_cancellation; let _ = echo_cancellation;
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
let (capture_target, playback_target) = (input_device.clone(), output_device.clone()); let (capture_target, playback_target) =
(input_device.clone(), output_device.clone());
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) { if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
if was_in_room { if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await; let _ = ui_tx.send(UiEvent::RoomReset).await;
} }
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await; let _ = ui_tx
.send(UiEvent::Error(format!("Failed to start capture: {}", e)))
.await;
let _ = room_state.leave().await; let _ = room_state.leave().await;
net.audio_router.clear(); net.audio_router.clear();
net.file_router.clear(); net.file_router.clear();
@@ -1629,11 +1709,15 @@ async fn run_core_loop(
// here (drain side + fill side); the mixer reads it to pace // here (drain side + fill side); the mixer reads it to pace
// production to the hardware clock instead of a fixed timer. // production to the hardware clock instead of a fixed timer.
let ring_fill = Arc::new(AtomicUsize::new(0)); let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone()) { if let Err(e) =
audio_backend.start_playback(playback_rx, playback_target, ring_fill.clone())
{
if was_in_room { if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await; let _ = ui_tx.send(UiEvent::RoomReset).await;
} }
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start playback: {}", e))).await; let _ = ui_tx
.send(UiEvent::Error(format!("Failed to start playback: {}", e)))
.await;
let _ = audio_backend.stop(); let _ = audio_backend.stop();
let _ = room_state.leave().await; let _ = room_state.leave().await;
net.audio_router.clear(); net.audio_router.clear();
@@ -1641,7 +1725,8 @@ async fn run_core_loop(
continue; continue;
} }
let jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>> = Arc::new(Mutex::new(HashMap::new())); let jitter: Arc<Mutex<HashMap<EndpointId, JitterBuffer>>> =
Arc::new(Mutex::new(HashMap::new()));
// 1. Capture & encoding thread // 1. Capture & encoding thread
let is_muted_clone = is_muted.clone(); let is_muted_clone = is_muted.clone();
@@ -1657,14 +1742,15 @@ async fn run_core_loop(
let is_multitrack_capture = is_multitrack.clone(); let is_multitrack_capture = is_multitrack.clone();
let capture_thread = std::thread::spawn(move || { let capture_thread = std::thread::spawn(move || {
use opus::{Channels, Application}; use opus::{Application, Channels};
let mut encoder = match OpusEncoder::new(48000, Channels::Mono, Application::Voip) { let mut encoder =
Ok(enc) => enc, match OpusEncoder::new(48000, Channels::Mono, Application::Voip) {
Err(e) => { Ok(enc) => enc,
crate::log_msg(&format!("Capture thread error: {:?}", e)); Err(e) => {
return; crate::log_msg(&format!("Capture thread error: {:?}", e));
} return;
}; }
};
// Per-sender packet sequence number, prepended to every frame so // Per-sender packet sequence number, prepended to every frame so
// receivers can reorder and conceal loss. Wraps after ~years. // receivers can reorder and conceal loss. Wraps after ~years.
let mut seq: u32 = 0; let mut seq: u32 = 0;
@@ -1679,7 +1765,10 @@ async fn run_core_loop(
while let Ok(mut pcm) = capture_rx.recv() { while let Ok(mut pcm) = capture_rx.recv() {
// Apply the input gain first so the meter, gate, and what we // Apply the input gain first so the meter, gate, and what we
// transmit all reflect the same (gained) signal. // transmit all reflect the same (gained) signal.
apply_volume(&mut pcm, f32::from_bits(input_gain_clone.load(Ordering::Relaxed))); apply_volume(
&mut pcm,
f32::from_bits(input_gain_clone.load(Ordering::Relaxed)),
);
if let Some(peak) = mic_meter.push(&pcm) { if let Some(peak) = mic_meter.push(&pcm) {
let _ = ui_tx_capture.try_send(UiEvent::MicLevel(peak)); let _ = ui_tx_capture.try_send(UiEvent::MicLevel(peak));
@@ -1688,7 +1777,9 @@ async fn run_core_loop(
if is_muted_clone.load(Ordering::Relaxed) { if is_muted_clone.load(Ordering::Relaxed) {
continue; continue;
} }
if ptt_mode_clone.load(Ordering::Relaxed) && !ptt_active_clone.load(Ordering::Relaxed) { if ptt_mode_clone.load(Ordering::Relaxed)
&& !ptt_active_clone.load(Ordering::Relaxed)
{
continue; continue;
} }
@@ -1757,7 +1848,10 @@ async fn run_core_loop(
match JitterBuffer::new() { match JitterBuffer::new() {
Ok(jb) => entry.insert(jb), Ok(jb) => entry.insert(jb),
Err(e) => { Err(e) => {
crate::log_msg(&format!("Failed to init jitter buffer for {:?}: {:?}", from_peer, e)); crate::log_msg(&format!(
"Failed to init jitter buffer for {:?}: {:?}",
from_peer, e
));
continue; continue;
} }
} }
@@ -1819,7 +1913,9 @@ async fn run_core_loop(
loop { loop {
// Pace to the hardware clock: only produce while the ring // Pace to the hardware clock: only produce while the ring
// is draining below target. Otherwise yield and re-check. // is draining below target. Otherwise yield and re-check.
if ring_fill_mixer.load(Ordering::Relaxed) >= crate::audio::PLAYBACK_TARGET_SAMPLES { if ring_fill_mixer.load(Ordering::Relaxed)
>= crate::audio::PLAYBACK_TARGET_SAMPLES
{
tokio::time::sleep(IDLE_NAP).await; tokio::time::sleep(IDLE_NAP).await;
continue; continue;
} }
@@ -1915,9 +2011,11 @@ async fn run_core_loop(
peer_frames.push((frame, pan)); peer_frames.push((frame, pan));
} }
} }
peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id)); peer_eqs
peer_noise_gates .retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id));
.retain(|id, _| peers_seen.contains(id) || current_gates.contains_key(id)); peer_noise_gates.retain(|id, _| {
peers_seen.contains(id) || current_gates.contains_key(id)
});
// Lossless i32 sum, then the limiter applies the master // Lossless i32 sum, then the limiter applies the master
// output gain (in f32, so a boost past the ceiling is // output gain (in f32, so a boost past the ceiling is
@@ -1954,7 +2052,8 @@ async fn run_core_loop(
&multitrack_mixer, &multitrack_mixer,
&is_multitrack_mixer, &is_multitrack_mixer,
&ui_tx_mixer, &ui_tx_mixer,
).await; )
.await;
let _ = ui_tx_mixer let _ = ui_tx_mixer
.send(UiEvent::Error(format!( .send(UiEvent::Error(format!(
"Recording stopped — write failed: {e}" "Recording stopped — write failed: {e}"
@@ -1977,7 +2076,8 @@ async fn run_core_loop(
&multitrack_mixer, &multitrack_mixer,
&is_multitrack_mixer, &is_multitrack_mixer,
&ui_tx_mixer, &ui_tx_mixer,
).await; )
.await;
let _ = ui_tx_mixer let _ = ui_tx_mixer
.send(UiEvent::Error(format!( .send(UiEvent::Error(format!(
"Recording stopped — write failed: {e}" "Recording stopped — write failed: {e}"
@@ -2013,7 +2113,9 @@ async fn run_core_loop(
if was_in_room { if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await; let _ = ui_tx.send(UiEvent::RoomReset).await;
} }
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe events: {}", e))).await; let _ = ui_tx
.send(UiEvent::Error(format!("Failed to subscribe events: {}", e)))
.await;
continue; continue;
} }
}; };
@@ -2130,9 +2232,13 @@ async fn run_core_loop(
&& let Some(mt) = multitrack_events.lock().unwrap().as_mut() && let Some(mt) = multitrack_events.lock().unwrap().as_mut()
&& let Err(e) = mt.add_peer(peer_id, &state.name) && let Err(e) = mt.add_peer(peer_id, &state.name)
{ {
crate::log_msg(&format!("multitrack add_peer (join) failed: {e}")); crate::log_msg(&format!(
"multitrack add_peer (join) failed: {e}"
));
} }
let _ = ui_tx_events.send(UiEvent::PeerJoined { id: peer_id, state }).await; let _ = ui_tx_events
.send(UiEvent::PeerJoined { id: peer_id, state })
.await;
} }
RoomEvent::PeerLeft(peer_id) => { RoomEvent::PeerLeft(peer_id) => {
// Graceful leave — evict immediately. // Graceful leave — evict immediately.
@@ -2189,9 +2295,17 @@ async fn run_core_loop(
)); ));
} }
} }
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await; let _ = ui_tx_events
.send(UiEvent::PeerUpdated { id: peer_id, state })
.await;
} }
RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => { RoomEvent::ChatMessage {
from,
name,
text,
ts: _,
attachment,
} => {
// Auto-fetch image attachments so they render inline // Auto-fetch image attachments so they render inline
// without a click; non-image files wait for an explicit // without a click; non-image files wait for an explicit
// FetchAttachment (the "Save" chip). The descriptor was // FetchAttachment (the "Save" chip). The descriptor was
@@ -2208,7 +2322,11 @@ async fn run_core_loop(
let key = (from, att.id); let key = (from, att.id);
let already_inflight = let already_inflight =
inflight_attachments.lock().unwrap().contains(&key); inflight_attachments.lock().unwrap().contains(&key);
if should_auto_fetch(is_image, roster.contains(&from), already_inflight) { if should_auto_fetch(
is_image,
roster.contains(&from),
already_inflight,
) {
// Reserve the dedup slot, then a permit. If the // Reserve the dedup slot, then a permit. If the
// pool is exhausted, drop the auto-fetch (and the // pool is exhausted, drop the auto-fetch (and the
// dedup marker) — the descriptor still shows and // dedup marker) — the descriptor still shows and
@@ -2238,12 +2356,14 @@ async fn run_core_loop(
} }
} }
} }
let _ = ui_tx_events.send(UiEvent::ChatMessage { let _ = ui_tx_events
from: from.to_string(), .send(UiEvent::ChatMessage {
name, from: from.to_string(),
text, name,
attachment, text,
}).await; attachment,
})
.await;
} }
RoomEvent::ClockSkewSuspected { author, skew_ms } => { RoomEvent::ClockSkewSuspected { author, skew_ms } => {
crate::log_msg(&format!( crate::log_msg(&format!(
@@ -2267,7 +2387,9 @@ async fn run_core_loop(
// rejoin (PeerJoined/PeerUpdated) or a transport // rejoin (PeerJoined/PeerUpdated) or a transport
// reconnect (ConnEvent::Connected) cancels it first. // reconnect (ConnEvent::Connected) cancels it first.
transport_events.keep_audio_sender_for_reconnect_grace(peer_id); transport_events.keep_audio_sender_for_reconnect_grace(peer_id);
let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await; let _ = ui_tx_events
.send(UiEvent::PeerConnecting { id: peer_id })
.await;
arm_grace_timer( arm_grace_timer(
&grace_timers_events, &grace_timers_events,
&seen_connected_events, &seen_connected_events,
@@ -2293,7 +2415,12 @@ async fn run_core_loop(
if was_in_room { if was_in_room {
let _ = ui_tx.send(UiEvent::RoomReset).await; let _ = ui_tx.send(UiEvent::RoomReset).await;
} }
let _ = ui_tx.send(UiEvent::Error(format!("Failed to subscribe conn events: {}", e))).await; let _ = ui_tx
.send(UiEvent::Error(format!(
"Failed to subscribe conn events: {}",
e
)))
.await;
continue; continue;
} }
}; };
@@ -2350,13 +2477,25 @@ async fn run_core_loop(
name: room_label, name: room_label,
ticket: share_ticket.clone(), ticket: share_ticket.clone(),
}); });
let _ = ui_tx.send(UiEvent::RoomJoined { ticket: share_ticket, self_id }).await; let _ = ui_tx
.send(UiEvent::RoomJoined {
ticket: share_ticket,
self_id,
})
.await;
active_session = Some(session); active_session = Some(session);
} }
CoreCommand::Leave => { CoreCommand::Leave => {
// Finalize any recording first, while the audio feeders are alive. // Finalize any recording first, while the audio feeders are alive.
stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; stop_recording(
&recorder,
&is_recording,
&multitrack,
&is_multitrack,
&ui_tx,
)
.await;
current_sharing = None; current_sharing = None;
if let Some(session) = active_session.take() { if let Some(session) = active_session.take() {
session.shutdown(audio_backend.clone()).await; session.shutdown(audio_backend.clone()).await;
@@ -2373,7 +2512,14 @@ async fn run_core_loop(
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery(); let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; net = build_net_stack(
secret_key.clone(),
network_mode,
lookup,
friends_handler.clone(),
publish,
)
.await?;
net_rebuild_pending = false; net_rebuild_pending = false;
} }
} }
@@ -2384,11 +2530,8 @@ async fn run_core_loop(
is_muted.store(new_state, Ordering::Relaxed); is_muted.store(new_state, Ordering::Relaxed);
if let Some(session) = &active_session { if let Some(session) = &active_session {
let self_state = presence.to_state( let self_state =
new_state, presence.to_state(new_state, net.endpoint.addr(), current_sharing.clone());
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await; let _ = session.room_state.update_self_state(self_state).await;
} }
} }
@@ -2476,7 +2619,10 @@ async fn run_core_loop(
output_gain.store(vol.to_bits(), Ordering::Relaxed); output_gain.store(vol.to_bits(), Ordering::Relaxed);
} }
CoreCommand::SetMicMonitor { enabled, input_device } => { CoreCommand::SetMicMonitor {
enabled,
input_device,
} => {
// During a call the in-call capture thread already reports the // During a call the in-call capture thread already reports the
// mic level, and it owns the backend's capture stream — leave it be. // mic level, and it owns the backend's capture stream — leave it be.
if active_session.is_some() { if active_session.is_some() {
@@ -2489,7 +2635,8 @@ async fn run_core_loop(
Ok(()) => { Ok(()) => {
let ui = ui_tx.clone(); let ui = ui_tx.clone();
let gain = input_gain.clone(); let gain = input_gain.clone();
let thread = std::thread::spawn(move || run_mic_monitor(rx, ui, gain)); let thread =
std::thread::spawn(move || run_mic_monitor(rx, ui, gain));
mic_monitor = Some(MicMonitor { thread }); mic_monitor = Some(MicMonitor { thread });
} }
Err(e) => { Err(e) => {
@@ -2521,7 +2668,14 @@ async fn run_core_loop(
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery(); let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; net = build_net_stack(
secret_key.clone(),
network_mode,
lookup,
friends_handler.clone(),
publish,
)
.await?;
} else { } else {
net_rebuild_pending = true; net_rebuild_pending = true;
} }
@@ -2555,7 +2709,14 @@ async fn run_core_loop(
let lookup = net.memory_lookup.clone(); let lookup = net.memory_lookup.clone();
net.shutdown().await; net.shutdown().await;
let publish = presence_mode.lock().unwrap().publishes_to_discovery(); let publish = presence_mode.lock().unwrap().publishes_to_discovery();
net = build_net_stack(secret_key.clone(), network_mode, lookup, friends_handler.clone(), publish).await?; net = build_net_stack(
secret_key.clone(),
network_mode,
lookup,
friends_handler.clone(),
publish,
)
.await?;
} else { } else {
net_rebuild_pending = true; net_rebuild_pending = true;
} }
@@ -2718,7 +2879,9 @@ async fn run_core_loop(
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs()) .map(|d| d.as_secs())
.unwrap_or(0); .unwrap_or(0);
let result: Result<String, String> = if recording_mode.is_multitrack() { let result: Result<String, String> = if recording_mode
.is_multitrack()
{
// Multitrack/Both: a per-session directory of stems. // Multitrack/Both: a per-session directory of stems.
std::fs::create_dir_all(&base) std::fs::create_dir_all(&base)
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -2740,7 +2903,9 @@ async fn run_core_loop(
if let Some(session) = &active_session { if let Some(session) = &active_session {
for (id, st) in session.room_state.active_peers() { for (id, st) in session.room_state.active_peers() {
if let Err(e) = mt.add_peer(id, &st.name) { if let Err(e) = mt.add_peer(id, &st.name) {
crate::log_msg(&format!("multitrack add_peer failed: {e}")); crate::log_msg(&format!(
"multitrack add_peer failed: {e}"
));
} }
} }
} }
@@ -2766,10 +2931,11 @@ async fn run_core_loop(
match result { match result {
Ok(path) => { Ok(path) => {
is_recording.store(true, Ordering::Relaxed); is_recording.store(true, Ordering::Relaxed);
crate::log_msg(&format!("Recording started ({recording_mode:?}): {path}")); crate::log_msg(&format!(
let _ = ui_tx "Recording started ({recording_mode:?}): {path}"
.send(UiEvent::RecordingStarted { path }) ));
.await; let _ =
ui_tx.send(UiEvent::RecordingStarted { path }).await;
} }
Err(e) => { Err(e) => {
let _ = ui_tx let _ = ui_tx
@@ -2786,7 +2952,14 @@ async fn run_core_loop(
} }
} }
} else { } else {
stop_recording(&recorder, &is_recording, &multitrack, &is_multitrack, &ui_tx).await; stop_recording(
&recorder,
&is_recording,
&multitrack,
&is_multitrack,
&ui_tx,
)
.await;
} }
} }
@@ -2798,7 +2971,11 @@ async fn run_core_loop(
} }
} }
CoreCommand::SendChatFile { text, attachment, data } => { CoreCommand::SendChatFile {
text,
attachment,
data,
} => {
if let Some(session) = &active_session { if let Some(session) = &active_session {
// Make the bytes fetchable by room members, then broadcast the // Make the bytes fetchable by room members, then broadcast the
// descriptor alongside the (possibly empty) caption text. // descriptor alongside the (possibly empty) caption text.
@@ -2853,25 +3030,13 @@ async fn run_core_loop(
CoreCommand::FetchMusic { from, id, size } => { CoreCommand::FetchMusic { from, id, size } => {
if let Some(session) = &active_session { if let Some(session) = &active_session {
spawn_music_fetch( spawn_music_fetch(session.transport.clone(), ui_tx.clone(), from, id, size);
session.transport.clone(),
ui_tx.clone(),
from,
id,
size,
);
} }
} }
CoreCommand::PrefetchMusic { from, id, size } => { CoreCommand::PrefetchMusic { from, id, size } => {
if let Some(session) = &active_session { if let Some(session) = &active_session {
spawn_music_prefetch( spawn_music_prefetch(session.transport.clone(), ui_tx.clone(), from, id, size);
session.transport.clone(),
ui_tx.clone(),
from,
id,
size,
);
} }
} }
@@ -2897,14 +3062,19 @@ async fn run_core_loop(
Vec::new() Vec::new()
}; };
let _ = ui_tx let _ = ui_tx
.send(UiEvent::AudioAppsListed { apps, app_audio_supported }) .send(UiEvent::AudioAppsListed {
apps,
app_audio_supported,
})
.await; .await;
} }
CoreCommand::StartScreenShare { audio_app } => { CoreCommand::StartScreenShare { audio_app } => {
let Some(session) = &mut active_session else { let Some(session) = &mut active_session else {
let _ = ui_tx let _ = ui_tx
.send(UiEvent::Error("Join a call before sharing your screen".into())) .send(UiEvent::Error(
"Join a call before sharing your screen".into(),
))
.await; .await;
continue; continue;
}; };
@@ -2927,8 +3097,9 @@ async fn run_core_loop(
// audio drops. The channel closes when the host dies (drain hits // audio drops. The channel closes when the host dies (drain hits
// EOF), ending the forwarder task on its own. // EOF), ending the forwarder task on its own.
let notices = audio_app.as_deref().map(|_| { let notices = audio_app.as_deref().map(|_| {
let (tx, mut rx) = let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::PixelpassEvent>(); crate::screenshare::PixelpassEvent,
>();
let ui_tx_notices = ui_tx.clone(); let ui_tx_notices = ui_tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Some(ev) = rx.recv().await { while let Some(ev) = rx.recv().await {
@@ -3021,12 +3192,12 @@ async fn run_core_loop(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, frame_level, KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
coalesce_insert, coalesce_pop, mix_frames, mix_stereo_frames, next_game_change, PeerSpeakTicket, admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok,
should_auto_fetch, stereo_to_mono, KnownPeers, MicLevelMeter, PeerSpeakTicket, coalesce_insert, coalesce_pop, frame_level, mix_frames, mix_stereo_frames,
MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, next_game_change, should_auto_fetch, stereo_to_mono,
}; };
use crate::core::messages::{coalesce_key, CoalesceKey, CoreCommand}; use crate::core::messages::{CoalesceKey, CoreCommand, coalesce_key};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
fn endpoint_id() -> iroh::EndpointId { fn endpoint_id() -> iroh::EndpointId {
@@ -3037,14 +3208,30 @@ mod tests {
fn admit_retained_rejects_only_new_ids_at_the_cap() { fn admit_retained_rejects_only_new_ids_at_the_cap() {
// Below the cap, a brand-new identity is retained. // Below the cap, a brand-new identity is retained.
assert!(admit_retained(0, true, MAX_RETAINED_PEERS)); assert!(admit_retained(0, true, MAX_RETAINED_PEERS));
assert!(admit_retained(MAX_RETAINED_PEERS - 1, true, MAX_RETAINED_PEERS)); assert!(admit_retained(
MAX_RETAINED_PEERS - 1,
true,
MAX_RETAINED_PEERS
));
// At the cap, a brand-new identity is refused — this is the bound that stops // At the cap, a brand-new identity is refused — this is the bound that stops
// an insider grace-cycling distinct identities from growing the retain table. // an insider grace-cycling distinct identities from growing the retain table.
assert!(!admit_retained(MAX_RETAINED_PEERS, true, MAX_RETAINED_PEERS)); assert!(!admit_retained(
MAX_RETAINED_PEERS,
true,
MAX_RETAINED_PEERS
));
// A peer already tracked always refreshes, even at (or past) the cap: it only // A peer already tracked always refreshes, even at (or past) the cap: it only
// updates an existing address and never adds a slot. // updates an existing address and never adds a slot.
assert!(admit_retained(MAX_RETAINED_PEERS, false, MAX_RETAINED_PEERS)); assert!(admit_retained(
assert!(admit_retained(MAX_RETAINED_PEERS + 5, false, MAX_RETAINED_PEERS)); MAX_RETAINED_PEERS,
false,
MAX_RETAINED_PEERS
));
assert!(admit_retained(
MAX_RETAINED_PEERS + 5,
false,
MAX_RETAINED_PEERS
));
} }
#[test] #[test]
@@ -3176,12 +3363,14 @@ mod tests {
host_addr: iroh::EndpointAddr::from(original_host), host_addr: iroh::EndpointAddr::from(original_host),
topic_id, topic_id,
name: "Room".to_string(), name: "Room".to_string(),
}.to_string(); }
.to_string();
let restamped = PeerSpeakTicket { let restamped = PeerSpeakTicket {
host_addr: iroh::EndpointAddr::from(member_host), host_addr: iroh::EndpointAddr::from(member_host),
topic_id, topic_id,
name: "Room".to_string(), name: "Room".to_string(),
}.to_string(); }
.to_string();
assert_ne!(original, restamped); assert_ne!(original, restamped);
let original_topic = PeerSpeakTicket::topic_of(&original).unwrap(); let original_topic = PeerSpeakTicket::topic_of(&original).unwrap();
@@ -3219,7 +3408,10 @@ mod tests {
drop(tx); drop(tx);
assert_eq!(next_game_change(&mut rx).await, None); assert_eq!(next_game_change(&mut rx).await, None);
assert!(rx.is_none(), "closed receiver must disable its select source"); assert!(
rx.is_none(),
"closed receiver must disable its select source"
);
} }
#[test] #[test]
@@ -3250,7 +3442,10 @@ mod tests {
assert_eq!(m.push(&frame(100, chunk)), None); assert_eq!(m.push(&frame(100, chunk)), None);
let reported = m.push(&frame(100, chunk)).expect("window complete"); let reported = m.push(&frame(100, chunk)).expect("window complete");
let loud = frame_level(&frame(8000, chunk)); let loud = frame_level(&frame(8000, chunk));
assert!((reported - loud).abs() < 1e-6, "peak should hold the loud frame"); assert!(
(reported - loud).abs() < 1e-6,
"peak should hold the loud frame"
);
} }
#[test] #[test]
@@ -3259,14 +3454,18 @@ mod tests {
// Fill and report a loud window. // Fill and report a loud window.
assert!(m.push(&frame(8000, MIC_LEVEL_REPORT_SAMPLES)).is_some()); assert!(m.push(&frame(8000, MIC_LEVEL_REPORT_SAMPLES)).is_some());
// The next window of silence must report ~zero, not the stale loud peak. // The next window of silence must report ~zero, not the stale loud peak.
let reported = m.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)).expect("second window"); let reported = m
.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES))
.expect("second window");
assert_eq!(reported, 0.0, "peak and accumulator reset between windows"); assert_eq!(reported, 0.0, "peak and accumulator reset between windows");
} }
#[test] #[test]
fn mic_meter_silence_reports_zero() { fn mic_meter_silence_reports_zero() {
let mut m = MicLevelMeter::new(); let mut m = MicLevelMeter::new();
let reported = m.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES)).expect("window complete"); let reported = m
.push(&frame(0, MIC_LEVEL_REPORT_SAMPLES))
.expect("window complete");
assert_eq!(reported, 0.0); assert_eq!(reported, 0.0);
} }
+29 -7
View File
@@ -283,8 +283,14 @@ mod tests {
let q = echo.len() / 4; let q = echo.len() / 4;
let early = erle(&echo[..q], &cleaned[..q]); let early = erle(&echo[..q], &cleaned[..q]);
let late = erle(&echo[3 * q..], &cleaned[3 * q..]); let late = erle(&echo[3 * q..], &cleaned[3 * q..]);
assert!(late > early + 10.0, "should improve markedly: early {early:.1} late {late:.1}"); assert!(
assert!(late > 20.0, "converged ERLE should exceed 20 dB, got {late:.1}"); late > early + 10.0,
"should improve markedly: early {early:.1} late {late:.1}"
);
assert!(
late > 20.0,
"converged ERLE should exceed 20 dB, got {late:.1}"
);
} }
#[test] #[test]
@@ -307,7 +313,10 @@ mod tests {
let mut aec = Nlms::new(128, 0.5, 1e-6); let mut aec = Nlms::new(128, 0.5, 1e-6);
let out = aec.process(&silent_ref, &near); let out = aec.process(&silent_ref, &near);
for (a, b) in near.iter().zip(&out) { for (a, b) in near.iter().zip(&out) {
assert!((a - b).abs() < 1e-6, "near-end should pass through: {a} vs {b}"); assert!(
(a - b).abs() < 1e-6,
"near-end should pass through: {a} vs {b}"
);
} }
} }
@@ -341,14 +350,24 @@ mod tests {
let mut late_hits = 0; let mut late_hits = 0;
for i in 0..far.len() { for i in 0..far.len() {
if dtd.update(far[i], mic[i]) { if dtd.update(far[i], mic[i]) {
if i < onset { early_hits += 1 } else { late_hits += 1 } if i < onset {
early_hits += 1
} else {
late_hits += 1
}
} }
} }
// Echo-only stretch should rarely trip; near-end stretch should trip a lot. // Echo-only stretch should rarely trip; near-end stretch should trip a lot.
let early_rate = early_hits as f32 / onset as f32; let early_rate = early_hits as f32 / onset as f32;
let late_rate = late_hits as f32 / (far.len() - onset) as f32; let late_rate = late_hits as f32 / (far.len() - onset) as f32;
assert!(early_rate < 0.10, "false-positive rate {early_rate:.2} too high"); assert!(
assert!(late_rate > 0.50, "missed double-talk, rate only {late_rate:.2}"); early_rate < 0.10,
"false-positive rate {early_rate:.2} too high"
);
assert!(
late_rate > 0.50,
"missed double-talk, rate only {late_rate:.2}"
);
} }
#[test] #[test]
@@ -380,6 +399,9 @@ mod tests {
erle_dtd > erle_no + 15.0, erle_dtd > erle_no + 15.0,
"DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB" "DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB"
); );
assert!(erle_dtd > 15.0, "held filter should still cancel echo: {erle_dtd:.1} dB"); assert!(
erle_dtd > 15.0,
"held filter should still cancel echo: {erle_dtd:.1} dB"
);
} }
} }
+4 -1
View File
@@ -115,7 +115,10 @@ mod tests {
let path = EchoPath::synthetic(480, 480, 0.5, 99); let path = EchoPath::synthetic(480, 480, 0.5, 99);
let echo = path.apply(&far); let echo = path.apply(&far);
let ratio = rms(&echo) / rms(&far); let ratio = rms(&echo) / rms(&far);
assert!((0.3..0.7).contains(&ratio), "echo/far rms ratio {ratio} off target"); assert!(
(0.3..0.7).contains(&ratio),
"echo/far rms ratio {ratio} off target"
);
} }
#[test] #[test]
+4 -1
View File
@@ -141,7 +141,10 @@ mod tests {
buf[0] = Complex::new(1.0, 0.0); buf[0] = Complex::new(1.0, 0.0);
fft(&mut buf); fft(&mut buf);
for c in &buf { for c in &buf {
assert!(approx(c.magnitude(), 1.0, 1e-9), "expected flat 1.0, got {c:?}"); assert!(
approx(c.magnitude(), 1.0, 1e-9),
"expected flat 1.0, got {c:?}"
);
} }
} }
+25 -5
View File
@@ -62,11 +62,31 @@ pub struct Band {
/// Voice-relevant bands for spotting *where* residual echo or noise lives. /// Voice-relevant bands for spotting *where* residual echo or noise lives.
pub const VOICE_BANDS: &[Band] = &[ pub const VOICE_BANDS: &[Band] = &[
Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0 }, Band {
Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0 }, label: "low (80-300)",
Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0 }, low_hz: 80.0,
Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0 }, high_hz: 300.0,
Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.0 }, },
Band {
label: "low-mid (300-1k)",
low_hz: 300.0,
high_hz: 1000.0,
},
Band {
label: "mid (1k-3k)",
low_hz: 1000.0,
high_hz: 3000.0,
},
Band {
label: "high-mid (3k-6k)",
low_hz: 3000.0,
high_hz: 6000.0,
},
Band {
label: "high (6k-12k)",
low_hz: 6000.0,
high_hz: 12000.0,
},
]; ];
/// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single /// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single
+7 -2
View File
@@ -202,7 +202,8 @@ fn legend(opts: &RenderOpts) -> String {
if opts.ascii { if opts.ascii {
for i in 0..steps { for i in 0..steps {
let v = i as f32 / (steps - 1) as f32; let v = i as f32 / (steps - 1) as f32;
let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1); let idx =
((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
s.push(ASCII_RAMP[idx] as char); s.push(ASCII_RAMP[idx] as char);
} }
} else { } else {
@@ -243,7 +244,11 @@ mod tests {
fn render_produces_grid_of_expected_height() { fn render_produces_grid_of_expected_height() {
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000); let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
let spec = stft::analyze(&sig, 48_000, 1024, 512); let spec = stft::analyze(&sig, 48_000, 1024, 512);
let opts = RenderOpts { width: 40, height: 10, ..Default::default() }; let opts = RenderOpts {
width: 40,
height: 10,
..Default::default()
};
let out = render(&spec, &opts); let out = render(&spec, &opts);
// Header + 10 body rows + time axis (2) + legend = non-trivial. // Header + 10 body rows + time axis (2) + legend = non-trivial.
let lines = out.lines().count(); let lines = out.lines().count();
+4 -1
View File
@@ -94,7 +94,10 @@ mod tests {
.unwrap() .unwrap()
.0; .0;
let peak_hz = s.bin_hz(peak_bin); let peak_hz = s.bin_hz(peak_bin);
assert!((peak_hz - freq as f32).abs() < 100.0, "peak at {peak_hz} Hz, want {freq}"); assert!(
(peak_hz - freq as f32).abs() < 100.0,
"peak at {peak_hz} Hz, want {freq}"
);
} }
#[test] #[test]
+13 -3
View File
@@ -34,7 +34,12 @@ pub fn read(path: &Path) -> Result<WavData, String> {
let mut pos = 12usize; let mut pos = 12usize;
while pos + 8 <= bytes.len() { while pos + 8 <= bytes.len() {
let id = &bytes[pos..pos + 4]; let id = &bytes[pos..pos + 4];
let size = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]]) as usize; let size = u32::from_le_bytes([
bytes[pos + 4],
bytes[pos + 5],
bytes[pos + 6],
bytes[pos + 7],
]) as usize;
let body_start = pos + 8; let body_start = pos + 8;
let body_end = (body_start + size).min(bytes.len()); let body_end = (body_start + size).min(bytes.len());
match id { match id {
@@ -45,7 +50,9 @@ pub fn read(path: &Path) -> Result<WavData, String> {
sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]); sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
bits = u16::from_le_bytes([fmt[14], fmt[15]]); bits = u16::from_le_bytes([fmt[14], fmt[15]]);
if audio_format != 1 { if audio_format != 1 {
return Err(format!("unsupported WAV format tag {audio_format} (need PCM=1)")); return Err(format!(
"unsupported WAV format tag {audio_format} (need PCM=1)"
));
} }
} }
b"data" => { b"data" => {
@@ -75,7 +82,10 @@ pub fn read(path: &Path) -> Result<WavData, String> {
samples.push(avg / 32768.0); samples.push(avg / 32768.0);
} }
Ok(WavData { samples, sample_rate }) Ok(WavData {
samples,
sample_rate,
})
} }
/// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by /// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by
+13 -7
View File
@@ -76,10 +76,7 @@ pub fn sanitize_filename(raw: &str) -> String {
.trim(); .trim();
// Drop control chars; turn other whitespace into single spaces later. // Drop control chars; turn other whitespace into single spaces later.
let cleaned: String = base let cleaned: String = base.chars().filter(|c| !c.is_control()).collect();
.chars()
.filter(|c| !c.is_control())
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" "); let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let collapsed = collapsed.trim_matches('.').trim(); let collapsed = collapsed.trim_matches('.').trim();
@@ -145,7 +142,10 @@ pub fn looks_like_audio_name(name: &str) -> bool {
let Some((_, extension)) = name.rsplit_once('.') else { let Some((_, extension)) = name.rsplit_once('.') else {
return false; return false;
}; };
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac") matches!(
extension.to_ascii_lowercase().as_str(),
"wav" | "mp3" | "ogg" | "oga" | "flac"
)
} }
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it /// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
@@ -240,7 +240,11 @@ mod tests {
let long_stem = "x".repeat(200); let long_stem = "x".repeat(200);
let name = format!("{long_stem}.png"); let name = format!("{long_stem}.png");
let out = sanitize_filename(&name); let out = sanitize_filename(&name);
assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count()); assert!(
out.chars().count() <= MAX_FILENAME_LEN,
"len was {}",
out.chars().count()
);
assert!(out.ends_with(".png"), "extension preserved: {out}"); assert!(out.ends_with(".png"), "extension preserved: {out}");
} }
@@ -254,7 +258,9 @@ mod tests {
#[test] #[test]
fn image_sniffing_recognizes_containers() { fn image_sniffing_recognizes_containers() {
assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0])); assert!(is_probably_image(&[
0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0
]));
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0])); assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
assert!(is_probably_image(b"GIF89a....")); assert!(is_probably_image(b"GIF89a...."));
let mut webp = b"RIFF".to_vec(); let mut webp = b"RIFF".to_vec();
+22 -8
View File
@@ -66,7 +66,11 @@ impl FriendStore {
if self.contains(&id) { if self.contains(&id) {
return false; return false;
} }
self.friends.push(Friend { id, name, last_addr: addr }); self.friends.push(Friend {
id,
name,
last_addr: addr,
});
true true
} }
@@ -118,21 +122,24 @@ pub fn friends_path() -> Option<PathBuf> {
/// *parse* error bubbles up so a hand-edit being debugged isn't silently /// *parse* error bubbles up so a hand-edit being debugged isn't silently
/// overwritten with an empty list. /// overwritten with an empty list.
pub fn load() -> Result<FriendStore> { pub fn load() -> Result<FriendStore> {
let path = friends_path().context("could not determine a config directory for the friends list")?; let path =
friends_path().context("could not determine a config directory for the friends list")?;
load_at(&path) load_at(&path)
} }
/// Save the store. Atomic via tempfile-in-same-dir + rename. /// Save the store. Atomic via tempfile-in-same-dir + rename.
pub fn save(store: &FriendStore) -> Result<()> { pub fn save(store: &FriendStore) -> Result<()> {
let path = friends_path().context("could not determine a config directory for the friends list")?; let path =
friends_path().context("could not determine a config directory for the friends list")?;
save_at(&path, store) save_at(&path, store)
} }
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir. /// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
fn load_at(path: &Path) -> Result<FriendStore> { fn load_at(path: &Path) -> Result<FriendStore> {
match fs::read_to_string(path) { match fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s) Ok(s) => {
.with_context(|| format!("failed to parse {}", path.display())), serde_json::from_str(&s).with_context(|| format!("failed to parse {}", path.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())), Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
} }
@@ -141,11 +148,14 @@ fn load_at(path: &Path) -> Result<FriendStore> {
/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then /// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
/// rename, so a crash mid-write can't leave a truncated list. /// rename, so a crash mid-write can't leave a truncated list.
fn save_at(path: &Path, store: &FriendStore) -> Result<()> { fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
let parent = path.parent().context("friends path has no parent directory")?; let parent = path
.parent()
.context("friends path has no parent directory")?;
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?; let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?;
let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id())); let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id()));
fs::write(&tmp, json.as_bytes()).with_context(|| format!("failed to write {}", tmp.display()))?; fs::write(&tmp, json.as_bytes())
.with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, path) fs::rename(&tmp, path)
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; .with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
Ok(()) Ok(())
@@ -219,7 +229,11 @@ mod tests {
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all). /// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
fn temp_path(tag: &str) -> PathBuf { fn temp_path(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir(); let mut p = std::env::temp_dir();
p.push(format!("peerspeak-friendstest-{}-{}", std::process::id(), tag)); p.push(format!(
"peerspeak-friendstest-{}-{}",
std::process::id(),
tag
));
p.push("friends.json"); p.push("friends.json");
p p
} }
+45 -12
View File
@@ -9,11 +9,9 @@
//! is factored into the pure [`poll_once`] so the wiring of resolve + match + //! is factored into the pure [`poll_once`] so the wiring of resolve + match +
//! debounce is unit-tested without any I/O. //! debounce is unit-tested without any I/O.
use super::{
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
};
use super::scan; use super::scan;
use super::steam::SteamProbe; use super::steam::SteamProbe;
use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::io; use std::io;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -145,9 +143,14 @@ fn worker_loop(
let steam_game = steam.detect(); let steam_game = steam.detect();
let processes = scan::running_executables(); let processes = scan::running_executables();
if let Some(new_current) = if let Some(new_current) = poll_once(
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist) &mut debouncer,
{ &override_,
steam_game,
&processes,
&process_map,
&denylist,
) {
// A closed receiver means core shut down; stop quietly. // A closed receiver means core shut down; stop quietly.
if tx.send(new_current).is_err() { if tx.send(new_current).is_err() {
return; return;
@@ -169,11 +172,18 @@ mod tests {
use super::*; use super::*;
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame { fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
DetectedGame { id: id.into(), name: Some(name.into()), source } DetectedGame {
id: id.into(),
name: Some(name.into()),
source,
}
} }
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> { fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
} }
#[test] #[test]
@@ -185,17 +195,38 @@ mod tests {
// First poll: detected but not yet published (needs two hits). // First poll: detected but not yet published (needs two hits).
assert_eq!( assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny), poll_once(
&mut d,
&ManualOverride::Auto,
Some(steam.clone()),
&[],
&empty,
&deny
),
None None
); );
// Second poll: published. // Second poll: published.
assert_eq!( assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny), poll_once(
&mut d,
&ManualOverride::Auto,
Some(steam.clone()),
&[],
&empty,
&deny
),
Some(Some(steam)) Some(Some(steam))
); );
// Third identical poll: no change event. // Third identical poll: no change event.
assert_eq!( assert_eq!(
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny), poll_once(
&mut d,
&ManualOverride::Auto,
Some(game("steam:730", "CS2", GameSource::Steam)),
&[],
&empty,
&deny
),
None None
); );
} }
@@ -209,7 +240,9 @@ mod tests {
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny); poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny); let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
let published = change.expect("should publish on second hit").expect("a game"); let published = change
.expect("should publish on second hit")
.expect("a game");
assert_eq!(published.id, "exe:hl2_linux"); assert_eq!(published.id, "exe:hl2_linux");
assert_eq!(published.name.as_deref(), Some("Half-Life 2")); assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
} }
+48 -17
View File
@@ -92,11 +92,20 @@ pub fn resolve(
processes: &[DetectedGame], processes: &[DetectedGame],
) -> Resolution { ) -> Resolution {
match override_ { match override_ {
ManualOverride::ForceNone => Resolution { game: None, immediate: true }, ManualOverride::ForceNone => Resolution {
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true }, game: None,
immediate: true,
},
ManualOverride::Force(g) => Resolution {
game: Some(g.clone()),
immediate: true,
},
ManualOverride::Auto => { ManualOverride::Auto => {
let game = steam.or_else(|| processes.first().cloned()); let game = steam.or_else(|| processes.first().cloned());
Resolution { game, immediate: false } Resolution {
game,
immediate: false,
}
} }
} }
} }
@@ -192,7 +201,11 @@ impl Debouncer {
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a /// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
/// hypothetical `minecraft`), trims surrounding whitespace. /// hypothetical `minecraft`), trims surrounding whitespace.
pub fn normalize_exe(raw: &str) -> String { pub fn normalize_exe(raw: &str) -> String {
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase() raw.rsplit(['/', '\\'])
.next()
.unwrap_or(raw)
.trim()
.to_lowercase()
} }
/// Launcher/helper executables that must NEVER be reported as a game even if a /// Launcher/helper executables that must NEVER be reported as a game even if a
@@ -245,8 +258,10 @@ pub fn match_processes(
denylist: &BTreeSet<&str>, denylist: &BTreeSet<&str>,
) -> Vec<DetectedGame> { ) -> Vec<DetectedGame> {
// Normalize the user map once so lookups are basename/case-insensitive. // Normalize the user map once so lookups are basename/case-insensitive.
let normalized_map: BTreeMap<String, &String> = let normalized_map: BTreeMap<String, &String> = user_map
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect(); .iter()
.map(|(k, v)| (normalize_exe(k), v))
.collect();
let mut seen: BTreeSet<String> = BTreeSet::new(); let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out: Vec<DetectedGame> = Vec::new(); let mut out: Vec<DetectedGame> = Vec::new();
@@ -288,8 +303,14 @@ mod tests {
#[test] #[test]
fn stable_ids_are_namespaced() { fn stable_ids_are_namespaced() {
assert_eq!(DetectedGame::steam_id(730), "steam:730"); assert_eq!(DetectedGame::steam_id(730), "steam:730");
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux"); assert_eq!(
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe"); DetectedGame::exe_id("/usr/games/hl2_linux"),
"exe:hl2_linux"
);
assert_eq!(
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
"exe:minecraft.exe"
);
} }
#[test] #[test]
@@ -318,8 +339,16 @@ mod tests {
#[test] #[test]
fn resolve_falls_back_to_first_process_then_none() { fn resolve_falls_back_to_first_process_then_none() {
let procs = vec![ let procs = vec![
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process }, DetectedGame {
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process }, id: "exe:a".into(),
name: Some("A".into()),
source: GameSource::Process,
},
DetectedGame {
id: "exe:b".into(),
name: Some("B".into()),
source: GameSource::Process,
},
]; ];
let r = resolve(&ManualOverride::Auto, None, &procs); let r = resolve(&ManualOverride::Auto, None, &procs);
assert_eq!(r.game.as_ref().unwrap().id, "exe:a"); assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
@@ -426,7 +455,10 @@ mod tests {
// --- process matching -------------------------------------------------- // --- process matching --------------------------------------------------
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> { fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
} }
#[test] #[test]
@@ -461,14 +493,13 @@ mod tests {
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]); let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
let deny = builtin_denylist(); let deny = builtin_denylist();
// Same game twice (two processes) + reverse discovery order. // Same game twice (two processes) + reverse discovery order.
let running = vec![ let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
"/b/zed".into(),
"/a/alpha".into(),
"/c/alpha".into(),
];
let got = match_processes(&running, &user, &deny); let got = match_processes(&running, &user, &deny);
// Deduped to two, sorted by id (alpha before zed) regardless of scan order. // Deduped to two, sorted by id (alpha before zed) regardless of scan order.
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]); assert_eq!(
got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
vec!["exe:alpha", "exe:zed"]
);
} }
#[test] #[test]
+12 -4
View File
@@ -62,7 +62,7 @@ fn linux_proc_executables() -> Vec<String> {
fn windows_toolhelp_executables() -> Vec<String> { fn windows_toolhelp_executables() -> Vec<String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{ use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS, TH32CS_SNAPPROCESS,
}; };
@@ -78,7 +78,11 @@ fn windows_toolhelp_executables() -> Vec<String> {
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) }; let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 { while ok != 0 {
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe). // szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len()); let end = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..end]); let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
if !name.is_empty() { if !name.is_empty() {
out.push(name); out.push(name);
@@ -101,7 +105,10 @@ mod tests {
// The test runner itself is a process, so /proc enumeration must be // The test runner itself is a process, so /proc enumeration must be
// non-empty and include something that normalizes to our own exe basename. // non-empty and include something that normalizes to our own exe basename.
let exes = running_executables(); let exes = running_executables();
assert!(!exes.is_empty(), "expected to see running processes via /proc"); assert!(
!exes.is_empty(),
"expected to see running processes via /proc"
);
// Our own /proc/self/exe basename should appear among them. // Our own /proc/self/exe basename should appear among them.
let me = std::fs::read_link("/proc/self/exe") let me = std::fs::read_link("/proc/self/exe")
.ok() .ok()
@@ -109,7 +116,8 @@ mod tests {
if let Some(me) = me { if let Some(me) = me {
let me_norm = super::super::normalize_exe(&me); let me_norm = super::super::normalize_exe(&me);
assert!( assert!(
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm), exes.iter()
.any(|e| super::super::normalize_exe(e) == me_norm),
"running list should include our own executable {me_norm:?}" "running list should include our own executable {me_norm:?}"
); );
} }
+57 -20
View File
@@ -25,8 +25,7 @@ const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
#[cfg(any(windows, test))] #[cfg(any(windows, test))]
fn validate_reg_len(len: u32) -> Option<usize> { fn validate_reg_len(len: u32) -> Option<usize> {
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES) (len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
.then_some(len as usize / 2)
} }
#[cfg(any(windows, test))] #[cfg(any(windows, test))]
@@ -48,7 +47,14 @@ fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> { pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
let root = vdf::parse(registry_vdf).ok()?; let root = vdf::parse(registry_vdf).ok()?;
let raw = root let raw = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) .get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str)?; .and_then(Value::as_str)?;
let id: u32 = raw.trim().parse().ok()?; let id: u32 = raw.trim().parse().ok()?;
(id != 0).then_some(id) (id != 0).then_some(id)
@@ -196,7 +202,13 @@ impl SteamProbe {
return cached.name.clone(); return cached.name.clone();
} }
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c)); let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() }); self.manifests.insert(
app_id,
CachedManifest {
mtime,
name: name.clone(),
},
);
name name
} }
@@ -240,7 +252,11 @@ impl SteamProbe {
paths.push(root.clone()); paths.push(root.clone());
} }
} }
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() }; self.libraries = CachedLibraries {
source,
mtime,
paths: paths.clone(),
};
paths paths
} }
} }
@@ -360,8 +376,8 @@ mod win {
use std::path::PathBuf; use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{ use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ, HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
REG_DWORD, REG_SZ, RegQueryValueExW,
}; };
/// UTF-16, NUL-terminated, for a Win32 wide-string argument. /// UTF-16, NUL-terminated, for a Win32 wide-string argument.
@@ -374,9 +390,8 @@ mod win {
let subkey = wide("Software\\Valve\\Steam"); let subkey = wide("Software\\Valve\\Steam");
let mut hkey: HKEY = std::ptr::null_mut(); let mut hkey: HKEY = std::ptr::null_mut();
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle. // SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
let rc = unsafe { let rc =
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
};
(rc == ERROR_SUCCESS).then_some(hkey) (rc == ERROR_SUCCESS).then_some(hkey)
} }
@@ -463,13 +478,20 @@ mod tests {
#[test] #[test]
fn registry_string_lengths_are_bounded_and_trimmed() { fn registry_string_lengths_are_bounded_and_trimmed() {
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16"); assert_eq!(
validate_reg_len(5),
None,
"odd byte lengths are invalid UTF-16"
);
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None); assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
assert_eq!(validate_reg_len(8), Some(4)); assert_eq!(validate_reg_len(8), Some(4));
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>(); let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32; let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam")); assert_eq!(
decode_reg_sz(raw, returned_bytes).as_deref(),
Some("C:\\Steam")
);
} }
#[test] #[test]
@@ -495,10 +517,13 @@ mod tests {
"contentstatsid" "12345" "contentstatsid" "12345"
}"#; }"#;
let got = parse_library_paths(current); let got = parse_library_paths(current);
assert_eq!(got, vec![ assert_eq!(
PathBuf::from("/home/eric/.local/share/Steam"), got,
PathBuf::from("/mnt/games/SteamLibrary"), vec![
]); PathBuf::from("/home/eric/.local/share/Steam"),
PathBuf::from("/mnt/games/SteamLibrary"),
]
);
// Legacy shape: numeric keys map straight to path strings. // Legacy shape: numeric keys map straight to path strings.
let legacy = r#""LibraryFolders" { let legacy = r#""LibraryFolders" {
@@ -522,13 +547,25 @@ mod tests {
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0"; let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440)); assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored. // Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"),
None
);
// Absent → None (a non-Steam process). // Absent → None (a non-Steam process).
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"),
None
);
// Not fooled by a different var that merely contains the substring. // Not fooled by a different var that merely contains the substring.
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"),
None
);
// Garbage value → None, no panic. // Garbage value → None, no panic.
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None); assert_eq!(
parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
None
);
} }
#[test] #[test]
+33 -7
View File
@@ -234,10 +234,20 @@ mod tests {
} }
"#; "#;
let root = parse(acf).unwrap(); let root = parse(acf).unwrap();
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2")); assert_eq!(
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730")); root.get_path(&["AppState", "name"]).and_then(Value::as_str),
Some("Counter-Strike 2")
);
assert_eq!(
root.get_path(&["AppState", "appid"])
.and_then(Value::as_str),
Some("730")
);
// Case-insensitive key lookup. // Case-insensitive key lookup.
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2")); assert_eq!(
root.get_path(&["appstate", "NAME"]).and_then(Value::as_str),
Some("Counter-Strike 2")
);
} }
#[test] #[test]
@@ -262,8 +272,14 @@ mod tests {
"#; "#;
let root = parse(vdf).unwrap(); let root = parse(vdf).unwrap();
let lf = root.get("libraryfolders").unwrap(); let lf = root.get("libraryfolders").unwrap();
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam")); assert_eq!(
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam")); lf.get_path(&["0", "path"]).and_then(Value::as_str),
Some(r"C:\Program Files (x86)\Steam")
);
assert_eq!(
lf.get_path(&["1", "path"]).and_then(Value::as_str),
Some("/home/eric/.local/share/Steam")
);
// The library folder ids are iterable for discovery. // The library folder ids are iterable for discovery.
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect(); let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ids, vec!["0", "1"]); assert_eq!(ids, vec!["0", "1"]);
@@ -292,7 +308,14 @@ mod tests {
"#; "#;
let root = parse(reg).unwrap(); let root = parse(reg).unwrap();
let appid = root let appid = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"]) .get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str); .and_then(Value::as_str);
assert_eq!(appid, Some("570")); assert_eq!(appid, Some("570"));
} }
@@ -301,7 +324,10 @@ mod tests {
fn handles_comments_and_barewords() { fn handles_comments_and_barewords() {
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n"; let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
let root = parse(vdf).unwrap(); let root = parse(vdf).unwrap();
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue")); assert_eq!(
root.get_path(&["root", "barekey"]).and_then(Value::as_str),
Some("barevalue")
);
} }
#[test] #[test]
+7 -4
View File
@@ -89,9 +89,9 @@ impl HotkeyAction {
pub fn tier(self) -> HotkeyTier { pub fn tier(self) -> HotkeyTier {
match self { match self {
HotkeyAction::ToggleMute HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => {
| HotkeyAction::ToggleDeafen HotkeyTier::AppWide
| HotkeyAction::OpenSettings => HotkeyTier::AppWide, }
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly, HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
} }
} }
@@ -278,7 +278,10 @@ mod tests {
#[test] #[test]
fn parse_single_character_case_folds() { fn parse_single_character_case_folds() {
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string()))); assert_eq!(
parse_binding("M"),
Some(KeyBinding::Character("m".to_string()))
);
assert_eq!(format_binding(parse_binding("m").as_ref()), "M"); assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
} }
} }
+11 -6
View File
@@ -41,7 +41,8 @@ pub fn identity_path() -> Option<PathBuf> {
/// A *missing* file (first ever run, or right after a reset) is the normal /// A *missing* file (first ever run, or right after a reset) is the normal
/// create path. /// create path.
pub fn load_or_create() -> Result<SecretKey> { pub fn load_or_create() -> Result<SecretKey> {
let path = identity_path().context("could not determine a config directory for the identity key")?; let path =
identity_path().context("could not determine a config directory for the identity key")?;
load_or_create_at(&path) load_or_create_at(&path)
} }
@@ -49,7 +50,8 @@ pub fn load_or_create() -> Result<SecretKey> {
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and /// deliberate "Regenerate identity" / unlink action — the old id is discarded and
/// unrecoverable, so callers should confirm with the user first. /// unrecoverable, so callers should confirm with the user first.
pub fn regenerate() -> Result<SecretKey> { pub fn regenerate() -> Result<SecretKey> {
let path = identity_path().context("could not determine a config directory for the identity key")?; let path =
identity_path().context("could not determine a config directory for the identity key")?;
let key = SecretKey::generate(); let key = SecretKey::generate();
save_at(&path, &key)?; save_at(&path, &key)?;
Ok(key) Ok(key)
@@ -57,7 +59,8 @@ pub fn regenerate() -> Result<SecretKey> {
/// Atomic, `0600` write at the default identity path. See [`save_at`]. /// Atomic, `0600` write at the default identity path. See [`save_at`].
pub fn save(key: &SecretKey) -> Result<()> { pub fn save(key: &SecretKey) -> Result<()> {
let path = identity_path().context("could not determine a config directory for the identity key")?; let path =
identity_path().context("could not determine a config directory for the identity key")?;
save_at(&path, key) save_at(&path, key)
} }
@@ -80,13 +83,15 @@ fn load_or_create_at(path: &std::path::Path) -> Result<SecretKey> {
/// perms are applied before the rename so the secret is never briefly /// perms are applied before the rename so the secret is never briefly
/// world-readable. /// world-readable.
fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> { fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> {
let parent = path.parent().context("identity path has no parent directory")?; let parent = path
.parent()
.context("identity path has no parent directory")?;
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id())); let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
{ {
let mut f = let mut f = fs::File::create(&tmp)
fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?; .with_context(|| format!("failed to create {}", tmp.display()))?;
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
+28 -21
View File
@@ -1,27 +1,27 @@
pub mod audio;
pub mod codec;
pub mod dsp;
pub mod network;
pub mod protocol;
pub mod core;
pub mod app; pub mod app;
pub mod config; pub mod audio;
pub mod identity;
pub mod friends;
pub mod presence;
pub mod presence_net;
pub mod theme;
pub mod notify;
pub mod screenshare;
pub mod sanitize;
pub mod avatar; pub mod avatar;
pub mod background; pub mod background;
pub mod recents; pub mod codec;
pub mod config;
pub mod core;
pub mod discovery; pub mod discovery;
pub mod hotkeys; pub mod dsp;
pub mod files; pub mod files;
pub mod playlist; pub mod friends;
pub mod game; pub mod game;
pub mod hotkeys;
pub mod identity;
pub mod network;
pub mod notify;
pub mod playlist;
pub mod presence;
pub mod presence_net;
pub mod protocol;
pub mod recents;
pub mod sanitize;
pub mod screenshare;
pub mod theme;
pub mod widget; pub mod widget;
use std::fs::File; use std::fs::File;
@@ -75,7 +75,8 @@ pub fn redact_for_log(value: &str) -> String {
} }
pub fn short_bytes_hex(bytes: &[u8]) -> String { pub fn short_bytes_hex(bytes: &[u8]) -> String {
bytes.iter() bytes
.iter()
.take(6) .take(6)
.map(|b| format!("{b:02x}")) .map(|b| format!("{b:02x}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -83,7 +84,10 @@ pub fn short_bytes_hex(bytes: &[u8]) -> String {
} }
fn rotated_log_path(path: &Path) -> PathBuf { fn rotated_log_path(path: &Path) -> PathBuf {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log"); let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("peerspeak.log");
path.with_file_name(format!("{file_name}.1")) path.with_file_name(format!("{file_name}.1"))
} }
@@ -100,7 +104,10 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
let rotated = rotated_log_path(path); let rotated = rotated_log_path(path);
let _ = std::fs::remove_file(&rotated); let _ = std::fs::remove_file(&rotated);
if std::fs::rename(path, &rotated).is_err() { if std::fs::rename(path, &rotated).is_err() {
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path); let _ = std::fs::OpenOptions::new()
.write(true)
.truncate(true)
.open(path);
} }
} }
+274 -95
View File
@@ -1,14 +1,14 @@
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket}; use crate::network::{NetError, PeerSpeakTicket, PeerState, RoomEvent, RoomState};
use async_trait::async_trait;
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr}; use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
use iroh_gossip::net::Gossip; use iroh_gossip::net::Gossip;
use iroh_gossip::proto::TopicId; use iroh_gossip::proto::TopicId;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::collections::{BTreeSet, HashMap, HashSet};
use async_trait::async_trait;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use serde::{Serialize, Deserialize};
/// Domain-separation tag mixed into every signed gossip payload so a signature /// Domain-separation tag mixed into every signed gossip payload so a signature
/// can never be lifted out of this protocol/version into another context. /// can never be lifted out of this protocol/version into another context.
@@ -78,7 +78,12 @@ fn signable_bytes(topic: &[u8; 32], author: &EndpointId, ts: u64, msg: &GossipMe
fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload { fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload {
let author = secret.public(); let author = secret.public();
let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg)); let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg));
GossipPayload { author, ts, msg, sig } GossipPayload {
author,
ts,
msg,
sig,
}
} }
/// Why a received gossip payload was rejected (logging + tests). /// Why a received gossip payload was rejected (logging + tests).
@@ -233,7 +238,10 @@ impl ClockSkewMonitor {
} }
fn drop_oldest_authors(&mut self) { fn drop_oldest_authors(&mut self) {
let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP); let remove_count = self
.authors
.len()
.saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
let mut by_age: Vec<_> = self let mut by_age: Vec<_> = self
.authors .authors
.iter() .iter()
@@ -339,7 +347,8 @@ fn verify_gossip(
return Err(GossipReject::OutOfWindow); return Err(GossipReject::OutOfWindow);
} }
if let GossipMessage::Announce(state) = &payload.msg if let GossipMessage::Announce(state) = &payload.msg
&& state.addr.id != payload.author { && state.addr.id != payload.author
{
return Err(GossipReject::AnnounceAddressMismatch); return Err(GossipReject::AnnounceAddressMismatch);
} }
Ok(()) Ok(())
@@ -466,7 +475,8 @@ impl RoomState for IrohGossipState {
// Make every dial target resolvable: the ticket host plus any retained // Make every dial target resolvable: the ticket host plus any retained
// peers handed in (their addresses may have aged out of a fresh endpoint's // peers handed in (their addresses may have aged out of a fresh endpoint's
// book even though the persistent lookup usually still holds them). // book even though the persistent lookup usually still holds them).
self.address_lookup.add_endpoint_info(ticket.host_addr.clone()); self.address_lookup
.add_endpoint_info(ticket.host_addr.clone());
for addr in &extra_bootstrap { for addr in &extra_bootstrap {
self.address_lookup.add_endpoint_info(addr.clone()); self.address_lookup.add_endpoint_info(addr.clone());
} }
@@ -478,7 +488,10 @@ impl RoomState for IrohGossipState {
compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap); compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap);
crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers)); crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers));
let gossip_topic = self.gossip.subscribe(topic_id, bootstrap_peers).await let gossip_topic = self
.gossip
.subscribe(topic_id, bootstrap_peers)
.await
.map_err(|e| { .map_err(|e| {
let err = format!("Failed to join gossip topic: {}", e); let err = format!("Failed to join gossip topic: {}", e);
crate::log_msg(&err); crate::log_msg(&err);
@@ -505,32 +518,44 @@ impl RoomState for IrohGossipState {
let topic_bytes = ticket.topic_id; let topic_bytes = ticket.topic_id;
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id)); crate::log_msg(&format!(
"Spawned gossip topic loop for self_id={:?}",
self_id
));
let mut state_mutations_seen = HashMap::new(); let mut state_mutations_seen = HashMap::new();
let mut clock_skew_monitor = ClockSkewMonitor::default(); let mut clock_skew_monitor = ClockSkewMonitor::default();
// Broadcast initial state // Broadcast initial state
let initial_payload = { let initial_payload = {
let guard = self_state_clone.lock().unwrap(); let guard = self_state_clone.lock().unwrap();
guard.as_ref().map(|s| sign_gossip( guard.as_ref().map(|s| {
&secret_key, sign_gossip(
&topic_bytes, &secret_key,
now_millis(), &topic_bytes,
GossipMessage::Announce(s.clone()), now_millis(),
)) GossipMessage::Announce(s.clone()),
)
})
}; };
if let Some(payload) = initial_payload if let Some(payload) = initial_payload
&& let Ok(bytes) = serde_json::to_vec(&payload) { && let Ok(bytes) = serde_json::to_vec(&payload)
crate::log_msg(&format!("Broadcasting initial state from self_id={:?}", self_id)); {
let _ = gossip_sender_clone.broadcast(bytes.into()).await; crate::log_msg(&format!(
} "Broadcasting initial state from self_id={:?}",
self_id
));
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
}
// Stream topic messages // Stream topic messages
while let Some(res) = gossip_receiver.next().await { while let Some(res) = gossip_receiver.next().await {
match res { match res {
Ok(iroh_gossip::api::Event::Received(msg)) => { Ok(iroh_gossip::api::Event::Received(msg)) => {
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from)); crate::log_msg(&format!(
"Gossip received Event::Received from delivery={:?}",
msg.delivered_from
));
// Reject oversized frames BEFORE deserializing: parsing // Reject oversized frames BEFORE deserializing: parsing
// allocates, so a size check has to precede `from_slice` to // allocates, so a size check has to precede `from_slice` to
// bound the memory a hostile peer can make us hold. // bound the memory a hostile peer can make us hold.
@@ -577,13 +602,13 @@ impl RoomState for IrohGossipState {
continue; continue;
} }
let our_id = { let our_id = {
self_state_clone.lock().unwrap() self_state_clone.lock().unwrap().as_ref().map(|s| s.addr.id)
.as_ref()
.map(|s| s.addr.id)
}; };
if Some(payload.author) == our_id { if Some(payload.author) == our_id {
crate::log_msg("Gossip Event::Received from ourselves; ignoring"); crate::log_msg(
"Gossip Event::Received from ourselves; ignoring",
);
continue; continue;
} }
@@ -635,7 +660,9 @@ impl RoomState for IrohGossipState {
// Screen-share tickets are capabilities and // Screen-share tickets are capabilities and
// peer-supplied: cap/validate once at ingest // peer-supplied: cap/validate once at ingest
// so invalid offers never render a Watch button. // so invalid offers never render a Watch button.
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket); state.sharing = state
.sharing
.and_then(crate::screenshare::sanitize_ticket);
// The game-presence label is untrusted // The game-presence label is untrusted
// peer text like the name: sanitize + // peer text like the name: sanitize +
// length-cap at ingest (strip bidi/control, // length-cap at ingest (strip bidi/control,
@@ -651,8 +678,11 @@ impl RoomState for IrohGossipState {
// bounds a future fetch (reject anything // bounds a future fetch (reject anything
// outside the attachment cap). // outside the attachment cap).
state.music = state.music.and_then(|mut m| { state.music = state.music.and_then(|mut m| {
let name = crate::sanitize::sanitize_game_label(&m.name); let name =
if name.is_empty() || !crate::files::size_within_cap(m.size) { crate::sanitize::sanitize_game_label(&m.name);
if name.is_empty()
|| !crate::files::size_within_cap(m.size)
{
return None; return None;
} }
m.name = name; m.name = name;
@@ -679,8 +709,10 @@ impl RoomState for IrohGossipState {
// cleared ONLY once re-admitted — clearing it before // cleared ONLY once re-admitted — clearing it before
// a possible reject would orphan its recovery state // a possible reject would orphan its recovery state
// (Tier C F-01 audit). // (Tier C F-01 audit).
let is_reconnecting = let is_reconnecting = disconnected_peers
disconnected_peers.lock().unwrap().contains(&payload.author); .lock()
.unwrap()
.contains(&payload.author);
let admitted = { let admitted = {
let mut peer_map = peers.lock().unwrap(); let mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author); let is_new = !peer_map.contains_key(&payload.author);
@@ -689,11 +721,17 @@ impl RoomState for IrohGossipState {
// memory/tasks/dials without bound (Tier C // memory/tasks/dials without bound (Tier C
// F-01). Existing-peer updates and reconnects // F-01). Existing-peer updates and reconnects
// are exempt; only brand-new authors are gated. // are exempt; only brand-new authors are gated.
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting); let subject_to_cap =
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) { announce_subject_to_cap(is_new, is_reconnecting);
if !admit_into_roster(
peer_map.len(),
subject_to_cap,
MAX_ACTIVE_PEERS,
) {
None None
} else { } else {
let state_changed = peer_map.get(&payload.author) != Some(&state); let state_changed =
peer_map.get(&payload.author) != Some(&state);
if is_new || state_changed { if is_new || state_changed {
peer_map.insert(payload.author, state.clone()); peer_map.insert(payload.author, state.clone());
} }
@@ -723,35 +761,54 @@ impl RoomState for IrohGossipState {
// address set, so leave/re-announce cycles // address set, so leave/re-announce cycles
// can't accumulate attacker-supplied history // can't accumulate attacker-supplied history
// (Tier C F-01). // (Tier C F-01).
let _ = address_lookup.set_endpoint_info(state.addr.clone()); let _ = address_lookup
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await; .set_endpoint_info(state.addr.clone());
let _ = event_tx
.send(RoomEvent::PeerJoined(payload.author, state))
.await;
} else if state_changed { } else if state_changed {
crate::log_msg(&format!( crate::log_msg(&format!(
"Gossip peer state updated: {}, state: {}", "Gossip peer state updated: {}, state: {}",
crate::short_id(&payload.author.to_string()), crate::short_id(&payload.author.to_string()),
peer_state_for_log(&state) peer_state_for_log(&state)
)); ));
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await; let _ = event_tx
.send(RoomEvent::PeerUpdated(payload.author, state))
.await;
} }
} }
GossipMessage::Leave => { GossipMessage::Leave => {
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author)); crate::log_msg(&format!(
"Gossip peer leave request from author={:?}",
payload.author
));
// Drop this id's address-lookup entry so cycling // Drop this id's address-lookup entry so cycling
// distinct identities through Announce→Leave can't // distinct identities through Announce→Leave can't
// grow the lookup for the room's lifetime (Tier C // grow the lookup for the room's lifetime (Tier C
// F-01 audit). Re-announce re-populates it. // F-01 audit). Re-announce re-populates it.
let _ = address_lookup.remove_endpoint_info(payload.author); let _ = address_lookup.remove_endpoint_info(payload.author);
let removed = peers.lock().unwrap().remove(&payload.author).is_some(); let removed =
peers.lock().unwrap().remove(&payload.author).is_some();
let was_disconnected = disconnected_peers let was_disconnected = disconnected_peers
.lock() .lock()
.unwrap() .unwrap()
.remove(&payload.author); .remove(&payload.author);
if removed || was_disconnected { if removed || was_disconnected {
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await; let _ = event_tx
.send(RoomEvent::PeerLeft(payload.author))
.await;
} }
} }
GossipMessage::Chat { name, text, ts, attachment } => { GossipMessage::Chat {
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author)); name,
text,
ts,
attachment,
} => {
crate::log_msg(&format!(
"Gossip chat from author={:?}",
payload.author
));
// Defensively normalize an untrusted attachment // Defensively normalize an untrusted attachment
// descriptor: sanitize the filename and drop it // descriptor: sanitize the filename and drop it
// entirely if it declares an out-of-cap size. // entirely if it declares an out-of-cap size.
@@ -762,18 +819,23 @@ impl RoomState for IrohGossipState {
a.name = crate::files::sanitize_filename(&a.name); a.name = crate::files::sanitize_filename(&a.name);
Some(a) Some(a)
}); });
let _ = event_tx.send(RoomEvent::ChatMessage { let _ = event_tx
from: payload.author, .send(RoomEvent::ChatMessage {
name, from: payload.author,
text, name,
ts, text,
attachment, ts,
}).await; attachment,
})
.await;
} }
} }
} }
Err(e) => { Err(e) => {
crate::log_msg(&format!("Gossip failed to deserialize payload: {:?}", e)); crate::log_msg(&format!(
"Gossip failed to deserialize payload: {:?}",
e
));
} }
} }
} }
@@ -782,18 +844,24 @@ impl RoomState for IrohGossipState {
// Resend state on new neighbor connection to guarantee synchronization // Resend state on new neighbor connection to guarantee synchronization
let payload_opt = { let payload_opt = {
let guard = self_state_clone.lock().unwrap(); let guard = self_state_clone.lock().unwrap();
guard.as_ref().map(|state| sign_gossip( guard.as_ref().map(|state| {
&secret_key, sign_gossip(
&topic_bytes, &secret_key,
now_millis(), &topic_bytes,
GossipMessage::Announce(state.clone()), now_millis(),
)) GossipMessage::Announce(state.clone()),
)
})
}; };
if let Some(payload) = payload_opt if let Some(payload) = payload_opt
&& let Ok(bytes) = serde_json::to_vec(&payload) { && let Ok(bytes) = serde_json::to_vec(&payload)
crate::log_msg(&format!("Broadcasting state to new neighbor={:?}", peer_id)); {
let _ = gossip_sender_clone.broadcast(bytes.into()).await; crate::log_msg(&format!(
} "Broadcasting state to new neighbor={:?}",
peer_id
));
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
}
} }
Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => { Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => {
crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id)); crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id));
@@ -807,7 +875,10 @@ impl RoomState for IrohGossipState {
let removed = peers.lock().unwrap().remove(&peer_id).is_some(); let removed = peers.lock().unwrap().remove(&peer_id).is_some();
if removed { if removed {
disconnected_peers.lock().unwrap().insert(peer_id); disconnected_peers.lock().unwrap().insert(peer_id);
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id)); crate::log_msg(&format!(
"Peer connection lost (NeighborDown): {:?}",
peer_id
));
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await; let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
} }
} }
@@ -844,7 +915,9 @@ impl RoomState for IrohGossipState {
); );
if let Ok(bytes) = serde_json::to_vec(&payload) { if let Ok(bytes) = serde_json::to_vec(&payload) {
crate::log_msg("Broadcasting updated self state to gossip"); crate::log_msg("Broadcasting updated self state to gossip");
sender.broadcast(bytes.into()).await sender
.broadcast(bytes.into())
.await
.map_err(|e| NetError::Gossip(e.to_string()))?; .map_err(|e| NetError::Gossip(e.to_string()))?;
} }
} }
@@ -909,10 +982,17 @@ impl RoomState for IrohGossipState {
&self.secret_key, &self.secret_key,
&topic, &topic,
ts, ts,
GossipMessage::Chat { name, text, ts, attachment }, GossipMessage::Chat {
name,
text,
ts,
attachment,
},
); );
if let Ok(bytes) = serde_json::to_vec(&payload) { if let Ok(bytes) = serde_json::to_vec(&payload) {
sender.broadcast(bytes.into()).await sender
.broadcast(bytes.into())
.await
.map_err(|e| NetError::Gossip(e.to_string()))?; .map_err(|e| NetError::Gossip(e.to_string()))?;
} }
} }
@@ -934,12 +1014,7 @@ impl RoomState for IrohGossipState {
let sender_opt = self.active_sender.lock().unwrap().take(); let sender_opt = self.active_sender.lock().unwrap().take();
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) { if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
let payload = sign_gossip( let payload = sign_gossip(&self.secret_key, &topic, now_millis(), GossipMessage::Leave);
&self.secret_key,
&topic,
now_millis(),
GossipMessage::Leave,
);
if let Ok(bytes) = serde_json::to_vec(&payload) { if let Ok(bytes) = serde_json::to_vec(&payload) {
crate::log_msg("Broadcasting Leave message to gossip"); crate::log_msg("Broadcasting Leave message to gossip");
let _ = sender.broadcast(bytes.into()).await; let _ = sender.broadcast(bytes.into()).await;
@@ -1060,7 +1135,9 @@ mod tests {
#[test] #[test]
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() { fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
// A brand-new author counts against the cap... // A brand-new author counts against the cap...
assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false)); assert!(announce_subject_to_cap(
/* is_new */ true, /* is_reconnecting */ false
));
// ...but an ordinary update from an in-roster peer does not... // ...but an ordinary update from an in-roster peer does not...
assert!(!announce_subject_to_cap(false, false)); assert!(!announce_subject_to_cap(false, false));
// ...and neither does a re-announce from a peer mid-reconnect, even // ...and neither does a re-announce from a peer mid-reconnect, even
@@ -1070,7 +1147,11 @@ mod tests {
// Combined with admit_into_roster: a reconnecting author passes at a full // Combined with admit_into_roster: a reconnecting author passes at a full
// roster, a brand-new one does not. // roster, a brand-new one does not.
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3)); assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3)); assert!(!admit_into_roster(
3,
announce_subject_to_cap(true, false),
3
));
} }
#[test] #[test]
@@ -1105,7 +1186,10 @@ mod tests {
assert_eq!(monitor.observe(author, -122_000, 40_000), None); assert_eq!(monitor.observe(author, -122_000, 40_000), None);
assert_eq!( assert_eq!(
monitor.observe(author, -123_000, 69_999), monitor.observe(author, -123_000, 69_999),
Some(ClockSkewWarning { author, skew_ms: -123_000 }) Some(ClockSkewWarning {
author,
skew_ms: -123_000
})
); );
assert_eq!(monitor.observe(author, -124_000, 70_000), None); assert_eq!(monitor.observe(author, -124_000, 70_000), None);
} }
@@ -1125,7 +1209,10 @@ mod tests {
assert_eq!(monitor.observe(author, 127_000, 319_999), None); assert_eq!(monitor.observe(author, 127_000, 319_999), None);
assert_eq!( assert_eq!(
monitor.observe(author, 128_000, 320_000), monitor.observe(author, 128_000, 320_000),
Some(ClockSkewWarning { author, skew_ms: 128_000 }) Some(ClockSkewWarning {
author,
skew_ms: 128_000
})
); );
} }
@@ -1141,11 +1228,17 @@ mod tests {
assert_eq!(monitor.observe(b, 121_000, 1_000), None); assert_eq!(monitor.observe(b, 121_000, 1_000), None);
assert_eq!( assert_eq!(
monitor.observe(b, 121_000, 2_000), monitor.observe(b, 121_000, 2_000),
Some(ClockSkewWarning { author: b, skew_ms: 121_000 }) Some(ClockSkewWarning {
author: b,
skew_ms: 121_000
})
); );
assert_eq!( assert_eq!(
monitor.observe(a, -121_000, 2_000), monitor.observe(a, -121_000, 2_000),
Some(ClockSkewWarning { author: a, skew_ms: -121_000 }) Some(ClockSkewWarning {
author: a,
skew_ms: -121_000
})
); );
} }
@@ -1202,7 +1295,10 @@ mod tests {
assert!(long.as_str().len() > MAX_RELAY_URL_LEN); assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
let addr = EndpointAddr::from_parts( let addr = EndpointAddr::from_parts(
id, id,
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)], [
TransportAddr::Relay(short.clone()),
TransportAddr::Relay(long),
],
); );
let out = sanitize_endpoint_addr(&addr); let out = sanitize_endpoint_addr(&addr);
let relays: Vec<_> = out.relay_urls().cloned().collect(); let relays: Vec<_> = out.relay_urls().cloned().collect();
@@ -1222,7 +1318,12 @@ mod tests {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
let topic = [9u8; 32]; let topic = [9u8; 32];
let peer_state = sample_peer_state_for(secret.public()); let peer_state = sample_peer_state_for(secret.public());
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone())); let payload = sign_gossip(
&secret,
&topic,
1000,
GossipMessage::Announce(peer_state.clone()),
);
let serialized = serde_json::to_string(&payload).unwrap(); let serialized = serde_json::to_string(&payload).unwrap();
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap(); let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
@@ -1252,7 +1353,13 @@ mod tests {
}; };
let serialized = serde_json::to_string(&original).unwrap(); let serialized = serde_json::to_string(&original).unwrap();
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap(); let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
if let GossipMessage::Chat { name, text, ts, attachment } = deserialized { if let GossipMessage::Chat {
name,
text,
ts,
attachment,
} = deserialized
{
assert_eq!(name, "Alice"); assert_eq!(name, "Alice");
assert_eq!(text, "Hello"); assert_eq!(text, "Hello");
assert_eq!(ts, 123456789); assert_eq!(ts, 123456789);
@@ -1305,7 +1412,10 @@ mod tests {
// defaulting the attachment to None (serde(default)). // defaulting the attachment to None (serde(default)).
let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#; let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
let parsed: GossipMessage = serde_json::from_str(legacy).unwrap(); let parsed: GossipMessage = serde_json::from_str(legacy).unwrap();
if let GossipMessage::Chat { name, attachment, .. } = parsed { if let GossipMessage::Chat {
name, attachment, ..
} = parsed
{
assert_eq!(name, "Old"); assert_eq!(name, "Old");
assert_eq!(attachment, None); assert_eq!(attachment, None);
} else { } else {
@@ -1368,7 +1478,10 @@ mod tests {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
let topic = [1u8; 32]; let topic = [1u8; 32];
let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
assert_eq!(verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Ok(())); assert_eq!(
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
Ok(())
);
} }
#[test] #[test]
@@ -1392,7 +1505,12 @@ mod tests {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
let topic = [4u8; 32]; let topic = [4u8; 32];
let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave); let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None }; p.msg = GossipMessage::Chat {
name: "x".into(),
text: "y".into(),
ts: 5_000,
attachment: None,
};
assert_eq!( assert_eq!(
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
Err(GossipReject::BadSignature) Err(GossipReject::BadSignature)
@@ -1418,16 +1536,34 @@ mod tests {
let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave); let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave);
// Far in the past relative to "now" → stale (replay). // Far in the past relative to "now" → stale (replay).
assert_eq!( assert_eq!(
verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS + 1, GOSSIP_FRESHNESS_MS), verify_gossip(
&p,
&topic,
1_000_000 + GOSSIP_FRESHNESS_MS + 1,
GOSSIP_FRESHNESS_MS
),
Err(GossipReject::OutOfWindow) Err(GossipReject::OutOfWindow)
); );
// Implausibly future. // Implausibly future.
assert_eq!( assert_eq!(
verify_gossip(&p, &topic, 1_000_000 - GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS), verify_gossip(
&p,
&topic,
1_000_000 - GOSSIP_FRESHNESS_MS - 1,
GOSSIP_FRESHNESS_MS
),
Err(GossipReject::OutOfWindow) Err(GossipReject::OutOfWindow)
); );
// Within the window (clock skew tolerance) → accepted. // Within the window (clock skew tolerance) → accepted.
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok()); assert!(
verify_gossip(
&p,
&topic,
1_000_000 + GOSSIP_FRESHNESS_MS - 1,
GOSSIP_FRESHNESS_MS
)
.is_ok()
);
} }
#[test] #[test]
@@ -1449,10 +1585,30 @@ mod tests {
let author = fresh_id(); let author = fresh_id();
let mut seen = HashMap::new(); let mut seen = HashMap::new();
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10)); assert!(admit_state_mutation(
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10)); &mut seen,
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9)); author,
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11)); &GossipMessage::Leave,
10
));
assert!(!admit_state_mutation(
&mut seen,
author,
&GossipMessage::Leave,
10
));
assert!(!admit_state_mutation(
&mut seen,
author,
&GossipMessage::Leave,
9
));
assert!(admit_state_mutation(
&mut seen,
author,
&GossipMessage::Leave,
11
));
let announce = GossipMessage::Announce(sample_peer_state_for(author)); let announce = GossipMessage::Announce(sample_peer_state_for(author));
assert!(admit_state_mutation(&mut seen, author, &announce, 10)); assert!(admit_state_mutation(&mut seen, author, &announce, 10));
@@ -1465,13 +1621,26 @@ mod tests {
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() { fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
let author = fresh_id(); let author = fresh_id();
let mut seen = HashMap::new(); let mut seen = HashMap::new();
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200, attachment: None }; let later_chat = GossipMessage::Chat {
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None }; name: "A".into(),
text: "later".into(),
ts: 200,
attachment: None,
};
let earlier_chat = GossipMessage::Chat {
name: "A".into(),
text: "earlier".into(),
ts: 100,
attachment: None,
};
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200)); assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100)); assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200)); assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
assert!(seen.is_empty(), "chat must not populate the state-mutation replay map"); assert!(
seen.is_empty(),
"chat must not populate the state-mutation replay map"
);
} }
#[test] #[test]
@@ -1481,8 +1650,18 @@ mod tests {
let mut seen = HashMap::new(); let mut seen = HashMap::new();
let announce = GossipMessage::Announce(sample_peer_state_for(author)); let announce = GossipMessage::Announce(sample_peer_state_for(author));
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5)); assert!(admit_state_mutation(
&mut seen,
author,
&GossipMessage::Leave,
5
));
assert!(admit_state_mutation(&mut seen, author, &announce, 5)); assert!(admit_state_mutation(&mut seen, author, &announce, 5));
assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5)); assert!(admit_state_mutation(
&mut seen,
other,
&GossipMessage::Leave,
5
));
} }
} }
+71 -26
View File
@@ -1,16 +1,16 @@
use crate::network::{NetworkTransport, NetError, ConnEvent}; use crate::network::{ConnEvent, NetError, NetworkTransport};
use iroh::{Endpoint, EndpointId}; use async_trait::async_trait;
use iroh::endpoint::{Connection, ConnectionError, VarInt};
use bytes::Bytes; use bytes::Bytes;
use iroh::endpoint::{Connection, ConnectionError, VarInt};
use iroh::{Endpoint, EndpointId};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex as StdMutex};
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use async_trait::async_trait;
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
use crate::files::{AttachmentId, ChatAttachment}; use crate::files::{AttachmentId, ChatAttachment};
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is /// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
/// useless latency — keep it shallow and drop the oldest frame when full. /// useless latency — keep it shallow and drop the oldest frame when full.
@@ -111,7 +111,13 @@ impl Shared {
let shared = self.clone(); let shared = self.clone();
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx)); let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
let inbound_tx_ret = inbound_tx.clone(); let inbound_tx_ret = inbound_tx.clone();
peers.insert(peer_id, PeerHandle { supervisor, inbound_tx }); peers.insert(
peer_id,
PeerHandle {
supervisor,
inbound_tx,
},
);
crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id)); crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id));
inbound_tx_ret inbound_tx_ret
} }
@@ -123,7 +129,10 @@ impl Shared {
self.addrs.lock().unwrap().remove(&peer_id); self.addrs.lock().unwrap().remove(&peer_id);
if let Some(handle) = self.peers.lock().await.remove(&peer_id) { if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
handle.supervisor.abort(); handle.supervisor.abort();
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id)); crate::log_msg(&format!(
"Transport: stopped supervising peer {:?}",
peer_id
));
} }
} }
@@ -208,12 +217,15 @@ async fn supervise(
let mut backoff = INITIAL_BACKOFF; let mut backoff = INITIAL_BACKOFF;
// Show "connecting" until the first link is actually up. // Show "connecting" until the first link is actually up.
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
let mut conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await { let mut conn =
Some(conn) => conn, match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
None => return, // retired before we ever connected Some(conn) => conn,
}; None => return, // retired before we ever connected
};
loop { loop {
// A healthy link resets the dialer's backoff for the next outage. // A healthy link resets the dialer's backoff for the next outage.
@@ -223,8 +235,14 @@ async fn supervise(
shared.senders.lock().unwrap().insert(peer_id, send_tx); shared.senders.lock().unwrap().insert(peer_id, send_tx);
// Publish the live connection so an intentional leave can close it with // Publish the live connection so an intentional leave can close it with
// the goodbye code. // the goodbye code.
shared.live_conns.lock().unwrap().insert(peer_id, conn.clone()); shared
let _ = shared.conn_events_tx.try_send(ConnEvent::Connected(peer_id)); .live_conns
.lock()
.unwrap()
.insert(peer_id, conn.clone());
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connected(peer_id));
crate::log_msg(&format!("Transport: peer {:?} link up", peer_id)); crate::log_msg(&format!("Transport: peer {:?} link up", peer_id));
// Run until the link dies, a replacement arrives, or we're retired. The // Run until the link dies, a replacement arrives, or we're retired. The
@@ -272,21 +290,36 @@ async fn supervise(
match wake { match wake {
Wake::Shutdown => return, Wake::Shutdown => return,
Wake::Replacement(new_conn) => { Wake::Replacement(new_conn) => {
crate::log_msg(&format!("Transport: peer {:?} replaced with new inbound link", peer_id)); crate::log_msg(&format!(
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); "Transport: peer {:?} replaced with new inbound link",
peer_id
));
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
conn = new_conn; conn = new_conn;
} }
Wake::Closed(reason) => { Wake::Closed(reason) => {
// A graceful application close means the peer left on purpose — // A graceful application close means the peer left on purpose —
// don't reconnect; tell the core to evict it now. // don't reconnect; tell the core to evict it now.
if is_graceful_leave(&reason) { if is_graceful_leave(&reason) {
crate::log_msg(&format!("Transport: peer {:?} left gracefully ({:?})", peer_id, reason)); crate::log_msg(&format!(
"Transport: peer {:?} left gracefully ({:?})",
peer_id, reason
));
let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id)); let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id));
return; return;
} }
crate::log_msg(&format!("Transport: peer {:?} link dropped; reconnecting", peer_id)); crate::log_msg(&format!(
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id)); "Transport: peer {:?} link dropped; reconnecting",
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await { peer_id
));
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff)
.await
{
Some(conn) => conn, Some(conn) => conn,
None => return, // retired while reconnecting None => return, // retired while reconnecting
}; };
@@ -405,7 +438,10 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
// only happens if links are churning, and the supervisor gets the next one. // only happens if links are churning, and the supervisor gets the next one.
let inbound_tx = shared.ensure_supervisor(peer_id).await; let inbound_tx = shared.ensure_supervisor(peer_id).await;
if inbound_tx.try_send(connection).is_err() { if inbound_tx.try_send(connection).is_err() {
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id)); crate::log_msg(&format!(
"Transport: dropped inbound link from {:?} (queue full)",
peer_id
));
} }
Ok(()) Ok(())
} }
@@ -545,7 +581,14 @@ impl IrohTransport {
/// all supervisors so none linger redialing the about-to-close endpoint. /// all supervisors so none linger redialing the about-to-close endpoint.
/// Call this before shutting the router down. /// Call this before shutting the router down.
pub async fn leave(&self) { pub async fn leave(&self) {
let conns: Vec<Connection> = self.shared.live_conns.lock().unwrap().drain().map(|(_, c)| c).collect(); let conns: Vec<Connection> = self
.shared
.live_conns
.lock()
.unwrap()
.drain()
.map(|(_, c)| c)
.collect();
for conn in &conns { for conn in &conns {
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave"); conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
} }
@@ -639,7 +682,9 @@ impl IrohTransport {
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))? .map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?; .map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
if bytes.is_empty() { if bytes.is_empty() {
return Err(NetError::Other("file fetch: sender no longer has the file".to_string())); return Err(NetError::Other(
"file fetch: sender no longer has the file".to_string(),
));
} }
Ok(bytes) Ok(bytes)
} }
+53 -29
View File
@@ -1,10 +1,10 @@
use iroh::{EndpointId, EndpointAddr}; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use iroh::{EndpointAddr, EndpointId};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error; use thiserror::Error;
use tokio::sync::mpsc::Receiver; use tokio::sync::mpsc::Receiver;
use async_trait::async_trait;
use serde::{Serialize, Deserialize};
use std::str::FromStr;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum NetError { pub enum NetError {
@@ -148,7 +148,10 @@ pub enum RoomEvent {
/// A validly signed gossip payload was rejected only because its timestamp is /// A validly signed gossip payload was rejected only because its timestamp is
/// outside the replay-protection window. The peer is not in the roster yet, /// outside the replay-protection window. The peer is not in the roster yet,
/// so this surfaces as a room-level warning instead of a peer-card state. /// so this surfaces as a room-level warning instead of a peer-card state.
ClockSkewSuspected { author: EndpointId, skew_ms: i64 }, ClockSkewSuspected {
author: EndpointId,
skew_ms: i64,
},
/// A peer sent a room text-chat message. Carries the sender's id, their /// A peer sent a room text-chat message. Carries the sender's id, their
/// display name (embedded so it shows even without a presence entry), the /// display name (embedded so it shows even without a presence entry), the
/// text, and a sender-stamped millisecond timestamp. /// text, and a sender-stamped millisecond timestamp.
@@ -203,10 +206,12 @@ impl PeerSpeakTicket {
/// is idempotent. /// is idempotent.
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String { pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
match ticket_str.parse::<PeerSpeakTicket>() { match ticket_str.parse::<PeerSpeakTicket>() {
Ok(t) => { Ok(t) => PeerSpeakTicket {
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name } host_addr: my_addr,
.to_string() topic_id: t.topic_id,
name: t.name,
} }
.to_string(),
Err(_) => ticket_str.to_string(), Err(_) => ticket_str.to_string(),
} }
} }
@@ -215,7 +220,10 @@ impl PeerSpeakTicket {
/// can't be parsed or carries no label. Pure; used to label the gathering both /// can't be parsed or carries no label. Pure; used to label the gathering both
/// in the room UI and in the presence we report to friends. /// in the room UI and in the presence we report to friends.
pub fn label_of(ticket_str: &str) -> String { pub fn label_of(ticket_str: &str) -> String {
ticket_str.parse::<PeerSpeakTicket>().map(|t| t.name).unwrap_or_default() ticket_str
.parse::<PeerSpeakTicket>()
.map(|t| t.name)
.unwrap_or_default()
} }
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket /// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
@@ -223,7 +231,10 @@ impl PeerSpeakTicket {
/// the recents list (the host address and label change between members/sessions, /// the recents list (the host address and label change between members/sessions,
/// but the topic uniquely identifies the gathering). /// but the topic uniquely identifies the gathering).
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> { pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
ticket_str.parse::<PeerSpeakTicket>().ok().map(|t| t.topic_id) ticket_str
.parse::<PeerSpeakTicket>()
.ok()
.map(|t| t.topic_id)
} }
} }
@@ -246,8 +257,8 @@ impl FromStr for PeerSpeakTicket {
fn from_str(s: &str) -> Result<Self, Self::Err> { fn from_str(s: &str) -> Result<Self, Self::Err> {
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s) let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
.map_err(|e| NetError::InvalidTicket(e.to_string()))?; .map_err(|e| NetError::InvalidTicket(e.to_string()))?;
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded) let ticket: PeerSpeakTicket =
.map_err(|e| NetError::InvalidTicket(e.to_string()))?; serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?;
Ok(ticket) Ok(ticket)
} }
} }
@@ -327,13 +338,13 @@ pub trait RoomState: Send + Sync {
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>; async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
} }
pub mod iroh_impl;
pub mod gossip; pub mod gossip;
pub mod iroh_impl;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use iroh::{SecretKey, EndpointAddr}; use iroh::{EndpointAddr, SecretKey};
fn sample_peer_state() -> PeerState { fn sample_peer_state() -> PeerState {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
@@ -371,9 +382,12 @@ mod tests {
let host = SecretKey::generate().public(); let host = SecretKey::generate().public();
let topic_id = [3u8; 32]; let topic_id = [3u8; 32];
// A labelled ticket: restamp keeps the label, label_of reads it. // A labelled ticket: restamp keeps the label, label_of reads it.
let labelled = let labelled = PeerSpeakTicket {
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } host_addr: EndpointAddr::from(host),
.to_string(); topic_id,
name: "HangOut".into(),
}
.to_string();
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut"); assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
let member = SecretKey::generate().public(); let member = SecretKey::generate().public();
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member)); let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
@@ -410,10 +424,8 @@ mod tests {
// valid URL-safe-base64 that decodes to non-JSON bytes // valid URL-safe-base64 that decodes to non-JSON bytes
let bad_json = b"hello world"; let bad_json = b"hello world";
let encoded = base64::Engine::encode( let encoded =
&base64::engine::general_purpose::URL_SAFE_NO_PAD, base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json);
bad_json,
);
let res3 = encoded.parse::<PeerSpeakTicket>(); let res3 = encoded.parse::<PeerSpeakTicket>();
assert!(matches!(res3, Err(NetError::InvalidTicket(_)))); assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
} }
@@ -424,9 +436,12 @@ mod tests {
let host = SecretKey::generate().public(); let host = SecretKey::generate().public();
let member = SecretKey::generate().public(); let member = SecretKey::generate().public();
let topic_id = [42u8; 32]; let topic_id = [42u8; 32];
let original = let original = PeerSpeakTicket {
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() } host_addr: EndpointAddr::from(host),
.to_string(); topic_id,
name: "HangOut".into(),
}
.to_string();
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member)); let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap(); let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
@@ -441,18 +456,27 @@ mod tests {
fn test_restamp_is_idempotent_for_same_addr() { fn test_restamp_is_idempotent_for_same_addr() {
let me = SecretKey::generate().public(); let me = SecretKey::generate().public();
let topic_id = [7u8; 32]; let topic_id = [7u8; 32];
let mine = let mine = PeerSpeakTicket {
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() } host_addr: EndpointAddr::from(me),
.to_string(); topic_id,
name: String::new(),
}
.to_string();
// Re-stamping my own ticket with my own addr changes nothing. // Re-stamping my own ticket with my own addr changes nothing.
assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine); assert_eq!(
PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)),
mine
);
} }
#[test] #[test]
fn test_restamp_passes_through_unparseable() { fn test_restamp_passes_through_unparseable() {
let me = SecretKey::generate().public(); let me = SecretKey::generate().public();
// A malformed ticket is returned unchanged (the join will fail anyway). // A malformed ticket is returned unchanged (the join will fail anyway).
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket"); assert_eq!(
PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)),
"not-a-ticket"
);
} }
#[test] #[test]
+17 -9
View File
@@ -61,7 +61,6 @@ pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool {
master_enabled && sound_enabled master_enabled && sound_enabled
} }
/// A notification event with a distinct chime. /// A notification event with a distinct chime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound { pub enum Sound {
@@ -305,10 +304,7 @@ mod tests {
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"), escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
r"C:\Users\O''Brien\chime.wav" r"C:\Users\O''Brien\chime.wav"
); );
assert_eq!( assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c");
escape_powershell_single_quoted("a'b'c"),
"a''b''c"
);
} }
#[test] #[test]
@@ -338,7 +334,10 @@ mod tests {
// bare `~` -> home dir // bare `~` -> home dir
assert_eq!(expand_tilde("~"), home); assert_eq!(expand_tilde("~"), home);
// `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav` // `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav`
assert_eq!(expand_tilde("~/sub/dir/file.wav"), home.join("sub/dir/file.wav")); assert_eq!(
expand_tilde("~/sub/dir/file.wav"),
home.join("sub/dir/file.wav")
);
} }
// absolute path (`/etc/foo.wav`) -> unchanged // absolute path (`/etc/foo.wav`) -> unchanged
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav")); assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
@@ -351,10 +350,19 @@ mod tests {
// leading/trailing whitespace is trimmed // leading/trailing whitespace is trimmed
if let Some(home) = dirs::home_dir() { if let Some(home) = dirs::home_dir() {
assert_eq!(expand_tilde(" ~ "), home); assert_eq!(expand_tilde(" ~ "), home);
assert_eq!(expand_tilde(" ~/sub/dir/file.wav "), home.join("sub/dir/file.wav")); assert_eq!(
expand_tilde(" ~/sub/dir/file.wav "),
home.join("sub/dir/file.wav")
);
} }
assert_eq!(expand_tilde(" /etc/foo.wav "), PathBuf::from("/etc/foo.wav")); assert_eq!(
assert_eq!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav")); expand_tilde(" /etc/foo.wav "),
PathBuf::from("/etc/foo.wav")
);
assert_eq!(
expand_tilde(" foo/bar.wav "),
PathBuf::from("foo/bar.wav")
);
} }
#[test] #[test]
+5 -4
View File
@@ -49,9 +49,7 @@ pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Ve
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> { fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
let lower = entry.to_ascii_lowercase(); let lower = entry.to_ascii_lowercase();
if lower.starts_with("http://") if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
|| lower.starts_with("https://")
|| lower.starts_with("ftp://")
{ {
return None; return None;
} }
@@ -107,7 +105,10 @@ File3=/var/audio/two.MP3
#[test] #[test]
fn playlist_kind_is_case_insensitive() { fn playlist_kind_is_case_insensitive() {
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u)); assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u)); assert_eq!(
playlist_kind(Path::new("mix.m3u8")),
Some(PlaylistKind::M3u)
);
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls)); assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
assert_eq!(playlist_kind(Path::new("mix.txt")), None); assert_eq!(playlist_kind(Path::new("mix.txt")), None);
} }
+67 -19
View File
@@ -36,8 +36,11 @@ pub enum PresenceMode {
impl PresenceMode { impl PresenceMode {
/// All postures, default first — the option list for the Settings/home picker. /// All postures, default first — the option list for the Settings/home picker.
pub const ALL: [PresenceMode; 3] = pub const ALL: [PresenceMode; 3] = [
[PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable]; PresenceMode::Normal,
PresenceMode::Invisible,
PresenceMode::Discoverable,
];
/// Whether this posture publishes to discovery (the only mode that does). /// Whether this posture publishes to discovery (the only mode that does).
pub fn publishes_to_discovery(self) -> bool { pub fn publishes_to_discovery(self) -> bool {
@@ -193,7 +196,11 @@ mod tests {
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible)); assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
// Stranger is NEVER answered, in any mode. // Stranger is NEVER answered, in any mode.
assert!(!should_answer(&stranger, &friends, PresenceMode::Normal)); assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
assert!(!should_answer(&stranger, &friends, PresenceMode::Discoverable)); assert!(!should_answer(
&stranger,
&friends,
PresenceMode::Discoverable
));
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible)); assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
} }
@@ -214,7 +221,10 @@ mod tests {
ControlMsg::Ping, ControlMsg::Ping,
ControlMsg::Pong { room: None }, ControlMsg::Pong { room: None },
ControlMsg::Pong { ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }), room: Some(RoomPresence {
name: "HangOut".into(),
ticket: "abc".into(),
}),
}, },
]; ];
for msg in cases { for msg in cases {
@@ -245,19 +255,37 @@ mod tests {
); );
// Valid ticket -> InRoom with a sanitized name. // Valid ticket -> InRoom with a sanitized name.
let t = valid_ticket(friend); let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }), &ControlMsg::Pong {
}, friend); room: Some(RoomPresence {
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t })); name: "HangOut".into(),
ticket: t.clone(),
}),
},
friend,
);
assert_eq!(
got,
Some(FriendPresence::InRoom {
name: "HangOut".into(),
ticket: t
})
);
} }
#[test] #[test]
fn interpret_pong_downgrades_a_garbage_ticket_to_online() { fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
// A friend reporting a room with an unparseable ticket is treated as just // A friend reporting a room with an unparseable ticket is treated as just
// Online — no dead/hostile Join button is surfaced. // Online — no dead/hostile Join button is surfaced.
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }), &ControlMsg::Pong {
}, id()); room: Some(RoomPresence {
name: "Trap".into(),
ticket: "not-a-ticket".into(),
}),
},
id(),
);
assert_eq!(got, Some(FriendPresence::Online)); assert_eq!(got, Some(FriendPresence::Online));
} }
@@ -266,9 +294,15 @@ mod tests {
let friend = id(); let friend = id();
let attacker = id(); let attacker = id();
let t = valid_ticket(attacker); let t = valid_ticket(attacker);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }), &ControlMsg::Pong {
}, friend); room: Some(RoomPresence {
name: "Redirect".into(),
ticket: t,
}),
},
friend,
);
assert_eq!(got, Some(FriendPresence::Online)); assert_eq!(got, Some(FriendPresence::Online));
} }
@@ -287,10 +321,18 @@ mod tests {
let t = valid_ticket(friend); let t = valid_ticket(friend);
assert_eq!( assert_eq!(
presence_from_probe(Some(( presence_from_probe(Some((
&ControlMsg::Pong { room: Some(RoomPresence { name: "Den".into(), ticket: t.clone() }) }, &ControlMsg::Pong {
room: Some(RoomPresence {
name: "Den".into(),
ticket: t.clone()
})
},
friend, friend,
))), ))),
FriendPresence::InRoom { name: "Den".into(), ticket: t } FriendPresence::InRoom {
name: "Den".into(),
ticket: t
}
); );
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online. // A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
assert_eq!( assert_eq!(
@@ -304,9 +346,15 @@ mod tests {
// Control/bidi characters in a peer-supplied name are stripped. // Control/bidi characters in a peer-supplied name are stripped.
let friend = id(); let friend = id();
let t = valid_ticket(friend); let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }), &ControlMsg::Pong {
}, friend); room: Some(RoomPresence {
name: "Hang\u{202e}Out\u{0007}".into(),
ticket: t.clone(),
}),
},
friend,
);
match got { match got {
Some(FriendPresence::InRoom { name, .. }) => { Some(FriendPresence::InRoom { name, .. }) => {
assert!(!name.contains('\u{202e}'), "bidi override must be stripped"); assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
+21 -8
View File
@@ -54,7 +54,10 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
/// malformed) — the caller treats that as "appears offline". `peer` is usually a /// malformed) — the caller treats that as "appears offline". `peer` is usually a
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is /// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
/// also accepted (and used by hermetic tests). /// also accepted (and used by hermetic tests).
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> { pub async fn probe(
endpoint: &Endpoint,
peer: impl Into<EndpointAddr>,
) -> Result<(EndpointId, ControlMsg)> {
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN)) let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
.await .await
.context("timed out connecting to peer")? .context("timed out connecting to peer")?
@@ -62,7 +65,10 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
let from = conn.remote_id(); let from = conn.remote_id();
let io = async { let io = async {
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?; let (mut send, mut recv) = conn
.open_bi()
.await
.context("failed to open control stream")?;
send.write_all(&encode(&ControlMsg::Ping)?) send.write_all(&encode(&ControlMsg::Ping)?)
.await .await
.context("failed to write ping")?; .context("failed to write ping")?;
@@ -118,7 +124,10 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
let io = async { let io = async {
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?; let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
let bytes = recv.read_to_end(MAX_MSG).await.context("failed to read ping")?; let bytes = recv
.read_to_end(MAX_MSG)
.await
.context("failed to read ping")?;
match decode(&bytes)? { match decode(&bytes)? {
ControlMsg::Ping => {} ControlMsg::Ping => {}
other => bail!("expected a ping, got {other:?}"), other => bail!("expected a ping, got {other:?}"),
@@ -211,7 +220,10 @@ mod tests {
let handler: Handler = Arc::new(move |from| { let handler: Handler = Arc::new(move |from| {
if from == allowed { if from == allowed {
Some(ControlMsg::Pong { Some(ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }), room: Some(RoomPresence {
name: "HangOut".into(),
ticket: "t".into(),
}),
}) })
} else { } else {
None // stranger -> no reply None // stranger -> no reply
@@ -221,10 +233,11 @@ mod tests {
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await }); let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
// The allowed prober gets a Pong with the room. // The allowed prober gets a Pong with the room.
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone())) let (from, pong) =
.await tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
.expect("probe timed out") .await
.expect("probe failed"); .expect("probe timed out")
.expect("probe failed");
assert_eq!(from, server_addr.id); assert_eq!(from, server_addr.id);
match pong { match pong {
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"), ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
+21 -5
View File
@@ -83,10 +83,22 @@ mod tests {
/// so a version bump can't silently forget to update the wire string. /// so a version bump can't silently forget to update the wire string.
#[test] #[test]
fn alpns_match_their_proto_versions() { fn alpns_match_their_proto_versions() {
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()); assert_eq!(
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()); AUDIO_ALPN,
assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes()); format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}")); );
assert_eq!(
FRIENDS_ALPN,
format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()
);
assert_eq!(
FILES_ALPN,
format!("peerspeak/files/{FILES_PROTO}").as_bytes()
);
assert_eq!(
GOSSIP_SIG_DOMAIN,
format!("peerspeak-gossip-v{GOSSIP_PROTO}")
);
} }
#[test] #[test]
@@ -95,7 +107,11 @@ mod tests {
let mut b = a; let mut b = a;
b[5] = 10; b[5] = 10;
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic"); assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct"); assert_ne!(
versioned_topic(a),
versioned_topic(b),
"distinct rooms stay distinct"
);
} }
#[test] #[test]
+14 -3
View File
@@ -51,7 +51,14 @@ fn same_room(a: &str, b: &str) -> bool {
/// supplies `now` (unix seconds) and persists the list afterwards. /// supplies `now` (unix seconds) and persists the list afterwards.
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) { pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
list.retain(|r| !same_room(&r.ticket, &ticket)); list.retain(|r| !same_room(&r.ticket, &ticket));
list.insert(0, Recent { name, ticket, joined_at: now }); list.insert(
0,
Recent {
name,
ticket,
joined_at: now,
},
);
list.truncate(RECENTS_MAX); list.truncate(RECENTS_MAX);
} }
@@ -86,8 +93,12 @@ mod tests {
/// Build a real, parseable ticket for a fresh room with the given label. /// Build a real, parseable ticket for a fresh room with the given label.
fn ticket(name: &str, topic: [u8; 32]) -> String { fn ticket(name: &str, topic: [u8; 32]) -> String {
let host = SecretKey::generate().public(); let host = SecretKey::generate().public();
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() } PeerSpeakTicket {
.to_string() host_addr: EndpointAddr::from(host),
topic_id: topic,
name: name.into(),
}
.to_string()
} }
#[test] #[test]
+27 -6
View File
@@ -85,7 +85,10 @@ pub enum Segment {
/// Trailing characters commonly adjacent to a URL in prose that should NOT be /// Trailing characters commonly adjacent to a URL in prose that should NOT be
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly). /// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
fn is_url_trailing_punct(c: char) -> bool { fn is_url_trailing_punct(c: char) -> bool {
matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'') matches!(
c,
'.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\''
)
} }
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`, /// Find the byte index of the earliest `http://` or `https://` scheme in `s`,
@@ -144,7 +147,10 @@ mod tests {
fn strips_control_chars_and_collapses_whitespace() { fn strips_control_chars_and_collapses_whitespace() {
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then // NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
// collapse; ends trim. // collapse; ends trim.
assert_eq!(sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), "a b c d [31m"); assert_eq!(
sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "),
"a b c d [31m"
);
// A name that is only control/whitespace cleans to empty. // A name that is only control/whitespace cleans to empty.
assert_eq!(sanitize_name("\u{0}\r\n\t "), ""); assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
} }
@@ -183,7 +189,10 @@ mod tests {
let mid = "g".repeat(56); let mid = "g".repeat(56);
assert_eq!(sanitize_game_label(&mid).chars().count(), 56); assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100); let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS); assert_eq!(
sanitize_game_label(&long).chars().count(),
GAME_LABEL_MAX_CHARS
);
} }
#[test] #[test]
@@ -220,7 +229,10 @@ mod tests {
#[test] #[test]
fn linkify_plain_text_has_no_links() { fn linkify_plain_text_has_no_links() {
let segs = linkify("just a normal message, nothing here"); let segs = linkify("just a normal message, nothing here");
assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]); assert_eq!(
segs,
vec![Segment::Text("just a normal message, nothing here".into())]
);
} }
#[test] #[test]
@@ -280,7 +292,12 @@ mod tests {
fn linkify_only_matches_http_schemes() { fn linkify_only_matches_http_schemes() {
// Non-web schemes and bare domains are NOT linkified (conservative). // Non-web schemes and bare domains are NOT linkified (conservative).
let segs = linkify("email me@x.com or ftp://x.com or visit x.com"); let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
assert_eq!(segs, vec![Segment::Text("email me@x.com or ftp://x.com or visit x.com".into())]); assert_eq!(
segs,
vec![Segment::Text(
"email me@x.com or ftp://x.com or visit x.com".into()
)]
);
} }
#[test] #[test]
@@ -293,7 +310,11 @@ mod tests {
"weird))) http://c.com]]] tail", "weird))) http://c.com]]] tail",
"unicode 世界 http://d.com/路径 more 世界", "unicode 世界 http://d.com/路径 more 世界",
] { ] {
assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}"); assert_eq!(
reassemble(&linkify(msg)),
msg,
"roundtrip failed for {msg:?}"
);
} }
} }
} }
+41 -12
View File
@@ -158,9 +158,8 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
/// "no valid app selected" — the caller then shares the whole desktop audio. /// "no valid app selected" — the caller then shares the whole desktop audio.
pub fn sanitize_app_name(name: &str) -> Option<String> { pub fn sanitize_app_name(name: &str) -> Option<String> {
let name = name.trim(); let name = name.trim();
let ok = !name.is_empty() let ok =
&& name.len() <= MAX_APP_NAME_LEN !name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control());
&& !name.chars().any(|c| c.is_control());
ok.then(|| name.to_string()) ok.then(|| name.to_string())
} }
@@ -599,7 +598,10 @@ mod tests {
// i.e. after the `--` end-of-options guard, never parsed as a flag. // i.e. after the `--` end-of-options guard, never parsed as a flag.
let args = viewer_args("--malicious-flag"); let args = viewer_args("--malicious-flag");
assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last"); assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last");
let guard = args.iter().position(|a| a == "--").expect("`--` guard present"); let guard = args
.iter()
.position(|a| a == "--")
.expect("`--` guard present");
let ticket = args.len() - 1; let ticket = args.len() - 1;
assert!(guard < ticket, "ticket must follow the `--` guard"); assert!(guard < ticket, "ticket must follow the `--` guard");
// The real flags are parsed before the guard. // The real flags are parsed before the guard.
@@ -629,7 +631,13 @@ mod tests {
// `--strict-audio` so pixelpass never falls back to whole-desktop audio. // `--strict-audio` so pixelpass never falls back to whole-desktop audio.
assert_eq!( assert_eq!(
host_args(Some("Firefox")), host_args(Some("Firefox")),
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"] vec![
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio"
]
); );
// The hyphen-leading name is still bound to --app as a single token; // The hyphen-leading name is still bound to --app as a single token;
// --strict-audio is the trailing flag. // --strict-audio is the trailing flag.
@@ -643,12 +651,18 @@ mod tests {
// An empty / whitespace / control-laden selection is sanitized away, // An empty / whitespace / control-laden selection is sanitized away,
// falling back to whole-desktop capture rather than a broken flag. // falling back to whole-desktop capture rather than a broken flag.
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]); assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]); assert_eq!(
host_args(Some("bad\nname")),
vec!["--host", "--output", "json"]
);
} }
#[test] #[test]
fn sanitize_app_name_trims_and_rejects_garbage() { fn sanitize_app_name_trims_and_rejects_garbage() {
assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string())); assert_eq!(
sanitize_app_name(" Firefox \n"),
Some("Firefox".to_string())
);
assert_eq!(sanitize_app_name(""), None); assert_eq!(sanitize_app_name(""), None);
assert_eq!(sanitize_app_name(" "), None); assert_eq!(sanitize_app_name(" "), None);
assert_eq!(sanitize_app_name("a\tb"), None); assert_eq!(sanitize_app_name("a\tb"), None);
@@ -667,7 +681,11 @@ mod tests {
]"#; ]"#;
assert_eq!( assert_eq!(
parse_audio_apps(stdout), parse_audio_apps(stdout),
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()] vec![
"Firefox".to_string(),
"Spotify".to_string(),
"mpv".to_string()
]
); );
} }
@@ -754,13 +772,19 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
#[test] #[test]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() { fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm"; let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string())); assert_eq!(
sanitize_ticket(format!(" {ticket}\n")),
Some(ticket.to_string())
);
} }
#[test] #[test]
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() { fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
assert_eq!(sanitize_ticket("not-a-ticket".into()), None); assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None); assert_eq!(
sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))),
None
);
assert_eq!(sanitize_ticket("endpointabc-def".into()), None); assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
} }
@@ -784,7 +808,9 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
fn parses_connected_url() { fn parses_connected_url() {
assert_eq!( assert_eq!(
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#), parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
Some(PixelpassEvent::Connected("http://127.0.0.1:5500".to_string())) Some(PixelpassEvent::Connected(
"http://127.0.0.1:5500".to_string()
))
); );
} }
@@ -919,7 +945,10 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
let dir = Path::new("bin"); let dir = Path::new("bin");
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect(); let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
#[cfg(windows)] #[cfg(windows)]
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]); assert_eq!(
candidates,
vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]
);
#[cfg(not(windows))] #[cfg(not(windows))]
assert_eq!(candidates, vec![dir.join("pixelpass")]); assert_eq!(candidates, vec![dir.join("pixelpass")]);
} }
+6 -2
View File
@@ -211,7 +211,7 @@ impl AppTheme {
overlay: hex(0x6272a4), overlay: hex(0x6272a4),
text: hex(0xf8f8f2), text: hex(0xf8f8f2),
subtext: hex(0xbdc0d4), subtext: hex(0xbdc0d4),
blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent
lavender: hex(0x8be9fd), // cyan lavender: hex(0x8be9fd), // cyan
red: hex(0xff5555), red: hex(0xff5555),
maroon: hex(0xff79c6), // pink maroon: hex(0xff79c6), // pink
@@ -400,7 +400,11 @@ mod tests {
for theme in AppTheme::ALL { for theme in AppTheme::ALL {
let p = theme.palette(); let p = theme.palette();
let sub = contrast_ratio(p.subtext, p.base); let sub = contrast_ratio(p.subtext, p.base);
assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label()); assert!(
sub >= 3.0,
"{}: subtext contrast {sub:.2} < 3.0",
theme.label()
);
let accent = contrast_ratio(p.blue, p.base); let accent = contrast_ratio(p.blue, p.base);
assert!( assert!(
accent >= 3.0, accent >= 3.0,
+70 -75
View File
@@ -9,8 +9,8 @@ use iced::advanced::widget::{self, Widget};
use iced::advanced::{Layout, Shell}; use iced::advanced::{Layout, Shell};
use iced::widget::text_input; use iced::widget::text_input;
use iced::{ use iced::{
alignment, Background, Border, Color, Element, Event, Length, Padding, Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
Pixels, Point, Rectangle, Shadow, Size, Vector, Size, Vector, alignment,
}; };
use std::rc::Rc; use std::rc::Rc;
@@ -27,11 +27,7 @@ pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
(start != end).then(|| value.select(start, end).to_string()) (start != end).then(|| value.select(start, end).to_string())
} }
pub fn cut_selection( pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option<String>) {
value: &str,
start: usize,
end: usize,
) -> (Edit, Option<String>) {
let mut value = text_input::Value::new(value); let mut value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end); let (start, end) = normalized_range(&value, start, end);
@@ -80,18 +76,13 @@ pub fn select_all_range(value: &str) -> (usize, usize) {
(0, value.len()) (0, value.len())
} }
fn normalized_range( fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) {
value: &text_input::Value,
start: usize,
end: usize,
) -> (usize, usize) {
let len = value.len(); let len = value.len();
(start.min(end).min(len), start.max(end).min(len)) (start.min(end).min(len), start.max(end).min(len))
} }
type InputStyleFn<'a, Theme> = type InputStyleFn<'a, Theme> = Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
pub fn context_input<'a, Message, Theme, Renderer>( pub fn context_input<'a, Message, Theme, Renderer>(
placeholder: &str, placeholder: &str,
@@ -119,12 +110,8 @@ where
.locked(true) .locked(true)
} }
pub struct ContextInput< pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
'a, where
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
Theme: text_input::Catalog, Theme: text_input::Catalog,
Renderer: text::Renderer, Renderer: text::Renderer,
{ {
@@ -137,8 +124,7 @@ pub struct ContextInput<
style: Option<InputStyleFn<'a, Theme>>, style: Option<InputStyleFn<'a, Theme>>,
} }
impl<'a, Message, Theme, Renderer> impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
ContextInput<'a, Message, Theme, Renderer>
where where
Message: Clone + 'a, Message: Clone + 'a,
Theme: text_input::Catalog + 'a, Theme: text_input::Catalog + 'a,
@@ -172,16 +158,13 @@ where
self self
} }
pub fn on_input( pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
mut self, let on_input: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_input);
on_input: impl Fn(String) -> Message + 'a,
) -> Self {
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_input);
let input_callback = Rc::clone(&on_input); let input_callback = Rc::clone(&on_input);
self.input = self.input = self
self.input.on_input(move |value| input_callback.as_ref()(value)); .input
.on_input(move |value| input_callback.as_ref()(value));
self.on_input = Some(on_input); self.on_input = Some(on_input);
self self
} }
@@ -196,16 +179,13 @@ where
self self
} }
pub fn on_paste( pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
mut self, let on_paste: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_paste);
on_paste: impl Fn(String) -> Message + 'a,
) -> Self {
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_paste);
let paste_callback = Rc::clone(&on_paste); let paste_callback = Rc::clone(&on_paste);
self.input = self.input = self
self.input.on_paste(move |value| paste_callback.as_ref()(value)); .input
.on_paste(move |value| paste_callback.as_ref()(value));
self.on_paste = Some(on_paste); self.on_paste = Some(on_paste);
self self
} }
@@ -235,18 +215,12 @@ where
self self
} }
pub fn line_height( pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
mut self,
line_height: impl Into<text::LineHeight>,
) -> Self {
self.input = self.input.line_height(line_height); self.input = self.input.line_height(line_height);
self self
} }
pub fn align_x( pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
mut self,
alignment: impl Into<alignment::Horizontal>,
) -> Self {
self.input = self.input.align_x(alignment); self.input = self.input.align_x(alignment);
self self
} }
@@ -379,11 +353,9 @@ where
} }
}; };
tree.state.downcast_mut::<ContextInputState>().menu = tree.state.downcast_mut::<ContextInputState>().menu = cursor
cursor.position().map(|anchor| MenuState { .position()
anchor, .map(|anchor| MenuState { anchor, selection });
selection,
});
shell.capture_event(); shell.capture_event();
shell.request_redraw(); shell.request_redraw();
@@ -477,8 +449,7 @@ where
} }
} }
impl<'a, Message, Theme, Renderer> impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
From<ContextInput<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer> for Element<'a, Message, Theme, Renderer>
where where
Message: Clone + 'a, Message: Clone + 'a,
@@ -516,12 +487,7 @@ enum MenuAction {
} }
impl MenuAction { impl MenuAction {
const ALL: [Self; 4] = [ const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];
Self::Cut,
Self::Copy,
Self::Paste,
Self::SelectAll,
];
fn label(self) -> &'static str { fn label(self) -> &'static str {
match self { match self {
@@ -564,8 +530,7 @@ where
cursor: mouse::Cursor, cursor: mouse::Cursor,
) { ) {
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active); let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
let hovered_style = let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
let bounds = layout.bounds(); let bounds = layout.bounds();
let viewport = Rectangle::INFINITE; let viewport = Rectangle::INFINITE;
@@ -640,9 +605,7 @@ where
) { ) {
match event { match event {
Event::Keyboard(iced::keyboard::Event::KeyPressed { Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named( key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
iced::keyboard::key::Named::Escape,
),
.. ..
}) => { }) => {
self.close(shell); self.close(shell);
@@ -718,11 +681,7 @@ where
) )
} }
fn hit_action( fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
&self,
bounds: Rectangle,
position: Point,
) -> Option<MenuAction> {
if !bounds.contains(position) { if !bounds.contains(position) {
return None; return None;
} }
@@ -932,12 +891,48 @@ mod tests {
#[test] #[test]
fn locked_menu_allows_copy_and_select_all_only() { fn locked_menu_allows_copy_and_select_all_only() {
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true)); assert!(!menu_action_enabled(
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true)); MenuAction::Cut,
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true)); true,
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true)); true,
false,
true
));
assert!(menu_action_enabled(
MenuAction::Copy,
true,
true,
false,
true
));
assert!(!menu_action_enabled(
MenuAction::Paste,
true,
true,
false,
true
));
assert!(menu_action_enabled(
MenuAction::SelectAll,
true,
true,
false,
true
));
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true)); assert!(!menu_action_enabled(
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true)); MenuAction::Copy,
false,
true,
false,
true
));
assert!(!menu_action_enabled(
MenuAction::SelectAll,
false,
false,
false,
true
));
} }
} }
+46 -116
View File
@@ -3,16 +3,15 @@ use iced::advanced::layout;
use iced::advanced::mouse; use iced::advanced::mouse;
use iced::advanced::renderer; use iced::advanced::renderer;
use iced::advanced::text::{self as advanced_text, Paragraph, Span}; use iced::advanced::text::{self as advanced_text, Paragraph, Span};
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::widget::Widget; use iced::advanced::widget::Widget;
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::{Layout, Shell}; use iced::advanced::{Layout, Shell};
use iced::widget::text::{ use iced::widget::text::{
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping,
Wrapping,
}; };
use iced::{ use iced::{
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point, Background, Border, Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector,
Rectangle, Size, Vector, keyboard, alignment, keyboard,
}; };
const DRAG_THRESHOLD: f32 = 3.0; const DRAG_THRESHOLD: f32 = 3.0;
@@ -28,11 +27,7 @@ const HIT_SEARCH_STEPS: usize = 24;
// widget's per-line offsets would stop being global and selection/copy across // widget's per-line offsets would stop being global and selection/copy across
// lines would break — revisit then. // lines would break — revisit then.
pub fn selected_substring( pub fn selected_substring(text: &str, anchor: usize, cursor: usize) -> Option<String> {
text: &str,
anchor: usize,
cursor: usize,
) -> Option<String> {
let (start, end) = normalized_byte_range(text, anchor, cursor); let (start, end) = normalized_byte_range(text, anchor, cursor);
(start != end).then(|| text[start..end].to_owned()) (start != end).then(|| text[start..end].to_owned())
@@ -42,11 +37,7 @@ pub fn select_all(text: &str) -> (usize, usize) {
(0, text.len()) (0, text.len())
} }
fn normalized_byte_range( fn normalized_byte_range(text: &str, anchor: usize, cursor: usize) -> (usize, usize) {
text: &str,
anchor: usize,
cursor: usize,
) -> (usize, usize) {
let start = clamp_to_char_boundary(text, anchor.min(cursor)); let start = clamp_to_char_boundary(text, anchor.min(cursor));
let end = clamp_to_char_boundary(text, anchor.max(cursor)); let end = clamp_to_char_boundary(text, anchor.max(cursor));
@@ -75,13 +66,8 @@ where
SelectableRichText::with_spans(spans) SelectableRichText::with_spans(spans)
} }
pub struct SelectableRichText< pub struct SelectableRichText<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer>
'a, where
Link,
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
Link: Clone + 'static, Link: Clone + 'static,
Theme: Catalog, Theme: Catalog,
Renderer: advanced_text::Renderer, Renderer: advanced_text::Renderer,
@@ -101,8 +87,7 @@ pub struct SelectableRichText<
selection_color: Color, selection_color: Color,
} }
impl<'a, Link, Message, Theme, Renderer> impl<'a, Link, Message, Theme, Renderer> SelectableRichText<'a, Link, Message, Theme, Renderer>
SelectableRichText<'a, Link, Message, Theme, Renderer>
where where
Link: Clone + 'static, Link: Clone + 'static,
Theme: Catalog, Theme: Catalog,
@@ -127,9 +112,7 @@ where
} }
} }
pub fn with_spans( pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self {
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
) -> Self {
Self { Self {
spans: Box::new(spans), spans: Box::new(spans),
..Self::new() ..Self::new()
@@ -166,10 +149,7 @@ where
self self
} }
pub fn align_y( pub fn align_y(mut self, alignment: impl Into<alignment::Vertical>) -> Self {
mut self,
alignment: impl Into<alignment::Vertical>,
) -> Self {
self.align_y = alignment.into(); self.align_y = alignment.into();
self self
} }
@@ -179,10 +159,7 @@ where
self self
} }
pub fn on_link_click( pub fn on_link_click(mut self, on_link_click: impl Fn(Link) -> Message + 'a) -> Self {
mut self,
on_link_click: impl Fn(Link) -> Message + 'a,
) -> Self {
self.on_link_click = Some(Box::new(on_link_click)); self.on_link_click = Some(Box::new(on_link_click));
self self
} }
@@ -356,26 +333,16 @@ where
} }
for (index, span) in spans.iter().enumerate() { for (index, span) in spans.iter().enumerate() {
let is_hovered_link = self.on_link_click.is_some() let is_hovered_link = self.on_link_click.is_some() && Some(index) == self.hovered_link;
&& Some(index) == self.hovered_link;
if span.highlight.is_some() if span.highlight.is_some() || span.underline || span.strikethrough || is_hovered_link {
|| span.underline
|| span.strikethrough
|| is_hovered_link
{
let regions = state.paragraph.span_bounds(index); let regions = state.paragraph.span_bounds(index);
if let Some(highlight) = span.highlight { if let Some(highlight) = span.highlight {
for bounds in &regions { for bounds in &regions {
let bounds = Rectangle::new( let bounds = Rectangle::new(
bounds.position() bounds.position() - Vector::new(span.padding.left, span.padding.top),
- Vector::new( bounds.size() + Size::new(span.padding.x(), span.padding.y()),
span.padding.left,
span.padding.top,
),
bounds.size()
+ Size::new(span.padding.x(), span.padding.y()),
); );
renderer.fill_quad( renderer.fill_quad(
@@ -390,26 +357,17 @@ where
} }
if span.underline || span.strikethrough || is_hovered_link { if span.underline || span.strikethrough || is_hovered_link {
let size = span let size = span.size.or(self.size).unwrap_or(renderer.default_size());
.size
.or(self.size)
.unwrap_or(renderer.default_size());
let line_height = span let line_height = span
.line_height .line_height
.unwrap_or(self.line_height) .unwrap_or(self.line_height)
.to_absolute(size); .to_absolute(size);
let color = span let color = span.color.or(style.color).unwrap_or(defaults.text_color);
.color
.or(style.color)
.unwrap_or(defaults.text_color);
let baseline = translation let baseline =
+ Vector::new( translation + Vector::new(0.0, size.0 + (line_height.0 - size.0) / 2.0);
0.0,
size.0 + (line_height.0 - size.0) / 2.0,
);
if span.underline || is_hovered_link { if span.underline || is_hovered_link {
for bounds in &regions { for bounds in &regions {
@@ -497,13 +455,10 @@ where
state.dragging = true; state.dragging = true;
state.press_position = Some(position); state.press_position = Some(position);
state.span_pressed = self.hovered_link; state.span_pressed = self.hovered_link;
state.selection = state state.selection = state.paragraph.hit_test(position).map(|hit| {
.paragraph let offset = hit.cursor().min(flat_text.len());
.hit_test(position) (offset, offset)
.map(|hit| { });
let offset = hit.cursor().min(flat_text.len());
(offset, offset)
});
shell.capture_event(); shell.capture_event();
shell.request_redraw(); shell.request_redraw();
} else if state.active || state.selection.is_some() { } else if state.active || state.selection.is_some() {
@@ -521,8 +476,7 @@ where
&& let Some(hit) = state.paragraph.hit_test(position) && let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection && let Some((anchor, _)) = state.selection
{ {
state.selection = state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
Some((anchor, hit.cursor().min(flat_text.len())));
shell.request_redraw(); shell.request_redraw();
} }
} }
@@ -540,16 +494,14 @@ where
&& let Some(hit) = state.paragraph.hit_test(position) && let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection && let Some((anchor, _)) = state.selection
{ {
state.selection = state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
Some((anchor, hit.cursor().min(flat_text.len())));
} }
if !dragged { if !dragged {
if let (Some(on_link_clicked), Some(span)) = if let (Some(on_link_clicked), Some(span)) =
(&self.on_link_click, state.span_pressed) (&self.on_link_click, state.span_pressed)
&& Some(span) == self.hovered_link && Some(span) == self.hovered_link
&& let Some(link) = && let Some(link) = spans.get(span).and_then(|span| span.link.clone())
spans.get(span).and_then(|span| span.link.clone())
{ {
shell.publish(on_link_clicked(link)); shell.publish(on_link_clicked(link));
} }
@@ -570,25 +522,22 @@ where
physical_key, physical_key,
modifiers, modifiers,
.. ..
}) if state.active && modifiers.command() => { }) if state.active && modifiers.command() => match key.to_latin(*physical_key) {
match key.to_latin(*physical_key) { Some('c') | Some('C') => {
Some('c') | Some('C') => { if let Some((anchor, cursor)) = state.selection
if let Some((anchor, cursor)) = state.selection && let Some(selected) = selected_substring(&flat_text, anchor, cursor)
&& let Some(selected) = {
selected_substring(&flat_text, anchor, cursor) clipboard.write(clipboard::Kind::Standard, selected);
{
clipboard.write(clipboard::Kind::Standard, selected);
shell.capture_event();
}
}
Some('a') | Some('A') => {
state.selection = Some(select_all(&flat_text));
shell.capture_event(); shell.capture_event();
shell.request_redraw();
} }
_ => {}
} }
} Some('a') | Some('A') => {
state.selection = Some(select_all(&flat_text));
shell.capture_event();
shell.request_redraw();
}
_ => {}
},
_ => {} _ => {}
} }
} }
@@ -657,14 +606,8 @@ where
}; };
if state.spans != config.spans { if state.spans != config.spans {
state.paragraph = state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
Renderer::Paragraph::with_spans(text_with_spans()); state.spans = config.spans.iter().cloned().map(Span::to_static).collect();
state.spans = config
.spans
.iter()
.cloned()
.map(Span::to_static)
.collect();
} else { } else {
match state.paragraph.compare(advanced_text::Text { match state.paragraph.compare(advanced_text::Text {
content: (), content: (),
@@ -682,8 +625,7 @@ where
state.paragraph.resize(bounds); state.paragraph.resize(bounds);
} }
advanced_text::Difference::Shape => { advanced_text::Difference::Shape => {
state.paragraph = state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
Renderer::Paragraph::with_spans(text_with_spans());
} }
} }
} }
@@ -761,13 +703,7 @@ fn selection_rect_for_line<P: Paragraph>(
}) })
} }
fn x_for_offset<P: Paragraph>( fn x_for_offset<P: Paragraph>(paragraph: &P, y: f32, offset: usize, left: f32, right: f32) -> f32 {
paragraph: &P,
y: f32,
offset: usize,
left: f32,
right: f32,
) -> f32 {
let mut low = left; let mut low = left;
let mut high = right.max(left); let mut high = right.max(left);
@@ -787,10 +723,7 @@ fn x_for_offset<P: Paragraph>(
high high
} }
fn visual_lines<P: Paragraph>( fn visual_lines<P: Paragraph>(paragraph: &P, span_count: usize) -> Vec<Rectangle> {
paragraph: &P,
span_count: usize,
) -> Vec<Rectangle> {
let mut lines: Vec<Rectangle> = Vec::new(); let mut lines: Vec<Rectangle> = Vec::new();
for span in 0..span_count { for span in 0..span_count {
@@ -820,10 +753,7 @@ fn union(a: Rectangle, b: Rectangle) -> Rectangle {
let right = (a.x + a.width).max(b.x + b.width); let right = (a.x + a.width).max(b.x + b.width);
let bottom = (a.y + a.height).max(b.y + b.height); let bottom = (a.y + a.height).max(b.y + b.height);
Rectangle::new( Rectangle::new(Point::new(left, top), Size::new(right - left, bottom - top))
Point::new(left, top),
Size::new(right - left, bottom - top),
)
} }
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> { fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
+10 -3
View File
@@ -21,7 +21,7 @@ use iroh::endpoint::presets;
use iroh::protocol::Router; use iroh::protocol::Router;
use iroh::{Endpoint, RelayMode}; use iroh::{Endpoint, RelayMode};
use peerspeak::files::{ChatAttachment, AttachmentKind}; use peerspeak::files::{AttachmentKind, ChatAttachment};
use peerspeak::network::NetworkTransport; use peerspeak::network::NetworkTransport;
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport}; use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
use peerspeak::protocol::FILES_ALPN; use peerspeak::protocol::FILES_ALPN;
@@ -52,7 +52,12 @@ async fn spawn_node() -> Node {
.accept(FILES_ALPN, file_router) .accept(FILES_ALPN, file_router)
.spawn(); .spawn();
Node { endpoint, transport, _router: router, lookup } Node {
endpoint,
transport,
_router: router,
lookup,
}
} }
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a /// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
@@ -86,7 +91,9 @@ async fn loopback_attachment_round_trips_intact() {
let blob = big_blob(); let blob = big_blob();
let id = [42u8; 32]; let id = [42u8; 32];
server.transport.serve_attachment(id, Arc::new(blob.clone())); server
.transport
.serve_attachment(id, Arc::new(blob.clone()));
let att = ChatAttachment { let att = ChatAttachment {
name: "exterior-landscape.jpg".to_string(), name: "exterior-landscape.jpg".to_string(),
+2 -2
View File
@@ -62,7 +62,7 @@ async fn evicted_within(
Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true, Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true,
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft
Ok(None) => return false, // channel closed Ok(None) => return false, // channel closed
Err(_) => return false, // timed out — no eviction Err(_) => return false, // timed out — no eviction
} }
} }
} }
@@ -80,7 +80,7 @@ async fn left_within(
Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true, Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true,
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected
Ok(None) => return false, // channel closed Ok(None) => return false, // channel closed
Err(_) => return false, // timed out — no leave Err(_) => return false, // timed out — no leave
} }
} }
} }
+53 -11
View File
@@ -23,8 +23,8 @@ use iroh::protocol::{AcceptError, ProtocolHandler};
use peerspeak::codec::AudioEncoder; use peerspeak::codec::AudioEncoder;
use peerspeak::codec::opus_impl::OpusEncoder; use peerspeak::codec::opus_impl::OpusEncoder;
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer}; use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport}; use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::protocol::AUDIO_ALPN; use peerspeak::protocol::AUDIO_ALPN;
struct Node { struct Node {
@@ -91,7 +91,12 @@ async fn spawn_capture_peer(secret: iroh::SecretKey) -> CapturePeer {
.accept(AUDIO_ALPN, CaptureProtocol { conns_tx }) .accept(AUDIO_ALPN, CaptureProtocol { conns_tx })
.spawn(); .spawn();
CapturePeer { endpoint, _router: router, lookup, conns_rx } CapturePeer {
endpoint,
_router: router,
lookup,
conns_rx,
}
} }
/// Spawn a node with a specific secret key. Reusing a key gives the respawned /// Spawn a node with a specific secret key. Reusing a key gives the respawned
@@ -208,7 +213,11 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
received += 1; received += 1;
if let Some(frame) = jitter.pop_frame() { if let Some(frame) = jitter.pop_frame() {
assert_eq!(frame.len(), FRAME_SAMPLES, "decoded frame is one 20ms frame"); assert_eq!(
frame.len(),
FRAME_SAMPLES,
"decoded frame is one 20ms frame"
);
decoded_frames += 1; decoded_frames += 1;
} }
if received >= N { if received >= N {
@@ -290,7 +299,11 @@ async fn dialer_reconnects_after_link_drops() {
.expect("timed out awaiting initial connection") .expect("timed out awaiting initial connection")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(10)
)
.await,
"initial link should report Connecting then Connected" "initial link should report Connecting then Connected"
); );
@@ -306,7 +319,11 @@ async fn dialer_reconnects_after_link_drops() {
.expect("timed out awaiting reconnect") .expect("timed out awaiting reconnect")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(15)
)
.await,
"dropped link should report Connecting (down) then Connected (recovered)" "dropped link should report Connecting (down) then Connected (recovered)"
); );
@@ -319,7 +336,12 @@ async fn dialer_reconnects_after_link_drops() {
tokio::time::sleep(Duration::from_millis(5)).await; tokio::time::sleep(Duration::from_millis(5)).await;
} }
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await; let received = count_audio(
&conn2,
25,
tokio::time::Instant::now() + Duration::from_secs(3),
)
.await;
assert!( assert!(
received >= 20, received >= 20,
"audio should resume after reconnect; got {received} frames" "audio should resume after reconnect; got {received} frames"
@@ -359,7 +381,11 @@ async fn dialer_reports_left_on_graceful_close() {
.expect("timed out awaiting initial connection") .expect("timed out awaiting initial connection")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(10)
)
.await,
"initial link should report Connecting then Connected" "initial link should report Connecting then Connected"
); );
@@ -384,7 +410,10 @@ async fn dialer_reports_left_on_graceful_close() {
// And no re-dial reaches the peer within a short window. // And no re-dial reaches the peer within a short window.
let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await; let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await;
assert!(redial.is_err(), "supervisor must not re-dial after a graceful leave"); assert!(
redial.is_err(),
"supervisor must not re-dial after a graceful leave"
);
} }
#[tokio::test] #[tokio::test]
@@ -429,7 +458,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
.expect("timed out awaiting initial connection (retained address path)") .expect("timed out awaiting initial connection (retained address path)")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(10)
)
.await,
"initial link should report Connecting then Connected" "initial link should report Connecting then Connected"
); );
@@ -443,7 +476,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
.expect("timed out awaiting reconnect (retained address path)") .expect("timed out awaiting reconnect (retained address path)")
.expect("connection channel closed"); .expect("connection channel closed");
assert!( assert!(
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await, await_reconnect(
&mut conn_events,
tokio::time::Instant::now() + Duration::from_secs(15)
)
.await,
"reconnect should report Connecting then Connected with no lookup at all" "reconnect should report Connecting then Connected with no lookup at all"
); );
@@ -455,7 +492,12 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
tokio::time::sleep(Duration::from_millis(5)).await; tokio::time::sleep(Duration::from_millis(5)).await;
} }
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await; let received = count_audio(
&conn2,
25,
tokio::time::Instant::now() + Duration::from_secs(3),
)
.await;
assert!( assert!(
received >= 20, received >= 20,
"audio should resume after reconnecting via the retained address; got {received} frames" "audio should resume after reconnecting via the retained address; got {received} frames"