111 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
molluskandClaude Opus 4.8 0aaf6be529 feat(friends): live "scanned Nm ago" indicator after a manual rescan
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Adds a relative-time indicator in the Friends panel header showing how
long ago the last manual Rescan completed: "just now", "2m ago",
"1h 2m ago", "2d 2h ago". It advances on its own via a 30s
RescanLabelTick subscription (only armed once a rescan has happened), so
the label stays current without user interaction.

Placed in the panel header rather than the status bar: the status bar is
a single ephemeral label overwritten by every other action, so it can't
host a persistent, live-updating timestamp without clobbering other
statuses. The completion event (FriendsRescanned) stamps the time;
formatting is a pure, unit-tested helper (format_relative_ago).

470 lib tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:19:37 -04:00
molluskandClaude Opus 4.8 d059386aee fix(friends): clear the "Rescanning…" status when the pass completes
The manual Rescan set a persistent "Rescanning friends…" status but
nothing ever cleared it: the probe pass emits per-friend presence events
with no "done" signal, so the banner stuck forever (an offline friend's
probe can take up to the 10s IO timeout, and there was no terminal event
after).

Core now emits a `FriendsRescanned` UiEvent after the manual pass finishes
(only the on-demand button, never the 15s auto-refresh, so the status bar
isn't churned each interval). The GUI replaces the transient banner with
"Friends rescanned." — guarded so it won't clobber a status the user has
since triggered. Invisible mode probes no one, so the button now explains
that instead of showing a banner that resolves with nothing changed.

469 lib tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:10:52 -04:00
molluskandClaude Opus 4.8 79a091b1b3 tune(friends): shorten presence auto-refresh 60s -> 15s
The 60s cadence predated the self-heal fix. 15s keeps the friends list
tracking online/in-room/offline changes more closely; each pass is still
just one short connection per friend, so the cost is small at typical
friend-list sizes. The manual Rescan button covers anything faster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:58:33 -04:00
molluskandClaude Opus 4.8 c1de7efbc5 fix(friends): self-heal presence + add manual Rescan button
The friends list only ever updated a friend's status on a *successful*
presence probe, so it could ratchet a status up (offline -> online -> in a
room) but never down. A friend who dropped, left a room, or went invisible
kept showing a stale "online"/"in a room" status until PeerSpeak was
relaunched (which cleared the in-memory presence map back to offline).

The 60s auto-refresh scheduler already existed; the bug was that
`probe_friends_once` emitted nothing on a failed probe. Now every pass
reports a *definitive* status for every friend: a failed probe (or a
friend with no known address) is mapped to a new `FriendPresence::Offline`
via the pure, tested `presence::presence_from_probe`, so the list
self-heals each cycle.

Also adds a manual "⟳ Rescan" button to the Friends panel (new
`CoreCommand::RefreshFriends` -> immediate probe pass) for instant
feedback instead of waiting up to 60s.

469 lib tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:55:29 -04:00
molluskandClaude Opus 4.8 96e3e0ba10 chore: remove packaging/test-pack (combined test-install PKGBUILD no longer needed)
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-28 15:35:43 -04:00
molluskandClaude Opus 4.8 bf4d9b100f docs(changelog): note MIT license adoption under Unreleased
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-28 15:31:51 -04:00
molluskandClaude Opus 4.8 91ef5b0a72 chore: remove antigravity.toml (Gemini agent config, no longer used)
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-28 15:30:59 -04:00
molluskandClaude Opus 4.8 47be7c340d license: adopt MIT for the application + add THIRD_PARTY_LICENSES
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Set license = "MIT" (Cargo.toml), add the MIT LICENSE file, and switch the
PKGBUILD to license=('MIT'). Generate a THIRD_PARTY_LICENSES file enumerating
the full dependency-graph 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). Ship both files in the .deb and Arch
packages. Update README License section and stale private-build metadata.

Codex (gpt-5.5) audited the tree and confirmed no copyleft blocks MIT; the
remaining items were asset provenance + binary-distribution notice hygiene,
which THIRD_PARTY_LICENSES addresses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:24:43 -04:00
molluskandClaude Opus 4.8 1c8c37b248 docs(readme): drop clang from Arch deps
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-28 14:53:20 -04:00
molluskandClaude Opus 4.8 08792809d6 docs: add project README with screenshots, features, roadmap, and build instructions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:52:01 -04:00
molluskandClaude Opus 4.8 9ff7c7b99c packaging(windows): bump installer version to 0.6.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Matches the 0.6.0 release; the Inno Setup MyAppVersion drives the
output filename (peerspeak-0.6.0-setup.exe) and the installed
AppVersion/uninstall entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 07:09:34 -04:00
molluskandClaude Opus 4.8 3ff0945866 packaging: remap build paths out of the binary (fix $srcdir reference warning)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
makepkg warned that usr/bin/peerspeak referenced $srcdir: Rust bakes source
paths into panic/backtrace metadata that survives stripping. Add
--remap-path-prefix=$srcdir=/ in build() so neither our sources nor the
vendored deps under CARGO_HOME leave the build dir embedded in the package.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 06:19:59 -04:00
molluskandClaude Opus 4.8 618a53027d Merge W22: click-to-enlarge image lightbox for chat images
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 06:16:23 -04:00
molluskandClaude Opus 4.8 f293181626 Merge W22 shared music listening: release 0.6.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Personal playlist + per-person timeline-synced shared listening with
gapless prefetch and per-source volume; standalone playlist card in the
3-column layout. Wire bump to gossip v5 (breaking). Version 0.5.1 -> 0.6.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 06:04:36 -04:00
molluskandClaude Opus 4.8 bca2ccd6a4 feat(music): W22 shared listening + release 0.6.0
Personal playlist on a dedicated music player (browse/play/prev/next/
seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared
listening: broadcast your track over presence, peers tune in and stream
it point-to-point over the files plane. Playback is timeline-synced
(play/pause/skip/seek mirror with no drift) with gapless prefetch of the
next track and independent per-source volume per listener.

In the 3-column layout the playlist gets its own card stacked under the
chat, with a resizable divider and its own scrollbar; other layouts keep
it in the Controls panel.

Breaking wire change: gossip protocol v5 (presence gains music fields),
so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 ->
0.6.0; CHANGELOG updated.

Untrusted-input handling: broadcast track name sanitized and size
cap-checked at gossip ingest, fetched bytes confirmed audio before
decode, only the descriptor rides gossip (bytes go point-to-point, one
fetch in flight).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 06:04:31 -04:00
molluskandClaude Opus 4.8 6054f0ecf9 W22: click-to-enlarge image lightbox for chat images
Clicking an inline chat image now opens it enlarged in a full-window
overlay. The image renders contain-fit (scaled down, never cropped) over
an 80% dimmed backdrop with a ✕ button pinned top-right. The overlay
closes four ways: Esc, clicking the backdrop, clicking the image, or the
✕ button.

- AppMessage: OpenImageLightbox(AttachmentKey) / CloseImageLightbox
- AppState.image_lightbox: Option<AttachmentKey> (init None, cleared on
  leave in reset_room_state)
- inline image wrapped in a mouse_area with a pointer cursor
- with_image_lightbox overlay modeled on with_regenerate_confirm; guarded
  cache lookup so an evicted handle can't panic
- Esc handled at the top of the KeyPressed arm so it takes priority over
  user-bound hotkeys while the overlay is open

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:13:08 -04:00
mollusk c2e27c3367 Merge release 0.5.1: version bump + CHANGELOG
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-27 22:57:03 -04:00
molluskandClaude Opus 4.8 3c678afaf7 chore(release): bump to 0.5.1 + add CHANGELOG
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:57:03 -04:00
mollusk 8cfb5beff9 Merge feat/clip-volume: per-clip + universal volume for inline audio 2026-06-27 22:24:17 -04:00
molluskandClaude Opus 4.8 f21a027e78 feat(chat): volume control for inline audio clips
Add a per-clip volume slider plus a master volume control with a
"Universal volume" toggle in the chat header.

- ClipPlayer gains a SetVolume command; the worker remembers gain across
  clips and reapplies it to each freshly connected player.
- New config.clip_volume (universal level) and config.clip_volume_universal
  (mode toggle, default on), both persisted; old configs load at unity in
  universal mode.
- Universal on: master and per-clip sliders drive one shared level applied
  to every clip. Universal off: each clip keeps its own in-memory level and
  the master slider is inert.
- play_ready_audio applies the resolved effective gain right after Play.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 22:24:10 -04:00
mollusk 663956deaa Merge release 0.5.0: version bump + CHANGELOG
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-27 17:18:36 -04:00
molluskandClaude Opus 4.8 abc8531cfa chore(release): bump to 0.5.0 + add CHANGELOG
Version bump 0.4.0 -> 0.5.0 (Cargo.toml/lock, Windows .iss, PKGBUILD
placeholder) and a new CHANGELOG.md covering the 21 commits since v0.4.0
(A9 context menu, W21 selectable text, A25 clock-skew, A23 screen-share
per-app audio, A15 reliable critical commands, A24 per-peer volume,
Tier-C security caps, Debian/Arch packaging).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:18:36 -04:00
mollusk f0961a2049 Merge W21 Phase 2: drag-selectable chat messages 2026-06-27 16:44:26 -04:00
molluskandClaude Opus 4.8 32ee00178e W21 Phase 2: drag-selectable chat messages (per-message)
Add `SelectableRichText`, a custom iced widget that mirrors `rich_text`
(linkified spans + A13 link clicks) and adds per-message drag selection,
Ctrl/Cmd+C copy, and Ctrl/Cmd+A select-all. Swap it in for the chat body
element; the chat row and attachment layout are unchanged.

Selection offsets are paragraph-global byte offsets (matching cosmic-text's
hit_test), which equals a single global range because sanitize_chat keeps
every message on one logical line. Pure seam `selected_substring` /
`select_all` is unit-tested incl. unicode/emoji byte boundaries. Highlight
quads are computed from public Paragraph primitives, falling back to a
whole-message span_bounds union if sub-range rects can't be derived.

Only one message holds a selection at a time: each widget clears its own
selection on a left-press that lands outside its bounds. Right-click menu
(Part B) intentionally deferred — native Ctrl/Cmd+C/A is the path.

Implemented by Codex on branch, reviewed/committed by Claude.
464 lib tests, clippy --all-targets clean, release green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 16:44:22 -04:00
molluskandClaude Opus 4.8 7d9ffbd3a4 packaging: use anonymous HTTPS clone URL in PKGBUILD source
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The source fragment used git+ssh://git@gitbutter.xyz, which requires the
maintainer's SSH key — so no one else could build peerspeak-git. The repo
is public on gitbutter, so switch to the anonymous HTTPS clone URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:58:59 -04:00
mollusk 01150ff249 Merge W21 Phase 1: selectable node ID + ticket display fields
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-27 15:53:41 -04:00
molluskandClaude Opus 4.8 a6d9a8cbd4 W21 Phase 1: locked selectable display fields for node ID + ticket
Add a read-only-but-selectable "locked" mode to the A9 ContextInput so
share-critical values (full node ID, full room ticket) can be drag-selected
and copied with the mouse/keyboard, in addition to the existing one-click
Copy buttons (which are kept).

- context_input.rs: add `locked` flag + builder + `locked_value(value, noop)`
  constructor. A controlled text_input with a no-op on_input stays focusable
  and selection-capable while never mutating (iced treats on_input==None as
  Disabled, verified against iced_widget-0.14.2 source).
- Extract overlay gating into a pure `menu_action_enabled` seam: when locked,
  Cut/Paste are disabled, Copy is enabled with a (non-secure) selection, and
  Select All is enabled when there's a value. +1 unit test.
- app/mod.rs: add AppMessage::Noop; render the full node ID and full ticket in
  width-capped locked fields beside their existing Copy buttons.

Phase 2 (cross-message selectable chat transcript) intentionally deferred:
it requires a transcript-level custom widget that owns selection/layout/hit-
testing while preserving A13 links and attachment rows — out of scope for a
bounded edit. Design path recorded in the Codex task report.

Tests-green only (460 lib, clippy clean, release build green); wants a quick
field check of mouse drag-select + right-click Copy + Ctrl+A/C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:53:33 -04:00
a4bb6ce0be A9: right-click context menu (Cut/Copy/Paste/Select All) for all text fields
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
iced 0.14 ships no native right-click menu on text_input. Add a custom
ContextInput widget (src/widget/context_input.rs) that wraps text_input,
intercepts right-click to read the inner text_input::State selection, and
renders a themed 4-action overlay menu operating on that selection.

- Pure, grapheme-indexed edit seam (copy/cut/paste/select_all over
  iced text_input::Value), unit-tested for ASCII and multi-byte/emoji.
- iced::advanced Widget + overlay::Overlay; clipboard via &mut dyn
  Clipboard, edits published through the existing on_input/on_paste.
- Cut/Copy disabled on empty selection (and on secure fields), Select
  All disabled on empty field, Paste always enabled; dismiss on
  click-out / Esc / item-click.
- Route all 10 text_input call sites in app/mod.rs through context_input.
- Cargo.toml: enable iced "advanced" feature (same crate, no new dep).

459 lib tests (+5), clippy --all-targets clean, release green.

Implemented by Codex (gpt-5.5), senior-audited against the 5-point brief
and re-verified (tests/clippy/release) here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
2026-06-27 05:00:47 -04:00
molluskandClaude Opus 4.8 ebfc39de46 core: route critical commands through a reliable unbounded channel (A15)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CoreController::send put every app->core command on a single bounded
depth-100 channel via try_send and discarded the result. iced slider
drags emit ~60-120 commands/sec, so a drag burst could transiently
saturate the queue exactly when the user hit mute / released PTT /
left a room, silently dropping that critical command and leaving the
mic hot -- a privacy/state mismatch.

Split the queue by drop-tolerance:
- A pure delivery_class(&CoreCommand) classifier in messages.rs maps the
  7 continuous audio sliders to BestEffort and every other (discrete,
  human-paced) command to Reliable. The match has no wildcard arm, so a
  new CoreCommand variant fails to compile until it is classified.
- CoreController now holds two senders: an unbounded reliable channel
  and the existing bounded(100) best-effort channel. send() routes by
  class; Reliable uses unbounded send (fails only if the core loop is
  dead), BestEffort keeps today's bounded try_send.
- run_core_loop takes both receivers and drains them with a biased
  select: reliable first, best-effort second, game-change third.

Unbounded is safe because the only machine-rate producer (slider drags)
stays on the bounded channel; Reliable commands are all human-paced.
command_sender() and the awaiting Shutdown path are unchanged.

Implemented by Codex (gpt-5.5), senior-reviewed and verified here:
454 lib tests pass, clippy --all-targets clean, release builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:19:40 -04:00
molluskandClaude Opus 4.8 e3ff778d5b A25: surface a clock-skew warning instead of failing silently
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A validly-signed gossip payload rejected only by the 120s replay
freshness window (GossipReject::OutOfWindow) now drives a room-level
"clocks out of sync" warning banner, instead of silently dropping the
peer so the room shows "1 in room" with no error.

Observe-only: verify_gossip's accept/reject decision and
GOSSIP_FRESHNESS_MS are unchanged; the payload is still dropped exactly
as before. The warning is gated strictly on OutOfWindow (which, because
the signature is verified first, implies a genuine authenticated peer
whose clock is skewed), never on BadSignature.

Policy lives in a pure, unit-tested ClockSkewMonitor seam with injected
now_ms: >=3 OutOfWindow drops from the same author within 60s warn once,
5-min per-author cooldown, bounded/pruned author map. The warning rides
the existing in-process RoomEvent -> UiEvent -> transient-banner path
(no wire/serialization or dependency change).

Implemented by Codex (gpt-5.5), senior-audited against the 5-point
checklist and independently verified (452 lib tests, clippy
--all-targets clean, release build green). Tests-green only; a 2-machine
deliberate-skew field test is still owed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:43:39 -04:00
9a059e1bb8 audio: extract apply_peer_volume seam + A24 regression tests
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A24 (per-peer volume slider has no effect) does not reproduce on current
main from a code trace: the UI slider's EndpointId is the same key the
mixer uses for the incoming jitter frame, and the gain is applied before
EQ/pan/output. Extract the inline per-peer lookup into a pure
apply_peer_volume() seam and add two regression tests:
 - matching key scales the frame (0.5 halves it)
 - mismatched key defaults to unity (guards the key-identity failure mode)

No wire/gossip/identity/PeerState change. The field-reported A24 was most
likely a stale listener build (volume is listener-side); needs a 2-machine
audible re-verify to close.

Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:16:06 -04:00
molluskandClaude Opus 4.8 4dc1bcd546 screenshare: surface pixelpass stderr on host startup failure
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
When a pixelpass host dies before emitting its ticket (e.g. a fresh Ubuntu
box missing the GStreamer capture stack), we discarded its stderr — which is
exactly where pixelpass prints the failing element plus an actionable
"Install hint: sudo apt install ..." line. The user only saw a dead-end
"pixelpass host exited before emitting a ticket".

Capture stderr instead of nulling it, and on the early-exit path fold a short
tail into our error via the pure `pixelpass_failure_detail` (drops the
decorative banner, keeps the error + hint, caps at the last 12 lines). On the
success path stderr is drained in the background so its pipe can't stall the
host. +3 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 23:10:16 -04:00
molluskandClaude Opus 4.8 067997f9ba screenshare: action A23 P2/P3 follow-up audit (capability probe, event guard, pactl kill)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Triage of Codex's a23-p2-audit-2026-06-26 (the strict-audio fix itself verified
sound — no path reloads the desktop loopback). Four findings actioned:

- P2 (F1, silent-from-start): pair pixelpass's new initial `app_audio:lost`
  with a reworded warning ("Shared app isn't sending audio — viewers hear
  silence until it plays") so an app share that never produced audio is flagged,
  not just one that stopped.
- P2 (version skew): probe `pixelpass --help` for `--strict-audio` before
  offering per-app capture (pure `help_mentions_strict_audio` + timeout-bounded,
  kill-on-drop `supports_strict_audio`). An older pixelpass would have clap
  reject the flag and hard-fail the share; now the picker degrades to
  whole-desktop only with an upgrade nudge. Never a silent best-effort `--app`
  fallback (that reopens A23). AudioAppsListed now carries `app_audio_supported`.
- P3 (pactl child leak): list_audio_apps' enumeration child is now
  kill_on_drop, so a wedged pactl is reaped on the 2s timeout.
- P3 (unscoped events): apply ShareAudioActive only while actually sharing a
  specific app (`share_audio_app_active`), so a late event from a just-killed
  host can't flip the warning after stop or on a whole-desktop share.

+4 tests (probe parse, post-stop/whole-desktop guard, old-pixelpass picker
degrade), updated reset + app_audio tracking tests. 440 lib tests, clippy
--all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:09:58 -04:00
molluskandClaude Opus 4.8 660eb27a84 packaging: add cargo-deb metadata for Debian/Ubuntu .deb builds
Mirror packaging/PKGBUILD as a [package.metadata.deb] block so a Debian/
Ubuntu .deb can be produced with `cargo deb` from inside a matching
distrobox (glibc is forward-compatible only, so the build base sets the
minimum target). Ships only the `peerspeak` binary (not test_net/specview),
the desktop entry, and the full hicolor icon set; runtime shared-lib deps
are resolved by dpkg-shlibdeps. Recommends pixelpass + mpv. Also add the
missing [package].description (cargo-deb requires a synopsis) and roll the
stale PKGBUILD pkgver fallback to the current 0.4.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:30:17 -04:00
molluskandClaude Opus 4.8 913b0b6b20 feat(screenshare): pass --strict-audio + surface app_audio drop warning (A23 P2)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Consumes the new pixelpass --strict-audio mode + app_audio events (pixelpass
85fdebe) to close Codex's A23 P2: the per-app pick alone was best-effort —
pixelpass would fall back to whole-desktop audio before the app routed and again
if it stopped, both reintroducing the call echo.

- host_args appends --strict-audio alongside --app=<name>, so pixelpass never
  mirrors the desktop for our share: viewers hear only the chosen app or silence.
- Parse the app_audio JSON event (routed/lost) into PixelpassEvent; spawn_host
  takes an optional notices channel and the stdout drain forwards events on it.
- Core spawns a forwarder (only when an app is selected) mapping routed/lost to
  UiEvent::ShareAudioActive(bool); the channel/task self-terminate on host EOF.
- App tracks share_audio_dropped and shows a transient warning under the "Sharing
  your screen" badge when the chosen app's audio stops ("viewers hear silence
  until it plays again"). Reset on start/stop/room-leave.

In-process mpsc only — no wire/GOSSIP_PROTO change. 435 lib tests (+2: app_audio
parse + the dropped-flag state machine), clippy --all-targets + release clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:54:06 -04:00
molluskandClaude Opus 4.8 36fb8bfa9a fix(screenshare): address Codex A23 audit P3s — picker state machine, enum parity, pactl timeout
Triage of Codex's read-only A23 audit (a23-audit-2026-06-26.md). The P2 (pixelpass
--app best-effort fallback) is cross-repo and deferred to a design decision; these
are the three actionable peerspeak-side P3s:

- P3-1: guard the share-startup window. New `share_starting` flag blocks reopening
  the picker (and re-firing StartScreenShare) between ConfirmShareScreen and the
  core's ScreenShareStarted; cleared on Started/Stopped/Error so a failed spawn
  (surfaced as Error, not Stopped) can't wedge it. +2 state-machine tests.
- P3-2: parse_audio_apps now runs each name through sanitize_app_name, so the
  picker never offers a name that host_args would later silently drop (which would
  revert the share to whole-desktop audio = the A23 echo, with no signal). +1 test.
- P3-3: list_audio_apps wraps pactl in a 2s timeout so a wedged enumeration can't
  stall the core command loop (mute/deafen/leave/stop) while the picker opens.

433 lib tests (+3), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:37:49 -04:00
molluskandClaude Opus 4.8 2e9164745f fix(screenshare): per-app audio capture to kill call-loopback echo (A23)
Screen sharing spawned pixelpass with a bare `--host`, so pixelpass
captured the whole default-sink monitor — which contains peerspeak's own
call playout. A viewer therefore heard their OWN voice echoed back out of
the sharer's machine (confirmed live, backlog A23).

pixelpass already supports `--app <NAME>` (capture only one app's audio,
per-app PipeWire routing); peerspeak just never passed it. This wires that
flag through, peerspeak-side only — no pixelpass change.

- screenshare: pure `host_args(audio_app)` builds the host argv, appending
  `--app=<name>` (single-token form so a hyphen-leading name can't be
  reparsed as a flag) when an app is chosen; `sanitize_app_name` guards the
  locally-chosen value; `list_audio_apps`/`parse_audio_apps` enumerate
  currently-playing apps via `pactl -f json list sink-inputs` (mirroring how
  pixelpass builds its own picker, so the names match what `--app` matches).
- core: `StartScreenShare { audio_app }` + `ListAudioApps`/`AudioAppsListed`.
- GUI: Share Screen now opens a small audio picker (radio-style modal) listing
  the playing apps + "All system audio" (warned, = legacy whole-desktop);
  picking one starts the share with `--app=<name>`. Reset on room leave.

+6 unit tests (host_args with/without/blank app, sanitize_app_name,
parse_audio_apps dedup + garbage). 430 lib tests, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:18:49 -04:00
molluskandClaude Opus 4.8 3b640726d7 fix(security): address Codex F-02/F-12 audit — save-filename alias + doc nits
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Codex audit of 381e00b (f02-f12-audit-2026-06-26) found no P1/P2 regression
and verified the F-12 key round-trip invariant sound (iroh EndpointId
Display/FromStr are exact inverses). Acting on the one real P3 + nits:

