Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e043810eb0 | ||
|
|
5b80a1a010 | ||
|
|
b9803f93fb | ||
|
|
3df2378831 | ||
|
|
81c230a09c | ||
|
|
be3740f5f9 | ||
|
|
0d836d14c2 | ||
|
|
76c1a13e11 | ||
|
|
aa0515af1c | ||
|
|
9f06741b99 | ||
|
|
3aa768af52 | ||
|
|
1cfa932fbe | ||
|
|
d8b8fd79cf | ||
|
|
92a64465a4 | ||
|
|
6ba763774d | ||
|
|
692ad677d2 | ||
|
|
bf908adbf0 | ||
|
|
b68fca689e | ||
|
|
c82ef07464 | ||
|
|
9eab6c118d | ||
|
|
21ba633825 | ||
|
|
45b1b97dd8 | ||
|
|
ae2e9de523 | ||
|
|
985c63806b | ||
|
|
6fc55a286d | ||
|
|
d63db68318 | ||
|
|
e7923a1b5c | ||
|
|
b5569fe2c6 | ||
|
|
503f78153b | ||
|
|
d40385f85c | ||
|
|
bcf1343a55 | ||
|
|
6773a3882b | ||
|
|
1cd19b355f | ||
|
|
297f4397a7 | ||
|
|
283d938b79 | ||
|
|
fd72e6f018 | ||
|
|
8768cd242c | ||
|
|
da72541e18 | ||
|
|
8610ab2eb6 | ||
|
|
100117085d | ||
|
|
cab6bafce5 | ||
|
|
10203e1edb | ||
|
|
88ad5a0807 | ||
|
|
0588d92537 | ||
|
|
b4a4c00711 | ||
|
|
8c4f4a0b8b | ||
|
|
76c4f68bb3 | ||
|
|
c427231858 | ||
|
|
4bfc18463b | ||
|
|
26d66007de | ||
|
|
3d7b01c8a2 | ||
|
|
5f4eba1815 | ||
|
|
77d2bf2992 | ||
|
|
c8d0053431 | ||
|
|
1d038be9a0 | ||
|
|
554b613466 | ||
|
|
8898652349 | ||
|
|
5927148ee4 | ||
|
|
93f4954653 | ||
|
|
e6eb490939 | ||
|
|
e724167b03 | ||
|
|
7b9cb57003 | ||
|
|
af7a42a049 | ||
|
|
e78e7bc2a5 | ||
|
|
52ab374b74 | ||
|
|
8825707c17 | ||
|
|
76c62e5ac3 | ||
|
|
d2432740c1 | ||
|
|
99a4a336ad | ||
|
|
df45c0bfeb | ||
|
|
faad8ce26a | ||
|
|
e378b2e33b | ||
|
|
96e41de1b1 | ||
|
|
5c888f8357 | ||
|
|
074f004227 | ||
|
|
2d22036930 | ||
|
|
8014edf91c | ||
|
|
89d5218d25 | ||
|
|
f3c7aa7050 | ||
|
|
39b5dafd57 | ||
|
|
a78860db15 | ||
|
|
5f52aa1506 | ||
|
|
d92d0f6f6b | ||
|
|
551767f9f5 | ||
|
|
fa90cd3ce9 | ||
|
|
660261a9a5 | ||
|
|
3a74fd0230 | ||
|
|
2dbb1ea316 | ||
|
|
c902db2e90 | ||
|
|
83e5881768 | ||
|
|
8424b44dec | ||
|
|
33e49a8ca7 | ||
|
|
393c1c7f09 | ||
|
|
1bf14ba08f | ||
|
|
6f14d2668d | ||
|
|
49c3ce8c0a | ||
|
|
aec21a48d5 | ||
|
|
d0a16cb8b9 | ||
|
|
e0325d4590 | ||
|
|
e8a894be49 | ||
|
|
8c33b5c70f | ||
|
|
10bd15aeaa | ||
|
|
8ad0bea19d | ||
|
|
abb53af559 | ||
|
|
c0c1969332 | ||
|
|
d155091eed | ||
|
|
b553a94875 | ||
|
|
0aaf6be529 | ||
|
|
d059386aee | ||
|
|
79a091b1b3 | ||
|
|
c1de7efbc5 | ||
|
|
96e3e0ba10 | ||
|
|
bf4d9b100f | ||
|
|
91ef5b0a72 | ||
|
|
47be7c340d | ||
|
|
1c8c37b248 | ||
|
|
08792809d6 | ||
|
|
9ff7c7b99c | ||
|
|
3ff0945866 | ||
|
|
618a53027d | ||
|
|
f293181626 | ||
|
|
bca2ccd6a4 | ||
|
|
6054f0ecf9 | ||
|
|
c2e27c3367 | ||
|
|
3c678afaf7 | ||
|
|
8cfb5beff9 | ||
|
|
f21a027e78 | ||
|
|
663956deaa | ||
|
|
abc8531cfa | ||
|
|
f0961a2049 | ||
|
|
32ee00178e | ||
|
|
7d9ffbd3a4 | ||
|
|
01150ff249 | ||
|
|
a6d9a8cbd4 | ||
|
|
a4bb6ce0be | ||
|
|
ebfc39de46 | ||
|
|
e3ff778d5b | ||
|
|
9a059e1bb8 | ||
|
|
4dc1bcd546 | ||
|
|
067997f9ba | ||
|
|
660eb27a84 | ||
|
|
913b0b6b20 | ||
|
|
36fb8bfa9a | ||
|
|
2e9164745f | ||
|
|
3b640726d7 | ||
|
|
381e00bc0e | ||
|
|
1a3c481f4c | ||
|
|
f927567105 | ||
|
|
5c11947bd7 | ||
|
|
7349744d16 | ||
|
|
a6a88d15c0 | ||
|
|
a17b930524 | ||
|
|
6100abef33 | ||
|
|
49bd2ba687 | ||
|
|
6b0b23ef69 | ||
|
|
f422150c84 | ||
|
|
86d333d4dc | ||
|
|
fad65a4fcf | ||
|
|
3878e716dd | ||
|
|
961705ffa9 | ||
|
|
7d44808a5e | ||
|
|
e31d3db986 | ||
|
|
87a2209a85 | ||
|
|
9e8c8b4ace | ||
|
|
7e4f2f2127 | ||
|
|
b6eac330ca | ||
|
|
7fb1c96ca9 | ||
|
|
80a5b73e39 | ||
|
|
adf7d1c0e2 | ||
|
|
bcb597a0ea | ||
|
|
79b24fd567 | ||
|
|
efadc228eb | ||
|
|
2d067a2e41 | ||
|
|
8ea40f719c | ||
|
|
60c1951567 | ||
|
|
f75760b14e | ||
|
|
02cb46550e | ||
|
|
cbba4b644e | ||
|
|
c5375e200a | ||
|
|
06e97b9f50 | ||
|
|
601ec92181 | ||
|
|
713526b2a8 | ||
|
|
450121b591 | ||
|
|
eab9357f23 | ||
|
|
57f21a0edf | ||
|
|
70a0e6798f | ||
|
|
a30d9d5dbf | ||
|
|
2a6e6401ad | ||
|
|
a0a5922389 | ||
|
|
2c93c1c24f | ||
|
|
5564af02f9 | ||
|
|
ae29d1fea2 | ||
|
|
7ff7766ede | ||
|
|
b0fdd4e058 | ||
|
|
306bc295b1 | ||
|
|
8e0b4c16ec | ||
|
|
f52b5ea64e | ||
|
|
4d07e03395 | ||
|
|
20bfcffe6d | ||
|
|
185d47aa8d | ||
|
|
2eae95ede0 | ||
|
|
fdd532de53 | ||
|
|
46809153d8 | ||
|
|
6ccad0d37a | ||
|
|
ddb3d2aabc | ||
|
|
bbbe2d8f17 | ||
|
|
63b45e03ab | ||
|
|
2937e5191a | ||
|
|
47c58047ce | ||
|
|
85b12a26c9 | ||
|
|
e4767be210 | ||
|
|
10ee765ffd | ||
|
|
3034c42f71 | ||
|
|
465c7ba2b0 | ||
|
|
3ec09de87e | ||
|
|
4b8fb92dc5 | ||
|
|
1adf8a97bb | ||
|
|
10707152a3 | ||
|
|
f2e72624f7 | ||
|
|
319d0c5e29 | ||
|
|
d56c2c90b2 | ||
|
|
5086e86bd2 | ||
|
|
54780fa73b | ||
|
|
b1aa751a84 | ||
|
|
9efab491c7 | ||
|
|
f3f399a748 | ||
|
|
1afdccbefe | ||
|
|
7724da73b8 | ||
|
|
92c9d585b8 | ||
|
|
33e3998e7c | ||
|
|
44bad7b70b | ||
|
|
20643a24de |
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
# cargo-audit configuration. Keep the ignore list in sync with deny.toml,
|
||||
# which carries the full justification for each entry.
|
||||
[advisories]
|
||||
ignore = [
|
||||
# quick-xml DoS advisories: build-time only, reached solely via the
|
||||
# wayland-scanner proc-macro parsing trusted vendored protocol XML.
|
||||
# Fix (0.41.0) is semver-incompatible with wayland-scanner's `^0.39`;
|
||||
# drop once wayland-scanner bumps. See deny.toml.
|
||||
"RUSTSEC-2026-0194",
|
||||
"RUSTSEC-2026-0195",
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
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)
|
||||
# --locked so the pinned, vetted versions in Cargo.lock are exactly
|
||||
# what get audited (the lockfile-as-review-checkpoint model).
|
||||
run: cargo deny --locked check
|
||||
|
||||
- name: cargo-audit
|
||||
run: cargo audit
|
||||
@@ -0,0 +1,89 @@
|
||||
name: windows-build
|
||||
|
||||
# Milestone M1 of the Windows port (see docs/handoff windows-migration-plan):
|
||||
# prove the tree compiles for `x86_64-pc-windows-msvc` and the unit tests pass.
|
||||
# The audio backend is the Phase 0 `CpalBackend` stub for now — this job guards
|
||||
# the *compile* boundary (cfg gating, platform deps, the PlatformAudioBackend
|
||||
# alias) so a Unix-only assumption can't sneak back in and break Windows.
|
||||
#
|
||||
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
|
||||
# `windows-latest` label (a Linux-container approach does NOT apply here —
|
||||
# Windows jobs run on the host, not a Linux container). If your runner
|
||||
# advertises a different label, change `runs-on` below.
|
||||
#
|
||||
# MANUAL-ONLY until that runner exists: with push/PR triggers enabled, every
|
||||
# push queued a run no runner could claim and Gitea auto-cancelled it ~24h
|
||||
# later, littering the Actions page with cancelled runs. Restore the push/PR
|
||||
# triggers when a Windows runner is registered:
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# branches: [main, "windows-port-**"]
|
||||
# pull_request:
|
||||
# workflow_dispatch:
|
||||
#
|
||||
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
|
||||
# peerspeak-windows-opus-spike.md):
|
||||
# - MSVC C toolchain (Visual Studio Build Tools) — to compile vendored libopus.
|
||||
# - CMake on PATH — `audiopus_sys` builds libopus from source via cmake.
|
||||
# - CMAKE_POLICY_VERSION_MINIMUM=3.5 (set below) — the vendored libopus declares
|
||||
# an ancient `cmake_minimum_required` that CMake >= 4.0 refuses without it.
|
||||
# GitHub-hosted `windows-latest` images ship MSVC + CMake; a self-hosted runner
|
||||
# must provide both.
|
||||
|
||||
on:
|
||||
# Manual runs from the Gitea Actions UI only — see the header comment.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# The vendored libopus (audiopus_sys -> cmake) uses cmake_minimum_required < 3.5,
|
||||
# which CMake 4.x rejects unless this is set. See the opus spike report.
|
||||
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
|
||||
|
||||
jobs:
|
||||
windows-build:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust (MSVC, pinned to repo toolchain if present)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
components: clippy
|
||||
|
||||
- name: Show toolchain + build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rustc --version
|
||||
cargo --version
|
||||
# libopus is built from source via cmake; fail early with a clear
|
||||
# message if the runner lacks it rather than deep in the opus build.
|
||||
if ! command -v cmake >/dev/null 2>&1; then
|
||||
echo "::error::cmake not found on PATH. The opus crate builds libopus from source via cmake; install CMake on this runner."
|
||||
exit 1
|
||||
fi
|
||||
cmake --version
|
||||
|
||||
# Build on a *locked* tree so the pinned, vetted Cargo.lock versions are what
|
||||
# get compiled — same supply-chain stance as the cargo-deny job.
|
||||
- name: Build (all targets, msvc)
|
||||
run: cargo build --all-targets --locked --target x86_64-pc-windows-msvc
|
||||
|
||||
# Unit (lib) tests only: the `transport_loopback` integration tests stand up
|
||||
# real iroh/QUIC endpoints and need working loopback networking, which isn't
|
||||
# guaranteed on a CI runner. Add `--tests` here once a networked Windows
|
||||
# runner is confirmed.
|
||||
- name: Unit tests (lib, msvc)
|
||||
run: cargo test --lib --locked --target x86_64-pc-windows-msvc
|
||||
|
||||
# Informational for now (not `-D warnings`): the Windows tree may surface
|
||||
# platform-specific lints we haven't triaged. Tighten to deny-warnings once
|
||||
# it's clean.
|
||||
- name: Clippy (msvc)
|
||||
run: cargo clippy --all-targets --locked --target x86_64-pc-windows-msvc
|
||||
@@ -6,3 +6,8 @@
|
||||
/packaging/peerspeak/
|
||||
/packaging/*.pkg.tar.*
|
||||
/packaging/*.log
|
||||
|
||||
# Windows installer build artifacts (the staged exe + compiled setup.exe);
|
||||
# the .iss script and .ico are the tracked sources.
|
||||
/packaging/windows/peerspeak.exe
|
||||
/packaging/windows/output/
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.6] — 2026-07-19
|
||||
|
||||
### Fixed
|
||||
- **A screen share that falls behind now catches back up.** On a lossy
|
||||
connection (satellite links are the worst case) the share could settle several
|
||||
seconds behind the host and simply stay there for the rest of the call. The
|
||||
viewer now notices a deep buffer and plays imperceptibly fast until it is back
|
||||
at the live edge — the audio stays in tune and in sync while it does. This
|
||||
replaces the previous attempt at the problem, which measurement showed did not
|
||||
help. Applies to the Low latency setting; Smooth intentionally keeps its
|
||||
larger buffer.
|
||||
|
||||
### Changed
|
||||
- **Low latency now keeps a tighter viewer buffer.** The screen-share cache
|
||||
setting is a size in megabytes, which at a given bitrate quietly decides how
|
||||
many *seconds* behind a viewer can drift — a 2 MB buffer turned out to hold
|
||||
about six seconds of a typical share. Low latency now caps that buffer at 1 MB
|
||||
regardless of the setting, which halved how far behind a share fell on a bad
|
||||
connection before anything else kicked in. Smooth still honors the value you
|
||||
choose, since a deep buffer is the point of that mode.
|
||||
|
||||
## [0.6.5] — 2026-07-19
|
||||
|
||||
### Added
|
||||
- **Chat message sounds.** Successful outgoing messages and admitted incoming
|
||||
messages now have distinct notification chimes, each with its own enable
|
||||
toggle and optional custom WAV path in Notifications settings.
|
||||
- **Contact presence sounds.** The home-screen contacts list now announces a
|
||||
contact becoming online or offline. Initial online contacts are announced;
|
||||
initial offline results stay silent. Both events have independent toggles and
|
||||
optional custom WAV paths.
|
||||
- **Notification sound browser.** Every notification event now has a native
|
||||
Browse button for choosing a custom WAV instead of typing its path manually.
|
||||
|
||||
### Changed
|
||||
- **Tidier per-participant audio controls.** The equalizer bands and noise gate
|
||||
for each participant now live behind an **"Advanced audio"** foldout instead
|
||||
of being expanded all the time, so a call with several people no longer fills
|
||||
the panel with sliders. The controls themselves are unchanged.
|
||||
|
||||
### Fixed
|
||||
- **Low-latency screen sharing stays near the live edge again.** mpv's
|
||||
timestamp pacing could let stale frames accumulate across the reliable
|
||||
PixelPass transport until a share was 7–10 seconds behind. Low-latency mode
|
||||
now presents decoded frames immediately; Smooth mode retains timestamp pacing
|
||||
when keeping shared-video audio and video synchronized matters more.
|
||||
|
||||
## [0.6.4] — 2026-07-18
|
||||
|
||||
### Added
|
||||
- **Chat now tells you when a message didn't send.** A message that couldn't go
|
||||
out — because you weren't in a room, or the broadcast failed — is marked
|
||||
**"⚠ Not sent"** with a **Retry** button, instead of sitting in the transcript
|
||||
looking delivered. A successful send shows nothing (PeerSpeak has no
|
||||
delivery/read receipts, so anything else would be a false promise).
|
||||
- **Fast typing no longer loses messages.** When you fire off a quick burst,
|
||||
messages past the first few are held as **"queued…"** and sent a moment apart,
|
||||
matching the rate other people's clients accept. Previously a fast burst could
|
||||
look sent on your end while some messages silently never reached the room.
|
||||
|
||||
### Changed
|
||||
- **Tidier music playlist drawer.** The slide-out playlist no longer repeats the
|
||||
play/skip controls already on the player bar, and the track list now grows to
|
||||
fill the drawer instead of being boxed into a short scroll area, so you can see
|
||||
more of your playlist at once.
|
||||
- **Safer chat under the hood.** A round of chat hardening tightened how incoming
|
||||
messages, display names, links, and file/image attachments are validated and
|
||||
bounded, so a malformed or hostile message from a peer can't spoof a name,
|
||||
replay, flood, or run the app out of memory. No change to how normal chat looks
|
||||
or works.
|
||||
|
||||
### Fixed
|
||||
- **Burst packet loss no longer splices the wrong audio into the gap.** Loss
|
||||
concealment used Opus in-band FEC even when the next packet to arrive wasn't
|
||||
the one immediately after the gap, so losing several packets in a row could
|
||||
briefly play a later frame's audio in the wrong position. FEC now only
|
||||
reconstructs a gap from its immediate successor packet; larger gaps are
|
||||
concealed normally.
|
||||
- **A failed network restart no longer silently kills the app.** Changing the
|
||||
network mode (or regenerating your identity) rebuilds the connection stack;
|
||||
if that rebuild failed — rare, but possible when the local socket can't
|
||||
bind — PeerSpeak kept its window open but silently stopped responding to
|
||||
every command. It now falls back to your previous network settings and says
|
||||
so, and only gives up (with a clear error telling you to restart) if even
|
||||
the fallback fails.
|
||||
|
||||
[0.6.4]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.4
|
||||
|
||||
## [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`.
|
||||
@@ -105,6 +105,40 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android-activity"
|
||||
version = "0.6.1"
|
||||
@@ -114,12 +148,12 @@ dependencies = [
|
||||
"android-properties",
|
||||
"bitflags 2.11.1",
|
||||
"cc",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
@@ -166,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"
|
||||
@@ -712,6 +746,12 @@ dependencies = [
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cexpr"
|
||||
version = "0.6.0"
|
||||
@@ -1002,6 +1042,40 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation-sys",
|
||||
"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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmic-text"
|
||||
version = "0.15.0"
|
||||
@@ -1026,6 +1100,59 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa 0.9.1",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs 0.11.3",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2 0.4.3",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"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"
|
||||
@@ -1080,9 +1207,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.18"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
@@ -1228,6 +1355,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -1487,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"
|
||||
@@ -1622,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"
|
||||
@@ -2227,7 +2375,7 @@ dependencies = [
|
||||
"http",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"rand 0.10.1",
|
||||
"rustls",
|
||||
"thiserror 2.0.18",
|
||||
@@ -2247,7 +2395,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"idna",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"once_cell",
|
||||
"prefix-trie",
|
||||
"rand 0.10.1",
|
||||
@@ -2270,7 +2418,7 @@ dependencies = [
|
||||
"hickory-proto",
|
||||
"ipconfig",
|
||||
"ipnet",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"moka",
|
||||
"ndk-context",
|
||||
"once_cell",
|
||||
@@ -2480,6 +2628,7 @@ dependencies = [
|
||||
"iced_core",
|
||||
"log",
|
||||
"rustc-hash 2.1.2",
|
||||
"tokio",
|
||||
"wasm-bindgen-futures",
|
||||
"wasmtimer",
|
||||
]
|
||||
@@ -3102,6 +3251,22 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
@@ -3463,6 +3628,24 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
||||
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"
|
||||
@@ -3499,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",
|
||||
]
|
||||
@@ -3596,7 +3779,7 @@ dependencies = [
|
||||
"dispatch",
|
||||
"futures-channel",
|
||||
"futures-lite",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"ndk-context",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -3695,6 +3878,20 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys 0.5.0+25.2.9519653",
|
||||
"num_enum",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3704,7 +3901,7 @@ dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"num_enum",
|
||||
"raw-window-handle",
|
||||
"thiserror 1.0.69",
|
||||
@@ -3716,6 +3913,15 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.5.0+25.2.9519653"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
|
||||
dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-sys"
|
||||
version = "0.6.0+11769913"
|
||||
@@ -4127,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"
|
||||
@@ -4162,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"
|
||||
@@ -4466,6 +4720,29 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
|
||||
dependencies = [
|
||||
"jni 0.21.1",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"oboe-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oboe-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -4594,27 +4871,32 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.1.0"
|
||||
version = "0.6.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"cpal 0.15.3",
|
||||
"dirs",
|
||||
"iced",
|
||||
"image",
|
||||
"iroh",
|
||||
"iroh-gossip",
|
||||
"libc",
|
||||
"opus",
|
||||
"pipewire",
|
||||
"rand 0.10.1",
|
||||
"rfd",
|
||||
"ringbuf",
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"url",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5049,6 +5331,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"
|
||||
@@ -5338,12 +5630,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"
|
||||
@@ -5436,7 +5750,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
@@ -5877,7 +6191,7 @@ dependencies = [
|
||||
"fastrand",
|
||||
"js-sys",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
@@ -6007,6 +6321,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"
|
||||
@@ -7162,7 +7623,7 @@ dependencies = [
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
"ndk-sys",
|
||||
"ndk-sys 0.6.0+11769913",
|
||||
"objc",
|
||||
"once_cell",
|
||||
"ordered-float",
|
||||
@@ -7247,6 +7708,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
|
||||
dependencies = [
|
||||
"windows-core 0.54.0",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.58.0"
|
||||
@@ -7254,7 +7725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||
dependencies = [
|
||||
"windows-core 0.58.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7278,6 +7749,16 @@ dependencies = [
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.54.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
|
||||
dependencies = [
|
||||
"windows-result 0.1.2",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.58.0"
|
||||
@@ -7288,7 +7769,7 @@ dependencies = [
|
||||
"windows-interface 0.58.0",
|
||||
"windows-result 0.2.0",
|
||||
"windows-strings 0.1.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7386,13 +7867,22 @@ dependencies = [
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7411,7 +7901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||
dependencies = [
|
||||
"windows-result 0.2.0",
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7423,13 +7913,22 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7441,20 +7940,35 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7466,18 +7980,36 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -7490,24 +8022,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -7536,7 +8092,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"ndk",
|
||||
"ndk 0.9.0",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-foundation 0.2.2",
|
||||
|
||||
@@ -1,7 +1,41 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.1.0"
|
||||
version = "0.6.6"
|
||||
edition = "2024"
|
||||
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"
|
||||
@@ -25,23 +59,60 @@ 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) and a native file picker (xdg-portal backend, no GTK).
|
||||
# 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).
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
iroh = "1.0.0-rc.0"
|
||||
iroh-gossip = "0.99.0"
|
||||
opus = "0.3.1"
|
||||
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
|
||||
# the playback RT callback to fill exactly what the device asks for instead of
|
||||
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
|
||||
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
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"
|
||||
tokio = { version = "1.52.3", features = ["full"] }
|
||||
tokio-stream = "0.1.18"
|
||||
# Chat link policy: parse + validate clickable URL candidates (scheme/host/
|
||||
# userinfo checks in `sanitize::is_safe_web_url`). Already in the tree
|
||||
# transitively via iroh — this only promotes it to a direct dependency.
|
||||
url = "2.5"
|
||||
|
||||
# --- Platform-specific dependencies -----------------------------------------
|
||||
# Audio and the native file-picker backends differ per OS. Everything else in the
|
||||
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
|
||||
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
|
||||
# quantum), used by the playback RT callback to fill exactly what the device asks
|
||||
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
|
||||
# The field has existed in libpipewire since 0.3.49 (2022).
|
||||
pipewire = { version = "0.9", features = ["v0_3_49"] }
|
||||
# Native file picker via the XDG desktop portal (no GTK) on Linux.
|
||||
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
# Native file picker using the built-in Win32 dialog backend on Windows.
|
||||
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",
|
||||
] }
|
||||
|
||||
# Unix-only. Used for exactly one thing: sending SIGINT to our own
|
||||
# pixelpass child so it can run its cleanup before we resort to SIGKILL
|
||||
# (src/core/teardown.rs). Already in the tree via alsa/cpal/tokio, so
|
||||
# declaring it directly adds no new code to the build.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2.186"
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Settings |
|
||||
|---|
|
||||
|  |
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,51 @@
|
||||
# Security Review: `security-scan` branch (PeerSpeak)
|
||||
|
||||
_Date: 2026-06-18_
|
||||
|
||||
**Scope:** Protocol-versioning migration (`src/protocol.rs`, `versioned_topic`,
|
||||
ALPN/domain centralization, gossip topic namespacing) and the `deny.toml`
|
||||
supply-chain policy addition.
|
||||
|
||||
## Result: No high-confidence security vulnerabilities found.
|
||||
|
||||
Each plausible attack surface introduced by this branch was investigated and
|
||||
confirmed safe:
|
||||
|
||||
### 1. `versioned_topic` XOR transform — topic secrecy preserved
|
||||
`src/protocol.rs:46`, used at `src/network/gossip.rs:255`
|
||||
|
||||
The room `topic_id` is a uniformly random 32-byte secret (`rand::random()`,
|
||||
`src/core/mod.rs:1012`) acting as the room capability. XOR-ing it with the public
|
||||
constant `GOSSIP_PROTO.to_le_bytes()` cyclically is **bijective and
|
||||
entropy-preserving** — the result is still uniformly random; no byte becomes
|
||||
predictable and no entropy is lost. The room secret is no more recoverable by an
|
||||
observer than before the change (previously the raw `topic_id` was the on-wire
|
||||
topic; now it's a trivial public XOR of it). Bijectivity also preserves room
|
||||
distinctness, so isolation is not weakened. **Not a vulnerability.**
|
||||
|
||||
### 2. Signature topic-binding — no raw/versioned confusion
|
||||
`src/network/gossip.rs`
|
||||
|
||||
`active_topic_bytes` stores the **raw** `ticket.topic_id` (line 293), and both
|
||||
`sign_gossip` and `verify_gossip` bind against that raw value. Only the
|
||||
*subscribed* swarm topic (line 255) uses the versioned value. There is one swarm
|
||||
per join and every peer signs/verifies against the same raw topic, so no second
|
||||
topic exists to enable a raw↔versioned replay/confusion attack. Code matches
|
||||
VERSIONING.md's claim. **Not a vulnerability.**
|
||||
|
||||
### 3. `GOSSIP_SIG_DOMAIN` — moved verbatim
|
||||
Value identical (`"peerspeak-gossip-v1"`, `src/protocol.rs:34`); cross-version
|
||||
cryptographic domain separation preserved. **Not a vulnerability.**
|
||||
|
||||
### 4. ALPN changes — handshake compatibility only
|
||||
Audio `peerspeak-audio` → `peerspeak/audio/1`, friends `/0` → `/1`. No security
|
||||
check keys off the old ALPN strings (audio admission is gated by live room
|
||||
membership per S8, not the ALPN literal); no residual references to old strings
|
||||
in non-test code. **Not a vulnerability.**
|
||||
|
||||
### 5. `deny.toml`
|
||||
Ignores only two *unmaintained* advisories (`RUSTSEC-2024-0436`,
|
||||
`RUSTSEC-2026-0150`) on compile-time/FFI-only crates — documented, and dependency
|
||||
advisories are out of scope. **Not a vulnerability.**
|
||||
|
||||
The versioning migration is a clean, security-preserving change.
|
||||
@@ -0,0 +1,139 @@
|
||||
# PeerSpeak Versioning Standard
|
||||
|
||||
PeerSpeak is a full-mesh P2P voice app. Its "API contract" is not a library
|
||||
surface — it is the **wire protocol** two nodes use to talk. So versioning here
|
||||
tracks one question above all others:
|
||||
|
||||
> **Can a node on build X talk to a node on build Y?**
|
||||
|
||||
There are two distinct version layers. Keep them straight.
|
||||
|
||||
---
|
||||
|
||||
## Layer 1 — Release version (`Cargo.toml`)
|
||||
|
||||
The human-facing label you put on a build ("install this one").
|
||||
|
||||
**Scheme: SemVer, pre-1.0 (`0.MINOR.PATCH`).**
|
||||
|
||||
While we are pre-1.0 (friends-only, no stability promise yet):
|
||||
|
||||
| Change | Bump | Example |
|
||||
| --- | --- | --- |
|
||||
| **Breaking wire/protocol change** — peers on the old build can no longer interoperate; *everyone must update* | **MINOR** | `0.4.2 → 0.5.0` |
|
||||
| Compatible change — bug fix, internal refactor, or a feature that does **not** change the wire (UI, local-only behavior, additive logic that old peers ignore safely) | **PATCH** | `0.4.2 → 0.4.3` |
|
||||
|
||||
- **Reaching `1.0.0`:** when PeerSpeak is first shared beyond the trusted-friends
|
||||
circle (a "public" release), and we are willing to commit to wire stability.
|
||||
After 1.0, MAJOR = wire break, MINOR = compatible feature, PATCH = fix (normal
|
||||
SemVer).
|
||||
- Bump `version` in `Cargo.toml` as part of the change that warrants it, in the
|
||||
same commit. The number in `Cargo.toml` is the source of truth; surface it in
|
||||
the UI (e.g. an About/Settings line) so a user can read their build.
|
||||
|
||||
**Rule of thumb:** if you find yourself writing "all peers must rebuild" or
|
||||
"breaking gossip wire change" in a commit message (as S2 and W4 did), that is a
|
||||
**MINOR** bump, and it must also bump the relevant protocol version in Layer 2.
|
||||
|
||||
---
|
||||
|
||||
## Layer 2 — Protocol compatibility (the one that actually breaks calls)
|
||||
|
||||
Wire incompatibility must **fail fast and legibly** — never as a silent
|
||||
signature/decode error that looks like a bug or an attack. We achieve this by
|
||||
embedding a protocol version into each transport plane, so incompatible peers
|
||||
are rejected at connect/subscribe time instead of mid-conversation.
|
||||
|
||||
PeerSpeak has **three independent planes**, each versioned **separately** — bump
|
||||
only the plane whose wire format actually changed (audio rarely changes; gossip
|
||||
changes often; they must not be forced to bump together).
|
||||
|
||||
### ALPN naming convention
|
||||
|
||||
All peerspeak ALPNs use the form **`peerspeak/<plane>/<N>`** where `<N>` is that
|
||||
plane's protocol version (an integer, starts at `1`). iroh refuses a connection
|
||||
whose ALPN does not match exactly, so two peers on different `<N>` for a plane
|
||||
simply cannot open that connection → we map that to a clean "peer is running an
|
||||
incompatible version" instead of garbage.
|
||||
|
||||
| Plane | ALPN / mechanism | Bump when… |
|
||||
| --- | --- | --- |
|
||||
| **Audio** | ALPN `peerspeak/audio/<N>` | the Opus/datagram framing, sequencing, or audio-handshake changes |
|
||||
| **Friends/presence** | ALPN `peerspeak/friends/<N>` | the `ControlMsg` / presence ping-pong shape changes |
|
||||
| **Gossip** | *(see below — cannot use a custom ALPN)* | `GossipPayload` / `GossipMessage` / `PeerState` shape, signing, or freshness rules change |
|
||||
|
||||
### Gossip is special
|
||||
|
||||
The gossip plane runs over **iroh-gossip's own `GOSSIP_ALPN`**, which we do not
|
||||
control, so we cannot version it via the ALPN. Instead, the gossip protocol
|
||||
version is bound in **two** places:
|
||||
|
||||
1. **Topic namespacing (primary, fail-fast):** the room's `topic_id` is a random
|
||||
32 bytes carried in the ticket, but the topic we actually *subscribe* to is
|
||||
`protocol::versioned_topic(topic_id)` — a deterministic, dependency-free
|
||||
transform that folds `GOSSIP_PROTO` into the bytes. Peers on different gossip
|
||||
versions therefore derive **different subscription topics from the same ticket**
|
||||
and never share a swarm — the same isolation a versioned ALPN gives the other
|
||||
planes. The ticket format and the room identity (`topic_id`) are unchanged; only
|
||||
the subscribed topic is namespaced. (The transform is for *isolation*, not
|
||||
security — cryptographic separation is the signature domain below.)
|
||||
2. **Signature domain (cryptographic separation):** the signing domain string
|
||||
(`peerspeak-gossip-v<N>`, bound into every signed payload) carries the version,
|
||||
so two versions that somehow met on a topic would fail each other's verification
|
||||
rather than misread it.
|
||||
|
||||
Bumping the gossip version = bump `protocol::GOSSIP_PROTO` (drives
|
||||
`versioned_topic`) **and** `protocol::GOSSIP_SIG_DOMAIN` together (a unit test in
|
||||
`protocol.rs` asserts the domain string matches `GOSSIP_PROTO`, so they can't drift).
|
||||
|
||||
### Single source of truth for protocol versions
|
||||
|
||||
All protocol versions, ALPNs, the gossip signature domain, and `versioned_topic`
|
||||
live in **`src/protocol.rs`**. Every call site derives from there (e.g.
|
||||
`crate::protocol::AUDIO_ALPN`); **never hand-write an ALPN literal inline.** A
|
||||
unit test asserts each ALPN/domain string matches its integer version so a bump
|
||||
can't half-apply.
|
||||
|
||||
---
|
||||
|
||||
## "I changed X — what do I bump?" (quick reference)
|
||||
|
||||
| You changed… | Layer 2 (plane version) | Layer 1 (`Cargo.toml`) |
|
||||
| --- | --- | --- |
|
||||
| Opus framing / audio datagram layout | `peerspeak/audio/N` → `N+1` | MINOR |
|
||||
| `ControlMsg` / presence shape | `peerspeak/friends/N` → `N+1` | MINOR |
|
||||
| `GossipPayload`/`PeerState`/signing | `GOSSIP_PROTO_VERSION` + sig domain → next | MINOR |
|
||||
| UI, local config, recording, a fix that doesn't touch any wire | nothing | PATCH |
|
||||
| An *additive* gossip field that old peers safely ignore | judgement call — if old peers misbehave without it, treat as breaking (MINOR + gossip bump); if truly ignorable, PATCH | PATCH or MINOR |
|
||||
|
||||
When in doubt about "is this additive-safe?", assume **breaking** and bump. A
|
||||
false MINOR bump costs a coordinated rebuild; a false PATCH costs silent broken
|
||||
calls in the field.
|
||||
|
||||
---
|
||||
|
||||
## Release checklist (per build handed to anyone)
|
||||
|
||||
1. Decide MINOR vs PATCH from the table above; bump `Cargo.toml`.
|
||||
2. If MINOR for a wire reason, confirm the matching Layer-2 plane version(s) were
|
||||
bumped in the same change.
|
||||
3. Note the version + "breaking?" in the commit / handoff.
|
||||
4. Tag the commit (`v0.x.y`) so a given binary maps to a known commit.
|
||||
5. Rebuild **every** peer that must interoperate (e.g. dopedart, staged friend
|
||||
releases) when the bump was a MINOR/wire break.
|
||||
|
||||
---
|
||||
|
||||
## Current baseline (standard adopted + migrated, 2026-06-18, `0.2.0`)
|
||||
|
||||
- `Cargo.toml`: **`0.2.0`** — the MINOR bump for the (deliberately breaking)
|
||||
migration to this standard. **All peers must run ≥ `0.2.0` to interoperate**
|
||||
(the ALPNs and gossip topics changed); the pre-standard `0.1.0`-era build
|
||||
(e.g. an un-resynced dopedart) cannot talk to a `0.2.0` peer — by design, and it
|
||||
now fails cleanly at the handshake instead of silently.
|
||||
- Protocol versions (all at `1`): `peerspeak/audio/1`, `peerspeak/friends/1`,
|
||||
gossip `peerspeak-gossip-v1` + `versioned_topic`. All sourced from
|
||||
`src/protocol.rs`.
|
||||
- **Remaining nicety (not blocking):** surface `env!("CARGO_PKG_VERSION")` in the
|
||||
UI (an About/Settings line) and/or log it at startup, so a running build is
|
||||
self-identifying in the field. Small follow-up.
|
||||
@@ -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 capture→encode→broadcast and receive→jitter→decode→mix→playback, 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.
|
||||
"""
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 606 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 994 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -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 |
@@ -76,6 +76,14 @@ CHIMES = {
|
||||
"mic-toggle.wav": [(E5, 0.08)],
|
||||
# Reconnect gave up: disappointing low two-note fall.
|
||||
"reconnect-failed.wav": [(C5, 0.15), (349.23, 0.30)],
|
||||
# Our chat message entered the room: a tiny bright acknowledgement.
|
||||
"chat-sent.wav": [(1046.50, 0.06)],
|
||||
# A peer message arrived: a soft two-note lift, distinct but unobtrusive.
|
||||
"chat-received.wav": [(E5, 0.07), (G5, 0.11)],
|
||||
# A saved contact came online: a light, higher two-note arrival.
|
||||
"contact-online.wav": [(E5, 0.09), (880.00, 0.18)],
|
||||
# A saved contact went offline: the same tonal family falling away.
|
||||
"contact-offline.wav": [(E5, 0.09), (440.00, 0.18)],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# cargo-deny policy for peerspeak
|
||||
#
|
||||
# Supersedes a bare `cargo audit` run. Enforce with:
|
||||
# cargo install cargo-deny --locked
|
||||
# cargo deny check
|
||||
#
|
||||
# In CI, run `cargo deny check` on a locked tree so the pinned, vetted
|
||||
# versions in Cargo.lock are what actually get audited.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Advisories: RustSec database. Vulnerabilities and yanked crates are denied
|
||||
# by default. The two `ignore` entries below are *unmaintained* warnings only
|
||||
# (no known exploit); they are deep transitive deps we cannot remove. Pinning
|
||||
# them via Cargo.lock is our real protection — a future malicious release does
|
||||
# not reach us until we deliberately `cargo update`, so each update is a review
|
||||
# checkpoint. Revisit these if either advisory is upgraded to a vulnerability.
|
||||
# ---------------------------------------------------------------------------
|
||||
[advisories]
|
||||
ignore = [
|
||||
# paste: unmaintained, compile-time proc-macro only (zero runtime surface),
|
||||
# transitive via iroh/netdev/netlink and rav1e/image/iced. Maintained fork
|
||||
# `pastey` is already in the tree; stragglers will follow upstream.
|
||||
"RUSTSEC-2024-0436",
|
||||
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
||||
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
||||
"RUSTSEC-2026-0150",
|
||||
# ttf-parser: unmaintained, transitive via iced/cosmic-text (font parsing
|
||||
# for the GUI). Inputs are system + embedded fonts, not network data. No
|
||||
# upstream migration yet; revisit when iced moves off it.
|
||||
"RUSTSEC-2026-0192",
|
||||
# quick-xml 0.39.4 DoS advisories (quadratic dup-attr check; unbounded
|
||||
# namespace allocation). Build-time only: quick-xml is reached solely via
|
||||
# the wayland-scanner PROC-MACRO, which parses the wayland protocol XML
|
||||
# files vendored inside the wayland-* crates at compile time. Attacker
|
||||
# input never reaches it and it is not in the shipped binary. The fix
|
||||
# (0.41.0) is semver-incompatible with wayland-scanner 0.31.x's `^0.39`
|
||||
# requirement; drop both ignores once wayland-scanner releases a bump.
|
||||
"RUSTSEC-2026-0194",
|
||||
"RUSTSEC-2026-0195",
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bans: shape of the dependency graph.
|
||||
# ---------------------------------------------------------------------------
|
||||
[bans]
|
||||
# Multiple versions of the same crate bloat the build; warn rather than fail
|
||||
# since transitive graphs (iroh, iced) routinely carry duplicates we can't fix.
|
||||
multiple-versions = "warn"
|
||||
# Wildcard ("*") version requirements are a supply-chain footgun: they accept
|
||||
# any future release, defeating the lockfile-as-review-checkpoint model.
|
||||
wildcards = "deny"
|
||||
# ...but our own intra-repo path deps may use "*"; don't penalize those.
|
||||
allow-wildcard-paths = true
|
||||
|
||||
# Crates that may never appear in the graph. Add a maintained replacement's
|
||||
# predecessor here once you've migrated off it, to prevent regressions.
|
||||
deny = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sources: where crates are allowed to come from. This is the core anti-hijack
|
||||
# control — only the official crates.io registry is trusted; arbitrary git
|
||||
# sources (a common vector for slipping in unaudited code) are rejected.
|
||||
# ---------------------------------------------------------------------------
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
# allow-git = [] # add a specific, pinned git repo here only if ever needed
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Licenses: permissive set covering the current graph. If `cargo deny check`
|
||||
# reports an unmatched license, vet it and add the SPDX id here (or add a
|
||||
# per-crate entry under [licenses.exceptions]) rather than widening blindly.
|
||||
# ---------------------------------------------------------------------------
|
||||
[licenses]
|
||||
allow = [
|
||||
"MIT",
|
||||
"Apache-2.0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Zlib",
|
||||
"MPL-2.0",
|
||||
"Unicode-3.0",
|
||||
"Unicode-DFS-2016",
|
||||
"CC0-1.0",
|
||||
"0BSD",
|
||||
"Unlicense",
|
||||
"BSL-1.0",
|
||||
"NCSA", # University of Illinois/NCSA — BSD-like permissive
|
||||
"CDLA-Permissive-2.0", # Community Data License Agreement, permissive
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
exceptions = []
|
||||
|
||||
# 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
|
||||
@@ -102,6 +102,7 @@ covers internals). When you ship a feature, add it here.
|
||||
| iroh QUIC transport | ✅ | |
|
||||
| Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. |
|
||||
| Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. |
|
||||
| Per-peer connection badge (direct/relay + RTT, hover for addr/loss/bitrate) | ✅ | Peer-card badge fed by a 1 Hz poll of the live audio link's selected QUIC path (`connection_stats` → `core::connstats::derive`). Field-verified on a real 2-machine call 2026-07-08. |
|
||||
| Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. |
|
||||
| Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. |
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# PeerSpeak on Windows
|
||||
|
||||
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 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/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`:
|
||||
|
||||
- Config: `%APPDATA%\peerspeak\config.json`
|
||||
- Identity: `%APPDATA%\peerspeak\identity.key`
|
||||
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
|
||||
|
||||
## Building
|
||||
|
||||
### Cross-compile from Linux
|
||||
|
||||
Preferred local path:
|
||||
|
||||
```sh
|
||||
distrobox enter peerspeak-win -- bash -lc '
|
||||
cd ~/git/butter/peerspeak &&
|
||||
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
|
||||
'
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## First run and networking
|
||||
|
||||
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. Relayed connections are expected and valid.
|
||||
|
||||
## Known gaps
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| 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. |
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,584 @@
|
||||
# Chat hardening — ephemeral implementation plan
|
||||
|
||||
**Status (2026-07-18):** Phases 1–5 COMPLETE (all plan phases done). Phase 1 =
|
||||
shared text policy in `src/sanitize.rs`, ceilings enforced at UI input, sign
|
||||
point, and gossip ingress. Phase 2 = roster-bound authorship
|
||||
(`src/core/chatroster.rs`), replay dedup + rate limits (`ChatIngressGate` in
|
||||
`src/network/gossip.rs`). Phase 3 = attachment cache/serve-store budgets,
|
||||
downscaled previews, auto-fetch byte/request budgets (`src/core/fetchbudget.rs`),
|
||||
exact transfers, bounded local reads. Phase 4 = parsed-URL link policy
|
||||
(`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`, `url` crate), 8-link cap,
|
||||
cached link ranges in `ChatEntry`, 512 KiB history text budget, chat-body
|
||||
bidi-override strip (closes S14). Phase 5 = honest local send status
|
||||
(`CoreCommand::SendChat`/`SendChatFile` carry a local id, `UiEvent::ChatSendResult`,
|
||||
`SendStatus` on own echoes) PLUS sender-side pacing (`src/app/sendqueue.rs`
|
||||
mirrors the receivers' per-author budget so fast bursts trickle instead of being
|
||||
silently dropped downstream). All gates green each phase. This is a temporary
|
||||
scope contract for hardening the existing room chat; with every phase complete
|
||||
and the two-machine field test done, delete this file (see the completion note
|
||||
at the end). The two-machine field-test section below is still owed before that
|
||||
deletion. Do not add link previews as part of this effort.
|
||||
|
||||
## Goal
|
||||
|
||||
Strengthen the current encrypted, signed, session-only room chat without changing
|
||||
its product model: plain selectable text, clickable web links, and peer-to-peer
|
||||
attachments over the existing gossip and files planes. The work should make chat
|
||||
resistant to identity spoofing, replay, spam, oversized input, expensive rendering,
|
||||
and attachment-driven memory/bandwidth pressure while preserving normal Unicode
|
||||
conversation and the existing full-mesh architecture.
|
||||
|
||||
## Existing foundation to preserve
|
||||
|
||||
- Gossip payloads are signed by the claimed `EndpointId`, bound to the raw room
|
||||
topic and protocol domain, and checked before dispatch.
|
||||
- The signed envelope timestamp is admitted only within the two-minute gossip
|
||||
freshness window.
|
||||
- Inbound gossip frames are capped at 128 KiB before JSON deserialization. This
|
||||
larger plane-wide cap must remain because `Announce` may contain a custom avatar.
|
||||
- Chat history is session-only and capped at 300 entries.
|
||||
- Only `http://` and `https://` links are opened, as a single process argument
|
||||
without a shell.
|
||||
- Attachment descriptors are signed with the chat payload; attachment bytes use
|
||||
the encrypted files plane, have a 25 MiB per-file cap, and are keyed by both
|
||||
author and attachment id.
|
||||
- Image bytes are decoded defensively and automatic image fetches already have a
|
||||
four-task concurrency limit.
|
||||
|
||||
## Working design decisions
|
||||
|
||||
These are the implementation defaults unless code inspection or tests reveal a
|
||||
concrete reason to adjust them. Record any adjustment in the decision log.
|
||||
|
||||
1. **No wire change.** Keep `GossipMessage::Chat` unchanged and do not bump
|
||||
`GOSSIP_PROTO`. The redundant wire `name` and inner `Chat.ts` remain serialized
|
||||
for compatibility but are not trusted. Remove them only during a future planned
|
||||
gossip-version bump.
|
||||
2. **Roster identity is authoritative.** A chat line is admitted only for an
|
||||
authenticated identity already known to the current room (including the
|
||||
reconnect grace state). Its displayed name comes from the sanitized roster
|
||||
state, never from `GossipMessage::Chat.name`.
|
||||
3. **Body Unicode remains expressive.** Do not apply the short-label sanitizer to
|
||||
the message body; it strips format characters used by some languages and emoji.
|
||||
Continue neutralizing controls and whitespace, while treating author labels,
|
||||
filenames, and URLs more strictly because those are spoof-sensitive surfaces.
|
||||
4. **Bounds apply at every trust boundary.** UI input is bounded while editing,
|
||||
outgoing text is normalized before signing, and incoming text is byte-checked
|
||||
and normalized before it leaves the gossip layer. UI-only truncation is not an
|
||||
adequate ingress defense.
|
||||
5. **Automatic network work is stricter than manual work.** Keep the 25 MiB manual
|
||||
attachment ceiling, but auto-fetch only small images. Larger images remain
|
||||
available behind an explicit Load/Download action.
|
||||
6. **Caches are bounded by cost, not only entry count.** Count encoded bytes and
|
||||
estimated decoded image bytes. A count cap remains as a secondary bound.
|
||||
7. **Rate limiting degrades quietly.** Drop excess/replayed peer messages with a
|
||||
rate-limited log entry. Do not let a spammer produce a second UI-notification
|
||||
flood.
|
||||
|
||||
## Proposed policy constants
|
||||
|
||||
Keep these together near the code that enforces them and cover them with boundary
|
||||
tests. Values are starting points, not a compatibility contract.
|
||||
|
||||
| Policy | Initial value | Reason |
|
||||
| --- | ---: | --- |
|
||||
| Chat body characters | 2,000 | Preserves current UI behavior |
|
||||
| Chat body UTF-8 bytes | 8 KiB | Covers 2,000 four-byte scalars with small headroom |
|
||||
| Live input characters/bytes | Same as body | Prevent oversized paste/edit state |
|
||||
| Clickable links per message | 8 | Bounds spans and opener targets |
|
||||
| Retained chat text | 512 KiB plus 300 entries | Bounds redraw and selection work |
|
||||
| Per-author chat limiter | Burst 8, refill 1/second | Allows normal bursts, stops sustained spam |
|
||||
| Room-wide chat limiter | Burst 32, refill 8/second | Protects shared event/UI queues |
|
||||
| Exact-chat replay cache | 1,024 digests, 2-minute TTL | Covers freshness window with a hard bound |
|
||||
| Auto-fetch image encoded size | 4 MiB | Limits unsolicited bandwidth and allocations |
|
||||
| Attachment cache encoded budget | 128 MiB | Allows several ordinary files without GiB growth |
|
||||
| Attachment cache decoded-preview budget | 64 MiB | Bounds renderer-side image pressure |
|
||||
| Served attachment budget | 256 MiB plus a count cap | Bounds sender memory for a long session |
|
||||
| Inline preview longest side | 1,600 px | Chat renders near 260 px; full 4K decode is wasteful |
|
||||
| Decoded source image pixels | 16 megapixels maximum | Adds a total-pixel bound to per-side bounds |
|
||||
|
||||
## Phase 1 — Shared text policy and live-input bounds
|
||||
|
||||
**Target:** downstream layers never receive or retain an unexpectedly large or
|
||||
unsafe chat string.
|
||||
|
||||
- [x] Move chat constants and `sanitize_chat` from `src/app/mod.rs` into
|
||||
`src/sanitize.rs` (or a narrowly scoped shared chat-policy module if that keeps
|
||||
the API clearer).
|
||||
- [x] Implement a single-pass sanitizer that:
|
||||
- maps control characters to spaces;
|
||||
- collapses whitespace and trims ends;
|
||||
- enforces both the character and UTF-8 byte ceilings without splitting a scalar;
|
||||
- returns empty for content with no visible text.
|
||||
- [x] Add `cap_chat_input` for live editing. It must preserve the user's current
|
||||
whitespace while enforcing character and byte ceilings; normalization remains a
|
||||
submit/ingress operation so typing does not visibly jump.
|
||||
- [x] Apply `cap_chat_input` in `AppMessage::ChatInputChanged`, covering keyboard,
|
||||
clipboard, primary-selection, and context-menu paste paths through the controlled
|
||||
input widget.
|
||||
- [x] Sanitize outgoing text immediately before local echo and `CoreCommand` send.
|
||||
- [x] Sanitize again before `GossipMessage::Chat` is signed, so a future non-UI
|
||||
caller cannot bypass policy.
|
||||
- [x] At gossip ingress, reject raw chat text over the byte ceiling before doing
|
||||
downstream sanitization; sanitize accepted text before creating `RoomEvent`.
|
||||
- [x] Keep attachment-only messages when the sanitized caption is empty; drop a
|
||||
chat with neither visible text nor a valid attachment.
|
||||
- [x] Stop sanitizing an incoming chat `name` with the body sanitizer. Phase 2
|
||||
replaces it with the roster-bound name.
|
||||
|
||||
### Phase 1 tests
|
||||
|
||||
- [x] ASCII, multibyte Unicode, emoji, whitespace, NUL/CR/LF/TAB/ESC, empty input.
|
||||
- [x] Exact character and byte boundaries, including a four-byte scalar at the
|
||||
cutoff.
|
||||
- [x] Oversized paste never makes `state.chat_input` exceed either ceiling.
|
||||
- [x] Outgoing, incoming, and direct core/network paths converge on the same
|
||||
normalized result.
|
||||
- [x] Empty captions are retained only when a valid attachment remains.
|
||||
|
||||
## Phase 2 — Admission, identity binding, replay, and spam control
|
||||
|
||||
**Target:** only current authenticated room members can create chat UI work, and a
|
||||
member cannot impersonate another participant or monopolize the control/UI queues.
|
||||
|
||||
- [x] Change the core event task's chat roster from a bare `HashSet<EndpointId>` to
|
||||
a bounded map containing each member's latest sanitized display name (or retain a
|
||||
parallel name map if less invasive).
|
||||
- [x] Insert/update the map on `PeerJoined`/`PeerUpdated`, retain it during transient
|
||||
reconnect grace, and remove it on graceful or terminal eviction.
|
||||
- [x] Before attachment handling or UI forwarding, reject `RoomEvent::ChatMessage`
|
||||
whose author is not present in that authoritative roster.
|
||||
- [x] Replace the embedded wire name with the roster map's name before constructing
|
||||
`UiEvent::ChatMessage`. The UI may keep storing a name snapshot so old chat lines
|
||||
remain labeled after a peer leaves.
|
||||
- [x] Add a lightweight early known-author gate in the gossip loop using its live
|
||||
and disconnected-peer sets. Keep the core roster gate as defense in depth and as
|
||||
the final authority.
|
||||
- [x] Validate that the inner `Chat.ts` equals the signed envelope timestamp, or
|
||||
ignore it entirely. Do not use the inner timestamp for replay or ordering.
|
||||
- [x] Add exact-chat replay suppression after signature verification and before
|
||||
event-channel send:
|
||||
- hash the canonical signed bytes, not raw JSON formatting;
|
||||
- use BLAKE3 (make it a direct dependency if needed; it is already in the iroh
|
||||
dependency graph) or an equally collision-resistant existing primitive;
|
||||
- store a `HashSet` plus FIFO/TTL order for bounded lookup and eviction;
|
||||
- prune by both the gossip freshness window and the hard entry cap.
|
||||
- [x] Add a bounded token bucket per admitted author and a room-wide bucket before
|
||||
awaiting `event_tx.send`. Limiter state must be removed with roster eviction and
|
||||
remain bounded by the roster cap.
|
||||
- [x] Ensure duplicate messages are dropped before consuming rate-limit tokens, so
|
||||
a replay cannot starve a legitimate new message from that author.
|
||||
- [x] Rate-limit rejection logging per author/reason.
|
||||
- [ ] Consider applying the same local submit policy to accidental rapid Enter or
|
||||
button activation, without routing chat through the coalescing command path.
|
||||
|
||||
### Phase 2 tests
|
||||
|
||||
- [x] Valid roster author is admitted; never-announced, post-leave, forged, and
|
||||
stale authors are rejected.
|
||||
- [x] A peer sending `name = "Victim"` renders under its own roster name.
|
||||
- [x] A name update affects future messages without rewriting history.
|
||||
- [x] Reconnect grace continues accepting the known author; terminal eviction does
|
||||
not.
|
||||
- [x] The same signed chat is displayed once; distinct chats created in the same
|
||||
millisecond are both admitted.
|
||||
- [x] Replay-cache TTL/cap pruning cannot grow without bound.
|
||||
- [x] Per-author burst/refill and room-wide burst/refill boundaries.
|
||||
- [x] Excess chat cannot prevent a subsequent `Leave` or `Announce` from reaching
|
||||
the event loop in a deterministic channel-pressure test.
|
||||
|
||||
## Phase 3 — Attachment transfer and memory hardening
|
||||
|
||||
**Target:** neither peers nor long local sessions can turn chat attachments into
|
||||
unbounded memory, bandwidth, decoder, or task pressure.
|
||||
|
||||
### 3A. Cache and image cost
|
||||
|
||||
- [x] Extend `AttachmentCache` with encoded-byte and decoded-preview-byte counters.
|
||||
Preserve the count cap, but evict oldest entries until all three budgets fit.
|
||||
- [x] Give every entry an explicit weight. Replacement must subtract the old
|
||||
weight before checking/inserting the new one.
|
||||
- [x] Decide behavior for a single entry larger than the cache budget: service an
|
||||
immediate pending Save/Play request without retaining it, then expose it as
|
||||
evicted/unavailable rather than exceeding the budget.
|
||||
- [x] Add a total-pixel limit to `validate_image_bytes` in addition to the existing
|
||||
width/height limit.
|
||||
- [x] Build a downscaled inline preview handle with a maximum 1,600 px side. Keep
|
||||
original bytes only for Save; do not hand a full-resolution 4K image to the
|
||||
renderer merely to display it at chat width.
|
||||
- [x] Count estimated RGBA preview cost (`width * height * 4`) against the decoded
|
||||
budget even if iced internally copies or uploads it.
|
||||
- [x] Strip the same bidi/zero-width spoofing characters used for display labels
|
||||
from attachment filenames, while preserving ordinary Unicode filenames.
|
||||
|
||||
### 3B. Automatic download policy and state
|
||||
|
||||
- [x] Auto-fetch only roster-authored images whose declared size is at or below
|
||||
`MAX_AUTO_IMAGE_BYTES`; keep the existing `(author,id)` dedup and four-permit
|
||||
concurrency bound.
|
||||
- [x] Add per-author and session byte/request budgets for automatic fetches so a
|
||||
peer cannot drain bandwidth sequentially after each permit is released.
|
||||
- [x] Represent `NotFetched`, `Loading`, `Ready`, `Failed`, and `Evicted` distinctly
|
||||
enough for the UI to avoid an indefinite “loading…” label when auto-fetch was
|
||||
skipped or the cache evicted an item.
|
||||
- [x] Render a Load image button for large/skipped images. A manual click may use
|
||||
the 25 MiB file cap but still observes cache/decoder budgets.
|
||||
- [x] Ensure a repeated click cannot create duplicate unguarded fetch tasks.
|
||||
- [x] Keep non-image attachments manual-only.
|
||||
|
||||
### 3C. Exact transfers, local reads, and served files
|
||||
|
||||
- [x] In `IrohTransport::fetch_blob`, require `bytes.len() as u64 == declared_size`.
|
||||
Reject empty, short, and overlong transfers with a concise local error.
|
||||
- [x] Replace the file picker's unbounded `FileHandle::read()` with a helper that
|
||||
reads at most `MAX_ATTACHMENT_BYTES + 1`. Check metadata first where available,
|
||||
but retain the bounded read because metadata can race or be unavailable through
|
||||
a portal.
|
||||
- [x] Avoid duplicating a full attachment across UI, command queue, and serve store.
|
||||
Prefer `Arc<Vec<u8>>`/`Arc<[u8]>` through `AttachmentState`, `CoreCommand`, and
|
||||
`serve_attachment`, subject to iced handle API constraints.
|
||||
- [x] Replace the unbounded session `served_files` map with a count- and byte-
|
||||
budgeted FIFO store. Evicted ids should produce the existing “sender no longer
|
||||
has the file” response rather than stale or aliased data.
|
||||
- [x] Keep attachment ids keyed by author on receipt and preserve all existing
|
||||
request-length, timeout, filename, and decoder checks.
|
||||
|
||||
### Phase 3 tests
|
||||
|
||||
- [x] Byte-budget eviction, count eviction, replacement accounting, clear/reset,
|
||||
and an individually overweight entry.
|
||||
- [x] Decoded-preview budget and downscale dimensions for wide, tall, square, and
|
||||
boundary images.
|
||||
- [x] Image with valid per-side dimensions but excessive total pixels is rejected.
|
||||
- [x] A declared 4 MiB image auto-fetches; the first byte over the limit requires a
|
||||
click.
|
||||
- [x] Per-author/session auto-fetch budgets recover according to their policy and
|
||||
never exceed task concurrency.
|
||||
- [x] Short, exact, and overlong file responses.
|
||||
- [x] Local file reader stops at cap + 1 instead of allocating the full source.
|
||||
- [x] Served-file FIFO/byte eviction and replacement accounting.
|
||||
- [x] Same attachment id from two authors remains isolated throughout fetch, cache,
|
||||
save, and display.
|
||||
|
||||
## Phase 4 — URL and rendering resilience
|
||||
|
||||
**Target:** keep clickable links without making malformed/deceptive input or many
|
||||
small spans an unnecessary UI/launcher surface.
|
||||
|
||||
- [x] Make `url` a direct dependency (already present transitively) and validate
|
||||
link candidates with `url::Url`.
|
||||
- [x] A clickable URL must have an `http` or `https` scheme and a valid host.
|
||||
- [x] Treat URLs containing username/password syntax as plain text, or require an
|
||||
explicit confirmation that shows the parsed destination host. Prefer plain text
|
||||
for the first implementation.
|
||||
- [x] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
|
||||
replace prefix checks with the shared parsed-URL policy.
|
||||
- [x] Cap clickable candidates at eight per message. Remaining content stays
|
||||
selectable plain text and must still round-trip exactly.
|
||||
- [x] Refactor linkification to return borrowed ranges/offsets or cache link ranges
|
||||
in `ChatEntry`, avoiding allocation and rescanning on every redraw.
|
||||
- [x] Bound retained history by total sanitized text bytes as well as 300 entries.
|
||||
Eviction must keep attachment bookkeeping coherent and should not invalidate an
|
||||
open Save/Play operation.
|
||||
- [x] Do not add metadata fetching, remote images, Markdown, or link previews.
|
||||
- [x] (Folded in from S14, per the security handoff) Strip bidi
|
||||
overrides/isolates from the chat BODY in `sanitize_chat`, keeping the other
|
||||
expressive format characters (ZWJ/ZWNJ/LRM/RLM).
|
||||
|
||||
### Phase 4 tests
|
||||
|
||||
- [x] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
|
||||
punctuation, credentials/userinfo, and non-web schemes.
|
||||
- [x] Eight-link boundary and many-link adversarial input.
|
||||
- [x] Segment/range reconstruction exactly reproduces the sanitized message.
|
||||
- [x] Entry-count and total-text-budget history eviction.
|
||||
- [x] Opener policy cannot launch a non-web scheme even if called directly.
|
||||
|
||||
## Phase 5 — Honest local send status
|
||||
|
||||
**Target:** never present a locally echoed message as successfully broadcast when
|
||||
the core rejected it or gossip broadcast failed.
|
||||
|
||||
- [x] Add a local-only message id and `Pending`/`Broadcast`/`Failed` state to local
|
||||
chat entries. Do not put this id or state on the wire. (`ChatEntry.local_send:
|
||||
Option<LocalSend>`; `SendStatus` also has `Queued` for the paced-but-not-yet-sent
|
||||
state — see the pacing decision-log entry.)
|
||||
- [x] Carry the local id through `CoreCommand::SendChat`/`SendChatFile` and return a
|
||||
`UiEvent` result after the local gossip broadcast call succeeds or fails.
|
||||
(`SendChat`/`SendChatFile` gained `local_id`; new `UiEvent::ChatSendResult { local_id,
|
||||
error }`.)
|
||||
- [x] If the core is not in an active session, return failure instead of silently
|
||||
doing nothing. (`send_chat` now `Err`s on missing sender/topic and on encode
|
||||
failure; the core arm maps no-session to a `ChatSendResult` error.)
|
||||
- [x] Show failure compactly with a retry action. A successful local broadcast must
|
||||
not be labeled “delivered” or “read”; PeerSpeak has no peer acknowledgements.
|
||||
(Failed → red "⚠ Not sent — {reason} [Retry]" line; Broadcast/Pending render
|
||||
nothing — silence is the honest success state.)
|
||||
- [x] Retry creates one new signed broadcast while retaining replay correctness and
|
||||
attachment serving state. (`RetryChatSend(id)` re-dispatches the retained
|
||||
`PendingSend`; re-serving the same attachment id REPLACES the `ServeStore`
|
||||
entry, never double-counts — see `serve_store_replacement_accounting_and_remove_clear`.)
|
||||
|
||||
### Phase 5 tests
|
||||
|
||||
- [x] Local echo starts pending, becomes broadcast on success, and becomes failed
|
||||
on no-session/channel/gossip error. (`send_status_pending_then_broadcast_on_success`,
|
||||
`send_status_failed_keeps_payload_for_retry`.)
|
||||
- [x] Results update only the matching local entry, including after history
|
||||
eviction or room reset. (`send_result_updates_only_the_matching_entry`,
|
||||
`send_result_after_eviction_drops_orphan_payload`, `send_result_after_room_reset_is_a_noop`.)
|
||||
- [x] Retry does not duplicate served bytes or mutate an unrelated entry.
|
||||
(`retry_redispatches_only_the_targeted_send`; served-byte dedup =
|
||||
`serve_store_replacement_accounting_and_remove_clear` in `files.rs`.)
|
||||
|
||||
## Compatibility and versioning
|
||||
|
||||
- The planned implementation changes validation, local data structures, and
|
||||
internal `CoreCommand`/`UiEvent` shapes only. Keep the serialized
|
||||
`GossipMessage::Chat` and file request/response formats unchanged.
|
||||
- Therefore do **not** bump `GOSSIP_PROTO`, `FILES_PROTO`, or the pre-1.0 MINOR
|
||||
solely for this plan. The eventual release is a compatible PATCH unless scope
|
||||
expands into a wire change.
|
||||
- If implementation requires removing/adding serialized fields, changing
|
||||
attachment request framing, or introducing acknowledgements on the wire, stop
|
||||
and revise this section before coding that part. Follow `VERSIONING.md` and use
|
||||
the appropriate protocol plus release MINOR bump.
|
||||
|
||||
## Verification gates
|
||||
|
||||
Run after each phase, with focused tests first and the full gates before handoff:
|
||||
|
||||
```text
|
||||
cargo fmt --check
|
||||
cargo test --lib
|
||||
cargo test --all-targets
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Also retain the existing ignored/loopback coverage where the environment supports
|
||||
it; do not make ordinary unit tests depend on external network access.
|
||||
|
||||
### Two-machine field test
|
||||
|
||||
- [ ] Ordinary ASCII/Unicode conversation, rapid short burst, long boundary text,
|
||||
and oversized paste.
|
||||
- [ ] Rename during a room: new lines use the new roster name; old lines retain
|
||||
their snapshot.
|
||||
- [ ] Disconnect/reconnect grace and post-leave chat admission behavior.
|
||||
- [ ] Multiple normal images, one image above the auto threshold, a malformed
|
||||
“image”, and a maximum-size manual file.
|
||||
- [ ] Download/save after cache eviction; clear failure state and no runaway
|
||||
memory across repeated attachments.
|
||||
- [ ] Observe process RSS and UI responsiveness during a bounded spam/attachment
|
||||
stress run; verify leave/reconnect controls remain responsive.
|
||||
- [ ] Linux and Windows URL opening for valid links; malformed/userinfo links remain
|
||||
selectable but do not launch.
|
||||
- [ ] A message with more than eight URLs renders eight clickable links and the
|
||||
rest as selectable plain text, with nothing dropped.
|
||||
- [ ] A message attempting bidi-override display spoofing renders in send order
|
||||
(the override characters are stripped, emoji/joining-script text intact).
|
||||
- [ ] Send a fast burst (>8 messages in a second): all arrive at the peer in
|
||||
order, none silently lost; the sender sees "queued…" on the overflow that
|
||||
then clears as each goes out.
|
||||
- [ ] Send with no active session (or a failing broadcast): the message shows
|
||||
"⚠ Not sent" with a Retry, and Retry resends it once when connectivity is back.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
The plan is complete when:
|
||||
|
||||
1. Only active/grace-rostered authenticated authors reach chat UI state.
|
||||
2. Chat identity is roster-bound and cannot be overridden by the embedded wire
|
||||
name.
|
||||
3. Exact replay and sustained spam are bounded before shared event queues.
|
||||
4. Live input, inbound/outbound body size, history text, attachment caches,
|
||||
automatic transfers, served files, and decoded previews all have tested hard
|
||||
bounds.
|
||||
5. File transfer length and image decoding/display costs are validated.
|
||||
6. Clickable links pass a shared parsed-URL policy and rendering work is bounded.
|
||||
7. Local broadcast failure is visible without claiming peer delivery.
|
||||
8. Unit/all-target/clippy gates and the two-machine field test pass.
|
||||
9. Relevant durable docs (`README.md`, `docs/FEATURES.md`, `CHANGELOG.md`, security
|
||||
notes, and comments) describe the final behavior.
|
||||
10. This ephemeral plan is deleted after its useful status/history is transferred
|
||||
to durable documentation.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Link previews, metadata fetches, or remote thumbnail requests.
|
||||
- Persistent/offline chat history or server-side message storage.
|
||||
- Markdown, rich embeds, reactions, editing, deletion, threads, or search.
|
||||
- Read receipts or peer delivery acknowledgements.
|
||||
- Moderation UI, kicking, blocking, or trust-list redesign.
|
||||
- Antivirus/malware scanning of user-requested downloaded files.
|
||||
- A new application-layer group-encryption protocol or a broader cryptographic
|
||||
redesign. If PeerSpeak makes a formal end-to-end-encryption product claim, audit
|
||||
and document the exact iroh/gossip/relay threat model as a separate project.
|
||||
|
||||
## Decision log
|
||||
|
||||
- **2026-07-15:** Chose hardening over automatic link previews because receiving a
|
||||
message should not trigger third-party web requests or weaken PeerSpeak's
|
||||
privacy-oriented design.
|
||||
- **2026-07-15:** Initial scope keeps all wire formats stable; hardening is local
|
||||
admission, validation, resource accounting, and honest UI state.
|
||||
- **2026-07-17 (Phase 1):** The 8 KiB byte ceiling deliberately cannot bind on
|
||||
*sanitized* output (2,000 scalars × 4 bytes = 8,000 ≤ 8,192), so inside
|
||||
`sanitize_chat`/`cap_chat_input` it is defense in depth; its operative role is
|
||||
the raw-ingress reject in `admit_chat_text`.
|
||||
- **2026-07-17 (Phase 1):** Interim until Phase 2's roster binding: the incoming
|
||||
chat `name` now goes through the strict `sanitize_name` label sanitizer at the
|
||||
UI edge (was the body sanitizer), so author labels already get bidi/zero-width
|
||||
stripping and the 48-char label cap.
|
||||
- **2026-07-17 (Phase 1):** `send_chat` at the gossip sign point silently no-ops
|
||||
(Ok) on an empty-after-sanitize body with no attachment rather than erroring;
|
||||
the UI already prevents this case, and Phase 5's send-status work is where
|
||||
send-path feedback gets designed.
|
||||
- **2026-07-17 (Phase 2):** Replay dedup is keyed on the payload's own Ed25519
|
||||
**signature bytes** instead of a BLAKE3 digest (the plan allowed "an equally
|
||||
collision-resistant existing primitive"): ed25519 signing is deterministic
|
||||
(RFC 8032), so the 64-byte signature is already a collision-resistant
|
||||
fingerprint of the exact signed bytes — same dedup power, zero new direct
|
||||
dependencies. Cache entries are stamped with the signed envelope `ts` and
|
||||
pruned once it exits the freshness window, because `verify_gossip` already
|
||||
rejects such a frame before the cache is consulted.
|
||||
- **2026-07-17 (Phase 2):** A room-bucket reject refunds the just-consumed
|
||||
author token, so a room-wide squeeze caused by other members does not also
|
||||
drain an innocent author's personal budget.
|
||||
- **2026-07-17 (Phase 2):** Rate-limited frames are NOT entered into the replay
|
||||
cache: only fully admitted chats are. A legitimate message the room was too
|
||||
busy for, redelivered later by the swarm, is then displayed once instead of
|
||||
being misread as a replay of something never shown.
|
||||
- **2026-07-17 (Phase 2):** The "wire name never renders" guarantee is
|
||||
structural: the core event task binds the wire field as `name: _` and builds
|
||||
`UiEvent::ChatMessage` exclusively from `ChatRoster::name_of`, so there is no
|
||||
code path from wire name to UI. The roster map behavior is unit-tested; the
|
||||
end-to-end impersonation scenario stays on the (still-open) two-machine
|
||||
field-test list.
|
||||
- **2026-07-17 (Phase 2):** The channel-pressure requirement is met at the seam
|
||||
level: chat admission is bounded (32-burst / 8-per-s room-wide) BEFORE any
|
||||
`event_tx.send`, and `Announce`/`Leave` admission is independent of the chat
|
||||
gate — verified by unit tests. A full gossip-loop pressure harness was not
|
||||
built; the seam bound is what protects the channel.
|
||||
- **2026-07-17 (Phase 2):** An empty-after-sanitize roster name falls back to
|
||||
the short node id, so a member who announces an all-control-character name
|
||||
still gets a stable, non-blank chat label.
|
||||
- **2026-07-17 (Phase 2):** The "Consider applying the same local submit policy
|
||||
to accidental rapid Enter" item is DEFERRED: the receiving side is the
|
||||
security boundary (every peer independently enforces the buckets), and a
|
||||
local silent drop would be a UX regression better designed alongside Phase
|
||||
5's honest send status.
|
||||
- **2026-07-18 (Phase 3):** Constants that deviate from the proposed table, all
|
||||
bound-tested: total decoded pixels **14 MP** (not 16 MP) so the bound clears
|
||||
12 MP phone photos (4032×3024) yet actually binds inside the 4096²≈16.8 MP
|
||||
per-side envelope; cache encoded budget **96 MiB** (not 128) — still several
|
||||
full-size files, tighter worst case; serve store **128 MiB + 16 entries**
|
||||
(not 256 MiB) — a sender's own session should not pin a quarter GiB.
|
||||
- **2026-07-18 (Phase 3):** `validate_image_bytes`/`decode_preview` precheck
|
||||
dimensions from the container HEADER (`into_dimensions`) before any pixel
|
||||
decode, so an over-limit decode bomb is rejected without paying its decode
|
||||
cost; the decode-time `image::Limits` remain as defense in depth, and the
|
||||
decoded dimensions must equal the prechecked header dimensions.
|
||||
- **2026-07-18 (Phase 3):** Budget-pressure evictions leave NO cache entry
|
||||
(absence = NotFetched → the same Load/Download affordance), while the
|
||||
explicit `Evicted` state marks only an *individually over-budget* fetch whose
|
||||
bytes were used once (pending Save/Play serviced from hand) and dropped. Both
|
||||
render load-on-demand; only the bookkeeping differs.
|
||||
- **2026-07-18 (Phase 3):** The core still runs `validate_image_bytes` before
|
||||
emitting `AttachmentReady`, and the UI decodes once more to build the ≤1600px
|
||||
preview. Two bounded decodes per image were accepted over shipping decoded
|
||||
RGBA across the channel (which would defeat the encoded-only Arc sharing).
|
||||
- **2026-07-18 (Phase 3):** The image lightbox now enlarges the ≤1600px preview
|
||||
handle, not the original bitmap — originals are retained encoded-only for
|
||||
Save. At the lightbox's window-sized draw area the visual difference is nil
|
||||
for the chat use case; full fidelity remains one Save away.
|
||||
- **2026-07-18 (Phase 3):** `AutoFetchBudget` checks all four buckets
|
||||
(author/session × requests/bytes) and only then consumes atomically, so a
|
||||
rejection burns nothing (no refund path like Phase 2's room bucket needed).
|
||||
Tokens ARE consumed if the four-permit semaphore then rejects the spawn —
|
||||
that only happens mid-flood, when charging the author is the intent.
|
||||
- **2026-07-18 (Phase 3):** The auto-fetch budget's author map prunes
|
||||
least-recently-active past 64 entries instead of wiring roster eviction into
|
||||
the event task: authors are roster-gated upstream (≤32 live members), so
|
||||
strangers cannot churn the map, and a pruned author returning with full
|
||||
buckets is within policy.
|
||||
- **2026-07-18 (Phase 3):** Music-track serving shares the bounded serve store
|
||||
with chat attachments. A user who sends enough large attachments during a
|
||||
broadcast can evict their own current track; listeners then get the standard
|
||||
"sender no longer has the file" failure. Accepted: budget honesty over a
|
||||
second store, and the store comfortably fits current+next track plus a
|
||||
normal chat working set.
|
||||
- **2026-07-18 (Phase 3):** The clip player's command channel still takes one
|
||||
owned byte copy at the moment of a Play click (small, human-initiated). The
|
||||
Arc de-duplication targeted the send path (UI cache / command queue / serve
|
||||
store), which now shares a single allocation.
|
||||
- **2026-07-18 (Phase 3):** Overlong transfers are rejected by the transport
|
||||
read itself (`read_to_end(size)` errors past the bound) rather than an
|
||||
explicit length compare; short transfers get the explicit
|
||||
`len == declared_size` check. Music fetches ride `fetch_blob`, so they
|
||||
inherit exactness for free.
|
||||
- **2026-07-18 (Phase 4):** The S14 chat-body half (bidi strip) landed here per
|
||||
the security handoff: `sanitize_chat` strips ONLY bidi overrides/isolates
|
||||
(U+202A–202E, U+2066–2069) — the characters that can visually reorder a
|
||||
rendered line — while ZWJ/ZWNJ (emoji sequences, joining scripts) and the
|
||||
LRM/RLM direction *marks* (which cannot reorder) are kept. Labels/filenames
|
||||
keep the stricter full-format-strip.
|
||||
- **2026-07-18 (Phase 4):** A link's href is the exact displayed slice of the
|
||||
message — validation is parse-only, no normalization on open — so what the
|
||||
user sees IS the argv the opener receives. Consequence: WHATWG slash
|
||||
collapsing means `http:///path` parses to host `path` (as in browsers) and is
|
||||
accepted; the empty-host rejects are `http://` and friends that fail parsing.
|
||||
- **2026-07-18 (Phase 4):** URLs with userinfo syntax went the plan-preferred
|
||||
plain-text route (no confirmation dialog). A candidate that fails the policy
|
||||
leaves its WHOLE whitespace-delimited run as plain text without re-scanning
|
||||
the interior — `http://a@http://b.com` yields zero links, by design.
|
||||
- **2026-07-18 (Phase 4):** Scheme detection became ASCII-case-insensitive
|
||||
(`Http://…` from sentence auto-capitalization now linkifies); the policy
|
||||
check is unaffected since `url` normalizes scheme/host case during parsing.
|
||||
- **2026-07-18 (Phase 4):** Cached ranges in `ChatEntry.links`, filled inside
|
||||
`push_chat` (the single history choke point), were chosen over
|
||||
borrowed-return-per-redraw: redraws now slice cached char-boundary ranges,
|
||||
and only link spans allocate (their href String).
|
||||
- **2026-07-18 (Phase 4):** History byte-budget eviction (512 KiB, alongside
|
||||
the 300-entry cap) deliberately does NOT touch the attachment byte cache:
|
||||
that cache is bounded by its own Phase 3 budgets, and leaving it alone means
|
||||
an open Save/Play on an evicted line keeps its bytes-in-hand (the save
|
||||
dialog falls back to the generic "download" name). The just-pushed entry is
|
||||
never evicted; a single message's 8 KiB ceiling cannot exceed the budget.
|
||||
- **2026-07-18 (Phase 5):** Sender-side PACING was added to Phase 5's scope
|
||||
(originally receiver-status only). The Phase 2 decision log deferred the
|
||||
"apply the same local submit policy to accidental rapid Enter" item to pair
|
||||
with Phase 5, and honest status alone would still let a fast burst broadcast
|
||||
successfully yet be silently dropped by every receiver's per-author bucket
|
||||
(8 burst, then 1/s) with no sender feedback. The user chose "queue and
|
||||
trickle" over "throttle input": sends past the burst queue locally as
|
||||
`SendStatus::Queued` ("queued…") and release at the receivers' sustained
|
||||
rate, so nothing is lost and typing is never blocked.
|
||||
- **2026-07-18 (Phase 5):** The pacer (`src/app/sendqueue.rs`) reuses the
|
||||
gossip gate's OWN `TokenBucket` + `CHAT_AUTHOR_BURST`/`CHAT_AUTHOR_REFILL_PER_MS`
|
||||
(made `pub(crate)`), so the two sides of the rate policy are one definition
|
||||
and cannot drift. It mirrors only the PER-AUTHOR budget, not the room-wide
|
||||
one — we cannot know other members' send rates, and the per-author bucket is
|
||||
the one guaranteed to apply to us at every receiver.
|
||||
- **2026-07-18 (Phase 5):** Send status renders as a line UNDER the message
|
||||
(user pick over an inline suffix glyph); `Broadcast` and the transient
|
||||
`Pending` show nothing because PeerSpeak has no delivery/read receipts, so an
|
||||
unadorned message IS the honest "handed to the swarm" state. Only `Queued`
|
||||
and `Failed` (with Retry) are surfaced.
|
||||
- **2026-07-18 (Phase 5):** The pacer and the monotonic send-id counter
|
||||
deliberately SURVIVE a room reset while the queue and retry payloads are
|
||||
cleared: receivers' per-author buckets persist across our rejoin (so the
|
||||
pacer should not refill to full), and never-reused ids keep a late
|
||||
`ChatSendResult` from a pre-reset send from aliasing a new entry — verified by
|
||||
`send_result_after_room_reset_is_a_noop`.
|
||||
- **2026-07-18 (Phase 5):** The pacer clock is `Instant`-based
|
||||
(`AppState.send_clock`), not wall-clock, so a system time jump can neither
|
||||
rewind nor fast-forward the send budget.
|
||||
|
||||
## Completion
|
||||
|
||||
All five phases are implemented and every gate is green. Per the scope-contract
|
||||
note at the top, this file should be DELETED once the owed two-machine field
|
||||
test (the checklist below) has been run — that deletion is a separate,
|
||||
user-gated step, not part of the Phase 5 commit. Until then the plan stays as
|
||||
the record of what shipped and what remains to verify on real hardware.
|
||||
@@ -0,0 +1,472 @@
|
||||
# Phase 5 — dry-run audit gate: results
|
||||
|
||||
**Status: 🟢 GATE PASSED (run 2, 2026-07-26). All 13 §5.1 rows completed; the
|
||||
eligible half of every row is non-empty. O5 re-measured on the fixed graph and
|
||||
stays closed.** One new defect was found and fixed during the run (F13-1); three
|
||||
findings are recorded as non-blocking, and three rows carry recorded
|
||||
substitutions. Phase 6 is unblocked **by this file**, and F11-1 — the other gate —
|
||||
was closed with this data on 2026-07-26 (see "What still blocks phase 6").
|
||||
|
||||
- **Run date:** 2026-07-26 (run 1: 2026-07-25, gate FAILED — see history below)
|
||||
- **Host:** `cazen` — PipeWire 1.6.8, WirePlumber 0.5.15, CachyOS
|
||||
- **Audit build:** pixelpass `main` @ `91c4ded`, release profile
|
||||
- **peerspeak build:** `main` @ `b68fca6` (phase 1 merged)
|
||||
- **Ambient load:** Firefox playing audio throughout (a live, uncontrived
|
||||
candidate); Sunshine running (pid 3838); Arctis 1 Wireless as active sink
|
||||
- **Graph size:** 14 Nodes, 4 Devices, 57 Ports, 4 Links, 24 Clients
|
||||
|
||||
---
|
||||
|
||||
## What changed since run 1
|
||||
|
||||
Run 1 failed on two defects, both fixed before this run:
|
||||
|
||||
- **F1** (fatal): the registry `global` event delivers only a filtered subset of
|
||||
node properties, so eight properties the engine depends on were permanently
|
||||
absent. Fixed by design round 8 / **phase 3r** — bind every Node and Device
|
||||
and read properties from `info`.
|
||||
- **F2**: a machine-wide over-exclusion cascade downstream of F1.
|
||||
|
||||
Both are gone: the baseline run (no fixture at all) reports **1 candidate,
|
||||
eligible, empty taint set**.
|
||||
|
||||
### 🔴 F13-1 — FOUND AND FIXED DURING THIS RUN
|
||||
|
||||
**Row 1 failed on its first attempt, and the cause was a third defect of exactly
|
||||
the F2 class from a new source: pipewire-pulse's PID was unresolvable on this
|
||||
host, permanently.**
|
||||
|
||||
`pulse_pid::candidate` returned the single `pipewire.sec.pid` shared by two or
|
||||
more Clients, on the stated reasoning that "native PipeWire clients carry their
|
||||
own distinct PID; only the Pulse shim repeats one value". Measured: **WirePlumber
|
||||
repeats one too.** It holds two Clients — `WirePlumber` and
|
||||
`WirePlumber [export]` — both `sec_pid` 1747. Two values repeated (1747 and
|
||||
pipewire-pulse's 2528), the rule called that ambiguous, and returned `None`.
|
||||
|
||||
With the daemon PID unknown, `owner::keys_of`'s documented fail-closed asymmetry
|
||||
takes over: key 4's suppression never fires, every Pulse-emulated node fuses into
|
||||
one owner, and the cascade follows. Row 1's observed failure:
|
||||
|
||||
```
|
||||
ELIGIBLE (1): r1_plain_app
|
||||
EXCLUDED: Firefox tainted-owner-bridge key=application.process.id
|
||||
r1_c_play tainted-owner-bridge <- the CLEAN control half
|
||||
TAINT: ... + both sound cards, all three sunshine sinks, sunshine itself
|
||||
```
|
||||
|
||||
The rule was wrong in **both** directions, so the prefilter was removed rather
|
||||
than patched:
|
||||
|
||||
- **False ambiguity** — any second process holding two Clients defeats it.
|
||||
WirePlumber always does, so this was permanent, not a corner case.
|
||||
- **False absence** — a session where pipewire-pulse holds exactly one Client
|
||||
(one Pulse app running) repeats nothing, so the candidate is missed and the
|
||||
same cascade follows.
|
||||
|
||||
`comm` was always the authoritative check; repetition was a heuristic standing in
|
||||
front of it, and it was a guess about other processes' Client counts. Fixed in
|
||||
pixelpass `91c4ded`: `candidates()` lists every distinct `sec_pid`, `resolve()`
|
||||
picks the unique one whose `/proc/<pid>/comm` is exactly `pipewire-pulse`, and
|
||||
several matches still fail closed (a single `Option<u32>` cannot suppress two
|
||||
daemons — recorded, not approximated). The adapter probes only PIDs *entering*
|
||||
the candidate set, and `retain_probed_comms` bounds the map to live PIDs so a PID
|
||||
that leaves and returns is re-probed instead of answered from a stale `comm`.
|
||||
|
||||
**This is the §5.1 exact-partition requirement earning its keep for the second
|
||||
time.** The verdict was fail-closed and silent; only the asserted *eligible* half
|
||||
exposed it. An exclusion-only checklist would have passed this build too.
|
||||
|
||||
---
|
||||
|
||||
## §5.1 — the matrix
|
||||
|
||||
Every row ran with `PIXELPASS_AUDIO_AUDIT_AEC=off` except row 12. Every row ran
|
||||
in its **own** audit process, so nothing carries over (sticky taint is
|
||||
per-process state).
|
||||
|
||||
⚠️ **Methodology change from run 1, and it is load-bearing.** Run 1 built each
|
||||
fixture *before* starting the audit. On this host the entire graph then arrives
|
||||
as one enumeration burst (~122 events in 1–2 ms), so every node is first tainted
|
||||
while `graph_ready` is still false, that partial-graph taint is recorded into
|
||||
sticky state, and on the single ready record the sticky pass raises
|
||||
`TaintedOwnerBridge { key: None }` before the evidence pass can name a key —
|
||||
`raise` will not replace a same-rank reason. Verdicts were still correct but rows
|
||||
could not assert their key. This run starts the audit first, waits for readiness,
|
||||
then builds the fixture, so taint is derived from real topology *changes* against
|
||||
a ready graph — which is also the dynamic path §6.3 cares about. Keys are read at
|
||||
**derivation** (first non-sticky appearance), not from the final record.
|
||||
|
||||
| # | scenario | status |
|
||||
| --- | --- | --- |
|
||||
| 1 | null-sink + loopback forwarder, owner bridge | ✅ **pass** (after F13-1 fixed) |
|
||||
| 1b | Sunshine's topology (opportunistic, non-gating) | 🟡 observed, nothing to exclude — see below |
|
||||
| 2 | gst split clients, tainted input | ✅ **pass**, key 4 named at derivation |
|
||||
| 3 | two Pulse modules, one tainted | ✅ **pass** |
|
||||
| 4 | peerspeak native call playback | ✅ **pass** — real tagging site |
|
||||
| 5 | peerspeak-spawned mpv | ✅ **pass** — real tagging site, hand-launched mpv eligible |
|
||||
| 6 | peerspeak notification sound | ✅ **pass** — real tagging site |
|
||||
| 7 | second host's capture sink + forwarder | ✅ **pass**, eligible half non-empty |
|
||||
| 8 | EasyEffects | 🟡 **pass with substitution** — echo-cancel stood in |
|
||||
| 9 | Firefox three cases | ✅ **pass** (cases 2–3 via gst; see substitution) |
|
||||
| 10 | sticky taint across teardown | ✅ **pass**, all four phases incl. retirement |
|
||||
| 11 | recycled serial / index / link-group | ✅ **pass**, and provably non-vacuous |
|
||||
| 12 | AEC loaded → unloaded → Revoked | ✅ **pass** |
|
||||
| 13 | `Audio/Duplex` device | 🟡 **pass with synthetic node** — over-taint confirmed |
|
||||
|
||||
### Row 1 — owner bridge, key named
|
||||
|
||||
```
|
||||
ELIGIBLE (3): Firefox · r1_c_play · r1_plain_app
|
||||
EXCLUDED (2): peerspeak_owned_call_4242 peerspeak-owned
|
||||
r1_t_play tainted-owner-bridge key=node.link-group
|
||||
TAINT (5): the tagged producer, r1_t_src, r1_t_cap, r1_t_play, r1_t_dest
|
||||
```
|
||||
|
||||
The clean half is an **identically shaped** forwarder — same module type, same
|
||||
monitor-read, same re-emit — differing only in whether anything tainted feeds it.
|
||||
`r1_c_play` eligible is the assertion an exclude-everything build cannot satisfy.
|
||||
The key is `node.link-group`, a strong key, not a link walk.
|
||||
|
||||
### Row 2 — GStreamer split clients, key 4
|
||||
|
||||
Measured props confirm the shape is the real refutation: `r2_gst_tainted_src`
|
||||
(client 188) and `r2_gst_tainted_sink` (client 191) are **different Clients** of
|
||||
**one process**, pid 235628, with no `link-group` and no `pulse.module.id`. So
|
||||
`application.process.id` is the only key that can relate them.
|
||||
|
||||
Derivation record (seq 209): `r2_gst_tainted_sink` → `tainted-owner-bridge`,
|
||||
**`owner_key=application.process.id`**. `r2_gst_clean_sink`, reading an untainted
|
||||
monitor in a second process, is eligible.
|
||||
|
||||
### Rows 4–6 — peerspeak's own paths, through the real call sites
|
||||
|
||||
Driven by peerspeak's phase-1 live gate tests (`--ignored`), i.e. the real
|
||||
tagging sites, not a hand-rolled env: "emission alone proves only that peerspeak
|
||||
talks, not that pixelpass listens" (impl plan §3).
|
||||
|
||||
| node | verdict |
|
||||
| --- | --- |
|
||||
| `peerspeak_owned_call_238172` | EXCLUDED `peerspeak-owned` |
|
||||
| `peerspeak_owned_mpv_238196` | EXCLUDED `peerspeak-owned` |
|
||||
| `peerspeak_owned_notify_238231` | EXCLUDED `peerspeak-owned` |
|
||||
| `peerspeak_owned_clip_238249` | EXCLUDED `peerspeak-owned` (bonus — chat clips) |
|
||||
| `mpv` (launched by hand, untagged) | **ELIGIBLE** |
|
||||
|
||||
This is the cross-repo contract closed end to end on live nodes.
|
||||
|
||||
### Row 9 — the over-exclusion promise
|
||||
|
||||
```
|
||||
ELIGIBLE: Firefox (music only) · r9_mic_out (captures an untainted real device)
|
||||
EXCLUDED: r9_mon_out tainted-owner-bridge key=application.process.id
|
||||
```
|
||||
|
||||
`r9_mic_out` is the row that defends §6.1.1: an app that captures a real
|
||||
`session_device` source and also plays audio stays shareable. The device source
|
||||
itself never entered the taint set.
|
||||
|
||||
### Row 10 — the full sticky lifecycle
|
||||
|
||||
| phase | topology | verdict |
|
||||
| --- | --- | --- |
|
||||
| A | tainted producer + forwarder | `r10_play_out` EXCLUDED, key `node.link-group` |
|
||||
| B | **tagged producer killed**, forwarder lives | **still EXCLUDED** (sticky) — current topology alone no longer justifies it |
|
||||
| C | forwarder owner replaced, tainted sink kept | fresh forwarder EXCLUDED — correct: a sink that received call audio is still a hazard while it lives |
|
||||
| D | **every** tainted object torn down, then restart | taint set **empty** at 16.3 s; `r10_new_out` **ELIGIBLE** at 20.3 s |
|
||||
|
||||
Phase B proves stickiness works; phase D proves it is not permanent. Phase C is
|
||||
worth keeping in mind when reading any future report: partial teardown legitimately
|
||||
does *not* retire taint, and that is easy to mistake for over-exclusion.
|
||||
|
||||
### Row 11 — recycled identifiers, provably non-vacuous
|
||||
|
||||
| generation | `node.link-group` | global id (`r11_src`) | `object.serial` (`r11_play`) | pulse module |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 (tainted) | `loopback-2528-14` | 168 | 4702 | 536870919 |
|
||||
| 2 (after teardown) | **`loopback-2528-14`** | **168** | 4746 | 536870920 |
|
||||
|
||||
The `node.link-group` came back **byte-identical** — and it is the very key that
|
||||
carried the taint in generation 1 — and the global id was reused. Generation 2's
|
||||
`r11_play` is **ELIGIBLE** with an empty taint set. `object.serial` correctly did
|
||||
not recycle, which is why the model keys everything by it.
|
||||
|
||||
### Row 12 — AEC lifecycle
|
||||
|
||||
| stage | `aec_state` | `fan_out_permitted` | candidates |
|
||||
| --- | --- | --- | --- |
|
||||
| module live, configured | `validated` | `true` | Firefox + `r12_plain_app` ELIGIBLE; `echo-cancel-playback` EXCLUDED `aec-identity` |
|
||||
| module unloaded | `revoked` | `false` (`gate_reason=aec-revoked`) | every candidate EXCLUDED `aec-revoked` |
|
||||
|
||||
All **four** link-group siblings (`sink`, `source`, `capture`, `playback`) carry
|
||||
`aec-identity`; only `echo-cancel-playback` is a candidate, so it is the only one
|
||||
in the excluded partition. Ordinary apps staying eligible *while validated* is
|
||||
what makes "the gate is open" observable rather than inferred.
|
||||
|
||||
### Row 13 — `Audio/Duplex` over-taint (known accepted)
|
||||
|
||||
No real duplex device exists on this host, so one was synthesised by overriding
|
||||
`media.class=Audio/Duplex` on a null sink. Its playback side was tainted and its
|
||||
capture-side consumer was dragged down with it (`r13_dup_play` EXCLUDED), with
|
||||
the eligible half intact. **Fixture limit, stated plainly:** on a null sink the
|
||||
capture side *is* the monitor, so this cannot separate the duplex smear from the
|
||||
ordinary sink→monitor edge. The accepted over-taint is confirmed as *behaviour*;
|
||||
a real duplex device is still the only way to isolate the mechanism.
|
||||
|
||||
### Row 1b — Sunshine (opportunistic, non-gating)
|
||||
|
||||
Sunshine ran throughout. Its three null sinks stayed SUSPENDED and it read the
|
||||
**hardware** monitor instead, exactly as §5.3 warned. It appears consistently and
|
||||
correctly as `sunshine` / `tainted-upstream` whenever the monitor it reads is
|
||||
tainted (rows 8, 12, o5). It has **no re-emitting output leg** — it sends over
|
||||
the network — so it is never a candidate and there is nothing to exclude. Recorded
|
||||
as observed; the "if a re-emitting leg exists" clause did not apply. A real
|
||||
third-party forwarder sample remains owed.
|
||||
|
||||
---
|
||||
|
||||
## §5.2 — O5 re-measured
|
||||
|
||||
The run-1 numbers do not carry over: they were measured on the graph F1 degraded,
|
||||
and phase 3r adds a bind plus an `info` round-trip **per node**, which is new I/O
|
||||
that run never exercised.
|
||||
|
||||
Per-run, across all 13 rows (`recompute` in µs):
|
||||
|
||||
| run | events | ev/s | max | mean | emit max | busy fraction | ready@ms |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| baseline | 123 | 21.4 | 20 | 3 | 6 | 0.0001 | 1 |
|
||||
| o5 (churn) | 407 | 44.0 | 32 | 10 | 9 | 0.0006 | 1 |
|
||||
| row01 | 219 | 41.7 | 53 | 10 | 9 | 0.0006 | 1 |
|
||||
| row02 | 241 | 45.9 | **67** | 11 | 10 | 0.0006 | 1 |
|
||||
| row03 | 206 | 48.5 | 54 | 8 | 8 | 0.0005 | 1 |
|
||||
| row0456 | 185 | 20.0 | 38 | 7 | 10 | 0.0002 | 2 |
|
||||
| row07 | 184 | 43.3 | 40 | 7 | 9 | 0.0004 | 1 |
|
||||
| row08 | 172 | 32.6 | 44 | 6 | 7 | 0.0003 | 2 |
|
||||
| row09 | 224 | 30.9 | 52 | 9 | 9 | 0.0004 | 1 |
|
||||
| row10 | 332 | 14.3 | 41 | 11 | 15 | 0.0002 | 1 |
|
||||
| row11 | 298 | 24.1 | 41 | 9 | 11 | 0.0003 | 1 |
|
||||
| row12 | 188 | 25.9 | 39 | 7 | 8 | 0.0003 | 1 |
|
||||
| row13 | 193 | 36.8 | 43 | 8 | 7 | 0.0004 | 1 |
|
||||
|
||||
The dedicated churn run (five load/unload cycles of null-sink + loopback, the
|
||||
same shape as run 1's measurement):
|
||||
|
||||
```json
|
||||
{"kind":"metrics","graph_events":407,"tick_events":37,"emitted_records":407,
|
||||
"span_us":9249639,"graph_events_per_sec":44.0,
|
||||
"recompute_max_us":32,"recompute_mean_us":10,
|
||||
"recompute_p50":"<50us","recompute_p90":"<50us","recompute_p99":"<50us",
|
||||
"recompute_distribution":[["<50us",444]],
|
||||
"emit_max_us":9,"emit_mean_us":1,
|
||||
"busy_us":5240,"busy_fraction":0.0006,
|
||||
"queued_events":292,"queue_threshold_us":100}
|
||||
```
|
||||
|
||||
**O5 stays closed on the real graph.** Worst recompute across every run is
|
||||
**67 µs**; every single recompute in the churn run finished under 50 µs, against
|
||||
a 44 Hz event rate under churn heavier than a desktop produces at rest. The
|
||||
observer thread spent **0.06 %** of wall time working. Node binding roughly
|
||||
doubled the per-event cost (run 1: 15 µs max / 4 µs mean; now 32 µs / 10 µs on
|
||||
the same churn shape) and that is the honest cost of the F1 fix — it buys three
|
||||
orders of magnitude of remaining headroom, not one.
|
||||
|
||||
**Readiness with node binds: 1–2 ms**, with ~122 enumeration events and 18 binds
|
||||
(14 Nodes + 4 Devices), against the 2000 ms budget. `queued_events` is high
|
||||
(292) for the same benign reason as run 1: PipeWire delivers enumeration and
|
||||
teardown in bursts, and a 32 µs recompute drains a burst faster than it forms.
|
||||
`busy_fraction` is the number to trust.
|
||||
|
||||
⚠️ **The readiness budget still has no calibration argument.** 1–2 ms against
|
||||
2000 ms is three orders of magnitude of slack on *this* host with 18 binds; it is
|
||||
not an argument about a host with a large USB interface, many virtual devices, or
|
||||
a cold cache. Carried forward as open, unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Findings recorded, not blocking
|
||||
|
||||
### R2-1 — the audit's `sticky` flag is nearly always true, so it says little
|
||||
|
||||
As emitted, `sticky` means "this node is in the remembered set", which
|
||||
`seed_sticky` populates for any node whose current reason the sticky pass agrees
|
||||
with — i.e. essentially every currently-tainted node. It does **not** mean
|
||||
"excluded *only* because remembered", which is what its doc comment implies and
|
||||
what a reader diagnosing "why is this still excluded?" wants.
|
||||
|
||||
The information exists: round 9 already computes a second, **evidence-only** pass
|
||||
(that is the whole provenance mechanism). Emitting "excluded by memory alone"
|
||||
would make row 10 phase B assertable from a single record instead of from a
|
||||
sequence. Not fixed here — it is a reporting change to a merged phase in the
|
||||
middle of a gate run. Row 10 was asserted behaviourally instead, which is
|
||||
stronger anyway.
|
||||
|
||||
### R2-2 — a bridge key is lost when a leg reappears under a new serial
|
||||
|
||||
Row 2 named `application.process.id` at derivation (seq 209), then gst re-created
|
||||
that node; the sticky owner re-seeded the new serial through `reason_for`, whose
|
||||
documented fallback is `TaintedOwnerBridge { key: None }`, and `raise` will not
|
||||
replace a same-rank reason with a better-informed one. The verdict is unaffected;
|
||||
only the diagnosis degrades. The fallback is honest when the owner has no live
|
||||
tainted receiver, and stale when it does — which is the case worth improving.
|
||||
|
||||
### R2-3 — `owner_key` had to be added to the record to run row 1 at all
|
||||
|
||||
Row 1 asserts "reason = owner bridge, **naming the key**", and the record could
|
||||
not express it: `Reason::code` collapses `TaintedOwnerBridge { key }` to one
|
||||
string. `OwnerKey::code` already documented itself as ending up in the phase 5
|
||||
audit output; it was simply never wired to it. Added in pixelpass `d462754`
|
||||
(read-only, diagnostic-only, mutation-verified test). Worth noting as a gate-spec
|
||||
lesson: the row could not have been asserted from any previous build's output.
|
||||
|
||||
---
|
||||
|
||||
## Substitutions, stated so they are not mistaken for passes
|
||||
|
||||
| row | asked for | used instead | why |
|
||||
| --- | --- | --- | --- |
|
||||
| 8 | EasyEffects | `module-echo-cancel` with `AEC=off` | EasyEffects makes itself the default sink on start and the user had live audio playing. `module-filter-chain` cannot stand in either — it is a PipeWire module, so `pactl load-module` answers "No such entity" (measured). The stand-in produces the same shape (four nodes, one `node.link-group`) and exercises `foreign-echo-cancel` (decision D3), a reason code no other row reaches. |
|
||||
| 9 | Firefox's mic + monitor capture | `gst-launch` pipelines | Firefox's mic and monitor-capture paths need interactive GUI permission grants. Firefox is present live as case 1 in every row. Case 2 captures the motherboard's **analog input**, not the headset mic the user is wearing — identical to the engine (both `session_device` sources), and nothing of the user is recorded. |
|
||||
| 13 | a real `Audio/Duplex` device | synthetic `media.class` override | None on this host. See row 13 above for what the fixture cannot show. |
|
||||
|
||||
---
|
||||
|
||||
## What still blocks phase 6
|
||||
|
||||
This file passing removes **one** of the two gates. F11-1, the other, is now
|
||||
closed. Still outstanding:
|
||||
|
||||
1. **Hardware playback-to-capture paths ("Stereo Mix")** defeat `session_device`
|
||||
and are a real echo path — needs ALSA control inspection; user design call owed.
|
||||
2. **Phases 0b / 0c / 0d** are untouched and all precede phase 6.
|
||||
3. **The readiness budget calibration argument** (above).
|
||||
4. **Owed samples:** a real third-party forwarder (row 1b), EasyEffects (row 8),
|
||||
a real `Audio/Duplex` device (row 13).
|
||||
|
||||
### ✅ F11-1 — closed 2026-07-26, with this matrix's data
|
||||
|
||||
The rule now implemented (pixelpass `c78eb2d`, §6.1.2's round-13 box): **key 4 bounds an
|
||||
owner only when the node's Client resolves** — an unambiguous Client yielding
|
||||
`Some(pipewire.sec.pid)`, read *before* pipewire-pulse suppression — so a node can no
|
||||
longer bound itself, and escape `propagate_unresolved_owner`'s sweep, with an
|
||||
`application.process.id` it invented. Bridging still uses the full union.
|
||||
|
||||
Codex's round-12 sharpening was the decisive part: "resolved" must mean a `sec_pid`, not
|
||||
"a unique Client object exists", and the **unique-but-pid-less** row is the only one that
|
||||
tells the two apart. All five Client cases are unit tests (absent · ambiguous ·
|
||||
unique-but-pid-less · resolved-native · resolved-to-pipewire-pulse), plus the recorded
|
||||
three-step leak path end to end. Mutation-verified: dropping the provenance test fails
|
||||
four of the six rows and leaves the two no-over-exclusion rows green.
|
||||
|
||||
**The cost question the deferral was waiting on, measured on this host:** the before- and
|
||||
after-binaries audited the *same* live graph simultaneously (both are read-only observers)
|
||||
— tagged producer into the default sink, `parec` on its monitor as a live tainted reader
|
||||
so the sweep was genuinely armed, Firefox + `aplay` + `pacat` as bystanders. **181 records
|
||||
each, the same 14 distinct decision states, none exclusive to either side, no
|
||||
`unresolved-owner` on either, eligible half non-empty throughout.** O5 unmoved (identical
|
||||
p50 15 µs and busy fraction 0.0012). Every real app here is native or Pulse-emulated and
|
||||
**both resolve**; sweeping all 18 live nodes, the only unresolved-Client ones were
|
||||
`Dummy-Driver` and `Freewheel-Driver`, which carry no pid key to lose.
|
||||
|
||||
---
|
||||
|
||||
## Reproducing this run
|
||||
|
||||
Scripts live in the session scratchpad (not committed — they hard-code paths):
|
||||
one per row, plus `lib.sh`, `summarize.py` and `keys.py`. The shape of every row:
|
||||
|
||||
```sh
|
||||
audit_start out.jsonl off # start FIRST, wait for graph_ready
|
||||
... build fixture ... # taint arrives as topology CHANGES
|
||||
audit_stop # SIGTERM: flushes the O5 summary
|
||||
python3 summarize.py out.jsonl # final partition + derivations + metrics
|
||||
```
|
||||
|
||||
```
|
||||
env PIXELPASS_AUDIO_AUDIT_FILE=/path/out.jsonl PIXELPASS_AUDIO_AUDIT_AEC=off \
|
||||
./target/release/pixelpass --audit-audio
|
||||
```
|
||||
|
||||
Rig notes that cost time:
|
||||
|
||||
- A tagged producer: `env PIPEWIRE_ALSA='{ "peerspeak.owned": "1", "node.name":
|
||||
"peerspeak_owned_call_4242", "target.object": "<sink>" }' aplay -c 2 -r 48000
|
||||
-f S16_LE -t raw -d 30 /dev/zero`. Both carriers land, and `target.object`
|
||||
routes it.
|
||||
- ⚠️ `pactl load-module module-echo-cancel --help` **loads the module** with
|
||||
`--help` as its argument instead of printing help. It was loaded accidentally
|
||||
during this session and unloaded again; check `pactl list short modules` after
|
||||
any such probe.
|
||||
- ⚠️ `pkill -f <pattern>` matches the harness's own shell command line and kills
|
||||
the script. Use `pkill -x` or an exact pid.
|
||||
- ⚠️ Under `set -e`, `kill` on an already-exited pid aborts the row before its
|
||||
modules are unloaded; and `timeout` exiting 124 is *success* for the audit.
|
||||
|
||||
---
|
||||
|
||||
## History — run 1 (2026-07-25): GATE FAILED
|
||||
|
||||
Kept because the reasoning is still the record of why the observation boundary
|
||||
was redesigned.
|
||||
|
||||
### F1 🔴 FATAL — the registry `global` event delivers only a filtered subset of node properties
|
||||
|
||||
The phase-3 adapter read eight node properties the registry never announces.
|
||||
Parsed off `obj.props` in the registry `global` callback, they were silently
|
||||
absent, so every one was permanently `None`/`false`.
|
||||
|
||||
The complete set the registry announces for a `Node` on this host:
|
||||
|
||||
```
|
||||
application.name client.api client.id device.id factory.id media.class
|
||||
node.description node.name node.nick object.path object.serial
|
||||
priority.driver priority.session
|
||||
```
|
||||
|
||||
| property | announced? | what died without it |
|
||||
| --- | --- | --- |
|
||||
| `object.serial`, `node.name`, `media.class`, `client.id`, `device.id` | ✅ | — |
|
||||
| **`peerspeak.owned`** | ❌ | **the primary taint root (all of phase 1)** |
|
||||
| **`pulse.module.id`** | ❌ | **AEC identity exclusion + phase 4 validation** |
|
||||
| **`node.link-group`** | ❌ | the link-group owner key |
|
||||
| **`application.process.id`** | ❌ | the process owner key |
|
||||
| **`node.passthrough`** | ❌ | the passthrough local exclusion |
|
||||
| **`device.api`**, **`factory.name`**, **`alsa.driver_name`** | ❌ | `session_device` classification |
|
||||
|
||||
Ports lost `port.exclusive`; Links and Clients were fine — notably
|
||||
`pipewire.sec.pid` **is** announced, so pulse-PID derivation was reachable.
|
||||
|
||||
Demonstrated end to end: a null sink carrying `peerspeak.owned=true` whose
|
||||
monitor a `module-loopback` re-emitted was reported **eligible** with an **empty
|
||||
taint set**. In phase 6 that is an echo.
|
||||
|
||||
The fix became design round 8 (v3.5 §6.7) and phase 3r: bind each Node and read
|
||||
props off its `info`, exactly how `pw-dump` obtains them. `factory.id` is not a
|
||||
shortcut (`factory.id=19` resolves to `factory.name = "adapter"`), and
|
||||
`device.api` is on the *Device* global.
|
||||
|
||||
### F2 🟠 Machine-wide over-exclusion cascade, downstream of F1
|
||||
|
||||
With F1 in force, `pixelpass_capture_*` (matched on `node.name`, which *is*
|
||||
announced) was the only surviving taint root. Row 7 then excluded every
|
||||
`Stream/Output/Audio` on the machine: with no strong owner keys, every tainted
|
||||
capture stream was an **unbounded tainted reader**, tripping phase 2's
|
||||
fail-closed backstop, while WirePlumber's shared `client.id = 42` fused the
|
||||
device layer into one owner.
|
||||
|
||||
Net live behaviour: exclude everything, always, as soon as pixelpass's own
|
||||
capture sink existed. Fail-closed, so silence rather than echo — but entirely
|
||||
non-functional, and non-functional in a way that would have looked like "working
|
||||
safely" to any test that asserted only exclusions.
|
||||
|
||||
### What run 1's machinery got right
|
||||
|
||||
None of this needed revisiting:
|
||||
|
||||
- Running the recompute **inline on the observer thread**, once per applied
|
||||
registry event, upheld phase 4's no-coalescing contract and put the cost where
|
||||
O5 could measure it.
|
||||
- The **complete-partition record** is what caught F2 — and, in run 2, F13-1.
|
||||
- **Reason codes survived the trip** and were immediately diagnostic.
|
||||
- The **`peerspeak.owned` / `pulse.module.id` fixtures were right**: the engine
|
||||
does the correct thing when handed correct properties. Both failures were at
|
||||
the observation boundary, which is where phase 5 was designed to look.
|
||||
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 139 KiB |
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.tools/
|
||||
AppDir/
|
||||
*.AppImage
|
||||
squashfs-root/
|
||||
@@ -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" "$@"
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -0,0 +1,108 @@
|
||||
# PeerSpeak — how to install and join a call (Windows)
|
||||
|
||||
PeerSpeak is a little voice-chat app — like a private phone call over the
|
||||
internet, with no account, no signup, and no company in the middle. You install
|
||||
it once, then you and I connect directly to each other.
|
||||
|
||||
---
|
||||
|
||||
## 1. Install it
|
||||
|
||||
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
|
||||
paid certificate. It is **not** a virus warning.
|
||||
- Click **More info**
|
||||
- Then click **Run anyway**
|
||||
|
||||
3. Windows will ask *"Do you want to allow this app to make changes?"* — click
|
||||
**Yes**.
|
||||
|
||||
4. The setup window opens. Just keep clicking **Next**. Two checkboxes you'll
|
||||
see along the way:
|
||||
- **"Allow PeerSpeak through Windows Firewall"** — leave this **checked**
|
||||
(it lets the call connect without interruptions).
|
||||
- **"Create a desktop shortcut"** — check it if you'd like an icon on your
|
||||
desktop.
|
||||
|
||||
5. Click **Install**, then **Finish**. PeerSpeak opens.
|
||||
|
||||
That's it — it's installed. You can find it again any time from the **Start
|
||||
menu** (search "PeerSpeak").
|
||||
|
||||
---
|
||||
|
||||
## 2. Get on a call with me
|
||||
|
||||
PeerSpeak connects two people using a **room ticket** — a long code that acts
|
||||
like a one-time phone number for a specific call.
|
||||
|
||||
**The simple way (I host):**
|
||||
|
||||
1. I'll create a room and send you a **ticket** (a long jumble of letters and
|
||||
numbers).
|
||||
2. Copy the whole ticket I sent you.
|
||||
3. In PeerSpeak, paste it into the **"Join Room"** box near the bottom and press
|
||||
**Join**.
|
||||
4. You're in — you should see both our names listed, and we can talk.
|
||||
|
||||
**If you want to host instead:**
|
||||
|
||||
1. Type a room name and click **Create New Room**.
|
||||
2. PeerSpeak gives you a **ticket** — click **Copy Ticket** and send it to me.
|
||||
3. I paste it on my end and join you.
|
||||
|
||||
Either way works the same; it just depends on who makes the room.
|
||||
|
||||
---
|
||||
|
||||
## 3. While you're on a call
|
||||
|
||||
- **Your microphone** is on by default. There's a **mute** button if you need
|
||||
it.
|
||||
- The first time, Windows might ask for permission to use your **microphone** —
|
||||
click **Yes / Allow**.
|
||||
- If you can't hear me or I can't hear you, open **Settings** (top right) and
|
||||
check that the right **microphone** and **speakers/headphones** are selected.
|
||||
- To hang up, click **Leave 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. 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.
|
||||
|
||||
Any trouble, just message me and we'll sort it out.
|
||||
@@ -0,0 +1,85 @@
|
||||
# PeerSpeak — Windows installer
|
||||
|
||||
This directory builds a Windows setup installer for PeerSpeak using
|
||||
[Inno Setup](https://jrsoftware.org/isinfo.php).
|
||||
|
||||
PeerSpeak ships as a **single self-contained `peerspeak.exe`** — the GUI icon,
|
||||
notification chimes, and avatar presets are all embedded in the binary
|
||||
(`include_bytes!`), and the executable is statically linked against the GNU
|
||||
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 |
|
||||
|------|---------|---------|
|
||||
| `peerspeak.iss` | yes | Inno Setup script |
|
||||
| `peerspeak.ico` | yes | multi-resolution app icon (from `assets/icons/*.png`) |
|
||||
| `README.md` | yes | this file |
|
||||
| `peerspeak.exe` | no (gitignored) | staged build artifact, copied from `target/x86_64-pc-windows-gnu/release/` |
|
||||
| `output/peerspeak-<ver>-setup.exe` | no (gitignored) | the compiled installer |
|
||||
|
||||
## Build steps
|
||||
|
||||
1. **Cross-compile the Windows binary** (from the repo root, inside the
|
||||
`peerspeak-win` archlinux distrobox):
|
||||
|
||||
```sh
|
||||
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
|
||||
```
|
||||
|
||||
This needs the `rust-src` component and the `x86_64-pc-windows-gnu` target
|
||||
installed in that toolchain. The result is a statically-linked,
|
||||
GUI-subsystem `.exe` (no stray console window).
|
||||
|
||||
2. **Stage the binary** next to the script:
|
||||
|
||||
```sh
|
||||
cp target/x86_64-pc-windows-gnu/release/peerspeak.exe packaging/windows/
|
||||
```
|
||||
|
||||
3. **Regenerate the icon** if the source PNGs changed:
|
||||
|
||||
```sh
|
||||
magick assets/icons/peerspeak-16.png assets/icons/peerspeak-24.png \
|
||||
assets/icons/peerspeak-32.png assets/icons/peerspeak-48.png \
|
||||
assets/icons/peerspeak-64.png assets/icons/peerspeak-128.png \
|
||||
assets/icons/peerspeak-256.png packaging/windows/peerspeak.ico
|
||||
```
|
||||
|
||||
4. **Compile the installer** with Inno Setup. On Linux this runs under Wine:
|
||||
|
||||
```sh
|
||||
cd packaging/windows
|
||||
wine ~/.wine/drive_c/InnoSetup6/ISCC.exe peerspeak.iss
|
||||
```
|
||||
|
||||
The installer lands at `output/peerspeak-<version>-setup.exe`.
|
||||
|
||||
## What the installer does
|
||||
|
||||
- Installs `peerspeak.exe` to `Program Files\PeerSpeak` (requires admin / one
|
||||
UAC prompt).
|
||||
- Creates a Start-menu shortcut, with an optional desktop shortcut.
|
||||
- Optionally adds a Windows Firewall allow-rule for PeerSpeak (recommended —
|
||||
iroh uses UDP hole-punching, so this avoids a mid-call firewall prompt). The
|
||||
rule is removed on uninstall.
|
||||
- Provides a standard uninstaller.
|
||||
|
||||
> **Note:** the installer and the binary are **not code-signed**, so Windows
|
||||
> SmartScreen will show an "unknown publisher" warning on first run. The user
|
||||
> clicks *More info → Run anyway*. Removing this warning requires a paid
|
||||
> code-signing certificate.
|
||||
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,63 @@
|
||||
; Inno Setup script for PeerSpeak (Windows installer).
|
||||
;
|
||||
; PeerSpeak is a single self-contained binary: the GUI icon, notification
|
||||
; chimes, and avatar presets are all embedded in the .exe (include_bytes!),
|
||||
; so the only payload here is peerspeak.exe plus an .ico for the shortcuts.
|
||||
;
|
||||
; Build (under Wine on Linux, or native Windows):
|
||||
; wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" peerspeak.iss
|
||||
; Output lands in .\output\peerspeak-<version>-setup.exe
|
||||
;
|
||||
; The peerspeak.exe is cross-compiled with win-cross-build.sh
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.6.6"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
[Setup]
|
||||
; A stable AppId keeps upgrades/uninstall tracking consistent across versions.
|
||||
AppId={{2754D6C1-C8A4-4B13-9824-2D303439739D}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DefaultGroupName={#MyAppName}
|
||||
DisableProgramGroupPage=yes
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
SetupIconFile=peerspeak.ico
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
OutputDir=output
|
||||
OutputBaseFilename=peerspeak-{#MyAppVersion}-setup
|
||||
; Program Files install + firewall rule both need elevation.
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
Name: "firewall"; Description: "Allow PeerSpeak through Windows Firewall (recommended for voice calls)"; GroupDescription: "Network:"
|
||||
|
||||
[Files]
|
||||
Source: "peerspeak.exe"; DestDir: "{app}"; Flags: ignoreversion
|
||||
Source: "peerspeak.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"
|
||||
Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
|
||||
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\peerspeak.ico"; Tasks: desktopicon
|
||||
|
||||
[Run]
|
||||
; iroh uses UDP hole-punching; pre-authorizing avoids a mid-call firewall prompt.
|
||||
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall add rule name=""PeerSpeak"" dir=in action=allow program=""{app}\{#MyAppExeName}"" enable=yes profile=any"; Flags: runhidden; Tasks: firewall
|
||||
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#MyAppName}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
[UninstallRun]
|
||||
Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=""PeerSpeak"""; Flags: runhidden; RunOnceId: "DelPeerSpeakFirewall"
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Sender-side chat send status and pacing (chat-hardening Phase 5).
|
||||
//!
|
||||
//! Every RECEIVER admits our chat through a per-author token bucket
|
||||
//! ([`CHAT_AUTHOR_BURST`] then 1/s) and silently drops what exceeds it, with no
|
||||
//! acknowledgement wire. The only way the sender can be honest about fast
|
||||
//! bursts is to never exceed that budget in the first place: sends past the
|
||||
//! burst are queued locally (shown as "queued…") and trickled out at the
|
||||
//! receivers' sustained rate. The pacer deliberately reuses the receiver
|
||||
//! gate's own [`TokenBucket`] and constants so the two sides of the policy
|
||||
//! cannot drift apart.
|
||||
//!
|
||||
//! Everything here is pure — `now_ms` is passed in, never read from a clock —
|
||||
//! so every boundary is unit-testable.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::network::gossip::{CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, TokenBucket};
|
||||
|
||||
/// Send lifecycle of one locally authored chat message. Success is
|
||||
/// [`SendStatus::Broadcast`] — "our signed frame was handed to the gossip
|
||||
/// swarm" — deliberately NOT "delivered": PeerSpeak has no peer
|
||||
/// acknowledgements, so the honest success presentation is no label at all.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SendStatus {
|
||||
/// Waiting in the local outbound queue for a pacer token.
|
||||
Queued,
|
||||
/// Handed to the core; the broadcast result has not come back yet.
|
||||
Pending,
|
||||
/// The signed broadcast reached the gossip swarm.
|
||||
Broadcast,
|
||||
/// The send failed; carries a short reason. The entry offers a Retry.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
/// Local-only send bookkeeping attached to our own chat entries. The id never
|
||||
/// goes on the wire; it ties a `ChatSendResult` back to the matching echo.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LocalSend {
|
||||
pub id: u64,
|
||||
pub status: SendStatus,
|
||||
}
|
||||
|
||||
/// Sender-side pacer mirroring the receiver's per-author admission budget.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SendPacer {
|
||||
bucket: TokenBucket,
|
||||
}
|
||||
|
||||
impl SendPacer {
|
||||
pub fn new(now_ms: u64) -> Self {
|
||||
Self {
|
||||
bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take one send token if the mirrored per-author budget allows it now.
|
||||
pub fn try_send(&mut self, now_ms: u64) -> bool {
|
||||
self.bucket
|
||||
.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the queued ids that may be dispatched now: strict front-of-queue order,
|
||||
/// one pacer token each, stopping at the first refusal so a message can never
|
||||
/// overtake an earlier one.
|
||||
pub fn release_ready(queue: &mut VecDeque<u64>, pacer: &mut SendPacer, now_ms: u64) -> Vec<u64> {
|
||||
let mut ready = Vec::new();
|
||||
while !queue.is_empty() && pacer.try_send(now_ms) {
|
||||
// The unwrap is safe: the loop condition just checked non-empty.
|
||||
ready.push(queue.pop_front().unwrap());
|
||||
}
|
||||
ready
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const T0: u64 = 1_000_000;
|
||||
|
||||
#[test]
|
||||
fn pacer_allows_the_full_burst_then_refuses() {
|
||||
let mut pacer = SendPacer::new(T0);
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
assert!(pacer.try_send(T0));
|
||||
}
|
||||
assert!(!pacer.try_send(T0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pacer_refills_at_one_per_second() {
|
||||
let mut pacer = SendPacer::new(T0);
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
assert!(pacer.try_send(T0));
|
||||
}
|
||||
// 999ms is just under one token; 1000ms grants exactly one.
|
||||
assert!(!pacer.try_send(T0 + 999));
|
||||
assert!(pacer.try_send(T0 + 1000));
|
||||
assert!(!pacer.try_send(T0 + 1000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_ready_preserves_order_and_stops_at_refusal() {
|
||||
let mut pacer = SendPacer::new(T0);
|
||||
// Drain the burst so only refill tokens remain.
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
assert!(pacer.try_send(T0));
|
||||
}
|
||||
let mut queue: VecDeque<u64> = [10, 11, 12].into_iter().collect();
|
||||
// 2 seconds of refill = 2 tokens: exactly the first two, in order.
|
||||
let ready = release_ready(&mut queue, &mut pacer, T0 + 2000);
|
||||
assert_eq!(ready, vec![10, 11]);
|
||||
assert_eq!(queue, VecDeque::from([12]));
|
||||
// No tokens left at the same instant.
|
||||
assert!(release_ready(&mut queue, &mut pacer, T0 + 2000).is_empty());
|
||||
assert_eq!(queue, VecDeque::from([12]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_ready_empty_queue_consumes_no_tokens() {
|
||||
let mut pacer = SendPacer::new(T0);
|
||||
let mut queue = VecDeque::new();
|
||||
assert!(release_ready(&mut queue, &mut pacer, T0).is_empty());
|
||||
// The full burst must still be available.
|
||||
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||
assert!(pacer.try_send(T0));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
//! 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);
|
||||
}
|
||||
|
||||
/// **The fourth playback path's exit gate (round 10, R10-2).** Drives a
|
||||
/// real [`ClipPlayer`] — the same object the app uses for chat clips, peer
|
||||
/// music and the local playlist — and asserts the node it puts on the
|
||||
/// graph carries both ownership carriers.
|
||||
///
|
||||
/// This path was untagged through all of phase 1, which is a real echo:
|
||||
/// B broadcasts music, A tunes in, A shares their desktop, B hears their
|
||||
/// own track. It was missed because phase 1 worked from the impl plan's
|
||||
/// list of three playback sites and that list was incomplete — so this
|
||||
/// gate drives the *player*, not the tagging helper.
|
||||
///
|
||||
/// ⚠️ **Run alone**: it sets a process-wide environment variable, which is
|
||||
/// only sound single-threaded. In production `main` does this before
|
||||
/// anything is spawned; a test binary has no such guarantee, hence
|
||||
/// `--test-threads=1`.
|
||||
///
|
||||
/// `cargo test --lib -- --ignored --test-threads=1 clip_player_node`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump; run with --test-threads=1"]
|
||||
fn clip_player_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
|
||||
// SAFETY: `--test-threads=1` is documented above and in the ignore
|
||||
// reason; this is the same call `main` makes, exercised for real
|
||||
// rather than reimplemented, so the gate cannot pass against a
|
||||
// formatter that production never uses.
|
||||
unsafe { ownership::tag_this_process_alsa_audio() };
|
||||
|
||||
let (player, _status) = ClipPlayer::new(1.0);
|
||||
// Six seconds of silence: long enough for the poll, inaudible.
|
||||
player.play([0u8; 32], live_test::silent_wav(6));
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::CLIP_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
player.stop();
|
||||
|
||||
let (name, owned) = found.unwrap_or_else(|| {
|
||||
panic!("no live clip-player node named {prefix:?} appeared within 5s")
|
||||
});
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Per-peer listener-side voice EQ.
|
||||
//!
|
||||
//! The EQ is deliberately small and local: three RBJ cookbook biquads at fixed
|
||||
//! voice-oriented frequencies, with only gain exposed to the UI. State lives per
|
||||
//! peer in the playout mixer so filter delay registers are continuous across 20ms
|
||||
//! Opus frames; flat settings are treated as bypass so the default path is cheap
|
||||
//! and sample-exact.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DEFAULT_SAMPLE_RATE: f32 = 48_000.0;
|
||||
const LOW_SHELF_HZ: f32 = 160.0;
|
||||
const MID_PEAK_HZ: f32 = 2_400.0;
|
||||
const HIGH_SHELF_HZ: f32 = 6_500.0;
|
||||
const MID_Q: f32 = 1.0;
|
||||
const SHELF_Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
|
||||
const FLAT_EPSILON_DB: f32 = 0.001;
|
||||
|
||||
/// UI and config clamp for each band. Wide enough to be useful for voice, narrow
|
||||
/// enough that a peer cannot accidentally make the listener-side limiter do all
|
||||
/// the work.
|
||||
pub const EQ_GAIN_DB_MIN: f32 = -12.0;
|
||||
pub const EQ_GAIN_DB_MAX: f32 = 12.0;
|
||||
|
||||
/// Persisted per-peer EQ gains, in decibels. `Default` is flat/bypassed.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub struct EqSettings {
|
||||
#[serde(default)]
|
||||
pub low_gain_db: f32,
|
||||
#[serde(default)]
|
||||
pub mid_gain_db: f32,
|
||||
#[serde(default)]
|
||||
pub high_gain_db: f32,
|
||||
}
|
||||
|
||||
impl Default for EqSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
low_gain_db: 0.0,
|
||||
mid_gain_db: 0.0,
|
||||
high_gain_db: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EqSettings {
|
||||
pub fn flat() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Clamp all public gains to the supported UI/DSP range.
|
||||
pub fn clamped(self) -> Self {
|
||||
Self {
|
||||
low_gain_db: self.low_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||
mid_gain_db: self.mid_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||
high_gain_db: self.high_gain_db.clamp(EQ_GAIN_DB_MIN, EQ_GAIN_DB_MAX),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the EQ should be bypassed entirely.
|
||||
pub fn is_flat(self) -> bool {
|
||||
self.low_gain_db.abs() <= FLAT_EPSILON_DB
|
||||
&& self.mid_gain_db.abs() <= FLAT_EPSILON_DB
|
||||
&& self.high_gain_db.abs() <= FLAT_EPSILON_DB
|
||||
}
|
||||
}
|
||||
|
||||
/// A stateful three-band EQ. One instance belongs to one decoded peer stream.
|
||||
pub struct Eq {
|
||||
settings: EqSettings,
|
||||
low: Biquad,
|
||||
mid: Biquad,
|
||||
high: Biquad,
|
||||
}
|
||||
|
||||
impl Eq {
|
||||
/// Build an EQ at the application's audio rate (48 kHz).
|
||||
pub fn new(settings: EqSettings) -> Self {
|
||||
Self::with_sample_rate(settings, DEFAULT_SAMPLE_RATE)
|
||||
}
|
||||
|
||||
fn with_sample_rate(settings: EqSettings, sample_rate: f32) -> Self {
|
||||
let settings = settings.clamped();
|
||||
Self {
|
||||
settings,
|
||||
low: Biquad::low_shelf(sample_rate, LOW_SHELF_HZ, settings.low_gain_db, SHELF_Q),
|
||||
mid: Biquad::peaking(sample_rate, MID_PEAK_HZ, settings.mid_gain_db, MID_Q),
|
||||
high: Biquad::high_shelf(sample_rate, HIGH_SHELF_HZ, settings.high_gain_db, SHELF_Q),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> EqSettings {
|
||||
self.settings
|
||||
}
|
||||
|
||||
/// Process one mono PCM frame in place. Flat settings are sample-exact bypass.
|
||||
pub fn process_frame(&mut self, frame: &mut [i16]) {
|
||||
if self.settings.is_flat() {
|
||||
return;
|
||||
}
|
||||
for sample in frame {
|
||||
let x = *sample as f32;
|
||||
let y = self.high.process(self.mid.process(self.low.process(x)));
|
||||
*sample = y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Coeffs {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
}
|
||||
|
||||
impl Coeffs {
|
||||
fn normalized(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self {
|
||||
let inv_a0 = 1.0 / a0;
|
||||
Self {
|
||||
b0: b0 * inv_a0,
|
||||
b1: b1 * inv_a0,
|
||||
b2: b2 * inv_a0,
|
||||
a1: a1 * inv_a0,
|
||||
a2: a2 * inv_a0,
|
||||
}
|
||||
}
|
||||
|
||||
fn all_finite(self) -> bool {
|
||||
self.b0.is_finite()
|
||||
&& self.b1.is_finite()
|
||||
&& self.b2.is_finite()
|
||||
&& self.a1.is_finite()
|
||||
&& self.a2.is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
/// Direct Form II transposed biquad. The two delay registers are the state that
|
||||
/// must survive across frames.
|
||||
struct Biquad {
|
||||
coeffs: Coeffs,
|
||||
z1: f32,
|
||||
z2: f32,
|
||||
}
|
||||
|
||||
impl Biquad {
|
||||
fn new(coeffs: Coeffs) -> Self {
|
||||
debug_assert!(coeffs.all_finite());
|
||||
Self {
|
||||
coeffs,
|
||||
z1: 0.0,
|
||||
z2: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn low_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||
let sqrt_a = a.sqrt();
|
||||
let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
|
||||
let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0);
|
||||
let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
|
||||
let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
|
||||
let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0);
|
||||
let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
|
||||
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||
}
|
||||
|
||||
fn peaking(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||
let b0 = 1.0 + alpha * a;
|
||||
let b1 = -2.0 * cos_w0;
|
||||
let b2 = 1.0 - alpha * a;
|
||||
let a0 = 1.0 + alpha / a;
|
||||
let a1 = -2.0 * cos_w0;
|
||||
let a2 = 1.0 - alpha / a;
|
||||
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||
}
|
||||
|
||||
fn high_shelf(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> Self {
|
||||
let (a, cos_w0, alpha) = rbj_terms(sample_rate, freq, gain_db, q);
|
||||
let sqrt_a = a.sqrt();
|
||||
let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
|
||||
let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
|
||||
let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
|
||||
let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
|
||||
let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
|
||||
let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
|
||||
Self::new(Coeffs::normalized(b0, b1, b2, a0, a1, a2))
|
||||
}
|
||||
|
||||
fn process(&mut self, x: f32) -> f32 {
|
||||
let y = self.coeffs.b0 * x + self.z1;
|
||||
self.z1 = self.coeffs.b1 * x - self.coeffs.a1 * y + self.z2;
|
||||
self.z2 = self.coeffs.b2 * x - self.coeffs.a2 * y;
|
||||
|
||||
// Avoid carrying denormal-sized state forever on long quiet tails.
|
||||
if self.z1.abs() < 1.0e-20 {
|
||||
self.z1 = 0.0;
|
||||
}
|
||||
if self.z2.abs() < 1.0e-20 {
|
||||
self.z2 = 0.0;
|
||||
}
|
||||
y
|
||||
}
|
||||
}
|
||||
|
||||
fn rbj_terms(sample_rate: f32, freq: f32, gain_db: f32, q: f32) -> (f32, f32, f32) {
|
||||
let sr = sample_rate.max(1.0);
|
||||
let f = freq.clamp(1.0, sr * 0.49);
|
||||
let w0 = 2.0 * std::f32::consts::PI * f / sr;
|
||||
let a = 10.0f32.powf(gain_db / 40.0);
|
||||
let alpha = w0.sin() / (2.0 * q.max(0.001));
|
||||
(a, w0.cos(), alpha)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sine(freq: f32, len: usize, amp: f32) -> Vec<i16> {
|
||||
(0..len)
|
||||
.map(|n| {
|
||||
let t = n as f32 / DEFAULT_SAMPLE_RATE;
|
||||
(amp * (2.0 * std::f32::consts::PI * freq * t).sin()).round() as i16
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rms(frame: &[i16]) -> f32 {
|
||||
let sum: f32 = frame.iter().map(|&s| (s as f32).powi(2)).sum();
|
||||
(sum / frame.len().max(1) as f32).sqrt()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_eq_is_sample_exact_identity() {
|
||||
let mut eq = Eq::new(EqSettings::flat());
|
||||
let mut frame: Vec<i16> = (-480..480).map(|n| (n * 31) as i16).collect();
|
||||
let original = frame.clone();
|
||||
eq.process_frame(&mut frame);
|
||||
assert_eq!(frame, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn low_shelf_boost_raises_low_frequency_energy() {
|
||||
let mut eq = Eq::new(EqSettings {
|
||||
low_gain_db: 9.0,
|
||||
..EqSettings::flat()
|
||||
});
|
||||
let mut low = sine(100.0, 48_000, 3_000.0);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_shelf_boost_raises_high_frequency_energy() {
|
||||
let mut eq = Eq::new(EqSettings {
|
||||
high_gain_db: 9.0,
|
||||
..EqSettings::flat()
|
||||
});
|
||||
let mut high = sine(8_000.0, 48_000, 3_000.0);
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coefficients_are_finite_across_supported_gain_range() {
|
||||
for gain in [EQ_GAIN_DB_MIN, -6.0, 0.0, 6.0, EQ_GAIN_DB_MAX] {
|
||||
for b in [
|
||||
Biquad::low_shelf(DEFAULT_SAMPLE_RATE, LOW_SHELF_HZ, gain, SHELF_Q),
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hot_signal_does_not_nan_or_wrap() {
|
||||
let mut eq = Eq::new(EqSettings {
|
||||
low_gain_db: 12.0,
|
||||
mid_gain_db: 12.0,
|
||||
high_gain_db: 12.0,
|
||||
});
|
||||
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"
|
||||
);
|
||||
assert!(
|
||||
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
||||
"a boosted sine should retain both polarities"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_are_clamped() {
|
||||
let s = EqSettings {
|
||||
low_gain_db: -99.0,
|
||||
mid_gain_db: 2.0,
|
||||
high_gain_db: 99.0,
|
||||
}
|
||||
.clamped();
|
||||
assert_eq!(s.low_gain_db, EQ_GAIN_DB_MIN);
|
||||
assert_eq!(s.mid_gain_db, 2.0);
|
||||
assert_eq!(s.high_gain_db, EQ_GAIN_DB_MAX);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
use std::sync::mpsc::{Sender, Receiver};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Target depth of the playback ring buffer, in samples (48kHz mono).
|
||||
/// Playback output channel count. Capture/encode/network remain mono; only the
|
||||
/// listener-side playout bus is stereo.
|
||||
pub const PLAYBACK_CHANNELS: usize = 2;
|
||||
|
||||
/// Target depth of the playback ring buffer, in interleaved samples (48kHz
|
||||
/// stereo).
|
||||
///
|
||||
/// The playout chain is paced to keep the ring near this level: production is
|
||||
/// driven by how fast PipeWire actually drains the ring (the hardware clock),
|
||||
/// not by a fixed software timer — which is what eliminates the producer/
|
||||
/// consumer beat that otherwise churns ~20% of audio into drops + silence.
|
||||
/// 2880 = 60ms = 3×20ms frames, comfortably above the 2048-sample max quantum
|
||||
/// 5760 = 60ms = 3×20ms stereo frames, comfortably above the 2048-frame max quantum
|
||||
/// so a single hardware pull can never empty the ring before the mixer refills.
|
||||
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880;
|
||||
pub const PLAYBACK_TARGET_SAMPLES: usize = 2880 * PLAYBACK_CHANNELS;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AudioError {
|
||||
@@ -30,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.
|
||||
@@ -51,10 +60,65 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
}
|
||||
|
||||
pub mod echo_cancel;
|
||||
pub mod clip_player;
|
||||
pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod multitrack;
|
||||
// The cross-repo ownership tag (plan §5.1). Platform-neutral on purpose: the
|
||||
// carriers only matter on PipeWire, but the literals are a wire contract and
|
||||
// their test must run on every platform so a rename can't pass CI elsewhere.
|
||||
pub mod ownership;
|
||||
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.
|
||||
#[cfg(windows)]
|
||||
pub mod cpal_impl;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod echo_cancel;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pipewire_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`);
|
||||
/// `description` is the human-facing label shown in the UI. The two may be equal
|
||||
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||
#[cfg(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.
|
||||
///
|
||||
/// The whole app constructs and threads this alias (via
|
||||
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
|
||||
/// platform selection lives entirely here. Both implementations satisfy the
|
||||
/// [`AudioBackend`] trait, which is the only interface the core talks to.
|
||||
///
|
||||
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
|
||||
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
|
||||
#[cfg(windows)]
|
||||
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// Append `frame` fitted to exactly `frame_samples` (zero-padded if short),
|
||||
/// and mark the track as written for this cycle.
|
||||
fn write_frame(&mut self, frame: &[i16], frame_samples: usize) -> io::Result<()> {
|
||||
self.writer.write_samples(&fit(frame, frame_samples))?;
|
||||
self.written_this_cycle = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append `samples` of silence (no cycle-marking — used for padding).
|
||||
fn write_silence(&mut self, samples: usize) -> io::Result<()> {
|
||||
let mut remaining = samples;
|
||||
while remaining > 0 {
|
||||
let n = remaining.min(SILENCE_CHUNK);
|
||||
self.writer.write_samples(&vec![0i16; n])?;
|
||||
remaining -= n;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
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);
|
||||
}
|
||||
track.written_this_cycle = false;
|
||||
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
|
||||
));
|
||||
}
|
||||
self.cycles += 1;
|
||||
Ok(())
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"multitrack writer thread stopped",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Listener-side stereo pan law.
|
||||
//!
|
||||
//! Capture, Opus, and the network stay mono. These helpers are used only after a
|
||||
//! peer has been decoded locally, just before the playout mix is written to the
|
||||
//! stereo playback bus.
|
||||
|
||||
/// Clamp and compute constant-power pan gains for `pan` in `[-1.0, 1.0]`.
|
||||
///
|
||||
/// - `-1.0` is hard left `(1, 0)`
|
||||
/// - `0.0` is center `(sqrt(1/2), sqrt(1/2))`
|
||||
/// - `1.0` is hard right `(0, 1)`
|
||||
pub fn pan_gains(pan: f32) -> (f32, f32) {
|
||||
let pan = pan.clamp(-1.0, 1.0);
|
||||
let theta = (pan + 1.0) * std::f32::consts::FRAC_PI_4;
|
||||
(theta.cos(), theta.sin())
|
||||
}
|
||||
|
||||
/// Gains used by the legacy-compatible playback mixer.
|
||||
///
|
||||
/// The pure law above is constant-power. The existing application, however, was
|
||||
/// mono and users heard the full old mono signal in both ears. Scaling by sqrt(2)
|
||||
/// makes `pan = 0` exactly dual-mono `(1, 1)`, preserving the default sound while
|
||||
/// 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,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const EPS: f32 = 1.0e-6;
|
||||
|
||||
#[test]
|
||||
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}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_is_equal_and_power_preserving() {
|
||||
let (l, r) = pan_gains(0.0);
|
||||
assert!((l - r).abs() < EPS);
|
||||
assert!((l - std::f32::consts::FRAC_1_SQRT_2).abs() < EPS);
|
||||
assert!(((l * l + r * r) - 1.0).abs() < EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gains_move_monotonically() {
|
||||
let pans = [-1.0, -0.5, 0.0, 0.5, 1.0];
|
||||
let mut prev_l = f32::INFINITY;
|
||||
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"
|
||||
);
|
||||
prev_l = l;
|
||||
prev_r = r;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_center_preserves_legacy_dual_mono() {
|
||||
let (l, r) = playback_pan_gains(0.0);
|
||||
assert!((l - 1.0).abs() < EPS);
|
||||
assert!((r - 1.0).abs() < EPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_is_clamped() {
|
||||
assert_eq!(pan_gains(-9.0), pan_gains(-1.0));
|
||||
assert_eq!(pan_gains(9.0), pan_gains(1.0));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
use crate::audio::ownership;
|
||||
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 +45,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 +116,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 +164,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,7 +194,8 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
||||
|
||||
let mut params = [Pod::from_bytes(&values).unwrap()];
|
||||
|
||||
stream.connect(
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Input,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
@@ -224,6 +236,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 +271,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 +281,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,15 +317,17 @@ 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: 9600 samples (200ms capacity for mono 48kHz).
|
||||
const RING_CAPACITY: usize = 9600;
|
||||
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
||||
// 48kHz).
|
||||
const RING_CAPACITY: usize = 9600 * crate::audio::PLAYBACK_CHANNELS;
|
||||
let rb = HeapRb::<i16>::new(RING_CAPACITY);
|
||||
let (mut producer, consumer) = rb.split();
|
||||
|
||||
@@ -329,6 +372,11 @@ fn run_playback(
|
||||
mainloop_clone.quit();
|
||||
});
|
||||
|
||||
// Ownership tag, both carriers (`crate::audio::ownership`, plan §5.1).
|
||||
// This is the node that carries the far end's voice, so it is the single
|
||||
// most important thing for pixelpass to refuse to fan out: sharing it
|
||||
// would send the call back to the person already speaking on it.
|
||||
let owned_node_name = ownership::owned_node_name(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let mut props = properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Audio",
|
||||
*pw::keys::MEDIA_CATEGORY => "Playback",
|
||||
@@ -337,6 +385,19 @@ fn run_playback(
|
||||
// buffer — the real fix is the explicit Buffers param below — but it
|
||||
// expresses the intended quantum for any node that honours it.
|
||||
*pw::keys::NODE_LATENCY => "1024/48000",
|
||||
ownership::OWNED_PROP_KEY => ownership::OWNED_PROP_VALUE,
|
||||
// Set explicitly rather than relying on the stream name passed to
|
||||
// `StreamBox::new` below: props win over that name, and this one has
|
||||
// to be exact.
|
||||
*pw::keys::NODE_NAME => owned_node_name.as_str(),
|
||||
// Measured: this stream sets neither `application.name` nor a
|
||||
// description, so a mixer falls back to `node.name` — which the line
|
||||
// above just turned into an internal identifier. The plan's rule is
|
||||
// that the ownership prefix must not reach `node.description`; a
|
||||
// human label there is what keeps that rule's *intent* (mixers stay
|
||||
// readable) true for our own stream, exactly as mpv's own
|
||||
// description does for the spawned players.
|
||||
*pw::keys::NODE_DESCRIPTION => "PeerSpeak",
|
||||
};
|
||||
if let Some(target) = target_node {
|
||||
props.insert("node.target", target);
|
||||
@@ -371,7 +432,7 @@ fn run_playback(
|
||||
let data = &mut datas[0];
|
||||
let mut total_size = 0;
|
||||
if let Some(slice) = data.data() {
|
||||
let stride = 2; // S16LE Mono = 2 bytes per frame
|
||||
let stride = 2 * crate::audio::PLAYBACK_CHANNELS; // S16LE stereo
|
||||
// Fill exactly what the graph asked for this cycle (with
|
||||
// a safe fallback), never the whole mapped slice — that
|
||||
// over-pull past the ring depth was the original crackle.
|
||||
@@ -383,6 +444,8 @@ fn run_playback(
|
||||
user_data.callback_count.fetch_add(1, Ordering::Relaxed);
|
||||
let mut starved = 0u64;
|
||||
for i in 0..n_frames {
|
||||
let start = i * stride;
|
||||
for ch in 0..crate::audio::PLAYBACK_CHANNELS {
|
||||
let val = match user_data.consumer.try_pop() {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
@@ -391,19 +454,23 @@ fn run_playback(
|
||||
}
|
||||
};
|
||||
let bytes = val.to_le_bytes();
|
||||
let start = i * stride;
|
||||
slice[start] = bytes[0];
|
||||
slice[start + 1] = bytes[1];
|
||||
let offset = start + ch * 2;
|
||||
slice[offset] = bytes[0];
|
||||
slice[offset + 1] = bytes[1];
|
||||
}
|
||||
}
|
||||
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
|
||||
// nothing) so the mixer paces against true ring depth.
|
||||
// Wait-free fetch_sub, RT-safe.
|
||||
let popped = n_frames - starved as usize;
|
||||
let requested_samples = n_frames * crate::audio::PLAYBACK_CHANNELS;
|
||||
let popped = requested_samples - starved as usize;
|
||||
if popped > 0 {
|
||||
user_data.fill_gauge.fetch_sub(popped, Ordering::Relaxed);
|
||||
}
|
||||
@@ -411,7 +478,7 @@ fn run_playback(
|
||||
}
|
||||
let chunk = data.chunk_mut();
|
||||
*chunk.offset_mut() = 0;
|
||||
*chunk.stride_mut() = 2;
|
||||
*chunk.stride_mut() = (2 * crate::audio::PLAYBACK_CHANNELS) as _;
|
||||
*chunk.size_mut() = total_size as _;
|
||||
}
|
||||
}
|
||||
@@ -422,7 +489,7 @@ fn run_playback(
|
||||
let mut audio_info = spa::param::audio::AudioInfoRaw::new();
|
||||
audio_info.set_format(spa::param::audio::AudioFormat::S16LE);
|
||||
audio_info.set_rate(48000);
|
||||
audio_info.set_channels(1); // Mono
|
||||
audio_info.set_channels(crate::audio::PLAYBACK_CHANNELS as u32); // Stereo playback
|
||||
|
||||
let obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
||||
@@ -450,7 +517,7 @@ fn run_playback(
|
||||
// `frames_to_produce`). `requested()`, not the buffer size, now governs
|
||||
// per-cycle output, so this is a generous max rather than a hard pin.
|
||||
const MAX_QUANTUM_FRAMES: i32 = 8192;
|
||||
const STRIDE: i32 = 2; // S16LE mono = 2 bytes/frame
|
||||
const STRIDE: i32 = 2 * crate::audio::PLAYBACK_CHANNELS as i32; // S16LE stereo
|
||||
let buffers_obj = pw::spa::pod::Object {
|
||||
type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(),
|
||||
id: pw::spa::param::ParamType::Buffers.as_raw(),
|
||||
@@ -461,7 +528,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,
|
||||
},
|
||||
),
|
||||
)),
|
||||
),
|
||||
@@ -492,7 +563,8 @@ fn run_playback(
|
||||
Pod::from_bytes(&buffers_values).unwrap(),
|
||||
];
|
||||
|
||||
stream.connect(
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Output,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
@@ -517,10 +589,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, || {});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -555,7 +629,7 @@ fn run_playback(
|
||||
if verbose || du > 0 || dd > 0 {
|
||||
crate::log_msg(&format!(
|
||||
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d}) | quantum={q} frames, {dc} callbacks/s",
|
||||
fill / 48,
|
||||
fill / (48 * crate::audio::PLAYBACK_CHANNELS),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -572,12 +646,72 @@ 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};
|
||||
|
||||
/// Phase-1 exit gate, native-playback half (impl plan §3): the stream
|
||||
/// that carries the far end's voice appears on the graph with **both**
|
||||
/// ownership carriers, and still with the `Communication` media role.
|
||||
///
|
||||
/// The third and most important of the three tagged paths — this is the
|
||||
/// node whose audio, if fanned out, would send the call back to whoever
|
||||
/// is speaking on it.
|
||||
///
|
||||
/// Feeds silence, so the gate is inaudible. Live: needs PipeWire and
|
||||
/// `pw-dump`. `cargo test --lib -- --ignored native_playback`
|
||||
#[test]
|
||||
#[ignore = "live: requires a running PipeWire daemon and pw-dump"]
|
||||
fn native_playback_node_carries_both_ownership_carriers() {
|
||||
use crate::audio::ownership::{self, live_test};
|
||||
use crate::audio::{AudioBackend, PLAYBACK_TARGET_SAMPLES};
|
||||
|
||||
let backend = super::PipeWireBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
backend
|
||||
.start_playback(rx, None, ring_fill.clone())
|
||||
.expect("playback starts");
|
||||
|
||||
// Keep the ring fed so the node stays live for the whole poll; the
|
||||
// stream is created on connect, but a starved one is not a fair test
|
||||
// of what a real call looks like on the graph.
|
||||
let feeder = thread::spawn(move || {
|
||||
let silence = vec![0i16; 960 * 2];
|
||||
for _ in 0..300 {
|
||||
if ring_fill.load(Ordering::Relaxed) < PLAYBACK_TARGET_SAMPLES
|
||||
&& tx.send(silence.clone()).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
|
||||
let prefix = live_test::expected_prefix(ownership::NATIVE_PLAYBACK_ROLE);
|
||||
let found = live_test::poll_for_owned_node(&prefix, Duration::from_secs(5));
|
||||
let _ = backend.stop();
|
||||
let _ = feeder.join();
|
||||
|
||||
let (name, owned) =
|
||||
found.unwrap_or_else(|| panic!("no live node named {prefix:?} appeared within 5s"));
|
||||
assert!(
|
||||
name.starts_with(ownership::OWNED_NODE_NAME_PREFIX),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(
|
||||
owned.as_deref(),
|
||||
Some(ownership::OWNED_PROP_VALUE),
|
||||
"carrier 1 must be on the live node, not just carrier 2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_in_range_is_honored() {
|
||||
// The graph's requested quantum is produced verbatim when it fits.
|
||||
@@ -609,6 +743,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]
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
use super::AudioDevice;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AudioDevice {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub is_input: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AudioDevice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.description)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||
let output = Command::new("pw-cli")
|
||||
.arg("list-objects")
|
||||
@@ -28,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",
|
||||
});
|
||||
}
|
||||
@@ -56,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 = \"") {
|
||||
@@ -65,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
|
||||
@@ -120,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);
|
||||
|
||||
@@ -2,26 +2,36 @@
|
||||
//!
|
||||
//! 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;
|
||||
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
|
||||
@@ -34,15 +44,23 @@ const MAX_MIC_FIFO: usize = SAMPLE_RATE as usize / 5;
|
||||
pub struct WavWriter {
|
||||
file: File,
|
||||
/// Bytes of PCM data written so far (for the size fields).
|
||||
data_bytes: u32,
|
||||
data_bytes: u64,
|
||||
}
|
||||
|
||||
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, data_bytes: 0 })
|
||||
Ok(Self {
|
||||
file,
|
||||
data_bytes: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The 44-byte canonical WAV/PCM header for the given data length in bytes.
|
||||
@@ -68,46 +86,90 @@ impl WavWriter {
|
||||
|
||||
/// Append PCM samples to the data chunk.
|
||||
pub fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
|
||||
let added_bytes = u64::try_from(samples.len())
|
||||
.ok()
|
||||
.and_then(|len| len.checked_mul(2))
|
||||
.ok_or_else(|| io::Error::other("WAV sample buffer too large"))?;
|
||||
let new_data_bytes = self
|
||||
.data_bytes
|
||||
.checked_add(added_bytes)
|
||||
.ok_or_else(|| io::Error::other("WAV data size overflow"))?;
|
||||
if new_data_bytes > MAX_RIFF_DATA_BYTES {
|
||||
return Err(io::Error::other("WAV too large for RIFF"));
|
||||
}
|
||||
|
||||
let mut buf = Vec::with_capacity(samples.len() * 2);
|
||||
for &s in samples {
|
||||
buf.extend_from_slice(&s.to_le_bytes());
|
||||
}
|
||||
self.file.write_all(&buf)?;
|
||||
self.data_bytes += (samples.len() * 2) as u32;
|
||||
self.data_bytes = new_data_bytes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Patch the RIFF + data size fields and flush. Consumes the writer.
|
||||
pub fn finalize(mut self) -> io::Result<()> {
|
||||
let data_bytes = u32::try_from(self.data_bytes)
|
||||
.map_err(|_| io::Error::other("WAV too large for RIFF"))?;
|
||||
let riff_size = self
|
||||
.data_bytes
|
||||
.checked_add(RIFF_DATA_OVERHEAD)
|
||||
.and_then(|size| u32::try_from(size).ok())
|
||||
.ok_or_else(|| io::Error::other("WAV too large for RIFF"))?;
|
||||
self.file.seek(SeekFrom::Start(4))?;
|
||||
self.file.write_all(&(36 + self.data_bytes).to_le_bytes())?;
|
||||
self.file.write_all(&riff_size.to_le_bytes())?;
|
||||
self.file.seek(SeekFrom::Start(40))?;
|
||||
self.file.write_all(&self.data_bytes.to_le_bytes())?;
|
||||
self.file.write_all(&data_bytes.to_le_bytes())?;
|
||||
self.file.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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.
|
||||
@@ -131,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) {
|
||||
@@ -174,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() {
|
||||
@@ -186,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();
|
||||
@@ -210,33 +396,50 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_is_summed_with_mix_when_present() {
|
||||
fn wav_writer_rejects_data_that_would_overflow_riff_header() {
|
||||
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();
|
||||
assert_eq!(r.mic_fifo.len(), 0, "remaining mic sample consumed; rest is silence");
|
||||
let _ = r.finalize();
|
||||
let path = dir.join(format!("peerspeak-overflow-{}.wav", std::process::id()));
|
||||
let mut w = WavWriter::new(&path).unwrap();
|
||||
w.data_bytes = MAX_RIFF_DATA_BYTES - 1;
|
||||
let before_len = std::fs::metadata(&path).unwrap().len();
|
||||
|
||||
let err = w.write_samples(&[0]).unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), io::ErrorKind::Other);
|
||||
assert_eq!(w.data_bytes, MAX_RIFF_DATA_BYTES - 1);
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), before_len);
|
||||
drop(w);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_is_summed_with_mix_when_present() {
|
||||
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!(
|
||||
mic_fifo.len(),
|
||||
0,
|
||||
"remaining mic sample consumed; rest is silence"
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Dep-free linear-interpolation resamplers for the Windows/cpal backend (W4).
|
||||
//!
|
||||
//! The pipeline runs internally at 48 kHz (Opus + the 20 ms frame), but a WASAPI
|
||||
//! endpoint may run at a different rate (commonly 44.1 kHz) and/or a non-stereo
|
||||
//! channel layout. These convert at the device boundary so such a device plays and
|
||||
//! captures instead of hard-erroring (the W4 limitation in the Windows port).
|
||||
//!
|
||||
//! ## Where each is used
|
||||
//! - [`PushResampler`] (single channel) converts **capture** from the device rate
|
||||
//! to 48 kHz on the capture drain thread — off the RT callback.
|
||||
//! - [`StereoPullResampler`] converts **playback** from the internal 48 kHz stereo
|
||||
//! bus to the device rate inside the output RT callback, pulling internal frames
|
||||
//! from the ring on demand. It allocates nothing in `next`, so it is RT-safe.
|
||||
//!
|
||||
//! ## Quality
|
||||
//! This is plain linear interpolation with no anti-aliasing filter: correct,
|
||||
//! allocation-free, and adequate for speech, but it adds some aliasing when
|
||||
//! downsampling. The seam is intentionally tiny so a higher-quality polyphase/FIR
|
||||
//! resampler (e.g. the `rubato` crate, pending a supply-chain decision) can later
|
||||
//! replace the internals without touching the cpal backend. The matching-rate /
|
||||
//! matching-layout path in the backend bypasses these entirely and stays bit-exact.
|
||||
|
||||
/// Linear interpolation between `a` and `b` at fractional position `frac` in `[0, 1)`.
|
||||
#[inline]
|
||||
fn lerp(a: f32, b: f32, frac: f32) -> f32 {
|
||||
a + (b - a) * frac
|
||||
}
|
||||
|
||||
/// Stateful single-channel **push** resampler: feed input samples at `in_rate`,
|
||||
/// receive output samples at `out_rate` through an `emit` callback. It carries the
|
||||
/// fractional read position and the previous input sample across calls, so feeding
|
||||
/// the stream block-by-block joins seamlessly. Neither [`push`](Self::push) nor
|
||||
/// [`process`](Self::process) allocates.
|
||||
pub struct PushResampler {
|
||||
/// Input samples consumed per output sample (`in_rate / out_rate`).
|
||||
step: f64,
|
||||
/// Position of the next output sample, in input-sample units, measured from the
|
||||
/// index of `prev` (the most recent input). Always advanced to stay `< 1.0`
|
||||
/// after each input is consumed.
|
||||
next: f64,
|
||||
/// The previous input sample (left edge of the current interpolation segment).
|
||||
prev: f32,
|
||||
/// Whether any input has been seen yet (anchors the first output at input[0]).
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl PushResampler {
|
||||
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||
/// clamped to `>= 1` so `step` is always finite and non-zero: a zero `step`
|
||||
/// would make [`push`](Self::push)'s `while self.next < 1.0` loop forever. The
|
||||
/// cpal backend's `resolve()` also rejects such rates up front, so this is
|
||||
/// belt-and-suspenders against a future caller (review W7).
|
||||
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||
Self {
|
||||
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||
next: 0.0,
|
||||
prev: 0.0,
|
||||
started: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one input sample; `emit` is called for each output sample produced
|
||||
/// (zero or more, depending on the rate ratio).
|
||||
pub fn push(&mut self, cur: f32, mut emit: impl FnMut(f32)) {
|
||||
if !self.started {
|
||||
// First sample: just establish the left edge. Linear interpolation
|
||||
// needs the next input as the right edge, so the first output is
|
||||
// produced on the next push. This gives exact alignment
|
||||
// (`output[k] == input[k]` at equal rates) with one input-sample of
|
||||
// latency — negligible (~20 µs at 48 kHz).
|
||||
self.started = true;
|
||||
self.prev = cur;
|
||||
self.next = 0.0;
|
||||
return;
|
||||
}
|
||||
// `prev` sits at position 0 of this segment and `cur` at position 1; emit
|
||||
// every output whose position falls in [0, 1).
|
||||
while self.next < 1.0 {
|
||||
emit(lerp(self.prev, cur, self.next as f32));
|
||||
self.next += self.step;
|
||||
}
|
||||
self.next -= 1.0;
|
||||
self.prev = cur;
|
||||
}
|
||||
|
||||
/// Convenience for tests / batch callers: push a whole slice.
|
||||
pub fn process(&mut self, input: &[f32], mut emit: impl FnMut(f32)) {
|
||||
for &s in input {
|
||||
self.push(s, &mut emit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stateful stereo **pull** resampler: produce output frames at `out_rate` by
|
||||
/// pulling input frames at `in_rate` from a closure on demand. Call
|
||||
/// [`next`](Self::next) once per output frame; it pulls as many input frames as the
|
||||
/// ratio requires and returns the interpolated `(left, right)`, or `None` when the
|
||||
/// puller runs dry (an underrun). Allocates nothing, so it is safe in an RT output
|
||||
/// callback.
|
||||
pub struct StereoPullResampler {
|
||||
/// Input frames consumed per output frame (`in_rate / out_rate`).
|
||||
step: f64,
|
||||
/// Position of the next output frame within `[prev, cur)`, in `[0, 1)`.
|
||||
frac: f64,
|
||||
/// Left edge of the current interpolation segment.
|
||||
prev: (f32, f32),
|
||||
/// Right edge of the current interpolation segment.
|
||||
cur: (f32, f32),
|
||||
/// Whether `prev`/`cur` have been primed from the puller yet.
|
||||
primed: bool,
|
||||
}
|
||||
|
||||
impl StereoPullResampler {
|
||||
/// Build a resampler from `in_rate` to `out_rate` (both in Hz). Rates are
|
||||
/// clamped to `>= 1` so `step` is finite and non-zero — otherwise
|
||||
/// [`next`](Self::next)'s `while self.frac >= 1.0` could spin (review W7).
|
||||
pub fn new(in_rate: u32, out_rate: u32) -> Self {
|
||||
Self {
|
||||
step: in_rate.max(1) as f64 / out_rate.max(1) as f64,
|
||||
frac: 0.0,
|
||||
prev: (0.0, 0.0),
|
||||
cur: (0.0, 0.0),
|
||||
primed: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce the next output frame, pulling input frames via `pull` as needed.
|
||||
/// Returns `None` if `pull` returns `None` before the frame can be formed
|
||||
/// (underrun); the caller should substitute silence for that frame.
|
||||
pub fn next(&mut self, mut pull: impl FnMut() -> Option<(f32, f32)>) -> Option<(f32, f32)> {
|
||||
if !self.primed {
|
||||
// Prime both edges from two pulls so the first output frame aligns
|
||||
// exactly with the first input frame (`out[0] == in[0]` at equal
|
||||
// rates). Needs two frames available to start, which the prefilled
|
||||
// playback ring always has.
|
||||
self.prev = pull()?;
|
||||
self.cur = pull()?;
|
||||
self.primed = true;
|
||||
self.frac = 0.0;
|
||||
}
|
||||
// Advance the segment until the read position lands inside [prev, cur).
|
||||
while self.frac >= 1.0 {
|
||||
self.prev = self.cur;
|
||||
self.cur = pull()?;
|
||||
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),
|
||||
);
|
||||
self.frac += self.step;
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Equal rates align exactly: `output[k] == input[k]`. The final input lands on
|
||||
/// the next push (one-sample streaming latency), so we get `n - 1` outputs.
|
||||
#[test]
|
||||
fn push_identity_when_rates_match() {
|
||||
let mut r = PushResampler::new(48_000, 48_000);
|
||||
let input = [0.0, 0.1, 0.2, 0.3, 0.4];
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
assert_eq!(out.len(), input.len() - 1);
|
||||
for (a, b) in out.iter().zip(input.iter()) {
|
||||
assert!((a - b).abs() < 1e-6, "{a} vs {b}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Upsampling 2x roughly doubles the output count and the midpoints interpolate.
|
||||
#[test]
|
||||
fn push_upsample_2x_interpolates_midpoints() {
|
||||
let mut r = PushResampler::new(24_000, 48_000); // step = 0.5
|
||||
let input = [0.0, 1.0, 2.0, 3.0];
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
// (n - 1) segments at 2 outputs each = 6.
|
||||
assert_eq!(out.len(), 6, "out {out:?}");
|
||||
// A half-step between 1.0 and 2.0 must appear near 1.5.
|
||||
assert!(
|
||||
out.iter().any(|&s| (s - 1.5).abs() < 1e-3),
|
||||
"expected a ~1.5 midpoint in {out:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Downsampling drops the rate: fewer outputs than inputs, monotonic ramp preserved.
|
||||
#[test]
|
||||
fn push_downsample_reduces_count() {
|
||||
let mut r = PushResampler::new(48_000, 44_100); // step ~1.088
|
||||
let input: Vec<f32> = (0..441).map(|i| i as f32).collect();
|
||||
let mut out = Vec::new();
|
||||
r.process(&input, |s| out.push(s));
|
||||
// 441 in @ 48k -> ~405 out @ 44.1k.
|
||||
assert!(
|
||||
(390..=410).contains(&out.len()),
|
||||
"expected ~405 outputs, got {}",
|
||||
out.len()
|
||||
);
|
||||
// Output stays within the input's value range and is non-decreasing.
|
||||
for w in out.windows(2) {
|
||||
assert!(w[1] >= w[0] - 1e-3, "ramp should not reverse: {w:?}");
|
||||
}
|
||||
assert!(*out.last().unwrap() <= 440.0 + 1e-3);
|
||||
}
|
||||
|
||||
/// Pull resampler at equal rates returns each input frame in order, aligned.
|
||||
/// Two-pull priming uses one frame of lookahead, so `n` inputs yield `n - 1`
|
||||
/// outputs (the last frame emits once a successor arrives).
|
||||
#[test]
|
||||
fn pull_identity_when_rates_match() {
|
||||
let mut r = StereoPullResampler::new(48_000, 48_000);
|
||||
let frames = [(0.0, 9.0), (1.0, 8.0), (2.0, 7.0), (3.0, 6.0)];
|
||||
let mut idx = 0;
|
||||
let mut out = Vec::new();
|
||||
while let Some(f) = r.next(|| {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
}) {
|
||||
out.push(f);
|
||||
}
|
||||
assert_eq!(out.len(), frames.len() - 1, "out {out:?}");
|
||||
for (got, want) in out.iter().zip(frames.iter()) {
|
||||
assert!((got.0 - want.0).abs() < 1e-6 && (got.1 - want.1).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull resampler reports underrun (`None`) once the source is exhausted.
|
||||
#[test]
|
||||
fn pull_returns_none_on_underrun() {
|
||||
let mut r = StereoPullResampler::new(48_000, 44_100); // step ~1.088 -> pulls >1 per out
|
||||
let frames = [(0.0, 0.0), (1.0, -1.0)];
|
||||
let mut idx = 0;
|
||||
let mut pull = || {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
};
|
||||
// First frame primes + emits; subsequent calls eventually exhaust the source.
|
||||
let mut produced = 0;
|
||||
let mut hit_none = false;
|
||||
for _ in 0..10 {
|
||||
if r.next(&mut pull).is_some() {
|
||||
produced += 1;
|
||||
} else {
|
||||
hit_none = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(produced >= 1, "should produce at least the primed frame");
|
||||
assert!(hit_none, "should report underrun once the puller is dry");
|
||||
}
|
||||
|
||||
/// Downsampling via pull consumes more input frames than it emits output frames.
|
||||
#[test]
|
||||
fn pull_downsample_consumes_more_than_it_emits() {
|
||||
let mut r = StereoPullResampler::new(48_000, 24_000); // step = 2.0
|
||||
let input: Vec<(f32, f32)> = (0..100).map(|i| (i as f32, -(i as f32))).collect();
|
||||
let mut idx = 0;
|
||||
let mut emitted = 0;
|
||||
for _ in 0..40 {
|
||||
let f = r.next(|| {
|
||||
let v = input.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
});
|
||||
if f.is_some() {
|
||||
emitted += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// At step 2.0 we consume ~2 input frames per output frame.
|
||||
assert!(
|
||||
idx > emitted,
|
||||
"consumed {idx} input, emitted {emitted} output"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
||||
/// `while self.next < 1.0` forever). Clamping makes the call terminate (W7).
|
||||
#[test]
|
||||
fn push_zero_rate_does_not_spin() {
|
||||
let mut r = PushResampler::new(0, 48_000);
|
||||
let mut count = 0usize;
|
||||
// Feed two samples; with a clamped non-zero step this returns promptly.
|
||||
r.push(0.0, |_| count += 1);
|
||||
r.push(1.0, |_| count += 1);
|
||||
// Reaching here at all is the assertion (no hang); some output is produced.
|
||||
assert!(count >= 1);
|
||||
}
|
||||
|
||||
/// A zero output rate must not make the pull resampler's segment-advance loop
|
||||
/// spin. Clamping keeps `step` finite so `next` terminates (W7).
|
||||
#[test]
|
||||
fn pull_zero_out_rate_does_not_spin() {
|
||||
let mut r = StereoPullResampler::new(48_000, 0);
|
||||
let frames = [(0.0, 0.0), (1.0, 1.0), (2.0, 2.0)];
|
||||
let mut idx = 0;
|
||||
let got = r.next(|| {
|
||||
let v = frames.get(idx).copied();
|
||||
idx += 1;
|
||||
v
|
||||
});
|
||||
// Terminates and yields the primed frame instead of hanging.
|
||||
assert!(got.is_some());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Custom UI background (W16): turn a user-picked image into a capped PNG to
|
||||
//! render behind the whole UI, plus the legibility scrim drawn over it.
|
||||
//!
|
||||
//! This is the pure, unit-testable seam — `process_background` decodes/downscales
|
||||
//! arbitrary input defensively (same caution as avatar uploads) and `scrim_color`
|
||||
//! computes the overlay tint. The file I/O, the `rfd` picker, and the iced
|
||||
//! `stack!` that layers image → scrim → UI all live at the app edge in
|
||||
//! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so
|
||||
//! there's no gossip-frame budget here (hence a much larger size cap than avatars).
|
||||
|
||||
use iced::Color;
|
||||
|
||||
/// Longest side a custom background is downscaled to on ingest (aspect preserved,
|
||||
/// never upscaled). Big enough to look crisp filling the window, small enough to
|
||||
/// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap.
|
||||
pub const BACKGROUND_MAX_PX: u32 = 1920;
|
||||
|
||||
/// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's
|
||||
/// fully hidden behind the theme's base colour. Half keeps a photo clearly visible
|
||||
/// while text and cards stay readable over it.
|
||||
pub const DEFAULT_DIM: f32 = 0.5;
|
||||
|
||||
/// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is
|
||||
/// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is,
|
||||
/// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is
|
||||
/// bounded by the `image` crate's defaults so a malformed/huge file is rejected
|
||||
/// rather than exhausting memory. Errors come back as a message for the UI.
|
||||
pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
|
||||
// Only ever shrink. `resize` preserves aspect, fitting within the box; a
|
||||
// higher-quality filter than `thumbnail` since a background fills the window.
|
||||
let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX {
|
||||
img.resize(
|
||||
BACKGROUND_MAX_PX,
|
||||
BACKGROUND_MAX_PX,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
scaled
|
||||
.write_to(&mut png, image::ImageFormat::Png)
|
||||
.map_err(|e| format!("Couldn't encode image: {e}"))?;
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A valid PNG of the given size, as raw bytes (test helper).
|
||||
fn make_png(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::DynamicImage::new_rgb8(w, h);
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_background_downscales_oversized() {
|
||||
// A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX,
|
||||
// aspect preserved, and the result re-decodes as a PNG within bounds.
|
||||
let raw = make_png(4000, 2000);
|
||||
let png = process_background(&raw).expect("should process");
|
||||
let decoded = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX);
|
||||
assert_eq!(decoded.width(), BACKGROUND_MAX_PX);
|
||||
assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_background_leaves_small_images_unscaled() {
|
||||
let raw = make_png(640, 480);
|
||||
let png = process_background(&raw).expect("should process");
|
||||
let decoded = image::load_from_memory(&png).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (640, 480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_background_rejects_non_image() {
|
||||
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);
|
||||
let s = scrim_color(base, 0.5);
|
||||
assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3));
|
||||
assert!((s.a - 0.5).abs() < f32::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrim_color_clamps_dim() {
|
||||
let base = Color::BLACK;
|
||||
assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON);
|
||||
assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Audio playout diagnostic probe.
|
||||
//!
|
||||
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path
|
||||
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production
|
||||
//! the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! Drives a phase-continuous sine tone through the *real* playback path
|
||||
//! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
|
||||
//! production the production mixer uses (`core/mod.rs`): generate a frame only while the
|
||||
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
|
||||
//! PipeWire hardware clock. No network, no microphone — this isolates the local
|
||||
//! output path so we can confirm the clock-paced playout is glitch-free.
|
||||
//! hardware clock. No network, no microphone — this isolates the local output
|
||||
//! path so we can confirm the clock-paced playout is glitch-free.
|
||||
//!
|
||||
//! Use your ears on the tone (any click/pop is a glitch) together with the
|
||||
//! `playout-health:` lines tailed to stdout:
|
||||
@@ -17,21 +17,43 @@
|
||||
//!
|
||||
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
|
||||
//! e.g. cargo run --release --bin audio_probe -- 440 30
|
||||
//!
|
||||
//! This probe exercises the platform playback backend directly: PipeWire on Linux
|
||||
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
|
||||
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
#[cfg(target_os = "linux")]
|
||||
fn main() {
|
||||
unix_probe::run();
|
||||
}
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 samples = 20ms @ 48kHz mono
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
win_probe::run();
|
||||
}
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
#[cfg(not(any(target_os = "linux", windows)))]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly)."
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
#[cfg(target_os = "linux")]
|
||||
mod unix_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::pipewire_impl::PipeWireBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn run() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
@@ -69,11 +91,14 @@ async fn main() {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES);
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
@@ -87,11 +112,11 @@ async fn main() {
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
@@ -116,4 +141,111 @@ fn spawn_log_tailer() {
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod win_probe {
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use peerspeak::audio::AudioBackend;
|
||||
use peerspeak::audio::cpal_impl::CpalBackend;
|
||||
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
|
||||
|
||||
const SAMPLE_RATE: f32 = 48_000.0;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn run() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
|
||||
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
|
||||
let target_node: Option<String> = args.next();
|
||||
|
||||
// The playout-health logger is quiet in normal operation (it only logs
|
||||
// glitches); ask it for the full once-per-second heartbeat so the probe can
|
||||
// show the steady-state numbers.
|
||||
// SAFETY: set before any playback thread starts, so no concurrent env read.
|
||||
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
|
||||
|
||||
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
|
||||
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
|
||||
|
||||
// Tail the app log (where playout-health lines land) to stdout in the
|
||||
// background so it's all in one terminal.
|
||||
spawn_log_tailer();
|
||||
|
||||
let backend = CpalBackend::new();
|
||||
let (tx, rx) = mpsc::channel::<Vec<i16>>();
|
||||
let ring_fill = Arc::new(AtomicUsize::new(0));
|
||||
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
|
||||
eprintln!("failed to start playback: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
|
||||
// exactly like the production mixer: only produce while the ring is below
|
||||
// target, so production tracks the cpal/WASAPI hardware clock.
|
||||
use std::sync::atomic::Ordering;
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
|
||||
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
continue;
|
||||
}
|
||||
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
|
||||
for _ in 0..FRAME_SAMPLES {
|
||||
let t = n as f32 / SAMPLE_RATE;
|
||||
// 0.25 amplitude: clearly audible but not harsh.
|
||||
let sample =
|
||||
(0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
|
||||
// Stereo playback bus: duplicate the probe tone to L/R.
|
||||
frame.push(sample);
|
||||
frame.push(sample);
|
||||
n += 1;
|
||||
}
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!("playback channel closed early");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Let the ring drain, then stop.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
let _ = backend.stop();
|
||||
println!("\naudio_probe: done.");
|
||||
}
|
||||
|
||||
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
|
||||
/// reports) to stdout once they appear.
|
||||
fn spawn_log_tailer() {
|
||||
let path = peerspeak::log_file_path();
|
||||
std::thread::spawn(move || {
|
||||
// Wait for the file to exist (first log_msg creates it).
|
||||
let file = loop {
|
||||
if let Ok(f) = std::fs::File::open(&path) {
|
||||
break f;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
let mut reader = BufReader::new(file);
|
||||
let _ = reader.seek(SeekFrom::End(0));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
match reader.read_line(&mut line) {
|
||||
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
|
||||
Ok(_) => {
|
||||
if line.contains("playout-health:") {
|
||||
print!("{line}");
|
||||
}
|
||||
}
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(150)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +91,9 @@ 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);
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Roster-bound chat identity (chat-hardening plan, Phase 2).
|
||||
//!
|
||||
//! The wire `GossipMessage::Chat` carries a sender-CLAIMED display name, which
|
||||
//! any insider could set to another member's name. This map is the antidote:
|
||||
//! the core event task records each authenticated member's latest sanitized
|
||||
//! presence name here (from `PeerJoined`/`PeerUpdated`, the events that only
|
||||
//! fire for a verified signed `Announce`), and chat renders under THAT name —
|
||||
//! the embedded wire name is never displayed.
|
||||
//!
|
||||
//! Shared (`Arc<Mutex<…>>`) because eviction happens in two places: the event
|
||||
//! task itself (graceful `PeerLeft`) and the detached reconnect-grace timer
|
||||
//! (terminal eviction). A peer mid-reconnect-grace keeps its entry, so its
|
||||
//! chat stays admitted until the grace actually expires.
|
||||
|
||||
use iroh::EndpointId;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Bound on tracked names. Mirrors the gossip roster cap (`MAX_ACTIVE_PEERS`):
|
||||
/// insertions only follow cap-gated roster admissions, so this is pure defense
|
||||
/// in depth against that invariant breaking.
|
||||
const CHAT_ROSTER_CAP: usize = 32;
|
||||
|
||||
/// The authoritative id → display-name map for the current room. Cheap to
|
||||
/// clone; all clones share one map.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ChatRoster {
|
||||
names: Arc<Mutex<HashMap<EndpointId, String>>>,
|
||||
}
|
||||
|
||||
impl ChatRoster {
|
||||
/// Record (or refresh) a member's display name. The name is re-sanitized
|
||||
/// here (idempotent — gossip ingress already did) and an empty result falls
|
||||
/// back to the short node id so a chat line is never label-less. A NEW id
|
||||
/// is refused past the cap; updates to a present id always land.
|
||||
pub fn upsert(&self, id: EndpointId, name: &str) {
|
||||
let clean = crate::sanitize::sanitize_name(name);
|
||||
let label = if clean.is_empty() {
|
||||
crate::short_id(&id.to_string())
|
||||
} else {
|
||||
clean
|
||||
};
|
||||
let mut names = self.names.lock().unwrap();
|
||||
if names.contains_key(&id) || names.len() < CHAT_ROSTER_CAP {
|
||||
names.insert(id, label);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a member on graceful leave or terminal (grace-expired) eviction.
|
||||
pub fn remove(&self, id: &EndpointId) {
|
||||
self.names.lock().unwrap().remove(id);
|
||||
}
|
||||
|
||||
/// The roster-bound name for an id, or `None` if the author is not a
|
||||
/// current member — the caller must then drop the chat entirely.
|
||||
pub fn name_of(&self, id: &EndpointId) -> Option<String> {
|
||||
self.names.lock().unwrap().get(id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::SecretKey;
|
||||
|
||||
fn fresh_id() -> EndpointId {
|
||||
SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_then_lookup_returns_sanitized_name() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "Alice");
|
||||
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
|
||||
// Bidi override / zero-width spoofing characters are stripped.
|
||||
roster.upsert(a, "Al\u{202E}ice\u{200B}");
|
||||
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_update_affects_future_lookups() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "Alice");
|
||||
roster.upsert(a, "Alice2");
|
||||
assert_eq!(roster.name_of(&a), Some("Alice2".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_author_has_no_name() {
|
||||
let roster = ChatRoster::default();
|
||||
roster.upsert(fresh_id(), "Alice");
|
||||
assert_eq!(roster.name_of(&fresh_id()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_author_is_no_longer_a_member() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "Alice");
|
||||
roster.remove(&a);
|
||||
assert_eq!(roster.name_of(&a), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sanitized_name_falls_back_to_short_id() {
|
||||
let roster = ChatRoster::default();
|
||||
let a = fresh_id();
|
||||
roster.upsert(a, "\u{0}\r\n\t ");
|
||||
let label = roster.name_of(&a).unwrap();
|
||||
assert!(!label.is_empty());
|
||||
assert_eq!(label, crate::short_id(&a.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ids_are_refused_past_the_cap_but_updates_land() {
|
||||
let roster = ChatRoster::default();
|
||||
let first = fresh_id();
|
||||
roster.upsert(first, "member");
|
||||
for _ in 1..CHAT_ROSTER_CAP {
|
||||
roster.upsert(fresh_id(), "member");
|
||||
}
|
||||
// A brand-new 33rd id is refused...
|
||||
let overflow = fresh_id();
|
||||
roster.upsert(overflow, "overflow");
|
||||
assert_eq!(roster.name_of(&overflow), None);
|
||||
// ...but an update to a present id still lands at the cap.
|
||||
roster.upsert(first, "renamed");
|
||||
assert_eq!(roster.name_of(&first), Some("renamed".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Per-peer connection-transparency derivation.
|
||||
//!
|
||||
//! The transport hands us cumulative counters for each peer's selected QUIC
|
||||
//! path ([`PathSnapshot`]); this module turns two consecutive snapshots into
|
||||
//! the human-facing [`PeerConnInfo`] the UI renders (badge + tooltip): path
|
||||
//! type, RTT, and loss/bitrate over the poll window. Pure functions only —
|
||||
//! the polling task in `core::mod` owns the clock and the previous-snapshot
|
||||
//! map.
|
||||
|
||||
use crate::network::PathSnapshot;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often the core polls the transport for path snapshots.
|
||||
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Derived, display-ready connection info for one peer, sent to the UI via
|
||||
/// `UiEvent::ConnectionStats`. Window-relative fields are `None` when they
|
||||
/// can't be derived yet (first poll, path switch, or an idle window).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PeerConnInfo {
|
||||
/// True = relayed path, false = direct IP path.
|
||||
pub relay: bool,
|
||||
/// `ip:port` for a direct path, the relay URL for a relayed one.
|
||||
pub remote_addr: String,
|
||||
/// Path round-trip time, rounded to whole milliseconds.
|
||||
pub rtt_ms: u32,
|
||||
/// Percentage of packets sent in the window that were detected lost.
|
||||
pub loss_pct: Option<f32>,
|
||||
/// Outbound bitrate over the window, kilobits per second.
|
||||
pub up_kbps: Option<f32>,
|
||||
/// Inbound bitrate over the window, kilobits per second.
|
||||
pub down_kbps: Option<f32>,
|
||||
}
|
||||
|
||||
/// Derive display info from the current snapshot and (when comparable) the
|
||||
/// previous one. `prev` is comparable only if it's the same path — a relay→
|
||||
/// direct migration or a reconnect resets the counters, so those windows
|
||||
/// yield `None` rates rather than garbage (negative deltas show up as
|
||||
/// `cur < prev` and are treated the same way).
|
||||
pub fn derive(prev: Option<&PathSnapshot>, cur: &PathSnapshot, elapsed: Duration) -> PeerConnInfo {
|
||||
let rates = prev
|
||||
.filter(|p| comparable(p, cur))
|
||||
.and_then(|p| window_rates(p, cur, elapsed));
|
||||
PeerConnInfo {
|
||||
relay: cur.is_relay,
|
||||
remote_addr: cur.remote_addr.clone(),
|
||||
rtt_ms: cur.rtt.as_millis().min(u128::from(u32::MAX)) as u32,
|
||||
loss_pct: rates.and_then(|r| r.loss_pct),
|
||||
up_kbps: rates.map(|r| r.up_kbps),
|
||||
down_kbps: rates.map(|r| r.down_kbps),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `cur`'s counters continue `prev`'s: same path (address) and
|
||||
/// monotonically non-decreasing counters (a reconnect on the same address
|
||||
/// restarts them from zero).
|
||||
fn comparable(prev: &PathSnapshot, cur: &PathSnapshot) -> bool {
|
||||
prev.remote_addr == cur.remote_addr
|
||||
&& cur.tx_bytes >= prev.tx_bytes
|
||||
&& cur.rx_bytes >= prev.rx_bytes
|
||||
&& cur.tx_datagrams >= prev.tx_datagrams
|
||||
&& cur.lost_packets >= prev.lost_packets
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct WindowRates {
|
||||
loss_pct: Option<f32>,
|
||||
up_kbps: f32,
|
||||
down_kbps: f32,
|
||||
}
|
||||
|
||||
fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option<WindowRates> {
|
||||
let secs = elapsed.as_secs_f64();
|
||||
if secs <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let sent = cur.tx_datagrams - prev.tx_datagrams;
|
||||
let lost = cur.lost_packets - prev.lost_packets;
|
||||
// Loss detection lags sending (it needs ACK timeouts), so a window can see
|
||||
// more losses than sends; clamp to 100% rather than exceeding it. An idle
|
||||
// window (nothing sent or lost) has no loss story to tell.
|
||||
let loss_pct = if sent == 0 && lost == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(((lost as f64 / (sent.max(lost)) as f64) * 100.0) as f32)
|
||||
};
|
||||
let kbps = |bytes: u64| ((bytes as f64 * 8.0 / 1000.0) / secs) as f32;
|
||||
Some(WindowRates {
|
||||
loss_pct,
|
||||
up_kbps: kbps(cur.tx_bytes - prev.tx_bytes),
|
||||
down_kbps: kbps(cur.rx_bytes - prev.rx_bytes),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snap(addr: &str, tx_b: u64, rx_b: u64, tx_d: u64, lost: u64) -> PathSnapshot {
|
||||
PathSnapshot {
|
||||
is_relay: false,
|
||||
remote_addr: addr.to_string(),
|
||||
rtt: Duration::from_millis(12),
|
||||
tx_bytes: tx_b,
|
||||
rx_bytes: rx_b,
|
||||
tx_datagrams: tx_d,
|
||||
lost_packets: lost,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_poll_has_type_and_rtt_but_no_rates() {
|
||||
let cur = snap("1.2.3.4:5", 1000, 2000, 50, 0);
|
||||
let info = derive(None, &cur, POLL_INTERVAL);
|
||||
assert_eq!(info.rtt_ms, 12);
|
||||
assert!(!info.relay);
|
||||
assert_eq!(info.remote_addr, "1.2.3.4:5");
|
||||
assert_eq!(info.loss_pct, None);
|
||||
assert_eq!(info.up_kbps, None);
|
||||
assert_eq!(info.down_kbps, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_window_yields_rates_and_loss() {
|
||||
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||
// 1s window: 4000 bytes up (32 kbps), 2000 down (16 kbps), 2 of 100 lost.
|
||||
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||
assert_eq!(info.up_kbps, Some(32.0));
|
||||
assert_eq!(info.down_kbps, Some(16.0));
|
||||
assert_eq!(info.loss_pct, Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_window_has_no_loss_story() {
|
||||
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||
let cur = prev.clone();
|
||||
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||
assert_eq!(info.loss_pct, None);
|
||||
assert_eq!(info.up_kbps, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_detected_in_an_idle_window_clamps_to_full() {
|
||||
// Losses can be *detected* after sending stops (ACK timeouts fire late).
|
||||
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 0);
|
||||
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 3);
|
||||
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||
assert_eq!(info.loss_pct, Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_switch_resets_the_window() {
|
||||
let prev = snap("relay.example:443", 9000, 9000, 900, 5);
|
||||
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
|
||||
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||
assert_eq!(info.up_kbps, None);
|
||||
assert_eq!(info.loss_pct, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counter_reset_on_same_address_resets_the_window() {
|
||||
// Same address but the connection was rebuilt → counters restarted.
|
||||
let prev = snap("1.2.3.4:5", 9000, 9000, 900, 5);
|
||||
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
|
||||
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||
assert_eq!(info.up_kbps, None);
|
||||
assert_eq!(info.loss_pct, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_elapsed_yields_no_rates() {
|
||||
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||
let info = derive(Some(&prev), &cur, Duration::ZERO);
|
||||
assert_eq!(info.up_kbps, None);
|
||||
assert_eq!(info.loss_pct, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_rtt_saturates_instead_of_wrapping() {
|
||||
let mut cur = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||
cur.rtt = Duration::from_secs(u64::MAX);
|
||||
let info = derive(None, &cur, POLL_INTERVAL);
|
||||
assert_eq!(info.rtt_ms, u32::MAX);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Byte/request budgets for AUTOMATIC chat-attachment fetches (Phase 3B).
|
||||
//!
|
||||
//! The four-permit semaphore bounds how many auto-fetch tasks run at once, but
|
||||
//! not how much a peer can make us download over time: with permits released
|
||||
//! after each transfer, an insider could stream distinct ≤4 MiB images
|
||||
//! sequentially forever. This budget adds per-author and session (room-wide)
|
||||
//! token buckets over both request COUNT and declared BYTES. Like the Phase 2
|
||||
//! chat gate, time is passed in — never read from a clock — so every refill
|
||||
//! boundary is unit-testable.
|
||||
//!
|
||||
//! Only the automatic path consults this; a user's explicit click (Save /
|
||||
//! Download / Load image) is human-rate-limited and always allowed through to
|
||||
//! the fetch (still subject to the transfer cap and cache/decoder budgets).
|
||||
|
||||
use iroh::EndpointId;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Per-author request burst: how many auto-fetches one author can trigger
|
||||
/// back-to-back before refill pacing binds.
|
||||
pub const AUTHOR_REQ_BURST: f64 = 8.0;
|
||||
/// Per-author request refill: one recovered every 10 s.
|
||||
pub const AUTHOR_REQ_REFILL_PER_MS: f64 = 1.0 / 10_000.0;
|
||||
/// Per-author byte burst (declared sizes): a couple of full-size auto images
|
||||
/// plus a normal working set.
|
||||
pub const AUTHOR_BYTES_BURST: f64 = (16 * 1024 * 1024) as f64;
|
||||
/// Per-author byte refill: 64 KiB/s (~one 4 MiB auto image per minute).
|
||||
pub const AUTHOR_BYTES_REFILL_PER_MS: f64 = (64 * 1024) as f64 / 1000.0;
|
||||
|
||||
/// Session-wide request burst across all authors.
|
||||
pub const SESSION_REQ_BURST: f64 = 16.0;
|
||||
/// Session-wide request refill: one recovered every 5 s.
|
||||
pub const SESSION_REQ_REFILL_PER_MS: f64 = 1.0 / 5_000.0;
|
||||
/// Session-wide byte burst across all authors.
|
||||
pub const SESSION_BYTES_BURST: f64 = (48 * 1024 * 1024) as f64;
|
||||
/// Session-wide byte refill: 128 KiB/s.
|
||||
pub const SESSION_BYTES_REFILL_PER_MS: f64 = (128 * 1024) as f64 / 1000.0;
|
||||
|
||||
/// Bound on the per-author bucket map. Authors are roster members (≤32 live),
|
||||
/// so this tracks the roster plus recently departed; the least-recently-active
|
||||
/// entry is pruned past the cap.
|
||||
pub const AUTHOR_MAP_CAP: usize = 64;
|
||||
|
||||
/// A deterministic token bucket that can take a WEIGHTED cost (bytes), unlike
|
||||
/// the unit-cost bucket in the gossip chat gate.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct WeightedBucket {
|
||||
tokens: f64,
|
||||
last_ms: u64,
|
||||
}
|
||||
|
||||
impl WeightedBucket {
|
||||
fn full(burst: f64, now_ms: u64) -> Self {
|
||||
Self {
|
||||
tokens: burst,
|
||||
last_ms: now_ms,
|
||||
}
|
||||
}
|
||||
|
||||
/// Refill for elapsed time (capped at `burst`) without consuming.
|
||||
fn refill(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) {
|
||||
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
|
||||
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
|
||||
self.last_ms = now_ms;
|
||||
}
|
||||
|
||||
fn has(&self, cost: f64) -> bool {
|
||||
self.tokens >= cost
|
||||
}
|
||||
|
||||
fn take(&mut self, cost: f64) {
|
||||
self.tokens -= cost;
|
||||
}
|
||||
}
|
||||
|
||||
/// One author's pair of buckets plus last activity (for idle pruning).
|
||||
#[derive(Debug)]
|
||||
struct AuthorBudget {
|
||||
reqs: WeightedBucket,
|
||||
bytes: WeightedBucket,
|
||||
last_seen_ms: u64,
|
||||
}
|
||||
|
||||
/// Admission budget for automatic attachment fetches. All four buckets are
|
||||
/// checked BEFORE any is consumed, so a rejection never burns tokens (no
|
||||
/// refund bookkeeping — the check-then-take is atomic within `admit`).
|
||||
#[derive(Debug)]
|
||||
pub struct AutoFetchBudget {
|
||||
session_reqs: WeightedBucket,
|
||||
session_bytes: WeightedBucket,
|
||||
authors: HashMap<EndpointId, AuthorBudget>,
|
||||
}
|
||||
|
||||
impl AutoFetchBudget {
|
||||
pub fn new(now_ms: u64) -> Self {
|
||||
Self {
|
||||
session_reqs: WeightedBucket::full(SESSION_REQ_BURST, now_ms),
|
||||
session_bytes: WeightedBucket::full(SESSION_BYTES_BURST, now_ms),
|
||||
authors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an auto-fetch of `size` declared bytes for `author` may start
|
||||
/// now. Consumes one request token and `size` byte tokens from BOTH the
|
||||
/// author's and the session's buckets — or nothing at all on rejection.
|
||||
pub fn admit(&mut self, author: EndpointId, size: u64, now_ms: u64) -> bool {
|
||||
self.prune(author, now_ms);
|
||||
let entry = self.authors.entry(author).or_insert_with(|| AuthorBudget {
|
||||
reqs: WeightedBucket::full(AUTHOR_REQ_BURST, now_ms),
|
||||
bytes: WeightedBucket::full(AUTHOR_BYTES_BURST, now_ms),
|
||||
last_seen_ms: now_ms,
|
||||
});
|
||||
entry.last_seen_ms = now_ms;
|
||||
entry
|
||||
.reqs
|
||||
.refill(AUTHOR_REQ_BURST, AUTHOR_REQ_REFILL_PER_MS, now_ms);
|
||||
entry
|
||||
.bytes
|
||||
.refill(AUTHOR_BYTES_BURST, AUTHOR_BYTES_REFILL_PER_MS, now_ms);
|
||||
self.session_reqs
|
||||
.refill(SESSION_REQ_BURST, SESSION_REQ_REFILL_PER_MS, now_ms);
|
||||
self.session_bytes
|
||||
.refill(SESSION_BYTES_BURST, SESSION_BYTES_REFILL_PER_MS, now_ms);
|
||||
|
||||
let cost = size as f64;
|
||||
let ok = entry.reqs.has(1.0)
|
||||
&& entry.bytes.has(cost)
|
||||
&& self.session_reqs.has(1.0)
|
||||
&& self.session_bytes.has(cost);
|
||||
if ok {
|
||||
let entry = self.authors.get_mut(&author).expect("just inserted");
|
||||
entry.reqs.take(1.0);
|
||||
entry.bytes.take(cost);
|
||||
self.session_reqs.take(1.0);
|
||||
self.session_bytes.take(cost);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Keep the author map bounded: past the cap, drop the least-recently
|
||||
/// active entry that isn't the author being admitted. A pruned author
|
||||
/// returns with full buckets, but authors are roster-gated upstream, so
|
||||
/// the map can't be churned by strangers.
|
||||
fn prune(&mut self, keep: EndpointId, _now_ms: u64) {
|
||||
while self.authors.len() >= AUTHOR_MAP_CAP {
|
||||
let Some(victim) = self
|
||||
.authors
|
||||
.iter()
|
||||
.filter(|(id, _)| **id != keep)
|
||||
.min_by_key(|(_, b)| b.last_seen_ms)
|
||||
.map(|(id, _)| *id)
|
||||
else {
|
||||
break;
|
||||
};
|
||||
self.authors.remove(&victim);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn author_count(&self) -> usize {
|
||||
self.authors.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::SecretKey;
|
||||
|
||||
const T0: u64 = 1_000_000;
|
||||
const MIB: u64 = 1024 * 1024;
|
||||
|
||||
fn author() -> EndpointId {
|
||||
SecretKey::generate().public()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_request_burst_then_refill_recovers() {
|
||||
let mut b = AutoFetchBudget::new(T0);
|
||||
let a = author();
|
||||
// Tiny sizes so only the REQUEST buckets can bind.
|
||||
for _ in 0..AUTHOR_REQ_BURST as usize {
|
||||
assert!(b.admit(a, 1, T0));
|
||||
}
|
||||
assert!(!b.admit(a, 1, T0), "author request burst exhausted");
|
||||
// One request refills after 10 s.
|
||||
assert!(b.admit(a, 1, T0 + 10_000));
|
||||
assert!(!b.admit(a, 1, T0 + 10_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_byte_budget_binds_and_recovers() {
|
||||
let mut b = AutoFetchBudget::new(T0);
|
||||
let a = author();
|
||||
// 4 × 4 MiB = the full 16 MiB author byte burst (well under the
|
||||
// 8-request burst, so bytes are the binding constraint).
|
||||
for _ in 0..4 {
|
||||
assert!(b.admit(a, 4 * MIB, T0));
|
||||
}
|
||||
assert!(!b.admit(a, 4 * MIB, T0), "author byte burst exhausted");
|
||||
// 64 KiB/s → a 4 MiB image is affordable again after 64 s (which also
|
||||
// refills 6 request tokens, so bytes stay the binding constraint).
|
||||
assert!(!b.admit(a, 4 * MIB, T0 + 32_000));
|
||||
assert!(b.admit(a, 4 * MIB, T0 + 64_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_budget_binds_across_authors_without_burning_author_tokens() {
|
||||
let mut b = AutoFetchBudget::new(T0);
|
||||
// Three authors × 16 MiB exhausts the 48 MiB session byte burst even
|
||||
// though each author is within their own budget.
|
||||
for _ in 0..3 {
|
||||
let a = author();
|
||||
for _ in 0..4 {
|
||||
assert!(b.admit(a, 4 * MIB, T0));
|
||||
}
|
||||
}
|
||||
let fresh = author();
|
||||
assert!(!b.admit(fresh, 4 * MIB, T0), "session bytes exhausted");
|
||||
// The rejection consumed NOTHING: once the session refills enough for
|
||||
// one image (4 MiB / 128 KiB/s = 32 s), the fresh author's own full
|
||||
// burst is intact and admits immediately.
|
||||
assert!(b.admit(fresh, 4 * MIB, T0 + 32_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_request_bucket_binds_across_authors() {
|
||||
let mut b = AutoFetchBudget::new(T0);
|
||||
// 16 tiny requests from distinct authors exhaust the session request
|
||||
// burst while every author bucket stays nearly full.
|
||||
for _ in 0..SESSION_REQ_BURST as usize {
|
||||
assert!(b.admit(author(), 1, T0));
|
||||
}
|
||||
assert!(!b.admit(author(), 1, T0), "session requests exhausted");
|
||||
assert!(b.admit(author(), 1, T0 + 5_000), "one recovers after 5 s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn author_map_stays_bounded_pruning_least_recent() {
|
||||
let mut b = AutoFetchBudget::new(T0);
|
||||
// Session request refill would bind over a naive loop; space the
|
||||
// admissions out so only the map bound is under test.
|
||||
let mut t = T0;
|
||||
let first = author();
|
||||
assert!(b.admit(first, 1, t));
|
||||
for _ in 0..(AUTHOR_MAP_CAP + 10) {
|
||||
t += 10_000;
|
||||
assert!(b.admit(author(), 1, t));
|
||||
assert!(b.author_count() <= AUTHOR_MAP_CAP);
|
||||
}
|
||||
assert!(b.author_count() <= AUTHOR_MAP_CAP);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,10 @@ const PRIME_TIMEOUT_TICKS: usize = 25;
|
||||
/// badly behind, so we drop the oldest and resync rather than grow unbounded.
|
||||
const MAX_BUFFERED_FRAMES: usize = 32;
|
||||
|
||||
/// Sequence discontinuities larger than this (~10s at 20ms/frame) are treated
|
||||
/// as a restarted/new stream, not ordinary packet loss or reordering.
|
||||
const MAX_REASONABLE_SEQ_GAP: u32 = 500;
|
||||
|
||||
pub struct JitterBuffer {
|
||||
decoder: OpusDecoder,
|
||||
/// Reorder window: sequence number -> encoded Opus payload.
|
||||
@@ -116,6 +120,14 @@ impl JitterBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_to_stream(&mut self, seq: u32, payload: Vec<u8>) {
|
||||
self.packets.clear();
|
||||
self.packets.insert(seq, payload);
|
||||
self.next_seq = None;
|
||||
self.clean_run = 0;
|
||||
self.buffering_ticks = 0;
|
||||
}
|
||||
|
||||
/// Store a received packet, dropping ones we've already played past and
|
||||
/// bounding total depth.
|
||||
pub fn insert(&mut self, seq: u32, payload: Vec<u8>) {
|
||||
@@ -124,9 +136,19 @@ impl JitterBuffer {
|
||||
if let Some(next) = self.next_seq
|
||||
&& seq_before(seq, next)
|
||||
{
|
||||
if next.wrapping_sub(seq) > MAX_REASONABLE_SEQ_GAP {
|
||||
self.reset_to_stream(seq, payload);
|
||||
return;
|
||||
}
|
||||
self.note_disruption();
|
||||
return;
|
||||
}
|
||||
if let Some(next) = self.next_seq
|
||||
&& seq.wrapping_sub(next) > MAX_REASONABLE_SEQ_GAP
|
||||
{
|
||||
self.reset_to_stream(seq, payload);
|
||||
return;
|
||||
}
|
||||
self.packets.insert(seq, payload);
|
||||
|
||||
while self.packets.len() > MAX_BUFFERED_FRAMES {
|
||||
@@ -180,15 +202,24 @@ 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. Try Opus in-band FEC from the
|
||||
// packet right after the gap; if that packet isn't buffered
|
||||
// (burst loss) or FEC fails, fall back to plain PLC.
|
||||
self.next_seq = Some(next.wrapping_add(1));
|
||||
self.note_disruption();
|
||||
let (&smallest, next_payload) = self.packets.iter().next().expect("non-empty");
|
||||
if fec_covers_gap(next, smallest) {
|
||||
self.decoder
|
||||
.decode_fec(next_payload)
|
||||
.or_else(|_| self.decoder.decode(None))
|
||||
.ok()
|
||||
} else {
|
||||
self.decoder.decode(None).ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when nothing is buffered and playout is idle (talker silent).
|
||||
pub fn is_idle(&self) -> bool {
|
||||
@@ -196,11 +227,20 @@ impl JitterBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opus in-band FEC in packet N carries a low-fidelity copy of frame N-1 and
|
||||
/// nothing else — a lost frame `next` is FEC-recoverable solely from packet
|
||||
/// `next+1`. Any later successor's FEC data is a different frame's audio, and
|
||||
/// splicing it into this gap plays sound from the wrong position; the caller
|
||||
/// must conceal with plain PLC instead.
|
||||
fn fec_covers_gap(next: u32, smallest_buffered: u32) -> bool {
|
||||
smallest_buffered == next.wrapping_add(1)
|
||||
}
|
||||
|
||||
#[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`.
|
||||
@@ -211,6 +251,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();
|
||||
@@ -267,6 +333,137 @@ 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 fec_covers_gap_only_for_the_immediate_successor() {
|
||||
// Packet next+1 is the only one whose in-band FEC describes frame `next`.
|
||||
assert!(fec_covers_gap(4, 5));
|
||||
// A burst gap: the smallest survivor's FEC is some other frame's audio.
|
||||
assert!(!fec_covers_gap(3, 5));
|
||||
assert!(!fec_covers_gap(3, 3_000));
|
||||
// Sequence wraparound still counts as adjacent.
|
||||
assert!(fec_covers_gap(u32::MAX, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burst_gap_falls_back_to_plc_not_wrong_position_fec() {
|
||||
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();
|
||||
|
||||
// Frames 0..=6; 3 and 4 are lost as a burst, so when playout reaches
|
||||
// seq 3 the smallest buffered packet is 5 — whose FEC data is frame 4,
|
||||
// NOT frame 3. The buffer must conceal 3 with plain PLC rather than
|
||||
// splice frame 4's audio into the wrong position.
|
||||
let packets: Vec<Vec<u8>> = (0..7).map(|seq| tone_frame(&mut enc, 8_000, seq)).collect();
|
||||
|
||||
// Twin decoder replaying the exact call sequence the jitter buffer
|
||||
// should make for seq 3: decode 0,1,2 then a plain PLC conceal.
|
||||
let mut twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||
for packet in packets.iter().take(3) {
|
||||
twin.decode(Some(packet)).unwrap();
|
||||
}
|
||||
let expected_plc = twin.decode(None).unwrap();
|
||||
|
||||
let mut jb = JitterBuffer::new().unwrap();
|
||||
for (seq, packet) in packets.iter().enumerate() {
|
||||
if seq != 3 && seq != 4 {
|
||||
jb.insert(seq as u32, packet.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..3 {
|
||||
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||
}
|
||||
|
||||
// Seq 3: burst gap — bit-exact PLC (same decoder state, same inputs),
|
||||
// which decode_fec(packet 5) could never produce.
|
||||
let concealed = jb.pop_frame().expect("gap should be concealed");
|
||||
assert_eq!(concealed, expected_plc, "burst gap must use plain PLC");
|
||||
|
||||
// Seq 4: packet 5 IS the immediate successor, so its FEC data is
|
||||
// frame 4's audio — the correctly-positioned recovery still applies.
|
||||
let recovered = jb
|
||||
.pop_frame()
|
||||
.expect("adjacent gap should be reconstructed");
|
||||
let mut fec_twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||
for packet in packets.iter().take(3) {
|
||||
fec_twin.decode(Some(packet)).unwrap();
|
||||
}
|
||||
fec_twin.decode(None).unwrap();
|
||||
let expected_fec = fec_twin.decode_fec(&packets[5]).unwrap();
|
||||
assert_eq!(recovered, expected_fec, "adjacent gap should still use FEC");
|
||||
|
||||
// Then 5 and 6 play normally.
|
||||
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||
assert!(jb.pop_frame().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_packets_already_played() {
|
||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||
@@ -283,6 +480,44 @@ mod tests {
|
||||
assert_eq!(jb.packets.len(), 1); // only seq 7 remains buffered
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_behind_sequence_resets_as_restarted_stream() {
|
||||
let mut jb = JitterBuffer::new().unwrap();
|
||||
jb.next_seq = Some(5_000);
|
||||
jb.packets.insert(5_000, vec![9]);
|
||||
jb.clean_run = 12;
|
||||
jb.buffering_ticks = 4;
|
||||
|
||||
jb.insert(0, vec![1]);
|
||||
|
||||
assert_eq!(jb.next_seq, None);
|
||||
assert_eq!(jb.packets.len(), 1);
|
||||
assert_eq!(jb.packets.get(&0).map(Vec::as_slice), Some(&[1][..]));
|
||||
assert_eq!(jb.clean_run, 0);
|
||||
assert_eq!(jb.buffering_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_ahead_sequence_resets_to_bound_plc_run() {
|
||||
let mut jb = JitterBuffer::new().unwrap();
|
||||
jb.next_seq = Some(10);
|
||||
jb.packets.insert(10, vec![9]);
|
||||
jb.clean_run = 12;
|
||||
jb.buffering_ticks = 4;
|
||||
|
||||
let jumped_seq = 10 + MAX_REASONABLE_SEQ_GAP + 1;
|
||||
jb.insert(jumped_seq, vec![2]);
|
||||
|
||||
assert_eq!(jb.next_seq, None);
|
||||
assert_eq!(jb.packets.len(), 1);
|
||||
assert_eq!(
|
||||
jb.packets.get(&jumped_seq).map(Vec::as_slice),
|
||||
Some(&[2][..])
|
||||
);
|
||||
assert_eq!(jb.clean_run, 0);
|
||||
assert_eq!(jb.buffering_ticks, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seq_before_ordering() {
|
||||
// Basic ordering
|
||||
@@ -635,4 +870,3 @@ mod tests {
|
||||
assert_eq!(jb.clean_run, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,8 +9,20 @@ 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
|
||||
/// [`UiEvent::ShutdownComplete`].
|
||||
Shutdown,
|
||||
ToggleMute,
|
||||
/// Change our avatar (W4) and re-announce it to the room over presence.
|
||||
SetAvatar(crate::avatar::Avatar),
|
||||
@@ -18,6 +30,14 @@ pub enum CoreCommand {
|
||||
SetPttMode(bool),
|
||||
SetPttActive(bool),
|
||||
SetPeerVolume(EndpointId, f32),
|
||||
/// Listener-side per-peer EQ. Local only; never leaves this app instance.
|
||||
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),
|
||||
@@ -29,30 +49,110 @@ 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),
|
||||
/// Set what a recording captures (mixed / per-peer stems / both). Takes
|
||||
/// effect on the next recording start. Sent at startup from config.
|
||||
SetRecordingMode(RecordingMode),
|
||||
/// Broadcast a room text-chat message. No-op when not in a call.
|
||||
SendChat(String),
|
||||
/// Broadcast a room text-chat message. `local_id` is the app's local-only
|
||||
/// handle for this send — it never goes on the wire; the core echoes it back
|
||||
/// in [`UiEvent::ChatSendResult`] so the UI can mark the matching local echo
|
||||
/// honestly (chat-hardening Phase 5). Not being in a call is a FAILURE
|
||||
/// result, not a silent no-op.
|
||||
SendChat {
|
||||
local_id: u64,
|
||||
text: 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. `local_id` as in
|
||||
/// [`CoreCommand::SendChat`].
|
||||
SendChatFile {
|
||||
local_id: u64,
|
||||
text: String,
|
||||
attachment: crate::files::ChatAttachment,
|
||||
/// Shared, not owned: the same allocation is retained by the UI cache
|
||||
/// and handed to the serve store, so a 25 MiB attachment is held once,
|
||||
/// not copied across UI / command queue / serve store (Phase 3C).
|
||||
data: std::sync::Arc<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,
|
||||
/// **Core-internal.** The running pixelpass host's stdout ended — the
|
||||
/// process died (or its event stream broke), so the share identified by
|
||||
/// `generation` is over: reap the child, pull the ticket off presence, and
|
||||
/// tell the user. Synthesized by the core's own notice-forwarder task; the
|
||||
/// UI never sends it. `generation` scopes the fault to one specific host
|
||||
/// spawn, so a stale fault (the user already stopped, or started a new
|
||||
/// share) is ignored rather than tearing down the wrong share.
|
||||
ScreenShareHostFault {
|
||||
generation: u64,
|
||||
},
|
||||
/// 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`].
|
||||
@@ -60,67 +160,513 @@ 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 {
|
||||
local_id: _,
|
||||
text: _,
|
||||
}
|
||||
| CoreCommand::SendChatFile {
|
||||
local_id: _,
|
||||
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::ScreenShareHostFault { generation: _ }
|
||||
| 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 {
|
||||
local_id: _,
|
||||
text: _,
|
||||
}
|
||||
| CoreCommand::SendChatFile {
|
||||
local_id: _,
|
||||
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::ScreenShareHostFault { generation: _ }
|
||||
| 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 },
|
||||
PeerConnectionFailed { id: EndpointId },
|
||||
PeerUpdated { id: EndpointId, state: PeerState },
|
||||
/// 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,
|
||||
},
|
||||
/// 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)>),
|
||||
/// Periodic per-peer connection transparency snapshot (~1/sec): path type
|
||||
/// (direct/relay), RTT, and window loss/bitrate for every peer with a live
|
||||
/// audio link. A FULL replacement each time — a peer absent from the list
|
||||
/// has no live link right now, so its badge should disappear.
|
||||
ConnectionStats(Vec<(EndpointId, crate::core::connstats::PeerConnInfo)>),
|
||||
/// 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,
|
||||
},
|
||||
/// The outcome of one locally initiated chat send (chat-hardening Phase 5).
|
||||
/// `error = None` means our signed broadcast was handed to the gossip swarm
|
||||
/// — deliberately NOT a delivery/read receipt; PeerSpeak has no peer
|
||||
/// acknowledgements. `local_id` is the app's own handle from the
|
||||
/// `SendChat`/`SendChatFile` command and never appears on the wire.
|
||||
ChatSendResult {
|
||||
local_id: u64,
|
||||
error: Option<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: std::sync::Arc<Vec<u8>>,
|
||||
},
|
||||
/// An attachment fetch task was spawned (auto or on demand). Lets the UI
|
||||
/// show a real "loading" state instead of inferring it from cache absence —
|
||||
/// absence now means NOT fetched (e.g. auto-fetch was skipped), which
|
||||
/// renders a Load button rather than an indefinite "loading…" (Phase 3B).
|
||||
AttachmentFetchStarted {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
},
|
||||
/// 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 },
|
||||
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence
|
||||
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The
|
||||
/// GUI must mirror + persist this so its presence picker stops showing
|
||||
/// Discoverable. Distinct from a user-driven change so the GUI knows to update
|
||||
/// without having issued the command itself.
|
||||
PresenceModeReverted { mode: PresenceMode },
|
||||
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,
|
||||
},
|
||||
/// 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 {
|
||||
local_id: 1,
|
||||
text: "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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
use crate::network::{RoomState, gossip::IrohGossipState};
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::Instant;
|
||||
|
||||
const RECOVERY_COMMAND_CAPACITY: usize = 64;
|
||||
const RECOVERY_DELAYS: [Duration; 7] = [
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(2),
|
||||
Duration::from_secs(4),
|
||||
Duration::from_secs(8),
|
||||
Duration::from_secs(15),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(60),
|
||||
];
|
||||
|
||||
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,
|
||||
addr: EndpointAddr,
|
||||
},
|
||||
Cancel(EndpointId),
|
||||
}
|
||||
|
||||
struct RecoveryEntry {
|
||||
addr: EndpointAddr,
|
||||
attempt: usize,
|
||||
next_attempt: Instant,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
trait RecoveryRoom: Send + Sync {
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String>;
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RecoveryRoom for IrohGossipState {
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
||||
RoomState::rebootstrap_peers(self, peers)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable command side of the single per-session recovery coordinator.
|
||||
/// `active` is shared with transport/event handlers so cancellation is visible
|
||||
/// immediately even while the coordinator is awaiting an in-flight gossip call.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RecoveryCoordinator {
|
||||
tx: mpsc::Sender<RecoveryCommand>,
|
||||
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||
}
|
||||
|
||||
impl RecoveryCoordinator {
|
||||
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<()>, 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, terminal_tx));
|
||||
(handle, task, terminal_rx)
|
||||
}
|
||||
|
||||
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
||||
/// false when the peer is already recovering, preventing duplicate work.
|
||||
pub(super) fn begin(&self, peer_id: EndpointId) -> bool {
|
||||
self.active.lock().unwrap().insert(peer_id)
|
||||
}
|
||||
|
||||
/// Activate the reserved slot with its retained authenticated address.
|
||||
/// Uses a bounded non-blocking send while holding the active-set lock so a
|
||||
/// concurrent cancellation is ordered before or after this command.
|
||||
pub(super) fn activate(&self, peer_id: EndpointId, addr: EndpointAddr) -> Result<bool, ()> {
|
||||
let mut active = self.active.lock().unwrap();
|
||||
if !active.contains(&peer_id) {
|
||||
return Ok(false);
|
||||
}
|
||||
if self
|
||||
.tx
|
||||
.try_send(RecoveryCommand::Start { peer_id, addr })
|
||||
.is_err()
|
||||
{
|
||||
active.remove(&peer_id);
|
||||
return Err(());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn cancel(&self, peer_id: EndpointId) {
|
||||
self.active.lock().unwrap().remove(&peer_id);
|
||||
// Cancellation is governed by the shared active set, so it remains
|
||||
// immediate even if the bounded command queue is temporarily full.
|
||||
let _ = self.tx.try_send(RecoveryCommand::Cancel(peer_id));
|
||||
}
|
||||
|
||||
pub(super) fn is_active(&self, peer_id: &EndpointId) -> bool {
|
||||
self.active.lock().unwrap().contains(peer_id)
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
loop {
|
||||
// The shared active set is the authoritative cancellation gate. Prune
|
||||
// here as well as on Cancel commands so a saturated command queue cannot
|
||||
// leave an inactive, past-due entry spinning the timer loop.
|
||||
let active_snapshot = active.lock().unwrap().clone();
|
||||
entries.retain(|peer_id, _| active_snapshot.contains(peer_id));
|
||||
let next_deadline = entries.values().map(|entry| entry.next_attempt).min();
|
||||
let command = match next_deadline {
|
||||
Some(deadline) => {
|
||||
tokio::select! {
|
||||
command = rx.recv() => command,
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
let now = Instant::now();
|
||||
let active_snapshot = active.lock().unwrap().clone();
|
||||
let due: Vec<(EndpointId, EndpointAddr)> = entries
|
||||
.iter()
|
||||
.filter(|(id, entry)| {
|
||||
entry.next_attempt <= now && active_snapshot.contains(*id)
|
||||
})
|
||||
.map(|(id, entry)| (*id, entry.addr.clone()))
|
||||
.collect();
|
||||
|
||||
if !due.is_empty() {
|
||||
let addrs = due.iter().map(|(_, addr)| addr.clone()).collect();
|
||||
if let Err(error) = room_state.rebootstrap_peers(addrs).await {
|
||||
crate::log_msg(&format!(
|
||||
"Background peer recovery attempt failed: {error}"
|
||||
));
|
||||
}
|
||||
|
||||
let scheduled_at = Instant::now();
|
||||
for (peer_id, _) in due {
|
||||
if !active.lock().unwrap().contains(&peer_id) {
|
||||
entries.remove(&peer_id);
|
||||
continue;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => rx.recv().await,
|
||||
};
|
||||
|
||||
match command {
|
||||
Some(RecoveryCommand::Start { peer_id, addr }) => {
|
||||
if active.lock().unwrap().contains(&peer_id) {
|
||||
entries.entry(peer_id).or_insert(RecoveryEntry {
|
||||
addr,
|
||||
attempt: 0,
|
||||
next_attempt: Instant::now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(RecoveryCommand::Cancel(peer_id)) => {
|
||||
entries.remove(&peer_id);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::SecretKey;
|
||||
|
||||
struct RecordingRoom {
|
||||
attempts: mpsc::UnboundedSender<Vec<EndpointAddr>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl RecoveryRoom for RecordingRoom {
|
||||
async fn rebootstrap_peers(&self, peers: Vec<EndpointAddr>) -> Result<(), String> {
|
||||
self.attempts.send(peers).map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_backoff_reaches_and_stays_at_sixty_seconds() {
|
||||
let actual: Vec<u64> = (0..10)
|
||||
.map(|attempt| recovery_delay(attempt).as_secs())
|
||||
.collect();
|
||||
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);
|
||||
let coordinator = RecoveryCoordinator {
|
||||
tx,
|
||||
active: Arc::new(Mutex::new(HashSet::new())),
|
||||
};
|
||||
let peer_id = SecretKey::generate().public();
|
||||
|
||||
assert!(coordinator.begin(peer_id));
|
||||
assert!(
|
||||
!coordinator.begin(peer_id),
|
||||
"a peer gets only one recovery slot"
|
||||
);
|
||||
assert_eq!(
|
||||
coordinator.activate(peer_id, EndpointAddr::from(peer_id)),
|
||||
Ok(true)
|
||||
);
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(RecoveryCommand::Start { peer_id: id, .. }) if id == peer_id
|
||||
));
|
||||
|
||||
coordinator.cancel(peer_id);
|
||||
assert!(!coordinator.is_active(&peer_id));
|
||||
assert!(matches!(
|
||||
rx.try_recv(),
|
||||
Ok(RecoveryCommand::Cancel(id)) if id == peer_id
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coordinator_attempts_rebootstrap_immediately() {
|
||||
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
||||
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);
|
||||
|
||||
assert!(coordinator.begin(peer_id));
|
||||
assert_eq!(coordinator.activate(peer_id, addr.clone()), Ok(true));
|
||||
let attempted = tokio::time::timeout(Duration::from_secs(1), attempts_rx.recv())
|
||||
.await
|
||||
.expect("first recovery attempt should be immediate")
|
||||
.expect("recording room remains subscribed");
|
||||
assert_eq!(attempted, vec![addr]);
|
||||
|
||||
coordinator.cancel(peer_id);
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
//! Destruction-order guarantees for the screen-share children and the
|
||||
//! echo-cancel module (phase 0b of the screenshare audio-exclusion plan;
|
||||
//! design v3.4 §7.1–§7.2, decision D4).
|
||||
//!
|
||||
//! # The invariant
|
||||
//!
|
||||
//! > **The echo-cancel module must not unload while a pixelpass host is alive
|
||||
//! > and fanning out.**
|
||||
//!
|
||||
//! If it does, the AEC's virtual nodes vanish from under a live pixelpass that
|
||||
//! still holds link proxies and a stale module index. Phase 6 makes this sharp
|
||||
//! — it is the first phase whose objects live only as long as pixelpass does —
|
||||
//! so the ordering guarantee has to exist *before* it.
|
||||
//!
|
||||
//! Two paths have to honour it, and only one of them is code we get to run:
|
||||
//!
|
||||
//! 1. **The explicit path** — [`ScreenshareTeardown::shutdown_children`], awaited
|
||||
//! by `ActiveSession::shutdown` before the guard is dropped.
|
||||
//! 2. **The drop/unwind path** — nobody calls anything. The core has numerous
|
||||
//! `unwrap()` sites and no `panic=abort` profile, so unwind is reachable, and
|
||||
//! on that path the only thing standing between us and a violated invariant
|
||||
//! is *field declaration order* plus [`ReapOnDrop`].
|
||||
//!
|
||||
//! Hence the two structural rules enforced here:
|
||||
//!
|
||||
//! - `echo_cancel` is the **last declared field** of [`ScreenshareTeardown`].
|
||||
//! Rust drops fields in declaration order, so last-declared is last-dropped.
|
||||
//! This is not a style choice; reversing it reintroduces the bug.
|
||||
//! - Killing is not enough — a child must be **reaped**. `kill_on_drop(true)`
|
||||
//! only *signals*; it hands the child to the runtime's orphan queue and
|
||||
//! returns, which on an unwinding runtime may never be drained. [`ReapOnDrop`]
|
||||
//! therefore blocks, briefly and boundedly, until the child is actually gone.
|
||||
//!
|
||||
//! Everything here is generic over [`ChildProcess`] and over the guard type so
|
||||
//! the ordering is unit-testable without spawning processes or loading PipeWire
|
||||
//! modules — the same seam idiom as `replace_viewer_index` and
|
||||
//! `rebuild_with_fallback` in the parent module.
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How long [`ReapOnDrop::drop`] will block waiting for a killed child to be
|
||||
/// reaped before giving up and logging. This runs on the unwind path, so it is
|
||||
/// a deliberate trade: a bounded stall is preferable to unloading the AEC out
|
||||
/// from under a live pixelpass, and unbounded blocking in a `Drop` is not.
|
||||
const REAP_BUDGET: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Poll interval while waiting out [`REAP_BUDGET`].
|
||||
const REAP_POLL: Duration = Duration::from_millis(5);
|
||||
|
||||
/// How long a child gets to honour the graceful stop before it is killed.
|
||||
///
|
||||
/// A healthy pixelpass exits in well under this, so the normal path never
|
||||
/// spends it; only a wedged child does. It is awaited inline in the core
|
||||
/// command loop, so it is also how long a wedged child can delay other
|
||||
/// commands — hence seconds, not tens of seconds.
|
||||
const STOP_GRACE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// The child-process operations the teardown ordering actually depends on.
|
||||
///
|
||||
/// Deliberately narrow, and deliberately not `ExitStatus`-shaped: the ordering
|
||||
/// rules care only about *whether* a child has been signalled and *whether* it
|
||||
/// has been reaped, so the test double is a few lines instead of a fabricated
|
||||
/// exit status.
|
||||
pub(super) trait ChildProcess {
|
||||
/// Ask the child to exit **gracefully**, so it can run its own cleanup.
|
||||
/// Does **not** wait, and is not guaranteed to be honoured.
|
||||
fn request_stop(&mut self) -> std::io::Result<()>;
|
||||
|
||||
/// Signal the child to die. Does **not** wait.
|
||||
fn start_kill(&mut self) -> std::io::Result<()>;
|
||||
|
||||
/// Poll once. `true` once the child has exited **and been reaped**.
|
||||
fn try_reap(&mut self) -> bool;
|
||||
|
||||
/// Wait until the child has exited and been reaped.
|
||||
///
|
||||
/// The `io::Result` is load-bearing and must not be discarded by callers:
|
||||
/// a failed wait is *not* a confirmed reap, and treating it as one is how
|
||||
/// the AEC ends up unloading over a live child.
|
||||
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send;
|
||||
}
|
||||
|
||||
impl ChildProcess for tokio::process::Child {
|
||||
/// **SIGINT, not SIGTERM.** pixelpass installs only a `tokio::signal::ctrl_c()`
|
||||
/// handler (`pixelpass/src/common/signal.rs`), so SIGTERM would be the default
|
||||
/// disposition — instant death, no cleanup — which is indistinguishable from
|
||||
/// SIGKILL for our purposes.
|
||||
///
|
||||
/// Signalling by pid is safe against pid reuse here because we have not
|
||||
/// reaped this child: an exited-but-unreaped child is a zombie whose pid the
|
||||
/// kernel reserves until we `wait` it, so the pid cannot name a stranger.
|
||||
#[cfg(unix)]
|
||||
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||
let Some(pid) = self.id() else {
|
||||
// Already reaped — nothing to signal.
|
||||
return Ok(());
|
||||
};
|
||||
// SAFETY: `kill` is async-signal-safe and takes no pointers; the pid is
|
||||
// this process's own unreaped child (see above).
|
||||
if unsafe { libc::kill(pid as libc::pid_t, libc::SIGINT) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::last_os_error())
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows has no SIGINT to send to another process without attaching to its
|
||||
/// console, so the graceful request degrades to the hard kill and the
|
||||
/// bounded wait below simply returns early.
|
||||
#[cfg(not(unix))]
|
||||
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||
tokio::process::Child::start_kill(self)
|
||||
}
|
||||
|
||||
fn start_kill(&mut self) -> std::io::Result<()> {
|
||||
tokio::process::Child::start_kill(self)
|
||||
}
|
||||
|
||||
fn try_reap(&mut self) -> bool {
|
||||
matches!(self.try_wait(), Ok(Some(_)))
|
||||
}
|
||||
|
||||
async fn wait_reaped(&mut self) -> std::io::Result<()> {
|
||||
self.wait().await.map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// Did the explicit stop path actually confirm the child was reaped?
|
||||
///
|
||||
/// The distinction is not cosmetic: on [`Unconfirmed`](Self::Unconfirmed) we
|
||||
/// deliberately stopped waiting (see [`ReapOnDrop::shutdown`]), so pixelpass may
|
||||
/// still be alive and fanning out. A user-initiated Stop Share must not report
|
||||
/// that as a clean stop.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[must_use = "an unconfirmed stop means the child may still be sharing"]
|
||||
pub(super) enum StopOutcome {
|
||||
/// The child is gone and has been reaped.
|
||||
Reaped,
|
||||
/// We could not confirm the reap within the bound and gave up waiting.
|
||||
Unconfirmed,
|
||||
}
|
||||
|
||||
/// A child that is killed **and reaped** when it is dropped.
|
||||
///
|
||||
/// The explicit path calls [`shutdown`](Self::shutdown), which releases the
|
||||
/// child only once its reap is *confirmed*, so the `Drop` below is a no-op
|
||||
/// afterwards but stays armed through every await until then. `Drop` is the
|
||||
/// last-ditch protection for the panic/unwind/cancellation paths.
|
||||
pub(super) struct ReapOnDrop<C: ChildProcess> {
|
||||
/// `None` once the child has been reaped through the explicit path.
|
||||
child: Option<C>,
|
||||
/// Names the child in the reap-timeout log line.
|
||||
label: &'static str,
|
||||
}
|
||||
|
||||
impl<C: ChildProcess> ReapOnDrop<C> {
|
||||
pub(super) fn new(child: C, label: &'static str) -> Self {
|
||||
Self {
|
||||
child: Some(child),
|
||||
label,
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll once, without killing. `true` if the child has exited on its own —
|
||||
/// used to sweep player windows the user has already closed.
|
||||
pub(super) fn has_exited(&mut self) -> bool {
|
||||
match &mut self.child {
|
||||
Some(child) => {
|
||||
if child.try_reap() {
|
||||
self.child = None;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
// Already reaped through the explicit path.
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the child gracefully if it will go, and by force if it will not.
|
||||
/// Waits for it to be reaped either way. Idempotent.
|
||||
///
|
||||
/// Ask, then insist (design v3.4 §7.4): a pixelpass host that gets SIGINT
|
||||
/// unloads its capture sink on the way out, whereas SIGKILL skips that and
|
||||
/// leaks a null-sink module on every Stop Share.
|
||||
///
|
||||
/// The wait is the point: returning after signalling would let the caller
|
||||
/// proceed to unload the AEC while the child is still running.
|
||||
///
|
||||
/// ⚠️ The child stays owned by `self` across every `.await`, and is released
|
||||
/// **only after a confirmed reap**. Taking it out first would disarm the
|
||||
/// `Drop` fallback for exactly as long as the wait lasts: cancel or unwind
|
||||
/// this future at that moment and the raw child would drop with nothing but
|
||||
/// `kill_on_drop` (which signals without reaping) while `Drop` below found
|
||||
/// `None` and did nothing — the precise hole this type exists to close.
|
||||
pub(super) async fn shutdown(&mut self) -> StopOutcome {
|
||||
let Some(child) = self.child.as_mut() else {
|
||||
return StopOutcome::Reaped;
|
||||
};
|
||||
|
||||
// Three different things can go wrong here and they want three
|
||||
// different operator diagnoses: the signal never left (a runtime or
|
||||
// permission fault), the child ignored it (a wedged pixelpass), or the
|
||||
// wait itself broke (we no longer know anything about the child).
|
||||
// Collapsing them into one line was P3-1 of the round-16 review.
|
||||
if let Err(e) = child.request_stop() {
|
||||
crate::log_msg(&format!(
|
||||
"teardown: could not ask {} to stop: {e}",
|
||||
self.label
|
||||
));
|
||||
}
|
||||
match tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
|
||||
Ok(Ok(())) => {
|
||||
self.child = None;
|
||||
return StopOutcome::Reaped;
|
||||
}
|
||||
Ok(Err(e)) => crate::log_msg(&format!(
|
||||
"teardown: waiting for {} failed ({e}); killing it",
|
||||
self.label
|
||||
)),
|
||||
Err(_) => crate::log_msg(&format!(
|
||||
"teardown: {} ignored the graceful stop within {STOP_GRACE:?}; killing it",
|
||||
self.label
|
||||
)),
|
||||
}
|
||||
|
||||
if let Err(e) = child.start_kill() {
|
||||
crate::log_msg(&format!(
|
||||
"teardown: {} could not be killed: {e}",
|
||||
self.label
|
||||
));
|
||||
}
|
||||
|
||||
// The second wait is bounded too. An unbounded one lets a process stuck
|
||||
// in uninterruptible sleep wedge the core command loop forever, and a
|
||||
// permanently frozen app is a worse failure than the risk below.
|
||||
if let Ok(Ok(())) = tokio::time::timeout(STOP_GRACE, child.wait_reaped()).await {
|
||||
self.child = None;
|
||||
return StopOutcome::Reaped;
|
||||
}
|
||||
|
||||
// Explicit policy for the one case where the two guarantees conflict:
|
||||
// we could not confirm the reap and will NOT block indefinitely, so we
|
||||
// give up availability-first and leave the child owned — `Drop`'s
|
||||
// bounded retry stays armed, and the AEC may unload over a child that
|
||||
// is still somehow alive. That residual risk is logged, not silent —
|
||||
// and, for a user-initiated stop, reported to the caller rather than
|
||||
// dressed up as success.
|
||||
crate::log_msg(&format!(
|
||||
"teardown: {} could not be confirmed dead; the echo-cancel module \
|
||||
may unload while it lives",
|
||||
self.label
|
||||
));
|
||||
StopOutcome::Unconfirmed
|
||||
}
|
||||
|
||||
/// Is the `Drop` fallback still armed? Test-only: the arming rule is the
|
||||
/// whole point of holding the child across the waits.
|
||||
#[cfg(test)]
|
||||
fn is_armed(&self) -> bool {
|
||||
self.child.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: ChildProcess> Drop for ReapOnDrop<C> {
|
||||
fn drop(&mut self) {
|
||||
let Some(child) = self.child.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let _ = child.start_kill();
|
||||
// `Drop` cannot await, so poll on a bounded budget. See `REAP_BUDGET`.
|
||||
let deadline = Instant::now() + REAP_BUDGET;
|
||||
loop {
|
||||
if child.try_reap() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
crate::log_msg(&format!(
|
||||
"teardown: {} did not exit within the reap budget; \
|
||||
continuing (the echo-cancel module may unload while it lives)",
|
||||
self.label
|
||||
));
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(REAP_POLL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything in an `ActiveSession` whose **destruction order** is load-bearing.
|
||||
///
|
||||
/// ⚠️ Field order below **is** the invariant. `echo_cancel` is declared last so
|
||||
/// it is dropped last, after every screen-share child has been killed and
|
||||
/// reaped. Do not reorder these fields.
|
||||
pub(super) struct ScreenshareTeardown<C: ChildProcess, G> {
|
||||
/// Our pixelpass screen-share host child while sharing.
|
||||
host: Option<ReapOnDrop<C>>,
|
||||
/// pixelpass viewer children we spawned to watch peers' shares, each paired
|
||||
/// with the share ticket it is viewing so a re-watch of the same share can
|
||||
/// replace (not stack) its player.
|
||||
viewers: Vec<(String, ReapOnDrop<C>)>,
|
||||
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
|
||||
///
|
||||
/// ⚠️ **LAST FIELD ON PURPOSE** — see the module docs and the struct note.
|
||||
///
|
||||
/// Never read, and that is the design: the guard is held only so that its
|
||||
/// `Drop` runs, and only so that it runs *here*, last. `dead_code` is right
|
||||
/// that nothing reads it and wrong that it does nothing.
|
||||
#[allow(dead_code)]
|
||||
echo_cancel: Option<G>,
|
||||
}
|
||||
|
||||
impl<C: ChildProcess, G> ScreenshareTeardown<C, G> {
|
||||
pub(super) fn new(echo_cancel: Option<G>) -> Self {
|
||||
Self {
|
||||
host: None,
|
||||
viewers: Vec::new(),
|
||||
echo_cancel,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_sharing(&self) -> bool {
|
||||
self.host.is_some()
|
||||
}
|
||||
|
||||
pub(super) fn set_host(&mut self, child: C) {
|
||||
self.host = Some(ReapOnDrop::new(child, "screen-share host"));
|
||||
}
|
||||
|
||||
/// Stop sharing: kill the host and wait for it to be reaped. `None` if we
|
||||
/// were not sharing; otherwise whether the reap was actually confirmed —
|
||||
/// the caller owns telling the user, since an unconfirmed stop may leave
|
||||
/// pixelpass fanning out after the UI says sharing ended.
|
||||
pub(super) async fn stop_host(&mut self) -> Option<StopOutcome> {
|
||||
let mut host = self.host.take()?;
|
||||
Some(host.shutdown().await)
|
||||
}
|
||||
|
||||
/// Drop viewers whose player window has already closed, so the list only
|
||||
/// tracks live players.
|
||||
pub(super) fn sweep_exited_viewers(&mut self) {
|
||||
self.viewers.retain_mut(|(_, child)| !child.has_exited());
|
||||
}
|
||||
|
||||
/// Kill and reap the viewer already showing `ticket`, if any, so a re-watch
|
||||
/// replaces its player instead of stacking a second one.
|
||||
pub(super) async fn replace_viewer(&mut self, ticket: &str) -> bool {
|
||||
let Some(pos) = super::replace_viewer_index(&self.viewers, ticket) else {
|
||||
return false;
|
||||
};
|
||||
let (_, mut old) = self.viewers.remove(pos);
|
||||
// A viewer is our own player window, not the thing peers are watching:
|
||||
// an unconfirmed reap is already logged, and there is no user decision
|
||||
// riding on it the way there is for Stop Share.
|
||||
let _ = old.shutdown().await;
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn push_viewer(&mut self, ticket: String, child: C) {
|
||||
self.viewers
|
||||
.push((ticket, ReapOnDrop::new(child, "screen-share viewer")));
|
||||
}
|
||||
|
||||
/// Kill and reap **every** screen-share child, host first so viewers see the
|
||||
/// stream end promptly.
|
||||
///
|
||||
/// The caller must await this before the echo-cancel guard is dropped. On
|
||||
/// the drop/unwind path nothing calls it and field order carries the
|
||||
/// invariant instead.
|
||||
pub(super) async fn shutdown_children(&mut self) {
|
||||
// Outcomes are discarded on purpose: this runs on the session/teardown
|
||||
// path, where the policy is already availability-first and the residual
|
||||
// risk is logged by `shutdown` itself. There is no user still waiting
|
||||
// on an answer here, unlike `stop_host`.
|
||||
if let Some(host) = &mut self.host {
|
||||
let _ = host.shutdown().await;
|
||||
}
|
||||
self.host = None;
|
||||
for (_, viewer) in self.viewers.iter_mut() {
|
||||
let _ = viewer.shutdown().await;
|
||||
}
|
||||
self.viewers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ChildProcess, ReapOnDrop, STOP_GRACE, ScreenshareTeardown, StopOutcome};
|
||||
use std::future::Future;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
type Log = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
fn log() -> Log {
|
||||
Arc::new(Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
fn entries(log: &Log) -> Vec<String> {
|
||||
log.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn position(log: &Log, entry: &str) -> Option<usize> {
|
||||
entries(log).iter().position(|e| e == entry)
|
||||
}
|
||||
|
||||
/// Records the events the ordering rules turn on. Death is gated on an
|
||||
/// actual signal, so the double cannot report a reap that nothing caused.
|
||||
struct FakeChild {
|
||||
log: Log,
|
||||
label: &'static str,
|
||||
interrupted: bool,
|
||||
killed: bool,
|
||||
reaped: bool,
|
||||
/// A well-behaved child exits on SIGINT. A wedged one ignores it and
|
||||
/// dies only to SIGKILL.
|
||||
honours_interrupt: bool,
|
||||
/// When true the child is already dead before anyone signals it — the
|
||||
/// closed-player-window case that `sweep_exited_viewers` looks for.
|
||||
exited_on_its_own: bool,
|
||||
/// Death is not instantaneous: `try_reap` reports the child alive this
|
||||
/// many more times before it goes.
|
||||
polls_before_death: u32,
|
||||
/// `wait` reports an error instead of a reap.
|
||||
wait_fails: bool,
|
||||
}
|
||||
|
||||
impl FakeChild {
|
||||
/// A well-behaved child: exits when asked.
|
||||
fn new(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
log: log.clone(),
|
||||
label,
|
||||
interrupted: false,
|
||||
killed: false,
|
||||
reaped: false,
|
||||
honours_interrupt: true,
|
||||
exited_on_its_own: false,
|
||||
polls_before_death: 0,
|
||||
wait_fails: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A child that ignores the graceful stop entirely.
|
||||
fn wedged(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
honours_interrupt: false,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// A child that does not die the instant it is signalled: `try_reap`
|
||||
/// reports it alive for `polls` calls first. Without this the `Drop`
|
||||
/// polling loop could be replaced by a single `try_reap` and no test
|
||||
/// would notice.
|
||||
fn reaps_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
|
||||
Self {
|
||||
polls_before_death: polls,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// A child that ignores SIGINT *and* does not die the instant it is
|
||||
/// killed — the only shape that lets a test reach the post-SIGKILL
|
||||
/// wait and still be reaped by the `Drop` poll loop afterwards.
|
||||
fn wedged_then_dies_after_polls(log: &Log, label: &'static str, polls: u32) -> Self {
|
||||
Self {
|
||||
honours_interrupt: false,
|
||||
polls_before_death: polls,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// A child whose `wait` fails. A failed wait is not a confirmed reap,
|
||||
/// so it must not be reported as one.
|
||||
fn wait_fails(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
wait_fails: true,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
fn already_exited(log: &Log, label: &'static str) -> Self {
|
||||
Self {
|
||||
exited_on_its_own: true,
|
||||
..Self::new(log, label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Has anything actually made this child exit yet? A signalled child
|
||||
/// still has to burn through `polls_before_death` first.
|
||||
fn is_dead(&self) -> bool {
|
||||
let signalled = self.killed
|
||||
|| self.exited_on_its_own
|
||||
|| (self.interrupted && self.honours_interrupt);
|
||||
signalled && self.polls_before_death == 0
|
||||
}
|
||||
|
||||
/// One observation of a dying-but-not-yet-dead child.
|
||||
fn tick(&mut self) {
|
||||
self.polls_before_death = self.polls_before_death.saturating_sub(1);
|
||||
}
|
||||
|
||||
fn record(&self, event: &str) {
|
||||
self.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(format!("{}:{event}", self.label));
|
||||
}
|
||||
|
||||
fn mark_reaped(&mut self) {
|
||||
if !self.reaped {
|
||||
self.reaped = true;
|
||||
self.record("reap");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ChildProcess for FakeChild {
|
||||
fn request_stop(&mut self) -> std::io::Result<()> {
|
||||
if !self.interrupted {
|
||||
self.interrupted = true;
|
||||
self.record("sigint");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_kill(&mut self) -> std::io::Result<()> {
|
||||
if !self.killed {
|
||||
self.killed = true;
|
||||
self.record("kill");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn try_reap(&mut self) -> bool {
|
||||
if self.is_dead() {
|
||||
self.mark_reaped();
|
||||
return true;
|
||||
}
|
||||
self.tick();
|
||||
false
|
||||
}
|
||||
|
||||
/// Pending until something actually kills the child, so a wedged child
|
||||
/// really does make the caller wait out `STOP_GRACE`. No waker is
|
||||
/// registered: under `start_paused` the runtime auto-advances its clock
|
||||
/// when every task is idle, which is exactly what fires the timeout.
|
||||
fn wait_reaped(&mut self) -> impl Future<Output = std::io::Result<()>> + Send {
|
||||
std::future::poll_fn(move |_cx| {
|
||||
if self.wait_fails {
|
||||
return std::task::Poll::Ready(Err(std::io::Error::other("wait failed")));
|
||||
}
|
||||
if self.is_dead() {
|
||||
self.mark_reaped();
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
} else {
|
||||
std::task::Poll::Pending
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in for `EchoCancelGuard`, whose real `Drop` runs `pactl unload`.
|
||||
struct FakeAec(Log);
|
||||
|
||||
impl Drop for FakeAec {
|
||||
fn drop(&mut self) {
|
||||
self.0.lock().unwrap().push("aec:unload".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn teardown(log: &Log) -> ScreenshareTeardown<FakeChild, FakeAec> {
|
||||
ScreenshareTeardown::new(Some(FakeAec(log.clone())))
|
||||
}
|
||||
|
||||
// --- The drop/unwind path: field order + ReapOnDrop carry the invariant ---
|
||||
|
||||
/// Mutation gate #5 (remove the reap loop from `ReapOnDrop::drop`).
|
||||
///
|
||||
/// Asserts only that dropping a guard reaps, and reaps *after* killing —
|
||||
/// deliberately says nothing about the AEC, so reversing the struct's field
|
||||
/// order leaves this test green and only the ordering test below fails.
|
||||
#[test]
|
||||
fn dropping_a_guard_kills_and_then_reaps_the_child() {
|
||||
let log = log();
|
||||
drop(ReapOnDrop::new(FakeChild::new(&log, "host"), "host"));
|
||||
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
|
||||
}
|
||||
|
||||
/// Mutation gate #4 (reverse the field order of `ScreenshareTeardown`).
|
||||
///
|
||||
/// Asserts only kill-before-unload, so removing the reap loop leaves this
|
||||
/// test green and only the reap test above fails.
|
||||
#[test]
|
||||
fn the_aec_unloads_after_the_children_on_the_drop_path() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
|
||||
drop(t);
|
||||
|
||||
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
|
||||
let host_kill = position(&log, "host:kill").expect("the host must be killed");
|
||||
let viewer_kill = position(&log, "viewer:kill").expect("the viewer must be killed");
|
||||
assert!(
|
||||
host_kill < unload,
|
||||
"the AEC unloaded while the host was alive: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
assert!(
|
||||
viewer_kill < unload,
|
||||
"the AEC unloaded while a viewer was alive: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole invariant in one sequence, as documentation.
|
||||
#[test]
|
||||
fn the_drop_path_reaps_every_child_before_unloading_the_aec() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
drop(t);
|
||||
assert_eq!(entries(&log), vec!["host:kill", "host:reap", "aec:unload"]);
|
||||
}
|
||||
|
||||
// --- The explicit path: ask, then insist ---
|
||||
|
||||
/// A healthy child must be *asked*, never killed. If Stop Share went
|
||||
/// straight to SIGKILL, pixelpass would skip its own cleanup and leak a
|
||||
/// null-sink module every time (design v3.4 §7.4).
|
||||
#[tokio::test]
|
||||
async fn a_healthy_child_is_asked_to_stop_and_never_killed() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
|
||||
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
|
||||
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
|
||||
assert!(
|
||||
!entries(&log).contains(&"host:kill".to_string()),
|
||||
"a child that honoured the graceful stop must not be killed: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
}
|
||||
|
||||
/// ...but a child that ignores the request must not be able to hold the
|
||||
/// session open forever: the grace is bounded and SIGKILL follows.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_wedged_child_is_killed_once_the_grace_expires() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::wedged(&log, "host"));
|
||||
|
||||
// The outer bound turns "the fallback was removed" into a failure
|
||||
// rather than a hung test. Under `start_paused` no real time passes.
|
||||
let start = tokio::time::Instant::now();
|
||||
tokio::time::timeout(Duration::from_secs(60), t.stop_host())
|
||||
.await
|
||||
.expect("a wedged child must not block teardown indefinitely");
|
||||
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
|
||||
assert!(
|
||||
start.elapsed() >= STOP_GRACE,
|
||||
"the child must actually be given the grace period, waited {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// The assertion above compares elapsed time against `STOP_GRACE` itself,
|
||||
/// so it stays vacuously true if the constant is set to zero — both sides
|
||||
/// move together. Pin the constant independently: the whole point of the
|
||||
/// graceful stop is that pixelpass gets a real interval in which to unload
|
||||
/// its capture sink, and zero is not one.
|
||||
#[test]
|
||||
fn the_grace_is_a_real_interval() {
|
||||
assert!(
|
||||
STOP_GRACE >= Duration::from_millis(500),
|
||||
"too short to let pixelpass tear its pipeline down: {STOP_GRACE:?}"
|
||||
);
|
||||
// ...and short enough that a wedged child cannot visibly stall the core
|
||||
// command loop, which awaits this inline.
|
||||
assert!(
|
||||
STOP_GRACE <= Duration::from_secs(5),
|
||||
"long enough to freeze the UI's command handling: {STOP_GRACE:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hole the whole type exists to close, and the one place the old
|
||||
/// implementation left open: if `shutdown` is cancelled while waiting, the
|
||||
/// child must still be owned, so dropping the guard still kills and reaps.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelling_shutdown_mid_wait_leaves_the_fallback_armed() {
|
||||
let log = log();
|
||||
let mut guard = ReapOnDrop::new(FakeChild::wedged(&log, "host"), "host");
|
||||
|
||||
// Cancel well inside the grace, while it is still waiting.
|
||||
assert!(
|
||||
tokio::time::timeout(STOP_GRACE / 4, guard.shutdown())
|
||||
.await
|
||||
.is_err(),
|
||||
"the wedged child should still have been waiting when we cancelled"
|
||||
);
|
||||
assert!(
|
||||
guard.is_armed(),
|
||||
"a cancelled shutdown must not disarm the drop fallback"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:kill", "host:reap"]);
|
||||
}
|
||||
|
||||
/// The test above only ever cancels during the *graceful* wait, so a
|
||||
/// mutation that disarmed the wrapper between the two waits would survive
|
||||
/// it (round-16 review, P3-3). This one cancels during the post-SIGKILL
|
||||
/// wait — the window where we have already given up on cooperation and the
|
||||
/// `Drop` fallback is the only thing left.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelling_shutdown_after_the_kill_leaves_the_fallback_armed() {
|
||||
let log = log();
|
||||
// Ignores SIGINT, so the grace expires and we reach the kill; then
|
||||
// survives three polls, so the second wait is still pending when we
|
||||
// cancel, and the drop loop still gets to reap it.
|
||||
let mut guard = ReapOnDrop::new(
|
||||
FakeChild::wedged_then_dies_after_polls(&log, "host", 3),
|
||||
"host",
|
||||
);
|
||||
|
||||
assert!(
|
||||
tokio::time::timeout(STOP_GRACE + STOP_GRACE / 4, guard.shutdown())
|
||||
.await
|
||||
.is_err(),
|
||||
"we should have been cancelled inside the post-kill wait"
|
||||
);
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:kill"],
|
||||
"the graceful stop must have expired and escalated before we cancelled"
|
||||
);
|
||||
assert!(
|
||||
guard.is_armed(),
|
||||
"cancelling after the kill must not disarm the drop fallback either"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
// The fake's `start_kill` is idempotent, so `Drop` re-signalling an
|
||||
// already-killed child adds no entry; the *reap* is what proves the
|
||||
// fallback ran to completion after we abandoned the wait.
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:kill", "host:reap"],
|
||||
"Drop must poll until the child is actually gone"
|
||||
);
|
||||
}
|
||||
|
||||
/// A failed wait is not a reap. Reporting it as one is how the AEC ends up
|
||||
/// unloading over a child that is still alive.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn a_failed_wait_is_not_treated_as_a_confirmed_reap() {
|
||||
let log = log();
|
||||
let mut guard = ReapOnDrop::new(FakeChild::wait_fails(&log, "host"), "host");
|
||||
|
||||
assert_eq!(
|
||||
guard.shutdown().await,
|
||||
StopOutcome::Unconfirmed,
|
||||
"a stop we could not confirm must not be reported as a clean one"
|
||||
);
|
||||
|
||||
assert!(
|
||||
!entries(&log).contains(&"host:reap".to_string()),
|
||||
"nothing confirmed the reap: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
assert!(
|
||||
entries(&log).contains(&"host:kill".to_string()),
|
||||
"a child that would not stop must still be escalated: {:?}",
|
||||
entries(&log)
|
||||
);
|
||||
assert!(
|
||||
guard.is_armed(),
|
||||
"an unconfirmed reap must leave the drop fallback armed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Death is not instantaneous, so the drop path has to keep polling. A
|
||||
/// single `try_reap` in place of the loop must not pass.
|
||||
#[test]
|
||||
fn the_drop_path_polls_until_the_child_is_actually_gone() {
|
||||
let log = log();
|
||||
drop(ReapOnDrop::new(
|
||||
FakeChild::reaps_after_polls(&log, "host", 3),
|
||||
"host",
|
||||
));
|
||||
assert_eq!(entries(&log), vec!["host:kill", "host:reap"]);
|
||||
}
|
||||
|
||||
/// Mutation gate #3 (remove the wait after the host kill).
|
||||
#[tokio::test]
|
||||
async fn explicit_shutdown_reaps_the_host_before_the_aec_can_unload() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "viewer"));
|
||||
|
||||
t.shutdown_children().await;
|
||||
|
||||
// Reaped by the explicit path — before the guard is anywhere near dropped.
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:reap", "viewer:sigint", "viewer:reap"],
|
||||
"children must be stopped and reaped by the explicit path"
|
||||
);
|
||||
|
||||
drop(t);
|
||||
let unload = position(&log, "aec:unload").expect("the AEC guard must be dropped");
|
||||
let host_reap = position(&log, "host:reap").expect("the host must be reaped");
|
||||
assert!(host_reap < unload);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_shutdown_is_idempotent_with_the_drop_path() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
t.shutdown_children().await;
|
||||
drop(t);
|
||||
// Exactly one stop and one reap: the drop path must not re-signal a
|
||||
// child the explicit path already took.
|
||||
assert_eq!(
|
||||
entries(&log),
|
||||
vec!["host:sigint", "host:reap", "aec:unload"]
|
||||
);
|
||||
}
|
||||
|
||||
// --- Host/viewer bookkeeping ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_host_reports_whether_it_was_sharing() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
assert!(!t.is_sharing());
|
||||
assert_eq!(t.stop_host().await, None, "not sharing: nothing to stop");
|
||||
|
||||
t.set_host(FakeChild::new(&log, "host"));
|
||||
assert!(t.is_sharing());
|
||||
assert_eq!(t.stop_host().await, Some(StopOutcome::Reaped));
|
||||
assert!(!t.is_sharing());
|
||||
assert_eq!(entries(&log), vec!["host:sigint", "host:reap"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweeping_drops_only_the_players_that_already_closed() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.push_viewer(
|
||||
"closed".to_string(),
|
||||
FakeChild::already_exited(&log, "closed"),
|
||||
);
|
||||
t.push_viewer("live".to_string(), FakeChild::new(&log, "live"));
|
||||
|
||||
t.sweep_exited_viewers();
|
||||
|
||||
// The live player survives the sweep; only the closed one is dropped,
|
||||
// and dropping it must not kill anything (it was already gone).
|
||||
assert_eq!(t.viewers.len(), 1);
|
||||
assert_eq!(t.viewers[0].0, "live");
|
||||
assert_eq!(entries(&log), vec!["closed:reap"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn re_watching_a_share_replaces_that_player_only() {
|
||||
let log = log();
|
||||
let mut t = teardown(&log);
|
||||
t.push_viewer("ticket-A".to_string(), FakeChild::new(&log, "a"));
|
||||
t.push_viewer("ticket-B".to_string(), FakeChild::new(&log, "b"));
|
||||
|
||||
assert!(t.replace_viewer("ticket-A").await);
|
||||
assert_eq!(entries(&log), vec!["a:sigint", "a:reap"]);
|
||||
assert_eq!(t.viewers.len(), 1);
|
||||
assert_eq!(t.viewers[0].0, "ticket-B");
|
||||
|
||||
// A share we are not watching has nothing to replace.
|
||||
assert!(!t.replace_viewer("ticket-C").await);
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,19 @@
|
||||
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
|
||||
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend
|
||||
//! (typically in `Normal`) must be able to look up a friend who moved networks. A
|
||||
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved
|
||||
//! address is stale and the dial falls through to discovery.
|
||||
//! resolve is a DNS query to n0 that publishes nothing, but still exposes query
|
||||
//! timing/source metadata to n0; it only fires when a saved address is stale and
|
||||
//! the dial falls through to discovery.
|
||||
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
|
||||
//! publishes their address to n0 DNS; everyone else just looks it up.
|
||||
//! - **Stopping publishing removes the local publisher service**; iroh does not
|
||||
//! expose an explicit unpublish call here, so already-published pkarr records can
|
||||
//! linger until their default ~30s TTL expires.
|
||||
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
|
||||
//! ever touches n0 there, regardless of the Discoverable toggle.
|
||||
|
||||
use crate::config::NetworkMode;
|
||||
use crate::presence::PresenceMode;
|
||||
use std::time::Duration;
|
||||
|
||||
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
|
||||
@@ -46,12 +51,43 @@ pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan
|
||||
match network_mode {
|
||||
// The explicit serverless posture: no n0 contact at all, even to resolve.
|
||||
// A Discoverable toggle here is intentionally inert.
|
||||
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false },
|
||||
NetworkMode::DirectOnly => LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false,
|
||||
},
|
||||
// Relay-capable: always resolve (so a stationary friend can find a mover);
|
||||
// publish only when the user opted into Discoverable.
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => {
|
||||
LookupPlan { resolver: true, publisher: want_publish }
|
||||
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
|
||||
resolver: true,
|
||||
publisher: want_publish,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which presence mode may be committed after attempting to apply discovery
|
||||
/// services for `requested`.
|
||||
///
|
||||
/// On failure, keep the previous mode: it is the only locally truthful state because
|
||||
/// the endpoint's discovery services may still reflect the old posture. Same-mode
|
||||
/// requests are no-ops from a presence-truth perspective and do not surface an error.
|
||||
pub fn resolve_presence_transition(
|
||||
previous: PresenceMode,
|
||||
requested: PresenceMode,
|
||||
apply_ok: bool,
|
||||
) -> (PresenceMode, Option<String>) {
|
||||
if previous == requested {
|
||||
return (previous, None);
|
||||
}
|
||||
|
||||
if apply_ok {
|
||||
(requested, None)
|
||||
} else {
|
||||
(
|
||||
previous,
|
||||
Some(format!(
|
||||
"Couldn't update discovery mode; keeping {previous}."
|
||||
)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,12 +100,18 @@ mod tests {
|
||||
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
|
||||
assert_eq!(
|
||||
lookup_plan(mode, false),
|
||||
LookupPlan { resolver: true, publisher: false },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: false
|
||||
},
|
||||
"{mode:?}: resolve always on, no publish when not Discoverable"
|
||||
);
|
||||
assert_eq!(
|
||||
lookup_plan(mode, true),
|
||||
LookupPlan { resolver: true, publisher: true },
|
||||
LookupPlan {
|
||||
resolver: true,
|
||||
publisher: true
|
||||
},
|
||||
"{mode:?}: Discoverable adds publish on top of resolve"
|
||||
);
|
||||
}
|
||||
@@ -79,12 +121,18 @@ mod tests {
|
||||
fn direct_only_never_touches_n0_even_when_discoverable() {
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, false),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
// The serverless posture overrides the Discoverable request entirely.
|
||||
assert_eq!(
|
||||
lookup_plan(NetworkMode::DirectOnly, true),
|
||||
LookupPlan { resolver: false, publisher: false }
|
||||
LookupPlan {
|
||||
resolver: false,
|
||||
publisher: false
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,4 +140,42 @@ mod tests {
|
||||
fn timebox_is_thirty_minutes() {
|
||||
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_commits_requested_mode_after_successful_apply() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_previous_mode_when_apply_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Normal);
|
||||
assert!(err.unwrap().contains("keeping Normal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_keeps_discoverable_when_off_transition_fails() {
|
||||
let (mode, err) =
|
||||
resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false);
|
||||
|
||||
assert_eq!(mode, PresenceMode::Discoverable);
|
||||
assert!(err.unwrap().contains("keeping Discoverable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_transition_same_mode_is_noop_without_error() {
|
||||
assert_eq!(
|
||||
resolve_presence_transition(
|
||||
PresenceMode::Discoverable,
|
||||
PresenceMode::Discoverable,
|
||||
false
|
||||
),
|
||||
(PresenceMode::Discoverable, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
//! 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;
|
||||
|
||||
/// Max total decoded pixels, applied on top of the per-side [`MAX_IMAGE_PX`]
|
||||
/// limit. The per-side cap alone still admits a 4096×4096 ≈ 16.8 MP bitmap
|
||||
/// (~64 MiB transient RGBA); this bounds the worst-case decode allocation while
|
||||
/// still clearing common 12 MP phone photos (4032×3024 ≈ 12.2 MP).
|
||||
pub const MAX_IMAGE_TOTAL_PIXELS: u64 = 14_000_000;
|
||||
|
||||
/// Max pixels per side of the downscaled inline preview handed to the renderer.
|
||||
/// Original bytes are kept only for Save; the chat column never needs more than
|
||||
/// this (it displays at ~260 px, and the lightbox at window size).
|
||||
pub const IMAGE_PREVIEW_MAX_SIDE: u32 = 1600;
|
||||
|
||||
/// Largest declared size an image attachment may auto-fetch at. Anything larger
|
||||
/// (or any skipped/evicted image) renders a "Load image" button instead; a
|
||||
/// manual click may use the full [`MAX_ATTACHMENT_BYTES`] cap.
|
||||
pub const MAX_AUTO_IMAGE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
/// 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 and the same bidi/zero-width spoofing format chars
|
||||
// stripped from display names (a U+202E override can visually reverse an
|
||||
// extension, e.g. "photo\u{202E}gnp.exe" renders as "photoexe.png").
|
||||
// Ordinary non-ASCII filenames pass through untouched.
|
||||
let cleaned: String = base
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !crate::sanitize::is_spoofing_format_char(*c))
|
||||
.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 img = decode_image_bounded(bytes)?;
|
||||
Some((img.width(), img.height()))
|
||||
}
|
||||
|
||||
/// Shared bounded decode: header-check the dimensions (per-side AND total-pixel
|
||||
/// limits) BEFORE decoding, then decode under `image::Limits` as defense in
|
||||
/// depth. The precheck reads only the container header, so an over-limit bomb is
|
||||
/// rejected without paying its decode cost.
|
||||
fn decode_image_bounded(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
let (w, h) = reader.into_dimensions().ok()?;
|
||||
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
||||
return None;
|
||||
}
|
||||
if u64::from(w) * u64::from(h) > MAX_IMAGE_TOTAL_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_IMAGE_PX);
|
||||
limits.max_image_height = Some(MAX_IMAGE_PX);
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||
.with_guessed_format()
|
||||
.ok()?;
|
||||
reader.limits(limits);
|
||||
let img = reader.decode().ok()?;
|
||||
// Decoded size must match the header the precheck approved.
|
||||
if img.width() != w || img.height() != h {
|
||||
return None;
|
||||
}
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// A decoded, display-ready inline preview: RGBA pixels downscaled so neither
|
||||
/// side exceeds [`IMAGE_PREVIEW_MAX_SIDE`]. `rgba.len() == width * height * 4`,
|
||||
/// which is also the preview's decoded-budget weight in the attachment cache.
|
||||
pub struct ImagePreview {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Decode image bytes under the same limits as [`validate_image_bytes`] and
|
||||
/// build the downscaled inline preview. The full-resolution bitmap exists only
|
||||
/// transiently here; the renderer is never handed more than
|
||||
/// [`IMAGE_PREVIEW_MAX_SIDE`]² pixels. Returns `None` for anything that fails
|
||||
/// validation (caller falls back to a chip / failure row).
|
||||
pub fn decode_preview(bytes: &[u8]) -> Option<ImagePreview> {
|
||||
let img = decode_image_bounded(bytes)?;
|
||||
let img = if img.width() > IMAGE_PREVIEW_MAX_SIDE || img.height() > IMAGE_PREVIEW_MAX_SIDE {
|
||||
// `thumbnail` preserves aspect ratio within the bounding box.
|
||||
img.thumbnail(IMAGE_PREVIEW_MAX_SIDE, IMAGE_PREVIEW_MAX_SIDE)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let rgba = img.into_rgba8();
|
||||
let (width, height) = (rgba.width(), rgba.height());
|
||||
Some(ImagePreview {
|
||||
width,
|
||||
height,
|
||||
rgba: rgba.into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Estimated decoded RGBA cost of a preview, the weight counted against the
|
||||
/// attachment cache's decoded-byte budget (`width * height * 4`).
|
||||
pub fn preview_rgba_cost(width: u32, height: u32) -> usize {
|
||||
(width as usize)
|
||||
.saturating_mul(height as usize)
|
||||
.saturating_mul(4)
|
||||
}
|
||||
|
||||
/// Read at most [`MAX_ATTACHMENT_BYTES`] bytes from `r`. Returns `Ok(None)` if
|
||||
/// the source holds even one byte more (detected by reading cap + 1), so a huge
|
||||
/// or unbounded source is never fully buffered. Pure over `Read` for tests; the
|
||||
/// picker wraps it via [`read_file_capped`].
|
||||
pub fn read_capped<R: std::io::Read>(r: R) -> std::io::Result<Option<Vec<u8>>> {
|
||||
use std::io::Read as _;
|
||||
let mut buf = Vec::new();
|
||||
let mut limited = r.take(MAX_ATTACHMENT_BYTES + 1);
|
||||
limited.read_to_end(&mut buf)?;
|
||||
if buf.len() as u64 > MAX_ATTACHMENT_BYTES {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
/// Read a picked file, bounded by [`MAX_ATTACHMENT_BYTES`]. Checks metadata
|
||||
/// first to reject an obviously-oversized file without opening it, but keeps the
|
||||
/// bounded read regardless — metadata can race (the file can grow after the
|
||||
/// check) or be unavailable through a portal. `Ok(None)` = over the cap.
|
||||
pub fn read_file_capped(path: &std::path::Path) -> std::io::Result<Option<Vec<u8>>> {
|
||||
if let Ok(meta) = std::fs::metadata(path)
|
||||
&& meta.len() > MAX_ATTACHMENT_BYTES
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
read_capped(std::fs::File::open(path)?)
|
||||
}
|
||||
|
||||
/// Cap on how many blobs the session serve store retains at once (sent chat
|
||||
/// attachments plus the current/next broadcast music tracks).
|
||||
pub const SERVED_FILES_MAX_ENTRIES: usize = 16;
|
||||
|
||||
/// Byte budget for the serve store. Without it, a sender's own session could
|
||||
/// grow unbounded at up to [`MAX_ATTACHMENT_BYTES`] per send (Phase 3C).
|
||||
pub const SERVED_FILES_MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||
|
||||
/// Count- and byte-budgeted FIFO store of blobs we serve to room members over
|
||||
/// the file plane. Evicting an id makes a later request for it read as an empty
|
||||
/// body — the existing "sender no longer has the file" response — never stale
|
||||
/// or aliased bytes. Pure (no locks/IO) so budgets are unit-testable; the
|
||||
/// transport wraps it in its own mutex.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ServeStore {
|
||||
entries: std::collections::HashMap<AttachmentId, std::sync::Arc<Vec<u8>>>,
|
||||
/// Present ids in insertion order; the front is the eviction candidate.
|
||||
order: std::collections::VecDeque<AttachmentId>,
|
||||
total_bytes: usize,
|
||||
}
|
||||
|
||||
impl ServeStore {
|
||||
/// Insert or replace a blob, evicting oldest entries until the count and
|
||||
/// byte budgets fit. Replacement keeps the id's age and subtracts the old
|
||||
/// bytes before the new ones are counted. Returns `false` for a blob that
|
||||
/// alone exceeds the byte budget (not stored; an existing entry under the
|
||||
/// id is dropped rather than left stale).
|
||||
pub fn insert(&mut self, id: AttachmentId, bytes: std::sync::Arc<Vec<u8>>) -> bool {
|
||||
if let Some(old) = self.entries.get(&id) {
|
||||
self.total_bytes -= old.len();
|
||||
}
|
||||
if bytes.len() > SERVED_FILES_MAX_BYTES {
|
||||
if self.entries.remove(&id).is_some() {
|
||||
self.order.retain(|k| k != &id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
let replacing = self.entries.contains_key(&id);
|
||||
loop {
|
||||
let count_full = !replacing && self.entries.len() >= SERVED_FILES_MAX_ENTRIES;
|
||||
let bytes_full = self.total_bytes + bytes.len() > SERVED_FILES_MAX_BYTES;
|
||||
if !count_full && !bytes_full {
|
||||
break;
|
||||
}
|
||||
let Some(victim) = self.order.iter().find(|k| **k != id).copied() else {
|
||||
break;
|
||||
};
|
||||
self.remove(&victim);
|
||||
}
|
||||
if !replacing {
|
||||
self.order.push_back(id);
|
||||
}
|
||||
self.total_bytes += bytes.len();
|
||||
self.entries.insert(id, bytes);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &AttachmentId) -> Option<std::sync::Arc<Vec<u8>>> {
|
||||
self.entries.get(id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: &AttachmentId) {
|
||||
if let Some(old) = self.entries.remove(id) {
|
||||
self.total_bytes -= old.len();
|
||||
self.order.retain(|k| k != id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
self.order.clear();
|
||||
self.total_bytes = 0;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 sanitize_strips_bidi_and_zero_width_spoofing_chars() {
|
||||
// U+202E would visually reverse the tail, disguising the extension.
|
||||
assert_eq!(sanitize_filename("photo\u{202E}gnp.exe"), "photognp.exe");
|
||||
assert_eq!(sanitize_filename("a\u{200B}b\u{FEFF}.txt"), "ab.txt");
|
||||
// Ordinary Unicode filenames pass through.
|
||||
assert_eq!(sanitize_filename("família_fotos.png"), "família_fotos.png");
|
||||
assert_eq!(sanitize_filename("日本語.pdf"), "日本語.pdf");
|
||||
}
|
||||
|
||||
/// Encode a solid PNG of the given dimensions for limit tests.
|
||||
fn png_bytes(w: u32, h: u32) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_pixel(w, h, 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();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_image_rejects_excessive_total_pixels() {
|
||||
// Both sides within MAX_IMAGE_PX, but 4096 * 4096 > MAX_IMAGE_TOTAL_PIXELS.
|
||||
assert!(u64::from(MAX_IMAGE_PX) * u64::from(MAX_IMAGE_PX) > MAX_IMAGE_TOTAL_PIXELS);
|
||||
assert_eq!(validate_image_bytes(&png_bytes(4096, 4096)), None);
|
||||
// A 12 MP phone-photo shape passes both limits.
|
||||
assert_eq!(
|
||||
validate_image_bytes(&png_bytes(4032, 3024)),
|
||||
Some((4032, 3024))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_downscales_to_max_side_preserving_aspect() {
|
||||
// Wide: 3200x400 → 1600x200.
|
||||
let p = decode_preview(&png_bytes(3200, 400)).unwrap();
|
||||
assert_eq!((p.width, p.height), (1600, 200));
|
||||
assert_eq!(p.rgba.len(), preview_rgba_cost(1600, 200));
|
||||
// Tall: 400x3200 → 200x1600.
|
||||
let p = decode_preview(&png_bytes(400, 3200)).unwrap();
|
||||
assert_eq!((p.width, p.height), (200, 1600));
|
||||
// Square over the side cap: 2000x2000 → 1600x1600.
|
||||
let p = decode_preview(&png_bytes(2000, 2000)).unwrap();
|
||||
assert_eq!((p.width, p.height), (1600, 1600));
|
||||
// At/under the cap is untouched.
|
||||
let p = decode_preview(&png_bytes(1600, 900)).unwrap();
|
||||
assert_eq!((p.width, p.height), (1600, 900));
|
||||
let p = decode_preview(&png_bytes(4, 3)).unwrap();
|
||||
assert_eq!((p.width, p.height), (4, 3));
|
||||
assert_eq!(p.rgba.len(), preview_rgba_cost(4, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_rejects_what_validation_rejects() {
|
||||
assert!(decode_preview(b"not an image").is_none());
|
||||
assert!(decode_preview(&png_bytes(4096, 4096)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_capped_stops_at_cap_plus_one() {
|
||||
// Under the cap: full read.
|
||||
let small = vec![7u8; 1024];
|
||||
assert_eq!(
|
||||
read_capped(std::io::Cursor::new(&small))
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some(&small[..])
|
||||
);
|
||||
// Exactly at the cap: accepted. `repeat` is endless, `take` proves the
|
||||
// reader is bounded rather than draining the source.
|
||||
let at_cap = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES);
|
||||
let got = read_capped(at_cap).unwrap().unwrap();
|
||||
assert_eq!(got.len() as u64, MAX_ATTACHMENT_BYTES);
|
||||
// One byte over: rejected, and only cap + 1 bytes were ever buffered
|
||||
// (an unbounded source returns instead of allocating forever).
|
||||
let over = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES + 1);
|
||||
assert_eq!(read_capped(over).unwrap(), None);
|
||||
let endless = std::io::repeat(1);
|
||||
assert_eq!(read_capped(endless).unwrap(), None);
|
||||
}
|
||||
|
||||
#[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 serve_store_count_and_byte_eviction_fifo() {
|
||||
use std::sync::Arc;
|
||||
let mut s = ServeStore::default();
|
||||
let blob = |n: u8, len: usize| ([n; 32], Arc::new(vec![n; len]));
|
||||
// Count cap: entry 0 is evicted when the 17th arrives.
|
||||
for n in 0..=SERVED_FILES_MAX_ENTRIES as u8 {
|
||||
let (id, b) = blob(n, 8);
|
||||
assert!(s.insert(id, b));
|
||||
}
|
||||
assert_eq!(s.len(), SERVED_FILES_MAX_ENTRIES);
|
||||
assert!(s.get(&[0u8; 32]).is_none(), "oldest evicted by count");
|
||||
assert!(s.get(&[1u8; 32]).is_some());
|
||||
// Byte budget: two ~half-budget blobs evict everything older.
|
||||
let half = SERVED_FILES_MAX_BYTES / 2;
|
||||
let (a, ab) = blob(100, half);
|
||||
let (b, bb) = blob(101, half);
|
||||
assert!(s.insert(a, ab));
|
||||
assert!(s.insert(b, bb));
|
||||
assert!(s.get(&a).is_some());
|
||||
assert!(s.get(&b).is_some());
|
||||
assert!(s.get(&[1u8; 32]).is_none(), "evicted for byte budget");
|
||||
// A third half-budget blob evicts `a` (oldest), keeps `b`.
|
||||
let (c, cb) = blob(102, half);
|
||||
assert!(s.insert(c, cb));
|
||||
assert!(s.get(&a).is_none());
|
||||
assert!(s.get(&b).is_some());
|
||||
assert!(s.get(&c).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_store_replacement_accounting_and_remove_clear() {
|
||||
use std::sync::Arc;
|
||||
let mut s = ServeStore::default();
|
||||
let id = [9u8; 32];
|
||||
assert!(s.insert(id, Arc::new(vec![1; SERVED_FILES_MAX_BYTES - 10])));
|
||||
// Replacing the near-budget blob must subtract its old bytes first —
|
||||
// otherwise this same-id replacement would evict itself.
|
||||
assert!(s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES - 5])));
|
||||
assert_eq!(s.get(&id).unwrap()[0], 2);
|
||||
assert_eq!(s.len(), 1);
|
||||
s.remove(&id);
|
||||
assert!(s.get(&id).is_none());
|
||||
// Removed bytes were released: the budget admits a full-size blob again.
|
||||
assert!(s.insert(id, Arc::new(vec![3; SERVED_FILES_MAX_BYTES])));
|
||||
s.clear();
|
||||
assert_eq!(s.len(), 0);
|
||||
assert!(s.insert(id, Arc::new(vec![4; SERVED_FILES_MAX_BYTES])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_store_rejects_individually_overweight_blob() {
|
||||
use std::sync::Arc;
|
||||
let mut s = ServeStore::default();
|
||||
let id = [7u8; 32];
|
||||
assert!(s.insert(id, Arc::new(vec![1; 8])));
|
||||
assert!(!s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES + 1])));
|
||||
// The stale small blob is gone too — a fetch reads "no longer has it",
|
||||
// never old bytes under a replaced id.
|
||||
assert!(s.get(&id).is_none());
|
||||
assert_eq!(s.len(), 0);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Focused, app-local keyboard shortcuts.
|
||||
//!
|
||||
//! These helpers are intentionally pure: key serialization, formatting, lookup,
|
||||
//! and conflict detection live here, while iced event handling stays at the app
|
||||
//! edge. There are no OS-global shortcuts.
|
||||
|
||||
use iced::keyboard;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A serializable key identity. Modifiers are deliberately out of scope for this
|
||||
/// first pass; iced delivers the focused app key and we compare that exact key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum KeyBinding {
|
||||
Named(String),
|
||||
Character(String),
|
||||
}
|
||||
|
||||
impl KeyBinding {
|
||||
pub fn from_key(key: &keyboard::Key) -> Option<Self> {
|
||||
match key {
|
||||
keyboard::Key::Named(named) => Some(Self::Named(format!("{named:?}"))),
|
||||
keyboard::Key::Character(ch) => {
|
||||
let s = ch.to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Self::Character(s.to_lowercase()))
|
||||
}
|
||||
}
|
||||
keyboard::Key::Unidentified => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
KeyBinding::Named(name) => name.clone(),
|
||||
KeyBinding::Character(ch) => ch.to_uppercase(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a hand-editable binding string from config/docs/tests. Empty and
|
||||
/// `"unset"` are unbound.
|
||||
pub fn parse_binding(input: &str) -> Option<KeyBinding> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unset") {
|
||||
return None;
|
||||
}
|
||||
if trimmed.chars().count() == 1 {
|
||||
Some(KeyBinding::Character(trimmed.to_lowercase()))
|
||||
} else {
|
||||
Some(KeyBinding::Named(trimmed.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_binding(binding: Option<&KeyBinding>) -> String {
|
||||
binding
|
||||
.map(KeyBinding::label)
|
||||
.unwrap_or_else(|| "unset".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum HotkeyAction {
|
||||
ToggleMute,
|
||||
ToggleDeafen,
|
||||
OpenSettings,
|
||||
PushToTalk,
|
||||
LeaveRoom,
|
||||
}
|
||||
|
||||
impl HotkeyAction {
|
||||
pub const ALL: [HotkeyAction; 5] = [
|
||||
HotkeyAction::ToggleMute,
|
||||
HotkeyAction::ToggleDeafen,
|
||||
HotkeyAction::OpenSettings,
|
||||
HotkeyAction::PushToTalk,
|
||||
HotkeyAction::LeaveRoom,
|
||||
];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
HotkeyAction::ToggleMute => "Toggle mute",
|
||||
HotkeyAction::ToggleDeafen => "Toggle deafen",
|
||||
HotkeyAction::OpenSettings => "Open Settings",
|
||||
HotkeyAction::PushToTalk => "Push-to-talk",
|
||||
HotkeyAction::LeaveRoom => "Leave room",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tier(self) -> HotkeyTier {
|
||||
match self {
|
||||
HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => {
|
||||
HotkeyTier::AppWide
|
||||
}
|
||||
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HotkeyTier {
|
||||
AppWide,
|
||||
RoomOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HotkeyContext {
|
||||
pub in_call: bool,
|
||||
}
|
||||
|
||||
impl HotkeyContext {
|
||||
fn allows(self, action: HotkeyAction) -> bool {
|
||||
matches!(action.tier(), HotkeyTier::AppWide) || self.in_call
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted shortcut map. Defaults preserve the old Space push-to-talk binding
|
||||
/// and add a few function-key app shortcuts that do not collide with typing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct HotkeyMap {
|
||||
#[serde(default = "default_mute")]
|
||||
pub toggle_mute: Option<KeyBinding>,
|
||||
#[serde(default = "default_deafen")]
|
||||
pub toggle_deafen: Option<KeyBinding>,
|
||||
#[serde(default = "default_settings")]
|
||||
pub open_settings: Option<KeyBinding>,
|
||||
#[serde(default = "default_ptt")]
|
||||
pub push_to_talk: Option<KeyBinding>,
|
||||
#[serde(default)]
|
||||
pub leave_room: Option<KeyBinding>,
|
||||
}
|
||||
|
||||
impl Default for HotkeyMap {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
toggle_mute: default_mute(),
|
||||
toggle_deafen: default_deafen(),
|
||||
open_settings: default_settings(),
|
||||
push_to_talk: default_ptt(),
|
||||
leave_room: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Option<KeyBinding> {
|
||||
Some(KeyBinding::Named(name.to_string()))
|
||||
}
|
||||
|
||||
fn default_mute() -> Option<KeyBinding> {
|
||||
named("F9")
|
||||
}
|
||||
|
||||
fn default_deafen() -> Option<KeyBinding> {
|
||||
named("F10")
|
||||
}
|
||||
|
||||
fn default_settings() -> Option<KeyBinding> {
|
||||
named("F2")
|
||||
}
|
||||
|
||||
fn default_ptt() -> Option<KeyBinding> {
|
||||
named("Space")
|
||||
}
|
||||
|
||||
impl HotkeyMap {
|
||||
pub fn binding(&self, action: HotkeyAction) -> Option<&KeyBinding> {
|
||||
match action {
|
||||
HotkeyAction::ToggleMute => self.toggle_mute.as_ref(),
|
||||
HotkeyAction::ToggleDeafen => self.toggle_deafen.as_ref(),
|
||||
HotkeyAction::OpenSettings => self.open_settings.as_ref(),
|
||||
HotkeyAction::PushToTalk => self.push_to_talk.as_ref(),
|
||||
HotkeyAction::LeaveRoom => self.leave_room.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_binding(&mut self, action: HotkeyAction, binding: Option<KeyBinding>) {
|
||||
match action {
|
||||
HotkeyAction::ToggleMute => self.toggle_mute = binding,
|
||||
HotkeyAction::ToggleDeafen => self.toggle_deafen = binding,
|
||||
HotkeyAction::OpenSettings => self.open_settings = binding,
|
||||
HotkeyAction::PushToTalk => self.push_to_talk = binding,
|
||||
HotkeyAction::LeaveRoom => self.leave_room = binding,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lookup_key(&self, key: &keyboard::Key, context: HotkeyContext) -> Option<HotkeyAction> {
|
||||
let pressed = KeyBinding::from_key(key)?;
|
||||
HotkeyAction::ALL
|
||||
.into_iter()
|
||||
.find(|&action| context.allows(action) && self.binding(action) == Some(&pressed))
|
||||
}
|
||||
|
||||
pub fn lookup_binding(
|
||||
&self,
|
||||
binding: &KeyBinding,
|
||||
context: HotkeyContext,
|
||||
) -> Option<HotkeyAction> {
|
||||
HotkeyAction::ALL
|
||||
.into_iter()
|
||||
.find(|&action| context.allows(action) && self.binding(action) == Some(binding))
|
||||
}
|
||||
|
||||
pub fn conflicts(&self) -> Vec<HotkeyConflict> {
|
||||
let mut conflicts = Vec::new();
|
||||
let actions = HotkeyAction::ALL;
|
||||
for i in 0..actions.len() {
|
||||
for j in (i + 1)..actions.len() {
|
||||
let a = actions[i];
|
||||
let b = actions[j];
|
||||
if let (Some(ab), Some(bb)) = (self.binding(a), self.binding(b))
|
||||
&& ab == bb
|
||||
{
|
||||
conflicts.push(HotkeyConflict {
|
||||
binding: ab.clone(),
|
||||
first: a,
|
||||
second: b,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
conflicts
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HotkeyConflict {
|
||||
pub binding: KeyBinding,
|
||||
pub first: HotkeyAction,
|
||||
pub second: HotkeyAction,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unset_actions_format_as_unset() {
|
||||
assert_eq!(format_binding(None), "unset");
|
||||
assert_eq!(parse_binding("unset"), None);
|
||||
assert_eq!(parse_binding(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_binding_is_detected() {
|
||||
let mut map = HotkeyMap::default();
|
||||
map.set_binding(HotkeyAction::ToggleMute, parse_binding("M"));
|
||||
map.set_binding(HotkeyAction::ToggleDeafen, parse_binding("m"));
|
||||
let conflicts = map.conflicts();
|
||||
assert_eq!(conflicts.len(), 1);
|
||||
assert_eq!(conflicts[0].first, HotkeyAction::ToggleMute);
|
||||
assert_eq!(conflicts[0].second, HotkeyAction::ToggleDeafen);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_respects_room_tier() {
|
||||
let mut map = HotkeyMap::default();
|
||||
map.set_binding(HotkeyAction::LeaveRoom, parse_binding("Escape"));
|
||||
let binding = parse_binding("Escape").unwrap();
|
||||
assert_eq!(
|
||||
map.lookup_binding(&binding, HotkeyContext { in_call: false }),
|
||||
None,
|
||||
"room-only shortcuts should not fire outside a call"
|
||||
);
|
||||
assert_eq!(
|
||||
map.lookup_binding(&binding, HotkeyContext { in_call: true }),
|
||||
Some(HotkeyAction::LeaveRoom)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_ptt_is_space() {
|
||||
let map = HotkeyMap::default();
|
||||
assert_eq!(
|
||||
format_binding(map.binding(HotkeyAction::PushToTalk)),
|
||||
"Space"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_single_character_case_folds() {
|
||||
assert_eq!(
|
||||
parse_binding("M"),
|
||||
Some(KeyBinding::Character("m".to_string()))
|
||||
);
|
||||
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
pub mod audio;
|
||||
pub mod codec;
|
||||
pub mod dsp;
|
||||
pub mod network;
|
||||
pub mod core;
|
||||
pub mod app;
|
||||
pub mod audio;
|
||||
pub mod avatar;
|
||||
pub mod background;
|
||||
pub mod codec;
|
||||
pub mod config;
|
||||
pub mod identity;
|
||||
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 theme;
|
||||
pub mod notify;
|
||||
pub mod screenshare;
|
||||
pub mod sanitize;
|
||||
pub mod avatar;
|
||||
pub mod protocol;
|
||||
pub mod recents;
|
||||
pub mod discovery;
|
||||
pub mod sanitize;
|
||||
pub mod screenshare;
|
||||
pub mod theme;
|
||||
pub mod widget;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::fs::File;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
|
||||
// Owner-only log permissions are a Unix concept (mode bits); on Windows the log
|
||||
// inherits the directory's default ACL. Only referenced under `cfg(unix)`.
|
||||
#[cfg(unix)]
|
||||
const LOG_MODE: u32 = 0o600;
|
||||
|
||||
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
|
||||
/// (via `dirs::state_dir`), falling back to the system temp dir. Computed lazily
|
||||
/// so we never hardcode a per-user path.
|
||||
@@ -42,6 +56,81 @@ pub fn log_file_path() -> PathBuf {
|
||||
log_path().clone()
|
||||
}
|
||||
|
||||
/// Short, human-matchable id prefix for diagnostics. Never use this where the
|
||||
/// full value is needed for protocol behavior.
|
||||
pub fn short_id(id: &str) -> String {
|
||||
id.chars().take(8).collect()
|
||||
}
|
||||
|
||||
/// Redact a capability-bearing value for logs while keeping a tiny prefix for
|
||||
/// support correlation. Tickets and endpoint addresses are bearer capabilities:
|
||||
/// logging the full string is equivalent to leaking the room/share.
|
||||
pub fn redact_for_log(value: &str) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
"<redacted:empty>".to_string()
|
||||
} else {
|
||||
format!("<redacted:{}...>", short_id(value))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
.take(6)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
fn rotated_log_path(path: &Path) -> PathBuf {
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("peerspeak.log");
|
||||
path.with_file_name(format!("{file_name}.1"))
|
||||
}
|
||||
|
||||
fn prepare_log_file(path: &Path) -> std::io::Result<File> {
|
||||
prepare_log_file_with_limit(path, LOG_MAX_BYTES)
|
||||
}
|
||||
|
||||
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
if std::fs::metadata(path).is_ok_and(|m| m.len() > max_bytes) {
|
||||
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 mut opts = std::fs::OpenOptions::new();
|
||||
opts.create(true).append(true);
|
||||
// The log can carry capability-bearing values (redacted, but still): keep it
|
||||
// owner-only on Unix via the open mode. Windows has no mode bits; it inherits
|
||||
// the directory ACL, so this hardening is Unix-only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(LOG_MODE);
|
||||
}
|
||||
let file = opts.open(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Re-assert the mode in case the file pre-existed with looser perms.
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
|
||||
}
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub fn log_msg(msg: &str) {
|
||||
// Format the whole line into one buffer first, then emit it with a single
|
||||
// `write_all`. The file is opened with `O_APPEND`, so a lone `write()` is
|
||||
@@ -51,13 +140,65 @@ pub fn log_msg(msg: &str) {
|
||||
Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg),
|
||||
Err(_) => format!("{}\n", msg),
|
||||
};
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(log_path())
|
||||
{
|
||||
if let Ok(mut file) = prepare_log_file(log_path()) {
|
||||
use std::io::Write;
|
||||
let _ = file.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn temp_log_dir() -> PathBuf {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("peerspeak-log-test-{}-{stamp}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_keeps_only_a_short_prefix() {
|
||||
let secret = "abcdefghijklmnopqrstuvwxyz";
|
||||
let redacted = redact_for_log(secret);
|
||||
assert!(redacted.contains("abcdefgh"));
|
||||
assert!(!redacted.contains("ijklmnopqrstuvwxyz"));
|
||||
assert_eq!(redact_for_log(" "), "<redacted:empty>");
|
||||
}
|
||||
|
||||
// Owner-only log perms are a Unix concept; on Windows the file inherits the
|
||||
// directory ACL and there's no mode to assert.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn log_file_is_created_private() {
|
||||
let dir = temp_log_dir();
|
||||
let path = dir.join("peerspeak.log");
|
||||
let _file = prepare_log_file(&path).unwrap();
|
||||
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, LOG_MODE);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_log_is_rotated_on_open() {
|
||||
let dir = temp_log_dir();
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("peerspeak.log");
|
||||
{
|
||||
let mut file = std::fs::File::create(&path).unwrap();
|
||||
file.write_all(b"oversized").unwrap();
|
||||
}
|
||||
|
||||
let _file = prepare_log_file_with_limit(&path, 4).unwrap();
|
||||
let rotated = rotated_log_path(&path);
|
||||
|
||||
assert_eq!(std::fs::read_to_string(rotated).unwrap(), "oversized");
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||