39 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 99a4a336ad Release 0.6.3 — in-app screen-sharing controls + hwdec fixes
CI / check (push) Failing after 4s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.3 and document the screen-share work merged on this branch:
the advanced Settings section + per-call quality picker (96e41de), the
hardware-decode-defaults-off frame-1 freeze fix (96e41de), the per-call
quality override fix (e378b2e), and the VLC-honors-viewer-settings fix
(faad8ce). All local-only — no wire-protocol change, old configs load
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 17:20:00 -04:00
molluskandClaude Opus 4.8 df45c0bfeb screenshare: log the pixelpass-host and player argv on spawn
The screen-share code only logged pixelpass's high-level JSON events, never
the argv it spawned children with, so a field log couldn't confirm which
encode/viewer settings actually reached the helpers — e.g. the per-call
quality's --bitrate (host) or the hardware-decode --avcodec-hw/--hwdec flag
(player). Log both verbatim at spawn: host args carry no secret, and the
player line omits the local stream URL. Logged per attempt so a player
fallback is visible too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:47:06 -04:00
molluskandClaude Opus 4.8 faad8ce26a screenshare: honor viewer settings for the VLC player too
The viewer playback settings (hardware decode + buffering) only shaped
mpv's argv; vlc_args() was fixed, so a VLC viewer silently ignored them.
The load-bearing case is hardware decode: mpv defaults to software decode
(the A-bug fix), but VLC hardware-decodes by default, so a VLC viewer with
the default hardware_decode=false still got GPU decode and could hit the
frame-1 freeze the default exists to avoid — the toggle did nothing.

vlc_args() now takes the settings and maps the knobs that translate
cleanly to VLC: hardware decode (--avcodec-hw=none/any) and buffering
posture (network/live caching ms). The genuinely mpv-specific knobs
(cache_mb byte-cache, extra_mpv_args) stay mpv-only; the Settings UI
hints are reworded to say which knobs are mpv-only vs universal. +2 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:22:31 -04:00
molluskandClaude Opus 4.8 e378b2e33b screenshare: fix inline per-call quality override being discarded
The Share control's inline quality dropdown sets a session-only
`share_quality_selection`, but ToggleScreenShare (which opens the audio
picker on the only real path to a share) unconditionally reset it back to
the saved config default before ConfirmShareScreen read it. The picker has
no quality control of its own, so the user's per-call pick was silently
dropped 100% of the time and every share used the persisted default.

Drop the reset; add a regression test asserting the override survives
picker-open and reaches the confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:09:22 -04:00
molluskandClaude Opus 4.8 96e41de1b1 screenshare: advanced in-app streaming controls + hwdec toggle
Add a local-only "Screen sharing" section to Settings plus a per-call quality
picker on the Share control: in-app control over how a share is encoded
(quality/bitrate/framerate/max-height/max-viewers/software-x264, + extra
pixelpass args) and how it's played back (mpv/vlc, hardware decode, buffering,
cache, + extra mpv args). Settings live in AppConfig.screen_share (all
serde-defaulted, so old configs load unchanged) and become pixelpass host CLI
flags / mpv args at share/view launch.

Hardware decode defaults OFF, which also fixes the frozen-frame-with-audio bug:
forcing --hwdec=auto stalled some viewers' HW decoder on frame 1 while audio
kept playing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 21:15:11 -04:00
molluskandClaude Opus 4.8 5c888f8357 context_input: middle-click pastes the X11 PRIMARY selection
Joe (X11) reported that middle-click paste did nothing in the ticket and
node-ID fields. iced's base text_input only binds Ctrl+V to the Standard
(CLIPBOARD) selection and never reads PRIMARY or binds mouse button 2, so
the "select text, middle-click to paste" workflow was dead.

Add a Button::Middle branch to ContextInput::update that reads
clipboard::Kind::Primary, sanitizes it, and pastes at the cursor (reusing
the already-tested pure paste()). Factor the control-char stripping into a
shared, unit-tested sanitize_clip() helper also used by the menu Paste, so
a trailing newline on the PRIMARY selection is dropped. Respects `locked`
so read-only display fields still reject paste.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:25:01 -04:00
molluskandClaude Opus 4.8 074f004227 core: one screen-share player per share — re-watch replaces, not stacks
Every click of Watch (`CoreCommand::ViewShare`) spawned a fresh pixelpass
viewer + mpv and pushed it onto an untracked Vec. A field test hit the
consequence: the first click gave a frozen player (the host's capture was
stalling), so the viewer clicked again to retry — and got a SECOND mpv,
doubling the shared audio.

Track viewers paired with their share ticket. On ViewShare, reap players
whose window already closed (try_wait), then if a live player for the same
ticket exists, kill it before spawning the replacement. Re-watching a
share now swaps its player instead of stacking a second one. Pure
`replace_viewer_index` seam + test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:48:52 -04:00
molluskandClaude Opus 4.8 2d22036930 screenshare: drop mpv --untimed so shared video stays A/V-synced
The viewer launched mpv with `--untimed`, which displays each video
frame the instant it decodes and ignores audio timestamps. Sharing a
desktop (no audio) that just minimizes latency, but sharing a *video*
made its audio drift progressively out of sync — confirmed in a field
test watching a video together. Remove the flag so mpv paces video to
the audio clock; the remaining low-latency flags keep lag negligible for
desktop pointing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:29:59 -04:00
molluskandClaude Opus 4.8 8014edf91c Release 0.6.2 + AppImage packaging
CI / check (push) Failing after 3m41s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.2 (Cargo + Inno .iss), promote CHANGELOG [Unreleased] -> [0.6.2].
0.6.2 rolls up the licensing (MIT + THIRD_PARTY_LICENSES) and the friends-list
liveness fixes (active offline marking, 15s refresh, manual Rescan) on the
0.6.x wire format (gossip v5, compatible with 0.6.0/0.6.1).

