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>| {
ranges
.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()
};
@@ -666,15 +668,30 @@ fn run_capture(
let device_rate = config.sample_rate.0;
let stream = match sample_format {
SampleFormat::F32 => build_input::<f32, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
&device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::I16 => build_input::<i16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
&device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::U16 => build_input::<u16, _>(
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
&device,
&config,
producer,
channels,
overrun.clone(),
callbacks.clone(),
err_code.clone(),
),
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).
let ec = err_code.load(Ordering::Relaxed);
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;
}
if !drained {
@@ -914,16 +934,34 @@ fn run_playback(
let device_rate = config.sample_rate.0;
let stream = match sample_format {
SampleFormat::F32 => build_output::<f32, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
max_cb.clone(), callbacks.clone(), err_code.clone(),
&device,
&config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::I16 => build_output::<i16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
max_cb.clone(), callbacks.clone(), err_code.clone(),
&device,
&config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
),
SampleFormat::U16 => build_output::<u16, _>(
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
max_cb.clone(), callbacks.clone(), err_code.clone(),
&device,
&config,
consumer,
ring_fill.clone(),
underrun.clone(),
max_cb.clone(),
callbacks.clone(),
err_code.clone(),
),
other => Err(AudioError::Stream(format!(
"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).
let ec = err_code.load(Ordering::Relaxed);
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;
}
// 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(&self.module_index)
.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
/// 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.
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
// don't stack duplicate modules / fight over the virtual node names.
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() {
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 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`.
/// `kind` is "sources" or "sinks".
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;
};
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
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
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;
};
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)
&& 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}"));
}
}
@@ -198,13 +221,25 @@ mod tests {
let guard = enable(None, None).expect("module-echo-cancel should load");
let source_name = guard.source_name().to_string();
let sink_name = guard.sink_name().to_string();
assert!(node_present("sources", &source_name), "cleaned source must exist");
assert!(node_present("sinks", &sink_name), "reference sink must exist");
assert!(
node_present("sources", &source_name),
"cleaned source must exist"
);
assert!(
node_present("sinks", &sink_name),
"reference sink must exist"
);
drop(guard);
// Give pactl a moment to tear the nodes down.
std::thread::sleep(Duration::from_millis(300));
assert!(!node_present("sources", &source_name), "source must be gone after unload");
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
assert!(
!node_present("sources", &source_name),
"source must be gone after unload"
);
assert!(
!node_present("sinks", &sink_name),
"sink must be gone after unload"
);
}
#[test]
@@ -220,7 +255,10 @@ mod tests {
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
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]
+17 -9
View File
@@ -251,7 +251,10 @@ mod tests {
let before = rms(&low);
eq.process_frame(&mut 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]
@@ -264,7 +267,10 @@ mod tests {
let before = rms(&high);
eq.process_frame(&mut 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]
@@ -275,7 +281,10 @@ mod tests {
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_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);
eq.process_frame(&mut frame);
let peak = frame
.iter()
.map(|&s| i32::from(s).abs())
.max()
.unwrap_or(0);
assert!(peak > 1_000, "processed signal should retain audible energy");
let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0);
assert!(
peak > 1_000,
"processed signal should retain audible energy"
);
assert!(
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
"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");
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]
@@ -179,8 +182,15 @@ mod tests {
g.process(&mut f, 0.05);
// 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).
assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]);
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame");
assert!(
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]
@@ -193,8 +203,14 @@ mod tests {
}
// First quiet frame right after speech: hold keeps it open (not chopped).
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!(peak(&q) > 0, "held-open frame must not be silenced immediately");
assert!(
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.
let mut closed = false;
@@ -205,7 +221,10 @@ mod tests {
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]
@@ -216,8 +235,14 @@ mod tests {
g.process(&mut f, 0.05); // open=0.05, close=0.03
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
let mut mid = frame(1310);
assert!(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");
assert!(
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]
@@ -225,7 +250,10 @@ mod tests {
let mut g = NoiseGate::new(SR);
// Never opened; feed silence — should report don't-transmit promptly.
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]
@@ -266,7 +294,11 @@ mod tests {
let mut f2 = frame(10000);
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]
@@ -302,7 +334,10 @@ mod tests {
let mut f = frame(1310);
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]
@@ -333,6 +368,10 @@ mod tests {
last_peak = peak(&f);
}
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 ceiling = lim.ceiling().ceil() as i16;
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}");
}
}
@@ -175,7 +178,10 @@ mod tests {
let out_pos = lim.process(&pos_loud, 1.0);
for &s in &out_pos {
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
@@ -185,7 +191,10 @@ mod tests {
let neg_ceiling = -ceiling_ceil;
for &s in &out_neg {
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);
for &s in &out {
assert!(s > 0, "positive stays positive");
assert!(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}");
assert!(
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);
for (i, &s) in out.iter().enumerate() {
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,
@@ -230,7 +248,12 @@ mod tests {
let loud = vec![200_000i32; 10];
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.
@@ -247,13 +270,23 @@ mod tests {
// Output should be monotonic (non-decreasing)
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
let start_diff = (mid_val as i16 - out[0]).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.
@@ -265,7 +298,11 @@ mod tests {
// Immediately follow with a sub-ceiling sample
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.
@@ -287,7 +324,10 @@ mod tests {
let mut out_split = out_split1;
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
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_fresh = lim_fresh.process(&test_input, 1.0);
assert_ne!(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]);
assert_ne!(
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.
@@ -320,7 +368,10 @@ mod tests {
// Gain 0.0
let out_zero = lim.process(&input, 0.0);
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
let out_unity = lim.process(&input, 1.0);
@@ -354,6 +405,9 @@ mod tests {
let out = lim.process(&input, 1.0);
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::atomic::AtomicUsize;
use std::sync::mpsc::{Receiver, Sender};
use thiserror::Error;
/// Playback output channel count. Capture/encode/network remain mono; only the
@@ -35,7 +35,11 @@ pub enum AudioError {
pub trait AudioBackend: Send + Sync {
/// Starts capturing raw PCM audio from the input device (microphone),
/// 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),
/// 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
// pure, so it builds (and its tests run) everywhere even though only the cpal
// backend wires it in.
pub mod resample;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod pw_cli;
pub mod recorder;
pub mod resample;
/// A selectable audio device for the input/output pickers. `name` is the stable
/// 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),
// returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)]
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.
///
+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 mut slug: String = clean
.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();
// Collapse runs of '-' and trim them off the ends.
while slug.contains("--") {
@@ -284,7 +290,10 @@ mod tests {
let short: String = id.to_string().chars().take(8).collect();
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
// 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".
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
}
@@ -327,10 +336,18 @@ mod tests {
rec.finalize().unwrap();
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(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]
@@ -357,8 +374,14 @@ mod tests {
rec.finalize().unwrap();
// 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!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame);
assert_eq!(
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.
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
@@ -378,6 +401,9 @@ mod tests {
rec.end_cycle().unwrap();
rec.finalize().unwrap();
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.
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
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)]
@@ -36,8 +39,14 @@ mod tests {
fn hard_left_and_right_are_endpoints() {
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
let (l, r) = pan_gains(1.0);
assert!(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}");
assert!(
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]
@@ -55,8 +64,14 @@ mod tests {
let mut prev_r = f32::NEG_INFINITY;
for pan in pans {
let (l, r) = pan_gains(pan);
assert!(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");
assert!(
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_r = r;
}
+66 -44
View File
@@ -1,13 +1,16 @@
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 pw::{properties::properties, spa};
use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
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 {
capture_state: Mutex<Option<CaptureState>>,
@@ -41,7 +44,11 @@ impl 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();
if capture_guard.is_some() {
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> {
let mainloop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| AudioError::Init(e.to_string()))?;
fn run_capture(
cmd_rx: pw::channel::Receiver<()>,
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)
.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()))?;
// 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()];
stream.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
stream
.connect(
spa::utils::Direction::Input,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn the worker thread to pop from consumer and send Vec<i16> frames
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()`
/// 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.
fn drain_loop(
rx: &Receiver<Vec<i16>>,
running: &AtomicBool,
mut on_frame: impl FnMut(Vec<i16>),
) {
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
while running.load(Ordering::Relaxed) {
match rx.recv_timeout(WORKER_POLL) {
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 {
/// Safe per-cycle fallback when the graph doesn't report a quantum.
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)
}
@@ -303,11 +316,12 @@ fn run_playback(
target_node: Option<String>,
fill_gauge: Arc<AtomicUsize>,
) -> Result<(), AudioError> {
let mainloop = pw::main_loop::MainLoopRc::new(None)
.map_err(|e| AudioError::Init(e.to_string()))?;
let mainloop =
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
let context = pw::context::ContextRc::new(&mainloop, None)
.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()))?;
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
@@ -428,7 +442,9 @@ fn run_playback(
}
if starved > 0 {
// 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
// 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::utils::Choice(
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(),
];
stream.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
stream
.connect(
spa::utils::Direction::Output,
None,
pw::stream::StreamFlags::AUTOCONNECT
| pw::stream::StreamFlags::MAP_BUFFERS
| pw::stream::StreamFlags::RT_PROCESS,
&mut params,
)
.map_err(|e| AudioError::Stream(e.to_string()))?;
// Spawn a worker thread to read from rx and push to producer
let running = Arc::new(AtomicBool::new(true));
@@ -607,7 +628,10 @@ fn run_playback(
#[cfg(test)]
mod tests {
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::{Arc, Mutex};
use std::time::Duration;
@@ -647,9 +671,7 @@ mod tests {
#[test]
fn capture_size_larger_than_mapping_is_clamped() {
let mut samples = Vec::new();
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
samples.push(sample)
});
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample));
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/*
/// node, then resets the accumulators for the next block. Non-audio or
/// 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/") {
out.push(AudioDevice {
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",
});
}
@@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
for line in text.lines() {
let line = line.trim();
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 = \"") {
current_name = val.trim_end_matches('"').to_string();
} 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();
}
}
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
@@ -108,8 +127,14 @@ mod tests {
fn source_is_input_sink_is_output() {
let devices = parse_pw_nodes(SAMPLE_NODES);
// Find devices by name or description to verify is_input
let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap();
let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap();
let mic = devices
.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();
assert!(mic.is_input);
+8 -2
View File
@@ -145,7 +145,10 @@ impl StereoPullResampler {
self.frac -= 1.0;
}
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;
Some(out)
}
@@ -273,7 +276,10 @@ mod tests {
}
}
// 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
+12 -3
View File
@@ -206,7 +206,10 @@ pub struct ByteLru<V> {
impl<V: Clone> ByteLru<V> {
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
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
@@ -349,7 +352,10 @@ mod tests {
fn preset_png_in_range_and_out_of_range() {
// Every declared preset index resolves to embedded bytes.
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).
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
@@ -406,7 +412,10 @@ mod tests {
#[test]
fn sanitize_incoming_rejects_junk_and_oversize() {
// 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.
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
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
/// per theme since `base` comes from the active palette.
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)]
+58 -12
View File
@@ -105,7 +105,11 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
"pink" => generators::pink_noise(amp, len, seed),
"impulse" => generators::impulse(amp, 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)?;
@@ -125,8 +129,12 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
/// in which frequency range any residual lives.
fn cmd_erle(args: &[String]) -> Result<(), String> {
let (positional, flags) = parse_args(args);
let before = positional.first().ok_or("erle needs <before.wav> <after.wav>")?;
let after = positional.get(1).ok_or("erle needs <before.wav> <after.wav>")?;
let before = positional
.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 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,
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 {
let dtd = if flags.present("no-dtd") { "off" } else { "on" };
println!(
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
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!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual)));
println!(
" 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 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") {
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) ---");
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(())
}
@@ -339,13 +376,22 @@ impl Flags {
self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
}
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 {
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 {
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_gossip::net::Gossip;
use peerspeak::network::{PeerState, RoomState, gossip::IrohGossipState};
use tokio::time::{self, Duration};
#[tokio::main]
@@ -18,7 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.address_lookup(lookup_a.clone())
.bind()
.await?;
endpoint_a.online().await;
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())
.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
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())
.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
let topic_id = rand::random();
+63 -17
View File
@@ -1,5 +1,5 @@
use crate::codec::{AudioEncoder, AudioDecoder, CodecError};
use opus::{Encoder, Decoder, Application, Channels};
use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
use opus::{Application, Channels, Decoder, Encoder};
pub struct OpusEncoder {
encoder: Encoder,
@@ -8,7 +8,11 @@ pub struct OpusEncoder {
impl OpusEncoder {
/// Creates a new Opus encoder.
/// 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)
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
Ok(Self { encoder })
@@ -20,9 +24,11 @@ impl AudioEncoder for OpusEncoder {
// We allocate a buffer for the compressed output.
// A maximum packet size of 4000 bytes is more than enough for a single voice frame.
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)))?;
compressed.truncate(len);
Ok(compressed)
}
@@ -42,10 +48,18 @@ impl OpusDecoder {
/// Creates a new Opus decoder.
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
/// `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)
.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 {
@@ -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)))?;
pcm.truncate(decoded_per_channel * channels_count);
@@ -100,7 +116,10 @@ mod tests {
// encode it
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!(
compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
"Compressed size ({}) should be smaller than raw PCM size ({})",
@@ -110,13 +129,21 @@ mod tests {
// decode it
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)
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
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)
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]
@@ -125,11 +152,19 @@ mod tests {
// decode(None) returns exactly frame_samples (960) samples
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
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]
@@ -140,7 +175,11 @@ mod tests {
let pcm = vec![0i16; 960];
let compressed = encoder.encode(&pcm).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]
@@ -149,11 +188,18 @@ mod tests {
// decode(None) returns exactly frame_samples * 2 (1920) samples
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
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 {
/// All variants, for presentation in a picker.
pub const ALL: [NetworkMode; 3] =
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
pub const ALL: [NetworkMode; 3] = [
NetworkMode::RelayNoDiscovery,
NetworkMode::N0Full,
NetworkMode::DirectOnly,
];
}
/// Arrangement of the in-call room screen, chosen via the layout picker.
@@ -45,8 +48,11 @@ pub enum RoomLayout {
impl RoomLayout {
/// All variants, in picker display order.
pub const ALL: [RoomLayout; 3] =
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
pub const ALL: [RoomLayout; 3] = [
RoomLayout::ThreeColumn,
RoomLayout::BottomDock,
RoomLayout::Drawer,
];
}
/// What a call recording captures. `Mixed` is the original single-file behaviour;
@@ -65,8 +71,11 @@ pub enum RecordingMode {
impl RecordingMode {
/// All variants, in picker display order.
pub const ALL: [RecordingMode; 3] =
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both];
pub const ALL: [RecordingMode; 3] = [
RecordingMode::Mixed,
RecordingMode::Multitrack,
RecordingMode::Both,
];
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
pub fn is_multitrack(self) -> bool {
@@ -707,7 +716,10 @@ mod tests {
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
// 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.notifications_enabled);
// 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
// upgrade is silent-change-free.
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.
let mut cfg = AppConfig::default();
@@ -782,7 +797,10 @@ mod tests {
}"#;
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
// 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);
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
assert!(!cfg.game_presence_enabled);
@@ -794,9 +812,12 @@ mod tests {
fn test_game_maps_serialize_deterministically() {
// BTreeMap ordering makes the serialized config stable across runs.
let mut cfg = AppConfig::default();
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
cfg.game_backgrounds
.insert("steam:730".into(), "/a.png".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();
// Keys appear in sorted order (exe: before steam:).
let bg = json.find("game_backgrounds").unwrap();
@@ -854,8 +875,7 @@ mod tests {
recording_mode: RecordingMode::Both,
..AppConfig::default()
};
let back: AppConfig =
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(back.recording_mode, RecordingMode::Both);
// is_multitrack() classifies correctly.
assert!(!RecordingMode::Mixed.is_multitrack());
@@ -941,7 +961,10 @@ mod tests {
assert_eq!(round_tripped.input_volume, 1.5);
assert_eq!(round_tripped.output_volume, 0.25);
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!(round_tripped.music_broadcast);
assert!(!round_tripped.show_player_bar);
@@ -950,7 +973,8 @@ mod tests {
#[test]
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();
assert!(config_missing.notifications_enabled);
@@ -1009,7 +1033,10 @@ mod tests {
// Assert that deserialization succeeds even with unrecognized/unknown fields.
// 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();
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
/// 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).
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,
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
/// 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
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
/// 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,
/// since the endpoint is (re)built then.
SetNetworkMode(NetworkMode),
@@ -56,22 +67,40 @@ pub enum CoreCommand {
/// 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
/// 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
/// (used for on-demand file/chip downloads; images are auto-fetched on
/// 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
/// 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.
ForgetMusicTrack(crate::files::AttachmentId),
/// Set (or clear) our broadcast music timeline and re-announce presence.
SetMusicPresence(Option<crate::network::MusicPresence>),
/// 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.
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`).
/// Sent at startup so screen-share can resolve the binary.
SetPixelpassPath(Option<String>),
@@ -84,7 +113,9 @@ pub enum CoreCommand {
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
/// 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
/// ticket. No-op when not sharing.
StopScreenShare,
@@ -98,7 +129,11 @@ pub enum CoreCommand {
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and
/// 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.
AddFriend { id: EndpointId, name: String, addr: Option<EndpointAddr> },
AddFriend {
id: EndpointId,
name: String,
addr: Option<EndpointAddr>,
},
/// Remove a friend by id (W7).
RemoveFriend(EndpointId),
/// Locally rename a friend (W7).
@@ -189,10 +224,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
from: _,
attachment: _,
}
| CoreCommand::ServeMusicTrack {
id: _,
data: _,
}
| CoreCommand::ServeMusicTrack { id: _, data: _ }
| CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic {
@@ -272,10 +304,7 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
from: _,
attachment: _,
}
| CoreCommand::ServeMusicTrack {
id: _,
data: _,
}
| CoreCommand::ServeMusicTrack { id: _, data: _ }
| CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic {
@@ -311,55 +340,106 @@ pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
#[derive(Debug, Clone)]
pub enum UiEvent {
RoomJoined { ticket: String, self_id: String },
RoomJoined {
ticket: String,
self_id: String,
},
RoomLeft,
/// Clear room-scoped UI state after a failed in-call room switch, without a
/// leave chime. The persistent identity remains unchanged.
RoomReset,
PeerJoined { id: EndpointId, state: PeerState },
PeerLeft { id: EndpointId },
PeerJoined {
id: EndpointId,
state: PeerState,
},
PeerLeft {
id: EndpointId,
},
/// The fixed reconnect grace expired and bounded background gossip recovery
/// has started. This is non-terminal and must not play the failure chime.
PeerRecoveryStarted { id: EndpointId },
PeerConnectionFailed { id: EndpointId },
PeerUpdated { id: EndpointId, state: PeerState },
PeerRecoveryStarted {
id: EndpointId,
},
PeerConnectionFailed {
id: EndpointId,
},
PeerUpdated {
id: EndpointId,
state: PeerState,
},
/// 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.
PeerConnected { id: EndpointId },
PeerConnected {
id: EndpointId,
},
AudioLevels(Vec<(EndpointId, f32)>),
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
/// for the settings level meter. Throttled to ~10/sec.
MicLevel(f32),
/// Call recording started; carries the absolute WAV path being written.
RecordingStarted { path: String },
RecordingStarted {
path: String,
},
/// 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
/// messages are echoed by the UI on send). `from` is the sender's node id
/// 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
/// 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
/// disambiguates whose bytes these are and stops content aliasing (Tier C
/// 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.).
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.
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.
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.).
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
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
/// playing or enumeration isn't available. `app_audio_supported` reports
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
/// (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).
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".
ScreenShareStarted,
/// 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
/// outside the replay freshness window. `peer_ahead` describes the peer's
/// 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
/// 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
/// 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.
/// `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
/// 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
/// (malformed) — the GUI shows a degraded warning and disables edits so we never
/// 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
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
/// 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
/// per-friend `FriendPresence` already emitted). Lets the GUI clear 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
/// failure, this carries the previous truthful mode. The GUI must mirror +
/// 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
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
/// is detected. The GUI uses the stable `id` to switch the per-game background
@@ -413,7 +508,7 @@ pub enum UiEvent {
#[cfg(test)]
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::presence::PresenceMode;
use iroh::{EndpointId, SecretKey};
+344 -145
View File
@@ -1,31 +1,33 @@
pub mod messages;
pub mod jitter;
pub mod messages;
mod recovery;
use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::eq::{Eq, EqSettings};
use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
use crate::network::{
NetworkTransport, RoomState, SelfPresence, RoomEvent, ConnEvent, PeerSpeakTicket,
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
gossip::IrohGossipState,
};
use crate::core::jitter::{FRAME_SAMPLES, JitterBuffer};
use crate::core::messages::{
CoalesceKey, CoreCommand, DeliveryClass, UiEvent, coalesce_key, delivery_class,
};
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::presence::PresenceMode;
use crate::audio::multitrack::MultitrackRecorder;
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router};
use iroh::{
Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router,
};
use iroh_gossip::net::Gossip;
use tokio::sync::{mpsc, Mutex};
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex as StdMutex};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::sync::{Mutex, mpsc};
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.
type SeenConnected = Arc<std::sync::Mutex<HashSet<EndpointId>>>;
type KnownPeers =
Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
type KnownPeers = Arc<std::sync::Mutex<HashMap<[u8; 32], HashMap<EndpointId, EndpointAddr>>>>;
/// Per-topic cap on the retained rejoin-bootstrap / recovery target table
/// (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);
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;
};
if !recovery.coordinator.is_active(&peer_id) {
@@ -335,13 +338,17 @@ fn arm_grace_timer(
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;
};
match recovery.coordinator.activate(peer_id, addr) {
Ok(true) => {
let _ = ui_evict.send(UiEvent::PeerRecoveryStarted { id: peer_id }).await;
let _ = ui_evict
.send(UiEvent::PeerRecoveryStarted { id: peer_id })
.await;
}
Ok(false) => {}
Err(()) => {
@@ -349,7 +356,9 @@ fn arm_grace_timer(
"Cannot recover peer {:?}: recovery coordinator unavailable",
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
/// mixed. The map key must be the same `EndpointId` used for the jitter buffer.
fn apply_peer_volume(
frame: &mut [i16],
peer_id: EndpointId,
volumes: &HashMap<EndpointId, f32>,
) {
fn apply_peer_volume(frame: &mut [i16], peer_id: EndpointId, volumes: &HashMap<EndpointId, f32>) {
let vol = volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(frame, vol);
}
@@ -670,7 +675,8 @@ impl ActiveSession {
crate::log_msg("Stopping audio backend...");
let _ = audio_backend_clone.stop();
crate::log_msg("Audio backend stopped");
}).await;
})
.await;
// Unload the echo-cancel module now that the audio streams releasing its
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
@@ -744,7 +750,8 @@ fn apply_discovery(
plan: crate::discovery::LookupPlan,
) -> Result<(), anyhow::Error> {
use iroh::address_lookup::{
AddressLookupBuilder, dns::DnsAddressLookup,
AddressLookupBuilder,
dns::DnsAddressLookup,
pkarr::{PkarrPublisher, PkarrResolver},
};
let services = endpoint.address_lookup()?;
@@ -810,7 +817,11 @@ async fn build_net_stack(
.await?;
// 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
// 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-
/// fetches (Tier C F-02). Bounded by [`MAX_INFLIGHT_ATTACHMENT_FETCHES`].
type InflightAttachments =
Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
type InflightAttachments = Arc<std::sync::Mutex<HashSet<(EndpointId, crate::files::AttachmentId)>>>;
/// 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
@@ -921,12 +931,20 @@ fn spawn_attachment_fetch(
return;
}
let _ = ui_tx
.send(UiEvent::AttachmentReady { from, id: att.id, data })
.send(UiEvent::AttachmentReady {
from,
id: att.id,
data,
})
.await;
}
Err(e) => {
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;
}
}
@@ -943,13 +961,15 @@ fn spawn_music_fetch(
tokio::spawn(async move {
match transport.fetch_blob(from, id, size).await {
Ok(data) => {
let _ = ui_tx
.send(UiEvent::MusicReady { from, id, data })
.await;
let _ = ui_tx.send(UiEvent::MusicReady { from, id, data }).await;
}
Err(e) => {
let _ = ui_tx
.send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() })
.send(UiEvent::MusicFetchFailed {
from,
id,
error: e.to_string(),
})
.await;
}
}
@@ -972,7 +992,11 @@ fn spawn_music_prefetch(
}
Err(e) => {
let _ = ui_tx
.send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() })
.send(UiEvent::MusicFetchFailed {
from,
id,
error: e.to_string(),
})
.await;
}
}
@@ -1024,13 +1048,14 @@ async fn persist_and_emit_friends(
ui_tx: &mpsc::Sender<UiEvent>,
) {
let store = friends.lock().unwrap().clone();
if !read_only
&& let Err(e) = crate::friends::save(&store)
{
if !read_only && let Err(e) = crate::friends::save(&store) {
crate::log_msg(&format!("friends: save failed: {e:#}"));
}
let _ = ui_tx
.send(UiEvent::FriendsUpdated { friends: store.friends, read_only })
.send(UiEvent::FriendsUpdated {
friends: store.friends,
read_only,
})
.await;
}
@@ -1063,7 +1088,10 @@ async fn probe_friends_once(
let Some(addr) = f.last_addr.clone() else {
// Nothing to dial yet — report Offline so a prior status can't stick.
let _ = ui_tx
.send(UiEvent::FriendPresence { id, presence: crate::presence::FriendPresence::Offline })
.send(UiEvent::FriendPresence {
id,
presence: crate::presence::FriendPresence::Offline,
})
.await;
continue;
};
@@ -1215,7 +1243,9 @@ async fn run_core_loop(
let friends = Arc::new(std::sync::Mutex::new(initial_friends));
// Our presence posture, shared with the listener + scheduler. The GUI pushes it
// 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
// listener can offer friends a one-click Join. `None` when not in a call. Set on
// 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`
// in — all later access is via `net.memory_lookup`.
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,
Err(e) => {
// Only a local socket bind can fail here (the relay handshake is
@@ -1421,7 +1459,14 @@ async fn run_core_loop(
CoreCommand::Shutdown => {
crate::log_msg("Core shutdown requested");
// 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());
if let Some(session) = active_session.take() {
@@ -1436,14 +1481,29 @@ async fn run_core_loop(
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.avatar = avatar;
let was_in_room = active_session.is_some();
// Finalize any recording before tearing down the old session — its
// 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()`
// 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();
net.shutdown().await;
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;
}
@@ -1492,7 +1559,11 @@ async fn run_core_loop(
// The creator's chosen cosmetic label rides in the ticket so
// every joiner inherits it; sanitize it before it leaves here.
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()
} else {
let ticket_str = ticket.trim().to_string();
@@ -1509,7 +1580,9 @@ async fn run_core_loop(
if was_in_room {
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;
}
};
@@ -1531,11 +1604,8 @@ async fn run_core_loop(
// Fresh join starts not sharing; clear any stale share ticket.
current_sharing = None;
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
endpoint.addr(),
None,
);
let self_state =
presence.to_state(is_muted.load(Ordering::Relaxed), endpoint.addr(), None);
// Snapshot THIS room's retained peers (by topic) as extra bootstrap
// 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(),
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));
if was_in_room {
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.file_router.clear();
continue;
@@ -1601,7 +1676,9 @@ async fn run_core_loop(
"Echo cancellation unavailable, using direct devices: {e}"
));
let _ = ui_tx
.send(UiEvent::Error(format!("Echo cancellation unavailable: {e}")))
.send(UiEvent::Error(format!(
"Echo cancellation unavailable: {e}"
)))
.await;
(input_device.clone(), output_device.clone())
}
@@ -1612,13 +1689,16 @@ async fn run_core_loop(
#[cfg(not(target_os = "linux"))]
let _ = echo_cancellation;
#[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 was_in_room {
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;
net.audio_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
// production to the hardware clock instead of a fixed timer.
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 {
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 _ = room_state.leave().await;
net.audio_router.clear();
@@ -1641,7 +1725,8 @@ async fn run_core_loop(
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
let is_muted_clone = is_muted.clone();
@@ -1657,14 +1742,15 @@ async fn run_core_loop(
let is_multitrack_capture = is_multitrack.clone();
let capture_thread = std::thread::spawn(move || {
use opus::{Channels, Application};
let mut encoder = match OpusEncoder::new(48000, Channels::Mono, Application::Voip) {
Ok(enc) => enc,
Err(e) => {
crate::log_msg(&format!("Capture thread error: {:?}", e));
return;
}
};
use opus::{Application, Channels};
let mut encoder =
match OpusEncoder::new(48000, Channels::Mono, Application::Voip) {
Ok(enc) => enc,
Err(e) => {
crate::log_msg(&format!("Capture thread error: {:?}", e));
return;
}
};
// Per-sender packet sequence number, prepended to every frame so
// receivers can reorder and conceal loss. Wraps after ~years.
let mut seq: u32 = 0;
@@ -1679,7 +1765,10 @@ async fn run_core_loop(
while let Ok(mut pcm) = capture_rx.recv() {
// Apply the input gain first so the meter, gate, and what we
// 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) {
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) {
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;
}
@@ -1757,7 +1848,10 @@ async fn run_core_loop(
match JitterBuffer::new() {
Ok(jb) => entry.insert(jb),
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;
}
}
@@ -1819,7 +1913,9 @@ async fn run_core_loop(
loop {
// Pace to the hardware clock: only produce while the ring
// 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;
continue;
}
@@ -1915,9 +2011,11 @@ async fn run_core_loop(
peer_frames.push((frame, pan));
}
}
peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id));
peer_noise_gates
.retain(|id, _| peers_seen.contains(id) || current_gates.contains_key(id));
peer_eqs
.retain(|id, _| peers_seen.contains(id) || current_eq.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
// output gain (in f32, so a boost past the ceiling is
@@ -1954,7 +2052,8 @@ async fn run_core_loop(
&multitrack_mixer,
&is_multitrack_mixer,
&ui_tx_mixer,
).await;
)
.await;
let _ = ui_tx_mixer
.send(UiEvent::Error(format!(
"Recording stopped — write failed: {e}"
@@ -1977,7 +2076,8 @@ async fn run_core_loop(
&multitrack_mixer,
&is_multitrack_mixer,
&ui_tx_mixer,
).await;
)
.await;
let _ = ui_tx_mixer
.send(UiEvent::Error(format!(
"Recording stopped — write failed: {e}"
@@ -2013,7 +2113,9 @@ async fn run_core_loop(
if was_in_room {
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;
}
};
@@ -2130,9 +2232,13 @@ async fn run_core_loop(
&& let Some(mt) = multitrack_events.lock().unwrap().as_mut()
&& 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) => {
// 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
// without a click; non-image files wait for an explicit
// FetchAttachment (the "Save" chip). The descriptor was
@@ -2208,7 +2322,11 @@ async fn run_core_loop(
let key = (from, att.id);
let already_inflight =
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
// pool is exhausted, drop the auto-fetch (and the
// dedup marker) — the descriptor still shows and
@@ -2238,12 +2356,14 @@ async fn run_core_loop(
}
}
}
let _ = ui_tx_events.send(UiEvent::ChatMessage {
from: from.to_string(),
name,
text,
attachment,
}).await;
let _ = ui_tx_events
.send(UiEvent::ChatMessage {
from: from.to_string(),
name,
text,
attachment,
})
.await;
}
RoomEvent::ClockSkewSuspected { author, skew_ms } => {
crate::log_msg(&format!(
@@ -2267,7 +2387,9 @@ async fn run_core_loop(
// rejoin (PeerJoined/PeerUpdated) or a transport
// reconnect (ConnEvent::Connected) cancels it first.
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(
&grace_timers_events,
&seen_connected_events,
@@ -2293,7 +2415,12 @@ async fn run_core_loop(
if was_in_room {
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;
}
};
@@ -2350,13 +2477,25 @@ async fn run_core_loop(
name: room_label,
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);
}
CoreCommand::Leave => {
// 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;
if let Some(session) = active_session.take() {
session.shutdown(audio_backend.clone()).await;
@@ -2373,7 +2512,14 @@ async fn run_core_loop(
let lookup = net.memory_lookup.clone();
net.shutdown().await;
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;
}
}
@@ -2384,11 +2530,8 @@ async fn run_core_loop(
is_muted.store(new_state, Ordering::Relaxed);
if let Some(session) = &active_session {
let self_state = presence.to_state(
new_state,
net.endpoint.addr(),
current_sharing.clone(),
);
let self_state =
presence.to_state(new_state, net.endpoint.addr(), current_sharing.clone());
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);
}
CoreCommand::SetMicMonitor { enabled, input_device } => {
CoreCommand::SetMicMonitor {
enabled,
input_device,
} => {
// During a call the in-call capture thread already reports the
// mic level, and it owns the backend's capture stream — leave it be.
if active_session.is_some() {
@@ -2489,7 +2635,8 @@ async fn run_core_loop(
Ok(()) => {
let ui = ui_tx.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 });
}
Err(e) => {
@@ -2521,7 +2668,14 @@ async fn run_core_loop(
let lookup = net.memory_lookup.clone();
net.shutdown().await;
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 {
net_rebuild_pending = true;
}
@@ -2555,7 +2709,14 @@ async fn run_core_loop(
let lookup = net.memory_lookup.clone();
net.shutdown().await;
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 {
net_rebuild_pending = true;
}
@@ -2718,7 +2879,9 @@ async fn run_core_loop(
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.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.
std::fs::create_dir_all(&base)
.map_err(|e| e.to_string())
@@ -2740,7 +2903,9 @@ async fn run_core_loop(
if let Some(session) = &active_session {
for (id, st) in session.room_state.active_peers() {
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 {
Ok(path) => {
is_recording.store(true, Ordering::Relaxed);
crate::log_msg(&format!("Recording started ({recording_mode:?}): {path}"));
let _ = ui_tx
.send(UiEvent::RecordingStarted { path })
.await;
crate::log_msg(&format!(
"Recording started ({recording_mode:?}): {path}"
));
let _ =
ui_tx.send(UiEvent::RecordingStarted { path }).await;
}
Err(e) => {
let _ = ui_tx
@@ -2786,7 +2952,14 @@ async fn run_core_loop(
}
}
} 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 {
// Make the bytes fetchable by room members, then broadcast the
// descriptor alongside the (possibly empty) caption text.
@@ -2853,25 +3030,13 @@ async fn run_core_loop(
CoreCommand::FetchMusic { from, id, size } => {
if let Some(session) = &active_session {
spawn_music_fetch(
session.transport.clone(),
ui_tx.clone(),
from,
id,
size,
);
spawn_music_fetch(session.transport.clone(), ui_tx.clone(), from, id, size);
}
}
CoreCommand::PrefetchMusic { from, id, size } => {
if let Some(session) = &active_session {
spawn_music_prefetch(
session.transport.clone(),
ui_tx.clone(),
from,
id,
size,
);
spawn_music_prefetch(session.transport.clone(), ui_tx.clone(), from, id, size);
}
}
@@ -2897,14 +3062,19 @@ async fn run_core_loop(
Vec::new()
};
let _ = ui_tx
.send(UiEvent::AudioAppsListed { apps, app_audio_supported })
.send(UiEvent::AudioAppsListed {
apps,
app_audio_supported,
})
.await;
}
CoreCommand::StartScreenShare { audio_app } => {
let Some(session) = &mut active_session else {
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;
continue;
};
@@ -2927,8 +3097,9 @@ async fn run_core_loop(
// audio drops. The channel closes when the host dies (drain hits
// EOF), ending the forwarder task on its own.
let notices = audio_app.as_deref().map(|_| {
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<crate::screenshare::PixelpassEvent>();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<
crate::screenshare::PixelpassEvent,
>();
let ui_tx_notices = ui_tx.clone();
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
@@ -3021,12 +3192,12 @@ async fn run_core_loop(
#[cfg(test)]
mod tests {
use super::{
admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, frame_level,
coalesce_insert, coalesce_pop, mix_frames, mix_stereo_frames, next_game_change,
should_auto_fetch, stereo_to_mono, KnownPeers, MicLevelMeter, PeerSpeakTicket,
MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES,
KnownPeers, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, MIC_LEVEL_REPORT_SAMPLES, MicLevelMeter,
PeerSpeakTicket, admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok,
coalesce_insert, coalesce_pop, frame_level, mix_frames, mix_stereo_frames,
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};
fn endpoint_id() -> iroh::EndpointId {
@@ -3037,14 +3208,30 @@ mod tests {
fn admit_retained_rejects_only_new_ids_at_the_cap() {
// Below the cap, a brand-new identity is retained.
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
// 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
// updates an existing address and never adds a slot.
assert!(admit_retained(MAX_RETAINED_PEERS, false, MAX_RETAINED_PEERS));
assert!(admit_retained(MAX_RETAINED_PEERS + 5, false, MAX_RETAINED_PEERS));
assert!(admit_retained(
MAX_RETAINED_PEERS,
false,
MAX_RETAINED_PEERS
));
assert!(admit_retained(
MAX_RETAINED_PEERS + 5,
false,
MAX_RETAINED_PEERS
));
}
#[test]
@@ -3176,12 +3363,14 @@ mod tests {
host_addr: iroh::EndpointAddr::from(original_host),
topic_id,
name: "Room".to_string(),
}.to_string();
}
.to_string();
let restamped = PeerSpeakTicket {
host_addr: iroh::EndpointAddr::from(member_host),
topic_id,
name: "Room".to_string(),
}.to_string();
}
.to_string();
assert_ne!(original, restamped);
let original_topic = PeerSpeakTicket::topic_of(&original).unwrap();
@@ -3219,7 +3408,10 @@ mod tests {
drop(tx);
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]
@@ -3250,7 +3442,10 @@ mod tests {
assert_eq!(m.push(&frame(100, chunk)), None);
let reported = m.push(&frame(100, chunk)).expect("window complete");
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]
@@ -3259,14 +3454,18 @@ mod tests {
// Fill and report a loud window.
assert!(m.push(&frame(8000, MIC_LEVEL_REPORT_SAMPLES)).is_some());
// 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");
}
#[test]
fn mic_meter_silence_reports_zero() {
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);
}
+29 -7
View File
@@ -283,8 +283,14 @@ mod tests {
let q = echo.len() / 4;
let early = erle(&echo[..q], &cleaned[..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!(late > 20.0, "converged ERLE should exceed 20 dB, got {late:.1}");
assert!(
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]
@@ -307,7 +313,10 @@ mod tests {
let mut aec = Nlms::new(128, 0.5, 1e-6);
let out = aec.process(&silent_ref, &near);
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;
for i in 0..far.len() {
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.
let early_rate = early_hits as f32 / 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!(late_rate > 0.50, "missed double-talk, rate only {late_rate:.2}");
assert!(
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]
@@ -380,6 +399,9 @@ mod tests {
erle_dtd > erle_no + 15.0,
"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 echo = path.apply(&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]
+4 -1
View File
@@ -141,7 +141,10 @@ mod tests {
buf[0] = Complex::new(1.0, 0.0);
fft(&mut 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.
pub const VOICE_BANDS: &[Band] = &[
Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.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 },
Band {
label: "low (80-300)",
low_hz: 80.0,
high_hz: 300.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
+7 -2
View File
@@ -202,7 +202,8 @@ fn legend(opts: &RenderOpts) -> String {
if opts.ascii {
for i in 0..steps {
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);
}
} else {
@@ -243,7 +244,11 @@ mod tests {
fn render_produces_grid_of_expected_height() {
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
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);
// Header + 10 body rows + time axis (2) + legend = non-trivial.
let lines = out.lines().count();
+4 -1
View File
@@ -94,7 +94,10 @@ mod tests {
.unwrap()
.0;
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]
+13 -3
View File
@@ -34,7 +34,12 @@ pub fn read(path: &Path) -> Result<WavData, String> {
let mut pos = 12usize;
while pos + 8 <= bytes.len() {
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_end = (body_start + size).min(bytes.len());
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]]);
bits = u16::from_le_bytes([fmt[14], fmt[15]]);
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" => {
@@ -75,7 +82,10 @@ pub fn read(path: &Path) -> Result<WavData, String> {
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
+13 -7
View File
@@ -76,10 +76,7 @@ pub fn sanitize_filename(raw: &str) -> String {
.trim();
// Drop control chars; turn other whitespace into single spaces later.
let cleaned: String = base
.chars()
.filter(|c| !c.is_control())
.collect();
let cleaned: String = base.chars().filter(|c| !c.is_control()).collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
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 {
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
@@ -240,7 +240,11 @@ mod tests {
let long_stem = "x".repeat(200);
let name = format!("{long_stem}.png");
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}");
}
@@ -254,7 +258,9 @@ mod tests {
#[test]
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(b"GIF89a...."));
let mut webp = b"RIFF".to_vec();
+22 -8
View File
@@ -66,7 +66,11 @@ impl FriendStore {
if self.contains(&id) {
return false;
}
self.friends.push(Friend { id, name, last_addr: addr });
self.friends.push(Friend {
id,
name,
last_addr: addr,
});
true
}
@@ -118,21 +122,24 @@ pub fn friends_path() -> Option<PathBuf> {
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
/// overwritten with an empty list.
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)
}
/// Save the store. Atomic via tempfile-in-same-dir + rename.
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)
}
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
fn load_at(path: &Path) -> Result<FriendStore> {
match fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s)
.with_context(|| format!("failed to parse {}", path.display())),
Ok(s) => {
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) => 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
/// rename, so a crash mid-write can't leave a truncated list.
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()))?;
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()));
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)
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
@@ -219,7 +229,11 @@ mod tests {
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
fn temp_path(tag: &str) -> PathBuf {
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
}
+45 -12
View File
@@ -9,11 +9,9 @@
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
//! debounce is unit-tested without any I/O.
use super::{
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
};
use super::scan;
use super::steam::SteamProbe;
use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve};
use std::collections::BTreeMap;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -145,9 +143,14 @@ fn worker_loop(
let steam_game = steam.detect();
let processes = scan::running_executables();
if let Some(new_current) =
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
{
if let Some(new_current) = poll_once(
&mut debouncer,
&override_,
steam_game,
&processes,
&process_map,
&denylist,
) {
// A closed receiver means core shut down; stop quietly.
if tx.send(new_current).is_err() {
return;
@@ -169,11 +172,18 @@ mod tests {
use super::*;
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> {
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]
@@ -185,17 +195,38 @@ mod tests {
// First poll: detected but not yet published (needs two hits).
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
);
// Second poll: published.
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))
);
// Third identical poll: no change event.
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
);
}
@@ -209,7 +240,9 @@ mod tests {
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.name.as_deref(), Some("Half-Life 2"));
}
+48 -17
View File
@@ -92,11 +92,20 @@ pub fn resolve(
processes: &[DetectedGame],
) -> Resolution {
match override_ {
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
ManualOverride::ForceNone => Resolution {
game: None,
immediate: true,
},
ManualOverride::Force(g) => Resolution {
game: Some(g.clone()),
immediate: true,
},
ManualOverride::Auto => {
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
/// hypothetical `minecraft`), trims surrounding whitespace.
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
@@ -245,8 +258,10 @@ pub fn match_processes(
denylist: &BTreeSet<&str>,
) -> Vec<DetectedGame> {
// Normalize the user map once so lookups are basename/case-insensitive.
let normalized_map: BTreeMap<String, &String> =
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
let normalized_map: BTreeMap<String, &String> = user_map
.iter()
.map(|(k, v)| (normalize_exe(k), v))
.collect();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out: Vec<DetectedGame> = Vec::new();
@@ -288,8 +303,14 @@ mod tests {
#[test]
fn stable_ids_are_namespaced() {
assert_eq!(DetectedGame::steam_id(730), "steam:730");
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
assert_eq!(
DetectedGame::exe_id("/usr/games/hl2_linux"),
"exe:hl2_linux"
);
assert_eq!(
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
"exe:minecraft.exe"
);
}
#[test]
@@ -318,8 +339,16 @@ mod tests {
#[test]
fn resolve_falls_back_to_first_process_then_none() {
let procs = vec![
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process },
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process },
DetectedGame {
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);
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
@@ -426,7 +455,10 @@ mod tests {
// --- process matching --------------------------------------------------
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]
@@ -461,14 +493,13 @@ mod tests {
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
let deny = builtin_denylist();
// Same game twice (two processes) + reverse discovery order.
let running = vec![
"/b/zed".into(),
"/a/alpha".into(),
"/c/alpha".into(),
];
let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
let got = match_processes(&running, &user, &deny);
// 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]
+12 -4
View File
@@ -62,7 +62,7 @@ fn linux_proc_executables() -> Vec<String> {
fn windows_toolhelp_executables() -> Vec<String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS,
};
@@ -78,7 +78,11 @@ fn windows_toolhelp_executables() -> Vec<String> {
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 {
// 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]);
if !name.is_empty() {
out.push(name);
@@ -101,7 +105,10 @@ mod tests {
// The test runner itself is a process, so /proc enumeration must be
// non-empty and include something that normalizes to our own exe basename.
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.
let me = std::fs::read_link("/proc/self/exe")
.ok()
@@ -109,7 +116,8 @@ mod tests {
if let Some(me) = me {
let me_norm = super::super::normalize_exe(&me);
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:?}"
);
}
+57 -20
View File
@@ -25,8 +25,7 @@ const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
#[cfg(any(windows, test))]
fn validate_reg_len(len: u32) -> Option<usize> {
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
.then_some(len as usize / 2)
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
}
#[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> {
let root = vdf::parse(registry_vdf).ok()?;
let raw = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
.get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str)?;
let id: u32 = raw.trim().parse().ok()?;
(id != 0).then_some(id)
@@ -196,7 +202,13 @@ impl SteamProbe {
return cached.name.clone();
}
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
}
@@ -240,7 +252,11 @@ impl SteamProbe {
paths.push(root.clone());
}
}
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
self.libraries = CachedLibraries {
source,
mtime,
paths: paths.clone(),
};
paths
}
}
@@ -360,8 +376,8 @@ mod win {
use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
REG_DWORD, REG_SZ,
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
RegQueryValueExW,
};
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
@@ -374,9 +390,8 @@ mod win {
let subkey = wide("Software\\Valve\\Steam");
let mut hkey: HKEY = std::ptr::null_mut();
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
let rc = unsafe {
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
};
let rc =
unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
(rc == ERROR_SUCCESS).then_some(hkey)
}
@@ -463,13 +478,20 @@ mod tests {
#[test]
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(8), Some(4));
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
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]
@@ -495,10 +517,13 @@ mod tests {
"contentstatsid" "12345"
}"#;
let got = parse_library_paths(current);
assert_eq!(got, vec![
PathBuf::from("/home/eric/.local/share/Steam"),
PathBuf::from("/mnt/games/SteamLibrary"),
]);
assert_eq!(
got,
vec![
PathBuf::from("/home/eric/.local/share/Steam"),
PathBuf::from("/mnt/games/SteamLibrary"),
]
);
// Legacy shape: numeric keys map straight to path strings.
let legacy = r#""LibraryFolders" {
@@ -522,13 +547,25 @@ mod tests {
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));
// 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).
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.
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.
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]
+33 -7
View File
@@ -234,10 +234,20 @@ mod tests {
}
"#;
let root = parse(acf).unwrap();
assert_eq!(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"));
assert_eq!(
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.
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]
@@ -262,8 +272,14 @@ mod tests {
"#;
let root = parse(vdf).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!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
assert_eq!(
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.
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ids, vec!["0", "1"]);
@@ -292,7 +308,14 @@ mod tests {
"#;
let root = parse(reg).unwrap();
let appid = root
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
.get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str);
assert_eq!(appid, Some("570"));
}
@@ -301,7 +324,10 @@ mod tests {
fn handles_comments_and_barewords() {
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
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]
+7 -4
View File
@@ -89,9 +89,9 @@ impl HotkeyAction {
pub fn tier(self) -> HotkeyTier {
match self {
HotkeyAction::ToggleMute
| HotkeyAction::ToggleDeafen
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => {
HotkeyTier::AppWide
}
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
}
}
@@ -278,7 +278,10 @@ mod tests {
#[test]
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");
}
}
+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
/// create path.
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)
}
@@ -49,7 +50,8 @@ pub fn load_or_create() -> Result<SecretKey> {
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and
/// unrecoverable, so callers should confirm with the user first.
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();
save_at(&path, &key)?;
Ok(key)
@@ -57,7 +59,8 @@ pub fn regenerate() -> Result<SecretKey> {
/// Atomic, `0600` write at the default identity path. See [`save_at`].
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)
}
@@ -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
/// world-readable.
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()))?;
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
{
let mut f =
fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?;
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create {}", tmp.display()))?;
#[cfg(unix)]
{
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 config;
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 audio;
pub mod avatar;
pub mod background;
pub mod recents;
pub mod codec;
pub mod config;
pub mod core;
pub mod discovery;
pub mod hotkeys;
pub mod dsp;
pub mod files;
pub mod playlist;
pub mod friends;
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;
use std::fs::File;
@@ -75,7 +75,8 @@ pub fn redact_for_log(value: &str) -> String {
}
pub fn short_bytes_hex(bytes: &[u8]) -> String {
bytes.iter()
bytes
.iter()
.take(6)
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
@@ -83,7 +84,10 @@ pub fn short_bytes_hex(bytes: &[u8]) -> String {
}
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"))
}
@@ -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 _ = std::fs::remove_file(&rotated);
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_gossip::net::Gossip;
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::Receiver;
use std::sync::{Arc, Mutex};
use std::collections::{BTreeSet, HashMap, HashSet};
use async_trait::async_trait;
use tokio_stream::StreamExt;
use serde::{Serialize, Deserialize};
/// Domain-separation tag mixed into every signed gossip payload so a signature
/// 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 {
let author = secret.public();
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).
@@ -233,7 +238,10 @@ impl ClockSkewMonitor {
}
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
.authors
.iter()
@@ -339,7 +347,8 @@ fn verify_gossip(
return Err(GossipReject::OutOfWindow);
}
if let GossipMessage::Announce(state) = &payload.msg
&& state.addr.id != payload.author {
&& state.addr.id != payload.author
{
return Err(GossipReject::AnnounceAddressMismatch);
}
Ok(())
@@ -466,7 +475,8 @@ impl RoomState for IrohGossipState {
// 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
// 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 {
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);
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| {
let err = format!("Failed to join gossip topic: {}", e);
crate::log_msg(&err);
@@ -505,32 +518,44 @@ impl RoomState for IrohGossipState {
let topic_bytes = ticket.topic_id;
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 clock_skew_monitor = ClockSkewMonitor::default();
// Broadcast initial state
let initial_payload = {
let guard = self_state_clone.lock().unwrap();
guard.as_ref().map(|s| sign_gossip(
&secret_key,
&topic_bytes,
now_millis(),
GossipMessage::Announce(s.clone()),
))
guard.as_ref().map(|s| {
sign_gossip(
&secret_key,
&topic_bytes,
now_millis(),
GossipMessage::Announce(s.clone()),
)
})
};
if let Some(payload) = initial_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;
}
&& 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;
}
// Stream topic messages
while let Some(res) = gossip_receiver.next().await {
match res {
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
// allocates, so a size check has to precede `from_slice` to
// bound the memory a hostile peer can make us hold.
@@ -577,13 +602,13 @@ impl RoomState for IrohGossipState {
continue;
}
let our_id = {
self_state_clone.lock().unwrap()
.as_ref()
.map(|s| s.addr.id)
self_state_clone.lock().unwrap().as_ref().map(|s| s.addr.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;
}
@@ -635,7 +660,9 @@ impl RoomState for IrohGossipState {
// Screen-share tickets are capabilities and
// peer-supplied: cap/validate once at ingest
// 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
// peer text like the name: sanitize +
// length-cap at ingest (strip bidi/control,
@@ -651,8 +678,11 @@ impl RoomState for IrohGossipState {
// bounds a future fetch (reject anything
// outside the attachment cap).
state.music = state.music.and_then(|mut m| {
let name = crate::sanitize::sanitize_game_label(&m.name);
if name.is_empty() || !crate::files::size_within_cap(m.size) {
let name =
crate::sanitize::sanitize_game_label(&m.name);
if name.is_empty()
|| !crate::files::size_within_cap(m.size)
{
return None;
}
m.name = name;
@@ -679,8 +709,10 @@ impl RoomState for IrohGossipState {
// cleared ONLY once re-admitted — clearing it before
// a possible reject would orphan its recovery state
// (Tier C F-01 audit).
let is_reconnecting =
disconnected_peers.lock().unwrap().contains(&payload.author);
let is_reconnecting = disconnected_peers
.lock()
.unwrap()
.contains(&payload.author);
let admitted = {
let mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author);
@@ -689,11 +721,17 @@ impl RoomState for IrohGossipState {
// memory/tasks/dials without bound (Tier C
// F-01). Existing-peer updates and reconnects
// are exempt; only brand-new authors are gated.
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting);
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) {
let subject_to_cap =
announce_subject_to_cap(is_new, is_reconnecting);
if !admit_into_roster(
peer_map.len(),
subject_to_cap,
MAX_ACTIVE_PEERS,
) {
None
} 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 {
peer_map.insert(payload.author, state.clone());
}
@@ -723,35 +761,54 @@ impl RoomState for IrohGossipState {
// address set, so leave/re-announce cycles
// can't accumulate attacker-supplied history
// (Tier C F-01).
let _ = address_lookup.set_endpoint_info(state.addr.clone());
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
let _ = address_lookup
.set_endpoint_info(state.addr.clone());
let _ = event_tx
.send(RoomEvent::PeerJoined(payload.author, state))
.await;
} else if state_changed {
crate::log_msg(&format!(
"Gossip peer state updated: {}, state: {}",
crate::short_id(&payload.author.to_string()),
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 => {
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
// distinct identities through Announce→Leave can't
// grow the lookup for the room's lifetime (Tier C
// F-01 audit). Re-announce re-populates it.
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
.lock()
.unwrap()
.remove(&payload.author);
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 } => {
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author));
GossipMessage::Chat {
name,
text,
ts,
attachment,
} => {
crate::log_msg(&format!(
"Gossip chat from author={:?}",
payload.author
));
// Defensively normalize an untrusted attachment
// descriptor: sanitize the filename and drop it
// 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);
Some(a)
});
let _ = event_tx.send(RoomEvent::ChatMessage {
from: payload.author,
name,
text,
ts,
attachment,
}).await;
let _ = event_tx
.send(RoomEvent::ChatMessage {
from: payload.author,
name,
text,
ts,
attachment,
})
.await;
}
}
}
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
let payload_opt = {
let guard = self_state_clone.lock().unwrap();
guard.as_ref().map(|state| sign_gossip(
&secret_key,
&topic_bytes,
now_millis(),
GossipMessage::Announce(state.clone()),
))
guard.as_ref().map(|state| {
sign_gossip(
&secret_key,
&topic_bytes,
now_millis(),
GossipMessage::Announce(state.clone()),
)
})
};
if let Some(payload) = payload_opt
&& 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;
}
&& 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;
}
}
Ok(iroh_gossip::api::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();
if removed {
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;
}
}
@@ -844,7 +915,9 @@ impl RoomState for IrohGossipState {
);
if let Ok(bytes) = serde_json::to_vec(&payload) {
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()))?;
}
}
@@ -909,10 +982,17 @@ impl RoomState for IrohGossipState {
&self.secret_key,
&topic,
ts,
GossipMessage::Chat { name, text, ts, attachment },
GossipMessage::Chat {
name,
text,
ts,
attachment,
},
);
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()))?;
}
}
@@ -934,12 +1014,7 @@ impl RoomState for IrohGossipState {
let sender_opt = self.active_sender.lock().unwrap().take();
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
let payload = sign_gossip(
&self.secret_key,
&topic,
now_millis(),
GossipMessage::Leave,
);
let payload = sign_gossip(&self.secret_key, &topic, now_millis(), GossipMessage::Leave);
if let Ok(bytes) = serde_json::to_vec(&payload) {
crate::log_msg("Broadcasting Leave message to gossip");
let _ = sender.broadcast(bytes.into()).await;
@@ -1060,7 +1135,9 @@ mod tests {
#[test]
fn reconnecting_and_existing_peers_are_exempt_from_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...
assert!(!announce_subject_to_cap(false, false));
// ...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
// 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, false), 3));
assert!(!admit_into_roster(
3,
announce_subject_to_cap(true, false),
3
));
}
#[test]
@@ -1105,7 +1186,10 @@ mod tests {
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
assert_eq!(
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);
}
@@ -1125,7 +1209,10 @@ mod tests {
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
assert_eq!(
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, 2_000),
Some(ClockSkewWarning { author: b, skew_ms: 121_000 })
Some(ClockSkewWarning {
author: b,
skew_ms: 121_000
})
);
assert_eq!(
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);
let addr = EndpointAddr::from_parts(
id,
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)],
[
TransportAddr::Relay(short.clone()),
TransportAddr::Relay(long),
],
);
let out = sanitize_endpoint_addr(&addr);
let relays: Vec<_> = out.relay_urls().cloned().collect();
@@ -1222,7 +1318,12 @@ mod tests {
let secret = SecretKey::generate();
let topic = [9u8; 32];
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 deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
@@ -1252,7 +1353,13 @@ mod tests {
};
let serialized = serde_json::to_string(&original).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!(text, "Hello");
assert_eq!(ts, 123456789);
@@ -1305,7 +1412,10 @@ mod tests {
// defaulting the attachment to None (serde(default)).
let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
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!(attachment, None);
} else {
@@ -1368,7 +1478,10 @@ mod tests {
let secret = SecretKey::generate();
let topic = [1u8; 32];
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]
@@ -1392,7 +1505,12 @@ mod tests {
let secret = SecretKey::generate();
let topic = [4u8; 32];
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!(
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
Err(GossipReject::BadSignature)
@@ -1418,16 +1536,34 @@ mod tests {
let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave);
// Far in the past relative to "now" → stale (replay).
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)
);
// Implausibly future.
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)
);
// 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]
@@ -1449,10 +1585,30 @@ mod tests {
let author = fresh_id();
let mut seen = HashMap::new();
assert!(admit_state_mutation(&mut seen, author, &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));
assert!(admit_state_mutation(
&mut seen,
author,
&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));
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
@@ -1465,13 +1621,26 @@ mod tests {
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
let author = fresh_id();
let mut seen = HashMap::new();
let later_chat = GossipMessage::Chat { 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 };
let later_chat = GossipMessage::Chat {
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, &earlier_chat, 100));
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]
@@ -1481,8 +1650,18 @@ mod tests {
let mut seen = HashMap::new();
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, 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 iroh::{Endpoint, EndpointId};
use iroh::endpoint::{Connection, ConnectionError, VarInt};
use crate::network::{ConnEvent, NetError, NetworkTransport};
use async_trait::async_trait;
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::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::protocol::{AUDIO_ALPN, FILES_ALPN};
/// 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.
@@ -111,7 +111,13 @@ impl Shared {
let shared = self.clone();
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
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));
inbound_tx_ret
}
@@ -123,7 +129,10 @@ impl Shared {
self.addrs.lock().unwrap().remove(&peer_id);
if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
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;
// 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 {
Some(conn) => conn,
None => return, // retired before we ever connected
};
let mut conn =
match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
Some(conn) => conn,
None => return, // retired before we ever connected
};
loop {
// 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);
// Publish the live connection so an intentional leave can close it with
// the goodbye code.
shared.live_conns.lock().unwrap().insert(peer_id, conn.clone());
let _ = shared.conn_events_tx.try_send(ConnEvent::Connected(peer_id));
shared
.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));
// Run until the link dies, a replacement arrives, or we're retired. The
@@ -272,21 +290,36 @@ async fn supervise(
match wake {
Wake::Shutdown => return,
Wake::Replacement(new_conn) => {
crate::log_msg(&format!("Transport: peer {:?} replaced with new inbound link", peer_id));
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
crate::log_msg(&format!(
"Transport: peer {:?} replaced with new inbound link",
peer_id
));
let _ = shared
.conn_events_tx
.try_send(ConnEvent::Connecting(peer_id));
conn = new_conn;
}
Wake::Closed(reason) => {
// A graceful application close means the peer left on purpose —
// don't reconnect; tell the core to evict it now.
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));
return;
}
crate::log_msg(&format!("Transport: peer {:?} link dropped; reconnecting", 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 {
crate::log_msg(&format!(
"Transport: peer {:?} link dropped; reconnecting",
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,
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.
let inbound_tx = shared.ensure_supervisor(peer_id).await;
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(())
}
@@ -545,7 +581,14 @@ impl IrohTransport {
/// all supervisors so none linger redialing the about-to-close endpoint.
/// Call this before shutting the router down.
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 {
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(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
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)
}
+53 -29
View File
@@ -1,10 +1,10 @@
use iroh::{EndpointId, EndpointAddr};
use async_trait::async_trait;
use bytes::Bytes;
use iroh::{EndpointAddr, EndpointId};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use thiserror::Error;
use tokio::sync::mpsc::Receiver;
use async_trait::async_trait;
use serde::{Serialize, Deserialize};
use std::str::FromStr;
#[derive(Error, Debug)]
pub enum NetError {
@@ -148,7 +148,10 @@ pub enum RoomEvent {
/// 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,
/// 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
/// display name (embedded so it shows even without a presence entry), the
/// text, and a sender-stamped millisecond timestamp.
@@ -203,10 +206,12 @@ impl PeerSpeakTicket {
/// is idempotent.
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
match ticket_str.parse::<PeerSpeakTicket>() {
Ok(t) => {
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name }
.to_string()
Ok(t) => PeerSpeakTicket {
host_addr: my_addr,
topic_id: t.topic_id,
name: t.name,
}
.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
/// in the room UI and in the presence we report to friends.
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
@@ -223,7 +231,10 @@ impl PeerSpeakTicket {
/// the recents list (the host address and label change between members/sessions,
/// but the topic uniquely identifies the gathering).
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> {
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded)
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
let ticket: PeerSpeakTicket =
serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?;
Ok(ticket)
}
}
@@ -327,13 +338,13 @@ pub trait RoomState: Send + Sync {
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
}
pub mod iroh_impl;
pub mod gossip;
pub mod iroh_impl;
#[cfg(test)]
mod tests {
use super::*;
use iroh::{SecretKey, EndpointAddr};
use iroh::{EndpointAddr, SecretKey};
fn sample_peer_state() -> PeerState {
let secret = SecretKey::generate();
@@ -371,9 +382,12 @@ mod tests {
let host = SecretKey::generate().public();
let topic_id = [3u8; 32];
// A labelled ticket: restamp keeps the label, label_of reads it.
let labelled =
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
.to_string();
let labelled = PeerSpeakTicket {
host_addr: EndpointAddr::from(host),
topic_id,
name: "HangOut".into(),
}
.to_string();
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
let member = SecretKey::generate().public();
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
@@ -410,10 +424,8 @@ mod tests {
// valid URL-safe-base64 that decodes to non-JSON bytes
let bad_json = b"hello world";
let encoded = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
bad_json,
);
let encoded =
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json);
let res3 = encoded.parse::<PeerSpeakTicket>();
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
}
@@ -424,9 +436,12 @@ mod tests {
let host = SecretKey::generate().public();
let member = SecretKey::generate().public();
let topic_id = [42u8; 32];
let original =
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
.to_string();
let original = PeerSpeakTicket {
host_addr: EndpointAddr::from(host),
topic_id,
name: "HangOut".into(),
}
.to_string();
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
@@ -441,18 +456,27 @@ mod tests {
fn test_restamp_is_idempotent_for_same_addr() {
let me = SecretKey::generate().public();
let topic_id = [7u8; 32];
let mine =
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() }
.to_string();
let mine = PeerSpeakTicket {
host_addr: EndpointAddr::from(me),
topic_id,
name: String::new(),
}
.to_string();
// 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]
fn test_restamp_passes_through_unparseable() {
let me = SecretKey::generate().public();
// 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]
+17 -9
View File
@@ -61,7 +61,6 @@ pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool {
master_enabled && sound_enabled
}
/// A notification event with a distinct chime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Sound {
@@ -305,10 +304,7 @@ mod tests {
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
r"C:\Users\O''Brien\chime.wav"
);
assert_eq!(
escape_powershell_single_quoted("a'b'c"),
"a''b''c"
);
assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c");
}
#[test]
@@ -338,7 +334,10 @@ mod tests {
// bare `~` -> home dir
assert_eq!(expand_tilde("~"), home);
// `~/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
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
@@ -351,10 +350,19 @@ mod tests {
// leading/trailing whitespace is trimmed
if let Some(home) = dirs::home_dir() {
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!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav"));
assert_eq!(
expand_tilde(" /etc/foo.wav "),
PathBuf::from("/etc/foo.wav")
);
assert_eq!(
expand_tilde(" foo/bar.wav "),
PathBuf::from("foo/bar.wav")
);
}
#[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> {
let lower = entry.to_ascii_lowercase();
if lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("ftp://")
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
{
return None;
}
@@ -107,7 +105,10 @@ File3=/var/audio/two.MP3
#[test]
fn playlist_kind_is_case_insensitive() {
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.txt")), None);
}
+67 -19
View File
@@ -36,8 +36,11 @@ pub enum PresenceMode {
impl PresenceMode {
/// All postures, default first — the option list for the Settings/home picker.
pub const ALL: [PresenceMode; 3] =
[PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable];
pub const ALL: [PresenceMode; 3] = [
PresenceMode::Normal,
PresenceMode::Invisible,
PresenceMode::Discoverable,
];
/// Whether this posture publishes to discovery (the only mode that does).
pub fn publishes_to_discovery(self) -> bool {
@@ -193,7 +196,11 @@ mod tests {
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
// Stranger is NEVER answered, in any mode.
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));
}
@@ -214,7 +221,10 @@ mod tests {
ControlMsg::Ping,
ControlMsg::Pong { room: None },
ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }),
room: Some(RoomPresence {
name: "HangOut".into(),
ticket: "abc".into(),
}),
},
];
for msg in cases {
@@ -245,19 +255,37 @@ mod tests {
);
// Valid ticket -> InRoom with a sanitized name.
let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
}, friend);
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
let got = interpret_pong(
&ControlMsg::Pong {
room: Some(RoomPresence {
name: "HangOut".into(),
ticket: t.clone(),
}),
},
friend,
);
assert_eq!(
got,
Some(FriendPresence::InRoom {
name: "HangOut".into(),
ticket: t
})
);
}
#[test]
fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
// A friend reporting a room with an unparseable ticket is treated as just
// Online — no dead/hostile Join button is surfaced.
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
}, id());
let got = interpret_pong(
&ControlMsg::Pong {
room: Some(RoomPresence {
name: "Trap".into(),
ticket: "not-a-ticket".into(),
}),
},
id(),
);
assert_eq!(got, Some(FriendPresence::Online));
}
@@ -266,9 +294,15 @@ mod tests {
let friend = id();
let attacker = id();
let t = valid_ticket(attacker);
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
}, friend);
let got = interpret_pong(
&ControlMsg::Pong {
room: Some(RoomPresence {
name: "Redirect".into(),
ticket: t,
}),
},
friend,
);
assert_eq!(got, Some(FriendPresence::Online));
}
@@ -287,10 +321,18 @@ mod tests {
let t = valid_ticket(friend);
assert_eq!(
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,
))),
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.
assert_eq!(
@@ -304,9 +346,15 @@ mod tests {
// Control/bidi characters in a peer-supplied name are stripped.
let friend = id();
let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
}, friend);
let got = interpret_pong(
&ControlMsg::Pong {
room: Some(RoomPresence {
name: "Hang\u{202e}Out\u{0007}".into(),
ticket: t.clone(),
}),
},
friend,
);
match got {
Some(FriendPresence::InRoom { name, .. }) => {
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
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
/// 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))
.await
.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 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)?)
.await
.context("failed to write ping")?;
@@ -118,7 +124,10 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
let io = async {
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)? {
ControlMsg::Ping => {}
other => bail!("expected a ping, got {other:?}"),
@@ -211,7 +220,10 @@ mod tests {
let handler: Handler = Arc::new(move |from| {
if from == allowed {
Some(ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }),
room: Some(RoomPresence {
name: "HangOut".into(),
ticket: "t".into(),
}),
})
} else {
None // stranger -> no reply
@@ -221,10 +233,11 @@ mod tests {
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
// The allowed prober gets a Pong with the room.
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
.await
.expect("probe timed out")
.expect("probe failed");
let (from, pong) =
tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
.await
.expect("probe timed out")
.expect("probe failed");
assert_eq!(from, server_addr.id);
match pong {
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.
#[test]
fn alpns_match_their_proto_versions() {
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
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}"));
assert_eq!(
AUDIO_ALPN,
format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()
);
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]
@@ -95,7 +107,11 @@ mod tests {
let mut b = a;
b[5] = 10;
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]
+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.
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
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);
}
@@ -86,8 +93,12 @@ mod tests {
/// Build a real, parseable ticket for a fresh room with the given label.
fn ticket(name: &str, topic: [u8; 32]) -> String {
let host = SecretKey::generate().public();
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() }
.to_string()
PeerSpeakTicket {
host_addr: EndpointAddr::from(host),
topic_id: topic,
name: name.into(),
}
.to_string()
}
#[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
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
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`,
@@ -144,7 +147,10 @@ mod tests {
fn strips_control_chars_and_collapses_whitespace() {
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
// 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.
assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
}
@@ -183,7 +189,10 @@ mod tests {
let mid = "g".repeat(56);
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
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]
@@ -220,7 +229,10 @@ mod tests {
#[test]
fn linkify_plain_text_has_no_links() {
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]
@@ -280,7 +292,12 @@ mod tests {
fn linkify_only_matches_http_schemes() {
// 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");
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]
@@ -293,7 +310,11 @@ mod tests {
"weird))) http://c.com]]] tail",
"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.
pub fn sanitize_app_name(name: &str) -> Option<String> {
let name = name.trim();
let ok = !name.is_empty()
&& name.len() <= MAX_APP_NAME_LEN
&& !name.chars().any(|c| c.is_control());
let ok =
!name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control());
ok.then(|| name.to_string())
}
@@ -599,7 +598,10 @@ mod tests {
// i.e. after the `--` end-of-options guard, never parsed as a flag.
let args = viewer_args("--malicious-flag");
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;
assert!(guard < ticket, "ticket must follow 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.
assert_eq!(
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;
// --strict-audio is the trailing flag.
@@ -643,12 +651,18 @@ mod tests {
// An empty / whitespace / control-laden selection is sanitized away,
// 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("bad\nname")), vec!["--host", "--output", "json"]);
assert_eq!(
host_args(Some("bad\nname")),
vec!["--host", "--output", "json"]
);
}
#[test]
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("a\tb"), None);
@@ -667,7 +681,11 @@ mod tests {
]"#;
assert_eq!(
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]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
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]
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
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);
}
@@ -784,7 +808,9 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
fn parses_connected_url() {
assert_eq!(
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 candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
#[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))]
assert_eq!(candidates, vec![dir.join("pixelpass")]);
}
+6 -2
View File
@@ -211,7 +211,7 @@ impl AppTheme {
overlay: hex(0x6272a4),
text: hex(0xf8f8f2),
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
red: hex(0xff5555),
maroon: hex(0xff79c6), // pink
@@ -400,7 +400,11 @@ mod tests {
for theme in AppTheme::ALL {
let p = theme.palette();
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);
assert!(
accent >= 3.0,
+70 -75
View File
@@ -9,8 +9,8 @@ use iced::advanced::widget::{self, Widget};
use iced::advanced::{Layout, Shell};
use iced::widget::text_input;
use iced::{
alignment, Background, Border, Color, Element, Event, Length, Padding,
Pixels, Point, Rectangle, Shadow, Size, Vector,
Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
Size, Vector, alignment,
};
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())
}
pub fn cut_selection(
value: &str,
start: usize,
end: usize,
) -> (Edit, Option<String>) {
pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option<String>) {
let mut value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end);
@@ -80,18 +76,13 @@ pub fn select_all_range(value: &str) -> (usize, usize) {
(0, value.len())
}
fn normalized_range(
value: &text_input::Value,
start: usize,
end: usize,
) -> (usize, usize) {
fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) {
let len = value.len();
(start.min(end).min(len), start.max(end).min(len))
}
type InputStyleFn<'a, Theme> =
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
type InputStyleFn<'a, Theme> = Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
pub fn context_input<'a, Message, Theme, Renderer>(
placeholder: &str,
@@ -119,12 +110,8 @@ where
.locked(true)
}
pub struct ContextInput<
'a,
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
@@ -137,8 +124,7 @@ pub struct ContextInput<
style: Option<InputStyleFn<'a, Theme>>,
}
impl<'a, Message, Theme, Renderer>
ContextInput<'a, Message, Theme, Renderer>
impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
@@ -172,16 +158,13 @@ where
self
}
pub fn on_input(
mut self,
on_input: impl Fn(String) -> Message + 'a,
) -> Self {
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_input);
pub fn on_input(mut self, 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);
self.input =
self.input.on_input(move |value| input_callback.as_ref()(value));
self.input = self
.input
.on_input(move |value| input_callback.as_ref()(value));
self.on_input = Some(on_input);
self
}
@@ -196,16 +179,13 @@ where
self
}
pub fn on_paste(
mut self,
on_paste: impl Fn(String) -> Message + 'a,
) -> Self {
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_paste);
pub fn on_paste(mut self, 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);
self.input =
self.input.on_paste(move |value| paste_callback.as_ref()(value));
self.input = self
.input
.on_paste(move |value| paste_callback.as_ref()(value));
self.on_paste = Some(on_paste);
self
}
@@ -235,18 +215,12 @@ where
self
}
pub fn line_height(
mut self,
line_height: impl Into<text::LineHeight>,
) -> Self {
pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
self.input = self.input.line_height(line_height);
self
}
pub fn align_x(
mut self,
alignment: impl Into<alignment::Horizontal>,
) -> Self {
pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
self.input = self.input.align_x(alignment);
self
}
@@ -379,11 +353,9 @@ where
}
};
tree.state.downcast_mut::<ContextInputState>().menu =
cursor.position().map(|anchor| MenuState {
anchor,
selection,
});
tree.state.downcast_mut::<ContextInputState>().menu = cursor
.position()
.map(|anchor| MenuState { anchor, selection });
shell.capture_event();
shell.request_redraw();
@@ -477,8 +449,7 @@ where
}
}
impl<'a, Message, Theme, Renderer>
From<ContextInput<'a, Message, Theme, Renderer>>
impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
@@ -516,12 +487,7 @@ enum MenuAction {
}
impl MenuAction {
const ALL: [Self; 4] = [
Self::Cut,
Self::Copy,
Self::Paste,
Self::SelectAll,
];
const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];
fn label(self) -> &'static str {
match self {
@@ -564,8 +530,7 @@ where
cursor: mouse::Cursor,
) {
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
let hovered_style =
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
let bounds = layout.bounds();
let viewport = Rectangle::INFINITE;
@@ -640,9 +605,7 @@ where
) {
match event {
Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
self.close(shell);
@@ -718,11 +681,7 @@ where
)
}
fn hit_action(
&self,
bounds: Rectangle,
position: Point,
) -> Option<MenuAction> {
fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
if !bounds.contains(position) {
return None;
}
@@ -932,12 +891,48 @@ mod tests {
#[test]
fn locked_menu_allows_copy_and_select_all_only() {
assert!(!menu_action_enabled(MenuAction::Cut, 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::Cut,
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(MenuAction::SelectAll, false, false, false, true));
assert!(!menu_action_enabled(
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::renderer;
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::tree::{self, Tree};
use iced::advanced::{Layout, Shell};
use iced::widget::text::{
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn,
Wrapping,
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping,
};
use iced::{
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point,
Rectangle, Size, Vector, keyboard,
Background, Border, Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector,
alignment, keyboard,
};
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
// lines would break — revisit then.
pub fn selected_substring(
text: &str,
anchor: usize,
cursor: usize,
) -> Option<String> {
pub fn selected_substring(text: &str, anchor: usize, cursor: usize) -> Option<String> {
let (start, end) = normalized_byte_range(text, anchor, cursor);
(start != end).then(|| text[start..end].to_owned())
@@ -42,11 +37,7 @@ pub fn select_all(text: &str) -> (usize, usize) {
(0, text.len())
}
fn normalized_byte_range(
text: &str,
anchor: usize,
cursor: usize,
) -> (usize, usize) {
fn normalized_byte_range(text: &str, anchor: usize, cursor: usize) -> (usize, usize) {
let start = clamp_to_char_boundary(text, anchor.min(cursor));
let end = clamp_to_char_boundary(text, anchor.max(cursor));
@@ -75,13 +66,8 @@ where
SelectableRichText::with_spans(spans)
}
pub struct SelectableRichText<
'a,
Link,
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
pub struct SelectableRichText<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
@@ -101,8 +87,7 @@ pub struct SelectableRichText<
selection_color: Color,
}
impl<'a, Link, Message, Theme, Renderer>
SelectableRichText<'a, Link, Message, Theme, Renderer>
impl<'a, Link, Message, Theme, Renderer> SelectableRichText<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
@@ -127,9 +112,7 @@ where
}
}
pub fn with_spans(
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
) -> Self {
pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self {
Self {
spans: Box::new(spans),
..Self::new()
@@ -166,10 +149,7 @@ where
self
}
pub fn align_y(
mut self,
alignment: impl Into<alignment::Vertical>,
) -> Self {
pub fn align_y(mut self, alignment: impl Into<alignment::Vertical>) -> Self {
self.align_y = alignment.into();
self
}
@@ -179,10 +159,7 @@ where
self
}
pub fn on_link_click(
mut self,
on_link_click: impl Fn(Link) -> Message + 'a,
) -> Self {
pub fn on_link_click(mut self, on_link_click: impl Fn(Link) -> Message + 'a) -> Self {
self.on_link_click = Some(Box::new(on_link_click));
self
}
@@ -356,26 +333,16 @@ where
}
for (index, span) in spans.iter().enumerate() {
let is_hovered_link = self.on_link_click.is_some()
&& Some(index) == self.hovered_link;
let is_hovered_link = self.on_link_click.is_some() && Some(index) == self.hovered_link;
if span.highlight.is_some()
|| span.underline
|| span.strikethrough
|| is_hovered_link
{
if span.highlight.is_some() || span.underline || span.strikethrough || is_hovered_link {
let regions = state.paragraph.span_bounds(index);
if let Some(highlight) = span.highlight {
for bounds in &regions {
let bounds = Rectangle::new(
bounds.position()
- Vector::new(
span.padding.left,
span.padding.top,
),
bounds.size()
+ Size::new(span.padding.x(), span.padding.y()),
bounds.position() - Vector::new(span.padding.left, span.padding.top),
bounds.size() + Size::new(span.padding.x(), span.padding.y()),
);
renderer.fill_quad(
@@ -390,26 +357,17 @@ where
}
if span.underline || span.strikethrough || is_hovered_link {
let size = span
.size
.or(self.size)
.unwrap_or(renderer.default_size());
let size = span.size.or(self.size).unwrap_or(renderer.default_size());
let line_height = span
.line_height
.unwrap_or(self.line_height)
.to_absolute(size);
let color = span
.color
.or(style.color)
.unwrap_or(defaults.text_color);
let color = span.color.or(style.color).unwrap_or(defaults.text_color);
let baseline = translation
+ Vector::new(
0.0,
size.0 + (line_height.0 - size.0) / 2.0,
);
let baseline =
translation + Vector::new(0.0, size.0 + (line_height.0 - size.0) / 2.0);
if span.underline || is_hovered_link {
for bounds in &regions {
@@ -497,13 +455,10 @@ where
state.dragging = true;
state.press_position = Some(position);
state.span_pressed = self.hovered_link;
state.selection = state
.paragraph
.hit_test(position)
.map(|hit| {
let offset = hit.cursor().min(flat_text.len());
(offset, offset)
});
state.selection = state.paragraph.hit_test(position).map(|hit| {
let offset = hit.cursor().min(flat_text.len());
(offset, offset)
});
shell.capture_event();
shell.request_redraw();
} else if state.active || state.selection.is_some() {
@@ -521,8 +476,7 @@ where
&& let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection
{
state.selection =
Some((anchor, hit.cursor().min(flat_text.len())));
state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
shell.request_redraw();
}
}
@@ -540,16 +494,14 @@ where
&& let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection
{
state.selection =
Some((anchor, hit.cursor().min(flat_text.len())));
state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
}
if !dragged {
if let (Some(on_link_clicked), Some(span)) =
(&self.on_link_click, state.span_pressed)
&& Some(span) == self.hovered_link
&& let Some(link) =
spans.get(span).and_then(|span| span.link.clone())
&& let Some(link) = spans.get(span).and_then(|span| span.link.clone())
{
shell.publish(on_link_clicked(link));
}
@@ -570,25 +522,22 @@ where
physical_key,
modifiers,
..
}) if state.active && modifiers.command() => {
match key.to_latin(*physical_key) {
Some('c') | Some('C') => {
if let Some((anchor, cursor)) = state.selection
&& let Some(selected) =
selected_substring(&flat_text, anchor, cursor)
{
clipboard.write(clipboard::Kind::Standard, selected);
shell.capture_event();
}
}
Some('a') | Some('A') => {
state.selection = Some(select_all(&flat_text));
}) if state.active && modifiers.command() => match key.to_latin(*physical_key) {
Some('c') | Some('C') => {
if let Some((anchor, cursor)) = state.selection
&& let Some(selected) = selected_substring(&flat_text, anchor, cursor)
{
clipboard.write(clipboard::Kind::Standard, selected);
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 {
state.paragraph =
Renderer::Paragraph::with_spans(text_with_spans());
state.spans = config
.spans
.iter()
.cloned()
.map(Span::to_static)
.collect();
state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
state.spans = config.spans.iter().cloned().map(Span::to_static).collect();
} else {
match state.paragraph.compare(advanced_text::Text {
content: (),
@@ -682,8 +625,7 @@ where
state.paragraph.resize(bounds);
}
advanced_text::Difference::Shape => {
state.paragraph =
Renderer::Paragraph::with_spans(text_with_spans());
state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
}
}
}
@@ -761,13 +703,7 @@ fn selection_rect_for_line<P: Paragraph>(
})
}
fn x_for_offset<P: Paragraph>(
paragraph: &P,
y: f32,
offset: usize,
left: f32,
right: f32,
) -> f32 {
fn x_for_offset<P: Paragraph>(paragraph: &P, y: f32, offset: usize, left: f32, right: f32) -> f32 {
let mut low = left;
let mut high = right.max(left);
@@ -787,10 +723,7 @@ fn x_for_offset<P: Paragraph>(
high
}
fn visual_lines<P: Paragraph>(
paragraph: &P,
span_count: usize,
) -> Vec<Rectangle> {
fn visual_lines<P: Paragraph>(paragraph: &P, span_count: usize) -> Vec<Rectangle> {
let mut lines: Vec<Rectangle> = Vec::new();
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 bottom = (a.y + a.height).max(b.y + b.height);
Rectangle::new(
Point::new(left, top),
Size::new(right - left, bottom - top),
)
Rectangle::new(Point::new(left, top), Size::new(right - left, bottom - top))
}
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::{Endpoint, RelayMode};
use peerspeak::files::{ChatAttachment, AttachmentKind};
use peerspeak::files::{AttachmentKind, ChatAttachment};
use peerspeak::network::NetworkTransport;
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
use peerspeak::protocol::FILES_ALPN;
@@ -52,7 +52,12 @@ async fn spawn_node() -> Node {
.accept(FILES_ALPN, file_router)
.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
@@ -86,7 +91,9 @@ async fn loopback_attachment_round_trips_intact() {
let blob = big_blob();
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 {
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(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft
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(_)) => continue, // ignore PeerConnecting / PeerConnected
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::opus_impl::OpusEncoder;
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
use peerspeak::network::{ConnEvent, NetworkTransport};
use peerspeak::protocol::AUDIO_ALPN;
struct Node {
@@ -91,7 +91,12 @@ async fn spawn_capture_peer(secret: iroh::SecretKey) -> CapturePeer {
.accept(AUDIO_ALPN, CaptureProtocol { conns_tx })
.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
@@ -208,7 +213,11 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
received += 1;
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;
}
if received >= N {
@@ -290,7 +299,11 @@ async fn dialer_reconnects_after_link_drops() {
.expect("timed out awaiting initial connection")
.expect("connection channel closed");
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"
);
@@ -306,7 +319,11 @@ async fn dialer_reconnects_after_link_drops() {
.expect("timed out awaiting reconnect")
.expect("connection channel closed");
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)"
);
@@ -319,7 +336,12 @@ async fn dialer_reconnects_after_link_drops() {
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!(
received >= 20,
"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("connection channel closed");
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"
);
@@ -384,7 +410,10 @@ async fn dialer_reports_left_on_graceful_close() {
// 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;
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]
@@ -429,7 +458,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
.expect("timed out awaiting initial connection (retained address path)")
.expect("connection channel closed");
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"
);
@@ -443,7 +476,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
.expect("timed out awaiting reconnect (retained address path)")
.expect("connection channel closed");
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"
);
@@ -455,7 +492,12 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
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!(
received >= 20,
"audio should resume after reconnecting via the retained address; got {received} frames"