Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2eae95ede0 | ||
|
|
fdd532de53 | ||
|
|
46809153d8 | ||
|
|
6ccad0d37a | ||
|
|
ddb3d2aabc | ||
|
|
bbbe2d8f17 | ||
|
|
63b45e03ab | ||
|
|
2937e5191a |
+1
-1
@@ -49,7 +49,7 @@ tokio-stream = "0.1.18"
|
||||
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
|
||||
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
|
||||
# quantum), used by the playback RT callback to fill exactly what the device asks
|
||||
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# PeerSpeak on Windows
|
||||
|
||||
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
|
||||
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
|
||||
checks listed below.
|
||||
|
||||
## What works today
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
| GUI | Iced/wgpu builds and renders under Wine. |
|
||||
| Networking | Iroh QUIC transport and gossip compile on Windows. |
|
||||
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
|
||||
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
|
||||
| Identity | `ring` identity generation/load is platform-neutral. |
|
||||
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
|
||||
|
||||
Windows paths are resolved through `dirs`:
|
||||
|
||||
- Config: `%APPDATA%\peerspeak\config.json`
|
||||
- Identity: `%APPDATA%\peerspeak\identity.key`
|
||||
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
|
||||
|
||||
## Building
|
||||
|
||||
### Native Windows
|
||||
|
||||
Install MSVC Build Tools and CMake, then build normally:
|
||||
|
||||
```powershell
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
|
||||
|
||||
```powershell
|
||||
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Cross-compile from Linux
|
||||
|
||||
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
|
||||
|
||||
```sh
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
sudo pacman -S mingw-w64-gcc cmake
|
||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
|
||||
```
|
||||
|
||||
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
|
||||
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
|
||||
spike) lives in the maintainer's handoff docs, outside the repo.
|
||||
|
||||
## First run and networking
|
||||
|
||||
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
|
||||
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
|
||||
|
||||
The default network mode keeps the n0 relay available for NAT traversal without publishing
|
||||
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
|
||||
connections are expected and valid.
|
||||
|
||||
## Known gaps
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
|
||||
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
|
||||
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
|
||||
| Resampling/device format | Open. Devices must support 48 kHz, and output must support stereo; a 44.1 kHz-only/default device currently errors instead of playing. |
|
||||
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
||||
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. |
|
||||
|
||||
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
||||
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
||||
play notification chimes.
|
||||
+76
-30
@@ -2,7 +2,7 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
||||
use crate::network::PeerState;
|
||||
use crate::notify::{self, Sound};
|
||||
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
|
||||
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices};
|
||||
use crate::audio::{AudioDevice, enumerate_audio_devices};
|
||||
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||
use crate::presence::PresenceMode;
|
||||
@@ -553,11 +553,9 @@ pub fn run_gui() -> iced::Result {
|
||||
// the icon from the .desktop file matched by app_id instead).
|
||||
icon: window_icon(),
|
||||
// app_id must match the .desktop basename so Wayland compositors
|
||||
// (e.g. KWin) attach our launcher icon to the window.
|
||||
platform_specific: iced::window::settings::PlatformSpecific {
|
||||
application_id: "peerspeak".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
// (e.g. KWin) attach our launcher icon to the window. The field is
|
||||
// Linux-only in iced (X11/Wayland); see platform_specific_settings().
|
||||
platform_specific: platform_specific_settings(),
|
||||
// We save the final size ourselves on CloseRequested, then exit.
|
||||
exit_on_close_request: false,
|
||||
..Default::default()
|
||||
@@ -565,6 +563,22 @@ pub fn run_gui() -> iced::Result {
|
||||
.run()
|
||||
}
|
||||
|
||||
/// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to
|
||||
/// match our `.desktop` launcher icon) only exists in iced on Linux, so it is
|
||||
/// set there and left at defaults on Windows.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||||
iced::window::settings::PlatformSpecific {
|
||||
application_id: "peerspeak".to_string(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
|
||||
iced::window::settings::PlatformSpecific::default()
|
||||
}
|
||||
|
||||
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
|
||||
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
|
||||
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
|
||||
@@ -2557,10 +2571,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
mic_meter,
|
||||
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
|
||||
vertical_space(4.0),
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||||
{
|
||||
let control: Element<'_, AppMessage> = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
column![
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
|
||||
].spacing(8).into()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
column![
|
||||
checkbox(false)
|
||||
.label("Echo cancellation"),
|
||||
text("Echo cancellation is not available on Windows yet.").size(11).color(color_subtext),
|
||||
].spacing(8).into()
|
||||
}
|
||||
};
|
||||
control
|
||||
},
|
||||
].spacing(8).width(iced::Length::Fill),
|
||||
]
|
||||
.spacing(10)
|
||||
@@ -3263,26 +3295,40 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
column![]
|
||||
},
|
||||
vertical_space(20.0),
|
||||
// Echo cancellation — same flag + message as the Settings checkbox, so
|
||||
// toggling here and there stay in sync automatically (single source of
|
||||
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it
|
||||
// applies on the NEXT join (the PipeWire-module AEC is wired at join
|
||||
// time, not hot-swappable mid-call).
|
||||
tooltip(
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
container(
|
||||
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
|
||||
.size(11)
|
||||
.color(color_text),
|
||||
)
|
||||
.padding(8)
|
||||
.max_width(260.0)
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Top,
|
||||
)
|
||||
.gap(8),
|
||||
{
|
||||
// Echo cancellation is wired at join time on Linux; other
|
||||
// targets show an inert status row instead of a dead toggle.
|
||||
let control: Element<'_, AppMessage> = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
tooltip(
|
||||
checkbox(state.config.echo_cancellation_enabled)
|
||||
.label("Echo cancellation")
|
||||
.on_toggle(AppMessage::ToggleEchoCancellation),
|
||||
container(
|
||||
text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
|
||||
.size(11)
|
||||
.color(color_text),
|
||||
)
|
||||
.padding(8)
|
||||
.max_width(260.0)
|
||||
.style(c_style(color_crust, color_surface, 6.0)),
|
||||
iced::widget::tooltip::Position::Top,
|
||||
)
|
||||
.gap(8)
|
||||
.into()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
column![
|
||||
checkbox(false)
|
||||
.label("Echo cancellation"),
|
||||
text("Not available on Windows yet.").size(11).color(color_subtext),
|
||||
].spacing(4).into()
|
||||
}
|
||||
};
|
||||
control
|
||||
},
|
||||
vertical_space(20.0),
|
||||
{
|
||||
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
|
||||
|
||||
+262
-60
@@ -7,6 +7,9 @@
|
||||
//!
|
||||
//! - **Capture**: mono, 48 kHz, S16 PCM, emitted as `Vec<i16>` frames of
|
||||
//! [`CAPTURE_FRAME`] (960 = 20 ms) samples — matching the encoder/jitter frame.
|
||||
//! The RT capture callback only downmixes and pushes samples into a lock-free
|
||||
//! ring; the owning thread drains that ring, frames it, and sends — so the
|
||||
//! callback never allocates, locks, or touches an mpsc channel.
|
||||
//! - **Playback**: stereo interleaved ([`PLAYBACK_CHANNELS`]) S16 PCM at 48 kHz,
|
||||
//! drained from a ring buffer that is paced to the device's hardware clock via
|
||||
//! `ring_fill` exactly as the PipeWire backend does.
|
||||
@@ -20,7 +23,12 @@
|
||||
//! stream, plays it, and keeps it alive until the per-worker `running` flag flips
|
||||
//! (set by `stop`). The struct holds only `Send` handles (the flag + the join
|
||||
//! handle). The stream's RT callback does the actual audio work; the owning
|
||||
//! thread additionally feeds the playback ring from the network mixer.
|
||||
//! thread additionally feeds the playback ring (or drains the capture ring).
|
||||
//!
|
||||
//! `start_*` does not return until the owning thread reports back over a readiness
|
||||
//! channel that the device resolved and the stream is built and playing — so a
|
||||
//! device/format/WASAPI failure surfaces as a real `Err` to the caller instead of
|
||||
//! leaving the UI in a joined-but-silent room.
|
||||
//!
|
||||
//! ## Sample rate
|
||||
//!
|
||||
@@ -30,7 +38,7 @@
|
||||
//! sample-rate support (resampling) is a Phase 1.1 follow-up.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
@@ -42,13 +50,20 @@ use ringbuf::{
|
||||
HeapRb,
|
||||
};
|
||||
|
||||
use super::{AudioBackend, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
|
||||
use super::{AudioBackend, AudioDevice, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
|
||||
|
||||
/// The one sample rate the pipeline supports (Opus + the 20 ms frame).
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
/// Mono capture frame: 960 samples = 20 ms @ 48 kHz. Matches the PipeWire backend
|
||||
/// and `core::jitter::FRAME_SAMPLES`.
|
||||
const CAPTURE_FRAME: usize = 960;
|
||||
/// Lock-free capture ring capacity (mono samples) between the RT callback and the
|
||||
/// owning drain thread: 8 frames = 160 ms of headroom, so a scheduling hiccup on
|
||||
/// the drain thread doesn't immediately overrun the RT producer.
|
||||
const CAPTURE_RING_CAPACITY: usize = CAPTURE_FRAME * 8;
|
||||
/// How long the capture drain thread sleeps when the ring is momentarily empty,
|
||||
/// before polling again. Small enough to stay well under the 20 ms frame cadence.
|
||||
const CAPTURE_POLL: Duration = Duration::from_millis(5);
|
||||
/// Playback ring capacity in interleaved samples: 200 ms of stereo @ 48 kHz.
|
||||
/// Comfortably above [`PLAYBACK_TARGET_SAMPLES`] so the clock-paced producer has
|
||||
/// headroom and never has to drop frames in steady state.
|
||||
@@ -90,22 +105,20 @@ impl AudioBackend for CpalBackend {
|
||||
tx: Sender<Vec<i16>>,
|
||||
target_node: Option<String>,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut guard = self.capture.lock().unwrap();
|
||||
let guard = self.capture.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Err(AudioError::Stream("Capture already started".to_string()));
|
||||
}
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_thread = running.clone();
|
||||
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
|
||||
let thread = thread::Builder::new()
|
||||
.name("peerspeak-cpal-capture".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_capture(tx, target_node, running_thread) {
|
||||
crate::log_msg(&format!("cpal capture error: {e}"));
|
||||
}
|
||||
run_capture(tx, target_node, running_thread, ready_tx);
|
||||
})
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
*guard = Some(StreamWorker { running, thread });
|
||||
Ok(())
|
||||
finish_start(guard, StreamWorker { running, thread }, ready_rx, "capture")
|
||||
}
|
||||
|
||||
fn start_playback(
|
||||
@@ -114,22 +127,20 @@ impl AudioBackend for CpalBackend {
|
||||
target_node: Option<String>,
|
||||
ring_fill: Arc<AtomicUsize>,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut guard = self.playback.lock().unwrap();
|
||||
let guard = self.playback.lock().unwrap();
|
||||
if guard.is_some() {
|
||||
return Err(AudioError::Stream("Playback already started".to_string()));
|
||||
}
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_thread = running.clone();
|
||||
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
|
||||
let thread = thread::Builder::new()
|
||||
.name("peerspeak-cpal-playback".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(e) = run_playback(rx, target_node, ring_fill, running_thread) {
|
||||
crate::log_msg(&format!("cpal playback error: {e}"));
|
||||
}
|
||||
run_playback(rx, target_node, ring_fill, running_thread, ready_tx);
|
||||
})
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
*guard = Some(StreamWorker { running, thread });
|
||||
Ok(())
|
||||
finish_start(guard, StreamWorker { running, thread }, ready_rx, "playback")
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<(), AudioError> {
|
||||
@@ -143,6 +154,81 @@ impl AudioBackend for CpalBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Block until the just-spawned worker reports (over `ready_rx`) that its stream
|
||||
/// is built and playing, then either install it (`Ok`) or join it and surface the
|
||||
/// real error. This is what makes `start_capture`/`start_playback` fail loudly
|
||||
/// instead of returning `Ok` into a joined-but-silent room (Codex review W1).
|
||||
fn finish_start(
|
||||
mut guard: std::sync::MutexGuard<'_, Option<StreamWorker>>,
|
||||
worker: StreamWorker,
|
||||
ready_rx: Receiver<Result<(), AudioError>>,
|
||||
what: &str,
|
||||
) -> Result<(), AudioError> {
|
||||
match ready_rx.recv() {
|
||||
Ok(Ok(())) => {
|
||||
*guard = Some(worker);
|
||||
Ok(())
|
||||
}
|
||||
// Setup failed (Err) or the worker exited before reporting (recv Err):
|
||||
// either way it has stopped, so reap it and surface the error.
|
||||
Ok(Err(e)) => {
|
||||
worker.running.store(false, Ordering::Relaxed);
|
||||
let _ = worker.thread.join();
|
||||
Err(e)
|
||||
}
|
||||
Err(_) => {
|
||||
worker.running.store(false, Ordering::Relaxed);
|
||||
let _ = worker.thread.join();
|
||||
Err(AudioError::Init(format!(
|
||||
"cpal {what} worker exited before reporting readiness"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device enumeration (for the settings device pickers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Enumerate WASAPI input/output devices via cpal, sorted by description to match
|
||||
/// the PipeWire backend's stable UI ordering.
|
||||
///
|
||||
/// cpal exposes a single friendly name per device, which is also what [`resolve`]
|
||||
/// matches `target_node` against — so `name` and `description` are the same string
|
||||
/// and a saved selection round-trips. Note: WASAPI device names are less stable
|
||||
/// across driver/endpoint changes than PipeWire node names, so a saved device may
|
||||
/// not always be found again; selection then falls back to the system default.
|
||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||
let host = cpal::default_host();
|
||||
let mut devices = Vec::new();
|
||||
|
||||
if let Ok(inputs) = host.input_devices() {
|
||||
for device in inputs {
|
||||
if let Ok(name) = device.name() {
|
||||
devices.push(AudioDevice {
|
||||
description: name.clone(),
|
||||
name,
|
||||
is_input: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(outputs) = host.output_devices() {
|
||||
for device in outputs {
|
||||
if let Ok(name) = device.name() {
|
||||
devices.push(AudioDevice {
|
||||
description: name.clone(),
|
||||
name,
|
||||
is_input: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
devices.sort_by(|a, b| a.description.cmp(&b.description));
|
||||
devices
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Device / config selection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,7 +254,20 @@ fn resolve(
|
||||
}
|
||||
};
|
||||
let device = match target {
|
||||
Some(name) => find_device_by_name(&host, output, &name).or_else(default),
|
||||
// A saved device name that no longer resolves falls back to the system
|
||||
// default — but log it, because WASAPI friendly names can change across
|
||||
// driver/endpoint changes, so a silent fallback otherwise looks like
|
||||
// "audio went to the wrong device for no reason" (review W7).
|
||||
Some(ref name) => match find_device_by_name(&host, output, name) {
|
||||
Some(dev) => Some(dev),
|
||||
None => {
|
||||
crate::log_msg(&format!(
|
||||
"cpal: saved {} device '{name}' not found; using system default",
|
||||
if output { "output" } else { "input" },
|
||||
));
|
||||
default()
|
||||
}
|
||||
},
|
||||
None => default(),
|
||||
}
|
||||
.ok_or_else(|| AudioError::Device("no audio device available".to_string()))?;
|
||||
@@ -242,53 +341,108 @@ fn run_capture(
|
||||
tx: Sender<Vec<i16>>,
|
||||
target: Option<String>,
|
||||
running: Arc<AtomicBool>,
|
||||
) -> Result<(), AudioError> {
|
||||
let (device, config, sample_format) = resolve(false, target)?;
|
||||
let channels = config.channels as usize;
|
||||
ready: Sender<Result<(), AudioError>>,
|
||||
) {
|
||||
// The RT callback pushes mono samples into this lock-free ring; we drain it on
|
||||
// this (non-RT) thread, so the callback never allocates or sends on a channel.
|
||||
let rb = HeapRb::<i16>::new(CAPTURE_RING_CAPACITY);
|
||||
let (producer, mut consumer) = rb.split();
|
||||
let overrun = Arc::new(AtomicU64::new(0));
|
||||
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => build_input::<f32>(&device, &config, tx, channels),
|
||||
SampleFormat::I16 => build_input::<i16>(&device, &config, tx, channels),
|
||||
SampleFormat::U16 => build_input::<u16>(&device, &config, tx, channels),
|
||||
other => Err(AudioError::Stream(format!(
|
||||
"unsupported capture sample format: {other:?}"
|
||||
))),
|
||||
}?;
|
||||
// Fallible device/stream setup. We report the real error to `start_capture`
|
||||
// before doing any work, so a join never lands in a silent room.
|
||||
let setup = || -> Result<(Stream, String, SampleFormat, usize), AudioError> {
|
||||
let (device, config, sample_format) = resolve(false, target)?;
|
||||
let channels = config.channels as usize;
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => {
|
||||
build_input::<f32, _>(&device, &config, producer, channels, overrun.clone())
|
||||
}
|
||||
SampleFormat::I16 => {
|
||||
build_input::<i16, _>(&device, &config, producer, channels, overrun.clone())
|
||||
}
|
||||
SampleFormat::U16 => {
|
||||
build_input::<u16, _>(&device, &config, producer, channels, overrun.clone())
|
||||
}
|
||||
other => Err(AudioError::Stream(format!(
|
||||
"unsupported capture sample format: {other:?}"
|
||||
))),
|
||||
}?;
|
||||
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
|
||||
Ok((stream, name, sample_format, channels))
|
||||
};
|
||||
|
||||
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
let (stream, dev_name, sample_format, channels) = match setup() {
|
||||
Ok(v) => {
|
||||
let _ = ready.send(Ok(()));
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
crate::log_msg(&format!(
|
||||
"cpal capture started: device='{dev_name}' format={sample_format:?} channels={channels} rate={SAMPLE_RATE} Hz"
|
||||
));
|
||||
|
||||
// The RT callback does the work; this thread just keeps `stream` alive until
|
||||
// `stop()` flips the flag, at which point the stream is dropped (= stopped).
|
||||
// Drain the RT ring on this thread: pop mono samples, frame them (the `Vec`
|
||||
// allocation lives here, off the RT path), and send completed frames. Keep
|
||||
// `stream` alive until `stop()` flips the flag.
|
||||
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
|
||||
let mut last_overrun = 0u64;
|
||||
while running.load(Ordering::Relaxed) {
|
||||
thread::sleep(WORKER_POLL);
|
||||
let mut drained = false;
|
||||
while let Some(sample) = consumer.try_pop() {
|
||||
drained = true;
|
||||
if let Some(frame) = acc.push(sample) {
|
||||
// Consumer gone (call ended) → stop feeding; the stream is
|
||||
// dropped below on the way out.
|
||||
if tx.send(frame).is_err() {
|
||||
drop(stream);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let o = overrun.load(Ordering::Relaxed);
|
||||
if o != last_overrun {
|
||||
crate::log_msg(&format!(
|
||||
"cpal capture overrun: dropped {} samples (drain thread fell behind)",
|
||||
o - last_overrun
|
||||
));
|
||||
last_overrun = o;
|
||||
}
|
||||
if !drained {
|
||||
thread::sleep(CAPTURE_POLL);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
drop(stream);
|
||||
}
|
||||
|
||||
fn build_input<T>(
|
||||
fn build_input<T, P>(
|
||||
device: &Device,
|
||||
config: &StreamConfig,
|
||||
tx: Sender<Vec<i16>>,
|
||||
mut producer: P,
|
||||
channels: usize,
|
||||
overrun: Arc<AtomicU64>,
|
||||
) -> Result<Stream, AudioError>
|
||||
where
|
||||
T: SizedSample + Send + 'static,
|
||||
i16: FromSample<T>,
|
||||
P: Producer<Item = i16> + Send + 'static,
|
||||
{
|
||||
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
|
||||
let err_fn = |e| crate::log_msg(&format!("cpal capture stream error: {e}"));
|
||||
device
|
||||
.build_input_stream::<T, _, _>(
|
||||
config,
|
||||
move |data: &[T], _| {
|
||||
// RT-safe: downmix + wait-free push only. A full ring means the
|
||||
// drain thread stalled; count the drop and keep going.
|
||||
for frame in data.chunks_exact(channels) {
|
||||
let mono = downmix_to_mono(frame);
|
||||
if let Some(full) = acc.push(mono) {
|
||||
// Consumer gone (call ended) → stop feeding; the owning
|
||||
// thread will drop the stream on `stop()`.
|
||||
if tx.send(full).is_err() {
|
||||
return;
|
||||
}
|
||||
if producer.try_push(mono).is_err() {
|
||||
overrun.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -350,9 +504,8 @@ fn run_playback(
|
||||
target: Option<String>,
|
||||
ring_fill: Arc<AtomicUsize>,
|
||||
running: Arc<AtomicBool>,
|
||||
) -> Result<(), AudioError> {
|
||||
let (device, config, sample_format) = resolve(true, target)?;
|
||||
|
||||
ready: Sender<Result<(), AudioError>>,
|
||||
) {
|
||||
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
||||
let (mut producer, consumer) = rb.split();
|
||||
|
||||
@@ -369,29 +522,56 @@ fn run_playback(
|
||||
// Diagnostics (mirrors the PipeWire backend's playout-health line).
|
||||
let underrun = Arc::new(AtomicU64::new(0));
|
||||
let dropped = Arc::new(AtomicU64::new(0));
|
||||
// Largest single output-callback length seen (interleaved samples). WASAPI
|
||||
// shared-mode picks its own period, so this can exceed the prefill target —
|
||||
// which would force an underrun every cycle (review W2). The callback only
|
||||
// does a wait-free fetch_max; the health logger reports/warns off the RT path.
|
||||
let max_cb = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => {
|
||||
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||
}
|
||||
SampleFormat::I16 => {
|
||||
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||
}
|
||||
SampleFormat::U16 => {
|
||||
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone())
|
||||
}
|
||||
other => Err(AudioError::Stream(format!(
|
||||
"unsupported playback sample format: {other:?}"
|
||||
))),
|
||||
}?;
|
||||
// Fallible device/stream setup; report the real error to `start_playback`
|
||||
// before any work so a failure surfaces instead of a silent room. `consumer`
|
||||
// is moved into the output callback here.
|
||||
let setup = || -> Result<(Stream, String, SampleFormat), AudioError> {
|
||||
let (device, config, sample_format) = resolve(true, target)?;
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => {
|
||||
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||
}
|
||||
SampleFormat::I16 => {
|
||||
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||
}
|
||||
SampleFormat::U16 => {
|
||||
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
|
||||
}
|
||||
other => Err(AudioError::Stream(format!(
|
||||
"unsupported playback sample format: {other:?}"
|
||||
))),
|
||||
}?;
|
||||
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
|
||||
Ok((stream, name, sample_format))
|
||||
};
|
||||
|
||||
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
let (stream, dev_name, sample_format) = match setup() {
|
||||
Ok(v) => {
|
||||
let _ = ready.send(Ok(()));
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
crate::log_msg(&format!(
|
||||
"cpal playback started: device='{dev_name}' format={sample_format:?} channels={PLAYBACK_CHANNELS} rate={SAMPLE_RATE} Hz"
|
||||
));
|
||||
|
||||
let logger = spawn_health_logger(
|
||||
running.clone(),
|
||||
ring_fill.clone(),
|
||||
underrun.clone(),
|
||||
dropped.clone(),
|
||||
max_cb.clone(),
|
||||
);
|
||||
|
||||
// Feed the ring from the network mixer until `stop()` flips `running` or the
|
||||
@@ -413,7 +593,6 @@ fn run_playback(
|
||||
running.store(false, Ordering::Relaxed);
|
||||
let _ = logger.join();
|
||||
drop(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_output<T, C>(
|
||||
@@ -422,6 +601,7 @@ fn build_output<T, C>(
|
||||
mut consumer: C,
|
||||
ring_fill: Arc<AtomicUsize>,
|
||||
underrun: Arc<AtomicU64>,
|
||||
max_cb: Arc<AtomicUsize>,
|
||||
) -> Result<Stream, AudioError>
|
||||
where
|
||||
T: SizedSample + FromSample<i16> + Send + 'static,
|
||||
@@ -432,6 +612,8 @@ where
|
||||
.build_output_stream::<T, _, _>(
|
||||
config,
|
||||
move |data: &mut [T], _| {
|
||||
// Wait-free; the logger thread reads this off the RT path.
|
||||
max_cb.fetch_max(data.len(), Ordering::Relaxed);
|
||||
let (popped, starved) = fill_output(&mut consumer, data);
|
||||
if starved > 0 {
|
||||
underrun.fetch_add(starved, Ordering::Relaxed);
|
||||
@@ -480,10 +662,12 @@ fn spawn_health_logger(
|
||||
ring_fill: Arc<AtomicUsize>,
|
||||
underrun: Arc<AtomicU64>,
|
||||
dropped: Arc<AtomicU64>,
|
||||
max_cb: Arc<AtomicUsize>,
|
||||
) -> JoinHandle<()> {
|
||||
let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some();
|
||||
thread::spawn(move || {
|
||||
let (mut last_u, mut last_d) = (0u64, 0u64);
|
||||
let mut reported_cb = 0usize;
|
||||
while running.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
let u = underrun.load(Ordering::Relaxed);
|
||||
@@ -498,6 +682,24 @@ fn spawn_health_logger(
|
||||
fill / (48 * PLAYBACK_CHANNELS),
|
||||
));
|
||||
}
|
||||
// Report the device's callback size the first time it's seen (and on
|
||||
// any new high). If a callback asks for more than the prefill target,
|
||||
// the ring can't satisfy it and underruns every cycle — the W2 bug
|
||||
// signature; warn so a real-host log shows whether it's biting.
|
||||
let cb = max_cb.load(Ordering::Relaxed);
|
||||
if cb > reported_cb {
|
||||
reported_cb = cb;
|
||||
let ms = cb / (48 * PLAYBACK_CHANNELS);
|
||||
if cb > PLAYBACK_TARGET_SAMPLES {
|
||||
crate::log_msg(&format!(
|
||||
"cpal output callback up to {cb} samples/cycle (~{ms}ms) EXCEEDS prefill target {PLAYBACK_TARGET_SAMPLES} — expect periodic underruns; needs a larger target or a fixed buffer size (review W2)",
|
||||
));
|
||||
} else if verbose {
|
||||
crate::log_msg(&format!(
|
||||
"cpal output callback up to {cb} samples/cycle (~{ms}ms), target {PLAYBACK_TARGET_SAMPLES}",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+32
-6
@@ -56,19 +56,46 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
}
|
||||
|
||||
pub mod echo_cancel;
|
||||
pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
pub mod pan;
|
||||
#[cfg(unix)]
|
||||
#[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;
|
||||
|
||||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||
/// identifier the backend uses to request the device (`target_node`);
|
||||
/// `description` is the human-facing label shown in the UI. The two may be equal
|
||||
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
/// The audio backend implementation for the current platform.
|
||||
///
|
||||
/// The whole app constructs and threads this alias (via
|
||||
@@ -76,10 +103,9 @@ pub mod recorder;
|
||||
/// platform selection lives entirely here. Both implementations satisfy the
|
||||
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||||
///
|
||||
/// - Linux/Unix → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]); a no-op stub until the
|
||||
/// Phase 1 capture/playback implementation lands.
|
||||
#[cfg(unix)]
|
||||
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||
#[cfg(windows)]
|
||||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||
|
||||
+1
-13
@@ -1,18 +1,6 @@
|
||||
use super::AudioDevice;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||
let output = Command::new("pw-cli")
|
||||
.arg("list-objects")
|
||||
|
||||
@@ -18,20 +18,20 @@
|
||||
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
||||
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
||||
//!
|
||||
//! This probe exercises the PipeWire backend directly, so it is a Unix-only tool.
|
||||
//! On non-Unix targets `main` is a stub that explains the limitation.
|
||||
//! This probe exercises the PipeWire backend directly, so it is a Linux-only tool.
|
||||
//! On non-Linux targets `main` is a stub that explains the limitation.
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
unix_probe::run();
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn main() {
|
||||
eprintln!("audio_probe is only supported on Unix builds (it drives the PipeWire backend directly).");
|
||||
eprintln!("audio_probe is only supported on Linux builds (it drives the PipeWire backend directly).");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(target_os = "linux")]
|
||||
mod unix_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -390,6 +390,7 @@ struct ActiveSession {
|
||||
grace_timers: GraceTimers,
|
||||
transport: Arc<IrohTransport>,
|
||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
|
||||
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
|
||||
/// also dies if the session is dropped without an explicit stop).
|
||||
@@ -431,6 +432,7 @@ impl ActiveSession {
|
||||
|
||||
// Unload the echo-cancel module now that the audio streams releasing its
|
||||
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
|
||||
#[cfg(target_os = "linux")]
|
||||
drop(self.echo_cancel);
|
||||
|
||||
crate::log_msg("Leaving room...");
|
||||
@@ -1095,7 +1097,9 @@ async fn run_core_loop(
|
||||
// The guard unloads the module on drop — including the early-return
|
||||
// paths below, since it's a local until moved into the session. On
|
||||
// any failure, warn and fall back to the direct devices.
|
||||
#[cfg(target_os = "linux")]
|
||||
let mut echo_cancel_guard = None;
|
||||
#[cfg(target_os = "linux")]
|
||||
let (capture_target, playback_target) = if echo_cancellation {
|
||||
match crate::audio::echo_cancel::enable(
|
||||
input_device.as_deref(),
|
||||
@@ -1122,6 +1126,10 @@ async fn run_core_loop(
|
||||
} else {
|
||||
(input_device.clone(), output_device.clone())
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = echo_cancellation;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
|
||||
|
||||
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
|
||||
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
|
||||
@@ -1632,6 +1640,7 @@ async fn run_core_loop(
|
||||
conn_event_task,
|
||||
grace_timers,
|
||||
transport: transport.clone(),
|
||||
#[cfg(target_os = "linux")]
|
||||
echo_cancel: echo_cancel_guard,
|
||||
screenshare_host: None,
|
||||
screenshare_viewers: Vec::new(),
|
||||
|
||||
+41
-5
@@ -3,11 +3,12 @@
|
||||
//!
|
||||
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
|
||||
//! binary is self-contained — no asset directory to ship alongside it. On first
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget
|
||||
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs
|
||||
//! on a detached thread that waits on the child, so it never blocks the UI and
|
||||
//! never leaves a zombie. Any failure (no player, no audio) is silent by design —
|
||||
//! a missing chime should never disrupt a call.
|
||||
//! use each sound is written once to a temp file, then played fire-and-forget.
|
||||
//! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
|
||||
//! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
|
||||
//! detached thread that waits on the child, so it never blocks the UI and never
|
||||
//! leaves a zombie. Any failure (no player, no audio) is silent by design — a
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
||||
Some(path)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
/// Try each available player in turn, waiting on the first that starts (which
|
||||
/// reaps the child). Runs on a detached thread, so the wait is harmless.
|
||||
#[cfg(not(windows))]
|
||||
fn spawn_player(path: &Path) {
|
||||
for player in ["pw-play", "paplay", "aplay"] {
|
||||
let started = Command::new(player)
|
||||
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
|
||||
/// `PlaySync()` blocking for the sound duration is fine.
|
||||
#[cfg(windows)]
|
||||
fn spawn_player(path: &Path) {
|
||||
let path = escape_powershell_single_quoted(&path.display().to_string());
|
||||
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
|
||||
let _ = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-NonInteractive")
|
||||
.arg("-Command")
|
||||
.arg(command)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -234,6 +258,18 @@ mod tests {
|
||||
assert!(!should_play(false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_powershell_single_quote_escape() {
|
||||
assert_eq!(
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sound_indices_unique_and_match_all() {
|
||||
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
|
||||
|
||||
+21
-1
@@ -25,6 +25,16 @@ use tokio::process::{Child, Command};
|
||||
/// points elsewhere.
|
||||
const PIXELPASS_BIN: &str = "pixelpass";
|
||||
|
||||
#[cfg(windows)]
|
||||
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
|
||||
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
||||
[dir.join(PIXELPASS_BIN)]
|
||||
}
|
||||
|
||||
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
|
||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||
const MAX_TICKET_LEN: usize = 512;
|
||||
@@ -143,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
|
||||
}
|
||||
let path_var = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path_var)
|
||||
.map(|dir| dir.join(PIXELPASS_BIN))
|
||||
.flat_map(|dir| pixelpass_path_candidates(&dir))
|
||||
.find(|c| c.is_file())
|
||||
}
|
||||
|
||||
@@ -513,4 +523,14 @@ mod tests {
|
||||
// only assert it doesn't return the empty path as a match.
|
||||
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixelpass_path_candidates_are_platform_specific() {
|
||||
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")]);
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user