Adds packaging/appimage: a thin AppImage recipe (linuxdeploy) that bundles the
pixelpass screen-share helper in usr/bin so peerspeak's $PATH lookup finds it
with no code change. Assets are include_bytes!-embedded; the graphics stack and
pixelpass's gstreamer/mpv tools are left to the host. Built on Ubuntu 24.04
(glibc 2.39) for reach across Debian 13+/Fedora 40+/rolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 16:07:41 -04:00
molluskandClaude Opus 4.8 89d5218d25 Housekeeping: Windows doc refresh + scan.rs test-import cleanup
Codex-authored refresh of docs/WINDOWS.md and packaging/windows/{README,INSTALL}.md
from the 2026-07-01 Windows session; scan.rs qualifies super::running_executables()
to drop an unused glob import. PKGBUILD pkgver reflects the last Arch build
(auto-regenerated by makepkg's pkgver()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:59:26 -04:00
mollusk f3c7aa7050 Merge A6 playback handoff fix 2026-07-01 13:50:44 -04:00
mollusk 39b5dafd57 fix(audio): bound playback handoff queue 2026-07-01 13:39:10 -04:00
mollusk a78860db15 Merge W12 FEC/DTX follow-up (Codex, senior-reviewed)
CI / check (push) Failing after 23s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-30 16:56:19 -04:00
5f52aa1506 W12 follow-up: consume in-band FEC, drop redundant DTX
Fixes the two P2 efficacy findings from the Codex audit of the W12
profiles feature.

FEC was enabled on the encoder but never used: the jitter buffer's
loss path did pure PLC, so the redundancy was wasted bitrate. Now the
gap path reconstructs the lost frame from the next buffered packet via
Opus in-band FEC (new `AudioDecoder::decode_fec`, libopus decode with
fec=true into a one-frame buffer), keeping that packet for its own
normal decode and falling back to PLC if FEC decode fails. This is the
documented libopus FEC pattern; receiver-side only, no wire change.

DTX was enabled on BadNetwork but provided no benefit — the capture
noise gate already suppresses silence transmission, and the broadcast
DTX silence packets only created seq gaps that grew the jitter cushion.
All profiles now set dtx=false (plumbing kept for a future revisit).

Adds a jitter-buffer test proving FEC reconstruction beats pure PLC
(RMS error < 0.75x) and that the FEC source packet stays buffered.
500 lib tests, clippy + fmt clean, release build clean.

Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:56:19 -04:00
molluskandClaude Opus 4.8 d92d0f6f6b Add W12 Opus/network quality profiles
CI / check (push) Failing after 25s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Add a small, named codec-policy picker (Low latency / Balanced / Bad
network) instead of exposing raw Opus knobs. The profile->params mapping
is a pure function (`codec::opus_impl::opus_params`) for unit testing;
profiles tune bitrate, in-band FEC, expected packet-loss, and DTX.

- config: `AudioProfile` enum (serde + Display + ALL + u8 round-trip),
  persisted `audio_profile` field (default Balanced).
- codec: `OpusParams` + pure `opus_params()` + `OpusEncoder::apply_params`
  / `apply_profile`.
- core: new `SetAudioProfile` command (Reliable, no coalesce); a shared
  `AtomicU8` lets the capture thread re-tune the live encoder on a
  mid-call switch and read it at each new call's encoder creation.
- app: Settings "Connection quality" picker in the Audio tab, startup
  config-sync send, and a one-line hint per profile.

No wire-format change (GOSSIP/audio planes untouched). 499 lib tests
green (config + codec mapping/apply tests added), clippy + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:13:26 -04:00
mollusk 551767f9f5 Show build version on launch screen
CI / check (push) Failing after 37s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-29 16:54:26 -04:00
mollusk fa90cd3ce9 Update Arch package version 2026-06-29 16:53:03 -04:00
molluskandClaude Opus 4.8 660261a9a5 deps: bump memmap2 0.9.10 -> 0.9.11 (clears RUSTSEC-2026-0186)
CI / check (push) Successful in 2m6s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
cargo-audit flagged memmap2 0.9.10 as unsound (RUSTSEC-2026-0186, unchecked
pointer offset); 0.9.11 is the patched release. Warning-level only (audit/deny
don't fail on it), but cheap to clear. Audit now down to the two deliberately
-accepted unmaintained warnings (audiopus_sys, paste; ignored in deny.toml).
Lockfile-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:06:19 -04:00
molluskandClaude Opus 4.8 3a74fd0230 ci: drop concurrency block (Gitea 1.26 dropped runs with it set)
CI / check (push) Failing after 12m11s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A push that only changed Cargo.lock failed to create any Actions run while the
concurrency group was present; removing it restores reliable push triggering.
Single-dev CI doesn't need run-cancellation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 16:02:15 -04:00
molluskandClaude Opus 4.8 2dbb1ea316 deps: bump anyhow 1.0.102 -> 1.0.103 (fixes RUSTSEC-2026-0190)
CI / check (push) Failing after 12m46s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI's cargo-deny flagged RUSTSEC-2026-0190: unsoundness in anyhow's
Error::downcast_mut() (UB via borrow-rule violation after Error::context),
reached transitively (n0-error / iroh + the image/rav1e chain). 1.0.103 is the
patched release; lockfile-only, no API change. cargo deny check now fully clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:56:35 -04:00
molluskandClaude Opus 4.8 c902db2e90 style: rustfmt the 0.6.2 additions (A17b + version-in-UI)
CI / check (push) Failing after 2m34s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI's fmt --check caught that Codex's hand-written additions in these two files
weren't rustfmt-formatted (the senior gate ran clippy + tests but not
fmt --check). Pure line-wrapping, no logic change. Keeps the crate fmt-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:52:31 -04:00
molluskandClaude Opus 4.8 83e5881768 ci: cancel superseded in-progress runs (concurrency group)
CI / check (push) Failing after 7s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:51:31 -04:00
molluskandClaude Opus 4.8 8424b44dec ci: add Gitea Actions workflow (self-hosted host-mode runner)
CI / check (push) Failing after 13s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CI runs on a self-hosted host-mode gitea-runner on the desktop (label `arch`),
so the cheap gitbutter VPS only queues jobs while all compile/test compute runs
locally. Pipeline on push-to-main / PR / manual dispatch: cargo fmt --check,
clippy --all-targets -D warnings, cargo test --all-targets + doc tests, cargo
deny check, cargo audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:43:35 -04:00
molluskandClaude Opus 4.8 33e49a8ca7 Merge 0.6.2 refinements (Track B): A17b multitrack offload + build-version-in-UI
Track B code body for the 0.6.2 patch release. No wire change (GOSSIP_PROTO stays
5, interoperable with 0.6.0/0.6.1). Two code commits + two investigation closeouts
(A6 root-caused -> deferred to W5; A3 palette audit -> accepted as-is).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 15:28:48 -04:00
molluskandClaude Opus 4.8 393c1c7f09 feat(ui): surface the build version in Settings + at startup
A field build is now self-identifying. `run_gui` logs `PeerSpeak v<version>
starting` (from env!("CARGO_PKG_VERSION")) on launch, and the Settings panel
shows a muted `PeerSpeak v<version>` footer — pinned to the bottom of the
220px category sidebar (wide layout) and appended under the body in the narrow
(<820px) layout. Compile-time string, no new test, no deps, local-only.

Renders the current crate version, so it tracks the Cargo.toml bump at each
release cut (shows v0.6.1 until 0.6.2 is stamped in Track A).

Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:58:33 -04:00
molluskandClaude Opus 4.8 1bf14ba08f perf(recording): move multitrack stem disk I/O off the mixer path (A17b)
The multitrack recorder wrote every per-stem WAV frame (and the potentially
large late-joiner back-pad) inline on the caller thread while holding the
recorder mutex, so a slow/contended disk stalled the playout mixer (local
underruns) and the events loop. This is the multitrack counterpart to A17
(e0325d4), which moved the single-file recorder's writes off the mixer path.

Design: the front (MultitrackRecorder) now keeps only cheap in-memory state
(known-peer set, mic FIFO, a pending-cycle builder) and on each end_cycle
assembles ONE whole-cycle batch (new peers + mic frame + optional mix frame +
the map of peer frames written this cycle) and try_sends it over a bounded
sync_channel(256) to a dedicated writer thread. The writer thread owns every
WavWriter, is authoritative for its own cycle count, back-pads a brand-new
peer by cycles_written*frame_samples, fills absent peer/mix frames with
silence, latches the first write/create error then drains, and finalizes all
headers on channel close.

The unit of hand-off is a whole cycle, not a track: the writer appends exactly
frame_samples to every existing track per applied batch, and a full queue
DROPS the entire batch (counted + logged at 1 and every 256). So a dropped
cycle omits the same 20ms from every stem at once and all tracks stay
equal-length and sample-aligned by construction even under disk back-pressure.
On drop the batch's new-peer announcements are rolled back out of the known set
so they re-announce (and correctly re-back-pad) on the next applied cycle.

Public method signatures are unchanged -> zero core/mod.rs edits. The
WAV/file format is unchanged (no wire/on-disk change), no new deps
(std::sync::mpsc + std::thread, as A17). Writer logic is factored behind a
generic SampleWriter seam so the apply-batch alignment invariant is unit-tested
without spawning the thread; new tests cover the back-pad-on-apply invariant,
the dropped-cycle equal-length property, and async create-error surfacing at
finalize. The three existing end-to-end tests pass unchanged (now exercising
the threaded path). 496 lib tests, clippy --all-targets clean, release builds.

Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 14:53:52 -04:00
molluskandClaude Opus 4.8 6f14d2668d docs(protocol): correct GOSSIP_PROTO version mapping (v5 = 0.6.0, not 0.7.0)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The const comment claimed "v5 (0.7.0)" while GOSSIP_PROTO has been 5 since the
v0.6.0 tag (introduced by bca2ccd, "release 0.6.0"). Git confirms the value went
straight 3 -> 5 in that one release and a GOSSIP_PROTO == 4 build never existed.
Merge the two mislabeled v4/v5 bullets into one accurate v4-v5 (0.6.0) entry and
note the 3->5 jump + that this breaking gossip change correctly rode the
0.5.1 -> 0.6.0 MINOR bump per VERSIONING.md (0.6.1 is a wire-compatible PATCH,
still proto 5). Comment-only; no wire/behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:24:53 -04:00
molluskandClaude Opus 4.8 49c3ce8c0a release: 0.6.1 refinements
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Wire-compatible refinement release (no *_PROTO change; interoperates with 0.6.0):
- A19  config: atomic save + corruption-preserving load
- S5   notify: chime temp-WAV symlink-clobber hardening
- A15b core: continuous-control command coalescing (last-value-wins)
- A2   window: clamp restored X11 position + sanity guard
- A17  recording: single-file WAV disk I/O moved off the mixer path
- A20  cargo fmt across the crate

All Codex-implemented (gpt-5.5 xhigh), senior-reviewed, tests-green (493 lib),
clippy --all-targets clean. Deferred: CI workflow, ARCHITECTURE/FEATURES doc
refresh, A17b (multitrack writer-thread offload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:16:10 -04:00
molluskandClaude Opus 4.8 aec21a48d5 chore(release): bump version to 0.6.1
0.6.1 refinements release: A19 atomic config, S5 temp-WAV hardening, A15b slider
coalescing, A2 window-position clamp, A17 single-file recording I/O off the mixer
path, and a crate-wide cargo fmt. All wire-compatible (no *_PROTO change) with
0.6.0 peers -- no resync required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:15:57 -04:00
molluskandClaude Opus 4.8 d0a16cb8b9 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>
2026-06-29 02:11:44 -04:00
molluskandClaude Opus 4.8 e0325d4590 perf(recording): move single-file WAV disk I/O off the mixer path (A17)
Recorder::write_frame ran on the playout mixer path and did a blocking write_all
to disk per 20ms frame; slow/contended storage could stall the mixer and cause
local playback underruns. Now the mixer thread only does the cheap mic-sum
(extracted as the pure mix_with_mic helper) and try_sends the frame to a
dedicated writer thread over a bounded sync_channel(256). The writer thread owns
the WavWriter, writes queued frames, records the first write error then drains
without writing, and patches the WAV size fields on channel close. A full queue
DROPS the recording frame (counted + logged at 1 and every 256) rather than
blocking call audio; a disconnected writer surfaces BrokenPipe. finalize() closes
the channel, joins the thread, and returns the first write error or the finalize
result (thread panic handled).

Scope: single-file Recorder only; WavWriter unchanged so the multitrack recorder
is untouched (its writer-thread offload is deferred as A17b). Public method
signatures preserved -> no core/mod.rs changes. New end-to-end threaded WAV
readback test + mix_with_mic helper tests; existing FIFO/mic-sum intent kept.
No new deps, no wire change. Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:10:29 -04:00
molluskandClaude Opus 4.8 e8a894be49 fix(window): clamp restored X11 window position, sanity-guard absurd coords (A2)
initial_window_position fed saved window_x/window_y straight into
Position::Specific with no bounds check, so a saved position on a since-
disconnected monitor (or after a resolution shrink) could open the window fully
off-screen on a bare X11 WM that doesn't clamp. New pure clamp_window_position
seam: given display bounds it pulls a partly-offscreen window back inside,
centers one parked on a vanished monitor, and crucially PRESERVES legitimate
multi-monitor negative-origin coordinates (a naive clamp-to-0 would break that).

iced 0.14 has no dependency-free way to learn the virtual-desktop bounds before
the window exists, so screen_bounds() returns None for now and the clamp applies
a sanity envelope (reject |coord| > 32000 -> Centered) while preserving today's
restore behavior; the full clamp is unit-tested and ready for when bounds can be
supplied. Five clamp tests (inside, edge-clamp, disconnected, negative-origin,
None-sanity) + existing tests updated. No new deps, no wire change.
Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:58:26 -04:00
molluskandClaude Opus 4.8 8c33b5c70f perf(core): coalesce continuous-control commands, last-value-wins (A15b)
A fast slider drag could burst past the bounded(100) best-effort command queue
and try_send would drop commands -- possibly the FINAL value of the drag, leaving
a gain/pan/volume stuck mid-drag until the next interaction. Replace the
best-effort queue with a coalescing latest-value map keyed by control
(CoalesceKey) plus a bounded(1) wake channel: send() overwrites the latest value
per control (never drops, never blocks) and wakes the loop, which pops one
coalesced command at a time and self-re-arms while entries remain. The existing
single-command match handler is reused unchanged.

command_sender() now returns a typed CoreCommandSender that routes by
delivery_class, so the window-close Shutdown (Reliable) goes through the
unbounded reliable channel (drained biased-first) instead of the best-effort
path -- a small correctness improvement. Mute/PTT remain Reliable, untouched.

Pure seams coalesce_key/coalesce_insert/coalesce_pop with unit tests
(overwrite-same-key, distinct-peers, global control, empty pop, drain-each-once)
and a coalesce_key<->BestEffort invariant assertion. No new deps, no wire change.
Codex-implemented (gpt-5.5 xhigh), senior-reviewed; tests-green (487 lib).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:42:41 -04:00
molluskandClaude Opus 4.8 10bd15aeaa fix(notify): harden chime temp-WAV writes against symlink-clobber (S5)
cached_path materialized each embedded chime to a fixed, predictable path
(/tmp/peerspeak-<name>.wav) via fs::write, which follows symlinks -> a local
attacker on a shared host could pre-plant a symlink and redirect the write. New
write_private_wav seam writes to a randomized peerspeak-<stem>-<pid>-<counter>-
<nanos>.wav name with OpenOptions::create_new (O_EXCL, refuses to write through
an existing path) and 0600 mode at creation on Unix. Per-process cache and the
None-on-error fallback (chime simply doesn't play) are unchanged.

Unit tests: exact bytes, 0600 mode, unique paths, create_new-refuses-existing.
No new deps, no wire/schema change. Codex-implemented (gpt-5.5 xhigh), reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:28:41 -04:00
molluskandClaude Opus 4.8 8ad0bea19d fix(config): atomic save + corruption-preserving load (A19)
AppConfig::save now writes to a same-dir temp file and atomically renames
over the target (mirrors identity.rs/friends.rs), and surfaces errors via
log_msg instead of swallowing them. AppConfig::load distinguishes a missing
config (silent default, first run) from a present-but-corrupt one: the damaged
file is moved aside to config.json.corrupt.<unix_secs> before falling back to
defaults, so a later save can no longer clobber the user's real prefs.

Path-injectable seams save_to/load_from + LoadOutcome with unit tests
(round-trip, missing, corrupt-preserves-bytes, no leftover temp). No new deps,
no schema/wire change. Codex-implemented (gpt-5.5 xhigh), senior-reviewed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 01:15:08 -04:00
molluskandClaude Opus 4.8 abb53af559 docs(deb): document the Debian .deb build environment
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The .deb recipe already lives in Cargo.toml's [package.metadata.deb], but the
build *environment* (bookworm distrobox, glibc floor, the mandatory separate
CARGO_TARGET_DIR) was only captured in handoff notes. Add a packaging/debian
README so the deb path is as self-documenting as the Arch + AppImage paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:03:48 -04:00
molluskandClaude Opus 4.8 c0c1969332 style(music): theme-colored skip glyphs in player bar + drawer
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The skip-back/forward buttons rendered orange in every theme because the
emoji glyphs ⏮/⏭ (U+23EE/U+23ED) are drawn by the system color-emoji font,
which ignores the button's text color. Replace them with |◀ / ▶| built from
the text-presentation triangles ◀/▶ (U+25C0/U+25B6) — the same family the
play button already uses — so they honor .color() and follow the active
theme like the play button does. Applies to both the now-playing player bar
and the full music drawer panel. Pure visual change; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:33:32 -04:00
d155091eed style(music): center the transport controls on the player bar
The transport buttons were pushed to the far right because the
now-playing label had width(Fill). Regroup the bar into three sections
— left(Fill) identity+label, centered transport, right(Fill) position +
expand — so the controls sit in the middle. Pure widget regrouping; no
message, config, or behavior change.

Implemented by Codex (gpt-5.5), reviewed + gates re-run by Claude.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:07:59 -04:00
b553a94875 feat(music): relocate playlist into a now-playing bar + slide-out drawer
The full music panel was rendered inline at all times (its own card in
the 3-column layout, stuffed into the Controls panel otherwise), which
crowded every layout. Move it behind two toggles:

- A room-only ♪ button in the top bar shows/hides a slim 56px
  now-playing player bar (track + ⏮ ⏸/▶ ⏭ + position + expand). The
  preference persists (AppConfig.show_player_bar).
- The bar's ⤢ button opens the full panel in a resizable right-edge
  drawer (DividerKind::PlaylistDrawer, mirrors the Chat drawer). When
  open, body_w shrinks so the layouts' fixed panels don't overflow.

Removes all inline playlist placement (3-col card + ThreeColPlaylist
divider, ctrl_music block) and the now-dead clamp/consts. Pure
now_playing_label seam + drawer-width clamp test. 474 lib tests, clippy
-D warnings clean, release build green.

Implemented by Codex (gpt-5.5), reviewed + gates re-run by Claude.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:59:49 -04:00
72 changed files with 7315 additions and 2130 deletions
+42
View File
@@ -0,0 +1,42 @@
name: CI
# Runs on the self-hosted host-mode runner on the desktop (label `arch`). The
# gitbutter VPS only queues the job; all compile/test compute happens locally.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
check:
runs-on: arch
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Toolchain versions
run: |
rustc --version
cargo --version
cargo clippy --version
cargo deny --version
cargo audit --version
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy (all targets, warnings as errors)
run: cargo clippy --all-targets -- -D warnings
- name: Tests
run: cargo test --all-targets
- name: Doc tests
run: cargo test --doc
- name: cargo-deny (advisories, bans, licenses, sources)
run: cargo deny check
- name: cargo-audit
run: cargo audit
+15 -1
View File
@@ -2,7 +2,19 @@
All notable changes to PeerSpeak are documented here.
## [Unreleased]
## [0.6.3] — 2026-07-06
### Added
- **In-app screen-sharing controls.** A new **Screen sharing** section in Settings, plus a per-call **quality picker** on the Share control, put the whole share pipeline under your control without editing config files. Encode side: quality preset, bitrate, framerate, maximum resolution, maximum viewers, a force-software-encode switch, and an escape hatch for extra pixelpass arguments. Playback side: choose **mpv or VLC**, toggle **hardware decoding**, pick a buffering posture (low-latency vs. smooth), set the demuxer cache, and pass extra mpv arguments. Everything is stored locally in your config and defaults are unchanged, so existing setups keep working as-is.
### Fixed
- **Shared video no longer freezes on the first frame while audio keeps playing.** Hardware decoding now defaults **off**; forcing `--hwdec=auto` stalled some viewers' hardware decoder on frame 1. You can re-enable hardware decoding from the new Screen sharing settings if your machine handles it well.
- **The per-call quality picker is now honored.** The inline quality dropdown next to the Share button was being reset to the saved default before a share started, so every share silently used the default quality regardless of what you picked.
- **VLC now respects your playback settings.** VLC hardware-decodes by default, so a VLC viewer previously ignored the hardware-decode toggle (and could hit the same frame-1 freeze) and the buffering posture. VLC viewers now map both settings onto VLC's own options.
[0.6.3]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.3
## [0.6.2] — 2026-07-03
### Fixed
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
@@ -13,6 +25,8 @@ All notable changes to PeerSpeak are documented here.
### Licensing
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
[0.6.2]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.2
## [0.6.0] — 2026-06-28
### Added
Generated
+5 -5
View File
@@ -200,9 +200,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "arbitrary"
@@ -3682,9 +3682,9 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]]
name = "memmap2"
version = "0.9.10"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.6.0"
version = "0.6.3"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.6.0"
version = "0.6.3"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT"
+22
View File
@@ -0,0 +1,22 @@
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
&& let Some(reference) = head.strip_prefix("ref: ")
{
println!("cargo:rerun-if-changed=.git/{}", reference.trim());
}
let short = Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=PEERSPEAK_GIT_SHORT={short}");
}
+65 -39
View File
@@ -1,19 +1,28 @@
# 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.
Current status: PeerSpeak cross-compiles to `x86_64-pc-windows-gnu` from Linux and
has passed an older native Windows 11 VM smoke test for launch, GUI render, call
join, and audio flow. The build environment is **not** the Windows VM; current
Windows binaries are built from Linux, normally inside the `peerspeak-win`
distrobox or with the same GNU target environment.
The Windows runtime still trails Linux in a few important areas. See the Claude
handoff file `windows-parity-audit.md` for the full audit and task breakdown.
## What works today
| Area | Status |
|---|---|
| GUI | Iced/wgpu builds and renders under Wine. |
| Networking | Iroh QUIC transport and gossip compile on Windows. |
| GUI | Iced/wgpu builds for Windows and rendered in the Windows 11 VM. |
| Networking | Iroh QUIC transport and gossip compile on Windows; VM call reached two peers. |
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
| Device selection | cpal enumerates input/output devices; see caveat below about stable IDs. |
| Resampling/remap | WASAPI devices can run non-48 kHz formats; PeerSpeak converts at the backend boundary. |
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
| Identity | `ring` identity generation/load is platform-neutral. |
| Identity/config | Stored through `dirs` under the Windows profile. |
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
| Game detection | Steam registry `RunningAppID` plus Toolhelp process-scan fallback compile on Windows. |
| File dialogs | `rfd` uses the native Win32 dialog backend. |
Windows paths are resolved through `dirs`:
@@ -23,55 +32,72 @@ Windows paths are resolved through `dirs`:
## Building
### Native Windows
### Cross-compile from Linux
Install MSVC Build Tools and CMake, then build normally:
Preferred local path:
```powershell
cargo build --release
```sh
distrobox enter peerspeak-win -- bash -lc '
cd ~/git/butter/peerspeak &&
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
'
```
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
Equivalent direct command when the host has the GNU target, MinGW, `rust-src`, and
CMake available:
```sh
CMAKE_POLICY_VERSION_MINIMUM=3.5 RUSTC_BOOTSTRAP=1 \
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak \
-Z build-std=std,panic_abort
```
`CMAKE_POLICY_VERSION_MINIMUM=3.5` is required with host CMake 4.x because the
vendored Opus build used by `audiopus_sys` still declares an old minimum CMake
version. Without that env var, the Windows build/check fails during Opus configure.
### Native Windows
A native MSVC build is not the active development path. If used, install MSVC Build
Tools and CMake, then build normally:
```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.
Expect a Windows Firewall prompt the first time the app opens network sockets, or
use the Inno installer option that pre-adds a firewall allow rule. PeerSpeak uses
UDP for QUIC plus relay traffic when direct NAT traversal is unavailable.
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.
The default network mode keeps the n0 relay available for NAT traversal without
publishing presence to n0 DNS. 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 | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
| Echo cancellation | Linux-only today. The Windows UI shows it disabled as unavailable. |
| Screen share | Blocked by PixelPass, which is currently Linux-only in practice. PeerSpeak can spawn `pixelpass.exe`, but there is no Windows PixelPass host/viewer parity yet. |
| Device persistence | Uses cpal friendly names as keys. These can duplicate or change across Windows driver/profile changes; stable WASAPI endpoint IDs are still needed. |
| Release hygiene | Keep `.iss` and installer output in sync with `Cargo.toml`; rebuild Windows artifacts during each release. |
| Runtime coverage | The Windows VM smoke test proved an older tester build. Current `main` needs a fresh VM smoke matrix before calling parity current. |
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.
## Current smoke checklist
Before calling a Windows build current, verify on the Windows VM or real Windows
hardware:
- Launch current `peerspeak.exe`; GUI renders and settings open.
- Run `audio_probe.exe 440 30`; listen for glitches and inspect `playout-health`.
- Create/join a Linux <-> Windows room; confirm mic and playback both directions.
- Select input/output devices, restart, and confirm selections persist or fall back clearly.
- Play chimes and custom chime paths.
- Send chat, image/file attachments, and save an attachment through the native dialog.
- Import/play/share/listen to music from Windows file paths.
- Record mixed/stems/both and inspect the WAV output path.
- Exercise friends/presence/recents and the clock-skew banner.
- Test Steam and non-Steam game detection on a real Windows Steam install.
- Install/upgrade/uninstall through the Inno installer, including firewall rule cleanup.
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.5.0.r0.g0000000
pkgver=0.6.2.r319.g8014edf
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
+4
View File
@@ -0,0 +1,4 @@
.tools/
AppDir/
*.AppImage
squashfs-root/
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# AppRun for the PeerSpeak AppImage.
#
# PeerSpeak bundles the pixelpass screen-share helper in usr/bin. We prepend our
# own usr/bin to PATH so peerspeak's $PATH lookup for `pixelpass` finds the
# bundled copy, while the host's tools (gst-launch-1.0, pactl, mpv — which
# pixelpass in turn shells out to) remain reachable via the appended host PATH.
# That no-sandbox spawning is exactly why this app suits AppImage over Flatpak.
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
exec "$HERE/usr/bin/peerspeak" "$@"
+76
View File
@@ -0,0 +1,76 @@
# PeerSpeak AppImage
A "thin" AppImage: the `peerspeak` binary, the bundled `pixelpass` screen-share
helper, a launcher (`AppRun`), and the desktop entry + icon. Run
`./build-appimage.sh` to produce `peerspeak-<version>-x86_64.AppImage`.
## Why thin, and why pixelpass is bundled
PeerSpeak owns voice; **pixelpass** owns pixels. They are never Cargo
dependencies of each other — peerspeak shells out to the `pixelpass` binary over
its CLI. The AppImage co-locates `pixelpass` in `usr/bin`, and `AppRun` prepends
`usr/bin` to `PATH`, so peerspeak's normal `$PATH` lookup finds it with no code
change. Joe gets one file, and screen-share works out of the box.
Almost nothing is bundled: peerspeak's own assets (notification WAVs, avatar
presets, window icon, fonts) are `include_bytes!`-embedded, and the graphics
stack (`libGL`, `libvulkan`, `libwayland-*`, `libxkbcommon`, X11) is dlopen'd at
runtime and on the AppImage excludelist because it must match the host driver.
So the image carries just the two binaries plus a handful of small libs.
## Host requirements
The AppImage runs on any reasonably current glibc-based distro that has:
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
on AMD/Intel or the NVIDIA driver all work.
- **PipeWire** (with the PulseAudio shim, for `pactl`).
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
it prints the exact package names for your distro if any are missing:
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
good/bad/ugly, libav, and the PipeWire plugin),
- **mpv** (or vlc) for the viewer side,
- on X11, `xwininfo` for single-window capture.
On Arch/Artix that is one pacman line, e.g.:
```sh
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
gst-plugins-ugly gst-libav gst-plugin-pipewire mpv xorg-xwininfo libpulse
```
(Add `gstreamer-vaapi` for hardware H.264 encode on AMD/Intel; the software
x264 path always works. On XLibre / X11 the capture path uses `ximagesrc` and
needs no XDG portal — no systemd required.)
## Building for broad compatibility (glibc baseline)
An AppImage requires a host glibc **at least as new** as the build host's. Built
on a rolling distro (glibc 2.43) it only runs on equally-new systems. Build
inside **Ubuntu 24.04** (glibc 2.39, PipeWire 1.0.5) for wide reach — pixelpass's
`pipewire` crate binds the system PipeWire headers and needs PipeWire >= 1.0, so
the older Debian 12 `peerspeak-bookworm` box (PW 0.3.65) cannot build it. 2.39
covers Debian 13+, Fedora 40+, and current rolling distros.
```sh
# One-time: an Ubuntu 24.04 distrobox that reuses the host rustup toolchain.
distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
distrobox enter peerspeak-appimage -- sudo apt-get update
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
curl ca-certificates file patchelf git
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
distrobox enter peerspeak-appimage -- env \
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
./packaging/appimage/build-appimage.sh
```
## Caveats
- **Hardware encode (VAAPI)** uses the host GPU driver and can't be bundled; the
software x264 path always works.
- The bundled `pixelpass` is built headless (no `gui` feature) — it is only ever
driven by peerspeak, never launched standalone from this image.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Build a "thin" PeerSpeak AppImage that also bundles the pixelpass screen-share
# helper.
#
# PeerSpeak is an iced/wgpu GUI app; pixelpass is the separate screen-share
# orchestrator peerspeak shells out to (never a Cargo dependency). Both link
# almost nothing — the graphics stack (libGL, libvulkan, wayland, xkbcommon,
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
# binaries plus their handful of non-excludelisted libs. The custom AppRun
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
# pixelpass, while the host's tools stay reachable.
#
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
#
# Usage: packaging/appimage/build-appimage.sh
# Output: packaging/appimage/peerspeak-<version>-x86_64.AppImage
#
# Build inside an Ubuntu 24.04 distrobox (glibc 2.39, PipeWire 1.0.5) for broad
# reach — pixelpass's `pipewire` crate needs PipeWire >= 1.0 headers, so the
# older peerspeak-bookworm box (PW 0.3.65) cannot build it. The 2.39 baseline
# covers Debian 13+, Fedora 40+, and all current rolling distros. See README.md.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
tools="$here/.tools"
appdir="$here/AppDir"
mkdir -p "$tools"
# linuxdeploy is itself an AppImage; run it without FUSE so this works in a
# container / on CI without libfuse2.
export APPIMAGE_EXTRACT_AND_RUN=1
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
export VERSION
# Isolated target dirs so an old-glibc box build never clobbers the host target/.
cache="${PEERSPEAK_APPIMAGE_CACHE:-$HOME/.cache/peerspeak-appimage}"
ps_target="$cache/peerspeak-target"
pp_target="$cache/pixelpass-target"
# The pixelpass screen-share helper we bundle. Sibling checkout by default.
pixelpass_repo="${PIXELPASS_REPO:-$repo/../pixelpass}"
if [ ! -d "$pixelpass_repo" ]; then
echo "!! pixelpass repo not found at $pixelpass_repo (set PIXELPASS_REPO)" >&2
exit 1
fi
echo ">> building peerspeak (release)"
( cd "$repo" && CARGO_TARGET_DIR="$ps_target" cargo build --release )
ps_bin="$ps_target/release/peerspeak"
# Headless pixelpass: peerspeak drives it via `--host`/viewer + `--output json`,
# never its GUI, so the default (no `gui` feature) keeps the GL toolkit out.
echo ">> building pixelpass (release, headless) from $pixelpass_repo"
( cd "$pixelpass_repo" && CARGO_TARGET_DIR="$pp_target" cargo build --release )
pp_bin="$pp_target/release/pixelpass"
echo ">> fetching linuxdeploy"
ld="$tools/linuxdeploy-x86_64.AppImage"
if [ ! -x "$ld" ]; then
curl -fL --retry 3 -o "$ld" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
chmod +x "$ld"
fi
echo ">> assembling AppDir"
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin"
install -m755 "$ps_bin" "$appdir/usr/bin/peerspeak"
install -m755 "$pp_bin" "$appdir/usr/bin/pixelpass"
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
# -e (repeated): analyse both binaries for libraries to bundle; excludelisted
# graphics/glibc libs are skipped. -d/-i: desktop entry + icon.
# --custom-apprun: our launcher that puts the bundled pixelpass on PATH.
( cd "$here" && OUTPUT="peerspeak-${VERSION}-x86_64.AppImage" "$ld" \
--appdir "$appdir" \
-e "$appdir/usr/bin/peerspeak" \
-e "$appdir/usr/bin/pixelpass" \
-d "$repo/packaging/peerspeak.desktop" \
-i "$repo/assets/icons/peerspeak-256.png" \
--icon-filename peerspeak \
--custom-apprun "$here/AppRun" \
--output appimage )
echo ">> done: $here/peerspeak-${VERSION}-x86_64.AppImage"
+87
View File
@@ -0,0 +1,87 @@
# Debian / Ubuntu `.deb` build
This documents how the `peerspeak_*.deb` is produced, so the deb path is as
self-documenting as the Arch (`packaging/PKGBUILD`) and AppImage paths.
The deb **recipe itself** lives in-repo as the `[package.metadata.deb]` block in
the top-level `Cargo.toml` (cargo-deb's equivalent of a PKGBUILD). This file
documents only the **build environment**, which is otherwise undiscoverable from
a fresh clone.
## TL;DR
```sh
# one-time: create + provision the build box (see "Build environment" below)
distrobox enter peerspeak-bookworm -- bash -lc '
source ~/.cargo/env
cd ~/git/butter/peerspeak
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak # MANDATORY, see below
cargo deb
'
# output: $CARGO_TARGET_DIR/debian/peerspeak_<version>-1_amd64.deb
```
## Build environment
- **Base: a Debian 12 (bookworm) distrobox named `peerspeak-bookworm`.**
Created with `distrobox create --name peerspeak-bookworm --image debian:12`.
Bookworm ships **glibc 2.36**, which sets the widest practical compatibility
floor (see "glibc floor" below).
- **NEVER build the `.deb` on the Arch host.** Two independent reasons:
1. The Arch host's glibc is far newer, so the resulting `.deb` would demand a
glibc no normal Debian/Ubuntu user has, and ships an empty `Depends`.
2. distrobox shares `$HOME` (and therefore the repo's `target/`) with the host,
so a host build links Arch-compiled C objects into the "Debian" binary.
### One-time provisioning inside the box
```sh
distrobox enter peerspeak-bookworm
sudo apt update
sudo apt install -y build-essential pkg-config clang libclang-dev \
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
# clang/libclang -> pipewire-sys bindgen ; libxcb1-dev -> link
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
cargo install cargo-deb
```
### The mandatory separate `CARGO_TARGET_DIR`
Because distrobox shares `$HOME`, the repo's default `target/` is the **same
directory** the Arch host builds into. If you run `cargo deb` without overriding
the target dir, cargo will happily reuse Arch-built `.o`/rlib artifacts and link
them into the Debian binary, producing a `.deb` that crashes or demands the
host's glibc.
Always point the build at a box-local cache:
```sh
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak
```
(Run `cargo clean` first if you ever suspect a polluted target dir.)
## glibc floor
The `.deb` is built against the build box's glibc, which becomes the install
floor (`libc6 (>= 2.36)` lands in `Depends` via `$auto`):
| Build box | glibc | Runs on |
|----------------------|-------|--------------------------------------|
| `debian:12` (current)| 2.36 | Debian 12+, Ubuntu 24.04+ (glibc ≥ 2.36) |
| `ubuntu:26.04` (old) | 2.43 | Ubuntu 26.04+ only — too narrow, abandoned |
If a friend is on something even older than Debian 12, drop the floor further by
recreating the box from an older base image and rebuilding.
## Runtime `Depends` / `Recommends`
- `Depends = "$auto"` — cargo-deb runs `dpkg-shlibdeps`, which discovers the
linked shared libraries (PipeWire, Opus, ALSA, xcb, glibc, …) automatically.
- `Recommends = "pixelpass, mpv"``pixelpass` provides in-room screen sharing
and `mpv` is the screen-share viewer (these are companion programs invoked as
subprocesses, not linked libraries, so they are Recommends not Depends).
See `pixelpass`'s own `packaging/debian/README.md` for why **its** `Depends`
lists the whole GStreamer stack explicitly.
+1 -1
View File
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
## 1. Install it
1. Double-click **`peerspeak-0.4.0-setup.exe`** (the file I sent you).
1. Double-click **`peerspeak-<version>-setup.exe`** (the file I sent you).
2. **Windows will probably show a blue "Windows protected your PC" warning.**
This is normal — it shows up for any app that isn't from a big company with a
+3 -2
View File
@@ -11,8 +11,9 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
## Version compatibility
The installer version tracks the crate version in `Cargo.toml` (currently
**0.4.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
The installer version tracks the release version in `Cargo.toml` — keep
`MyAppVersion` in `peerspeak.iss` in sync when cutting a release. Do not reuse an
old installer filename after a crate-version bump.
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
peers on different MINOR versions can't connect (they fail fast at the
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.6.0"
#define MyAppVersion "0.6.3"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+2438 -919
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.
///
+461 -91
View File
@@ -12,9 +12,11 @@
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
//! networking, no realtime work. The mixer (a non-RT task) drives it.
use std::collections::{HashMap, VecDeque};
use std::collections::{HashMap, HashSet, VecDeque};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
use iroh::EndpointId;
@@ -24,6 +26,8 @@ use crate::core::jitter::FRAME_SAMPLES;
/// Cap on the silence chunk written at once when pre-padding a late joiner, so a
/// long-running call can't trigger a single multi-hundred-MB allocation.
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
const WRITER_QUEUE_CYCLES: usize = 256;
const DROP_LOG_INTERVAL_CYCLES: u64 = 256;
/// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
@@ -56,41 +60,6 @@ pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf
))
}
/// One output track: its WAV writer plus whether it has been written *this*
/// cycle (so `end_cycle` knows which tracks to pad with silence).
struct Track {
writer: WavWriter,
written_this_cycle: bool,
}
impl Track {
fn create(path: &Path) -> io::Result<Self> {
Ok(Self {
writer: WavWriter::new(path)?,
written_this_cycle: false,
})
}
/// Append `frame` fitted to exactly `frame_samples` (zero-padded if short),
/// and mark the track as written for this cycle.
fn write_frame(&mut self, frame: &[i16], frame_samples: usize) -> io::Result<()> {
self.writer.write_samples(&fit(frame, frame_samples))?;
self.written_this_cycle = true;
Ok(())
}
/// Append `samples` of silence (no cycle-marking — used for padding).
fn write_silence(&mut self, samples: usize) -> io::Result<()> {
let mut remaining = samples;
while remaining > 0 {
let n = remaining.min(SILENCE_CHUNK);
self.writer.write_samples(&vec![0i16; n])?;
remaining -= n;
}
Ok(())
}
}
/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
/// happen — Opus frames are uniform), zero-padded if shorter.
fn fit(frame: &[i16], n: usize) -> Vec<i16> {
@@ -108,7 +77,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("--") {
@@ -120,41 +95,210 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
format!("{slug}-{short}.wav")
}
/// A live multitrack recording: per-peer stems + your mic, plus an optional
/// mixed track, all under one session directory and clocked together.
pub struct MultitrackRecorder {
dir: PathBuf,
frame_samples: usize,
/// Cycles recorded so far = the shared length (in frames) of every track.
cycles: u64,
peers: HashMap<EndpointId, Track>,
/// Your mic track. Fed asynchronously from the capture thread via
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
/// one frame per `end_cycle` so it aligns with the cycle clock.
mic: WavWriter,
mic_fifo: VecDeque<i16>,
/// Present in "Both" mode (stems + mixed), absent in "stems only".
mix: Option<Track>,
#[derive(Default)]
struct PendingCycle {
new_peers: Vec<NewPeer>,
peer_frames: HashMap<EndpointId, Vec<i16>>,
mix_frame: Option<Vec<i16>>,
}
impl MultitrackRecorder {
/// Create a recording in `dir` (which must already exist). `with_mix` adds
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
struct NewPeer {
id: EndpointId,
filename: String,
}
struct CycleBatch {
new_peers: Vec<NewPeer>,
mic_frame: Vec<i16>,
mix_frame: Option<Vec<i16>>,
peer_frames: HashMap<EndpointId, Vec<i16>>,
}
trait SampleWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()>;
fn finalize(self) -> io::Result<()>;
}
impl SampleWriter for WavWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
WavWriter::write_samples(self, samples)
}
fn finalize(self) -> io::Result<()> {
WavWriter::finalize(self)
}
}
struct WriterState<W> {
dir: PathBuf,
frame_samples: usize,
peers: HashMap<EndpointId, W>,
mic: W,
mix: Option<W>,
cycles_written: u64,
}
impl WriterState<WavWriter> {
fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
let mic = WavWriter::new(&dir.join("me.wav"))?;
let mix = if with_mix {
Some(Track::create(&dir.join("mix.wav"))?)
Some(WavWriter::new(&dir.join("mix.wav"))?)
} else {
None
};
Ok(Self {
dir: dir.to_path_buf(),
frame_samples,
cycles: 0,
peers: HashMap::new(),
mic,
mic_fifo: VecDeque::new(),
mix,
cycles_written: 0,
})
}
}
impl<W: SampleWriter> WriterState<W> {
fn apply_batch<F>(&mut self, batch: &CycleBatch, mut create_peer: F) -> io::Result<()>
where
F: FnMut(&Path) -> io::Result<W>,
{
for peer in &batch.new_peers {
if !self.peers.contains_key(&peer.id) {
let writer = create_peer(&self.dir.join(&peer.filename))?;
self.peers.insert(peer.id, writer);
let pad = self.back_pad_samples()?;
let writer = self.peers.get_mut(&peer.id).unwrap();
Self::write_silence(writer, pad)?;
}
}
self.mic.write_samples(&batch.mic_frame)?;
if let Some(mix) = self.mix.as_mut() {
if let Some(frame) = batch.mix_frame.as_deref() {
mix.write_samples(frame)?;
} else {
Self::write_silence(mix, self.frame_samples)?;
}
}
let silence = vec![0i16; self.frame_samples];
for (id, writer) in &mut self.peers {
let frame = batch
.peer_frames
.get(id)
.map(Vec::as_slice)
.unwrap_or(&silence);
writer.write_samples(frame)?;
}
self.cycles_written += 1;
Ok(())
}
fn back_pad_samples(&self) -> io::Result<usize> {
let cycles = usize::try_from(self.cycles_written)
.map_err(|_| io::Error::other("multitrack recording too long"))?;
cycles
.checked_mul(self.frame_samples)
.ok_or_else(|| io::Error::other("multitrack recording too long"))
}
fn write_silence(writer: &mut W, samples: usize) -> io::Result<()> {
let mut remaining = samples;
let silence = vec![0i16; remaining.min(SILENCE_CHUNK)];
while remaining > 0 {
let n = remaining.min(silence.len());
writer.write_samples(&silence[..n])?;
remaining -= n;
}
Ok(())
}
fn finalize(self) -> io::Result<()> {
let mut first_finalize_error = None;
record_first_error(&mut first_finalize_error, self.mic.finalize());
if let Some(mix) = self.mix {
record_first_error(&mut first_finalize_error, mix.finalize());
}
for writer in self.peers.into_values() {
record_first_error(&mut first_finalize_error, writer.finalize());
}
if let Some(e) = first_finalize_error {
Err(e)
} else {
Ok(())
}
}
}
fn record_first_error(slot: &mut Option<io::Error>, result: io::Result<()>) {
if slot.is_none()
&& let Err(e) = result
{
*slot = Some(e);
}
}
/// Applies whole-cycle batches on the writer thread. Each applied batch appends
/// exactly `frame_samples` to every existing track, and a dropped batch never
/// reaches this loop for any track, so stem lengths stay equal even when the
/// bounded queue applies back-pressure.
fn writer_thread_main(
mut state: WriterState<WavWriter>,
batch_rx: mpsc::Receiver<CycleBatch>,
) -> io::Result<()> {
let mut first_write_error = None;
for batch in batch_rx {
if first_write_error.is_none()
&& let Err(e) = state.apply_batch(&batch, WavWriter::new)
{
first_write_error = Some(e);
}
}
let finalize_result = state.finalize();
if let Some(e) = first_write_error {
Err(e)
} else {
finalize_result
}
}
/// A live multitrack recording: per-peer stems + your mic, plus an optional
/// mixed track, all under one session directory and clocked together.
pub struct MultitrackRecorder {
dir: PathBuf,
frame_samples: usize,
known_peers: HashSet<EndpointId>,
/// Your mic track. Fed asynchronously from the capture thread via
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
/// one frame per `end_cycle` so it aligns with the cycle clock.
mic_fifo: VecDeque<i16>,
/// Present in "Both" mode (stems + mixed), absent in "stems only".
with_mix: bool,
batch_tx: SyncSender<CycleBatch>,
writer_thread: JoinHandle<io::Result<()>>,
dropped_cycles: u64,
pending: PendingCycle,
}
impl MultitrackRecorder {
/// Create a recording in `dir` (which must already exist). `with_mix` adds
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
let writer_state = WriterState::create(dir, frame_samples, with_mix)?;
let (batch_tx, batch_rx) = mpsc::sync_channel(WRITER_QUEUE_CYCLES);
let writer_thread = thread::spawn(move || writer_thread_main(writer_state, batch_rx));
Ok(Self {
dir: dir.to_path_buf(),
frame_samples,
known_peers: HashSet::new(),
mic_fifo: VecDeque::new(),
with_mix,
batch_tx,
writer_thread,
dropped_cycles: 0,
pending: PendingCycle::default(),
})
}
@@ -167,12 +311,14 @@ impl MultitrackRecorder {
/// so it aligns with the others. Idempotent: a peer already tracked is left
/// as-is (re-announce / name change doesn't restart their file).
pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> {
if self.peers.contains_key(&id) {
if self.known_peers.contains(&id) {
return Ok(());
}
let mut track = Track::create(&self.dir.join(track_filename(name, &id)))?;
track.write_silence(self.cycles as usize * self.frame_samples)?;
self.peers.insert(id, track);
self.known_peers.insert(id);
self.pending.new_peers.push(NewPeer {
id,
filename: track_filename(name, &id),
});
Ok(())
}
@@ -180,11 +326,13 @@ impl MultitrackRecorder {
/// registered yet (write raced ahead of the join event), auto-register it
/// with an id-only name so no audio is dropped.
pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> {
if !self.peers.contains_key(&id) {
if !self.known_peers.contains(&id) {
self.add_peer(id, "")?;
}
let fs = self.frame_samples;
self.peers.get_mut(&id).unwrap().write_frame(frame, fs)
self.pending
.peer_frames
.insert(id, fit(frame, self.frame_samples));
Ok(())
}
/// Buffer a frame of your transmitted mic audio (called from the capture
@@ -209,9 +357,8 @@ impl MultitrackRecorder {
/// Record the finished mixed-bus frame for the current cycle (no-op in
/// stems-only mode).
pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
let fs = self.frame_samples;
if let Some(mix) = self.mix.as_mut() {
mix.write_frame(frame, fs)?;
if self.with_mix {
self.pending.mix_frame = Some(fit(frame, self.frame_samples));
}
Ok(())
}
@@ -224,28 +371,63 @@ impl MultitrackRecorder {
// Mic: always one frame per cycle, drained from the FIFO (silence on
// underrun), so it tracks the cycle clock like the peer stems.
let mic_frame = self.drain_mic(fs);
self.mic.write_samples(&mic_frame)?;
// Peers + the optional mix track: pad any not written this cycle.
for track in self.peers.values_mut().chain(self.mix.as_mut()) {
if !track.written_this_cycle {
track.write_silence(fs)?;
let mut pending = std::mem::take(&mut self.pending);
pending.new_peers.sort_by(|a, b| {
a.filename
.cmp(&b.filename)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
});
let batch = CycleBatch {
new_peers: pending.new_peers,
mic_frame,
mix_frame: if self.with_mix {
pending.mix_frame
} else {
None
},
peer_frames: pending.peer_frames,
};
match self.batch_tx.try_send(batch) {
Ok(()) => Ok(()),
Err(TrySendError::Full(batch)) => {
for peer in &batch.new_peers {
self.known_peers.remove(&peer.id);
}
self.dropped_cycles = self.dropped_cycles.saturating_add(1);
if self.dropped_cycles == 1
|| self.dropped_cycles.is_multiple_of(DROP_LOG_INTERVAL_CYCLES)
{
crate::log_msg(&format!(
"multitrack recording: writer queue full; dropped {} cycle(s)",
self.dropped_cycles
));
}
Ok(())
}
track.written_this_cycle = false;
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"multitrack writer thread stopped",
)),
}
self.cycles += 1;
Ok(())
}
/// Finalize every track's WAV header. Consumes the recorder.
pub fn finalize(self) -> io::Result<()> {
self.mic.finalize()?;
if let Some(mix) = self.mix {
mix.writer.finalize()?;
}
for (_, track) in self.peers {
track.writer.finalize()?;
}
Ok(())
let Self {
dir: _,
frame_samples: _,
known_peers: _,
mic_fifo: _,
with_mix: _,
batch_tx,
writer_thread,
dropped_cycles: _,
pending: _,
} = self;
drop(batch_tx);
writer_thread
.join()
.unwrap_or_else(|_| Err(io::Error::other("multitrack writer thread panicked")))
}
}
@@ -271,6 +453,51 @@ mod tests {
d
}
#[derive(Default)]
struct TestWriter {
samples: Vec<i16>,
}
impl SampleWriter for TestWriter {
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
self.samples.extend_from_slice(samples);
Ok(())
}
fn finalize(self) -> io::Result<()> {
Ok(())
}
}
fn test_writer_state(frame_samples: usize, with_mix: bool) -> WriterState<TestWriter> {
WriterState {
dir: PathBuf::new(),
frame_samples,
peers: HashMap::new(),
mic: TestWriter::default(),
mix: if with_mix {
Some(TestWriter::default())
} else {
None
},
cycles_written: 0,
}
}
fn test_batch(
new_peers: Vec<NewPeer>,
mic_frame: Vec<i16>,
mix_frame: Option<Vec<i16>>,
peer_frames: Vec<(EndpointId, Vec<i16>)>,
) -> CycleBatch {
CycleBatch {
new_peers,
mic_frame,
mix_frame,
peer_frames: peer_frames.into_iter().collect(),
}
}
#[test]
fn fit_pads_and_truncates() {
assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
@@ -284,7 +511,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"));
}
@@ -302,6 +532,110 @@ mod tests {
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn apply_batch_advances_existing_tracks_and_back_pads_late_peer() {
let frame = 3;
let early = an_id();
let late = an_id();
let mut state = test_writer_state(frame, true);
state.cycles_written = 2;
state.mic.samples = vec![8; 2 * frame];
state.mix.as_mut().unwrap().samples = vec![6; 2 * frame];
state.peers.insert(
early,
TestWriter {
samples: vec![1; 2 * frame],
},
);
let batch = test_batch(
vec![NewPeer {
id: late,
filename: "late.wav".to_string(),
}],
vec![9; frame],
None,
vec![(early, vec![2; frame]), (late, vec![7; frame])],
);
state
.apply_batch(&batch, |_| Ok(TestWriter::default()))
.unwrap();
assert_eq!(state.cycles_written, 3);
assert_eq!(state.mic.samples.len(), 3 * frame);
assert_eq!(state.mix.as_ref().unwrap().samples.len(), 3 * frame);
assert_eq!(
&state.mix.as_ref().unwrap().samples[2 * frame..],
&[0, 0, 0]
);
assert_eq!(state.peers.get(&early).unwrap().samples.len(), 3 * frame);
assert_eq!(
&state.peers.get(&early).unwrap().samples[2 * frame..],
&[2, 2, 2]
);
assert_eq!(
state.peers.get(&late).unwrap().samples,
vec![0, 0, 0, 0, 0, 0, 7, 7, 7],
"late peer is back-padded by completed cycles before this batch"
);
}
#[test]
fn skipped_batches_keep_all_tracks_equal_length() {
let frame = 2;
let p1 = an_id();
let p2 = an_id();
let mut state = test_writer_state(frame, true);
let first = test_batch(
vec![
NewPeer {
id: p1,
filename: "p1.wav".to_string(),
},
NewPeer {
id: p2,
filename: "p2.wav".to_string(),
},
],
vec![1; frame],
Some(vec![5; frame]),
vec![(p1, vec![10; frame]), (p2, vec![20; frame])],
);
state
.apply_batch(&first, |_| Ok(TestWriter::default()))
.unwrap();
let _dropped_cycle = test_batch(
Vec::new(),
vec![2; frame],
Some(vec![6; frame]),
vec![(p1, vec![11; frame])],
);
let after_drop = test_batch(
Vec::new(),
vec![3; frame],
None,
vec![(p1, vec![12; frame])],
);
state
.apply_batch(&after_drop, |_| Ok(TestWriter::default()))
.unwrap();
let expected = 2 * frame;
assert_eq!(state.cycles_written, 2);
assert_eq!(state.mic.samples.len(), expected);
assert_eq!(state.mix.as_ref().unwrap().samples.len(), expected);
assert_eq!(state.peers.get(&p1).unwrap().samples.len(), expected);
assert_eq!(state.peers.get(&p2).unwrap().samples.len(), expected);
assert_eq!(
&state.peers.get(&p2).unwrap().samples[frame..],
&[0, 0],
"peer absent from an applied batch gets silence for that cycle"
);
}
#[test]
fn all_tracks_equal_length_after_n_cycles() {
let dir = tmpdir("equal");
@@ -327,10 +661,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 +699,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 +726,28 @@ 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"
);
}
#[cfg(unix)]
#[test]
fn async_peer_create_error_surfaces_at_finalize() {
use std::os::unix::fs::PermissionsExt;
let dir = tmpdir("asyncerr");
let mut rec = MultitrackRecorder::create(&dir, 4, false).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
rec.add_peer(an_id(), "blocked").unwrap();
rec.end_cycle().unwrap();
let result = rec.finalize();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
let err = result.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
let _ = std::fs::remove_dir_all(&dir);
}
}
+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);
+151 -43
View File
@@ -2,21 +2,26 @@
//!
//! Records the **full call as you experienced it**: the mixed incoming audio
//! (everyone you hear) summed with your own transmitted mic, into a single mono
//! WAV. Writing is driven by the playout mixer (one [`Recorder::write_frame`]
//! per produced 20ms frame, paced by the hardware clock); your mic arrives
//! separately from the capture thread via [`Recorder::push_mic`] and is buffered
//! in a small FIFO so the two independently-clocked streams stay roughly aligned.
//! WAV. Mixing/enqueue is driven by the playout mixer (one
//! [`Recorder::write_frame`] per produced 20ms frame, paced by the hardware
//! clock), while disk writes happen on a dedicated writer thread; your mic
//! arrives separately from the capture thread via [`Recorder::push_mic`] and is
//! buffered in a small FIFO so the two independently-clocked streams stay
//! roughly aligned.
//! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag
//! between your voice and the recording is bounded) — harmless for a voice
//! recording, no realtime crackle concern.
//!
//! No external crates: the WAV writer emits the 44-byte canonical header itself
//! and patches the two size fields on [`Recorder::finalize`].
//! and patches the two size fields on the writer thread during
//! [`Recorder::finalize`].
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, SyncSender, TrySendError};
use std::thread::{self, JoinHandle};
/// Capture sample rate (mono, 48kHz, matching the rest of the audio path).
const SAMPLE_RATE: u32 = 48_000;
@@ -25,6 +30,8 @@ const CHANNELS: u16 = 1;
const RIFF_DATA_OVERHEAD: u64 = 36;
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
const MAX_NAME_ATTEMPTS: usize = 1_000;
const WRITER_QUEUE_FRAMES: usize = 256;
const DROP_LOG_INTERVAL_FRAMES: u64 = 256;
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
/// if the capture clock runs persistently faster than playout — past this we drop
@@ -118,13 +125,15 @@ impl WavWriter {
}
}
/// A live call recorder: a [`WavWriter`] plus a small mic FIFO that aligns your
/// transmitted mic with the playout mixer's incoming-mix frames.
/// A live call recorder: a writer-thread queue plus a small mic FIFO that aligns
/// your transmitted mic with the playout mixer's incoming-mix frames.
pub struct Recorder {
writer: WavWriter,
frame_tx: SyncSender<Vec<i16>>,
writer_thread: JoinHandle<io::Result<()>>,
/// Your transmitted mic samples, awaiting alignment with the next mix frame.
mic_fifo: VecDeque<i16>,
path: PathBuf,
dropped_frames: u64,
}
impl Recorder {
@@ -142,10 +151,15 @@ impl Recorder {
let path = dir.join(name);
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(file) => {
let writer = WavWriter::from_file(file)?;
let (frame_tx, frame_rx) = mpsc::sync_channel(WRITER_QUEUE_FRAMES);
let writer_thread = thread::spawn(move || writer_thread_main(writer, frame_rx));
return Ok(Self {
writer: WavWriter::from_file(file)?,
frame_tx,
writer_thread,
mic_fifo: VecDeque::new(),
path,
dropped_frames: 0,
});
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
@@ -179,21 +193,73 @@ impl Recorder {
/// treated as silence (you weren't transmitting), so quiet stretches record
/// the incoming mix alone.
pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
let mut out = Vec::with_capacity(mixed.len());
for &m in mixed {
let mic = self.mic_fifo.pop_front().unwrap_or(0);
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
out.push(sum as i16);
let out = mix_with_mic(mixed, &mut self.mic_fifo);
match self.frame_tx.try_send(out) {
Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => {
self.dropped_frames = self.dropped_frames.saturating_add(1);
if self.dropped_frames == 1
|| self.dropped_frames.is_multiple_of(DROP_LOG_INTERVAL_FRAMES)
{
crate::log_msg(&format!(
"recording: writer queue full; dropped {} frame(s)",
self.dropped_frames
));
}
Ok(())
}
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"recording writer thread stopped",
)),
}
self.writer.write_samples(&out)
}
/// Finish the file, patching its size fields. Consumes the recorder.
pub fn finalize(self) -> io::Result<()> {
self.writer.finalize()
let Self {
frame_tx,
writer_thread,
mic_fifo: _,
path: _,
dropped_frames: _,
} = self;
drop(frame_tx);
writer_thread
.join()
.unwrap_or_else(|_| Err(io::Error::other("recording writer thread panicked")))
}
}
fn writer_thread_main(mut writer: WavWriter, frame_rx: mpsc::Receiver<Vec<i16>>) -> io::Result<()> {
let mut first_write_error = None;
for frame in frame_rx {
if first_write_error.is_none()
&& let Err(e) = writer.write_samples(&frame)
{
first_write_error = Some(e);
}
}
let finalize_result = writer.finalize();
if let Some(e) = first_write_error {
Err(e)
} else {
finalize_result
}
}
fn mix_with_mic(mixed: &[i16], mic_fifo: &mut VecDeque<i16>) -> Vec<i16> {
let mut out = Vec::with_capacity(mixed.len());
for &m in mixed {
let mic = mic_fifo.pop_front().unwrap_or(0);
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
out.push(sum as i16);
}
out
}
/// Civil date (year, month, day) from a count of days since the Unix epoch.
/// Howard Hinnant's `civil_from_days`; valid across the whole practical range.
fn civil_from_days(z: i64) -> (i64, u32, u32) {
@@ -222,6 +288,23 @@ pub fn timestamp_filename(unix_secs: u64) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
fn unique_temp_dir(prefix: &str) -> PathBuf {
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}-{}-{id}", std::process::id()))
}
fn read_wav_samples(path: &Path) -> (Vec<u8>, Vec<i16>) {
let bytes = std::fs::read(path).unwrap();
let samples = bytes[44..]
.chunks_exact(2)
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
.collect();
(bytes, samples)
}
#[test]
fn timestamp_filename_is_utc_and_padded() {
@@ -236,10 +319,7 @@ mod tests {
#[test]
fn same_second_recordings_get_unique_files_without_truncation() {
let dir = std::env::temp_dir().join(format!(
"peerspeak-collision-{}",
std::process::id()
));
let dir = unique_temp_dir("peerspeak-collision");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
@@ -258,6 +338,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recorder_thread_writes_mixed_samples_and_header_on_finalize() {
let dir = unique_temp_dir("peerspeak-recorder-thread");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut recorder = Recorder::create(&dir, 1_700_000_123).unwrap();
let path = recorder.path().to_path_buf();
recorder.push_mic(&[1000, i16::MAX, -1000, i16::MIN, 2222]);
recorder.write_frame(&[10, 20, -32700]).unwrap();
recorder.push_mic(&[300, -300]);
recorder
.write_frame(&[0, 1000, i16::MAX, i16::MIN])
.unwrap();
recorder.finalize().unwrap();
let expected = vec![1010, i16::MAX, i16::MIN, i16::MIN, 3222, i16::MAX, i16::MIN];
let expected_data_bytes = u32::try_from(expected.len() * 2).unwrap();
let (bytes, samples) = read_wav_samples(&path);
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
assert_eq!(&bytes[36..40], b"data");
let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
assert_eq!(data, expected_data_bytes);
assert_eq!(riff, RIFF_DATA_OVERHEAD as u32 + expected_data_bytes);
assert_eq!(bytes.len(), 44 + expected.len() * 2);
assert_eq!(samples, expected);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn wav_header_round_trips_sizes() {
let dir = std::env::temp_dir();
@@ -300,38 +414,32 @@ mod tests {
#[test]
fn mic_is_summed_with_mix_when_present() {
let dir = std::env::temp_dir();
let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(),
path: PathBuf::new(),
};
r.push_mic(&[1000, 2000, 3000]);
// write_frame pops mic per-sample and sums; we can't read the file mid-stream,
// so assert the FIFO drains exactly by frame length.
r.write_frame(&[10, 20]).unwrap();
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
r.write_frame(&[0, 0]).unwrap();
let mut mic_fifo = VecDeque::from([1000, 2000, 3000]);
let first = mix_with_mic(&[10, 20], &mut mic_fifo);
assert_eq!(first, vec![1010, 2020]);
assert_eq!(mic_fifo.len(), 1, "two samples consumed, one mic left");
let second = mix_with_mic(&[0, 0], &mut mic_fifo);
assert_eq!(second, vec![3000, 0]);
assert_eq!(
r.mic_fifo.len(),
mic_fifo.len(),
0,
"remaining mic sample consumed; rest is silence"
);
let _ = r.finalize();
}
#[test]
fn mic_fifo_is_capped() {
let dir = std::env::temp_dir();
let mut r = Recorder {
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
.unwrap(),
mic_fifo: VecDeque::new(),
path: PathBuf::new(),
};
let dir = unique_temp_dir("peerspeak-cap");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut r = Recorder::create(&dir, 1_700_000_001).unwrap();
r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap");
let _ = r.finalize();
r.finalize().unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
}
+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();
+3
View File
@@ -20,6 +20,9 @@ pub trait AudioDecoder: Send {
/// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss,
/// enabling the decoder to perform packet loss concealment (PLC).
fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError>;
/// Reconstructs the previous lost frame from the next packet's in-band FEC.
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError>;
}
pub mod opus_impl;
+182 -17
View File
@@ -1,5 +1,49 @@
use crate::codec::{AudioEncoder, AudioDecoder, CodecError};
use opus::{Encoder, Decoder, Application, Channels};
use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
use crate::config::AudioProfile;
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
/// Concrete libopus encoder settings derived from an [`AudioProfile`]. Plain
/// data, so the profile→params mapping ([`opus_params`]) stays a pure,
/// unit-testable function (W12).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpusParams {
/// Target bitrate in bits/sec.
pub bitrate: i32,
/// Enable in-band forward error correction (loss redundancy in the bitstream).
pub inband_fec: bool,
/// Expected packet-loss percentage (0..=100); tunes how much FEC libopus adds.
pub packet_loss_perc: i32,
/// Discontinuous transmission: stop sending during silence to save bandwidth.
pub dtx: bool,
}
/// Map a named profile to concrete Opus parameters. Pure — the W12 testable seam.
///
/// `BadNetwork` deliberately runs a *lower* bitrate than `Balanced`: in-band FEC
/// redundancy is carried inside the same bitstream, so trimming the base bitrate
/// leaves headroom for the redundancy on a congested link.
pub fn opus_params(profile: AudioProfile) -> OpusParams {
match profile {
AudioProfile::LowLatency => OpusParams {
bitrate: 24_000,
inband_fec: false,
packet_loss_perc: 0,
dtx: false,
},
AudioProfile::Balanced => OpusParams {
bitrate: 32_000,
inband_fec: true,
packet_loss_perc: 10,
dtx: false,
},
AudioProfile::BadNetwork => OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 25,
dtx: false,
},
}
}
pub struct OpusEncoder {
encoder: Encoder,
@@ -8,11 +52,38 @@ 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 })
}
/// Apply concrete codec parameters to the live encoder. Safe to call between
/// frames, so the user can switch profile mid-call.
pub fn apply_params(&mut self, params: &OpusParams) -> Result<(), CodecError> {
self.encoder
.set_bitrate(Bitrate::Bits(params.bitrate))
.map_err(|e| CodecError::Init(format!("set_bitrate: {}", e)))?;
self.encoder
.set_inband_fec(params.inband_fec)
.map_err(|e| CodecError::Init(format!("set_inband_fec: {}", e)))?;
self.encoder
.set_packet_loss_perc(params.packet_loss_perc)
.map_err(|e| CodecError::Init(format!("set_packet_loss_perc: {}", e)))?;
self.encoder
.set_dtx(params.dtx)
.map_err(|e| CodecError::Init(format!("set_dtx: {}", e)))?;
Ok(())
}
/// Apply a named [`AudioProfile`] (shorthand for `apply_params(&opus_params(p))`).
pub fn apply_profile(&mut self, profile: AudioProfile) -> Result<(), CodecError> {
self.apply_params(&opus_params(profile))
}
}
impl AudioEncoder for OpusEncoder {
@@ -20,9 +91,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 +115,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,18 +154,72 @@ 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);
Ok(pcm)
}
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError> {
let channels_count = self.channels_count();
let mut pcm = vec![0i16; self.frame_samples * channels_count];
let decoded_per_channel = self
.decoder
.decode(next_payload, &mut pcm, true)
.map_err(|e| CodecError::Decode(format!("Opus FEC decoding failed: {}", e)))?;
pcm.truncate(decoded_per_channel * channels_count);
Ok(pcm)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_opus_params_mapping() {
let low = opus_params(AudioProfile::LowLatency);
let bal = opus_params(AudioProfile::Balanced);
let bad = opus_params(AudioProfile::BadNetwork);
// LowLatency has no loss redundancy; the other two do.
assert!(!low.inband_fec);
assert_eq!(low.packet_loss_perc, 0);
assert!(bal.inband_fec);
assert!(bad.inband_fec);
// Capture-side gating suppresses silence; no profile adds Opus DTX.
assert!(!low.dtx && !bal.dtx && !bad.dtx);
assert!(bad.packet_loss_perc > bal.packet_loss_perc);
// BadNetwork trims base bitrate to make room for FEC redundancy.
assert!(bad.bitrate < bal.bitrate);
// All bitrates are sane positive voice rates.
for p in [low, bal, bad] {
assert!(p.bitrate > 0 && p.bitrate <= 64_000);
assert!((0..=100).contains(&p.packet_loss_perc));
}
}
#[test]
fn test_apply_profile_sets_bitrate() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
// Every profile applies cleanly to a real encoder...
for profile in AudioProfile::ALL {
encoder.apply_profile(profile).unwrap();
}
// ...and the last-applied bitrate is reflected by the encoder.
encoder.apply_profile(AudioProfile::Balanced).unwrap();
let want = opus_params(AudioProfile::Balanced).bitrate;
assert_eq!(encoder.encoder.get_bitrate().unwrap(), Bitrate::Bits(want));
}
#[test]
fn test_round_trip() {
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
@@ -100,7 +235,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 +248,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 +271,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 +294,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 +307,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"
);
}
}
+505 -31
View File
@@ -1,9 +1,12 @@
use crate::notify::Sound;
use crate::theme::AppTheme;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Relay/discovery posture, trading connectivity against how much the n0
/// infrastructure learns about you. See the network module for details.
@@ -24,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.
@@ -42,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;
@@ -62,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 {
@@ -81,6 +93,60 @@ impl std::fmt::Display for RecordingMode {
}
}
/// Named Opus encoder / network-resilience policy (W12). The user picks a
/// profile instead of raw codec knobs; the concrete libopus parameters live in
/// `codec::opus_impl::opus_params`. Applies live to the running encoder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum AudioProfile {
/// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a
/// clean LAN / low-loss link where added latency matters more than loss.
LowLatency,
/// Sensible default: voice bitrate with in-band FEC for light packet loss.
#[default]
Balanced,
/// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy
/// loss, at a lower bitrate to leave headroom for the redundancy.
BadNetwork,
}
impl AudioProfile {
/// All variants, in picker display order.
pub const ALL: [AudioProfile; 3] = [
AudioProfile::LowLatency,
AudioProfile::Balanced,
AudioProfile::BadNetwork,
];
/// Compact discriminant for handing the profile to the capture thread via an
/// atomic. Pairs with [`AudioProfile::from_u8`].
pub fn as_u8(self) -> u8 {
match self {
AudioProfile::LowLatency => 0,
AudioProfile::Balanced => 1,
AudioProfile::BadNetwork => 2,
}
}
/// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default.
pub fn from_u8(v: u8) -> AudioProfile {
match v {
0 => AudioProfile::LowLatency,
2 => AudioProfile::BadNetwork,
_ => AudioProfile::Balanced,
}
}
}
impl std::fmt::Display for AudioProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
AudioProfile::LowLatency => "Low latency",
AudioProfile::Balanced => "Balanced",
AudioProfile::BadNetwork => "Bad network",
})
}
}
impl std::fmt::Display for RoomLayout {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
@@ -102,6 +168,139 @@ impl std::fmt::Display for NetworkMode {
}
}
/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free
/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareQuality {
#[default]
Auto,
Low,
Medium,
High,
Source,
}
impl ShareQuality {
pub const ALL: [ShareQuality; 5] = [
ShareQuality::Auto,
ShareQuality::Low,
ShareQuality::Medium,
ShareQuality::High,
ShareQuality::Source,
];
}
impl std::fmt::Display for ShareQuality {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareQuality::Auto => "Auto",
ShareQuality::Low => "Low",
ShareQuality::Medium => "Medium",
ShareQuality::High => "High",
ShareQuality::Source => "Source",
})
}
}
/// Preferred local player for watching a peer's screen share.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum SharePlayer {
#[default]
Mpv,
Vlc,
}
impl SharePlayer {
pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc];
}
impl std::fmt::Display for SharePlayer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
SharePlayer::Mpv => "mpv",
SharePlayer::Vlc => "VLC",
})
}
}
/// Local player buffering posture for screen-share playback.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ShareBuffering {
#[default]
LowLatency,
Smooth,
}
impl ShareBuffering {
pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth];
}
impl std::fmt::Display for ShareBuffering {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
ShareBuffering::LowLatency => "Low latency",
ShareBuffering::Smooth => "Smooth",
})
}
}
fn default_screen_share_cache_mb() -> u32 {
2
}
/// Local-only screen-share preferences. Host fields become pixelpass host CLI
/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values
/// deliberately let pixelpass/player defaults stand.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenShareSettings {
#[serde(default)]
pub quality: ShareQuality,
#[serde(default)]
pub bitrate_mbps: Option<u32>,
#[serde(default)]
pub framerate: Option<u32>,
#[serde(default)]
pub max_height: Option<u32>,
#[serde(default)]
pub max_viewers: Option<u32>,
#[serde(default)]
pub force_software_encode: bool,
#[serde(default)]
pub extra_host_args: String,
#[serde(default)]
pub player: SharePlayer,
#[serde(default)]
pub hardware_decode: bool,
#[serde(default)]
pub buffering: ShareBuffering,
#[serde(default = "default_screen_share_cache_mb")]
pub cache_mb: u32,
#[serde(default)]
pub extra_mpv_args: String,
}
impl Default for ScreenShareSettings {
fn default() -> Self {
Self {
quality: ShareQuality::default(),
bitrate_mbps: None,
framerate: None,
max_height: None,
max_viewers: None,
force_software_encode: false,
extra_host_args: String::new(),
player: SharePlayer::default(),
hardware_decode: false,
buffering: ShareBuffering::default(),
cache_mb: default_screen_share_cache_mb(),
extra_mpv_args: String::new(),
}
}
}
fn default_true() -> bool {
true
}
@@ -138,6 +337,10 @@ fn default_chat_drawer_width() -> f32 {
320.0
}
fn default_playlist_drawer_width() -> f32 {
320.0
}
fn default_window_width() -> f32 {
900.0
}
@@ -174,12 +377,19 @@ pub struct AppConfig {
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
#[serde(default)]
pub music_broadcast: bool,
/// Show the slim now-playing player bar in the room screen.
#[serde(default = "default_true")]
pub show_player_bar: bool,
/// When true, `clip_volume` governs every clip. When false, each clip keeps
/// its own (in-memory) level and the universal slider is inactive.
#[serde(default = "default_true")]
pub clip_volume_universal: bool,
#[serde(default)]
pub network_mode: NetworkMode,
/// Opus encoder / network-resilience profile (W12). Applies live to the
/// running encoder; default `Balanced`.
#[serde(default)]
pub audio_profile: AudioProfile,
/// Presence posture for the friends idle listener (W7): invisible / normal /
/// discoverable. Default `Normal` = answer friends only, no DNS beacon.
#[serde(default)]
@@ -207,6 +417,9 @@ pub struct AppConfig {
/// Chat drawer width for the drawer layout (px).
#[serde(default = "default_chat_drawer_width")]
pub chat_drawer_width: f32,
/// Playlist drawer width for the room-screen right-edge music panel (px).
#[serde(default = "default_playlist_drawer_width")]
pub playlist_drawer_width: f32,
/// Chosen arrangement of the in-call room screen.
#[serde(default)]
pub room_layout: RoomLayout,
@@ -289,6 +502,9 @@ pub struct AppConfig {
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
#[serde(default)]
pub pixelpass_path: Option<String>,
/// Local-only host/player controls for screen sharing.
#[serde(default)]
pub screen_share: ScreenShareSettings,
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
/// one-click rejoin; never sent over the wire. De-duped by room topic and
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
@@ -335,6 +551,13 @@ pub struct AppConfig {
pub window_y: Option<i32>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadOutcome {
Missing,
Loaded,
Recovered,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
@@ -348,8 +571,10 @@ impl Default for AppConfig {
music_playlist: Vec::new(),
music_volume: 1.0,
music_broadcast: false,
show_player_bar: true,
clip_volume_universal: true,
network_mode: NetworkMode::default(),
audio_profile: AudioProfile::default(),
presence_mode: crate::presence::PresenceMode::default(),
echo_cancellation_enabled: false,
notifications_enabled: true,
@@ -358,6 +583,7 @@ impl Default for AppConfig {
threecol_playlist_height: default_threecol_playlist_height(),
controls_width: default_controls_width(),
chat_drawer_width: default_chat_drawer_width(),
playlist_drawer_width: default_playlist_drawer_width(),
room_layout: RoomLayout::default(),
theme: AppTheme::default(),
avatar: crate::avatar::Avatar::default(),
@@ -384,6 +610,7 @@ impl Default for AppConfig {
sound_mic_toggle_enabled: true,
sound_reconnect_failed_enabled: true,
pixelpass_path: None,
screen_share: ScreenShareSettings::default(),
recents: Vec::new(),
peer_eq: HashMap::new(),
peer_pan: HashMap::new(),
@@ -463,24 +690,115 @@ impl AppConfig {
}
pub fn load() -> Self {
if let Some(path) = Self::config_path()
&& let Ok(contents) = fs::read_to_string(&path)
&& let Ok(config) = serde_json::from_str(&contents) {
return config;
}
Self::default()
let Some(path) = Self::config_path() else {
return Self::default();
};
let (config, _) = Self::load_from(&path);
config
}
pub fn save(&self) {
if let Some(path) = Self::config_path() {
if let Some(dir) = path.parent() {
let _ = fs::create_dir_all(dir);
if let Err(e) = self.save_to(&path) {
crate::log_msg(&format!("config: save failed: {e:#}"));
}
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = fs::write(path, json);
} else {
crate::log_msg("config: save failed: could not determine a config directory");
}
}
pub fn load_from(path: &Path) -> (Self, LoadOutcome) {
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(config) => (config, LoadOutcome::Loaded),
Err(e) => {
let backup = recover_corrupt_config(path, &format!("failed to parse: {e}"));
(Self::default(), backup)
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
(Self::default(), LoadOutcome::Missing)
}
Err(e) => {
let backup = recover_corrupt_config(path, &format!("failed to read: {e}"));
(Self::default(), backup)
}
}
}
pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
let parent = path
.parent()
.context("config 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(self).context("failed to encode config")?;
let tmp = config_tmp_path(path)?;
let result = (|| -> anyhow::Result<()> {
{
let mut f = fs::File::create(&tmp)
.with_context(|| format!("failed to create {}", tmp.display()))?;
f.write_all(json.as_bytes())
.with_context(|| format!("failed to write {}", tmp.display()))?;
f.sync_all().ok();
}
fs::rename(&tmp, path).with_context(|| {
format!("failed to rename {} -> {}", tmp.display(), path.display())
})?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&tmp);
}
result
}
}
fn config_tmp_path(path: &Path) -> anyhow::Result<PathBuf> {
let parent = path
.parent()
.context("config path has no parent directory")?;
let mut name = path
.file_name()
.context("config path has no file name")?
.to_os_string();
name.push(format!(".tmp.{}", std::process::id()));
Ok(parent.join(name))
}
fn corrupt_backup_path(path: &Path) -> PathBuf {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_else(|| "config.json".into());
name.push(format!(".corrupt.{secs}"));
parent.join(name)
}
fn recover_corrupt_config(path: &Path, reason: &str) -> LoadOutcome {
let backup = corrupt_backup_path(path);
match fs::rename(path, &backup) {
Ok(()) => {
crate::log_msg(&format!(
"config: {reason}; moved damaged config to {}",
backup.display()
));
}
Err(e) => {
crate::log_msg(&format!(
"config: {reason}; failed to move damaged config to {}: {e}",
backup.display()
));
}
}
LoadOutcome::Recovered
}
#[cfg(test)]
@@ -495,14 +813,109 @@ mod tests {
assert_eq!(original, deserialized);
}
fn temp_config_path(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"peerspeak-configtest-{}-{}",
std::process::id(),
tag
));
p.push("config.json");
p
}
#[test]
fn save_to_then_load_from_round_trips() {
let path = temp_config_path("roundtrip");
let _ = fs::remove_dir_all(path.parent().unwrap());
let cfg = AppConfig {
username: "Ada".into(),
input_device: "mic".into(),
output_device: "speaker".into(),
noise_gate_threshold: 0.42,
..AppConfig::default()
};
cfg.save_to(&path).unwrap();
let (loaded, outcome) = AppConfig::load_from(&path);
assert_eq!(outcome, LoadOutcome::Loaded);
assert_eq!(loaded, cfg);
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn load_from_missing_returns_default_without_corrupt_backup() {
let path = temp_config_path("missing");
let _ = fs::remove_dir_all(path.parent().unwrap());
let (loaded, outcome) = AppConfig::load_from(&path);
assert_eq!(outcome, LoadOutcome::Missing);
assert_eq!(loaded, AppConfig::default());
assert!(!path.parent().unwrap().exists());
}
#[test]
fn load_from_corrupt_file_preserves_original_bytes() {
let path = temp_config_path("corrupt");
let _ = fs::remove_dir_all(path.parent().unwrap());
fs::create_dir_all(path.parent().unwrap()).unwrap();
let corrupt = b"{ this is not json";
fs::write(&path, corrupt).unwrap();
let (loaded, outcome) = AppConfig::load_from(&path);
assert_eq!(outcome, LoadOutcome::Recovered);
assert_eq!(loaded, AppConfig::default());
assert_ne!(fs::read(&path).ok().as_deref(), Some(corrupt.as_slice()));
let backups: Vec<_> = fs::read_dir(path.parent().unwrap())
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|entry| {
entry
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("config.json.corrupt."))
})
.collect();
assert_eq!(backups.len(), 1, "expected one corrupt backup");
assert_eq!(fs::read(&backups[0]).unwrap(), corrupt);
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn save_to_leaves_no_tmp_file_after_success() {
let path = temp_config_path("atomic");
let _ = fs::remove_dir_all(path.parent().unwrap());
AppConfig::default().save_to(&path).unwrap();
let tmp_files: Vec<_> = fs::read_dir(path.parent().unwrap())
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|entry| {
entry
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.contains(".tmp."))
})
.collect();
assert!(tmp_files.is_empty(), "leftover temp files: {tmp_files:?}");
let _ = fs::remove_dir_all(path.parent().unwrap());
}
#[test]
fn test_backward_compat_default_fill() {
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();
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.
@@ -517,6 +930,8 @@ mod tests {
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
assert_eq!(deserialized.controls_width, 280.0);
assert_eq!(deserialized.chat_drawer_width, 320.0);
assert_eq!(deserialized.playlist_drawer_width, 320.0);
assert!(deserialized.show_player_bar);
assert!(deserialized.custom_sound_self_join.is_none());
assert!(deserialized.custom_sound_peer_join.is_none());
assert!(deserialized.custom_sound_peer_leave.is_none());
@@ -525,10 +940,21 @@ mod tests {
assert!(deserialized.custom_sound_self_leave.is_none());
assert!(deserialized.custom_sound_mic_toggle.is_none());
assert!(deserialized.custom_sound_reconnect_failed.is_none());
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
assert_eq!(
deserialized.screen_share.buffering,
ShareBuffering::LowLatency
);
assert_eq!(deserialized.screen_share.cache_mb, 2);
// 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();
@@ -575,7 +1001,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);
@@ -587,9 +1016,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();
@@ -647,8 +1079,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());
@@ -701,6 +1132,7 @@ mod tests {
assert!(def.music_playlist.is_empty());
assert_eq!(def.music_volume, 1.0);
assert!(!def.music_broadcast);
assert!(def.show_player_bar);
assert!(def.clip_volume_universal);
// Missing in JSON → unity (serde default).
@@ -712,6 +1144,7 @@ mod tests {
assert!(cfg_missing.music_playlist.is_empty());
assert_eq!(cfg_missing.music_volume, 1.0);
assert!(!cfg_missing.music_broadcast);
assert!(cfg_missing.show_player_bar);
// Configs predating the toggle default to universal mode.
assert!(cfg_missing.clip_volume_universal);
@@ -723,6 +1156,7 @@ mod tests {
music_playlist: vec!["/tmp/song.ogg".to_string()],
music_volume: 0.6,
music_broadcast: true,
show_player_bar: false,
clip_volume_universal: false,
..AppConfig::default()
};
@@ -731,15 +1165,20 @@ 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);
assert!(!round_tripped.clip_volume_universal);
}
#[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);
@@ -784,6 +1223,38 @@ mod tests {
assert_ne!(display_0, display_2);
}
#[test]
fn test_audio_profile() {
// Default is Balanced.
assert_eq!(AudioProfile::default(), AudioProfile::Balanced);
// ALL holds the three variants.
assert_eq!(AudioProfile::ALL.len(), 3);
assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency));
assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced));
assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork));
// as_u8 / from_u8 round-trip every variant, and unknown bytes fall back
// to the default rather than panicking.
for p in AudioProfile::ALL {
assert_eq!(AudioProfile::from_u8(p.as_u8()), p);
}
assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced);
// serde round-trips, and Display strings are non-empty + distinct.
let mut labels = Vec::new();
for p in AudioProfile::ALL {
let s = serde_json::to_string(&p).unwrap();
assert_eq!(serde_json::from_str::<AudioProfile>(&s).unwrap(), p);
let label = p.to_string();
assert!(!label.is_empty());
labels.push(label);
}
labels.sort();
labels.dedup();
assert_eq!(labels.len(), 3);
}
#[test]
fn test_unknown_field_tolerance() {
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
@@ -795,11 +1266,14 @@ mod tests {
"unrecognized_field_xyz_123": "some_value"
}"#;
let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);
// 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, "");
assert_eq!(config.output_device, "");
+95 -5
View File
@@ -202,11 +202,15 @@ impl JitterBuffer {
None
} else {
// Gap with later packets already buffered: a packet was lost
// or reordered out of window. Conceal this frame via Opus PLC
// and grow the cushion — the jitter beat our current delay.
// or reordered out of window. First try Opus in-band FEC from
// the next packet; if unavailable, fall back to plain PLC.
self.next_seq = Some(next.wrapping_add(1));
self.note_disruption();
self.decoder.decode(None).ok()
let next_payload = self.packets.values().next().expect("non-empty");
self.decoder
.decode_fec(next_payload)
.or_else(|_| self.decoder.decode(None))
.ok()
}
}
}
@@ -221,8 +225,8 @@ impl JitterBuffer {
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::AudioEncoder;
use crate::codec::opus_impl::OpusEncoder;
use crate::codec::opus_impl::{OpusDecoder, OpusEncoder, OpusParams};
use crate::codec::{AudioDecoder, AudioEncoder};
use opus::{Application, Channels};
/// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`.
@@ -233,6 +237,32 @@ mod tests {
enc.encode(&pcm).unwrap()
}
fn tone_frame(enc: &mut OpusEncoder, amp: i16, frame_index: usize) -> Vec<u8> {
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
.map(|i| {
let sample_index = frame_index * FRAME_SAMPLES + i;
let t = sample_index as f32 / 48_000.0;
let fundamental = (t * 220.0 * 2.0 * std::f32::consts::PI).sin();
let harmonic = (t * 440.0 * 2.0 * std::f32::consts::PI).sin();
((fundamental * 0.7 + harmonic * 0.3) * amp as f32) as i16
})
.collect();
enc.encode(&pcm).unwrap()
}
fn rms_error(a: &[i16], b: &[i16]) -> f64 {
assert_eq!(a.len(), b.len());
let sum_sq: f64 = a
.iter()
.zip(b)
.map(|(&left, &right)| {
let diff = left as f64 - right as f64;
diff * diff
})
.sum();
(sum_sq / a.len() as f64).sqrt()
}
#[test]
fn buffers_then_plays_in_order() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
@@ -289,6 +319,66 @@ mod tests {
assert!(jb.pop_frame().is_none());
}
#[test]
fn uses_in_band_fec_from_next_packet_for_gap() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
enc.apply_params(&OpusParams {
bitrate: 20_000,
inband_fec: true,
packet_loss_perc: 60,
dtx: false,
})
.unwrap();
let dropped_seq = 5usize;
let amps = [1800, 1800, 1800, 1800, 1800, 12_000, 12_000, 12_000];
let packets: Vec<Vec<u8>> = amps
.into_iter()
.enumerate()
.map(|(seq, amp)| tone_frame(&mut enc, amp, seq))
.collect();
let mut expected_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(dropped_seq) {
expected_decoder.decode(Some(packet)).unwrap();
}
let expected_lost = expected_decoder
.decode(Some(&packets[dropped_seq]))
.unwrap();
let mut plc_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
for packet in packets.iter().take(dropped_seq) {
plc_decoder.decode(Some(packet)).unwrap();
}
let pure_plc = plc_decoder.decode(None).unwrap();
let mut jb = JitterBuffer::new().unwrap();
for (seq, packet) in packets.iter().enumerate() {
if seq != dropped_seq {
jb.insert(seq as u32, packet.clone());
}
}
for _ in 0..dropped_seq {
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
}
let recovered = jb.pop_frame().expect("gap should be reconstructed");
assert_eq!(recovered.len(), FRAME_SAMPLES);
assert!(
jb.packets.contains_key(&(dropped_seq as u32 + 1)),
"FEC source packet must remain buffered for normal decode"
);
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
let fec_error = rms_error(&recovered, &expected_lost);
let plc_error = rms_error(&pure_plc, &expected_lost);
assert!(
fec_error < plc_error * 0.75,
"FEC reconstruction should be materially closer than PLC (fec_error={fec_error}, plc_error={plc_error})"
);
}
#[test]
fn drops_packets_already_played() {
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
+288 -48
View File
@@ -1,4 +1,4 @@
use crate::config::{NetworkMode, RecordingMode};
use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
use crate::friends::Friend;
use crate::network::PeerState;
use crate::presence::{FriendPresence, PresenceMode};
@@ -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,10 +49,17 @@ 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),
/// Set the Opus encoder / network-resilience profile (W12). Applies live to
/// the running capture encoder, and to the next call's encoder. Sent at
/// startup from config and whenever the user changes it.
SetAudioProfile(AudioProfile),
/// Start/stop recording the call to a local WAV (your mic + the incoming
/// mix). No-op start if already recording / not in a call.
SetRecording(bool),
@@ -56,22 +71,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,13 +117,20 @@ 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>,
settings: ScreenShareSettings,
quality: ShareQuality,
},
/// Stop sharing our screen: kill the pixelpass host and clear the presence
/// ticket. No-op when not sharing.
StopScreenShare,
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
/// open it in a local player.
ViewShare(String),
ViewShare {
ticket: String,
settings: ScreenShareSettings,
},
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
/// on the next room join (the endpoint is rebuilt then). The core replies with
/// an updated [`UiEvent::IdentityStatus`].
@@ -98,7 +138,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).
@@ -130,6 +174,17 @@ pub enum DeliveryClass {
BestEffort,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum CoalesceKey {
InputVolume,
OutputVolume,
NoiseGate,
PeerVolume(EndpointId),
PeerPan(EndpointId),
PeerGate(EndpointId),
PeerEq(EndpointId),
}
/// Route a command by how bad it is to drop it. Discrete, human-paced user
/// actions are Reliable (must land). The only high-frequency commands are the
/// continuous audio sliders, where dropping intermediate values is harmless;
@@ -166,6 +221,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
input_device: _,
}
| CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_)
@@ -178,10 +234,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
from: _,
attachment: _,
}
| CoreCommand::ServeMusicTrack {
id: _,
data: _,
}
| CoreCommand::ServeMusicTrack { id: _, data: _ }
| CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic {
@@ -196,9 +249,16 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { audio_app: _ }
| CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ViewShare(_)
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
@@ -215,57 +275,196 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
}
}
/// Coalescing bucket for high-frequency continuous controls. A key exists
/// exactly for [`DeliveryClass::BestEffort`] commands.
pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
match cmd {
CoreCommand::SetPeerVolume(peer_id, _) => Some(CoalesceKey::PeerVolume(*peer_id)),
CoreCommand::SetPeerPan(peer_id, _) => Some(CoalesceKey::PeerPan(*peer_id)),
CoreCommand::SetPeerGate(peer_id, _) => Some(CoalesceKey::PeerGate(*peer_id)),
CoreCommand::SetPeerEq(peer_id, _) => Some(CoalesceKey::PeerEq(*peer_id)),
CoreCommand::SetInputVolume(_) => Some(CoalesceKey::InputVolume),
CoreCommand::SetOutputVolume(_) => Some(CoalesceKey::OutputVolume),
CoreCommand::SetNoiseGateThreshold(_) => Some(CoalesceKey::NoiseGate),
CoreCommand::Join {
name: _,
ticket: _,
room_name: _,
input_device: _,
output_device: _,
echo_cancellation: _,
avatar: _,
}
| CoreCommand::Leave
| CoreCommand::Shutdown
| CoreCommand::ToggleMute
| CoreCommand::SetAvatar(_)
| CoreCommand::ToggleDeafen
| CoreCommand::SetPttMode(_)
| CoreCommand::SetPttActive(_)
| CoreCommand::SetPeerMuted(_, _)
| CoreCommand::SetMicMonitor {
enabled: _,
input_device: _,
}
| CoreCommand::SetNetworkMode(_)
| CoreCommand::SetAudioProfile(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_)
| CoreCommand::SendChatFile {
text: _,
attachment: _,
data: _,
}
| CoreCommand::FetchAttachment {
from: _,
attachment: _,
}
| CoreCommand::ServeMusicTrack { id: _, data: _ }
| CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic {
from: _,
id: _,
size: _,
}
| CoreCommand::PrefetchMusic {
from: _,
id: _,
size: _,
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare {
audio_app: _,
settings: _,
quality: _,
}
| CoreCommand::StopScreenShare
| CoreCommand::ViewShare {
ticket: _,
settings: _,
}
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
name: _,
addr: _,
}
| CoreCommand::RemoveFriend(_)
| CoreCommand::RenameFriend(_, _)
| CoreCommand::RefreshFriends
| CoreCommand::SetPresenceMode(_)
| CoreCommand::SetGamePresenceEnabled(_)
| CoreCommand::SetGameOverride(_)
| CoreCommand::SetGameProcessMap(_) => None,
}
}
#[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).
@@ -278,24 +477,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
@@ -305,7 +517,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
@@ -319,7 +533,7 @@ pub enum UiEvent {
#[cfg(test)]
mod tests {
use super::{delivery_class, CoreCommand, DeliveryClass};
use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class};
use crate::audio::eq::EqSettings;
use crate::presence::PresenceMode;
use iroh::{EndpointId, SecretKey};
@@ -332,17 +546,37 @@ mod tests {
fn continuous_audio_controls_are_best_effort() {
let peer = endpoint_id();
let commands = [
CoreCommand::SetPeerVolume(peer, 0.7),
CoreCommand::SetPeerPan(peer, -0.2),
CoreCommand::SetPeerGate(peer, 0.1),
CoreCommand::SetPeerEq(peer, EqSettings::default()),
CoreCommand::SetInputVolume(0.8),
CoreCommand::SetOutputVolume(0.9),
CoreCommand::SetNoiseGateThreshold(0.02),
(
CoreCommand::SetPeerVolume(peer, 0.7),
CoalesceKey::PeerVolume(peer),
),
(
CoreCommand::SetPeerPan(peer, -0.2),
CoalesceKey::PeerPan(peer),
),
(
CoreCommand::SetPeerGate(peer, 0.1),
CoalesceKey::PeerGate(peer),
),
(
CoreCommand::SetPeerEq(peer, EqSettings::default()),
CoalesceKey::PeerEq(peer),
),
(CoreCommand::SetInputVolume(0.8), CoalesceKey::InputVolume),
(CoreCommand::SetOutputVolume(0.9), CoalesceKey::OutputVolume),
(
CoreCommand::SetNoiseGateThreshold(0.02),
CoalesceKey::NoiseGate,
),
];
for cmd in commands {
for (cmd, key) in commands {
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
assert_eq!(coalesce_key(&cmd), Some(key));
assert_eq!(
coalesce_key(&cmd).is_some(),
delivery_class(&cmd) == DeliveryClass::BestEffort
);
}
}
@@ -365,11 +599,17 @@ mod tests {
},
CoreCommand::SetPeerMuted(peer, true),
CoreCommand::SetPresenceMode(PresenceMode::Normal),
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
CoreCommand::SendChat("hello".to_string()),
];
for cmd in commands {
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
assert_eq!(coalesce_key(&cmd), None);
assert_eq!(
coalesce_key(&cmd).is_some(),
delivery_class(&cmd) == DeliveryClass::BestEffort
);
}
}
}
+708 -179
View File
File diff suppressed because it is too large Load Diff
+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]
+13 -7
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);
@@ -93,15 +97,16 @@ fn windows_toolhelp_executables() -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn enumerates_at_least_this_process() {
// 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");
let exes = super::running_executables();
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 +114,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]
+120 -14
View File
@@ -11,12 +11,16 @@
//! missing chime should never disrupt a call.
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
static ENABLED: AtomicBool = AtomicBool::new(true);
static TEMP_WAV_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
/// toggle gates everything; these silence individual events while the master
@@ -57,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 {
@@ -195,14 +198,38 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
if let Some(path) = guard.get(sound.name()) {
return Some(path.clone());
}
let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name()));
if std::fs::write(&path, sound.bytes()).is_err() {
return None;
}
let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) {
Ok(path) => path,
Err(_) => return None,
};
guard.insert(sound.name(), path.clone());
Some(path)
}
fn write_private_wav(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
let counter = TEMP_WAV_COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = dir.join(format!(
"peerspeak-{stem}-{}-{counter}-{nanos}.wav",
std::process::id()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&path)?;
file.write_all(bytes)?;
Ok(path)
}
#[cfg(any(windows, test))]
fn escape_powershell_single_quoted(s: &str) -> String {
s.replace('\'', "''")
@@ -249,6 +276,19 @@ fn spawn_player(path: &Path) {
mod tests {
use super::*;
static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_wav_dir(tag: &str) -> PathBuf {
let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"peerspeak-notifytest-{}-{tag}-{counter}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn test_should_play_truth_table() {
// Plays only when BOTH the master and the per-sound flag are on.
@@ -264,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]
@@ -297,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"));
@@ -310,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]
@@ -332,4 +381,61 @@ mod tests {
// a `~`-prefixed path that resolves to a non-existent file -> Some(false)
assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false));
}
#[test]
fn write_private_wav_writes_exact_bytes() {
let dir = temp_wav_dir("writes");
let bytes = b"RIFFpeerspeak-test";
let path = write_private_wav(&dir, "unit", bytes).unwrap();
assert!(path.exists());
assert_eq!(std::fs::read(&path).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn write_private_wav_creates_0600_file() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_wav_dir("mode");
let path = write_private_wav(&dir, "unit", b"mode").unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_private_wav_uses_unique_paths() {
let dir = temp_wav_dir("unique");
let first = write_private_wav(&dir, "same-stem", b"first").unwrap();
let second = write_private_wav(&dir, "same-stem", b"second").unwrap();
assert_ne!(first, second);
assert!(first.exists());
assert!(second.exists());
assert_eq!(std::fs::read(&first).unwrap(), b"first");
assert_eq!(std::fs::read(&second).unwrap(), b"second");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn create_new_refuses_existing_path() {
let dir = temp_wav_dir("create-new");
let path = dir.join("preexisting.wav");
std::fs::write(&path, b"original").unwrap();
let err = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
assert_eq!(std::fs::read(&path).unwrap(), b"original");
let _ = std::fs::remove_dir_all(&dir);
}
}
+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"),
+31 -11
View File
@@ -33,12 +33,16 @@ pub const FRIENDS_PROTO: u32 = 1;
/// change is isolated into its own topic + signature domain so v2 and v3 peers
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
///
/// v4 (0.6.0): `PeerState` gained an optional `music` presence field carrying a
/// current shared-listening track descriptor and playback timeline. Bytes still
/// ride the files plane by id; gossip carries only the descriptor/timeline.
///
/// v5 (0.7.0): `MusicPresence` gained optional prefetch hints for the next
/// track so tuned-in listeners can fetch it before the DJ advances.
/// v4v5 (0.6.0): the W22 shared-listening / music presence work. `PeerState`
/// gained an optional `music` presence field (a current shared-listening track
/// descriptor + playback timeline; the audio bytes still ride the files plane by
/// id, gossip carries only the descriptor/timeline), and `MusicPresence` then
/// gained optional prefetch hints for the next track so tuned-in listeners can
/// fetch it before the DJ advances. Both shipped together in the **0.6.0** release
/// (commit `bca2ccd`), where the const advanced straight `3 → 5`: there was never
/// a `GOSSIP_PROTO == 4` build — 4 is a skipped step. (Per `VERSIONING.md` this
/// breaking gossip change rode the `0.5.1 → 0.6.0` MINOR bump, so the discipline
/// was honoured; 0.6.1 is a wire-compatible PATCH on top, still proto 5.)
pub const GOSSIP_PROTO: u32 = 5;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
@@ -83,10 +87,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 +111,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:?}"
);
}
}
}
+305 -41
View File
@@ -21,6 +21,8 @@ use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
/// The binary we shell out to. Looked up on `$PATH` unless a config override
/// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass";
@@ -139,7 +141,11 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
/// otherwise rejects hyphen-leading option values). The name is locally chosen
/// (our own enumeration / the user's pick), not peer-supplied, but is still
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
pub fn host_args(
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
) -> Vec<String> {
let mut args = vec![
"--host".to_string(),
"--output".to_string(),
@@ -149,18 +155,52 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
args.push(format!("--app={name}"));
args.push("--strict-audio".to_string());
}
if quality != ShareQuality::Auto {
args.push(format!("--quality={}", pixelpass_quality(quality)));
}
if let Some(height) = settings.max_height {
args.push(format!("--max-height={height}"));
}
if let Some(mbps) = settings.bitrate_mbps {
args.push(format!("--bitrate={}", mbps.saturating_mul(1000)));
}
if let Some(fps) = settings.framerate {
args.push(format!("--framerate={fps}"));
}
if settings.force_software_encode {
args.push("--no-hwencode".to_string());
}
if let Some(max) = settings.max_viewers {
args.push(format!("--max-viewers={max}"));
}
args.extend(split_extra_args(&settings.extra_host_args));
args
}
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
match quality {
ShareQuality::Auto => "auto",
ShareQuality::Low => "low",
ShareQuality::Medium => "medium",
ShareQuality::High => "high",
ShareQuality::Source => "source",
}
}
/// Split user-supplied advanced argv text into separate tokens. Peerspeak does
/// not depend on a shell lexer, so quoted values are not interpreted here.
fn split_extra_args(raw: &str) -> impl Iterator<Item = String> + '_ {
raw.split_whitespace().map(str::to_string)
}
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
/// trim, reject empty / overlong, and reject names carrying control characters
/// (newlines etc.) that have no place in a real `application.name`. `None` means
/// "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())
}
@@ -321,15 +361,22 @@ pub fn is_available(config_override: Option<&str>) -> bool {
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
/// running (streaming to viewers) until killed or dropped; remaining stdout is
/// drained in a background task so a full pipe can't stall the host. We do
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
/// not pass encode/viewer overrides unless the local settings explicitly ask for
/// them, so pixelpass keeps its own defaults in the common case.
pub async fn spawn_host(
bin: &Path,
audio_app: Option<&str>,
settings: &ScreenShareSettings,
quality: ShareQuality,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) -> std::io::Result<(Child, String)> {
let args = host_args(audio_app, settings, quality);
// Log the exact argv we hand pixelpass so a field log can confirm which
// encode/quality flags (e.g. --bitrate) actually reached the host — these
// are local flags with no ticket/secret, so logging them verbatim is safe.
crate::log_msg(&format!("pixelpass host spawn: {} {}", bin.display(), args.join(" ")));
let mut child = Command::new(bin)
.args(host_args(audio_app))
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
// Capture stderr (not null): pixelpass prints its startup precondition
@@ -429,10 +476,14 @@ pub fn pixelpass_failure_detail(stderr: &str) -> String {
}
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
/// child so the caller can kill it on room-leave; it also self-exits when the
/// player window closes (its tunnel ends).
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
/// stream in a local player (mpv/VLC in the configured order, then fallback).
/// Returns the live viewer child so the caller can kill it on room-leave; it also
/// self-exits when the player window closes (its tunnel ends).
pub async fn spawn_viewer(
bin: &Path,
ticket: &str,
settings: &ScreenShareSettings,
) -> std::io::Result<Child> {
let mut child = Command::new(bin)
.args(viewer_args(ticket))
.stdin(Stdio::null())
@@ -466,7 +517,7 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
}
};
if let Err(e) = launch_player(&url) {
if let Err(e) = launch_player(&url, settings) {
let _ = child.kill().await;
return Err(e);
}
@@ -548,23 +599,33 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
}
}
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
/// background task so it doesn't linger as a zombie when its window closes.
fn launch_player(url: &str) -> std::io::Result<()> {
const MPV_ARGS: &[&str] = &[
"--profile=low-latency",
"--untimed",
"--hwdec=auto",
"--audio-buffer=0.2",
"--demuxer-max-bytes=2M",
"--demuxer-readahead-secs=0.5",
];
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"];
/// Open the viewer stream URL in a media player, then fall back to vlc. The
/// player is reaped in a background task so it doesn't linger as a zombie when
/// its window closes.
///
/// The flags keep latency low while preserving A/V sync. We deliberately do
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
/// progressively out of sync with its audio. Pacing to the audio clock costs a
/// little latency (negligible for pointing at a desktop) and keeps a shared
/// video in sync. We also leave hwdec at the `low-latency` default (software
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
/// kept playing.
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
let mpv_args = mpv_args(settings);
let vlc_args = vlc_args(settings);
let first = match settings.player {
SharePlayer::Mpv => ("mpv", &mpv_args),
SharePlayer::Vlc => ("vlc", &vlc_args),
};
let second = match settings.player {
SharePlayer::Mpv => ("vlc", &vlc_args),
SharePlayer::Vlc => ("mpv", &mpv_args),
};
let child = match spawn_player("mpv", MPV_ARGS, url) {
let child = match spawn_player(first.0, first.1, url) {
Ok(c) => c,
Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| {
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"no media player found — install mpv or vlc to watch screen shares",
@@ -578,7 +639,64 @@ fn launch_player(url: &str) -> std::io::Result<()> {
Ok(())
}
fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result<Child> {
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
let mut args = Vec::new();
match settings.buffering {
ShareBuffering::LowLatency => {
args.push("--profile=low-latency".to_string());
args.push("--audio-buffer=0.2".to_string());
args.push("--demuxer-readahead-secs=0.5".to_string());
}
ShareBuffering::Smooth => {
args.push("--cache=yes".to_string());
args.push("--demuxer-readahead-secs=2".to_string());
}
}
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
if settings.hardware_decode {
args.push("--hwdec=auto".to_string());
}
args.extend(split_extra_args(&settings.extra_mpv_args));
args
}
/// Build the argv for a VLC viewer. VLC honors the subset of viewer settings
/// that map cleanly onto its option set: the buffering posture (network/live
/// caching, in ms) and hardware decoding. The rest of the viewer knobs are
/// mpv-specific — `cache_mb` is an mpv demuxer *byte* cache (VLC's caching is
/// time-based, already covered by `buffering`) and `extra_mpv_args` is literally
/// mpv flags — so they are deliberately not mapped here; the Settings UI labels
/// them as mpv-only. Pure: no I/O.
///
/// The hardware-decode mapping is the load-bearing one: VLC hardware-decodes by
/// default, so without an explicit `--avcodec-hw=none` a VLC viewer would ignore
/// the (default-off) hardware-decode toggle and could hit the frame-1 freeze
/// that default exists to avoid — the same A-bug that made us drop mpv's forced
/// `--hwdec=auto`.
fn vlc_args(settings: &ScreenShareSettings) -> Vec<String> {
let caching_ms = match settings.buffering {
ShareBuffering::LowLatency => 200,
ShareBuffering::Smooth => 1500,
};
let hw = if settings.hardware_decode {
"--avcodec-hw=any"
} else {
"--avcodec-hw=none"
};
vec![
format!("--network-caching={caching_ms}"),
format!("--live-caching={caching_ms}"),
hw.to_string(),
]
}
fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child> {
// Log the player + its flags (mpv/vlc, incl. hardware-decode: --hwdec /
// --avcodec-hw) so a field log can confirm the viewer settings reached the
// player. The `url` is omitted deliberately — it is the local stream address
// and is not needed to verify the flags. Logged on each attempt, so a
// fallback from the preferred player to the other one is visible too.
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
Command::new(bin)
.args(args)
.arg(url)
@@ -599,7 +717,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.
@@ -619,7 +740,11 @@ mod tests {
fn host_args_without_app_shares_whole_desktop() {
// No app selected → no --app flag → pixelpass keeps its default
// (whole-desktop) audio capture.
assert_eq!(host_args(None), vec!["--host", "--output", "json"]);
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(None, &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
@@ -627,13 +752,20 @@ mod tests {
// The chosen app rides in the `--app=<name>` single-token form so a
// name beginning with `-` can never be reparsed as a flag (A23), plus
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(Some("Firefox")),
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"]
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
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.
let args = host_args(Some("-rm -rf"));
let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto);
assert_eq!(args[3], "--app=-rm -rf");
assert_eq!(args[4], "--strict-audio");
}
@@ -642,13 +774,130 @@ mod tests {
fn host_args_blank_or_control_app_is_dropped() {
// 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"]);
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(Some(" "), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
assert_eq!(
host_args(Some("bad\nname"), &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
fn host_args_apply_screen_share_settings_and_extra_args_last() {
let settings = ScreenShareSettings {
bitrate_mbps: Some(5),
framerate: Some(60),
max_height: Some(1080),
max_viewers: Some(4),
force_software_encode: true,
extra_host_args: "--relay https://relay.example --verbose".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
host_args(Some("Firefox"), &settings, ShareQuality::High),
vec![
"--host",
"--output",
"json",
"--app=Firefox",
"--strict-audio",
"--quality=high",
"--max-height=1080",
"--bitrate=5000",
"--framerate=60",
"--no-hwencode",
"--max-viewers=4",
"--relay",
"https://relay.example",
"--verbose",
]
);
}
#[test]
fn mpv_args_default_matches_low_latency_software_decode() {
assert_eq!(
mpv_args(&ScreenShareSettings::default()),
vec![
"--profile=low-latency",
"--audio-buffer=0.2",
"--demuxer-readahead-secs=0.5",
"--demuxer-max-bytes=2M",
]
);
}
#[test]
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
mpv_args(&settings),
vec![
"--cache=yes",
"--demuxer-readahead-secs=2",
"--demuxer-max-bytes=16M",
"--hwdec=auto",
"--no-osc",
"--vd-lavc-threads=2",
]
);
}
#[test]
fn vlc_args_default_disables_hardware_decode() {
// The A-bug fix default (hardware_decode = false) must reach VLC too:
// VLC hardware-decodes by default, so without an explicit
// `--avcodec-hw=none` a VLC viewer would ignore the toggle and could hit
// the frame-1 freeze. Low-latency buffering keeps the 200 ms caches.
assert_eq!(
vlc_args(&ScreenShareSettings::default()),
vec![
"--network-caching=200",
"--live-caching=200",
"--avcodec-hw=none",
]
);
}
#[test]
fn vlc_args_smooth_buffering_and_hwdecode() {
// Enabling hardware decode flips VLC to `--avcodec-hw=any`; Smooth
// buffering raises the network/live caches. cache_mb / extra_mpv_args are
// mpv-only and must NOT leak into the VLC argv.
let settings = ScreenShareSettings {
hardware_decode: true,
buffering: ShareBuffering::Smooth,
cache_mb: 16,
extra_mpv_args: "--no-osc".to_string(),
..ScreenShareSettings::default()
};
assert_eq!(
vlc_args(&settings),
vec![
"--network-caching=1500",
"--live-caching=1500",
"--avcodec-hw=any",
]
);
}
#[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 +916,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 +1007,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 +1043,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 +1180,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,
+130 -81
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);
@@ -74,24 +70,26 @@ pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
}
}
/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY
/// selection) from clipboard text before it is pasted. Shared by the
/// right-click menu Paste and the middle-click PRIMARY paste.
pub fn sanitize_clip(raw: &str) -> String {
raw.chars().filter(|c| !c.is_control()).collect()
}
pub fn select_all_range(value: &str) -> (usize, usize) {
let value = text_input::Value::new(value);
(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 +117,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 +131,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 +165,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 +186,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 +222,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 +360,51 @@ 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();
return;
}
// Middle-click pastes the X11 PRIMARY selection at the cursor. iced's
// base text_input only wires Ctrl+V to the Standard (CLIPBOARD)
// selection, so without this the common "select text, middle-click to
// paste" workflow does nothing on X11.
let middle_click_on_input = matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
) && cursor.is_over(layout.bounds());
if middle_click_on_input && !self.locked {
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default());
if !clip.is_empty() {
let value = text_input::Value::new(&self.value);
let input_state = tree.children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
let (start, end) = match input_state.cursor().state(&value) {
text_input::cursor::State::Index(index) => {
let index = index.min(value.len());
(index, index)
}
text_input::cursor::State::Selection { start, end } => {
normalized_range(&value, start, end)
}
};
let edit = paste(&self.value, start, end, &clip);
input_state.move_cursor_to(edit.cursor);
if let Some(on_paste) = &self.on_paste {
shell.publish(on_paste.as_ref()(edit.value));
} else if let Some(on_input) = &self.on_input {
shell.publish(on_input.as_ref()(edit.value));
}
}
shell.capture_event();
shell.request_redraw();
@@ -477,8 +498,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 +536,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 +579,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 +654,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 +730,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;
}
@@ -758,12 +766,7 @@ where
}
}
MenuAction::Paste => {
let clip = clipboard
.read(clipboard::Kind::Standard)
.unwrap_or_default()
.chars()
.filter(|c| !c.is_control())
.collect::<String>();
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Standard).unwrap_or_default());
let edit = paste(self.value, start, end, &clip);
self.publish_paste(edit, shell);
@@ -883,6 +886,16 @@ mod tests {
assert_eq!(clip, None);
}
#[test]
fn sanitize_clip_strips_control_chars_keeps_text() {
// An X11 PRIMARY selection commonly carries a trailing newline.
assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc");
assert_eq!(sanitize_clip("a\tb\r\nc"), "abc");
// Non-control unicode is preserved.
assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀");
assert_eq!(sanitize_clip(""), "");
}
#[test]
fn paste_replaces_selection_or_inserts_at_cursor() {
assert_eq!(
@@ -932,12 +945,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"