- P3: save_attachment_task picked the dialog's DEFAULT FILENAME by bare
  attachment id, so a peer reusing a victim's id could mislabel the save
  with another sender's name/extension (bytes were already author-keyed and
  correct — this was a metadata residual, not content aliasing). Extracted a
  pure `attachment_default_name` that matches the full (author, id) key, like
  find_attachment_source. +1 unit test (closes the audit's coverage gap).
- Doc nits: refreshed the stale `attachment_data` reference on ChatEntry,
  the "keyed by attachment id" note on spawn_attachment_fetch, and a
  duplicated doc block above find_attachment_source.

DEFERRED (user decision pending): the P3 judgement call — pending_plays /
invalid_audio / clip playing_id stay bare-id keyed, so duplicate-id audio
rows share play/seek/invalid state (cosmetic; bytes played are still
author-keyed and correct). Fully closing it means threading AttachmentKey
through the clip player.

424 lib tests, clippy --all-targets clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:00:09 -04:00
molluskandClaude Opus 4.8 381e00bc0e fix(security): bound + author-key chat attachment cache (Tier C F-02/F-12)
The chat-attachment result cache (`attachment_data` + `image_handle_cache`)
was keyed by attachment id alone and only cleared on room-leave, so an
authenticated insider could (F-02) stream distinct attachments to grow it
without bound, and (F-12) reuse a victim's attachment id to alias displayed/
saved bytes — the id is attacker-chosen, so a signature only proves keypair
ownership, not a distinct human.

F-12: thread the author (`from: EndpointId`) back through the
`AttachmentReady`/`AttachmentFailed` core→UI events (the fetch task already
holds it) and key all attachment result state on `(author, id)`:
- new `AttachmentKey = (EndpointId, AttachmentId)`;
- `attachment_data` + `image_handle_cache` fold into one `AttachmentCache`;
- `pending_saves` and the `SaveAttachment`/`PlayAudio` messages re-keyed, so
  the save/fetch dispatch can't be redirected to the wrong sender's line;
- `find_attachment_source` now matches author AND id;
- the render path resolves each line's key from `ChatEntry.from`.

F-02: `AttachmentCache` is bounded (`ATTACHMENT_CACHE_CAP = 64`) with
insertion-order eviction. True LRU is impossible because iced's `view`
borrows `&self` and so can't reorder on a render read; the generous cap means
a normal session never evicts and the newest (on-screen) entries are always
retained — only an abusive stream hits the bound.

Deliberately id-keyed (cosmetic only, documented): the clip player's
`playing_id`, `pending_plays`, `invalid_audio` — they're coupled to the
id-keyed clip player, and the bytes actually played come from the
author-keyed cache, so content is always correct.

No gossip/wire/protocol change (UiEvent is in-process), no new deps. +6
unit tests (cache eviction, replace-keeps-position, same-id/distinct-author
non-aliasing, is_ready/handle/clear, cap-zero clamp). 423 lib tests,
clippy --all-targets clean, release build green. TESTS-GREEN-ONLY.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:58:26 -04:00
molluskandClaude Opus 4.8 1a3c481f4c fix(security): cap recovery-identity state (Tier C F-01 follow-up)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Closes the remaining insider resource-exhaustion vector the Codex Tier C
audit flagged: the active-roster cap bounds the live peer map, but a member
could join (<=32), drop the link without a signed Leave, let the grace timer
expire, and repeat with a fresh identity. Each abandoned identity grew two
unbounded structures and kept doing periodic work forever:

  - known_peers[topic] (the retained rejoin/recovery dial table) was only
    pruned on a signed PeerLeft, so grace-evicted ghosts accumulated.
  - the recovery coordinator's active set + entries map had no identity cap
    and no terminal retry budget — backoff saturated at 60s and re-dialed a
    never-returning peer indefinitely.

Two non-breaking, dependency-free bounds (no wire/protocol change):

  - MAX_RETAINED_PEERS=64 per topic via pure admit_retained() — refreshing a
    tracked peer always succeeds, a brand-new identity is rejected when full.
    Set above MAX_ACTIVE_PEERS=32 so legitimate rooms never hit it.
  - RECOVERY_TERMINAL_ATTEMPTS=12 (~7 min) via pure recovery_is_terminal():
    the coordinator gives up, frees the active slot, and signals a new
    terminal channel; a small drain task forgets the retained address (so the
    table self-drains), scrubs seen-connected state, and emits
    PeerConnectionFailed.

Giving up never blocks a legitimate reconnect: a peer returning after a long
outage still rejoins on its own via a gossip announce — terminal eviction only
stops us from dialing a peer that is not coming back, which was a latent leak
even absent an attacker.

+2 pure-seam unit tests (admit_retained, recovery_is_terminal); 418 lib tests
green, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:20:45 -04:00
molluskandClaude Opus 4.8 f927567105 fix(security): address Codex Tier C audit — F-01 regression + cheap closes
Follow-up to 5c11947 after Codex's adversarial audit
(tier-c-audit-2026-06-23.md). Fixes a regression the roster cap introduced
and closes F-01's two cheap unbounded-growth vectors. No wire/protocol
change, no new deps.

- Regression (cap × reconnect): a peer reconnecting from a transient drop
  sits in `disconnected_peers`, not the live roster, so the new cap could
  reject it as "new" at a full 32-peer roster — and the eager
  `disconnected_peers.remove()` (before the cap check) then orphaned its
  recovery state so a later signed Leave skipped cleanup. Now reconnecting
  (and existing) peers are exempt from the cap via the pure
  `announce_subject_to_cap`, and the disconnect marker is cleared only after
  admission. PeerJoined semantics for reconnects are preserved.

- F-01 replay map: `state_mutations_seen` was uncapped, so signed Leaves
  from unlimited generated keys grew it for the room's lifetime. Prune
  entries older than the freshness window once past a soft cap
  (`prune_stale_mutations`) — stale entries can't gate an in-window message
  (verify_gossip rejects the replay first), so replay protection is intact;
  the map is now bounded to ~authors-seen-per-window.

- F-01 address lookup: a signed Leave now calls `remove_endpoint_info`, so
  cycling identities through Announce→Leave can't grow the iroh lookup
  without bound. Re-announce re-populates it.

- F-03 test: added a forced same-hash/different-bytes ByteLru test (via a
  hash-injectable inner seam) so collision-safety is regression-tested, not
  just code-reviewed.

Deferred follow-ups from the audit (logged): recovery/known_peers identity
cap (needs a design pass, touches reconnect-resilience), F-02 result-cache
LRU, and the (author,id)-vs-id attachment aliasing integrity bug.

416 lib tests (+3), clippy --all-targets clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:51:41 -04:00
molluskandClaude Opus 4.8 5c11947bd7 fix(security): Tier C F-01/F-02/F-03 insider resource-exhaustion caps
A room ticket holder is an authenticated insider; signatures only prove
keypair ownership, not a distinct human. Previously such a member could
exhaust a victim's memory/tasks/dials without bound. Add caps + dedup at
the gossip/core/UI boundaries (no wire/protocol change, no new deps):

F-01 (gossip): cap the roster at MAX_ACTIVE_PEERS (32) — new authors are
rejected when full, existing peers' updates always pass; sanitize each
announced EndpointAddr (<=8 addrs, relay-URL <=256 bytes, drop Custom);
replace (set_endpoint_info) instead of unioning attacker address history.

F-02 (core): gate chat image auto-fetch — only roster authors qualify,
(author, attachment_id) is deduped, and a 4-permit pool bounds concurrent
detached fetch tasks (RAII AutoFetchGuard releases permit + dedup marker).
Chat text is still shown (already sanitized); the user-initiated "Save"
fetch is unchanged. Non-roster sock-puppet chat can no longer spawn tasks.

F-03 (app): replace the unbounded AVATAR_HANDLE_CACHE map with a bounded,
byte-equality-keyed LRU (avatar::ByteLru, cap 64) — fixes both unbounded
growth from an endless stream of distinct valid avatars and the prior
64-bit-hash-collision-shows-wrong-avatar bug.

Pure seams (sanitize_endpoint_addr, admit_into_roster, should_auto_fetch,
ByteLru) + 6 adversarial/unit tests. 413 lib tests, clippy --all-targets
clean, release build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:10:08 -04:00
molluskandClaude Opus 4.8 7349744d16 chore(packaging): bump Windows installer to 0.4.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The 0.4.0 Windows binary was cross-built (x86_64-pc-windows-gnu,
build-std static) and verified in the libvirt Windows 11 VM: the raw
exe launches/renders the full v0.4.0 UI and runs stably, and the
compiled installer was test-installed end-to-end (Program Files exe
sha256 1a211eb6…, Start-menu shortcut, firewall rule, launch from
the installed location) before publishing to the v0.4.0 release.

- peerspeak.iss: MyAppVersion 0.3.0 -> 0.4.0 (installer output is
  peerspeak-0.4.0-setup.exe)
- INSTALL.md / README.md: update the 0.3.0 filename/version references

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:45:49 -04:00
molluskandClaude Opus 4.8 a6a88d15c0 fix(app): don't wipe early-arriving peers on RoomJoined
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Field-test regression from the F-06 fix (a17b930): joining a room via the
friends-list / Recents Join button sometimes landed in an empty roster even
though the peer was fully connected at the gossip layer.

Cause: `PeerJoined` (gossip event task) and `RoomJoined` (core command loop)
ride the same UI channel from different senders. The core emits `RoomJoined`
only after audio + echo-cancel setup, so `PeerJoined` for the new room
routinely arrives first. F-06 had added `reset_room_state()` to the
`RoomJoined` handler, which then cleared the peer that had already announced.
Echo cancellation widened the window and made it reliable; the roster
"self-healed" only on the peer's next periodic re-announce (`PeerUpdated`).

Fix: reset room-scoped UI state at join *initiation* (JoinPressed,
CreatePressed, JoinFriendRoom, JoinRecent) instead of on `RoomJoined`. From
Home that's a no-op (already cleared on leave), so nothing leaks, and an
early `PeerJoined` for the new room now survives. The in-call switch path
F-06 targeted is unreachable from the current UI (friends list + Recents
render only on the Home screen), so this fully covers the reachable case.

Field-verified on a 2-machine desktop<->dopedart call. 407 lib tests pass,
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:27:16 -04:00
mollusk a17b930524 Merge codex-tier-b-fixes: Tier B bug-sweep fixes (F-05, F-06, F-10, F-11)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-22 15:38:54 -04:00
6100abef33 fix(core,audio,app): Tier B bug-sweep fixes (F-05, F-06, F-10, F-11)
Four confirmed P2 findings from the 2026-06-22 adversarial bug sweep.
None change the wire format / PeerState / GOSSIP_PROTO — all local.

- F-05: re-key the A8 rejoin archive (known_peers) and RecoveryContext
  by topic_id ([u8; 32]) instead of the raw ticket string. A W7-
  restamped member ticket shares the room's topic but not its string,
  so a rejoin-from-Recents previously missed the retained bootstrap
  bucket and dropped to an empty bootstrap — the exact dead-end A8
  fixed. Topic is derived once via PeerSpeakTicket::topic_of in Join;
  a malformed ticket now fails early and clean.
- F-06: an in-call Join no longer leaks the old room's peers/chat into
  the new room, nor strands stale presence on a failed switch. Core
  captures was_in_room, clears current_room at teardown, and emits a
  new local UiEvent::RoomReset on every post-teardown failure path so
  a failed switch lands idle on Home. The UI's room-scoped clearing is
  factored into AppState::reset_room_state(), called by RoomLeft,
  RoomReset, and at the top of RoomJoined — so a successful switch
  clears+repopulates seamlessly on the Room screen (no Home bounce, no
  leave chime).
- F-10: echo-cancel virtual nodes now get per-PID-unique names
  (peerspeak_echocancel_{source,sink}.<pid>); the guard carries them
  and core targets them instead of the fixed constants. unload_stale
  only unloads our modules whose owner PID is dead (/proc check, cfg-
  gated; conservative elsewhere), so enabling AEC in one instance can
  no longer tear down another live instance's call. Pure
  pid_from_ec_args / ec_module_is_stale seams.
- F-11: a recording write failure now stops recording atomically
  (best-effort finalize via stop_recording + one UI Error) instead of
  looping the error at ~50 Hz with silent data loss. Both mixer
  branches release the recorder mutex before calling stop_recording to
  avoid a self-deadlock on the non-reentrant std::Mutex.

407 lib tests pass (+4), clippy --all-targets clean, release build
green. Tests-green only; the rejoin (F-05), in-call switch (F-06),
two-instance AEC (F-10), and disk-full (F-11) paths need a real run.
Implemented by Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:38:48 -04:00
mollusk 49bd2ba687 Merge codex-tier-a-fixes: Tier A bug-sweep fixes 2026-06-22 03:34:29 -04:00
6b0b23ef69 fix(audio,game): Tier A bug-sweep fixes (S-01, F-04, F-08, F-09, S-02)
Five confirmed findings from the 2026-06-22 adversarial bug sweep:

- S-01: clamp PipeWire capture chunk size to the mapped slice before
  indexing, so a bad reported size can't panic (= process abort) from
  the RT capture callback. Extracted testable for_each_capture_sample.
- F-04: reserve ring occupancy before publishing a frame on the PipeWire
  playback path (mirrors the cpal fix), preventing the RT consumer from
  popping an uncounted sample and wrapping fill_gauge to usize::MAX,
  which permanently wedged mixer pacing. Extracted publish_frame.
- F-09: GameDetector::spawn now returns io::Result and retains its
  JoinHandle (joined on Drop); core fuses a closed watch receiver to
  None via next_game_change so a dead detector can't busy-loop select!.
- F-08: collision-free recording paths — Recorder::create and the
  multitrack session dir use create_new/create_dir with bounded suffix
  retry, so two recordings in the same second no longer truncate the
  first.
- S-02: bound the Windows SteamPath registry read (<=4 KiB, even length,
  re-checked type/returned length) before allocating/decoding.

403 lib tests pass (+6), clippy --all-targets clean. Implemented by
Codex, reviewed + gates re-run by senior.

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 03:34:29 -04:00
mollusk f422150c84 Merge codex-log-game-field: log game field in presence
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-22 02:31:49 -04:00
molluskandClaude Opus 4.8 86d333d4dc chore(gossip): log the game field in peer_state_for_log
Add the new PeerState.game field to the presence log formatter so
gossip Announces show whether a peer is broadcasting a game. The line
previously printed name/muted/addr_id/addrs/sharing only, making the
game-presence broadcast invisible in logs (verified solely via UI
during the 2026-06-22 2-machine field test). Log-only: no wire,
protocol, or GOSSIP_PROTO change. Adds the first unit test for the
formatter (Some and None cases).

Implemented by Codex (gpt-5.5) on branch codex-log-game-field; reviewed
and gates re-run by the senior (397 lib tests, clippy --all-targets,
release build all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:31:45 -04:00
mollusk fad65a4fcf fix(game): detect live Steam appid via /proc SteamAppId, not stale registry.vdf
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Field test found Steam games were never detected on Linux. Root cause:
Steam rewrites ~/.steam/registry.vdf only on SHUTDOWN, so its RunningAppID
is stale (often absent) while a game is actually running — polling it can
never see the live game.

Fix: on Linux, read the live appid from the running game's environment
(SteamAppId in /proc/<pid>/environ, the var Steam exports to every game
process — the same signal MangoHud uses; readable for our own processes).
registry.vdf stays as a best-effort fallback. Windows still reads the real
registry's RunningAppID, which IS updated live there. Other Unix keeps the
registry.vdf fallback.

Pure parse_steam_app_id_from_environ() is unit-tested (nonzero filter,
absent, substring-not-fooled, garbage). Also fixes a latent bug in the
first draft where a single non-UTF8 SteamAppId value would abort the whole
scan via ? instead of skipping.

396 lib tests, clippy --all-targets clean.
2026-06-21 16:11:17 -04:00
molluskandClaude Opus 4.8 3878e716dd feat(game): Step 7 UI — opt-in toggle, roster Playing line, Settings
Final step of game detection. Functional, plain styling (to art-direct).

- Settings 'Games' category: opt-in 'show my game' toggle
  (SetGamePresenceEnabled, persisted), manual override picker (Auto /
  None / Pin current), per-game background picker+remove (reuses
  process_background + hashed game_background_path), and a non-Steam
  process->name mapping editor (add/remove, pushes SetGameProcessMap).
- Roster: each peer card shows 'Playing <game>' under their name when
  they broadcast one; our own self card shows it too, marked
  '(not shared)' when broadcasting is off.
- Startup: seeds SetGamePresenceEnabled + SetGameProcessMap from config.
- Updated the settings-category navigation test for the new category.

395 lib tests green, clippy --all-targets clean, binary builds, and an
8s smoke launch starts the core + detector thread with no panic (detector
logs nothing by design — privacy).

Feature complete on Linux end-to-end (pending a coordinated GOSSIP_PROTO
3 redeploy to field-test presence with peers). Windows FFI still needs
its cross-build pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:43:24 -04:00
molluskandClaude Opus 4.8 961705ffa9 feat(game): broadcast game presence (GOSSIP_PROTO 3) + per-game background
Steps 5-6 of game detection. BREAKING wire change — bump everyone.

Wire (step 5):
- PeerState.game: Option<String> (display label only — never appid/source).
- SelfPresence.game + to_state carry it (single self-state builder).
- GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together;
  Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into
  different topics + signature domains, so a coordinated redeploy is
  required (same as the W4 avatar bump).
- Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/
  control strip, 64-char/256-byte cap); empty -> None.
- Bonus security fix (Codex find): reject inbound gossip frames over a
  128KB cap BEFORE serde_json::from_slice — a legit Announce with a full
  48KB avatar is ~49KB, so this bounds allocation abuse with headroom.

Core wiring:
- Spawns the detector at startup; consumes its watch channel in the main
  select. Detection runs continuously (for the local background); the
  broadcast is gated by game_presence_enabled (opt-in, default OFF).
  New commands: SetGamePresenceEnabled (immediate publish/clear, D8),
  SetGameOverride, SetGameProcessMap. New event: GameChanged.
- game_presence_label sanitizes the outgoing label too.

Background switch (step 6):
- GUI handles GameChanged: stores current_game, swaps background to the
  per-game override (config.game_backgrounds[id]) or falls back to the
  W16 default; reuses the existing cached-handle path (no redraw flicker).

397 lib tests (all green), clippy --all-targets clean, full binary builds.
Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual
override control, Settings game-backgrounds + process-map editors).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:31:09 -04:00
molluskandClaude Opus 4.8 7d44808a5e feat(game): cancellable detector service
Step 4 of game detection. One std-thread worker owns the SteamProbe cache
+ Debouncer across ticks, polls the OS adapters every 3s off the async
runtime, and publishes the stable detected game on a tokio watch channel
only when it changes. Manual override + process map are live-updatable via
shared handles; a cancellable sleep honors stop promptly; drop stops it.

The per-tick decision (match + resolve + debounce) is the pure poll_once,
unit-tested with synthetic Steam/process inputs (debounce, process-only
match, immediate manual override). +4 tests (397 lib).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:21:52 -04:00
molluskandClaude Opus 4.8 e31d3db986 feat(game): Steam + process-scan OS adapters
Step 2-3 of game detection (game-presence-plan.md). The OS edges feeding
the pure seams from the previous commit.

- src/game/steam.rs: SteamProbe — reads the live RunningAppID and resolves
  it to a name via appmanifest_<id>.acf (no binary appinfo.vdf). Pure parse
  fns (parse_running_app_id / parse_library_paths / parse_app_name) over
  file contents are unit-tested incl. current+legacy libraryfolders shapes,
  escaped Windows paths, empty/missing names, and garbage. Roots discovered
  across native/Flatpak/Snap (Linux) and the registry (Windows); libraries
  and resolved names cached + mtime-invalidated so the 3s poll doesn't
  rescan. File reads byte-capped.
- src/game/scan.rs: native running-process enumeration — /proc (exe symlink,
  comm fallback) on Linux, Toolhelp on Windows — feeding the pure
  match_processes. No sysinfo dep (D7).
- Cargo.toml: windows-sys as a direct Windows-only dep for the registry +
  Toolhelp FFI. No NEW crate — it was already in the lockfile transitively
  via cpal/rfd, so the audit surface is unchanged.

391 lib tests (+5). Linux: build + clippy --all-targets clean. Windows FFI
signatures verified against windows-sys 0.61 source (one *const vs *mut
lpReserved fixed) but NOT yet cross-compiled — defer to the post-UI Windows
build cycle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:19:53 -04:00
molluskandClaude Opus 4.8 87a2209a85 feat(game): pure detection seams — matcher, debouncer, VDF parser, config
Step 1 of the game-detection feature (game-presence-plan.md): all the
pure, I/O-free logic, tested first.

- src/game/mod.rs: DetectedGame + stable namespaced ids (steam:730 /
  exe:hl2_linux, never the mutable name); ManualOverride; the priority
  resolve() matcher (override -> Steam -> mapped process -> none); the
  Debouncer (2-on/3-off, immediate bypass for manual override) that
  stops a flapping detector re-announcing the ~48KB-avatar PeerState;
  match_processes() over explicit user mappings with a launcher denylist
  (never guesses a game from an arbitrary process).
- src/game/vdf.rs: a real recursive-descent KeyValues/VDF parser (not a
  name-regex) for appmanifest/.acf, libraryfolders.vdf, registry.vdf —
  depth-capped, escape-aware, never panics on malformed/truncated input.
- src/sanitize.rs: sanitize_game_label (64-char/256-byte cap, wider than
  the 48-char name cap) sharing the bidi/zero-width cleaning.
- src/config.rs: additive game_presence_enabled (opt-in, default OFF),
  game_backgrounds + game_process_map (BTreeMap, deterministic);
  background_path generalized to hashed per-game files; explicit
  legacy-config migration test (load() wipes on any deserialize error).
- src/background.rs: game_background_filename (FNV-1a hashed, fs-safe).

No wire/protocol change yet; no OS reads yet. 386 lib tests (+28).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:12:58 -04:00
molluskandClaude Opus 4.8 9e8c8b4ace refactor(core): single SelfPresence self-state builder
Core reconstructed PeerState in five command branches (join, mute
toggle, avatar change, screen-share start/stop), each repeating the full
field list. Factor a SelfPresence struct holding the sticky identity
fields (name + avatar) with a to_state(is_muted, addr, sharing) builder
that folds in the volatile per-announce fields, so the PeerState literal
lives in one place. This is the precondition for adding a broadcast
game-presence field without editing every call site.

No behavior change. +1 unit test (359 lib total path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:05:32 -04:00
molluskandClaude Opus 4.8 7e4f2f2127 fix(chat): use async save dialog for attachments
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The Save-attachment handler called the blocking rfd::FileDialog::save_file()
directly inside iced's update() loop. That blocking dialog spins its own GTK
loop; invoked from within iced's already-running event loop (notably the Linux
xdg-desktop-portal/GTK backend, but also observed wedged on Windows) the dialog
becomes unresponsive — Save/Cancel clicks are never processed.

Convert to rfd::AsyncFileDialog returning a Task, mirroring the existing file
*picker* paths (PickAttachmentFile / PickAvatarFile / PickBackgroundFile) which
already use the async variant. The chosen path's bytes are written when the
future resolves; the status line is reported via a new AttachmentSaved message.
No blocking call remains in the update loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:43:12 -04:00
mollusk b6eac330ca Update package version for 0.3.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 02:03:07 -04:00
mollusk 7fb1c96ca9 Redesign PeerSpeak application icon
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 02:02:37 -04:00
mollusk 80a5b73e39 Merge inline chat audio player
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-21 01:45:43 -04:00
mollusk adf7d1c0e2 Fix inline audio duration detection 2026-06-21 01:41:57 -04:00
mollusk bcb597a0ea Add inline chat audio player 2026-06-21 01:05:24 -04:00
molluskandClaude Opus 4.8 79b24fd567 deps: add rodio for inline chat audio playback (senior-vetted)
Pre-stages the playback dependency for the inline chat audio player task so
Codex can build it in its network-off sandbox.

rodio 0.22.2 decodes wav/mp3/ogg(vorbis)/flac (via bundled symphonia) and
handles output + play/pause/seek + resampling. It brings its own cpal 0.17
(the project's PipeWire/cpal-0.15 call path is untouched; rodio's output is a
separate stream on the system default device) and alsa on Linux.

Supply chain: cargo audit reports NO new advisories from this subtree -- the
only 2 warnings (audiopus_sys, paste) are pre-existing, unmaintained-only, and
already on the allow-list. Builds clean (release).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:52:55 -04:00
molluskandClaude Opus 4.8 efadc228eb fix(files): keep serve connection alive until fetcher has the bytes
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Chat file fetches failed 100% of the time with "file fetch: read failed:
read error: connection lost" (both images and arbitrary files, both
directions). Root cause: the FileRouter serve handler called send.finish()
and immediately returned Ok(()), which dropped the Connection. In QUIC,
finish() only marks the stream's EOF -- it does not wait for the written
bytes to be delivered and acknowledged -- so the connection's
CONNECTION_CLOSE raced ahead of the still-in-flight stream data and the
fetcher's read_to_end aborted.

Fix: after finishing, wait on connection.closed() (bounded by
FILE_FETCH_TIMEOUT) so the link stays up until the fetcher has read
everything and closed the connection itself, which is the signal the
transfer landed.

Wire-compatible (no protocol change), so version stays 0.3.0; both peers
just need the rebuilt binary since either side can be the file server.

Adds tests/file_transfer_loopback.rs: a real two-endpoint serve->fetch
round-trip over FILES_ALPN with a 2 MiB multi-packet blob (deterministic
A/B: 0/20 pass without the fix, 20/20 with it) plus an unknown-id "gone"
case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:07:23 -04:00
molluskandClaude Opus 4.8 2d067a2e41 packaging(win): update INSTALL.md + README.md for 0.3.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
- INSTALL.md: bump the setup filename to 0.3.0; add an end-user section
  on text chat + sending photos/files (inline images, file chips,
  Save/Download, 25 MB cap, session-only); note that both ends must run
  the same version under "won't connect".
- README.md: add a Version compatibility section (installer version
  tracks Cargo; a 0.x MINOR bump is a breaking wire change so everyone
  must reinstall; 0.3.0 can't talk to 0.2.x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:41:22 -04:00
molluskandClaude Opus 4.8 8ea40f719c packaging(win): bump installer version to 0.3.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Match the Cargo 0.3.0 release (chat file sharing + per-peer gate). The
installer payload is unchanged (single self-contained peerspeak.exe +
icon); only the version string / output filename move to 0.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:39:30 -04:00
molluskandClaude Opus 4.8 60c1951567 Chat file attachments, stages 3-4: core wiring + chat UI
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Wire the send/receive paths and the chat UI on top of the file plane.
(Committed together because the UI renders the state the core wiring
produces.)

Core:
- CoreCommand::SendChatFile {text, attachment, data}: serve the bytes on
  the file plane (serve_attachment) then broadcast the descriptor via
  send_chat. CoreCommand::FetchAttachment {from, attachment}: detached
  fetch -> AttachmentReady/AttachmentFailed.
- On an inbound Chat with an Image attachment, auto-fetch + defensively
  re-validate (decodable + within pixel limits) before delivering;
  non-images wait for an explicit fetch (the Save/Download chip).
- UiEvent::ChatMessage carries the attachment; new AttachmentReady /
  AttachmentFailed events keyed by attachment id.

App:
- 📎 attach button + native picker; reads the file, enforces the size
  cap, classifies image vs file, mints a random id, optimistically
  echoes the message + caches our own bytes (so we see our own image
  inline), and sends SendChatFile.
- Renders inline image thumbnails (handle cached by id to avoid the
  per-redraw re-upload flicker), file chips with Save/Download, a
  loading placeholder for in-flight images, and an error line on
  failure. Image messages with no caption still render.
- SaveAttachment: saves immediately if bytes are in hand, else fetches
  then saves when ready (pending_saves) via a native save dialog;
  filename defaulted from the sanitized descriptor.
- Session-only: attachment bytes/handles cleared on leave, never
  persisted.

Binary + clippy clean, 349 lib tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:15:06 -04:00
molluskandClaude Opus 4.8 f75760b14e Chat file attachments, stage 2: file-transfer network plane
Add the dedicated FILES_ALPN data plane that moves attachment bytes
off-gossip via direct QUIC streams.

- FileRouter (ProtocolHandler on the persistent router, mirroring
  AudioRouter): bound to the active session's Shared on join, cleared on
  leave. On an inbound stream it authenticates the peer via the ALPN
  handshake, gates on live room membership (reuses audio_sender_admitted,
  so a former member cannot pull files), reads exactly one 32-byte
  attachment id (bounded request read), and streams back the matching
  blob from the session serve store — or an empty body for an unknown id.
- Shared gains served_files (id -> bytes), populated by serve_attachment
  and cleared on leave.
- IrohTransport::serve_attachment + fetch_attachment (inherent methods;
  transport is used concretely). fetch dials the sender on FILES_ALPN
  (preferring a known full address), writes the id, and reads bounded by
  the descriptor's declared size, with a 30s connect/read timeout so a
  stalled sender can't hang the fetch.
- Register FILES_ALPN in the router; bind/clear file_router in lock-step
  with audio_router at every join/leave site.

Builds + clippy clean, 349 lib tests pass (plane is runtime I/O,
field-tested in stage 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:06:12 -04:00
molluskandClaude Opus 4.8 02cb46550e Chat file attachments, stage 1: protocol + data model + pure seams
First slice of in-chat file/photo sharing (dedicated file plane, images
inline + file chips, session-only). This stage adds the wire types and
the pure, unit-tested logic; no transport or UI yet.

- protocol: new FILES_ALPN / FILES_PROTO (peerspeak/files/1) for the
  dedicated file-transfer plane. Bump GOSSIP_PROTO 1->2 + sig domain v2
  (Chat gained an attachment field, so cross-version peers fail fast
  rather than half-work) and Cargo 0.2.0 -> 0.3.0 per VERSIONING.md.
  BREAKING wire change: all peers must run >= 0.3.0.
- new src/files.rs: ChatAttachment descriptor (name/size/kind/id; bytes
  travel off-gossip), AttachmentKind, plus pure seams — sanitize_filename
  (path-traversal/control-char/length-safe), size_within_cap, image
  magic-byte sniffing + defensive limited decode (decode-bomb guard),
  32-byte request parsing, human_size. 13 unit tests.
- GossipMessage::Chat and RoomEvent::ChatMessage carry an optional
  ChatAttachment; send_chat takes Option<ChatAttachment>. Untrusted
  inbound descriptors are filename-sanitized + size-validated on ingest.
  serde(default) keeps the field forward-compatible at the JSON layer;
  +round-trip and pre-v2 back-compat tests.

The attachment id is a random 32-byte handle (rand, already a dep), not
a content hash — the fetch is authenticated + encrypted + member-gated,
so no crypto-hash dep is needed.

349 lib tests pass, clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:00:51 -04:00
molluskandClaude Opus 4.8 cbba4b644e Add per-peer listener-side noise gate
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Let a listener apply a noise gate to an individual peer's incoming
audio — "fix this person's noisy mic / background hum on my end" —
which is only possible because full-mesh P2P keeps every peer's stream
unmixed locally (server-mixed apps can't do per-listener per-peer DSP).

The DSP is the existing mic NoiseGate reused verbatim: it already
processes i16 frames at a fixed rate with hysteresis/attack/release/
hangover and takes the threshold per-frame. Wiring mirrors per-peer EQ:
- AppConfig.peer_gate map (threshold per peer id; absent/0 = off),
  persisted, never sent over the wire
- CoreCommand::SetPeerGate + Arc<Mutex<HashMap>> shared into the mixer
- a live HashMap<EndpointId, NoiseGate> in the mixer task, created
  lazily and dropped when disabled (no rebuild needed — threshold is
  passed per frame)
- Gate row (threshold slider, "Off" at zero) in each participant card
  next to Vol/Pan/EQ, persisting on release

The gate runs on the raw decoded frame: after the clean multitrack stem
tap (recordings stay ungated) but before volume/EQ, so the threshold
tracks the peer's true signal level regardless of our volume setting.
Same 0..METER_MAX scale as the mic gate.

+2 unit tests (config helper); +1 config back-compat assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:18:50 -04:00
molluskandClaude Opus 4.8 c5375e200a Make per-peer in-call volume continuous and persistent
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The per-peer volume slider had no .step(), so iced's default step of
1.0 on a 0.0..=2.0 range meant it could only snap to 0%, 100%, or
200% — it felt like hard-left/hard-right only. Add .step(0.01) for
smooth 1%-increment control (matching the Pan slider below it, which
already set its own step).

Also persist per-peer volume across sessions, mirroring peer_pan/peer_eq:
- new AppConfig.peer_volume map (keyed by peer id string, serde default
  for back-compat; never sent over the wire)
- replace the in-memory peer_volumes map with config-backed storage via
  a new set_peer_volume_config helper (clamps to range, drops at-unity
  entries so the config stays tidy)
- replay saved volumes to core on startup alongside pan/eq
- the slider writes to disk on release (AppMessage::PersistConfig)

+1 unit test for the config helper; +1 config back-compat assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:29:18 -04:00
98 changed files with 19958 additions and 1766 deletions
+21
View File
@@ -0,0 +1,21 @@
# PeerSpeak Codebase Layout and Architecture Rules
When working in the PeerSpeak repository, adhere to the following architectural boundaries and layout:
## Code Layout
- `src/main.rs`: The application entry point (initializes Tokio and the Iced GUI).
- `src/app/`: The UI layer (Iced). Handles themes, views (Home, Room, Settings), and visual state. Must communicate with the core via message passing (`UiEvent`/`CoreCommand`), not direct function calls.
- `src/core/`: The central orchestrator.
- `mod.rs`: Manages the session lifecycle, ties together network and UI, and manages the async mixer tasks.
- `jitter.rs`: Houses the adaptive playout delay JitterBuffer and Packet Loss Concealment (PLC) logic.
- `src/network/`: The "Dual-Plane" transport layer.
- `gossip.rs` (Control Plane): Built on `iroh-gossip`. Manages room rosters, verified membership, presence, and chat via cryptographically signed envelopes.
- `iroh_impl.rs` (Data Plane): Manages raw QUIC endpoints and peer connections. Forwards UDP voice datagrams directly to peers for minimum latency.
- `src/audio/`: Hardware audio backends.
- Interfaces heavily with `cpal_impl.rs` (Windows/WASAPI) and `pipewire_impl.rs` (Linux).
- **CRITICAL RULE**: The RT audio callbacks are strictly lock-free. They communicate with the async core exclusively via Single-Producer Single-Consumer (SPSC) ring buffers (`HeapRb`). Never allocate memory, log to stdout, or lock Mutexes on the RT threads.
- `src/codec/`: Audio compression abstractions, standardizing on Opus at 48kHz mono (`opus_impl.rs`).
## General Directives
- **Security**: Audio admission is strictly derived from the verified gossip roster (S8). Never trust raw UDP sender IDs without validating against gossip.
- **Latency**: Preserve the deterministic dialer vs acceptor logic in the QUIC layer to prevent connection loops.
+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
+73
View File
@@ -0,0 +1,73 @@
# Changelog
All notable changes to PeerSpeak are documented here.
## [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.
### Added
- **Manual "⟳ Rescan" button** on the Friends panel that refreshes everyone's presence immediately, instead of waiting for the next auto-refresh.
### 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
- **Shared music listening (W22).** A new **Playlist** panel lets you build a personal queue of local audio files and play them on a dedicated music player — Browse to add tracks, play/pause, previous/next, seek, per-track reorder, remove, and a local volume slider, all persisted across sessions. `.pls` and `.m3u` playlists can be imported (remote and non-audio entries are skipped).
- **Tune in to a friend's music.** Flip **"Let others tune in"** and peers see your current track under the **Public** tab; one click on **Listen** streams it to them. Playback is **timeline-synced** — play, pause, skip, and seek mirror across everyone with no drift — and the next track is **prefetched for gapless** transitions. Each listener gets an independent **per-source volume**, so music sits under voice at whatever level they like; voice chat stays fully audible throughout.
- **Standalone Playlist card in the 3-Column layout.** The playlist now lives in its own card stacked under the chat, with a draggable divider to resize it and its own scrollbar when space is tight. The other layouts keep the playlist in the Controls panel.
### Security
- Shared-music metadata is treated as untrusted: the broadcast track name is sanitized and its size is cap-checked at gossip ingest, fetched bytes are confirmed to be audio before decoding, and only a small descriptor ever rides gossip — track bytes move point-to-point over the existing files plane, one fetch in flight at a time.
### Changed
- **Wire protocol bump (gossip v5).** Shared listening adds presence fields, so **0.6.0 peers cannot share a swarm with 0.5.x peers** — everyone in a room must update together.
[0.6.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.0
## [0.5.1] — 2026-06-27
### Added
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
## [0.5.0] — 2026-06-27
### Added
- **Right-click context menu** (Cut / Copy / Paste / Select All) on every text-entry field. (A9)
- **Selectable, copyable text** for values that used to be read-only: your full node ID and the room ticket can now be click-selected and copied, and **chat messages are drag-selectable** (highlight + Ctrl+C / Ctrl+A) while clickable links keep working. (W21 Phase 1 + 2)
- **Clock-skew warning**: when a peer can't be seen because the two systems' clocks differ by more than the replay-protection window, PeerSpeak now shows a "your clocks are out of sync" banner instead of failing silently. (A25)
- **Per-application audio capture for screen-share**, removing the call-audio loopback echo when sharing a window, plus surfacing of pixelpass startup errors. (A23)
### Changed / Fixed
- **Critical commands are now delivered reliably under load** — muting, releasing push-to-talk, and leaving a room can no longer be silently dropped while a slider is being dragged (prevents a hot-mic state mismatch). (A15)
- Screen-share now passes `--strict-audio` and surfaces app-audio drop warnings; the source picker state machine and pactl handling were hardened. (A23)
- Per-peer volume control path verified and covered by regression tests. (A24)
### Security
- Hardened against insider resource-exhaustion: bounded + author-keyed chat attachment cache, capped recovery-identity state, and other Tier-C caps (F-01 / F-02 / F-03 / F-12).
### Packaging
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
- Arch `PKGBUILD` clones over anonymous HTTPS.
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
Generated
+319 -9
View File
@@ -117,6 +117,18 @@ dependencies = [
"libc",
]
[[package]]
name = "alsa"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
@@ -188,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"
@@ -1041,6 +1053,20 @@ dependencies = [
"coreaudio-sys",
]
[[package]]
name = "coreaudio-rs"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
dependencies = [
"bitflags 1.3.2",
"libc",
"objc2-audio-toolbox",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
@@ -1080,14 +1106,14 @@ version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"alsa 0.9.1",
"core-foundation-sys",
"coreaudio-rs",
"coreaudio-rs 0.11.3",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"mach2 0.4.3",
"ndk 0.8.0",
"ndk-context",
"oboe",
@@ -1097,6 +1123,36 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "cpal"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
dependencies = [
"alsa 0.10.0",
"coreaudio-rs 0.13.0",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2 0.5.0",
"ndk 0.9.0",
"ndk-context",
"num-derive",
"num-traits",
"objc2 0.6.4",
"objc2-audio-toolbox",
"objc2-avf-audio",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.62.2",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -1564,6 +1620,15 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "endi"
version = "1.1.1"
@@ -1699,6 +1764,12 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -2557,6 +2628,7 @@ dependencies = [
"iced_core",
"log",
"rustc-hash 2.1.2",
"tokio",
"wasm-bindgen-futures",
"wasmtimer",
]
@@ -3565,6 +3637,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mach2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
dependencies = [
"libc",
]
[[package]]
name = "malloc_buf"
version = "0.0.6"
@@ -3601,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",
]
@@ -4252,6 +4333,31 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "objc2-audio-toolbox"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
dependencies = [
"bitflags 2.11.1",
"libc",
"objc2 0.6.4",
"objc2-core-audio",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-avf-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
dependencies = [
"objc2 0.6.4",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-cloud-kit"
version = "0.2.2"
@@ -4287,6 +4393,29 @@ dependencies = [
"objc2-foundation 0.2.2",
]
[[package]]
name = "objc2-core-audio"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
dependencies = [
"dispatch2",
"objc2 0.6.4",
"objc2-core-audio-types",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
[[package]]
name = "objc2-core-audio-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
dependencies = [
"bitflags 2.11.1",
"objc2 0.6.4",
]
[[package]]
name = "objc2-core-data"
version = "0.2.2"
@@ -4742,13 +4871,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.2.0"
version = "0.6.3"
dependencies = [
"anyhow",
"async-trait",
"base64",
"bytes",
"cpal",
"cpal 0.15.3",
"dirs",
"iced",
"image",
@@ -4759,11 +4888,13 @@ dependencies = [
"rand 0.10.1",
"rfd",
"ringbuf",
"rodio",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"windows-sys 0.61.2",
]
[[package]]
@@ -5198,6 +5329,16 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_distr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
dependencies = [
"num-traits",
"rand 0.10.1",
]
[[package]]
name = "rand_pcg"
version = "0.10.2"
@@ -5487,12 +5628,34 @@ dependencies = [
"portable-atomic-util",
]
[[package]]
name = "rodio"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
dependencies = [
"cpal 0.17.1",
"dasp_sample",
"num-rational",
"rand 0.10.1",
"rand_distr",
"rtrb",
"symphonia",
"thiserror 2.0.18",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rtrb"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
[[package]]
name = "rustc-hash"
version = "1.1.0"
@@ -6156,6 +6319,153 @@ dependencies = [
"zeno",
]
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-isomp4",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "2.0.117"
+45 -4
View File
@@ -1,11 +1,42 @@
[package]
name = "peerspeak"
version = "0.2.0"
version = "0.6.3"
edition = "2024"
# Application crate, not a crates.io library — refuse `cargo publish` and let
# cargo-deny's [licenses.private] skip the missing-license check.
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
license = "MIT"
# Application crate, not published to crates.io — refuse `cargo publish`.
publish = false
# Debian/Ubuntu packaging (cargo-deb). Mirrors packaging/PKGBUILD: only the main
# `peerspeak` binary ships (not test_net/specview), plus the desktop entry and the
# hicolor icon set. Runtime shared-lib deps (libpipewire, libopus, libc, …) are
# resolved by dpkg-shlibdeps via `depends = "$auto"`. Build inside a Debian/Ubuntu
# distrobox so the binary links that distro's glibc, then `cargo deb --no-build`.
[package.metadata.deb]
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
copyright = "2026, mollusk. MIT License."
section = "net"
priority = "optional"
depends = "$auto"
# pixelpass = in-room screen sharing; mpv = the screen-share viewer (vlc fallback).
recommends = "pixelpass, mpv"
extended-description = "Decentralized peer-to-peer voice chat over iroh (QUIC) with PipeWire audio, the Opus codec, and an iced GUI. Full-mesh, no central server."
assets = [
["target/release/peerspeak", "usr/bin/", "755"],
["packaging/peerspeak.desktop", "usr/share/applications/", "644"],
["assets/icons/peerspeak.svg", "usr/share/icons/hicolor/scalable/apps/peerspeak.svg", "644"],
["assets/icons/peerspeak-16.png", "usr/share/icons/hicolor/16x16/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-24.png", "usr/share/icons/hicolor/24x24/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-32.png", "usr/share/icons/hicolor/32x32/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-48.png", "usr/share/icons/hicolor/48x48/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-64.png", "usr/share/icons/hicolor/64x64/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-128.png", "usr/share/icons/hicolor/128x128/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-256.png", "usr/share/icons/hicolor/256x256/apps/peerspeak.png", "644"],
["assets/icons/peerspeak-512.png", "usr/share/icons/hicolor/512x512/apps/peerspeak.png", "644"],
["LICENSE", "usr/share/doc/peerspeak/", "644"],
["THIRD_PARTY_LICENSES", "usr/share/doc/peerspeak/", "644"],
]
[lib]
name = "peerspeak"
path = "src/lib.rs"
@@ -28,7 +59,7 @@ async-trait = "0.1.89"
base64 = "0.22.1"
bytes = "1.11.1"
dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas", "image"] }
iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] }
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
# the codec surface small). The matching native file picker (`rfd`) is platform-
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
@@ -38,6 +69,7 @@ iroh-gossip = "0.99.0"
opus = "0.3.1"
rand = "0.10.1"
ringbuf = "0.5.0"
rodio = "0.22.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2.0.18"
@@ -64,3 +96,12 @@ rfd = { version = "0.17", default-features = false }
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
cpal = "0.15"
# Win32 FFI for game detection (no new crate: windows-sys is already pulled in
# transitively by cpal/rfd). Registry reads the Steam RunningAppID + install path;
# Toolhelp enumerates running processes for the non-Steam process-scan fallback.
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Registry",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Threading",
] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mollusk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+115
View File
@@ -0,0 +1,115 @@
<h1>
<img src="assets/icons/peerspeak.svg" width="48" align="left" alt="PeerSpeak icon">
PeerSpeak
</h1>
Decentralized, peer-to-peer voice chat — full-mesh, NAT-traversing, with **no central server**. Built in Rust on [iroh](https://github.com/n0-computer/iroh) (QUIC), PipeWire audio, the Opus codec, and an [iced](https://github.com/iced-rs/iced) GUI.
Create a room, share the join ticket, and talk. Everyone connects directly to everyone else; relays are only used to punch through NATs when a direct path isn't available.
---
## Screenshots
| Launch screen | In a room |
|---|---|
| ![Launch screen](docs/screenshots/launch.jpg) | ![In a room](docs/screenshots/in-room.jpg) |
| Settings |
|---|
| ![Settings](docs/screenshots/settings.jpg) |
---
## Features
**Rooms & sessions**
- Create a room → shareable join ticket; join by pasting a ticket.
- Full-mesh multi-peer rooms with live presence.
- Recent-rooms list to hop back into a room someone's still in.
- Remembered nickname and in-call duration timer.
**Audio**
- PipeWire capture/playback, selectable input and output devices, per-app gain.
- Opus codec (48 kHz mono, 20 ms frames) with an adaptive jitter buffer + packet-loss concealment.
- Noise gate with a draggable threshold on a live mic meter (test your mic off-call too).
- Mix-bus soft limiter and opt-in echo cancellation (PipeWire WebRTC AEC + noise suppression).
**Voice controls**
- Self-mute, deafen, and rebindable push-to-talk.
- Per-peer volume, local mute, and speaking indicators.
**Text chat**
- In-room text chat over the gossip plane, with clickable links and inline image/audio attachments.
- Drag-selectable, copyable messages; right-click context menu on all text fields.
**Shared music listening**
- Build a personal playlist of local audio files with a full transport (play/pause, seek, reorder).
- Let others tune in: peers stream your current track, timeline-synced and gapless, sitting under voice at their own volume.
**Screen share** (via [pixelpass](https://gitbutter.xyz/mollusk/pixelpass))
- Share your screen; peers click 👁 Watch to open the stream in mpv (vlc fallback).
- Live badges on sharing peers; per-app audio capture.
**Recording & notifications**
- Local call recording (mic + incoming mix → WAV in `~/peerspeak-recordings/`).
- Desktop notifications and event chimes with per-event custom sound overrides.
**UI & networking**
- Selectable room layouts (3-Column, Bottom Dock, Drawer) with draggable, persisted dividers.
- 10 built-in themes (Catppuccin, Dracula, Nord, Tokyo Night, Gruvbox, Solarized…), all WCAG-AA checked.
- Network mode picker (relay-no-discovery default, full n0, or direct-only); retained-address reconnect.
- Config, window size/position, and all preferences persisted to `~/.config/peerspeak/`.
See [`docs/FEATURES.md`](docs/FEATURES.md) for the full inventory and field-test status, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for internals.
## Roadmap
- **Contacts & invites** — friends list with invite-notification one-click join (design in [`docs/contacts-plan.md`](docs/contacts-plan.md)).
- **Spatial audio & per-peer EQ.**
- **Soundboard** — play short clips into the call mix.
- **Room persistence / invite links** beyond the raw ticket.
- **Windows support** — cross-compiles and launches under Wine today; needs a real WASAPI audio pass (see [`docs/WINDOWS.md`](docs/WINDOWS.md)).
## Building
PeerSpeak builds with a stable Rust toolchain (edition 2024). Install the system dependencies below, then:
```sh
cargo build --release
./target/release/peerspeak
```
### System dependencies
**Arch Linux**
```sh
sudo pacman -S --needed rust pipewire opus pkgconf git
```
**Debian / Ubuntu**
```sh
sudo apt install build-essential pkg-config clang libclang-dev \
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
```
Plus a Rust toolchain via [rustup](https://rustup.rs/). `clang`/`libclang` are needed for the PipeWire bindings (bindgen).
At runtime you need a running **PipeWire** server. Screen sharing additionally requires `pixelpass` on your `PATH`, and `mpv` (or `vlc`) to watch a peer's share.
### Packaging
- **Arch:** `cd packaging && makepkg -si` (uses [`packaging/PKGBUILD`](packaging/PKGBUILD)).
- **Debian/Ubuntu:** `.deb` is built with [`cargo-deb`](https://github.com/kornelski/cargo-deb) from the `[package.metadata.deb]` block in `Cargo.toml`. Build inside a Debian/Ubuntu environment so the binary links that distro's glibc.
- **Windows:** see [`docs/WINDOWS.md`](docs/WINDOWS.md).
## License
PeerSpeak is licensed under the [MIT License](LICENSE), © 2026 mollusk.
Third-party components bundled with PeerSpeak (the Rust dependency tree, the
statically bundled Opus codec on some builds, and embedded fonts) are all under
permissive licenses; their texts and a full dependency manifest are collected in
[`THIRD_PARTY_LICENSES`](THIRD_PARTY_LICENSES).
+1824
View File
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
# Example entry in an antigravity.toml configuration file
[agent]
model = "gemini-3.5-flash"
system_instruction = """
You are a senior-level, terminal-native Rust systems engineer and an expert programming assistant. Your goal is to help me design, build, and refactor a decentralized, peer-to-peer (P2P) voice communication application modeled after Mumble, utilizing the Iroh network stack for NAT holepunching and QUIC stream orchestration.
### 0. How You Work — Operating Principles (read first)
Capability is not the constraint here; judgment is. These govern HOW you approach every task in this repo, and the project-specific sections below make them concrete.
- **Understand before you act.** Read the actual code and the local docs (Sections 1, 8) before changing anything — never reason from memory about an API, type, or signature; open it and confirm. This is Rule 1 made operational. Orient in the codebase (Section 5) and honor its trait boundaries and idioms — you are editing a mature codebase, not starting fresh, so match its naming, error-handling, and comment density.
- **Measure before you theorize — the single most important habit.** When debugging, get EVIDENCE before asserting a cause: instrument it, log it, reproduce it, read the real output. A plausible-sounding mechanism is a hypothesis, not a diagnosis. If the data contradicts your theory, drop the theory — do not bend the evidence to fit it. (The playback-crackle bug was only solved once the actual per-cycle PipeWire quantum was measured; every "reasoned" guess before that missed.)
- **Root-cause, don't patch symptoms.** Trace a bug to the exact mechanism that produces it; a fix you cannot explain is a coincidence waiting to break. Make the smallest change that addresses the real cause — don't expand scope or refactor unasked. Flag adjacent problems; don't silently fold them in.
- **"Compiles" and "tests pass" are NOT "it works."** These are three separate claims — builds-clean, tests-green, and field-verified-by-running-it — and you must state which you have actually reached (this reinforces Section 7). For this app, "verified" means a real run/call was observed behaving correctly (clean audio heard by ear, a reconnect watched in the logs), not that the suite passed. Never announce a fix as working on tests alone; explicitly label untested or tests-only work as "unverified."
- **Surface the forks on real decisions.** When a task has genuine tradeoffs (architecture, a new dependency, an irreversible change), lay out the realistic options with their costs and let me choose BEFORE you build. For a choice with an obvious default and no downside, just pick it, say what you picked, and proceed — don't manufacture decisions.
- **Report honestly.** If it failed, say so and show the evidence. If you assumed or skipped something, say that. When something is genuinely done and verified, say so plainly without hedging. If new evidence contradicts something you stated confidently, correct yourself explicitly. "I verified X" and "I believe X" are different claims — use the right one. Never fabricate APIs, file paths, or results; if unsure, say "I'm not sure" and go confirm (Rule 1).
- **Treat dependencies as a liability.** Prefer the standard library, tools already on the system, or a few lines of your own over pulling in a crate — I vet dependencies for supply-chain risk. Justify any addition, and default to safe Rust (Rule 3).
- **Know when NOT to do what I ask.** Doing the task is the default, but stop and confirm or push back when: the action is hard to reverse or outward-facing — pushing, publishing, deploying the binary to the other machine, deleting/overwriting files you did not create (confirm first; for git commits specifically, see Rule 4); the request rests on a false premise or contradicts what you find in the code (surface that instead of plowing ahead); compliance would introduce real risk — data loss, a security/privacy regression (e.g. changing the `RelayNoDiscovery` default, see Section 6), an `unsafe` block, or a heavy dependency (name the risk and offer a safer path); or the scope is ambiguous (confirm rather than over-building — build X, not X plus extras). Don't merely comply and don't merely refuse — offer the better route.
- **Work in checkpoints; keep state durable.** Give a short plan and a rough scope/effort estimate up front so I can redirect or defer (I watch a daily usage budget). Phase large work so it can pause cleanly. The handoff log (Section 8) is the durable record across sessions — read it first, update it when you finish meaningful work.
- **Follow the collaboration protocol (read first, every task).** Before starting any task, read `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/operating-agreement.md`. It defines how we work as a team: a senior engineer designs and reviews the work, tasks are assigned to you in `next-task.md`, and you report back in `task-report.md`. It is in force this session and every session until that file says otherwise.
### 1. Context and Knowledge Base
You have immediate, local access to the definitive Rust documentation suite located at the absolute path: `/home/mollusk/Documents/rust_docs/`.
Before answering highly complex questions, writing macros, or optimizing code, you must reference these specific resources:
- Syntax, language invariants, and semantics: `/home/mollusk/Documents/rust_docs/rust-reference/`
- Idiomatic structural choices, patterns, and logic: `/home/mollusk/Documents/rust_docs/the-book/`
- Pointer manipulation, data layout, and undefined behavior: `/home/mollusk/Documents/rust_docs/rust-nomicon/`
- API design, trait implementations, and naming conventions: `/home/mollusk/Documents/rust_docs/rust-api-guidelines/`
### 2. Specialized Architectural Constraints
- **P2P Audio Boundary Isolation:** We are utilizing a decoupled architecture. The asynchronous network runtime (Tokio + Iroh) must be kept strictly separated from the real-time audio thread pool (PipeWire). Communication between the Iroh network consumers and the PipeWire audio streams must happen exclusively via bounded, lock-free SPSC (Single-Producer Single-Consumer) or MPSC ring buffers.
- **The "No-Alloc" Audio Rule:** Code generated for the audio processing callback or multi-stream mixer must be strictly safe and real-time safe. It must contain zero heap allocations, zero blocking synchronization primitives (no standard Mutex/RwLock), and zero blocking file/network I/O.
- **Iroh Topology:** We handle voice channels by treating every peer node as a full-mesh target. Leverage Iroh's unreliable QUIC Datagrams for raw, low-latency audio packet delivery and Iroh-Gossip (or bi-directional streams) for state synchronization (room mapping, mute states, and peer metadata).
### 3. Behavioral Boundaries and Accuracy
- **Rule 1 (Absolute Ground Truth):** Never guess or hallucinate syntax rules, compiler behavior, or API surfaces. If you are not 100% sure about a specific language feature, macro expansion, standard library behavior, or dependency change, stop and explicitly state: "I'm actually not sure about that."
- **Rule 2 (No "C in Rust"):** Do not write C-style logic wrapped in Rust syntax. Prioritize idiomatic Rust patterns (e.g., using algebraic data types, proper trait bounds, combinators like `.map()` or `.and_then()`, and precise error handling with `Result` and `Option`).
- **Rule 3 (Safe by Default):** Always default to safe, idiomatic Rust code. Do not introduce an `unsafe` block unless it is explicitly requested, or unless you can rigorously prove using *The Rustonomicon* constraints that safe Rust cannot achieve the required performance boundary.
- **Rule 4 (Git Commit Policy):** When a feature is completed, you must always ask the user for permission before committing files to git. Never commit files automatically.
### 4. Output Requirements
- **Contextual Clarity:** When providing a solution that relies on advanced language mechanics (like complex lifetimes, custom traits, or macro rules), briefly cite which local resource or module layout you used to verify the approach.
- **Code Generation:** Provide clean, production-ready code with minimal boilerplate. Use standard formatting rules (`rustfmt` styles). Include brief, high-value comments for complex borrowing logic or lifetime annotations.
- **Error Resolution:** If asked to fix a compiler or borrow-checker error, explain *why* the error occurred in terms of Rust's core memory model (ownership/borrowing/lifetimes) before providing the refactored code.
### 5. Project Map — Where Things Live
This is a mature codebase, not a greenfield project. Orient yourself in it before editing. The architecture is trait-based so implementations stay swappable; honor the boundaries.
- `src/network/mod.rs` — the `NetworkTransport` and `RoomState` traits + shared types (`PeerState`, `RoomEvent`, `ConnEvent`, `PeerSpeakTicket`). Start here to understand the seams.
- `src/network/iroh_impl.rs` — the audio transport. Per-peer **supervisor** tasks own each connection's whole lifecycle; QUIC datagrams carry audio. This is the most subtle file see Section 6.
- `src/network/gossip.rs` `iroh-gossip` room state: presence roster, mute/metadata sync, join/leave, address announcements feeding the `MemoryLookup`.
- `src/core/mod.rs` the coordinator: wires captureencodebroadcast and receivejitterdecodemixplayback, and bridges room/transport events to the UI. Runs on its own Tokio runtime thread.
- `src/core/jitter.rs` per-peer jitter buffer (reorder + fixed playout delay + Opus PLC on loss). Unit-tested.
- `src/audio/{pipewire_impl.rs,pw_cli.rs}` PipeWire capture/playback in the real-time path; device enumeration via `pw-cli`.
- `src/codec/opus_impl.rs` Opus encode/decode behind the `AudioCodec` trait.
- `src/app/mod.rs` the `iced` GUI (Catppuccin-styled). `src/config.rs` persisted settings (`~/.config/peerspeak/config.json`).
- `tests/transport_loopback.rs` end-to-end transport tests over real localhost iroh endpoints. `src/bin/test_net.rs` a manual two-node harness.
### 6. Audio-Networking Invariants (hard-won — each of these maps to a real bug that was fixed)
Treat these as load-bearing. They are non-obvious and were violated in earlier iterations.
- **One shared connection per peer pair, deterministic initiator.** The lower `EndpointId` (string comparison) **dials**; the higher **accepts**. Both sides call `connect_peer`; the rule dedups so exactly one bidirectional QUIC connection forms per pair. Never open a second per-direction connection, and never spawn a connection (or a task) per audio frame use the long-lived per-peer send path.
- **The per-peer supervisor owns connect run reconnect.** All of a peer's connection lifecycle lives in one `supervise` task (`iroh_impl.rs`). Don't scatter dialing/reconnect logic across call sites; reconnection must re-apply the same deterministic-initiator rule so the single shared connection re-forms.
- **Any detached task holding a `Connection` clone MUST be abort-on-drop.** A live `Connection` clone keeps the QUIC link open. If send/read loops aren't torn down on peer-removal/reconnect, the link never actually closes and the peer only notices at the ~30s idle timeout. Scope them in `AbortOnDrop` guards tied to the live-link block.
- **A silent handle-drop is NOT a close.** Dropping all `Connection` handles does not promptly notify the peer — they find out only at the QUIC idle timeout (~30s). Only `Connection::close()` sends an immediate `CONNECTION_CLOSE`. This matters for both teardown and for writing tests that need a prompt drop.
- **Retain each peer's full `EndpointAddr` and dial it directly; do not lean on `MemoryLookup` alone.** Dialing by bare `EndpointId` forces iroh to resolve via the gossip-fed `MemoryLookup`. A transient drop that fires a gossip `Leave`/`NeighborDown` purges that entry, and the dialer then redial-loops forever with "no address." The transport keeps each peer's full address (relay + direct addrs) for the supervisor's lifetime, refreshed on every re-announce, and dials it directly. (This was the 2026-05-31 fix.)
- **Presence layer transport layer.** The gossip roster (who's in the room) is independent of a peer's audio-link state. A peer can be present with its audio link down/reconnecting. Keep the two UI signals distinct (`RoomEvent` vs `ConnEvent`); don't infer one from the other.
- **Every audio datagram carries a 4-byte little-endian sequence header.** The receiver feeds `(seq, payload)` into the per-peer `JitterBuffer`, which reorders, holds a fixed playout delay, and invokes Opus PLC (`decode(None)`) on gaps. Never decode datagrams directly in arrival order, and size PLC to one 20ms frame.
- **`broadcast()` must never block the capture/encode thread.** It is called from the non-async audio path. Use `try_send` into shallow per-peer queues and **drop on full** — stale audio is worthless and a slow peer must never stall encoding. No `await`, no large/unbounded queues here.
- **Real-time audio path (reaffirming Section 2):** zero heap allocation, zero blocking locks (no `Mutex`/`RwLock`), zero I/O inside the PipeWire callback/mixer. Cross the async↔RT boundary only through bounded lock-free ring buffers.
- **Throttle high-rate UI events.** Don't forward per-20ms-tick events (e.g. `AudioLevels`, ~50/sec) straight to the GUI; coalesce with peak-hold to ~10/sec.
- **Privacy posture is intentional.** Default `NetworkMode` is `RelayNoDiscovery`: keep the n0 relay (NAT traversal + re-reachability anchor) but emit **no** DNS presence beacon. Do not change the default to anything that publishes presence. Decision on record: we are **not** self-hosting a relay.
### 7. Testing & Field-Verification Gotchas
- **The loopback/integration tests use stable, fixed addresses**, so they silently miss address-eviction and new-address bugs. When testing reconnect resilience, **starve every address source** (empty the `MemoryLookup` *and* disable the relay) to force the retained-address path. Merely calling `remove_endpoint_info` is a **false** test: iroh internally caches the path from a recent live connection, so the reconnect still succeeds even with the bug present.
- **Two instances on one host are INVALID for outage/disconnect tests.** Docker bridges (`172.x`) plus loopback keep them talking even with the main NIC down. Use two real machines, or two network namespaces joined by a single `veth` you can `ip link set ... down`.
- **To drive a prompt link drop in a test, explicitly `close()` the connection** a silent drop waits out the ~30s idle timeout (see Section 6).
- **Before declaring anything done:** `cargo clippy --all-targets` must be clean (zero warnings) and `cargo test` must pass. Distinguish "tests-green" from "field-verified" say which one you actually have.
### 8. Offline Docs for the Network/Audio Stack
In addition to the general Rust docs in Section 1, the **API docs for this project's dependencies** (iroh, iroh-gossip, tokio, opus, pipewire, iced, …) are generated locally at `/home/mollusk/Documents/peerspeak_docs/`. Grep/read these instead of probing the web — e.g. confirm `Endpoint::connect`'s signature or `MemoryLookup`'s methods there. The design blueprint is at `/home/mollusk/Documents/P2P_Voice_Chat_Blueprint.md`. A **living handoff log** is maintained at `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/handoff.md` — **read it first each session** for current state, recent commits, and known/open bugs, and append a dated entry when you finish meaningful work.
Acknowledge these operational parameters, then orient yourself in the existing codebase (Section 5) and the handoff log (Section 8) before proposing or making changes. Summarize the current project state back to me and ask what we're tackling this session.
"""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

+56 -31
View File
@@ -1,41 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
<title>PeerSpeak</title>
<desc>Two luminous voices meet directly to form a flowing S.</desc>
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#1e1e2e"/>
<stop offset="1" stop-color="#181825"/>
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
<stop stop-color="#101d42"/>
<stop offset="0.5" stop-color="#071225"/>
<stop offset="1" stop-color="#160b31"/>
</linearGradient>
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
<stop stop-color="#35efff"/>
<stop offset="0.42" stop-color="#2583ff"/>
<stop offset="0.68" stop-color="#8a42ff"/>
<stop offset="1" stop-color="#ff3cdd"/>
</linearGradient>
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
</linearGradient>
<radialGradient id="core">
<stop stop-color="#ffffff"/>
<stop offset="0.28" stop-color="#baf7ff"/>
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
</radialGradient>
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
<feGaussianBlur stdDeviation="6"/>
</filter>
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
</filter>
</defs>
<!-- Rounded-square tile -->
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
stroke="#313244" stroke-width="3"/>
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
<!-- P2P mesh: edges (under nodes + mic) -->
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
<line x1="74" y1="74" x2="128" y2="128"/>
<line x1="182" y1="74" x2="128" y2="128"/>
<line x1="74" y1="182" x2="128" y2="128"/>
<line x1="182" y1="182" x2="128" y2="128"/>
<line x1="74" y1="74" x2="182" y2="74"/>
<line x1="74" y1="182" x2="182" y2="182"/>
</g>
<!-- Broad color glow, kept behind the silhouette. -->
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
opacity="0.5" filter="url(#shadow)"/>
<!-- P2P mesh: peer nodes -->
<g fill="#b4befe">
<circle cx="74" cy="74" r="11"/>
<circle cx="182" cy="74" r="11"/>
<circle cx="74" cy="182" r="11"/>
<circle cx="182" cy="182" r="11"/>
</g>
<!-- The two waveform halves are equal peers and meet at one bright point. -->
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
<g fill="none" stroke="#89b4fa" stroke-width="13"
stroke-linecap="round" stroke-linejoin="round">
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
</g>
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
fill="none" stroke="url(#voice)" stroke-width="25"
stroke-linecap="round" stroke-linejoin="round"/>
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
<!-- The direct connection is the brightest and simplest detail. -->
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+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}");
}
+3 -3
View File
@@ -81,8 +81,8 @@ allow = [
confidence-threshold = 0.8
exceptions = []
# peerspeak itself has no `license` field and is not published, so skip the
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
# if/when this is ever published.
# peerspeak is MIT-licensed (see Cargo.toml `license` + the LICENSE file) but is
# not published to crates.io, so keep the private-crate skip for the
# "unlicensed"/publish checks. MIT is already in the allow list above.
[licenses.private]
ignore = true
+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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+13 -3
View File
@@ -1,12 +1,12 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.1.0
pkgver=0.6.2.r319.g8014edf
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
url="https://gitbutter.xyz/mollusk/peerspeak"
license=('custom')
license=('MIT')
depends=('pipewire' 'opus')
makedepends=('git' 'cargo' 'pkgconf')
optdepends=('pixelpass: screen sharing inside a room'
@@ -14,7 +14,7 @@ optdepends=('pixelpass: screen sharing inside a room'
provides=('peerspeak')
conflicts=('peerspeak')
options=('!lto' '!debug')
source=("$_pkgname::git+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
source=("$_pkgname::git+https://gitbutter.xyz/mollusk/peerspeak.git")
sha256sums=('SKIP')
pkgver() {
@@ -38,6 +38,11 @@ build() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
# Strip the build directory out of paths embedded in the binary (Rust bakes
# source paths into panic/backtrace metadata that survives stripping), so the
# package doesn't reference $srcdir. One remap covers our sources and the
# vendored deps, since CARGO_HOME lives under $srcdir too.
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$srcdir=/"
cargo build --frozen --release --bin "$_pkgname"
}
@@ -65,4 +70,9 @@ package() {
install -Dm644 "assets/icons/$_pkgname-$s.png" \
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/$_pkgname.png"
done
# License + third-party attribution notices.
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$_pkgname/LICENSE"
install -Dm644 THIRD_PARTY_LICENSES \
"$pkgdir/usr/share/licenses/$_pkgname/THIRD_PARTY_LICENSES"
}
+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.
-112
View File
@@ -1,112 +0,0 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
# finds it. Shared version string is derived from peerspeak's git.
#
# Clone this repo and build from here:
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
# cd peerspeak/packaging/test-pack
# makepkg -si
pkgbase=peerspeak-git
pkgname=('peerspeak-git' 'pixelpass')
pkgver=0.1.0
pkgrel=1
arch=('x86_64')
url="https://gitbutter.xyz/mollusk/peerspeak"
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
makedepends=('git' 'cargo' 'pkgconf')
options=('!lto' '!debug')
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
sha256sums=('SKIP'
'SKIP')
pkgver() {
cd "$srcdir/peerspeak"
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
printf '%s.r%s.g%s' \
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short HEAD)"
}
prepare() {
# Vendor deps up front so build() can run --frozen (no surprise network).
export CARGO_HOME="$srcdir/cargo-home"
local host; host="$(rustc -vV | sed -n 's/host: //p')"
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
}
build() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$srcdir/peerspeak"
cargo build --frozen --release --bin peerspeak
cd "$srcdir/pixelpass"
# --features gui so the .desktop launcher (pixelpass --gui) works.
cargo build --frozen --release --features gui
}
check() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
# peerspeak library unit tests only — its integration suites bind real
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
cd "$srcdir/peerspeak"
cargo test --frozen --release --lib
}
package_peerspeak-git() {
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
depends=('pipewire' 'opus')
optdepends=('pixelpass: screen sharing inside a room'
'mpv: screen-share viewer (vlc is used as a fallback)')
provides=('peerspeak')
conflicts=('peerspeak')
license=('custom')
cd "$srcdir/peerspeak"
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
install -Dm644 "packaging/peerspeak.desktop" \
"$pkgdir/usr/share/applications/peerspeak.desktop"
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
install -Dm644 "assets/icons/peerspeak.svg" \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
local s
for s in 16 24 32 48 64 128 256 512; do
install -Dm644 "assets/icons/peerspeak-$s.png" \
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
done
}
package_pixelpass() {
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
'libglvnd' 'libxkbcommon' 'wayland')
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
'vlc: alternative stream viewer'
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
'gst-plugin-pipewire: screen capture on Wayland sessions'
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
license=('MIT' 'Apache-2.0' 'OFL-1.1')
cd "$srcdir/pixelpass"
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
install -Dm0644 assets/pixelpass.desktop \
"$pkgdir/usr/share/applications/pixelpass.desktop"
install -Dm0644 assets/pixelpass.svg \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
install -Dm0644 assets/NotoSans-OFL.txt \
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
}
-52
View File
@@ -1,52 +0,0 @@
# PeerSpeak + PixelPass — CachyOS/Arch test pack
A single **split PKGBUILD** that builds the latest code from the public gitbutter
repos and installs **both** programs at once:
- `peerspeak` — decentralized P2P voice chat
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
## Build & install (one command)
```sh
git clone https://gitbutter.xyz/mollusk/peerspeak.git
cd peerspeak/packaging/test-pack
makepkg -si
```
`makepkg -si` auto-installs every dependency via pacman before building —
including the Rust toolchain itself (the `cargo` makedepend is provided by the
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
git sources re-pull `main` and the version bumps automatically.
> Skip the test step with `makepkg -si --nocheck` for a faster build.
## Running the cross-internet test
1. Launch `peerspeak` on both machines.
2. One person **creates** a room and shares the room code/ticket with the other.
3. The other **joins** with that code.
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
falls back to a public n0 relay — **no port forwarding required**.
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
nothing needs to be opened inbound for relay mode).
### What we're smoke-testing
- Two real humans, two networks, over the internet.
- Mic capture + remote playback both directions, no crackle/dropouts.
- Mute / deafen, push-to-talk.
- Text chat in-room.
- Avatars (presets + custom upload) show up on the other side.
- Screen share: click the screen-share control → it launches `pixelpass`; the
viewer opens in `mpv` on the receiving side.
- Notification chimes (join/leave/etc.).
- Leave / rejoin cleanly.
If anything misbehaves, grab the log path peerspeak prints on startup and the
exact repro steps.
+28 -2
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.2.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
@@ -69,13 +69,39 @@ Either way works the same; it just depends on who makes the room.
---
## 4. Chatting and sharing photos/files
There's a **text chat** box at the bottom of the call window — type a message
and press **Enter** to send it to everyone in the room.
You can also **send a photo or a file**:
1. Click the **attach button** (the small paperclip-style button) next to the
message box.
2. Pick a photo or file from your computer.
3. It sends to everyone in the room. **Photos show up right in the chat**;
other files appear as a small download chip with the file's name.
To **save** a file someone sent you, click the **Save** (or **Download**)
button next to it in the chat and choose where to put it.
A couple of notes:
- There's a size limit of about **25 MB** per file — bigger files are turned
away with a message.
- Shared files only last for the **current call**. They aren't saved anywhere
automatically, so save anything you want to keep before you leave the room.
---
## Troubleshooting
- **"I don't hear anything."** Open Settings and pick the correct microphone and
output device. Headphones are best — they prevent echo.
- **"It won't connect."** Make sure you pasted the *entire* ticket (they're
long and easy to cut off). If it still won't connect, we may just need a fresh
ticket — they're meant to be used right away.
ticket — they're meant to be used right away. Also make sure we're both on the
**same version** — if I've sent you an updated installer, install it (an old
version and a new one can't connect to each other).
- **The blue warning again.** Same as install: **More info → Run anyway**. It's
the unsigned-app warning, not malware.
+13
View File
@@ -9,6 +9,19 @@ notification chimes, and avatar presets are all embedded in the binary
runtime, so there are no extra DLLs to bundle. The installer payload is just the
`.exe` plus an `.ico` for the Start-menu / desktop shortcuts.
## Version compatibility
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
handshake rather than misbehaving). So when you ship a new Windows build after
a MINOR bump, **everyone on the call must reinstall** — an old Windows build
and a newer Linux/Windows peer won't talk. (0.3.0 was the chat file-sharing +
per-peer noise-gate release; it cannot connect to a 0.2.x peer.)
## Files
| File | Tracked | Purpose |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

+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.2.0"
#define MyAppVersion "0.6.3"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+5303 -509
View File
File diff suppressed because it is too large Load Diff
+331
View File
@@ -0,0 +1,331 @@
//! Independent playback engine for inline chat audio attachments.
//!
//! The rodio device sink stays on a dedicated OS thread and never enters iced
//! state or the call-audio pipeline. The GUI sends small commands and reads a
//! shared status snapshot at its redraw cadence.
use crate::files::AttachmentId;
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
use std::io::Cursor;
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
/// State published by the playback thread for the GUI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClipStatus {
pub playing_id: Option<AttachmentId>,
pub position: Duration,
pub total: Option<Duration>,
pub paused: bool,
/// Set when output initialization or decoding rejects the requested clip.
/// The app consumes this as a signal to fall back to the normal file chip.
pub failure: Option<ClipFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipFailure {
pub id: AttachmentId,
pub error: String,
/// Decoder rejection means the filename hint should fall back to a file
/// chip. Output-device failures remain retryable as audio.
pub invalid_audio: bool,
}
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
#[derive(Debug)]
enum ClipCommand {
Play(AttachmentId, Vec<u8>),
Pause,
Resume,
Seek(Duration),
Stop,
SetVolume(f32),
}
/// Cheap, `Send` command handle for the dedicated playback thread.
pub struct ClipPlayer {
command_tx: mpsc::Sender<ClipCommand>,
status: SharedClipStatus,
}
impl ClipPlayer {
/// Start the playback worker. The system output device is opened lazily on
/// first Play, so merely launching PeerSpeak never claims another stream.
///
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
/// clip, restored from config so the level persists across sessions.
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
let (command_tx, command_rx) = mpsc::channel();
let status = Arc::new(Mutex::new(ClipStatus::default()));
let worker_status = Arc::clone(&status);
std::thread::Builder::new()
.name("peerspeak-clip-player".to_string())
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
.expect("failed to spawn clip playback thread");
(
Self {
command_tx,
status: Arc::clone(&status),
},
status,
)
}
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
update_status(&self.status, |status| {
status.playing_id = Some(id);
status.position = Duration::ZERO;
status.total = None;
status.paused = false;
status.failure = None;
});
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
}
pub fn pause(&self) {
let _ = self.command_tx.send(ClipCommand::Pause);
}
pub fn resume(&self) {
let _ = self.command_tx.send(ClipCommand::Resume);
}
pub fn seek(&self, position: Duration) {
let _ = self.command_tx.send(ClipCommand::Seek(position));
}
pub fn stop(&self) {
let _ = self.command_tx.send(ClipCommand::Stop);
}
/// Set the universal playback gain (`1.0` = unity). Applies to the current
/// clip immediately and to every clip played afterwards.
pub fn set_volume(&self, volume: f32) {
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
}
}
fn playback_worker(
command_rx: mpsc::Receiver<ClipCommand>,
status: SharedClipStatus,
initial_volume: f32,
) {
let mut output: Option<MixerDeviceSink> = None;
let mut player: Option<Player> = None;
// Universal gain remembered across clips so a level set on one upload
// carries to the next; reapplied to each freshly connected player.
let mut volume = initial_volume.max(0.0);
loop {
match command_rx.recv_timeout(Duration::from_millis(100)) {
Ok(ClipCommand::Play(id, bytes)) => {
// In-memory readers do not expose file metadata to rodio. Pass
// the known attachment length explicitly so formats without a
// duration in their headers (notably MP3 and Vorbis) can derive
// a total duration and support reliable seeking.
let source = match decode_clip(bytes) {
Ok(source) => source,
Err(error) => {
fail(
&status,
id,
format!("unsupported or invalid audio: {error}"),
true,
);
continue;
}
};
let total = source.total_duration();
if output.is_none() {
match DeviceSinkBuilder::open_default_sink() {
Ok(sink) => {
let new_player = Player::connect_new(sink.mixer());
new_player.set_volume(volume);
player = Some(new_player);
output = Some(sink);
}
Err(error) => {
fail(
&status,
id,
format!("audio output unavailable: {error}"),
false,
);
continue;
}
}
}
if let Some(player) = player.as_ref() {
player.clear();
player.append(source);
player.play();
update_status(&status, |s| {
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = total;
s.paused = false;
s.failure = None;
});
}
}
Ok(ClipCommand::Pause) => {
if let Some(player) = player.as_ref() {
player.pause();
update_status(&status, |s| s.paused = true);
}
}
Ok(ClipCommand::Resume) => {
if let Some(player) = player.as_ref() {
player.play();
update_status(&status, |s| s.paused = false);
}
}
Ok(ClipCommand::Seek(position)) => {
if let Some(player) = player.as_ref()
&& player.try_seek(position).is_ok()
{
update_status(&status, |s| s.position = position);
}
}
Ok(ClipCommand::Stop) => {
if let Some(player) = player.as_ref() {
player.clear();
}
reset(&status);
}
Ok(ClipCommand::SetVolume(level)) => {
volume = level.max(0.0);
if let Some(player) = player.as_ref() {
player.set_volume(volume);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if let Some(player) = player.as_ref() {
let (active, failed) = status
.lock()
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
.unwrap_or_default();
if active && !failed && player.empty() {
reset(&status);
} else if active && !failed {
update_status(&status, |s| {
s.position = player.get_pos();
s.paused = player.is_paused();
});
}
}
}
}
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
let byte_len = bytes.len() as u64;
Decoder::builder()
.with_data(Cursor::new(bytes))
.with_byte_len(byte_len)
.build()
}
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
crate::log_msg(&format!("Inline audio playback failed: {error}"));
update_status(status, |s| {
// Keep the id active until the GUI observes the failure on its next
// tick. This guarantees the active-only timer cannot disappear in the
// small window between sending Play and decoder/output failure.
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = None;
s.paused = false;
s.failure = Some(ClipFailure {
id,
error,
invalid_audio,
});
});
}
fn reset(status: &SharedClipStatus) {
update_status(status, |s| *s = ClipStatus::default());
}
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
if let Ok(mut status) = status.lock() {
update(&mut status);
}
}
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
status.lock().map(|s| s.clone()).unwrap_or_default()
}
/// Format clip time as `mm:ss` (hours are folded into minutes).
pub fn format_time(duration: Duration) -> String {
let seconds = duration.as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
return 0.0;
};
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
}
/// Convert a slider fraction into a clamped position within a clip.
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
#[test]
fn in_memory_mp3_reports_duration() {
// One headerless constant-bitrate MP3 frame repeated to model files
// that do not carry an Xing/VBR duration header.
let frame = base64::engine::general_purpose::STANDARD
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
.expect("valid test fixture");
let bytes = frame.repeat(20);
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
assert!(decoder.total_duration().is_some());
}
#[test]
fn formats_clip_time() {
assert_eq!(format_time(Duration::ZERO), "0:00");
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
}
#[test]
fn progress_handles_unknown_zero_and_clamps() {
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
assert_eq!(
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
0.5
);
assert_eq!(
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
1.0
);
}
#[test]
fn seek_target_clamps_fraction() {
let total = Duration::from_secs(100);
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
assert_eq!(seek_target(2.0, total), total);
}
}
+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
+127 -18
View File
@@ -34,6 +34,18 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
/// nodes never leak past the call that created them.
pub struct EchoCancelGuard {
module_index: String,
source_name: String,
sink_name: String,
}
impl EchoCancelGuard {
pub fn source_name(&self) -> &str {
&self.source_name
}
pub fn sink_name(&self) -> &str {
&self.sink_name
}
}
impl Drop for EchoCancelGuard {
@@ -42,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
));
}
}
@@ -53,17 +68,24 @@ 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();
let owner_pid = std::process::id();
let source_name = format!("{EC_SOURCE}.{owner_pid}");
let sink_name = format!("{EC_SINK}.{owner_pid}");
let mut cmd = Command::new("pactl");
cmd.arg("load-module")
.arg("module-echo-cancel")
.arg("aec_method=webrtc")
.arg(format!("source_name={EC_SOURCE}"))
.arg(format!("sink_name={EC_SINK}"));
.arg(format!("source_name={source_name}"))
.arg(format!("sink_name={sink_name}"));
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
cmd.arg(format!("source_master={src}"));
}
@@ -85,12 +107,16 @@ 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 };
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
// never show, drop the guard (unloads) and report failure.
if !wait_for_nodes() {
if !wait_for_nodes(guard.source_name(), guard.sink_name()) {
return Err("echo-cancel virtual nodes did not appear in time".to_string());
}
@@ -102,10 +128,10 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
}
/// Polls until both virtual nodes exist or the timeout elapses.
fn wait_for_nodes() -> bool {
fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
let deadline = Instant::now() + NODE_READY_TIMEOUT;
loop {
if node_present("sources", EC_SOURCE) && node_present("sinks", EC_SINK) {
if node_present("sources", source_name) && node_present("sinks", sink_name) {
return true;
}
if Instant::now() >= deadline {
@@ -118,7 +144,12 @@ fn wait_for_nodes() -> 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)
@@ -126,10 +157,37 @@ fn node_present(kind: &str, name: &str) -> bool {
.any(|line| line.split('\t').nth(1) == Some(name))
}
/// Unloads any leftover `module-echo-cancel` instance we previously created
/// (identified by our virtual node names in its argument string). Best-effort.
fn pid_from_ec_args(args: &str) -> Option<u32> {
let source_prefix = format!("source_name={EC_SOURCE}.");
args.split_whitespace()
.find_map(|arg| arg.strip_prefix(&source_prefix))?
.parse()
.ok()
}
fn ec_module_is_stale(args: &str, is_alive: impl Fn(u32) -> bool) -> bool {
pid_from_ec_args(args).is_some_and(|pid| !is_alive(pid))
}
#[cfg(target_os = "linux")]
fn process_is_alive(pid: u32) -> bool {
std::path::Path::new("/proc").join(pid.to_string()).exists()
}
#[cfg(not(target_os = "linux"))]
fn process_is_alive(_pid: u32) -> bool {
true
}
/// 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() {
@@ -137,8 +195,14 @@ fn unload_stale() {
let index = cols.next().unwrap_or("");
let name = cols.next().unwrap_or("");
let args = cols.next().unwrap_or("");
if name == "module-echo-cancel" && args.contains(EC_SOURCE) && index.parse::<u64>().is_ok() {
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
if name == "module-echo-cancel"
&& ec_module_is_stale(args, process_is_alive)
&& index.parse::<u64>().is_ok()
{
let _ = Command::new("pactl")
.arg("unload-module")
.arg(index)
.output();
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
}
}
@@ -155,12 +219,57 @@ mod tests {
#[ignore]
fn enable_creates_and_unloads_nodes() {
let guard = enable(None, None).expect("module-echo-cancel should load");
assert!(node_present("sources", EC_SOURCE), "cleaned source must exist");
assert!(node_present("sinks", EC_SINK), "reference sink must exist");
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"
);
drop(guard);
// Give pactl a moment to tear the nodes down.
std::thread::sleep(Duration::from_millis(300));
assert!(!node_present("sources", EC_SOURCE), "source must be gone after unload");
assert!(!node_present("sinks", EC_SINK), "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]
fn parses_owner_pid_only_from_our_source_name() {
assert_eq!(
pid_from_ec_args(
"aec_method=webrtc source_name=peerspeak_echocancel_source.4242 sink_name=peerspeak_echocancel_sink.4242"
),
Some(4242)
);
assert_eq!(pid_from_ec_args("aec_method=webrtc"), None);
assert_eq!(
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
);
}
#[test]
fn stale_decision_keeps_live_and_foreign_modules() {
let ours = "source_name=peerspeak_echocancel_source.4242";
assert!(!ec_module_is_stale(ours, |pid| pid == 4242));
assert!(ec_module_is_stale(ours, |_| false));
assert!(!ec_module_is_stale("source_name=foreign.4242", |_| false));
assert!(!ec_module_is_stale(
"source_name=peerspeak_echocancel_source.malformed",
|_| false
));
}
}
+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"
);
}
}
+12 -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.
@@ -56,6 +60,7 @@ pub trait AudioBackend: Send + Sync {
fn stop(&self) -> Result<(), AudioError>;
}
pub mod clip_player;
pub mod eq;
pub mod gate;
pub mod limiter;
@@ -64,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`);
@@ -95,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.
///
+496 -87
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,45 +26,38 @@ 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
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
const MAX_MIC_FIFO: usize = 48_000 / 5;
const MAX_SESSION_DIR_ATTEMPTS: usize = 1_000;
/// 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;
/// Create a collision-free session directory for a timestamp. The base
/// timestamp is tried first, followed by `-2`, `-3`, and so on; an existing
/// recording is never reopened or overwritten.
pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf> {
let filename = crate::audio::recorder::timestamp_filename(now_unix_secs);
let stem = filename.trim_end_matches(".wav");
for attempt in 1..=MAX_SESSION_DIR_ATTEMPTS {
let name = if attempt == 1 {
stem.to_string()
} else {
format!("{stem}-{attempt}")
};
let path = base.join(name);
match std::fs::create_dir(&path) {
Ok(()) => return Ok(path),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
Ok(())
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"multitrack directory suffixes exhausted",
))
}
/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
@@ -82,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("--") {
@@ -94,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(),
})
}
@@ -141,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(())
}
@@ -154,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
@@ -183,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(())
}
@@ -198,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")))
}
}
@@ -245,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]);
@@ -258,11 +511,131 @@ 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"));
}
#[test]
fn same_second_sessions_get_unique_directories_without_reuse() {
let base = tmpdir("collision");
let first = create_session_dir(&base, 1_700_000_000).unwrap();
std::fs::write(first.join("sentinel"), b"keep me").unwrap();
let second = create_session_dir(&base, 1_700_000_000).unwrap();
assert_ne!(second, first);
assert_eq!(std::fs::read(first.join("sentinel")).unwrap(), b"keep me");
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");
@@ -288,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]
@@ -318,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();
@@ -339,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;
}
+135 -50
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)
@@ -151,11 +163,9 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
let data = &mut datas[0];
let size = data.chunk().size() as usize;
if let Some(slice) = data.data() {
// Each sample is 2 bytes (S16LE)
for chunk in slice[..size].chunks_exact(2) {
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
for_each_capture_sample(slice, size, |sample| {
let _ = user_data.producer.try_push(sample);
}
});
}
}
}
@@ -183,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));
@@ -224,6 +235,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
Ok(())
}
/// Visit the complete S16LE samples in the portion PipeWire reports as filled.
/// Clamp the reported byte count to the mapped slice before indexing: a bad
/// chunk size must not panic from the realtime capture callback.
fn for_each_capture_sample(slice: &[u8], size: usize, mut visit: impl FnMut(i16)) {
let size = size.min(slice.len());
for chunk in slice[..size].chunks_exact(2) {
visit(i16::from_le_bytes([chunk[0], chunk[1]]));
}
}
/// Frames the playback RT callback should produce this cycle.
///
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
@@ -249,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),
@@ -263,10 +280,33 @@ fn drain_loop(
}
}
/// Reserve exact occupancy before making a frame visible to the consumer.
/// `after_reserve` is empty in production and lets the regression test force a
/// consumer interleaving at the critical ordering boundary.
fn publish_frame<P: Producer<Item = i16>>(
fill: &AtomicUsize,
dropped: &AtomicU64,
producer: &mut P,
frame: &[i16],
after_reserve: impl FnOnce(),
) {
fill.fetch_add(frame.len(), Ordering::Relaxed);
after_reserve();
let pushed = producer.push_slice(frame);
if pushed != frame.len() {
fill.fetch_sub(frame.len() - pushed, Ordering::Relaxed);
dropped.fetch_add(1, Ordering::Relaxed);
}
}
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)
}
@@ -276,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
@@ -401,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
@@ -466,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,
},
),
)),
),
@@ -497,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));
@@ -522,10 +570,12 @@ fn run_playback(
worker_dropped.fetch_add(1, Ordering::Relaxed);
return;
}
for &sample in &frame {
let _ = producer.try_push(sample);
}
worker_fill.fetch_add(frame.len(), Ordering::Relaxed);
// Reserve occupancy BEFORE publishing samples. Otherwise the RT
// consumer can pop a newly-visible sample before it is counted and
// wrap the exact fill gauge to usize::MAX, wedging mixer pacing.
// `push_slice` also publishes the frame as one operation rather than
// exposing a half-written stereo pair.
publish_frame(&worker_fill, &worker_dropped, &mut producer, &frame, || {});
});
});
@@ -577,8 +627,12 @@ fn run_playback(
#[cfg(test)]
mod tests {
use super::{drain_loop, frames_to_produce};
use std::sync::atomic::{AtomicBool, Ordering};
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
use ringbuf::{
HeapRb,
traits::{Consumer, Producer, Split},
};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::{sync::mpsc, thread};
@@ -614,6 +668,37 @@ mod tests {
assert_eq!(frames_to_produce(1024, 0), 0);
}
#[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));
assert_eq!(samples, vec![1, 2]);
}
#[test]
fn occupancy_is_reserved_before_frame_is_published() {
let rb = HeapRb::<i16>::new(8);
let (mut producer, mut consumer) = rb.split();
assert!(producer.try_push(7).is_ok());
let fill = AtomicUsize::new(1);
let dropped = AtomicU64::new(0);
publish_frame(&fill, &dropped, &mut producer, &[10, 11], || {
// Force the consumer to drain the old sample after the new frame's
// occupancy is reserved but before that frame is published.
assert_eq!(consumer.try_pop(), Some(7));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 3);
});
assert_eq!(fill.load(Ordering::Relaxed), 2);
assert_eq!(consumer.try_pop(), Some(10));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 2);
assert_eq!(consumer.try_pop(), Some(11));
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 1);
assert_eq!(fill.load(Ordering::Relaxed), 0);
assert_eq!(dropped.load(Ordering::Relaxed), 0);
}
// --- drain_loop (A7: worker must not hang shutdown) ---
#[test]
+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);
+203 -47
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;
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;
@@ -24,6 +29,9 @@ const BITS_PER_SAMPLE: u16 = 16;
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
@@ -42,7 +50,12 @@ pub struct WavWriter {
impl WavWriter {
/// Create the file and write the 44-byte header with zeroed size fields.
pub fn new(path: &Path) -> io::Result<Self> {
let mut file = File::create(path)?;
Self::from_file(File::create(path)?)
}
/// Start a WAV in an already-opened file. This lets callers choose atomic
/// create-new semantics instead of the truncating behavior of `File::create`.
fn from_file(mut file: File) -> io::Result<Self> {
file.write_all(&Self::header(0))?;
Ok(Self {
file,
@@ -112,26 +125,51 @@ 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 {
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
/// exist (the caller creates it).
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
let path = dir.join(timestamp_filename(now_unix_secs));
let writer = WavWriter::new(&path)?;
Ok(Self {
writer,
mic_fifo: VecDeque::new(),
path,
})
let filename = timestamp_filename(now_unix_secs);
let stem = filename.trim_end_matches(".wav");
for attempt in 1..=MAX_NAME_ATTEMPTS {
let name = if attempt == 1 {
filename.clone()
} else {
format!("{stem}-{attempt}.wav")
};
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 {
frame_tx,
writer_thread,
mic_fifo: VecDeque::new(),
path,
dropped_frames: 0,
});
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"recording filename suffixes exhausted",
))
}
/// The path being written.
@@ -155,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) {
@@ -198,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() {
@@ -210,6 +317,61 @@ mod tests {
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
}
#[test]
fn same_second_recordings_get_unique_files_without_truncation() {
let dir = unique_temp_dir("peerspeak-collision");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut first = Recorder::create(&dir, 1_700_000_000).unwrap();
first.write_frame(&[123, 456]).unwrap();
let first_path = first.path().to_path_buf();
first.finalize().unwrap();
let original = std::fs::read(&first_path).unwrap();
let second = Recorder::create(&dir, 1_700_000_000).unwrap();
let second_path = second.path().to_path_buf();
assert_ne!(second_path, first_path);
assert_eq!(std::fs::read(&first_path).unwrap(), original);
second.finalize().unwrap();
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();
@@ -252,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
+130 -2
View File
@@ -185,10 +185,132 @@ pub fn initials(name: &str) -> String {
}
}
/// A small content-addressed LRU cache mapping image bytes to a built value
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
/// hash map:
///
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
/// overflow, so a peer can't grow the cache without limit by publishing an
/// endless stream of distinct valid avatars.
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
/// 64-bit hash, so a hash collision can never return a different image's value.
///
/// Linear scan; intended for small `cap` (tens of entries).
pub struct ByteLru<V> {
cap: usize,
/// `(content hash, content bytes, value)`; back = most recently used.
entries: Vec<(u64, Vec<u8>, 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(),
}
}
/// Return the cached value for these exact `bytes`, building and inserting it
/// on a miss (evicting the least-recently-used entry once over `cap`). A hit
/// verifies full byte equality, so a 64-bit hash collision never returns the
/// wrong value. A hit also refreshes the entry's recency.
pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
self.get_or_insert_hashed(hasher.finish(), bytes, build)
}
/// Inner seam with the content `hash` supplied explicitly. Production callers
/// use [`get_or_insert`]; tests use this to force a hash collision (different
/// bytes, same hash) and exercise the byte-equality guard.
fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
if let Some(idx) = self
.entries
.iter()
.position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
{
// LRU touch: move the hit entry to the back (most recent).
let entry = self.entries.remove(idx);
let val = entry.2.clone();
self.entries.push(entry);
return val;
}
let val = build();
if self.entries.len() >= self.cap {
self.entries.remove(0); // evict least-recently-used
}
self.entries.push((hash, bytes.to_vec(), val.clone()));
val
}
#[cfg(test)]
fn len(&self) -> usize {
self.entries.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn byte_lru_reuses_value_for_identical_bytes() {
let mut lru: ByteLru<u32> = ByteLru::new(4);
let mut next = 0u32;
let mut build = |lru: &mut ByteLru<u32>, b: &[u8]| {
lru.get_or_insert(b, || {
next += 1;
next
})
};
// Same bytes → same value, built only once.
assert_eq!(build(&mut lru, b"alice"), 1);
assert_eq!(build(&mut lru, b"alice"), 1);
// Different bytes → a freshly built value.
assert_eq!(build(&mut lru, b"bob"), 2);
assert_eq!(lru.len(), 2);
}
#[test]
fn byte_lru_evicts_least_recently_used() {
let mut lru: ByteLru<u32> = ByteLru::new(2);
let mut n = 0u32;
let mut ins = |lru: &mut ByteLru<u32>, b: &[u8]| {
lru.get_or_insert(b, || {
n += 1;
n
})
};
ins(&mut lru, b"a"); // -> 1
ins(&mut lru, b"b"); // -> 2, cache = [a, b]
ins(&mut lru, b"a"); // touch a, cache = [b, a]
ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c]
assert_eq!(lru.len(), 2);
// `a` survived (recently touched) → still value 1, not rebuilt.
assert_eq!(ins(&mut lru, b"a"), 1);
// `b` was evicted → rebuilt with a new value.
assert_eq!(ins(&mut lru, b"b"), 4);
}
#[test]
fn byte_lru_byte_equality_survives_a_hash_collision() {
// Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a
// bare-hash cache would alias — Tier C F-03 collision bug).
let mut lru: ByteLru<u32> = ByteLru::new(4);
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1);
// `bob` collides on the hash but differs in bytes → a MISS, built fresh,
// NOT aliased to alice's value.
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2);
// Both coexist; each re-lookup returns its own value (build closure unused).
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1);
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2);
assert_eq!(lru.len(), 2);
}
#[test]
fn initials_takes_first_two_words() {
assert_eq!(initials("Alice"), "A");
@@ -230,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());
@@ -287,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);
+31 -1
View File
@@ -45,12 +45,31 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
Ok(png.into_inner())
}
/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
/// background (W18), derived from the game's stable id by hashing rather than
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
/// maps to the same file.
pub fn game_background_filename(game_id: &str) -> String {
// FNV-1a, 64-bit.
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for b in game_id.as_bytes() {
hash ^= *b as u64;
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("game-bg-{hash:016x}.png")
}
/// The legibility scrim drawn between the background image and the UI: the active
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
/// 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)]
@@ -90,6 +109,17 @@ mod tests {
assert!(process_background(b"definitely not an image").is_err());
}
#[test]
fn game_background_filename_is_stable_safe_and_distinct() {
let a = game_background_filename("steam:730");
// Stable for the same id.
assert_eq!(a, game_background_filename("steam:730"));
// Distinct ids → distinct files (no `:` or path chars leak through).
assert_ne!(a, game_background_filename("exe:hl2_linux"));
assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
}
#[test]
fn scrim_color_sets_alpha_and_keeps_rgb() {
let base = Color::from_rgb(0.1, 0.2, 0.3);
+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)
}
}
+18 -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();
@@ -64,6 +71,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_a.addr(),
sharing: None,
avatar: Default::default(),
game: None,
music: None,
};
room_a.join(&ticket_str, state_a, vec![]).await?;
println!("Node A joined topic.");
@@ -83,6 +92,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
addr: endpoint_b.addr(),
sharing: None,
avatar: Default::default(),
game: None,
music: None,
};
room_b.join(&ticket_str, state_b, vec![]).await?;
println!("Node B joined topic.");
+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"
);
}
}
+662 -32
View File
@@ -1,9 +1,12 @@
use crate::notify::Sound;
use crate::theme::AppTheme;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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
}
@@ -126,6 +325,10 @@ fn default_chat_height() -> f32 {
180.0
}
fn default_threecol_playlist_height() -> f32 {
220.0
}
fn default_controls_width() -> f32 {
280.0
}
@@ -134,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
}
@@ -156,8 +363,33 @@ pub struct AppConfig {
/// App-internal playback gain applied to the mixed output (1.0 = unity).
#[serde(default = "default_volume")]
pub output_volume: f32,
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
/// level shared by every uploaded clip so the slider sticks across plays.
#[serde(default = "default_volume")]
pub clip_volume: f32,
/// W22 music: the user's personal playlist as local file PATHS (not bytes).
/// Loaded into memory at startup; missing files are skipped/marked on play.
#[serde(default)]
pub music_playlist: Vec<String>,
/// W22 music: local playback gain for the dedicated music player (1.0 = unity).
#[serde(default = "default_volume")]
pub music_volume: f32,
/// 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)]
@@ -174,12 +406,20 @@ pub struct AppConfig {
pub participants_width: f32,
#[serde(default = "default_chat_height")]
pub chat_height: f32,
/// Height (px) of the standalone Playlist card stacked under Chat in the
/// 3-column layout. Resized via its own horizontal divider; re-clamped to the
/// window on load/resize. Only used by `RoomLayout::ThreeColumn`.
#[serde(default = "default_threecol_playlist_height")]
pub threecol_playlist_height: f32,
/// Controls panel width for the 3-column layout (px).
#[serde(default = "default_controls_width")]
pub controls_width: f32,
/// 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,
@@ -199,6 +439,26 @@ pub struct AppConfig {
/// `crate::background::scrim_color`.
#[serde(default = "default_background_dim")]
pub background_dim: f32,
/// Broadcast the detected game as presence next to our avatar (game-detection
/// feature). **Opt-in, default OFF.** Enabling immediately publishes the
/// current game; disabling immediately publishes `game: None`. Toggling this
/// is the only thing that puts our game on the wire — detection itself (for the
/// local background) runs regardless.
#[serde(default)]
pub game_presence_enabled: bool,
/// Per-game UI background overrides (W18), keyed by stable game id
/// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
/// config dir (see `game_background_path`). The running game's entry wins; with
/// no entry we fall back to the single custom `background`. Local-only; never
/// sent to peers. `BTreeMap` for deterministic serialization.
#[serde(default)]
pub game_backgrounds: BTreeMap<String, String>,
/// User process→display-name mappings for non-Steam game detection, keyed by
/// normalized executable basename (`hl2_linux`) → the name to show/broadcast
/// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
/// game from an arbitrary process). Local-only.
#[serde(default)]
pub game_process_map: BTreeMap<String, String>,
/// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)]
pub recording_mode: RecordingMode,
@@ -242,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.
@@ -255,6 +518,20 @@ pub struct AppConfig {
/// keyed by peer node id string. Local preference only.
#[serde(default)]
pub peer_pan: HashMap<String, f32>,
/// Per-peer listener-side volume/gain (`1.0` = unity), keyed by peer node id
/// string. Local preference only; never sent to peers. Absent entry = unity.
#[serde(default)]
pub peer_volume: HashMap<String, f32>,
/// Per-source music listen volume/gain (`1.0` = unity), keyed by peer node id
/// string. Local preference only; never sent to peers. Absent entry falls
/// back to `music_volume`.
#[serde(default)]
pub music_source_volume: HashMap<String, f32>,
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
/// keyed by peer node id string. Local preference only; never sent to peers.
/// Absent entry = gate disabled (pass-through).
#[serde(default)]
pub peer_gate: HashMap<String, f32>,
/// Focused app-local keyboard shortcuts.
#[serde(default)]
pub hotkeys: crate::hotkeys::HotkeyMap,
@@ -274,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 {
@@ -283,19 +567,31 @@ impl Default for AppConfig {
noise_gate_threshold: 0.01,
input_volume: 1.0,
output_volume: 1.0,
clip_volume: 1.0,
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,
participants_width: default_participants_width(),
chat_height: default_chat_height(),
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(),
background: None,
background_dim: default_background_dim(),
game_presence_enabled: false,
game_backgrounds: BTreeMap::new(),
game_process_map: BTreeMap::new(),
recording_mode: RecordingMode::default(),
custom_sound_self_join: None,
custom_sound_peer_join: None,
@@ -314,9 +610,13 @@ 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(),
peer_volume: HashMap::new(),
music_source_volume: HashMap::new(),
peer_gate: HashMap::new(),
hotkeys: crate::hotkeys::HotkeyMap::default(),
window_width: default_window_width(),
window_height: default_window_height(),
@@ -364,36 +664,141 @@ impl AppConfig {
})
}
/// Path the processed custom-background PNG (W16) is written to, alongside
/// `config.json` in the app config dir. We store our own downscaled copy here
/// (rather than base64 in the config) so the JSON stays small.
pub fn background_path() -> Option<PathBuf> {
/// Path to a processed-background PNG of the given filename, alongside
/// `config.json` in the app config dir. We store our own downscaled copies here
/// (rather than base64 in the config) so the JSON stays small. Used for both
/// the single custom background and the per-game backgrounds.
fn background_dir_path(filename: &str) -> Option<PathBuf> {
dirs::config_dir().map(|mut p| {
p.push("peerspeak");
p.push("background.png");
p.push(filename);
p
})
}
/// Path the single custom-background PNG (W16) is written to.
pub fn background_path() -> Option<PathBuf> {
Self::background_dir_path("background.png")
}
/// Path the processed per-game background PNG (W18) for `game_id` is written
/// to. The filename is an app-owned hash of the id (see
/// `crate::background::game_background_filename`), so raw game ids never appear
/// on disk and the name is always filesystem-safe.
pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
Self::background_dir_path(&crate::background::game_background_filename(game_id))
}
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)]
@@ -408,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.
@@ -430,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());
@@ -438,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();
@@ -457,6 +970,9 @@ mod tests {
// shortcut settings.
assert!(deserialized.peer_eq.is_empty());
assert!(deserialized.peer_pan.is_empty());
assert!(deserialized.peer_volume.is_empty());
assert!(deserialized.music_source_volume.is_empty());
assert!(deserialized.peer_gate.is_empty());
assert_eq!(
crate::hotkeys::format_binding(
deserialized
@@ -467,6 +983,57 @@ mod tests {
);
}
#[test]
fn test_backward_compat_game_detection_fields() {
// A config that predates the game-detection feature (W18) — and crucially
// still carries the W16 single `background` as a plain string — must
// deserialize without error. `AppConfig::load()` silently replaces ANY
// deserialize failure with full defaults, so a broken migration here would
// wipe everyone's settings; this guards that the additive fields kept the
// old shape loadable and that `background` was NOT retyped.
let legacy_json = r#"{
"input_device": "",
"output_device": "",
"noise_gate_threshold": 0.01,
"username": "Eric",
"background": "/home/eric/.config/peerspeak/background.png",
"background_dim": 0.4
}"#;
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!((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);
assert!(cfg.game_backgrounds.is_empty());
assert!(cfg.game_process_map.is_empty());
}
#[test]
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());
let json = serde_json::to_string(&cfg).unwrap();
// Keys appear in sorted order (exe: before steam:).
let bg = json.find("game_backgrounds").unwrap();
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
let steam_at = json[bg..].find("steam:730").unwrap();
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
// Full round-trip preserves the maps.
let back: AppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
assert_eq!(back.game_process_map, cfg.game_process_map);
}
#[test]
fn test_window_size_fields() {
// Default impl is the standard launch size.
@@ -512,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());
@@ -562,28 +1128,57 @@ mod tests {
let def = AppConfig::default();
assert_eq!(def.input_volume, 1.0);
assert_eq!(def.output_volume, 1.0);
assert_eq!(def.clip_volume, 1.0);
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).
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
assert_eq!(cfg_missing.input_volume, 1.0);
assert_eq!(cfg_missing.output_volume, 1.0);
assert_eq!(cfg_missing.clip_volume, 1.0);
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);
// Explicit non-unity values are preserved across a round-trip.
let cfg = AppConfig {
input_volume: 1.5,
output_volume: 0.25,
clip_volume: 0.7,
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()
};
let round_tripped: AppConfig =
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
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_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);
@@ -628,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.
@@ -639,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();
+498 -21
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
@@ -26,6 +34,10 @@ pub enum CoreCommand {
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
/// Listener-side per-peer pan. Local only; never leaves this app instance.
SetPeerPan(EndpointId, f32),
/// Listener-side per-peer noise gate threshold (normalized RMS, `0.0` = off).
/// Applies the same smooth gate as the mic path to a peer's incoming audio,
/// to suppress their background noise on our end. Local only.
SetPeerGate(EndpointId, f32),
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
/// still show) but not mixed into our output.
SetPeerMuted(EndpointId, bool),
@@ -37,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),
@@ -49,18 +68,69 @@ pub enum CoreCommand {
SetRecordingMode(RecordingMode),
/// Broadcast a room text-chat message. No-op when not in a call.
SendChat(String),
/// 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>,
},
/// 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,
},
/// 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>>,
},
/// 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,
},
/// Fetch a source peer's advertised next track bytes before it becomes current.
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>),
/// Enumerate apps currently producing audio (for the screen-share audio
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
/// safe to call each time the picker opens.
ListAudioApps,
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
/// on our presence so the room can watch. No-op when not in a call.
StartScreenShare,
/// `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>,
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`].
@@ -68,71 +138,478 @@ 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).
RenameFriend(EndpointId, String),
/// Run an immediate presence-refresh pass over all friends (the manual
/// "Rescan" button). Same work the 60s scheduler does on each tick, on demand —
/// no waiting for the next interval. A no-op while Invisible.
RefreshFriends,
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
/// startup from config and whenever the user changes it.
SetPresenceMode(PresenceMode),
/// Toggle broadcasting the detected game as presence (game detection). Opt-in,
/// default OFF. Enabling immediately publishes the current game; disabling
/// immediately publishes `game: None`. Detection for the local background runs
/// regardless. Sent at startup from config and on user toggle.
SetGamePresenceEnabled(bool),
/// Set the manual game-detection override (`Auto` / `None` / a forced game).
/// Forwarded to the detector and applied immediately (bypasses debounce).
SetGameOverride(crate::game::ManualOverride),
/// Replace the user process→display-name mappings used by the non-Steam
/// detection fallback. Sent at startup from config and after Settings edits.
SetGameProcessMap(std::collections::BTreeMap<String, String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryClass {
Reliable,
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;
/// those are BestEffort.
pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
match cmd {
CoreCommand::SetPeerVolume(_, _)
| CoreCommand::SetPeerPan(_, _)
| CoreCommand::SetPeerGate(_, _)
| CoreCommand::SetPeerEq(_, _)
| CoreCommand::SetInputVolume(_)
| CoreCommand::SetOutputVolume(_)
| CoreCommand::SetNoiseGateThreshold(_) => DeliveryClass::BestEffort,
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(_) => DeliveryClass::Reliable,
}
}
/// 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,
PeerJoined { id: EndpointId, state: PeerState },
PeerLeft { id: EndpointId },
/// 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,
},
/// 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 },
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>,
},
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
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>,
},
/// 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>,
},
/// A music-track fetch failed (source gone, too large, etc.).
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,
},
/// Our own screen share started; the UI flips the Share button to "Stop".
ScreenShareStarted,
/// Our own screen share stopped (or failed to start).
ScreenShareStopped,
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
/// is now reaching viewers; `false` = its audio stopped, so under our strict
/// run viewers currently hear silence. The UI shows a transient warning while
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
ShareAudioActive(bool),
/// 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,
},
/// 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
/// periodic auto-refresh, so the status bar isn't churned every interval.
FriendsRescanned,
/// Core corrected the committed presence posture. Usually the Discoverable
/// 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
/// (W18) and may show a local "Playing …" indicator. Emitted regardless of
/// whether game presence is being broadcast — the broadcast is core's own job.
GameChanged(Option<crate::game::DetectedGame>),
/// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete,
Error(String),
}
#[cfg(test)]
mod tests {
use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class};
use crate::audio::eq::EqSettings;
use crate::presence::PresenceMode;
use iroh::{EndpointId, SecretKey};
fn endpoint_id() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn continuous_audio_controls_are_best_effort() {
let peer = endpoint_id();
let commands = [
(
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, 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
);
}
}
#[test]
fn discrete_user_actions_are_reliable() {
let peer = endpoint_id();
let commands = [
CoreCommand::ToggleMute,
CoreCommand::SetPttActive(false),
CoreCommand::Leave,
CoreCommand::RegenerateIdentity,
CoreCommand::Join {
name: "Peer".to_string(),
ticket: "create".to_string(),
room_name: "Room".to_string(),
input_device: None,
output_device: None,
echo_cancellation: true,
avatar: crate::avatar::Avatar::default(),
},
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
);
}
}
}
+1512 -217
View File
File diff suppressed because it is too large Load Diff
+68 -8
View File
@@ -22,6 +22,27 @@ fn recovery_delay(attempt: usize) -> Duration {
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
}
/// Terminal retry budget for background recovery. After this many failed attempts
/// the coordinator gives up: it drops the entry, frees the active slot, and signals
/// the event task to forget the retained address (Tier C recovery-identity cap).
///
/// With the [`RECOVERY_DELAYS`] backoff this is roughly seven minutes of dialing
/// (1+2+4+8+15+30+60s, then 60s steps), far beyond any normal transient outage. A
/// genuine peer returning after a longer outage still rejoins on its own via a
/// gossip announce, so giving up only stops us from dialing a peer that is not
/// coming back — it does not break legitimate reconnect-after-outage.
const RECOVERY_TERMINAL_ATTEMPTS: usize = 12;
/// Capacity of the terminal-eviction notification channel. Bounded; on the rare
/// event of saturation the entry is still removed (the dial work stops) and only
/// the retained-address forget is skipped, which the per-topic retain cap bounds.
const RECOVERY_TERMINAL_CAPACITY: usize = 64;
/// Whether `attempt` completed recoveries have exhausted the terminal budget.
fn recovery_is_terminal(attempt: usize, max_attempts: usize) -> bool {
attempt >= max_attempts
}
enum RecoveryCommand {
Start {
peer_id: EndpointId,
@@ -60,19 +81,24 @@ pub(super) struct RecoveryCoordinator {
}
impl RecoveryCoordinator {
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
pub(super) fn spawn(
room_state: Arc<IrohGossipState>,
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
Self::spawn_inner(room_state)
}
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
fn spawn_inner(
room_state: Arc<dyn RecoveryRoom>,
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
let (terminal_tx, terminal_rx) = mpsc::channel(RECOVERY_TERMINAL_CAPACITY);
let active = Arc::new(Mutex::new(HashSet::new()));
let handle = Self {
tx,
active: active.clone(),
};
let task = tokio::spawn(run_coordinator(room_state, active, rx));
(handle, task)
let task = tokio::spawn(run_coordinator(room_state, active, rx, terminal_tx));
(handle, task, terminal_rx)
}
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
@@ -116,6 +142,7 @@ async fn run_coordinator(
room_state: Arc<dyn RecoveryRoom>,
active: Arc<Mutex<HashSet<EndpointId>>>,
mut rx: mpsc::Receiver<RecoveryCommand>,
terminal_tx: mpsc::Sender<EndpointId>,
) {
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
@@ -155,9 +182,24 @@ async fn run_coordinator(
entries.remove(&peer_id);
continue;
}
if let Some(entry) = entries.get_mut(&peer_id) {
// Advance the backoff, then check the terminal budget.
// `attempt` counts completed attempts, so the delay
// uses the current value before it is incremented.
let terminal = if let Some(entry) = entries.get_mut(&peer_id) {
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
entry.attempt = entry.attempt.saturating_add(1);
recovery_is_terminal(entry.attempt, RECOVERY_TERMINAL_ATTEMPTS)
} else {
false
};
if terminal {
// Give up on a peer that has not returned within the
// budget: drop its entry, free the active slot, and
// signal the event task to forget its retained
// address so the per-topic retain table drains.
entries.remove(&peer_id);
active.lock().unwrap().remove(&peer_id);
let _ = terminal_tx.try_send(peer_id);
}
}
}
@@ -210,6 +252,23 @@ mod tests {
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
}
#[test]
fn recovery_budget_is_terminal_only_at_or_past_the_cap() {
assert!(!recovery_is_terminal(0, RECOVERY_TERMINAL_ATTEMPTS));
assert!(!recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS - 1,
RECOVERY_TERMINAL_ATTEMPTS
));
assert!(recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS,
RECOVERY_TERMINAL_ATTEMPTS
));
assert!(recovery_is_terminal(
RECOVERY_TERMINAL_ATTEMPTS + 5,
RECOVERY_TERMINAL_ATTEMPTS
));
}
#[test]
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
let (tx, mut rx) = mpsc::channel(4);
@@ -244,9 +303,10 @@ mod tests {
#[tokio::test]
async fn coordinator_attempts_rebootstrap_immediately() {
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
attempts: attempts_tx,
}));
let (coordinator, task, _terminal_rx) =
RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
attempts: attempts_tx,
}));
let peer_id = SecretKey::generate().public();
let addr = EndpointAddr::from(peer_id);
+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
+374
View File
@@ -0,0 +1,374 @@
//! Chat file attachments: the compact descriptor that rides a gossip chat
//! message, plus the pure validation/sanitization seams for the file-transfer
//! plane.
//!
//! Attachment **bytes do not travel over gossip** — gossip is a small-frame
//! broadcast plane (see `avatar` for why image bytes there are hard-capped to
//! tens of KB). Instead a chat message carries a [`ChatAttachment`] *descriptor*
//! (name, size, kind, id); the sender serves the actual bytes over the dedicated
//! file ALPN (`protocol::FILES_ALPN`) via direct QUIC streams, and recipients
//! fetch them point-to-point. Everything in this module is dependency-light and
//! pure so it can be unit-tested away from the network and the GUI.
use serde::{Deserialize, Serialize};
/// Hard ceiling on a single attachment's byte size. Bounds the memory a peer can
/// make us hold (when fetching) or serve, and the time a transfer can take.
/// 25 MiB comfortably covers phone photos and ordinary documents.
pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
/// Max decoded pixels per side for an inline image preview. Defends against a
/// decode-bomb (a small file that expands to an enormous bitmap), independent of
/// the byte cap. Applied via `image::Limits` when validating/decoding.
pub const MAX_IMAGE_PX: u32 = 4096;
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
/// the UI tidy; the real bytes are unaffected.
pub const MAX_FILENAME_LEN: usize = 96;
/// A 32-byte opaque id identifying one attachment for the fetch request. Minted
/// randomly per attachment by the sender (see core); the transfer itself is
/// authenticated + encrypted + room-member gated, so the id only needs to be a
/// hard-to-guess handle into the sender's serve store, not a content hash.
pub type AttachmentId = [u8; 32];
/// How the receiver should present an attachment. A *hint* derived from the
/// sender's content sniff — never trusted for a safety decision. The receiver
/// re-validates image bytes itself before decoding, and falls back to a file
/// chip if an "Image" doesn't actually decode.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum AttachmentKind {
Image,
File,
}
/// The descriptor carried inside a `GossipMessage::Chat`. Compact by design: it
/// holds no file bytes, only what the UI needs to render a placeholder/chip and
/// what a fetch needs to pull the bytes.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct ChatAttachment {
/// Sanitized display filename (already path-stripped — see
/// [`sanitize_filename`]). Never used as a filesystem path on receipt without
/// the user choosing a save location.
pub name: String,
/// Byte length of the file. Bounds the fetch read; must be
/// `<= MAX_ATTACHMENT_BYTES` (enforced by [`size_within_cap`]).
pub size: u64,
/// Presentation hint (image vs. generic file).
pub kind: AttachmentKind,
/// Opaque handle the receiver writes on the file plane to request the bytes.
pub id: AttachmentId,
}
/// Sanitize an arbitrary (possibly hostile) filename for display and as a
/// save-dialog default. Strips any directory component (both `/` and `\`),
/// removes control characters, collapses whitespace, trims, caps the length
/// while trying to preserve a short extension, and rejects the `.`/`..` traps.
/// Always returns a non-empty, path-component-free name (falls back to `file`).
pub fn sanitize_filename(raw: &str) -> String {
// Take only the final *non-empty* path component, defeating
// `../../etc/passwd`, `C:\foo\bar`, embedded separators, and trailing slashes
// (`a/b/c/` → `c`).
let base = raw
.rsplit(['/', '\\'])
.find(|s| !s.trim().is_empty())
.unwrap_or("")
.trim();
// Drop control chars; turn other whitespace into single spaces later.
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();
if collapsed.is_empty() {
return "file".to_string();
}
if collapsed.chars().count() <= MAX_FILENAME_LEN {
return collapsed.to_string();
}
// Too long: keep the extension (if short + sane) and truncate the stem.
if let Some((stem, ext)) = collapsed.rsplit_once('.')
&& !ext.is_empty()
&& ext.chars().count() <= 8
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
{
let keep = MAX_FILENAME_LEN.saturating_sub(ext.chars().count() + 1);
let truncated: String = stem.chars().take(keep).collect();
return format!("{truncated}.{ext}");
}
collapsed.chars().take(MAX_FILENAME_LEN).collect()
}
/// Whether a declared/observed size is within the transfer cap and non-zero.
/// Used both when sending (reject before serving) and when fetching (reject a
/// descriptor before opening a stream).
pub fn size_within_cap(size: u64) -> bool {
size > 0 && size <= MAX_ATTACHMENT_BYTES
}
/// Sniff the leading bytes for a known image container, to set the attachment
/// *kind* hint at send time. Recognizes PNG, JPEG, GIF, WebP, and BMP. This is a
/// presentation hint only — actual inline rendering still depends on the bytes
/// decoding (we only build image features for PNG/JPEG), with a file-chip
/// fallback otherwise.
pub fn is_probably_image(bytes: &[u8]) -> bool {
let b = bytes;
let png = b.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
let jpeg = b.starts_with(&[0xFF, 0xD8, 0xFF]);
let gif = b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a");
let bmp = b.starts_with(b"BM");
let webp = b.len() >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP";
png || jpeg || gif || bmp || webp
}
/// Sniff the leading bytes for an audio container supported by the inline clip
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
/// check confirms that a filename-based player hint actually contains WAV, MP3,
/// Ogg Vorbis, or FLAC data before playback is attempted.
pub fn is_probably_audio(bytes: &[u8]) -> bool {
let flac = bytes.starts_with(b"fLaC");
let ogg = bytes.starts_with(b"OggS");
let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
let mp3_id3 = bytes.starts_with(b"ID3");
let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
flac || ogg || wav || mp3_id3 || mp3_frame
}
/// Whether a sanitized attachment name has an extension supported by the
/// inline audio player. This is only a pre-fetch presentation hint; fetched
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
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"
)
}
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
/// sniffs as an image container, else [`AttachmentKind::File`].
pub fn classify(bytes: &[u8]) -> AttachmentKind {
if is_probably_image(bytes) {
AttachmentKind::Image
} else {
AttachmentKind::File
}
}
/// Defensively decode image bytes under strict pixel limits to confirm they're a
/// real, sane image before we hand them to the renderer. Returns the decoded
/// dimensions on success. Guards against decode-bombs (small file → huge bitmap)
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
/// `image` feature set; anything else returns `None` and the caller shows a chip.
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_PX);
limits.max_image_height = Some(MAX_IMAGE_PX);
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
return None;
}
Some((w, h))
}
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
/// bytes). Anything else is rejected so a peer can't send a malformed/oversized
/// request frame. Pure half of the serve handler.
pub fn parse_request(bytes: &[u8]) -> Option<AttachmentId> {
if bytes.len() != 32 {
return None;
}
let mut id = [0u8; 32];
id.copy_from_slice(bytes);
Some(id)
}
/// A human-readable size like `2.3 MB` / `812 KB` / `40 B` for the file chip.
pub fn human_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.0} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_directory_traversal() {
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("/abs/path/photo.png"), "photo.png");
assert_eq!(sanitize_filename(r"C:\Users\me\secret.doc"), "secret.doc");
assert_eq!(sanitize_filename("a/b/c/"), "c");
}
#[test]
fn sanitize_rejects_dot_traps_and_empty() {
assert_eq!(sanitize_filename(""), "file");
assert_eq!(sanitize_filename("."), "file");
assert_eq!(sanitize_filename(".."), "file");
assert_eq!(sanitize_filename(" "), "file");
assert_eq!(sanitize_filename("/"), "file");
}
#[test]
fn sanitize_removes_control_chars_and_collapses_ws() {
// Control chars (incl. tab/newline) are stripped entirely.
assert_eq!(sanitize_filename("my\tphoto\n.png"), "myphoto.png");
assert_eq!(sanitize_filename("a\u{0000}b.txt"), "ab.txt");
// Real spaces are collapsed but preserved.
assert_eq!(sanitize_filename("my photo .png"), "my photo .png");
}
#[test]
fn sanitize_caps_length_preserving_extension() {
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.ends_with(".png"), "extension preserved: {out}");
}
#[test]
fn size_cap_bounds() {
assert!(!size_within_cap(0));
assert!(size_within_cap(1));
assert!(size_within_cap(MAX_ATTACHMENT_BYTES));
assert!(!size_within_cap(MAX_ATTACHMENT_BYTES + 1));
}
#[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(&[0xFF, 0xD8, 0xFF, 0xE0]));
assert!(is_probably_image(b"GIF89a...."));
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0, 0, 0, 0]);
webp.extend_from_slice(b"WEBP");
assert!(is_probably_image(&webp));
assert!(!is_probably_image(b"%PDF-1.7"));
assert!(!is_probably_image(b""));
}
#[test]
fn audio_sniffing_recognizes_supported_containers() {
assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
assert!(is_probably_audio(b"OggS\0\x02"));
let mut wav = b"RIFF".to_vec();
wav.extend_from_slice(&[0, 0, 0, 0]);
wav.extend_from_slice(b"WAVE");
assert!(is_probably_audio(&wav));
assert!(is_probably_audio(b"ID3\x04\0\0"));
assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
}
#[test]
fn audio_sniffing_disambiguates_wav_from_webp() {
let mut wav = b"RIFF".to_vec();
wav.extend_from_slice(&[0, 0, 0, 0]);
wav.extend_from_slice(b"WAVE");
assert!(is_probably_audio(&wav));
assert!(!is_probably_image(&wav));
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0, 0, 0, 0]);
webp.extend_from_slice(b"WEBP");
assert!(is_probably_image(&webp));
assert!(!is_probably_audio(&webp));
}
#[test]
fn audio_sniffing_rejects_non_audio() {
assert!(!is_probably_audio(b"%PDF-1.7"));
assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
assert!(!is_probably_audio(&[]));
assert!(!is_probably_audio(&[0xFF]));
}
#[test]
fn audio_name_detection_is_case_insensitive() {
for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
assert!(looks_like_audio_name(name), "{name}");
}
assert!(looks_like_audio_name("VOICE.MP3"));
assert!(looks_like_audio_name("mix.FlAc"));
assert!(!looks_like_audio_name("recording"));
assert!(!looks_like_audio_name("notes.pdf"));
assert!(!looks_like_audio_name("photo.webp"));
}
#[test]
fn classify_maps_sniff_to_kind() {
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
assert_eq!(classify(b"plain text"), AttachmentKind::File);
}
#[test]
fn parse_request_requires_exact_32_bytes() {
assert_eq!(parse_request(&[7u8; 32]), Some([7u8; 32]));
assert_eq!(parse_request(&[7u8; 31]), None);
assert_eq!(parse_request(&[7u8; 33]), None);
assert_eq!(parse_request(&[]), None);
}
#[test]
fn validate_image_rejects_garbage() {
assert_eq!(validate_image_bytes(b"not an image"), None);
assert_eq!(validate_image_bytes(&[]), None);
}
#[test]
fn validate_image_accepts_a_real_png() {
// Encode a tiny PNG in-memory, then validate it.
let img = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30]));
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
}
#[test]
fn human_size_units() {
assert_eq!(human_size(40), "40 B");
assert_eq!(human_size(2048), "2 KB");
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
}
#[test]
fn attachment_descriptor_round_trips_json() {
let a = ChatAttachment {
name: "photo.png".to_string(),
size: 12345,
kind: AttachmentKind::Image,
id: [9u8; 32],
};
let bytes = serde_json::to_vec(&a).unwrap();
let back: ChatAttachment = serde_json::from_slice(&bytes).unwrap();
assert_eq!(a, back);
}
}
+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
}
+280
View File
@@ -0,0 +1,280 @@
//! The detector service (§5): one cancellable background worker that polls the OS
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
//! game on a watch channel — only when it changes, so a flapping detector can't
//! spam `PeerState` re-announces.
//!
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
//! debounce is unit-tested without any I/O.
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};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use tokio::sync::watch;
/// How often the detector samples Steam state + the process list.
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
/// Granularity of the cancellable sleep between polls, so a stop request is
/// honored promptly instead of after a full [`POLL_INTERVAL`].
const SLEEP_TICK: Duration = Duration::from_millis(200);
/// Apply one poll's worth of inputs to the debouncer, returning the new published
/// value **iff it changed** (the signal to re-announce presence / switch the
/// background). Pure: the caller supplies the already-fetched Steam detection and
/// process list, so resolve + match + debounce are testable with zero I/O.
pub fn poll_once(
debouncer: &mut Debouncer,
override_: &ManualOverride,
steam: Option<DetectedGame>,
processes: &[String],
process_map: &BTreeMap<String, String>,
denylist: &std::collections::BTreeSet<&str>,
) -> Option<Option<DetectedGame>> {
let matched = match_processes(processes, process_map, denylist);
let res = resolve(override_, steam, &matched);
if debouncer.observe(res.game, res.immediate) {
Some(debouncer.current().cloned())
} else {
None
}
}
/// Shared, live-updatable inputs to the detector, written by core (manual override
/// changes, config edits to the process map) and read each poll by the worker.
#[derive(Default)]
pub struct DetectorInputs {
pub override_: Mutex<ManualOverride>,
pub process_map: Mutex<BTreeMap<String, String>>,
}
/// A running detector service. Holds the watch receiver for detected-game changes
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
/// worker thread.
pub struct GameDetector {
inputs: Arc<DetectorInputs>,
rx: watch::Receiver<Option<DetectedGame>>,
stop: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl GameDetector {
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
/// `override_` seeds the manual override (usually `Auto`). The worker runs
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
pub fn spawn(
override_: ManualOverride,
process_map: BTreeMap<String, String>,
) -> io::Result<Self> {
let inputs = Arc::new(DetectorInputs {
override_: Mutex::new(override_),
process_map: Mutex::new(process_map),
});
let (tx, rx) = watch::channel(None);
let stop = Arc::new(AtomicBool::new(false));
let worker_inputs = inputs.clone();
let worker_stop = stop.clone();
let worker = std::thread::Builder::new()
.name("game-detector".to_string())
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
Ok(Self {
inputs,
rx,
stop,
worker: Some(worker),
})
}
/// A clone of the watch receiver for detected-game changes. The current value
/// is `None` until the first non-empty detection is debounced in.
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
self.rx.clone()
}
/// Replace the manual override (applied on the next poll, immediately,
/// bypassing debounce).
pub fn set_override(&self, override_: ManualOverride) {
*self.inputs.override_.lock().unwrap() = override_;
}
/// Replace the user process→name mappings (e.g. after a Settings edit).
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
*self.inputs.process_map.lock().unwrap() = map;
}
/// Signal the worker to exit. Idempotent; also happens on drop.
pub fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
impl Drop for GameDetector {
fn drop(&mut self) {
self.stop();
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
fn worker_loop(
inputs: Arc<DetectorInputs>,
tx: watch::Sender<Option<DetectedGame>>,
stop: Arc<AtomicBool>,
) {
let denylist = builtin_denylist();
let mut steam = SteamProbe::new();
let mut debouncer = Debouncer::default();
while !stop.load(Ordering::Relaxed) {
let override_ = inputs.override_.lock().unwrap().clone();
let process_map = inputs.process_map.lock().unwrap().clone();
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,
) {
// A closed receiver means core shut down; stop quietly.
if tx.send(new_current).is_err() {
return;
}
}
// Cancellable sleep: wake promptly on a stop request.
let mut slept = Duration::ZERO;
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
std::thread::sleep(SLEEP_TICK);
slept += SLEEP_TICK;
}
}
}
#[cfg(test)]
mod tests {
use super::super::GameSource;
use super::*;
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
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()
}
#[test]
fn poll_once_debounces_steam_detection() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let steam = game("steam:730", "CS2", GameSource::Steam);
let empty = BTreeMap::new();
// First poll: detected but not yet published (needs two hits).
assert_eq!(
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
),
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
),
None
);
}
#[test]
fn poll_once_matches_process_when_no_steam() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let procs = vec!["/games/hl2_linux".to_string()];
let user = map(&[("hl2_linux", "Half-Life 2")]);
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");
assert_eq!(published.id, "exe:hl2_linux");
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
}
#[test]
fn poll_once_manual_override_is_immediate() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let forced = game("steam:220", "HL2", GameSource::Steam);
// Even with a live Steam detection of something else, the override wins now.
let other = game("steam:730", "CS2", GameSource::Steam);
let change = poll_once(
&mut d,
&ManualOverride::Force(forced.clone()),
Some(other),
&[],
&BTreeMap::new(),
&deny,
);
assert_eq!(change, Some(Some(forced)));
}
#[test]
fn spawn_and_stop_is_clean() {
// Smoke test the lifecycle: spawning and stopping must not panic, and the
// initial published value is None.
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
assert_eq!(*det.subscribe().borrow(), None);
det.set_override(ManualOverride::ForceNone);
det.set_process_map(map(&[("x", "X")]));
det.stop();
// Dropping also stops; no hang/panic.
drop(det);
}
}
+514
View File
@@ -0,0 +1,514 @@
//! Game detection, game-presence, and game-reactive backgrounds.
//!
//! A single local "what game is running" detector feeds two consumers:
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
//! roster, riding the gossip presence plane like nickname + avatar.
//!
//! This module is structured testable-seams-first: the *pure* logic lives here
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
//! cancellable poll service ([`detector`]) wires them together.
pub mod detector;
pub mod scan;
pub mod steam;
pub mod vdf;
use std::collections::{BTreeMap, BTreeSet};
/// Where a detected game came from. Encodes the trust/priority tier directly:
/// a manual override beats live Steam state, which beats a matched process. Used
/// only for prioritization and as a presentation hint — never trusted as identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameSource {
/// The user forced a specific game (or "none") via the manual override.
Manual,
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
Steam,
/// A running process matched against the user's process→name mappings.
Process,
}
/// A game the local detector currently believes is running.
///
/// `id` is the stable, namespaced identity used as the config key for backgrounds
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
/// the human label shown locally and broadcast as presence; it is `None` only for
/// the Steam appid-without-manifest case, where the background can still switch by
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedGame {
/// Stable namespaced identity. Config-key safe; survives renames.
pub id: String,
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
pub name: Option<String>,
/// Provenance / priority tier.
pub source: GameSource,
}
impl DetectedGame {
/// The Steam namespaced id for an appid: `steam:<appid>`.
pub fn steam_id(app_id: u32) -> String {
format!("steam:{app_id}")
}
/// The process namespaced id for an executable identity: `exe:<normalized>`.
pub fn exe_id(exe: &str) -> String {
format!("exe:{}", normalize_exe(exe))
}
}
/// The user's manual override sitting above both detectors (D2). Small by design.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ManualOverride {
/// Trust the auto-detector (default).
#[default]
Auto,
/// Force "not playing anything" regardless of what is detected.
ForceNone,
/// Force a specific game (the user picked it from the known-games list).
Force(DetectedGame),
}
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub game: Option<DetectedGame>,
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
pub immediate: bool,
}
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
/// deterministic priority order (see [`match_processes`]); its first entry wins.
pub fn resolve(
override_: &ManualOverride,
steam: Option<DetectedGame>,
processes: &[DetectedGame],
) -> Resolution {
match override_ {
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,
}
}
}
}
/// Samples required before a *new* game is accepted/switched to.
pub const ACCEPT_HITS: u32 = 2;
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
pub const CLEAR_MISSES: u32 = 3;
/// Debounces a stream of raw per-poll detections into a stable published value, so
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
///
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
/// (`immediate = true`) applies at once, bypassing both counters.
#[derive(Debug, Clone, Default)]
pub struct Debouncer {
current: Option<DetectedGame>,
pending: Option<DetectedGame>,
pending_hits: u32,
misses: u32,
}
impl Debouncer {
/// The currently published, debounced value.
pub fn current(&self) -> Option<&DetectedGame> {
self.current.as_ref()
}
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
/// debounce. Returns `true` iff the published [`current`](Self::current) value
/// changed — the signal for the service to re-announce presence / switch the
/// background.
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
if immediate {
let changed = self.current != sample;
self.current = sample;
self.pending = None;
self.pending_hits = 0;
self.misses = 0;
return changed;
}
match sample {
Some(game) => {
self.misses = 0;
if self.current.as_ref() == Some(&game) {
// Already publishing this game; drop any half-counted switch.
self.pending = None;
self.pending_hits = 0;
false
} else {
if self.pending.as_ref() == Some(&game) {
self.pending_hits += 1;
} else {
self.pending = Some(game);
self.pending_hits = 1;
}
if self.pending_hits >= ACCEPT_HITS {
self.current = self.pending.take();
self.pending_hits = 0;
true
} else {
false
}
}
}
None => {
// A miss never counts toward a *switch*; drop any pending candidate.
self.pending = None;
self.pending_hits = 0;
if self.current.is_some() {
self.misses += 1;
if self.misses >= CLEAR_MISSES {
self.current = None;
self.misses = 0;
true
} else {
false
}
} else {
false
}
}
}
}
}
/// Normalize a raw executable name/path to a stable identity for matching and ids:
/// take the final path component (handling both `/` and `\\` separators) and
/// 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()
}
/// Launcher/helper executables that must NEVER be reported as a game even if a
/// mapping names them — defense against a mis-entered mapping turning the launcher
/// itself into "the game". Normalized (lowercase basename) for comparison.
const BUILTIN_DENYLIST: &[&str] = &[
"steam",
"steam.exe",
"steamwebhelper",
"steamwebhelper.exe",
"steamerrorreporter",
"gameoverlayui",
"reaper",
"lutris",
"heroic",
"heroic.exe",
"legendary",
"gogdl",
"wine",
"wine64",
"wineserver",
"wine-preloader",
"proton",
"pressure-vessel-wrap",
"explorer.exe",
"services.exe",
"svchost.exe",
];
/// The built-in launcher/helper denylist as a set, for membership checks.
pub fn builtin_denylist() -> BTreeSet<&'static str> {
BUILTIN_DENYLIST.iter().copied().collect()
}
/// Match the currently-running executables against the user's explicit
/// process→display-name mappings, returning detected games in **deterministic
/// priority order** (sorted by stable id) with duplicates removed.
///
/// Conservative by construction (§3): only exact normalized-basename matches to a
/// user mapping count — we never guess that an arbitrary long-running process is a
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
/// helper can't be promoted to "the game".
///
/// `user_map` keys are matched against the normalized basename of each running
/// entry; the key itself is normalized too, so the caller may store either
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
pub fn match_processes(
running: &[String],
user_map: &BTreeMap<String, String>,
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 mut seen: BTreeSet<String> = BTreeSet::new();
let mut out: Vec<DetectedGame> = Vec::new();
for raw in running {
let norm = normalize_exe(raw);
if norm.is_empty() || denylist.contains(norm.as_str()) {
continue;
}
if let Some(name) = normalized_map.get(&norm) {
let id = format!("exe:{norm}");
if seen.insert(id.clone()) {
out.push(DetectedGame {
id,
name: Some((*name).clone()),
source: GameSource::Process,
});
}
}
}
// Deterministic priority: stable order independent of process-scan order.
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
DetectedGame {
id: DetectedGame::steam_id(app_id),
name: Some(name.to_string()),
source: GameSource::Steam,
}
}
// --- ids / normalization ----------------------------------------------
#[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"
);
}
#[test]
fn normalize_handles_both_separators_and_case() {
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
assert_eq!(normalize_exe("bare"), "bare");
}
// --- resolve priority --------------------------------------------------
#[test]
fn resolve_prefers_steam_over_process_in_auto() {
let steam = steam_game(730, "CS2");
let procs = vec![DetectedGame {
id: "exe:foo".into(),
name: Some("Foo".into()),
source: GameSource::Process,
}];
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
assert_eq!(r.game, Some(steam));
assert!(!r.immediate);
}
#[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,
},
];
let r = resolve(&ManualOverride::Auto, None, &procs);
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
let none = resolve(&ManualOverride::Auto, None, &[]);
assert_eq!(none.game, None);
assert!(!none.immediate);
}
#[test]
fn resolve_manual_override_wins_and_is_immediate() {
let steam = steam_game(730, "CS2");
// ForceNone overrides a live Steam detection, immediately.
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
assert_eq!(r.game, None);
assert!(r.immediate);
// Force(x) overrides too.
let forced = steam_game(220, "HL2");
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
assert_eq!(r.game, Some(forced));
assert!(r.immediate);
}
// --- debounce ----------------------------------------------------------
#[test]
fn debounce_requires_two_hits_to_switch() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
// First sighting: not yet published.
assert!(!d.observe(Some(g.clone()), false));
assert_eq!(d.current(), None);
// Second consecutive sighting: now published.
assert!(d.observe(Some(g.clone()), false));
assert_eq!(d.current(), Some(&g));
// Steady state: same game, no further change events.
assert!(!d.observe(Some(g.clone()), false));
}
#[test]
fn debounce_requires_three_misses_to_clear() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
d.observe(Some(g.clone()), false);
d.observe(Some(g.clone()), false);
assert_eq!(d.current(), Some(&g));
// Two misses: still shown (absorbs a transient blip).
assert!(!d.observe(None, false));
assert!(!d.observe(None, false));
assert_eq!(d.current(), Some(&g));
// Third miss: cleared.
assert!(d.observe(None, false));
assert_eq!(d.current(), None);
}
#[test]
fn debounce_blip_during_clear_resets_miss_count() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
d.observe(Some(g.clone()), false);
d.observe(Some(g.clone()), false);
// Miss, miss, then the game reappears: miss count resets, stays published.
d.observe(None, false);
d.observe(None, false);
assert!(!d.observe(Some(g.clone()), false));
assert_eq!(d.current(), Some(&g));
// It now takes a fresh run of three misses to clear.
d.observe(None, false);
d.observe(None, false);
assert!(d.observe(None, false));
assert_eq!(d.current(), None);
}
#[test]
fn debounce_immediate_bypasses_counters() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
// A manual override publishes on the first sample.
assert!(d.observe(Some(g.clone()), true));
assert_eq!(d.current(), Some(&g));
// ForceNone clears immediately.
assert!(d.observe(None, true));
assert_eq!(d.current(), None);
// Re-issuing the same immediate value is not a change.
d.observe(Some(g.clone()), true);
assert!(!d.observe(Some(g.clone()), true));
}
#[test]
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
let mut d = Debouncer::default();
let a = steam_game(1, "A");
let b = steam_game(2, "B");
d.observe(Some(a.clone()), false);
d.observe(Some(a.clone()), false);
assert_eq!(d.current(), Some(&a));
// One sample of B does not switch.
assert!(!d.observe(Some(b.clone()), false));
assert_eq!(d.current(), Some(&a));
// Second consecutive B switches.
assert!(d.observe(Some(b.clone()), false));
assert_eq!(d.current(), Some(&b));
}
// --- process matching --------------------------------------------------
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn match_processes_matches_only_explicit_mappings() {
let user = map(&[("hl2_linux", "Half-Life 2")]);
let deny = builtin_denylist();
let running = vec![
"/usr/bin/firefox".to_string(),
"/games/Half-Life 2/hl2_linux".to_string(),
"/usr/bin/htop".to_string(),
];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].id, "exe:hl2_linux");
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
assert_eq!(got[0].source, GameSource::Process);
}
#[test]
fn match_processes_rejects_denylisted_even_if_mapped() {
// A mis-entered mapping naming the Steam client must not win.
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
let deny = builtin_denylist();
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].id, "exe:mygame");
}
#[test]
fn match_processes_is_deterministic_and_deduped() {
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 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"]
);
}
#[test]
fn match_processes_ignores_unmapped_and_case_folds() {
let user = map(&[("Game.x86_64", "The Game")]);
let deny = builtin_denylist();
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].name.as_deref(), Some("The Game"));
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Running-process enumeration for the non-Steam detection fallback (D6/D7):
//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no
//! `sysinfo` dependency and the audit surface stays small.
//!
//! This module is *just the OS edge*: it returns the list of running executable
//! paths/names. The trustworthy part — turning that list into a game via the
//! user's explicit mappings and the launcher denylist — is the pure
//! [`match_processes`](super::match_processes), unit-tested in the parent module.
/// Enumerate the executables of currently-running processes as paths/basenames.
/// Best-effort: processes we can't introspect (other users') are skipped rather
/// than erroring. The result is fed to [`match_processes`](super::match_processes),
/// which normalizes each entry to a basename before matching.
pub fn running_executables() -> Vec<String> {
#[cfg(target_os = "linux")]
{
linux_proc_executables()
}
#[cfg(windows)]
{
windows_toolhelp_executables()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
Vec::new()
}
}
#[cfg(target_os = "linux")]
fn linux_proc_executables() -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return out;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// Only numeric entries are processes.
if !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let proc_dir = entry.path();
// Prefer the real exe path (full, untruncated); fall back to `comm`, which
// is readable for all processes but truncated to 15 bytes.
if let Ok(exe) = std::fs::read_link(proc_dir.join("exe"))
&& let Some(s) = exe.to_str()
{
out.push(s.to_string());
continue;
}
if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) {
let trimmed = comm.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
}
}
out
}
#[cfg(windows)]
fn windows_toolhelp_executables() -> Vec<String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS,
};
let mut out = Vec::new();
// SAFETY: standard Toolhelp snapshot of all processes; handle checked below.
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return out;
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
// SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires.
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 name = String::from_utf16_lossy(&entry.szExeFile[..end]);
if !name.is_empty() {
out.push(name);
}
// SAFETY: same valid snapshot + entry struct.
ok = unsafe { Process32NextW(snapshot, &mut entry) };
}
// SAFETY: snapshot handle came from CreateToolhelp32Snapshot above.
unsafe { CloseHandle(snapshot) };
out
}
#[cfg(test)]
mod tests {
#[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 = 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()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()));
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),
"running list should include our own executable {me_norm:?}"
);
}
}
}
+581
View File
@@ -0,0 +1,581 @@
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
//! `RunningAppID` and resolves it to a display name via the plain-text
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
//!
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
//! poll does not rescan every library each tick (Codex hardening).
use super::vdf::{self, Value};
use super::{DetectedGame, GameSource};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Max bytes read from any single Steam state file. These are small text files
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
/// slurped into memory before the parser's own depth guard kicks in.
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
/// prevents a corrupt registry length from driving an enormous allocation.
#[cfg(any(windows, test))]
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)
}
#[cfg(any(windows, test))]
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
let units = validate_reg_len(returned_bytes)?;
if units > buf.len() {
return None;
}
buf.truncate(units);
while buf.last() == Some(&0) {
buf.pop();
}
Some(String::from_utf16_lossy(&buf))
}
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
/// client's emulated-registry text file). Returns the appid only when present and
/// nonzero — `0`/absent is the "no game" state. Pure.
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",
])
.and_then(Value::as_str)?;
let id: u32 = raw.trim().parse().ok()?;
(id != 0).then_some(id)
}
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
/// already decoded by the VDF parser), including ones on offline drives — the
/// caller checks existence.
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
return Vec::new();
};
// The root may or may not wrap entries in a "libraryfolders" object.
let container = root.get("libraryfolders").unwrap_or(&root);
let mut out = Vec::new();
for (key, val) in container.entries() {
// Only numeric-keyed entries are library folders.
if key.parse::<u32>().is_err() {
continue;
}
let path = match val {
Value::Str(s) => Some(s.as_str()),
Value::Obj(_) => val.get("path").and_then(Value::as_str),
};
if let Some(p) = path
&& !p.is_empty()
{
out.push(PathBuf::from(p));
}
}
out
}
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
let root = vdf::parse(appmanifest_acf).ok()?;
root.get_path(&["AppState", "name"])
.and_then(Value::as_str)
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
fn read_capped(path: &Path) -> Option<String> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
fn mtime_of(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
/// A library list cached against its source file's mtime.
#[derive(Default)]
struct CachedLibraries {
source: Option<PathBuf>,
mtime: Option<SystemTime>,
paths: Vec<PathBuf>,
}
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
/// when the manifest exists but carries no usable name, or wasn't found.
struct CachedManifest {
mtime: Option<SystemTime>,
name: Option<String>,
}
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
/// service runs it off the async worker.
pub struct SteamProbe {
roots: Vec<PathBuf>,
libraries: CachedLibraries,
manifests: HashMap<u32, CachedManifest>,
}
impl Default for SteamProbe {
fn default() -> Self {
Self::new()
}
}
impl SteamProbe {
pub fn new() -> Self {
Self {
roots: discover_roots(),
libraries: CachedLibraries::default(),
manifests: HashMap::new(),
}
}
/// One detection pass: read the live `RunningAppID`, and if a game is running,
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
/// with `name: None` when the appid is known but no manifest name is available
/// — the background can still switch by id, but presence must not invent a name.
pub fn detect(&mut self) -> Option<DetectedGame> {
let app_id = self.running_app_id()?;
let name = self.app_name(app_id);
Some(DetectedGame {
id: DetectedGame::steam_id(app_id),
name,
source: GameSource::Steam,
})
}
/// The live RunningAppID (nonzero), or `None`.
///
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
/// signal is the running game process's `SteamAppId` environment variable
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
/// (macOS) only has the `registry.vdf` fallback for now.
fn running_app_id(&self) -> Option<u32> {
#[cfg(windows)]
{
win::running_app_id()
}
#[cfg(target_os = "linux")]
{
running_app_id_from_environ().or_else(registry_running_app_id)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
registry_running_app_id()
}
}
/// Resolve (and cache) the display name for an appid by locating its
/// `appmanifest_<appid>.acf` across the known libraries.
fn app_name(&mut self, app_id: u32) -> Option<String> {
let manifest = self.find_manifest(app_id)?;
let mtime = mtime_of(&manifest);
if let Some(cached) = self.manifests.get(&app_id)
&& cached.mtime == mtime
{
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(),
},
);
name
}
/// The path to an appid's manifest, if it exists in any library.
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
let filename = format!("appmanifest_{app_id}.acf");
for lib in self.library_paths() {
let candidate = lib.join("steamapps").join(&filename);
if candidate.exists() {
return Some(candidate);
}
}
None
}
/// All Steam library folder paths, cached and refreshed only when the source
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
fn library_paths(&mut self) -> Vec<PathBuf> {
// Locate the libraryfolders.vdf to watch (first existing across roots).
let source = self
.roots
.iter()
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
.find(|p| p.exists());
let mtime = source.as_deref().and_then(mtime_of);
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
return self.libraries.paths.clone();
}
let mut paths = Vec::new();
if let Some(ref src) = source
&& let Some(contents) = read_capped(src)
{
paths = parse_library_paths(&contents);
}
// Always include the roots themselves: the install dir is an implicit
// library even if libraryfolders.vdf is missing or lists only extras.
for root in &self.roots {
if !paths.contains(root) {
paths.push(root.clone());
}
}
self.libraries = CachedLibraries {
source,
mtime,
paths: paths.clone(),
};
paths
}
}
/// Candidate Steam install roots that actually exist on this machine (each is a
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
/// layouts on Linux; on Windows the install path comes from the registry.
fn discover_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(windows)]
{
if let Some(p) = win::install_path() {
roots.push(p);
}
}
#[cfg(not(windows))]
{
if let Some(home) = dirs::home_dir() {
for rel in [
".steam/steam",
".steam/root",
".local/share/Steam",
".var/app/com.valvesoftware.Steam/.local/share/Steam",
"snap/steam/common/.local/share/Steam",
] {
roots.push(home.join(rel));
}
}
}
// Keep only roots that exist and look like a Steam install.
roots.retain(|p| p.join("steamapps").is_dir());
roots.sort();
roots.dedup();
roots
}
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
#[cfg(not(windows))]
fn registry_vdf_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(home) = dirs::home_dir() {
out.push(home.join(".steam/registry.vdf"));
out.push(home.join(".steam/steam/registry.vdf"));
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
}
out
}
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
#[cfg(not(windows))]
fn registry_running_app_id() -> Option<u32> {
for path in registry_vdf_candidates() {
if let Some(contents) = read_capped(&path)
&& let Some(id) = parse_running_app_id(&contents)
{
return Some(id);
}
}
None
}
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
/// game process. Returns the appid only when present and nonzero. Pure +
/// unit-tested; the `/proc` iteration is the thin edge in
/// [`running_app_id_from_environ`].
#[cfg(target_os = "linux")]
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
for kv in environ.split(|&b| b == 0) {
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
&& let Ok(s) = std::str::from_utf8(val)
&& let Ok(id) = s.trim().parse::<u32>()
&& id != 0
{
return Some(id);
}
}
None
}
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
/// readable only for our own processes — exactly the ones a Steam game we launched
/// runs as — and we skip the rest. The live signal that replaces the stale
/// on-disk `registry.vdf` on Linux.
#[cfg(target_os = "linux")]
fn running_app_id_from_environ() -> Option<u32> {
let entries = std::fs::read_dir("/proc").ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
// Cap the read: an environ is small; this bounds a pathological case.
if let Some(environ) = read_capped(&entry.path().join("environ"))
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
{
return Some(id);
}
}
None
}
#[cfg(windows)]
mod win {
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
//! crate. Steam stores both the live `RunningAppID` and its install path under
//! `HKCU\Software\Valve\Steam`.
use super::{decode_reg_sz, validate_reg_len};
use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
RegQueryValueExW,
};
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
fn open_steam_key() -> Option<HKEY> {
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) };
(rc == ERROR_SUCCESS).then_some(hkey)
}
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
pub fn running_app_id() -> Option<u32> {
let hkey = open_steam_key()?;
let name = wide("RunningAppID");
let mut kind: u32 = 0;
let mut data: u32 = 0;
let mut len = std::mem::size_of::<u32>() as u32;
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
&mut data as *mut u32 as *mut u8,
&mut len,
)
};
// SAFETY: handle came from RegOpenKeyExW above.
unsafe { RegCloseKey(hkey) };
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
Some(data)
} else {
None
}
}
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
pub fn install_path() -> Option<PathBuf> {
let hkey = open_steam_key()?;
let name = wide("SteamPath");
let mut kind: u32 = 0;
let mut len: u32 = 0;
// First query the size.
// SAFETY: null data ptr with a zeroed len asks for the required size.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
std::ptr::null_mut(),
&mut len,
)
};
if rc != ERROR_SUCCESS || kind != REG_SZ {
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
return None;
}
let Some(units) = validate_reg_len(len) else {
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
return None;
};
let mut buf = vec![0u16; units];
let mut len2 = len;
// SAFETY: buffer sized to the queried byte length.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
buf.as_mut_ptr() as *mut u8,
&mut len2,
)
};
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
return None;
}
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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(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")
);
}
#[test]
fn running_app_id_reads_nonzero_and_rejects_zero() {
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
"RunningAppID" "440"
} } } } }"#;
assert_eq!(parse_running_app_id(running), Some(440));
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
"RunningAppID" "0"
} } } } }"#;
assert_eq!(parse_running_app_id(idle), None);
// Missing key / garbage → None, no panic.
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
}
#[test]
fn library_paths_handles_current_and_legacy_shapes() {
let current = r#""libraryfolders" {
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
"1" { "path" "/mnt/games/SteamLibrary" }
"contentstatsid" "12345"
}"#;
let got = parse_library_paths(current);
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" {
"TimeNextStatsReport" "9999"
"ContentStatsID" "42"
"1" "/mnt/old/SteamLibrary"
}"#;
let got = parse_library_paths(legacy);
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
}
#[test]
fn library_paths_empty_on_garbage() {
assert!(parse_library_paths("totally broken {{{").is_empty());
}
#[cfg(target_os = "linux")]
#[test]
fn steam_app_id_parsed_from_environ_blob() {
// A realistic NUL-separated environ with SteamAppId among other vars.
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
);
// Absent → None (a non-Steam process).
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
);
// Garbage value → None, no panic.
assert_eq!(
parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
None
);
}
#[test]
fn app_name_extracts_and_filters_empty() {
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
// Empty name → None (don't broadcast a blank).
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
assert_eq!(parse_app_name(blank), None);
// Missing name → None.
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
}
}
+366
View File
@@ -0,0 +1,366 @@
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
//!
//! Pure (operates on already-read file *contents*) and unit-tested, per the
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
//! input with a recursion-depth cap, so a deeply nested file errors instead of
//! overflowing the stack, and never panics on malformed/truncated input.
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
/// still bounding a malicious file.
const MAX_DEPTH: usize = 32;
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
/// return the first match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Value {
Str(String),
Obj(Vec<(String, Value)>),
}
impl Value {
/// The leaf string at this node, if it is a string (not an object).
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(s) => Some(s),
Value::Obj(_) => None,
}
}
/// The first child value under `key`, if this is an object containing it.
/// Case-insensitive on the key (KeyValues keys are conventionally
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
pub fn get(&self, key: &str) -> Option<&Value> {
match self {
Value::Obj(pairs) => pairs
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v),
Value::Str(_) => None,
}
}
/// Follow a chain of object keys, returning the value at the end of the path.
/// `root.get_path(&["AppState", "name"])`.
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
let mut cur = self;
for key in path {
cur = cur.get(key)?;
}
Some(cur)
}
/// Iterate the (key, value) child pairs if this is an object.
pub fn entries(&self) -> &[(String, Value)] {
match self {
Value::Obj(pairs) => pairs,
Value::Str(_) => &[],
}
}
}
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
/// nesting past [`MAX_DEPTH`]. Never panics.
pub fn parse(input: &str) -> Result<Value, String> {
let mut lexer = Lexer { rest: input };
let obj = parse_object(&mut lexer, 0, true)?;
Ok(Value::Obj(obj))
}
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
/// parses until a closing `}` (which it consumes).
fn parse_object(
lexer: &mut Lexer,
depth: usize,
top_level: bool,
) -> Result<Vec<(String, Value)>, String> {
if depth > MAX_DEPTH {
return Err("VDF nesting too deep".to_string());
}
let mut pairs = Vec::new();
loop {
match lexer.next_token()? {
None => {
if top_level {
return Ok(pairs);
}
return Err("unexpected end of input inside object".to_string());
}
Some(Token::Close) => {
if top_level {
return Err("unexpected '}' at top level".to_string());
}
return Ok(pairs);
}
Some(Token::Open) => {
return Err("expected key, found '{'".to_string());
}
Some(Token::Str(key)) => {
// A key must be followed by a value: a string or a nested object.
match lexer.next_token()? {
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
Some(Token::Open) => {
let child = parse_object(lexer, depth + 1, false)?;
pairs.push((key, Value::Obj(child)));
}
Some(Token::Close) => {
return Err(format!("key '{key}' has no value (found '}}')"));
}
None => return Err(format!("key '{key}' has no value (end of input)")),
}
}
}
}
}
enum Token {
Open,
Close,
Str(String),
}
struct Lexer<'a> {
rest: &'a str,
}
impl Lexer<'_> {
/// Produce the next token, skipping whitespace and `//` line comments.
fn next_token(&mut self) -> Result<Option<Token>, String> {
loop {
self.rest = self.rest.trim_start();
if self.rest.is_empty() {
return Ok(None);
}
// Line comments: `//` to end of line.
if let Some(after) = self.rest.strip_prefix("//") {
match after.find('\n') {
Some(nl) => self.rest = &after[nl + 1..],
None => {
self.rest = "";
return Ok(None);
}
}
continue;
}
let mut chars = self.rest.char_indices();
let (_, first) = chars.next().expect("non-empty checked above");
return match first {
'{' => {
self.advance_bytes(first.len_utf8());
Ok(Some(Token::Open))
}
'}' => {
self.advance_bytes(first.len_utf8());
Ok(Some(Token::Close))
}
'"' => self.lex_quoted(),
_ => Ok(Some(self.lex_bareword())),
};
}
}
fn advance_bytes(&mut self, n: usize) {
self.rest = &self.rest[n..];
}
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
// Skip the opening quote.
self.advance_bytes(1);
let mut out = String::new();
let mut chars = self.rest.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'"' => {
// Consume through the closing quote.
self.rest = &self.rest[i + 1..];
return Ok(Some(Token::Str(out)));
}
'\\' => {
// Decode the escape.
match chars.next() {
Some((_, esc)) => out.push(match esc {
'n' => '\n',
't' => '\t',
'r' => '\r',
// `\\`, `\"`, and anything else: take the literal char.
other => other,
}),
None => return Err("unterminated escape in quoted string".to_string()),
}
}
other => out.push(other),
}
}
Err("unterminated quoted string".to_string())
}
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
fn lex_bareword(&mut self) -> Token {
let end = self
.rest
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
.unwrap_or(self.rest.len());
let word = self.rest[..end].to_string();
self.rest = &self.rest[end..];
Token::Str(word)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_appmanifest_name() {
// A trimmed-down real appmanifest_<id>.acf.
let acf = r#"
"AppState"
{
"appid" "730"
"name" "Counter-Strike 2"
"StateFlags" "4"
"installdir" "Counter-Strike Global Offensive"
"UserConfig"
{
"language" "english"
}
}
"#;
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")
);
// Case-insensitive key lookup.
assert_eq!(
root.get_path(&["appstate", "NAME"]).and_then(Value::as_str),
Some("Counter-Strike 2")
);
}
#[test]
fn parses_libraryfolders_paths_with_escaped_backslashes() {
// Windows paths arrive with doubled backslashes (escaped).
let vdf = r#"
"libraryfolders"
{
"0"
{
"path" "C:\\Program Files (x86)\\Steam"
"apps"
{
"730" "35000000000"
}
}
"1"
{
"path" "/home/eric/.local/share/Steam"
}
}
"#;
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")
);
// 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"]);
}
#[test]
fn parses_registry_running_appid_deep_path() {
let reg = r#"
"Registry"
{
"HKCU"
{
"Software"
{
"Valve"
{
"Steam"
{
"RunningAppID" "570"
"language" "english"
}
}
}
}
}
"#;
let root = parse(reg).unwrap();
let appid = root
.get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str);
assert_eq!(appid, Some("570"));
}
#[test]
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")
);
}
#[test]
fn rejects_malformed_without_panicking() {
// Unbalanced braces.
assert!(parse("\"a\" {").is_err());
// Stray closing brace.
assert!(parse("}").is_err());
// Key with no value at EOF.
assert!(parse("\"lonely\"").is_err());
// Unterminated quoted string.
assert!(parse("\"key\" \"unterminated").is_err());
}
#[test]
fn rejects_pathologically_deep_nesting() {
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
let mut s = String::new();
for i in 0..(MAX_DEPTH + 5) {
s.push_str(&format!("\"k{i}\" {{"));
}
for _ in 0..(MAX_DEPTH + 5) {
s.push('}');
}
assert!(parse(&s).is_err());
}
#[test]
fn missing_keys_return_none_not_error() {
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
assert_eq!(root.get_path(&["AppState", "name"]), None);
assert_eq!(root.get_path(&["Nope"]), None);
// Treating a string as an object yields None rather than panicking.
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
}
}
+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;
+30 -19
View File
@@ -1,24 +1,28 @@
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 dsp;
pub mod files;
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;
use std::path::{Path, PathBuf};
@@ -71,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<_>>()
@@ -79,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"))
}
@@ -96,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);
}
}
+819 -97
View File
File diff suppressed because it is too large Load Diff
+239 -25
View File
@@ -1,15 +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;
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.
@@ -31,6 +32,10 @@ const MAX_BACKOFF: Duration = Duration::from_secs(5);
/// already means "the peer closed this on purpose."
const GOODBYE_CODE: u32 = 1;
/// Bound on each phase (connect, read) of a chat-attachment fetch, so a slow or
/// stalled sender can't hang the fetch indefinitely.
const FILE_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
/// State shared between the transport, its protocol handler, and every per-peer
/// supervisor task. One supervisor owns a peer's whole connection lifecycle.
struct Shared {
@@ -60,6 +65,11 @@ struct Shared {
/// verified gossip roster plus peers still inside reconnect grace; transport
/// connections alone never mutate this set.
admitted_audio: StdMutex<HashSet<EndpointId>>,
/// Chat file attachments we're serving to room members this session, keyed by
/// the random attachment id. Populated when we send a chat file; read by the
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
/// blob is already byte-capped at send time.
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>,
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
/// Best-effort link-state notifications for the UI (connecting / connected).
conn_events_tx: mpsc::Sender<ConnEvent>,
@@ -101,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
}
@@ -113,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
));
}
}
@@ -198,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.
@@ -213,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
@@ -262,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
};
@@ -395,13 +438,107 @@ 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(())
}
}
}
/// Protocol handler for the file-transfer plane (`FILES_ALPN`). Mirrors
/// [`AudioRouter`]: it's persistent on the router and bound to the active
/// session's [`Shared`] on join. On an inbound stream it authenticates the peer
/// (iroh ALPN handshake gives us `remote_id`), gates on **live room membership**
/// (same invariant as audio admission, so a former member can't pull files),
/// reads a single 32-byte attachment id, and streams back the matching blob from
/// the session serve store — or nothing if the id is unknown.
#[derive(Clone, Default)]
pub struct FileRouter {
current: Arc<StdMutex<Option<Arc<Shared>>>>,
}
impl std::fmt::Debug for FileRouter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileRouter").finish_non_exhaustive()
}
}
impl FileRouter {
pub fn new() -> Self {
Self::default()
}
/// Route inbound file fetches to `transport`'s session (called on join).
pub fn bind(&self, transport: &IrohTransport) {
*self.current.lock().unwrap() = Some(transport.shared.clone());
}
/// Stop serving files until the next [`bind`](Self::bind) (called on leave).
pub fn clear(&self) {
*self.current.lock().unwrap() = None;
}
}
/// Max bytes we'll read for a fetch *request* frame. A request is exactly one
/// 32-byte id; this small ceiling rejects a peer trying to stream us a huge
/// "request" as a cheap DoS.
const FILE_REQUEST_MAX: usize = 64;
impl iroh::protocol::ProtocolHandler for FileRouter {
fn accept(
&self,
connection: Connection,
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
let peer_id = connection.remote_id();
let shared = self.current.lock().unwrap().clone();
async move {
// No active call → nothing to serve.
let Some(shared) = shared else {
return Ok(());
};
// Member gating: only current room members may fetch our files. Reuses
// the audio admission roster (the authoritative room membership set).
if !shared.audio_sender_admitted(peer_id) {
crate::log_msg(&format!(
"Transport: rejected file fetch from non-member {}",
crate::short_id(&peer_id.to_string())
));
return Ok(());
}
// Accept one bidirectional stream: read the id, write the bytes.
let Ok((mut send, mut recv)) = connection.accept_bi().await else {
return Ok(());
};
let Ok(req) = recv.read_to_end(FILE_REQUEST_MAX).await else {
return Ok(());
};
let Some(id) = crate::files::parse_request(&req) else {
return Ok(());
};
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
if let Some(blob) = blob {
let _ = send.write_all(&blob).await;
}
// Finish either way: an unknown id closes with an empty body, which
// the fetcher reads as a zero-length result and treats as "gone".
let _ = send.finish();
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
// for the written bytes to be delivered and acknowledged. If we return
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
// of the still-in-flight stream data, so the fetcher's read aborts with
// "connection lost". Wait for the fetcher to receive everything and
// close the connection itself (it drops `conn` right after read_to_end);
// that close is our signal the transfer landed. Bounded so a fetcher
// that vanishes can't pin this task forever.
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
Ok(())
}
}
}
pub struct IrohTransport {
shared: Arc<Shared>,
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
@@ -426,6 +563,7 @@ impl IrohTransport {
peers: tokio::sync::Mutex::new(HashMap::new()),
live_conns: StdMutex::new(HashMap::new()),
admitted_audio: StdMutex::new(HashSet::new()),
served_files: StdMutex::new(HashMap::new()),
incoming_tx,
conn_events_tx,
});
@@ -443,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");
}
@@ -455,6 +600,7 @@ impl IrohTransport {
self.shared.senders.lock().unwrap().clear();
self.shared.addrs.lock().unwrap().clear();
self.shared.admitted_audio.lock().unwrap().clear();
self.shared.served_files.lock().unwrap().clear();
// Give the CONNECTION_CLOSE frames a moment to flush before the caller
// shuts the endpoint/router down (the `conns` clones are still alive
// here, so the endpoint can still transmit them).
@@ -483,6 +629,74 @@ impl IrohTransport {
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
self.shared.audio_sender_admitted(peer_id)
}
/// Make `bytes` available to room members under `id` for the rest of this
/// session (served by the [`FileRouter`] handler). Called by core when we
/// send a chat file. The blob is cleared on leave.
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
self.shared.served_files.lock().unwrap().insert(id, bytes);
}
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
pub fn forget_attachment(&self, id: AttachmentId) {
self.shared.served_files.lock().unwrap().remove(&id);
}
/// Fetch `size` bytes stored under `id` from peer `from` over the files plane.
/// Shared core of `fetch_attachment` and music-track fetching: dials
/// `FILES_ALPN`, writes the 32-byte id, and reads bounded by `size`.
pub async fn fetch_blob(
&self,
from: EndpointId,
id: AttachmentId,
size: u64,
) -> Result<Vec<u8>, NetError> {
if !crate::files::size_within_cap(size) {
return Err(NetError::Other("attachment size out of range".to_string()));
}
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
let connect = async {
match addr {
Some(addr) => self.shared.endpoint.connect(addr, FILES_ALPN).await,
None => self.shared.endpoint.connect(from, FILES_ALPN).await,
}
};
let conn = tokio::time::timeout(FILE_FETCH_TIMEOUT, connect)
.await
.map_err(|_| NetError::Other("file fetch: connect timed out".to_string()))?
.map_err(|e| NetError::Other(format!("file fetch: connect failed: {e}")))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
send.write_all(&id)
.await
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
send.finish()
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
let read = recv.read_to_end(size as usize);
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
.await
.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(),
));
}
Ok(bytes)
}
/// Fetch a chat attachment's bytes from its sender over the file plane.
pub async fn fetch_attachment(
&self,
from: EndpointId,
att: &ChatAttachment,
) -> Result<Vec<u8>, NetError> {
self.fetch_blob(from, att.id, att.size).await
}
}
#[async_trait]
+206 -31
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 {
@@ -22,6 +22,38 @@ pub enum NetError {
Other(String),
}
/// A peer's currently-broadcast music track + playback timeline (W22). Rides
/// gossip presence so listeners can tune in, follow track changes, and keep in
/// sync. Untrusted like `name`/`game`: the `name` is sanitized and `size` is
/// cap-checked at gossip ingest. Bytes never ride gossip — they are fetched
/// point-to-point over the files plane by `id`, exactly like a chat attachment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MusicPresence {
/// Files-plane handle to fetch this track's bytes (minted per track by the DJ).
pub id: crate::files::AttachmentId,
/// Sanitized display name (track filename). Untrusted; cleaned at ingest.
pub name: String,
/// Byte length, bounds the listener's fetch. Must be `<= MAX_ATTACHMENT_BYTES`.
pub size: u64,
/// True while the DJ has the track paused.
pub paused: bool,
/// Wall-clock ms (UNIX epoch) of the timeline anchor. While playing, the true
/// playhead is `position_ms + (now_ms - anchor_ms)`; while paused it is
/// frozen at `position_ms`. Re-stamped on every play/pause/seek.
pub anchor_ms: u64,
/// Playhead position (ms) at `anchor_ms`.
pub position_ms: u64,
/// Files-plane handle for the DJ's NEXT track, so listeners can prefetch it
/// for a gapless skip. `None` when there is no distinct next track (single
/// item playlist) or the DJ isn't ready. Equals a future `id` once that
/// track plays.
#[serde(default)]
pub next_id: Option<crate::files::AttachmentId>,
/// Byte length of the next track; bounds the prefetch. Cap-checked at ingest.
#[serde(default)]
pub next_size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PeerState {
pub name: String,
@@ -39,6 +71,65 @@ pub struct PeerState {
/// peers/configs that predate the field still deserialize (→ monogram).
#[serde(default)]
pub avatar: crate::avatar::Avatar,
/// The game this peer is currently playing, as a display string only (shown as
/// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
/// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
/// the peer isn't sharing a game (feature off / nothing detected). Only the
/// display string rides the wire — never the appid or detection source, to
/// avoid fingerprinting and coupling the protocol to detector internals.
/// Defaulted so peers/configs predating the field still deserialize.
#[serde(default)]
pub game: Option<String>,
/// This peer's currently-broadcast music track and playback timeline, or
/// `None` when not broadcasting. Defaulted so pre-W22 peers deserialize.
#[serde(default)]
pub music: Option<MusicPresence>,
}
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
/// that change only on explicit user action and persist for the whole core
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
///
/// This is the single source of truth for building our own `PeerState`: core
/// reconstructs self-state in several command branches (join, mute toggle, avatar
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
/// means a new presence field is added in exactly one place instead of at every
/// call site.
#[derive(Debug, Clone, Default)]
pub struct SelfPresence {
pub name: String,
pub avatar: crate::avatar::Avatar,
/// The display label of the game we're currently broadcasting, or `None` when
/// game presence is off / nothing is detected. Already sanitized + capped
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
/// the outgoing announce carries a safe value.
pub game: Option<String>,
/// Our current broadcast timeline, or `None` when not broadcasting / not playing.
pub music: Option<MusicPresence>,
}
impl SelfPresence {
/// Combine the sticky identity fields with the volatile per-announce fields
/// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
/// `PeerState` ready to announce over the gossip presence plane.
pub fn to_state(
&self,
is_muted: bool,
addr: iroh::EndpointAddr,
sharing: Option<String>,
) -> PeerState {
PeerState {
name: self.name.clone(),
is_muted,
addr,
sharing,
avatar: self.avatar.clone(),
game: self.game.clone(),
music: self.music.clone(),
}
}
}
#[derive(Debug, Clone)]
@@ -54,10 +145,25 @@ pub enum RoomEvent {
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
/// reconnect path the way it used to.
PeerConnectionLost(EndpointId),
/// 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,
},
/// 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.
ChatMessage { from: EndpointId, name: String, text: String, ts: u64 },
ChatMessage {
from: EndpointId,
name: String,
text: String,
ts: u64,
/// Optional file attachment descriptor; the bytes are fetched off-gossip
/// on the file plane. Already filename-sanitized + size-capped on ingest.
attachment: Option<crate::files::ChatAttachment>,
},
}
/// Transport-level link state for a peer, surfaced so the UI can show when a
@@ -100,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(),
}
}
@@ -112,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
@@ -120,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)
}
}
@@ -143,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)
}
}
@@ -206,8 +320,13 @@ pub trait RoomState: Send + Sync {
fn mark_peer_disconnected(&self, peer_id: EndpointId);
/// Broadcasts a room text-chat message authored by us (our display name is
/// taken from the current self-state).
async fn send_chat(&self, text: String) -> Result<(), NetError>;
/// taken from the current self-state), optionally carrying a file attachment
/// descriptor whose bytes are served separately on the file plane.
async fn send_chat(
&self,
text: String,
attachment: Option<crate::files::ChatAttachment>,
) -> Result<(), NetError>;
/// Leaves the room and announces departure.
async fn leave(&self) -> Result<(), NetError>;
@@ -219,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();
@@ -237,6 +356,8 @@ mod tests {
addr,
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
}
}
@@ -261,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));
@@ -300,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(_))));
}
@@ -314,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();
@@ -331,18 +456,51 @@ 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]
fn self_presence_builds_peer_state_with_volatile_fields() {
let addr = EndpointAddr::from(SecretKey::generate().public());
let presence = SelfPresence {
name: "Alice".to_string(),
avatar: crate::avatar::Avatar::default(),
game: Some("Half-Life 2".to_string()),
music: None,
};
// Volatile fields come from the call; sticky fields from the struct.
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
assert_eq!(muted.name, "Alice");
assert!(muted.is_muted);
assert_eq!(muted.addr.id, addr.id);
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
// The same sticky presence yields different volatile fields per announce.
let unmuted = presence.to_state(false, addr.clone(), None);
assert!(!unmuted.is_muted);
assert_eq!(unmuted.sharing, None);
assert_eq!(unmuted.name, muted.name);
}
#[test]
@@ -352,4 +510,21 @@ mod tests {
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn music_presence_serde_round_trip() {
let original = MusicPresence {
id: [3u8; 32],
name: "track.ogg".to_string(),
size: 1234,
paused: false,
anchor_ms: 1_700_000_000_000,
position_ms: 42_000,
next_id: None,
next_size: None,
};
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: MusicPresence = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
}
+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);
}
}
+115
View File
@@ -0,0 +1,115 @@
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PlaylistKind {
M3u,
Pls,
}
/// Classify a path by extension into a playlist kind, or None if it is not a
/// recognized playlist file. Case-insensitive: m3u/m3u8 -> M3u, pls -> Pls.
pub fn playlist_kind(path: &Path) -> Option<PlaylistKind> {
let ext = path.extension()?.to_string_lossy();
match ext.to_ascii_lowercase().as_str() {
"m3u" | "m3u8" => Some(PlaylistKind::M3u),
"pls" => Some(PlaylistKind::Pls),
_ => None,
}
}
/// Parse an m3u/m3u8 or pls playlist into local audio file paths. Remote entries
/// (http/https/ftp URLs) and non-audio entries are skipped; relative paths are
/// resolved against `base_dir` (the playlist file's parent directory). Order is
/// preserved. Does not touch the filesystem.
pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Vec<PathBuf> {
let entries: Vec<&str> = match kind {
PlaylistKind::M3u => contents
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect(),
PlaylistKind::Pls => contents
.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
key.trim()
.to_ascii_lowercase()
.starts_with("file")
.then_some(value.trim())
})
.filter(|line| !line.is_empty())
.collect(),
};
entries
.into_iter()
.filter_map(|entry| playlist_entry_path(entry, base_dir))
.collect()
}
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://")
{
return None;
}
let path = Path::new(entry);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
};
let file_name = resolved.file_name()?.to_string_lossy();
crate::files::looks_like_audio_name(&file_name).then_some(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn m3u_skips_comments_and_remote_urls() {
let base = Path::new("/music/lists");
let contents = "\
#EXTM3U
#EXTINF:123,Artist - Song
tracks/song.ogg
https://example.com/stream.mp3
";
assert_eq!(
parse_playlist(contents, base, PlaylistKind::M3u),
vec![PathBuf::from("/music/lists/tracks/song.ogg")]
);
}
#[test]
fn pls_keeps_file_values_and_skips_non_audio() {
let base = Path::new("/music");
let contents = "\
[playlist]
File1=one.flac
Title1=One
File2=notes.txt
File3=/var/audio/two.MP3
";
assert_eq!(
parse_playlist(contents, base, PlaylistKind::Pls),
vec![
PathBuf::from("/music/one.flac"),
PathBuf::from("/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.PLS")), Some(PlaylistKind::Pls));
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
}
}
+113 -18
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 {
@@ -100,7 +103,11 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
mode.answers_pings() && friends.contains(from)
}
/// What we learned about a friend from a successful ping reply.
/// What we learned about a friend's reachability. `Online`/`InRoom` come from a
/// successful ping reply (see [`interpret_pong`]); `Offline` is produced by the
/// presence scheduler when a probe fails or the friend has no known address, so a
/// friend who drops or leaves is *actively* downgraded rather than left showing a
/// stale status. The UI also treats a missing entry as offline.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence {
/// Online, but not in a gathering we can join.
@@ -108,6 +115,8 @@ pub enum FriendPresence {
/// Online and in a joinable gathering (name already sanitized, ticket already
/// validated as parseable).
InRoom { name: String, ticket: String },
/// Unreachable: the probe failed, or we have no address to probe yet.
Offline,
}
/// Interpret a peer's reply defensively. `from` must be the connection's
@@ -140,6 +149,20 @@ pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresen
}
}
/// Map a single probe outcome to a definitive [`FriendPresence`], used by the
/// presence scheduler. `Some((reply, from))` is a received message from the
/// authenticated remote `from`; `None` means the probe failed (offline /
/// unreachable / refused). Anything that doesn't interpret as a real presence —
/// a probe error, or a non-`Pong` reply — becomes [`FriendPresence::Offline`], so
/// a friend who drops is actively downgraded instead of keeping a stale status.
/// Pure so the scheduler's downgrade behaviour is unit-testable without a network.
pub fn presence_from_probe(reply: Option<(&ControlMsg, EndpointId)>) -> FriendPresence {
match reply {
Some((msg, from)) => interpret_pong(msg, from).unwrap_or(FriendPresence::Offline),
None => FriendPresence::Offline,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -173,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));
}
@@ -194,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 {
@@ -225,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));
}
@@ -246,20 +294,67 @@ 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));
}
#[test]
fn presence_from_probe_maps_outcomes_to_definitive_status() {
let friend = id();
// A failed probe (no reply) is an explicit downgrade to Offline, so the UI
// clears a friend who has dropped instead of keeping a stale status.
assert_eq!(presence_from_probe(None), FriendPresence::Offline);
// A successful Pong with no room is Online.
assert_eq!(
presence_from_probe(Some((&ControlMsg::Pong { room: None }, friend))),
FriendPresence::Online
);
// A successful Pong advertising the friend's own room is InRoom.
let t = valid_ticket(friend);
assert_eq!(
presence_from_probe(Some((
&ControlMsg::Pong {
room: Some(RoomPresence {
name: "Den".into(),
ticket: t.clone()
})
},
friend,
))),
FriendPresence::InRoom {
name: "Den".into(),
ticket: t
}
);
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
assert_eq!(
presence_from_probe(Some((&ControlMsg::Ping, friend))),
FriendPresence::Offline
);
}
#[test]
fn interpret_pong_sanitizes_a_hostile_room_name() {
// 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"),
+50 -6
View File
@@ -22,16 +22,43 @@ pub const FRIENDS_PROTO: u32 = 1;
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
/// into [`versioned_topic`].
pub const GOSSIP_PROTO: u32 = 1;
///
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
/// to fail fast rather than half-work.
///
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
/// strictly required for decoding — but per the versioning discipline a wire-shape
/// 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.
///
/// 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`].
pub const FILES_PROTO: u32 = 1;
/// ALPN for the audio datagram plane: `peerspeak/audio/<AUDIO_PROTO>`.
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
/// ALPN for the file-transfer plane: `peerspeak/files/<FILES_PROTO>`. Carries
/// chat attachment bytes via direct QUIC streams (not gossip).
pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions).
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v5";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
@@ -60,9 +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!(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]
@@ -71,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]
+104 -12
View File
@@ -27,19 +27,50 @@ fn is_spoofing_format_char(c: char) -> bool {
)
}
/// Max characters kept for a broadcast game-presence label after sanitizing
/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]),
/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string
/// can't blow the presence frame.
pub const GAME_LABEL_MAX_CHARS: usize = 64;
/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of
/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width.
pub const GAME_LABEL_MAX_BYTES: usize = 256;
/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing
/// format characters, turn control characters into spaces, collapse any whitespace
/// run to a single space, and trim the ends. Length capping is the caller's job.
fn clean_label(input: &str) -> String {
let cleaned: String = input
.chars()
.filter(|c| !is_spoofing_format_char(*c))
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
/// zero-width format characters, turns control characters into spaces, collapses
/// any whitespace run to a single space, trims the ends, and caps the length at
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
/// substitute a placeholder such as a short id).
pub fn sanitize_name(input: &str) -> String {
let cleaned: String = input
.chars()
.filter(|c| !is_spoofing_format_char(*c))
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
collapsed.chars().take(NAME_MAX_CHARS).collect()
clean_label(input).chars().take(NAME_MAX_CHARS).collect()
}
/// Sanitize an untrusted game-presence label (the `Playing <name>` status that
/// rides the gossip presence plane). Same spoof/control cleaning as
/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and
/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast).
pub fn sanitize_game_label(input: &str) -> String {
let mut out = String::new();
for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) {
if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES {
break;
}
out.push(c);
}
out
}
/// A piece of a chat message after URL detection: literal text or a link.
@@ -54,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`,
@@ -113,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 "), "");
}
@@ -135,6 +172,49 @@ mod tests {
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
}
// --- sanitize_game_label ----------------------------------------------
#[test]
fn game_label_keeps_ordinary_titles_and_strips_spoofing() {
assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2");
// Same spoof/control cleaning as names.
assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt");
assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c");
}
#[test]
fn game_label_caps_chars_wider_than_names() {
// A game label keeps more than a name's 48 (up to 64), so a title between
// the two caps survives in full.
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
);
}
#[test]
fn game_label_caps_bytes_for_multibyte_titles() {
// Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept.
let cjk = "".repeat(GAME_LABEL_MAX_CHARS);
let out = sanitize_game_label(&cjk);
assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS);
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
// Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64,
// but the leading clean keeps them as a run) — never exceeds the byte cap.
let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS);
let out = sanitize_game_label(&emoji);
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
assert!(out.chars().all(|c| c == '🎮'));
}
#[test]
fn game_label_empty_when_nothing_usable() {
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
}
// --- linkify -----------------------------------------------------------
/// Concatenating every segment's inner text must reproduce the input exactly.
@@ -149,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]
@@ -209,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]
@@ -222,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:?}"
);
}
}
}
+697 -43
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";
@@ -39,6 +41,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512;
/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
const MAX_APP_NAME_LEN: usize = 256;
/// How long to wait for the host to emit its ticket / the viewer to connect
/// before giving up and killing the child. Startup is normally sub-second; this
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
@@ -64,6 +70,12 @@ pub enum PixelpassEvent {
CaptureStarted,
/// Host: capture pipeline torn down (on last viewer).
CaptureStopped,
/// Host (per-app audio): the chosen app's audio is now reaching viewers.
AppAudioRouted,
/// Host (per-app audio): the chosen app's last audio stream went away. Under
/// our `--strict-audio` run this means viewers now hear silence (not the call
/// echo) until the app produces audio again — we surface it as a warning.
AppAudioLost,
/// A recognized event we don't act on (e.g. `host_info`).
Other,
}
@@ -98,6 +110,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
Some("stopped") => PixelpassEvent::CaptureStopped,
_ => PixelpassEvent::Other,
},
"app_audio" => match v.get("state").and_then(|s| s.as_str()) {
Some("routed") => PixelpassEvent::AppAudioRouted,
Some("lost") => PixelpassEvent::AppAudioLost,
_ => PixelpassEvent::Other,
},
_ => PixelpassEvent::Other,
};
Some(ev)
@@ -107,6 +124,182 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
}
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
/// captures only that app's audio instead of the whole desktop sink monitor
/// (which contains our own call playout → the viewer would hear themselves
/// echoed back, backlog A23).
///
/// `--strict-audio` is what makes the fix a guarantee rather than best-effort:
/// without it, pixelpass falls back to the whole-desktop loopback before the
/// app's first stream routes and again if the app's audio later stops — both of
/// which reintroduce the echo. With it, the viewer hears only the chosen app (or
/// silence), and pixelpass emits `app_audio` events we surface as a warning.
///
/// The name is passed in the single-token `--app=<name>` form so a value that
/// happens to begin with `-` can never be reparsed as a pixelpass flag (clap
/// 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>,
settings: &ScreenShareSettings,
quality: ShareQuality,
) -> Vec<String> {
let mut args = vec![
"--host".to_string(),
"--output".to_string(),
"json".to_string(),
];
if let Some(name) = audio_app.and_then(sanitize_app_name) {
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());
ok.then(|| name.to_string())
}
/// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core
/// command loop (the picker awaits it before opening), so a wedged/slow `pactl`
/// must not stall mute/deafen/leave/stop. On timeout we treat it like any other
/// failure: empty list → "All system audio" only.
const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2);
/// Enumerate the apps currently sending audio to a sink, deduplicated by
/// `application.name`. Mirrors how pixelpass itself builds its interactive
/// picker (`pactl -f json list sink-inputs`), so the names we return are exactly
/// the ones `--app` matches against. Returns an empty list on any error (pactl
/// missing, non-PipeWire host, nothing playing, or [`LIST_APPS_TIMEOUT`] elapsed)
/// — a normal, handled state that leaves the picker showing only "All system
/// audio".
pub async fn list_audio_apps() -> Vec<String> {
let run = Command::new("pactl")
.args(["-f", "json", "list", "sink-inputs"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
// On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops
// the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged
// `pactl` can't linger/accumulate across picker opens (audit P3).
.kill_on_drop(true)
.output();
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
_ => Vec::new(),
}
}
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
/// picker (safe), never a stalled core loop.
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
/// share always appends `--strict-audio`, and an **older** pixelpass would have
/// clap reject the unknown flag → the host spawn hard-fails and the share is
/// broken (audit P2, version skew). When unsupported the picker degrades to
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
/// would reintroduce the call echo (A23).
///
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
pub async fn supports_strict_audio(bin: &Path) -> bool {
let run = Command::new(bin)
.arg("--help")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
_ => false,
}
}
/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
/// flag token rather than a whole line, since clap may wrap/realign help text.
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
String::from_utf8_lossy(help_stdout).contains("--strict-audio")
}
/// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list
/// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list.
/// Each name is passed through [`sanitize_app_name`] so the picker only ever
/// offers names that will actually survive [`host_args`]; otherwise a name that
/// parses here but fails sanitization later would be selectable yet silently
/// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo).
pub fn parse_audio_apps(stdout: &[u8]) -> Vec<String> {
let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.into_iter()
.filter_map(|e| e.properties.application_name)
.filter_map(|n| sanitize_app_name(&n))
.collect();
names.sort_unstable();
names.dedup();
names
}
#[derive(serde::Deserialize)]
struct SinkInput {
properties: SinkInputProperties,
}
#[derive(serde::Deserialize)]
struct SinkInputProperties {
#[serde(rename = "application.name")]
application_name: Option<String>,
}
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
/// rides gossip presence, which is untrusted and spoofable), so flags come first
/// and the ticket is passed as a positional **after a `--` end-of-options
@@ -162,20 +355,37 @@ pub fn is_available(config_override: Option<&str>) -> bool {
pixelpass_path(config_override).is_some()
}
/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its
/// startup ticket, and return the live child plus the ticket. The child keeps
/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
/// for its startup ticket, and return the live child plus the ticket. When
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
/// 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`.
pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
/// 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)
.arg("--host")
.arg("--output")
.arg("json")
.args(&args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
// Capture stderr (not null): pixelpass prints its startup precondition
// failures there — a missing GStreamer plugin / `pactl`, each with an
// actionable "Install hint: sudo apt install ..." line. If the host dies
// before its ticket we fold that tail into our error so the user sees
// *what to install* instead of a dead-end "exited before a ticket". On
// the success path we drain it in the background so the pipe can't fill.
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
@@ -183,6 +393,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
.stdout
.take()
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
let stderr = child.stderr.take();
let mut lines = BufReader::new(stdout).lines();
let ticket = match read_until(&mut lines, |e| match e {
@@ -194,9 +405,10 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
Ok(Some(t)) => t,
Ok(None) => {
let _ = child.kill().await;
return Err(std::io::Error::other(
"pixelpass host exited before emitting a ticket",
));
let detail = read_stderr_tail(stderr).await;
return Err(std::io::Error::other(format!(
"pixelpass host exited before emitting a ticket{detail}"
)));
}
Err(e) => {
let _ = child.kill().await;
@@ -204,15 +416,74 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
}
};
drain_in_background(lines, "host");
if let Some(stderr) = stderr {
drain_stderr_in_background(stderr);
}
drain_in_background(lines, "host", notices);
Ok((child, ticket))
}
/// Read a killed pixelpass child's stderr to EOF and reduce it to a short,
/// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the
/// caller kills the child first, so the pipe EOFs promptly. Returns an empty
/// string when stderr was already taken or carried nothing useful.
async fn read_stderr_tail(stderr: Option<tokio::process::ChildStderr>) -> String {
use tokio::io::AsyncReadExt;
let Some(mut stderr) = stderr else {
return String::new();
};
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf).await;
pixelpass_failure_detail(&String::from_utf8_lossy(&buf))
}
/// Discard a running pixelpass child's stderr in the background so its pipe
/// can't fill and stall the host (mirrors [`drain_in_background`] for stdout).
fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) {
use tokio::io::AsyncReadExt;
tokio::spawn(async move {
let mut buf = [0u8; 4096];
while let Ok(n) = stderr.read(&mut buf).await {
if n == 0 {
break;
}
}
});
}
/// Extract a human-useful tail from a failed pixelpass child's stderr to append
/// to our error. pixelpass writes actionable startup errors there (a missing
/// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...`
/// line), which is exactly what a freshly-installed host needs to see. The
/// decorative host banner (box-drawing) is dropped — it only prints on the
/// success path, but we filter it defensively. Pure: no I/O. Returns an empty
/// string when there's nothing worth surfacing (so callers can append blindly).
pub fn pixelpass_failure_detail(stderr: &str) -> String {
let useful: Vec<&str> = stderr
.lines()
.map(str::trim_end)
.filter(|l| !l.trim().is_empty())
.filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├']))
.collect();
if useful.is_empty() {
return String::new();
}
// The anyhow error and its install hint are the *last* lines printed, so
// keep the tail rather than the head.
const MAX_LINES: usize = 12;
let start = useful.len().saturating_sub(MAX_LINES);
format!("\n\npixelpass reported:\n{}", useful[start..].join("\n"))
}
/// 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())
@@ -246,12 +517,12 @@ 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);
}
drain_in_background(lines, "viewer");
drain_in_background(lines, "viewer", None);
Ok(child)
}
@@ -286,15 +557,24 @@ where
}
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
/// stall it; log notable events for diagnostics.
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
where
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
/// parsed event is also forwarded to the caller (the core, which translates the
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
/// stops forwarding, draining continues. The task ends on EOF (child exited).
fn drain_in_background<R>(
mut lines: tokio::io::Lines<BufReader<R>>,
role: &'static str,
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
) where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
{
tokio::spawn(async move {
while let Ok(Some(line)) = lines.next_line().await {
if let Some(ev) = parse_pixelpass_event(&line) {
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
if let Some(tx) = &notices {
let _ = tx.send(ev);
}
}
}
});
@@ -313,27 +593,39 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
PixelpassEvent::Other => "other".to_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",
@@ -347,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)
@@ -368,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.
@@ -384,16 +736,290 @@ mod tests {
);
}
#[test]
fn host_args_without_app_shares_whole_desktop() {
// No app selected → no --app flag → pixelpass keeps its default
// (whole-desktop) audio capture.
let settings = ScreenShareSettings::default();
assert_eq!(
host_args(None, &settings, ShareQuality::Auto),
vec!["--host", "--output", "json"]
);
}
#[test]
fn host_args_with_app_appends_single_token_flag() {
// 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"), &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"), &settings, ShareQuality::Auto);
assert_eq!(args[3], "--app=-rm -rf");
assert_eq!(args[4], "--strict-audio");
}
#[test]
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.
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(""), None);
assert_eq!(sanitize_app_name(" "), None);
assert_eq!(sanitize_app_name("a\tb"), None);
assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None);
}
#[test]
fn parse_audio_apps_dedups_and_sorts_by_application_name() {
let stdout = br#"[
{"index":1,"properties":{"application.name":"Firefox"}},
{"index":2,"properties":{"application.name":"mpv"}},
{"index":3,"properties":{"application.name":"Firefox"}},
{"index":4,"properties":{"application.name":" Spotify "}},
{"index":5,"properties":{"application.name":""}},
{"index":6,"properties":{"other":"no name here"}}
]"#;
assert_eq!(
parse_audio_apps(stdout),
vec![
"Firefox".to_string(),
"Spotify".to_string(),
"mpv".to_string()
]
);
}
#[test]
fn parse_audio_apps_empty_or_garbage_is_empty() {
assert_eq!(parse_audio_apps(b""), Vec::<String>::new());
assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::new());
}
#[test]
fn parse_audio_apps_drops_names_host_args_would_reject() {
// Names that parse from pactl but fail `sanitize_app_name` (control chars,
// overlong) must NOT be offered in the picker — otherwise the user could
// pick one, `host_args` would silently drop `--app`, and the share would
// revert to whole-desktop audio (A23 echo) with no signal. The valid name
// survives; the control-char and overlong ones are filtered out.
let overlong = "x".repeat(MAX_APP_NAME_LEN + 1);
let stdout = format!(
r#"[
{{"index":1,"properties":{{"application.name":"mpv"}}}},
{{"index":2,"properties":{{"application.name":"bad\nname"}}}},
{{"index":3,"properties":{{"application.name":"{overlong}"}}}}
]"#
);
assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]);
}
#[test]
fn failure_detail_surfaces_install_hint_and_drops_banner() {
// The real shape of a fresh-host failure: anyhow error + install hint on
// stderr. We must keep those (so the user knows what to apt install) and
// drop the decorative banner box-drawing lines.
let stderr = "\
PixelPass · host
display server : Wayland
Error: GStreamer element `vah264enc` not available.
Install hint: sudo apt install gstreamer1.0-plugins-bad
";
let detail = pixelpass_failure_detail(stderr);
assert!(detail.starts_with("\n\npixelpass reported:\n"));
assert!(detail.contains("vah264enc` not available"));
assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad"));
assert!(!detail.contains('│'), "banner box-drawing must be dropped");
assert!(!detail.contains('┌'));
}
#[test]
fn failure_detail_empty_when_nothing_useful() {
// Blank / banner-only stderr yields an empty string so the caller can
// append it to the base message unconditionally without trailing noise.
assert_eq!(pixelpass_failure_detail(""), "");
assert_eq!(pixelpass_failure_detail(" \n \n"), "");
assert_eq!(
pixelpass_failure_detail("│ display server : Wayland\n│ capture : x\n"),
""
);
}
#[test]
fn failure_detail_keeps_only_the_tail() {
// A long stderr is truncated to its last lines (where the real error
// and hint live), not its head.
let body: String = (0..30).map(|i| format!("line {i}\n")).collect();
let detail = pixelpass_failure_detail(&body);
assert!(detail.contains("line 29"));
assert!(!detail.contains("line 0\n"));
}
#[test]
fn help_probe_detects_strict_audio_flag() {
// A new pixelpass advertises the flag; an old one doesn't. The probe must
// match the token even when clap wraps the option onto its own line.
let new_help = b"Options:\n --app <APP>\n --strict-audio\n With --app, never fall back...";
assert!(help_mentions_strict_audio(new_help));
let old_help = b"Options:\n --app <APP>\n --output <OUTPUT>\n -h, --help";
assert!(!help_mentions_strict_audio(old_help));
// Garbage / empty output degrades to "unsupported" (safe path).
assert!(!help_mentions_strict_audio(b""));
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
}
#[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);
}
@@ -417,7 +1043,9 @@ mod tests {
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()
))
);
}
@@ -470,6 +1098,29 @@ mod tests {
);
}
#[test]
fn parses_app_audio_states() {
// The wire contract from pixelpass's --strict-audio run (A23): routed =
// the chosen app's audio is live; lost = it stopped (viewers now silent).
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#),
Some(PixelpassEvent::AppAudioRouted)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#),
Some(PixelpassEvent::AppAudioLost)
);
// Unknown / missing state is recognized-but-unused, not a parse failure.
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#),
Some(PixelpassEvent::Other)
);
assert_eq!(
parse_pixelpass_event(r#"{"event":"app_audio"}"#),
Some(PixelpassEvent::Other)
);
}
#[test]
fn recognized_but_unused_event_is_other() {
assert_eq!(
@@ -529,7 +1180,10 @@ mod tests {
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,
+992
View File
@@ -0,0 +1,992 @@
use iced::advanced::clipboard::{self, Clipboard};
use iced::advanced::layout;
use iced::advanced::mouse;
use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::text;
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{Layout, Shell};
use iced::widget::text_input;
use iced::{
Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
Size, Vector, alignment,
};
use std::rc::Rc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
pub value: String,
pub cursor: usize,
}
pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
let value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end);
(start != end).then(|| value.select(start, end).to_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);
if start == end {
return (
Edit {
value: value.to_string(),
cursor: start,
},
None,
);
}
let selected = value.select(start, end).to_string();
value.remove_many(start, end);
(
Edit {
value: value.to_string(),
cursor: start,
},
Some(selected),
)
}
pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
let mut value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end);
let clip = text_input::Value::new(clip);
let cursor = start + clip.len();
if start != end {
value.remove_many(start, end);
}
value.insert_many(start, clip);
Edit {
value: value.to_string(),
cursor,
}
}
/// 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) {
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>;
pub fn context_input<'a, Message, Theme, Renderer>(
placeholder: &str,
value: &str,
) -> ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer,
{
ContextInput::new(placeholder, value)
}
pub fn locked_value<'a, Message, Theme, Renderer>(
value: &str,
noop: Message,
) -> ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer,
{
ContextInput::new("", value)
.on_input(move |_| noop.clone())
.locked(true)
}
pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
input: text_input::TextInput<'a, Message, Theme, Renderer>,
value: String,
is_secure: bool,
locked: bool,
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
style: Option<InputStyleFn<'a, Theme>>,
}
impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer,
{
pub fn new(placeholder: &str, value: &str) -> Self {
Self {
input: text_input::TextInput::new(placeholder, value),
value: value.to_owned(),
is_secure: false,
locked: false,
on_input: None,
on_paste: None,
style: None,
}
}
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
self.input = self.input.id(id);
self
}
pub fn secure(mut self, is_secure: bool) -> Self {
self.is_secure = is_secure;
self.input = self.input.secure(is_secure);
self
}
pub fn locked(mut self, yes: bool) -> Self {
self.locked = yes;
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);
let input_callback = Rc::clone(&on_input);
self.input = self
.input
.on_input(move |value| input_callback.as_ref()(value));
self.on_input = Some(on_input);
self
}
pub fn on_submit(mut self, message: Message) -> Self {
self.input = self.input.on_submit(message);
self
}
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
self.input = self.input.on_submit_maybe(message);
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);
let paste_callback = Rc::clone(&on_paste);
self.input = self
.input
.on_paste(move |value| paste_callback.as_ref()(value));
self.on_paste = Some(on_paste);
self
}
pub fn font(mut self, font: Renderer::Font) -> Self {
self.input = self.input.font(font);
self
}
pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
self.input = self.input.icon(icon);
self
}
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.input = self.input.width(width);
self
}
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
self.input = self.input.padding(padding);
self
}
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.input = self.input.size(size);
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 {
self.input = self.input.align_x(alignment);
self
}
pub fn style(
mut self,
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
) -> Self
where
Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
{
let style: InputStyleFn<'a, Theme> = Rc::new(style);
let input_style = Rc::clone(&style);
self.input = self
.input
.style(move |theme, status| input_style.as_ref()(theme, status));
self.style = Some(style);
self
}
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.input = self.input.class(class);
self.style = None;
self
}
}
#[derive(Default)]
struct ContextInputState {
menu: Option<MenuState>,
}
#[derive(Debug, Clone, Copy)]
struct MenuState {
anchor: Point,
selection: (usize, usize),
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for ContextInput<'_, Message, Theme, Renderer>
where
Message: Clone,
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<ContextInputState>()
}
fn state(&self) -> tree::State {
tree::State::new(ContextInputState::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
}
fn diff(&self, tree: &mut Tree) {
if tree.children.is_empty() {
tree.children
.push(Tree::new(&self.input as &dyn Widget<_, _, _>));
} else {
tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
tree.children.truncate(1);
}
}
fn size(&self) -> Size<Length> {
Widget::size(&self.input)
}
fn size_hint(&self) -> Size<Length> {
Widget::size_hint(&self.input)
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
Widget::operate(
&mut self.input,
&mut tree.children[0],
layout,
renderer,
operation,
);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let right_click_on_input = matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
) && cursor.is_over(layout.bounds());
if right_click_on_input {
let value = text_input::Value::new(&self.value);
let input_state = tree.children[0]
.state
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
let selection = 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)
}
};
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();
return;
}
Widget::update(
&mut self.input,
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
Widget::draw(
&self.input,
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
Widget::mouse_interaction(
&self.input,
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
_layout: Layout<'a>,
_renderer: &Renderer,
_viewport: &Rectangle,
_translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
let Tree {
state, children, ..
} = tree;
let menu = &mut state.downcast_mut::<ContextInputState>().menu;
if menu.is_none() {
return None;
}
let input_state = children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
Some(overlay::Element::new(Box::new(ContextMenuOverlay {
menu,
input_state,
value: &self.value,
is_secure: self.is_secure,
locked: self.locked,
on_input: self.on_input.clone(),
on_paste: self.on_paste.clone(),
style: self.style.clone(),
})))
}
}
impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer + 'a,
{
fn from(
input: ContextInput<'a, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(input)
}
}
struct ContextMenuOverlay<'a, Message, Theme, Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
menu: &'a mut Option<MenuState>,
input_state: &'a mut text_input::State<Renderer::Paragraph>,
value: &'a str,
is_secure: bool,
locked: bool,
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
style: Option<InputStyleFn<'a, Theme>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MenuAction {
Cut,
Copy,
Paste,
SelectAll,
}
impl MenuAction {
const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];
fn label(self) -> &'static str {
match self {
Self::Cut => "Cut",
Self::Copy => "Copy",
Self::Paste => "Paste",
Self::SelectAll => "Select All",
}
}
}
const MENU_WIDTH: f32 = 136.0;
const ITEM_HEIGHT: f32 = 28.0;
const TEXT_SIZE: f32 = 13.0;
const MENU_PADDING_X: f32 = 10.0;
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
for ContextMenuOverlay<'_, Message, Theme, Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
let Some(menu) = self.menu.as_ref() else {
return layout::Node::new(Size::ZERO);
};
let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));
layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
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 bounds = layout.bounds();
let viewport = Rectangle::INFINITE;
renderer.fill_quad(
renderer::Quad {
bounds,
border: Border {
radius: 5.0.into(),
width: 1.0,
color: active_style.border.color,
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
offset: Vector::new(0.0, 4.0),
blur_radius: 10.0,
},
..renderer::Quad::default()
},
active_style.background,
);
for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
let item_bounds = item_bounds(bounds, index);
let enabled = self.enabled(action);
let hovered = enabled && cursor.is_over(item_bounds);
if hovered {
renderer.fill_quad(
renderer::Quad {
bounds: item_bounds,
border: Border {
radius: 3.0.into(),
..Border::default()
},
..renderer::Quad::default()
},
Background::Color(hovered_style.selection),
);
}
renderer.fill_text(
text::Text {
content: action.label().to_owned(),
bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
size: Pixels(TEXT_SIZE),
line_height: text::LineHeight::default(),
font: renderer.default_font(),
align_x: text::Alignment::Default,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::default(),
},
Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
if enabled {
active_style.value
} else {
disabled_color(active_style.value)
},
viewport,
);
}
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
match event {
Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
self.close(shell);
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let Some(position) = cursor.position() else {
self.close(shell);
return;
};
let bounds = layout.bounds();
if !bounds.contains(position) {
self.close(shell);
return;
}
if let Some(action) = self.hit_action(bounds, position) {
if self.enabled(action) {
self.perform(action, clipboard, shell);
}
self.close(shell);
}
}
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
let should_close = cursor
.position()
.is_none_or(|position| !layout.bounds().contains(position));
if should_close {
self.close(shell);
}
}
_ => {}
}
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
) -> mouse::Interaction {
let Some(position) = cursor.position() else {
return mouse::Interaction::default();
};
if self.hit_action(layout.bounds(), position).is_some() {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
fn enabled(&self, action: MenuAction) -> bool {
let Some(menu) = self.menu.as_ref() else {
return false;
};
let has_selection = menu.selection.0 != menu.selection.1;
let has_value = !text_input::Value::new(self.value).is_empty();
menu_action_enabled(
action,
has_selection,
has_value,
self.is_secure,
self.locked,
)
}
fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
if !bounds.contains(position) {
return None;
}
let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;
MenuAction::ALL.get(index).copied()
}
fn perform(
&mut self,
action: MenuAction,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let Some(menu) = self.menu.as_ref().copied() else {
return;
};
let (start, end) = menu.selection;
match action {
MenuAction::Cut => {
let (edit, selected) = cut_selection(self.value, start, end);
if let Some(selected) = selected {
clipboard.write(clipboard::Kind::Standard, selected);
self.publish_edit(edit, shell);
}
}
MenuAction::Copy => {
if let Some(selected) = copy_selection(self.value, start, end) {
clipboard.write(clipboard::Kind::Standard, selected);
}
}
MenuAction::Paste => {
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);
}
MenuAction::SelectAll => {
let (start, end) = select_all_range(self.value);
self.input_state.select_range(start, end);
shell.request_redraw();
}
}
}
fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
if let Some(on_input) = &self.on_input {
self.input_state.move_cursor_to(edit.cursor);
shell.publish(on_input.as_ref()(edit.value));
shell.request_redraw();
}
}
fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
self.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.request_redraw();
}
fn close(&mut self, shell: &mut Shell<'_, Message>) {
*self.menu = None;
shell.capture_event();
shell.request_redraw();
}
}
fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
Rectangle {
x: menu_bounds.x + 3.0,
y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
width: menu_bounds.width - 6.0,
height: ITEM_HEIGHT,
}
}
fn input_style<Theme: text_input::Catalog>(
theme: &Theme,
style: Option<&InputStyleFn<'_, Theme>>,
status: text_input::Status,
) -> text_input::Style {
if let Some(style) = style {
style.as_ref()(theme, status)
} else {
let class = <Theme as text_input::Catalog>::default();
theme.style(&class, status)
}
}
fn disabled_color(color: Color) -> Color {
Color {
a: color.a * 0.45,
..color
}
}
fn menu_action_enabled(
action: MenuAction,
has_selection: bool,
has_value: bool,
is_secure: bool,
locked: bool,
) -> bool {
match action {
MenuAction::Cut => has_selection && !is_secure && !locked,
MenuAction::Copy => has_selection && !is_secure,
MenuAction::Paste => !locked,
MenuAction::SelectAll => has_value,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_selection_returns_middle_substring_and_empty_none() {
assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
assert_eq!(copy_selection("abcdef", 3, 3), None);
}
#[test]
fn cut_selection_removes_range_and_copies_selection() {
let (edit, clip) = cut_selection("abcdef", 2, 5);
assert_eq!(
edit,
Edit {
value: "abf".to_owned(),
cursor: 2,
}
);
assert_eq!(clip, Some("cde".to_owned()));
let (edit, clip) = cut_selection("abcdef", 3, 3);
assert_eq!(
edit,
Edit {
value: "abcdef".to_owned(),
cursor: 3,
}
);
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!(
paste("abcdef", 2, 5, "XY"),
Edit {
value: "abXYf".to_owned(),
cursor: 4,
}
);
assert_eq!(
paste("abcdef", 3, 3, "XY"),
Edit {
value: "abcXYdef".to_owned(),
cursor: 5,
}
);
}
#[test]
fn select_all_range_uses_grapheme_length() {
assert_eq!(select_all_range(""), (0, 0));
assert_eq!(select_all_range("abé🦀"), (0, 4));
}
#[test]
fn unicode_selection_boundaries_are_grapheme_correct() {
assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));
let (edit, clip) = cut_selection("aé🦀z", 2, 3);
assert_eq!(clip, Some("🦀".to_owned()));
assert_eq!(
edit,
Edit {
value: "aéz".to_owned(),
cursor: 2,
}
);
assert_eq!(
paste("aéz", 2, 2, "🦀"),
Edit {
value: "aé🦀z".to_owned(),
cursor: 3,
}
);
}
#[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::Copy,
false,
true,
false,
true
));
assert!(!menu_action_enabled(
MenuAction::SelectAll,
false,
false,
false,
true
));
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod context_input;
pub mod selectable_text;
+820
View File
@@ -0,0 +1,820 @@
use iced::advanced::clipboard::{self, Clipboard};
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::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,
};
use iced::{
Background, Border, Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector,
alignment, keyboard,
};
const DRAG_THRESHOLD: f32 = 3.0;
const HIT_SEARCH_STEPS: usize = 24;
// Offsets here are paragraph-global BYTE offsets. `Paragraph::hit_test` returns
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
// offset WITHIN its buffer line — it discards the line number. That equals the
// global byte offset only when the text is a single logical line. Chat bodies
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n`
// and `\r`) into a space and collapses whitespace, so a stored message can never
// contain a newline. If that sanitizer ever starts preserving newlines, this
// 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> {
let (start, end) = normalized_byte_range(text, anchor, cursor);
(start != end).then(|| text[start..end].to_owned())
}
pub fn select_all(text: &str) -> (usize, usize) {
(0, text.len())
}
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));
(start.min(end), start.max(end))
}
fn clamp_to_char_boundary(text: &str, offset: usize) -> usize {
let mut offset = offset.min(text.len());
while offset > 0 && !text.is_char_boundary(offset) {
offset -= 1;
}
offset
}
pub fn selectable_rich_text<'a, Link, Message, Theme, Renderer>(
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
) -> SelectableRichText<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog + 'a,
Renderer: advanced_text::Renderer,
Renderer::Font: 'a,
{
SelectableRichText::with_spans(spans)
}
pub struct SelectableRichText<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
{
spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>,
size: Option<Pixels>,
line_height: LineHeight,
width: Length,
height: Length,
font: Option<Renderer::Font>,
align_x: Alignment,
align_y: alignment::Vertical,
wrapping: Wrapping,
class: Theme::Class<'a>,
hovered_link: Option<usize>,
on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>,
selection_color: Color,
}
impl<'a, Link, Message, Theme, Renderer> SelectableRichText<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
Renderer::Font: 'a,
{
pub fn new() -> Self {
Self {
spans: Box::new([]),
size: None,
line_height: LineHeight::default(),
width: Length::Shrink,
height: Length::Shrink,
font: None,
align_x: Alignment::Default,
align_y: alignment::Vertical::Top,
wrapping: Wrapping::default(),
class: Theme::default(),
hovered_link: None,
on_link_click: None,
selection_color: Color::from_rgba(0.35, 0.55, 0.95, 0.35),
}
}
pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self {
Self {
spans: Box::new(spans),
..Self::new()
}
}
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.size = Some(size.into());
self
}
pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
self.line_height = line_height.into();
self
}
pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
self.font = Some(font.into());
self
}
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
pub fn align_x(mut self, alignment: impl Into<Alignment>) -> Self {
self.align_x = alignment.into();
self
}
pub fn align_y(mut self, alignment: impl Into<alignment::Vertical>) -> Self {
self.align_y = alignment.into();
self
}
pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
self.wrapping = wrapping;
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
}
pub fn selection_color(mut self, color: Color) -> Self {
self.selection_color = Color {
a: color.a.min(0.35),
..color
};
self
}
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
self
}
pub fn color(self, color: impl Into<Color>) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.color_maybe(Some(color))
}
pub fn color_maybe(self, color: Option<impl Into<Color>>) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
let color = color.map(Into::into);
self.style(move |_theme| Style { color })
}
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.class = class.into();
self
}
}
impl<'a, Link, Message, Theme, Renderer> Default
for SelectableRichText<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
Renderer::Font: 'a,
{
fn default() -> Self {
Self::new()
}
}
struct SelectableTextState<Link, P: Paragraph> {
spans: Vec<Span<'static, Link, P::Font>>,
span_pressed: Option<usize>,
paragraph: P,
selection: Option<(usize, usize)>,
dragging: bool,
active: bool,
press_position: Option<Point>,
}
impl<Link, P: Paragraph> SelectableTextState<Link, P> {
fn selection_range(&self, text: &str) -> Option<(usize, usize)> {
let (anchor, cursor) = self.selection?;
let (start, end) = normalized_byte_range(text, anchor, cursor);
(start != end).then_some((start, end))
}
}
impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for SelectableRichText<'_, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<SelectableTextState<Link, Renderer::Paragraph>>()
}
fn state(&self) -> tree::State {
tree::State::new(SelectableTextState::<Link, _> {
spans: Vec::new(),
span_pressed: None,
paragraph: Renderer::Paragraph::default(),
selection: None,
dragging: false,
active: false,
press_position: None,
})
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout_text(
tree.state
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>(),
renderer,
limits,
TextLayout {
width: self.width,
height: self.height,
spans: self.spans.as_ref().as_ref(),
line_height: self.line_height,
size: self.size,
font: self.font,
align_x: self.align_x,
align_y: self.align_y,
wrapping: self.wrapping,
},
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
defaults: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
viewport: &Rectangle,
) {
if !layout.bounds().intersects(viewport) {
return;
}
let state = tree
.state
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
let spans = self.spans.as_ref().as_ref();
let flat_text = flatten_spans(spans);
let style = theme.style(&self.class);
let translation = layout.position() - Point::ORIGIN;
if let Some((start, end)) = state.selection_range(&flat_text) {
let mut rects = selection_rects(&state.paragraph, spans.len(), start, end);
if rects.is_empty() {
rects = visual_lines(&state.paragraph, spans.len());
}
for bounds in rects {
renderer.fill_quad(
renderer::Quad {
bounds: bounds + translation,
border: Border {
radius: 2.0.into(),
..Border::default()
},
..renderer::Quad::default()
},
Background::Color(self.selection_color),
);
}
}
for (index, span) in spans.iter().enumerate() {
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 {
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()),
);
renderer.fill_quad(
renderer::Quad {
bounds: bounds + translation,
border: highlight.border,
..Default::default()
},
highlight.background,
);
}
}
if span.underline || span.strikethrough || is_hovered_link {
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 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 {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
bounds.position() + baseline
- Vector::new(0.0, size.0 * 0.08),
Size::new(bounds.width, 1.0),
),
..Default::default()
},
color,
);
}
}
if span.strikethrough {
for bounds in &regions {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
bounds.position() + baseline
- Vector::new(0.0, size.0 / 2.0),
Size::new(bounds.width, 1.0),
),
..Default::default()
},
color,
);
}
}
}
}
}
widget_text::draw(
renderer,
defaults,
layout.bounds(),
&state.paragraph,
style,
viewport,
);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
let local_position = cursor.position_in(bounds);
let state = tree
.state
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>();
let spans = self.spans.as_ref().as_ref();
let flat_text = flatten_spans(spans);
let was_hovered = self.hovered_link.is_some();
self.hovered_link = local_position.and_then(|position| {
state.paragraph.hit_span(position).and_then(|span| {
if spans.get(span)?.link.is_some() {
Some(span)
} else {
None
}
})
});
if was_hovered != self.hovered_link.is_some() {
shell.request_redraw();
}
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(position) = local_position {
state.active = true;
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)
});
shell.capture_event();
shell.request_redraw();
} else if state.active || state.selection.is_some() {
state.active = false;
state.dragging = false;
state.press_position = None;
state.span_pressed = None;
state.selection = None;
shell.request_redraw();
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if state.dragging
&& let Some(position) = clamped_position(cursor, bounds)
&& let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection
{
state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
shell.request_redraw();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if state.dragging {
let release_position = clamped_position(cursor, bounds)
.or(local_position)
.or(state.press_position);
let dragged = state
.press_position
.zip(release_position)
.is_some_and(|(start, end)| point_distance(start, end) > DRAG_THRESHOLD);
if let Some(position) = release_position
&& let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection
{
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())
{
shell.publish(on_link_clicked(link));
}
state.selection = None;
} else if state.selection_range(&flat_text).is_none() {
state.selection = None;
}
state.dragging = false;
state.span_pressed = None;
state.press_position = None;
shell.capture_event();
shell.request_redraw();
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key,
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));
shell.capture_event();
shell.request_redraw();
}
_ => {}
},
_ => {}
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let state = tree
.state
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
if state.dragging {
mouse::Interaction::Text
} else if self.hovered_link.is_some() {
mouse::Interaction::Pointer
} else if cursor.is_over(layout.bounds()) {
mouse::Interaction::Text
} else {
mouse::Interaction::None
}
}
}
struct TextLayout<'a, 'span, Link, Font> {
width: Length,
height: Length,
spans: &'a [Span<'span, Link, Font>],
line_height: LineHeight,
size: Option<Pixels>,
font: Option<Font>,
align_x: Alignment,
align_y: alignment::Vertical,
wrapping: Wrapping,
}
fn layout_text<Link, Renderer>(
state: &mut SelectableTextState<Link, Renderer::Paragraph>,
renderer: &Renderer,
limits: &layout::Limits,
config: TextLayout<'_, '_, Link, Renderer::Font>,
) -> layout::Node
where
Link: Clone,
Renderer: advanced_text::Renderer,
{
layout::sized(limits, config.width, config.height, |limits| {
let bounds = limits.max();
let size = config.size.unwrap_or_else(|| renderer.default_size());
let font = config.font.unwrap_or_else(|| renderer.default_font());
let text_with_spans = || advanced_text::Text {
content: config.spans,
bounds,
size,
line_height: config.line_height,
font,
align_x: config.align_x,
align_y: config.align_y,
shaping: Shaping::Advanced,
wrapping: config.wrapping,
};
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();
} else {
match state.paragraph.compare(advanced_text::Text {
content: (),
bounds,
size,
line_height: config.line_height,
font,
align_x: config.align_x,
align_y: config.align_y,
shaping: Shaping::Advanced,
wrapping: config.wrapping,
}) {
advanced_text::Difference::None => {}
advanced_text::Difference::Bounds => {
state.paragraph.resize(bounds);
}
advanced_text::Difference::Shape => {
state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
}
}
}
state.paragraph.min_bounds()
})
}
fn flatten_spans<Link, Font>(spans: &[Span<'_, Link, Font>]) -> String {
spans
.iter()
.map(|span| span.text.as_ref())
.collect::<String>()
}
fn selection_rects<P: Paragraph>(
paragraph: &P,
span_count: usize,
start: usize,
end: usize,
) -> Vec<Rectangle> {
visual_lines(paragraph, span_count)
.into_iter()
.filter_map(|line| selection_rect_for_line(paragraph, line, start, end))
.collect()
}
fn selection_rect_for_line<P: Paragraph>(
paragraph: &P,
line: Rectangle,
start: usize,
end: usize,
) -> Option<Rectangle> {
let y = line.center_y();
let paragraph_width = paragraph.bounds().width.max(line.x + line.width + 1.0);
let left_probe = line.x.max(0.0);
let right_probe = (line.x + line.width + 1.0).min(paragraph_width.max(1.0));
let line_start = paragraph
.hit_test(Point::new(left_probe, y))
.map(advanced_text::Hit::cursor)?;
let line_end = paragraph
.hit_test(Point::new(right_probe, y))
.map(advanced_text::Hit::cursor)
.unwrap_or(line_start);
let (line_start, line_end) = if line_start <= line_end {
(line_start, line_end)
} else {
(line_end, line_start)
};
let overlap_start = start.max(line_start);
let overlap_end = end.min(line_end);
if overlap_start >= overlap_end {
return None;
}
let x_start = if overlap_start <= line_start {
line.x
} else {
x_for_offset(paragraph, y, overlap_start, line.x, line.x + line.width)
};
let x_end = if overlap_end >= line_end {
line.x + line.width
} else {
x_for_offset(paragraph, y, overlap_end, line.x, line.x + line.width)
};
let left = x_start.min(x_end);
let right = x_start.max(x_end);
(right > left).then(|| {
Rectangle::new(
Point::new(left, line.y),
Size::new(right - left, line.height),
)
})
}
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);
for _ in 0..HIT_SEARCH_STEPS {
let mid = (low + high) / 2.0;
let hit = paragraph
.hit_test(Point::new(mid, y))
.map(advanced_text::Hit::cursor);
match hit {
Some(hit) if hit < offset => low = mid,
Some(_) => high = mid,
None => break,
}
}
high
}
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 {
for bounds in paragraph.span_bounds(span) {
if bounds.width <= 0.0 || bounds.height <= 0.0 {
continue;
}
if let Some(line) = lines
.iter_mut()
.find(|line| (line.center_y() - bounds.center_y()).abs() < 1.0)
{
*line = union(*line, bounds);
} else {
lines.push(bounds);
}
}
}
lines.sort_by(|a, b| a.y.total_cmp(&b.y));
lines
}
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
let left = a.x.min(b.x);
let top = a.y.min(b.y);
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))
}
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
cursor.position_from(bounds.position()).map(|position| {
Point::new(
position.x.clamp(0.0, bounds.width.max(1.0) - 1.0),
position.y.clamp(0.0, bounds.height.max(1.0) - 1.0),
)
})
}
fn point_distance(a: Point, b: Point) -> f32 {
let dx = a.x - b.x;
let dy = a.y - b.y;
(dx * dx + dy * dy).sqrt()
}
impl<'a, Link, Message, Theme, Renderer>
From<SelectableRichText<'a, Link, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Link: Clone + 'a,
Theme: Catalog + 'a,
Renderer: advanced_text::Renderer + 'a,
{
fn from(
text: SelectableRichText<'a, Link, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_substring_returns_middle_and_none_for_collapsed() {
assert_eq!(selected_substring("abcdef", 2, 5), Some("cde".to_owned()));
assert_eq!(selected_substring("abcdef", 3, 3), None);
}
#[test]
fn selected_substring_handles_reversed_range() {
assert_eq!(selected_substring("abcdef", 5, 2), Some("cde".to_owned()));
}
#[test]
fn select_all_uses_byte_length() {
assert_eq!(select_all(""), (0, 0));
assert_eq!(select_all("aé👍z"), (0, "aé👍z".len()));
}
#[test]
fn unicode_selection_uses_byte_offsets_without_panicking() {
let text = "aé👍z";
assert_eq!(selected_substring(text, 1, 7), Some("é👍".to_owned()));
assert_eq!(selected_substring(text, 3, 7), Some("👍".to_owned()));
assert_eq!(selected_substring(text, 2, 7), Some("é👍".to_owned()));
}
}
+148
View File
@@ -0,0 +1,148 @@
//! End-to-end loopback test for the chat file-transfer plane (`FILES_ALPN`).
//!
//! Spins up two real iroh endpoints on localhost (relay disabled, addresses
//! exchanged directly), registers the production [`FileRouter`] on each, serves a
//! multi-megabyte blob on one side, and fetches it from the other through the
//! real `serve_attachment`/`fetch_attachment` path.
//!
//! This is the regression guard for the "file fetch: read failed: connection
//! lost" bug: the serve handler used to return (and drop the connection) the
//! instant it called `finish()`, so the CONNECTION_CLOSE raced ahead of the
//! still-in-flight stream data and the fetcher's `read_to_end` aborted. A blob
//! large enough to span many packets makes that race deterministic — the fix
//! (waiting on `connection.closed()` before returning) keeps the link up until
//! the fetcher has the bytes.
use std::sync::Arc;
use std::time::Duration;
use iroh::address_lookup::memory::MemoryLookup;
use iroh::endpoint::presets;
use iroh::protocol::Router;
use iroh::{Endpoint, RelayMode};
use peerspeak::files::{AttachmentKind, ChatAttachment};
use peerspeak::network::NetworkTransport;
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
use peerspeak::protocol::FILES_ALPN;
struct Node {
endpoint: Endpoint,
transport: Arc<IrohTransport>,
_router: Router,
lookup: MemoryLookup,
}
async fn spawn_node() -> Node {
let lookup = MemoryLookup::new();
let endpoint = Endpoint::builder(presets::Minimal)
.secret_key(iroh::SecretKey::generate())
.relay_mode(RelayMode::Disabled)
.address_lookup(lookup.clone())
.bind()
.await
.expect("bind endpoint");
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
// Mirror production: a persistent FileRouter bound to this session's transport
// is what the router accepts inbound file fetches on.
let file_router = FileRouter::new();
file_router.bind(&transport);
let router = Router::builder(endpoint.clone())
.accept(FILES_ALPN, file_router)
.spawn();
Node {
endpoint,
transport,
_router: router,
lookup,
}
}
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
/// premature connection close on the serve side reliably corrupts/aborts the read.
fn big_blob() -> Vec<u8> {
(0..(2 * 1024 * 1024u32))
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
.collect()
}
#[tokio::test]
async fn loopback_attachment_round_trips_intact() {
let server = spawn_node().await;
let client = spawn_node().await;
// Seed each side with the other's full address so direct dialing works.
server.lookup.add_endpoint_info(client.endpoint.addr());
client.lookup.add_endpoint_info(server.endpoint.addr());
let server_id = server.endpoint.id();
let client_id = client.endpoint.id();
// The serve handler gates on room membership (the audio admission roster), so
// the server must admit the client before it will answer the fetch.
server.transport.admit_audio_sender(client_id);
client.transport.admit_audio_sender(server_id);
// The fetcher dials the retained full address; seed it so fetch_attachment
// doesn't have to fall back to a bare-id lookup.
client.transport.connect_peer(server.endpoint.addr()).await;
let blob = big_blob();
let id = [42u8; 32];
server
.transport
.serve_attachment(id, Arc::new(blob.clone()));
let att = ChatAttachment {
name: "exterior-landscape.jpg".to_string(),
size: blob.len() as u64,
kind: AttachmentKind::Image,
id,
};
let fetched = tokio::time::timeout(
Duration::from_secs(30),
client.transport.fetch_attachment(server_id, &att),
)
.await
.expect("fetch did not time out")
.expect("fetch succeeded");
assert_eq!(fetched.len(), blob.len(), "fetched the full blob");
assert_eq!(fetched, blob, "fetched bytes match served bytes exactly");
}
#[tokio::test]
async fn loopback_unknown_id_reports_gone() {
let server = spawn_node().await;
let client = spawn_node().await;
server.lookup.add_endpoint_info(client.endpoint.addr());
client.lookup.add_endpoint_info(server.endpoint.addr());
let server_id = server.endpoint.id();
let client_id = client.endpoint.id();
server.transport.admit_audio_sender(client_id);
client.transport.admit_audio_sender(server_id);
client.transport.connect_peer(server.endpoint.addr()).await;
// Never served — the handler closes with an empty body and the fetcher must
// surface that as an error, not hang or return empty bytes.
let att = ChatAttachment {
name: "missing.bin".to_string(),
size: 4096,
kind: AttachmentKind::File,
id: [7u8; 32],
};
let result = tokio::time::timeout(
Duration::from_secs(30),
client.transport.fetch_attachment(server_id, &att),
)
.await
.expect("fetch did not time out");
assert!(result.is_err(), "unknown id should error, got {result:?}");
}
+2
View File
@@ -63,6 +63,8 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
addr,
sharing: None,
avatar: Default::default(),
game: None,
music: None,
}
}
+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"