Compare commits
22
Commits
9ff7c7b99c
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c3ce8c0a | ||
|
|
aec21a48d5 | ||
|
|
d0a16cb8b9 | ||
|
|
e0325d4590 | ||
|
|
e8a894be49 | ||
|
|
8c33b5c70f | ||
|
|
10bd15aeaa | ||
|
|
8ad0bea19d | ||
|
|
abb53af559 | ||
|
|
c0c1969332 | ||
|
|
d155091eed | ||
|
|
b553a94875 | ||
|
|
0aaf6be529 | ||
|
|
d059386aee | ||
|
|
79a091b1b3 | ||
|
|
c1de7efbc5 | ||
|
|
96e3e0ba10 | ||
|
|
bf4d9b100f | ||
|
|
91ef5b0a72 | ||
|
|
47be7c340d | ||
|
|
1c8c37b248 | ||
|
|
08792809d6 |
@@ -2,6 +2,17 @@
|
||||
|
||||
All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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.0] — 2026-06-28
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+6
-4
@@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
||||
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
|
||||
@@ -14,7 +14,7 @@ publish = false
|
||||
# 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. Private build — not for redistribution."
|
||||
copyright = "2026, mollusk. MIT License."
|
||||
section = "net"
|
||||
priority = "optional"
|
||||
depends = "$auto"
|
||||
@@ -33,6 +33,8 @@ assets = [
|
||||
["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]
|
||||
|
||||
@@ -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).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
"""
|
||||
@@ -81,8 +81,8 @@ allow = [
|
||||
confidence-threshold = 0.8
|
||||
exceptions = []
|
||||
|
||||
# peerspeak itself has no `license` field and is not published, so skip the
|
||||
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
|
||||
# if/when this is ever published.
|
||||
# peerspeak is MIT-licensed (see Cargo.toml `license` + the LICENSE file) but is
|
||||
# not published to crates.io, so keep the private-crate skip for the
|
||||
# "unlicensed"/publish checks. MIT is already in the allow list above.
|
||||
[licenses.private]
|
||||
ignore = true
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
+6
-1
@@ -6,7 +6,7 @@ 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'
|
||||
@@ -70,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,87 @@
|
||||
# Debian / Ubuntu `.deb` build
|
||||
|
||||
This documents how the `peerspeak_*.deb` is produced, so the deb path is as
|
||||
self-documenting as the Arch (`packaging/PKGBUILD`) and AppImage paths.
|
||||
|
||||
The deb **recipe itself** lives in-repo as the `[package.metadata.deb]` block in
|
||||
the top-level `Cargo.toml` (cargo-deb's equivalent of a PKGBUILD). This file
|
||||
documents only the **build environment**, which is otherwise undiscoverable from
|
||||
a fresh clone.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```sh
|
||||
# one-time: create + provision the build box (see "Build environment" below)
|
||||
distrobox enter peerspeak-bookworm -- bash -lc '
|
||||
source ~/.cargo/env
|
||||
cd ~/git/butter/peerspeak
|
||||
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak # MANDATORY, see below
|
||||
cargo deb
|
||||
'
|
||||
# output: $CARGO_TARGET_DIR/debian/peerspeak_<version>-1_amd64.deb
|
||||
```
|
||||
|
||||
## Build environment
|
||||
|
||||
- **Base: a Debian 12 (bookworm) distrobox named `peerspeak-bookworm`.**
|
||||
Created with `distrobox create --name peerspeak-bookworm --image debian:12`.
|
||||
Bookworm ships **glibc 2.36**, which sets the widest practical compatibility
|
||||
floor (see "glibc floor" below).
|
||||
- **NEVER build the `.deb` on the Arch host.** Two independent reasons:
|
||||
1. The Arch host's glibc is far newer, so the resulting `.deb` would demand a
|
||||
glibc no normal Debian/Ubuntu user has, and ships an empty `Depends`.
|
||||
2. distrobox shares `$HOME` (and therefore the repo's `target/`) with the host,
|
||||
so a host build links Arch-compiled C objects into the "Debian" binary.
|
||||
|
||||
### One-time provisioning inside the box
|
||||
|
||||
```sh
|
||||
distrobox enter peerspeak-bookworm
|
||||
sudo apt update
|
||||
sudo apt install -y build-essential pkg-config clang libclang-dev \
|
||||
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
|
||||
# clang/libclang -> pipewire-sys bindgen ; libxcb1-dev -> link
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
source ~/.cargo/env
|
||||
cargo install cargo-deb
|
||||
```
|
||||
|
||||
### The mandatory separate `CARGO_TARGET_DIR`
|
||||
|
||||
Because distrobox shares `$HOME`, the repo's default `target/` is the **same
|
||||
directory** the Arch host builds into. If you run `cargo deb` without overriding
|
||||
the target dir, cargo will happily reuse Arch-built `.o`/rlib artifacts and link
|
||||
them into the Debian binary, producing a `.deb` that crashes or demands the
|
||||
host's glibc.
|
||||
|
||||
Always point the build at a box-local cache:
|
||||
|
||||
```sh
|
||||
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak
|
||||
```
|
||||
|
||||
(Run `cargo clean` first if you ever suspect a polluted target dir.)
|
||||
|
||||
## glibc floor
|
||||
|
||||
The `.deb` is built against the build box's glibc, which becomes the install
|
||||
floor (`libc6 (>= 2.36)` lands in `Depends` via `$auto`):
|
||||
|
||||
| Build box | glibc | Runs on |
|
||||
|----------------------|-------|--------------------------------------|
|
||||
| `debian:12` (current)| 2.36 | Debian 12+, Ubuntu 24.04+ (glibc ≥ 2.36) |
|
||||
| `ubuntu:26.04` (old) | 2.43 | Ubuntu 26.04+ only — too narrow, abandoned |
|
||||
|
||||
If a friend is on something even older than Debian 12, drop the floor further by
|
||||
recreating the box from an older base image and rebuilding.
|
||||
|
||||
## Runtime `Depends` / `Recommends`
|
||||
|
||||
- `Depends = "$auto"` — cargo-deb runs `dpkg-shlibdeps`, which discovers the
|
||||
linked shared libraries (PipeWire, Opus, ALSA, xcb, glibc, …) automatically.
|
||||
- `Recommends = "pixelpass, mpv"` — `pixelpass` provides in-room screen sharing
|
||||
and `mpv` is the screen-share viewer (these are companion programs invoked as
|
||||
subprocesses, not linked libraries, so they are Recommends not Depends).
|
||||
|
||||
See `pixelpass`'s own `packaging/debian/README.md` for why **its** `Depends`
|
||||
lists the whole GStreamer stack explicitly.
|
||||
@@ -1,112 +0,0 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
#
|
||||
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
|
||||
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
|
||||
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
|
||||
# finds it. Shared version string is derived from peerspeak's git.
|
||||
#
|
||||
# Clone this repo and build from here:
|
||||
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||
# cd peerspeak/packaging/test-pack
|
||||
# makepkg -si
|
||||
pkgbase=peerspeak-git
|
||||
pkgname=('peerspeak-git' 'pixelpass')
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
arch=('x86_64')
|
||||
url="https://gitbutter.xyz/mollusk/peerspeak"
|
||||
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
|
||||
makedepends=('git' 'cargo' 'pkgconf')
|
||||
options=('!lto' '!debug')
|
||||
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
|
||||
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
|
||||
sha256sums=('SKIP'
|
||||
'SKIP')
|
||||
|
||||
pkgver() {
|
||||
cd "$srcdir/peerspeak"
|
||||
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
|
||||
printf '%s.r%s.g%s' \
|
||||
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
|
||||
"$(git rev-list --count HEAD)" \
|
||||
"$(git rev-parse --short HEAD)"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
# Vendor deps up front so build() can run --frozen (no surprise network).
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
local host; host="$(rustc -vV | sed -n 's/host: //p')"
|
||||
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
|
||||
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
|
||||
}
|
||||
|
||||
build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
|
||||
cd "$srcdir/peerspeak"
|
||||
cargo build --frozen --release --bin peerspeak
|
||||
|
||||
cd "$srcdir/pixelpass"
|
||||
# --features gui so the .desktop launcher (pixelpass --gui) works.
|
||||
cargo build --frozen --release --features gui
|
||||
}
|
||||
|
||||
check() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
# peerspeak library unit tests only — its integration suites bind real
|
||||
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
|
||||
cd "$srcdir/peerspeak"
|
||||
cargo test --frozen --release --lib
|
||||
}
|
||||
|
||||
package_peerspeak-git() {
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
depends=('pipewire' 'opus')
|
||||
optdepends=('pixelpass: screen sharing inside a room'
|
||||
'mpv: screen-share viewer (vlc is used as a fallback)')
|
||||
provides=('peerspeak')
|
||||
conflicts=('peerspeak')
|
||||
license=('custom')
|
||||
|
||||
cd "$srcdir/peerspeak"
|
||||
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
|
||||
install -Dm644 "packaging/peerspeak.desktop" \
|
||||
"$pkgdir/usr/share/applications/peerspeak.desktop"
|
||||
|
||||
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
|
||||
install -Dm644 "assets/icons/peerspeak.svg" \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
|
||||
local s
|
||||
for s in 16 24 32 48 64 128 256 512; do
|
||||
install -Dm644 "assets/icons/peerspeak-$s.png" \
|
||||
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
|
||||
done
|
||||
}
|
||||
|
||||
package_pixelpass() {
|
||||
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
|
||||
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
|
||||
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
|
||||
'libglvnd' 'libxkbcommon' 'wayland')
|
||||
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
|
||||
'vlc: alternative stream viewer'
|
||||
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
|
||||
'gst-plugin-pipewire: screen capture on Wayland sessions'
|
||||
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
|
||||
license=('MIT' 'Apache-2.0' 'OFL-1.1')
|
||||
|
||||
cd "$srcdir/pixelpass"
|
||||
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
|
||||
install -Dm0644 assets/pixelpass.desktop \
|
||||
"$pkgdir/usr/share/applications/pixelpass.desktop"
|
||||
install -Dm0644 assets/pixelpass.svg \
|
||||
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
|
||||
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
|
||||
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
|
||||
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
|
||||
install -Dm0644 assets/NotoSans-OFL.txt \
|
||||
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
# PeerSpeak + PixelPass — CachyOS/Arch test pack
|
||||
|
||||
A single **split PKGBUILD** that builds the latest code from the public gitbutter
|
||||
repos and installs **both** programs at once:
|
||||
|
||||
- `peerspeak` — decentralized P2P voice chat
|
||||
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
|
||||
|
||||
## Build & install (one command)
|
||||
|
||||
```sh
|
||||
git clone https://gitbutter.xyz/mollusk/peerspeak.git
|
||||
cd peerspeak/packaging/test-pack
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
`makepkg -si` auto-installs every dependency via pacman before building —
|
||||
including the Rust toolchain itself (the `cargo` makedepend is provided by the
|
||||
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
|
||||
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
|
||||
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
|
||||
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
|
||||
|
||||
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
|
||||
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
|
||||
git sources re-pull `main` and the version bumps automatically.
|
||||
|
||||
> Skip the test step with `makepkg -si --nocheck` for a faster build.
|
||||
|
||||
## Running the cross-internet test
|
||||
|
||||
1. Launch `peerspeak` on both machines.
|
||||
2. One person **creates** a room and shares the room code/ticket with the other.
|
||||
3. The other **joins** with that code.
|
||||
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
|
||||
falls back to a public n0 relay — **no port forwarding required**.
|
||||
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
|
||||
nothing needs to be opened inbound for relay mode).
|
||||
|
||||
### What we're smoke-testing
|
||||
- Two real humans, two networks, over the internet.
|
||||
- Mic capture + remote playback both directions, no crackle/dropouts.
|
||||
- Mute / deafen, push-to-talk.
|
||||
- Text chat in-room.
|
||||
- Avatars (presets + custom upload) show up on the other side.
|
||||
- Screen share: click the screen-share control → it launches `pixelpass`; the
|
||||
viewer opens in `mpv` on the receiving side.
|
||||
- Notification chimes (join/leave/etc.).
|
||||
- Leave / rejoin cleanly.
|
||||
|
||||
If anything misbehaves, grab the log path peerspeak prints on startup and the
|
||||
exact repro steps.
|
||||
+2017
-908
File diff suppressed because it is too large
Load Diff
+53
-12
@@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result<cpal::SupportedStreamC
|
||||
let pick = |channels: Option<u16>| {
|
||||
ranges
|
||||
.iter()
|
||||
.find(|r| usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c))
|
||||
.find(|r| {
|
||||
usable_range(r) && supports_48k(r) && channels.is_none_or(|c| r.channels() == c)
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
|
||||
@@ -666,15 +668,30 @@ fn run_capture(
|
||||
let device_rate = config.sample_rate.0;
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => build_input::<f32, _>(
|
||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
||||
&device,
|
||||
&config,
|
||||
producer,
|
||||
channels,
|
||||
overrun.clone(),
|
||||
callbacks.clone(),
|
||||
err_code.clone(),
|
||||
),
|
||||
SampleFormat::I16 => build_input::<i16, _>(
|
||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
||||
&device,
|
||||
&config,
|
||||
producer,
|
||||
channels,
|
||||
overrun.clone(),
|
||||
callbacks.clone(),
|
||||
err_code.clone(),
|
||||
),
|
||||
SampleFormat::U16 => build_input::<u16, _>(
|
||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
||||
&device,
|
||||
&config,
|
||||
producer,
|
||||
channels,
|
||||
overrun.clone(),
|
||||
callbacks.clone(),
|
||||
err_code.clone(),
|
||||
),
|
||||
other => Err(AudioError::Stream(format!(
|
||||
@@ -764,7 +781,10 @@ fn run_capture(
|
||||
// Surface a stream error the RT callback flagged (it can't log itself).
|
||||
let ec = err_code.load(Ordering::Relaxed);
|
||||
if ec != STREAM_ERR_NONE && ec != last_err {
|
||||
crate::log_msg(&format!("cpal capture stream error: {}", stream_err_text(ec)));
|
||||
crate::log_msg(&format!(
|
||||
"cpal capture stream error: {}",
|
||||
stream_err_text(ec)
|
||||
));
|
||||
last_err = ec;
|
||||
}
|
||||
if !drained {
|
||||
@@ -914,16 +934,34 @@ fn run_playback(
|
||||
let device_rate = config.sample_rate.0;
|
||||
let stream = match sample_format {
|
||||
SampleFormat::F32 => build_output::<f32, _>(
|
||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
||||
&device,
|
||||
&config,
|
||||
consumer,
|
||||
ring_fill.clone(),
|
||||
underrun.clone(),
|
||||
max_cb.clone(),
|
||||
callbacks.clone(),
|
||||
err_code.clone(),
|
||||
),
|
||||
SampleFormat::I16 => build_output::<i16, _>(
|
||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
||||
&device,
|
||||
&config,
|
||||
consumer,
|
||||
ring_fill.clone(),
|
||||
underrun.clone(),
|
||||
max_cb.clone(),
|
||||
callbacks.clone(),
|
||||
err_code.clone(),
|
||||
),
|
||||
SampleFormat::U16 => build_output::<u16, _>(
|
||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
||||
&device,
|
||||
&config,
|
||||
consumer,
|
||||
ring_fill.clone(),
|
||||
underrun.clone(),
|
||||
max_cb.clone(),
|
||||
callbacks.clone(),
|
||||
err_code.clone(),
|
||||
),
|
||||
other => Err(AudioError::Stream(format!(
|
||||
"unsupported playback sample format: {other:?}"
|
||||
@@ -1199,7 +1237,10 @@ fn spawn_health_logger(
|
||||
// Surface a stream error the RT callback flagged (it can't log itself).
|
||||
let ec = err_code.load(Ordering::Relaxed);
|
||||
if ec != STREAM_ERR_NONE && ec != last_err {
|
||||
crate::log_msg(&format!("cpal playback stream error: {}", stream_err_text(ec)));
|
||||
crate::log_msg(&format!(
|
||||
"cpal playback stream error: {}",
|
||||
stream_err_text(ec)
|
||||
));
|
||||
last_err = ec;
|
||||
}
|
||||
// Report the device's per-cycle demand (in internal 48 kHz-stereo
|
||||
|
||||
+49
-11
@@ -54,7 +54,10 @@ impl Drop for EchoCancelGuard {
|
||||
.arg("unload-module")
|
||||
.arg(&self.module_index)
|
||||
.output();
|
||||
crate::log_msg(&format!("Echo cancel: unloaded module {}", self.module_index));
|
||||
crate::log_msg(&format!(
|
||||
"Echo cancel: unloaded module {}",
|
||||
self.module_index
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +68,10 @@ impl Drop for EchoCancelGuard {
|
||||
/// `None` (or an empty string) to bind to the system defaults. Returns `Err` with
|
||||
/// a human-readable reason if `pactl` is missing, the load fails, or the nodes
|
||||
/// don't appear — the caller should fall back to the direct devices.
|
||||
pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<EchoCancelGuard, String> {
|
||||
pub fn enable(
|
||||
real_source: Option<&str>,
|
||||
real_sink: Option<&str>,
|
||||
) -> Result<EchoCancelGuard, String> {
|
||||
// Best-effort: clear any stale instance left by a crashed prior run so we
|
||||
// don't stack duplicate modules / fight over the virtual node names.
|
||||
unload_stale();
|
||||
@@ -101,7 +107,11 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
||||
if module_index.parse::<u64>().is_err() {
|
||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||
}
|
||||
let guard = EchoCancelGuard { module_index, source_name, sink_name };
|
||||
let guard = EchoCancelGuard {
|
||||
module_index,
|
||||
source_name,
|
||||
sink_name,
|
||||
};
|
||||
|
||||
// The virtual nodes appear shortly after the module loads; wait for both so
|
||||
// the subsequent capture/playback streams can actually target them. If they
|
||||
@@ -134,7 +144,12 @@ fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
|
||||
/// Whether `pactl list <kind> short` lists a node named `name`.
|
||||
/// `kind` is "sources" or "sinks".
|
||||
fn node_present(kind: &str, name: &str) -> bool {
|
||||
let Ok(out) = Command::new("pactl").arg("list").arg(kind).arg("short").output() else {
|
||||
let Ok(out) = Command::new("pactl")
|
||||
.arg("list")
|
||||
.arg(kind)
|
||||
.arg("short")
|
||||
.output()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
@@ -167,7 +182,12 @@ fn process_is_alive(_pid: u32) -> bool {
|
||||
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
|
||||
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
|
||||
fn unload_stale() {
|
||||
let Ok(out) = Command::new("pactl").arg("list").arg("modules").arg("short").output() else {
|
||||
let Ok(out) = Command::new("pactl")
|
||||
.arg("list")
|
||||
.arg("modules")
|
||||
.arg("short")
|
||||
.output()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
@@ -179,7 +199,10 @@ fn unload_stale() {
|
||||
&& ec_module_is_stale(args, process_is_alive)
|
||||
&& index.parse::<u64>().is_ok()
|
||||
{
|
||||
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
||||
let _ = Command::new("pactl")
|
||||
.arg("unload-module")
|
||||
.arg(index)
|
||||
.output();
|
||||
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
||||
}
|
||||
}
|
||||
@@ -198,13 +221,25 @@ mod tests {
|
||||
let guard = enable(None, None).expect("module-echo-cancel should load");
|
||||
let source_name = guard.source_name().to_string();
|
||||
let sink_name = guard.sink_name().to_string();
|
||||
assert!(node_present("sources", &source_name), "cleaned source must exist");
|
||||
assert!(node_present("sinks", &sink_name), "reference sink must exist");
|
||||
assert!(
|
||||
node_present("sources", &source_name),
|
||||
"cleaned source must exist"
|
||||
);
|
||||
assert!(
|
||||
node_present("sinks", &sink_name),
|
||||
"reference sink must exist"
|
||||
);
|
||||
drop(guard);
|
||||
// Give pactl a moment to tear the nodes down.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(!node_present("sources", &source_name), "source must be gone after unload");
|
||||
assert!(!node_present("sinks", &sink_name), "sink must be gone after unload");
|
||||
assert!(
|
||||
!node_present("sources", &source_name),
|
||||
"source must be gone after unload"
|
||||
);
|
||||
assert!(
|
||||
!node_present("sinks", &sink_name),
|
||||
"sink must be gone after unload"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,7 +255,10 @@ mod tests {
|
||||
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
|
||||
None
|
||||
);
|
||||
assert_eq!(pid_from_ec_args("source_name=someone_elses_source.4242"), None);
|
||||
assert_eq!(
|
||||
pid_from_ec_args("source_name=someone_elses_source.4242"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+17
-9
@@ -251,7 +251,10 @@ mod tests {
|
||||
let before = rms(&low);
|
||||
eq.process_frame(&mut low);
|
||||
let after = rms(&low);
|
||||
assert!(after > before * 1.6, "low shelf should boost low RMS: {before} -> {after}");
|
||||
assert!(
|
||||
after > before * 1.6,
|
||||
"low shelf should boost low RMS: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,7 +267,10 @@ mod tests {
|
||||
let before = rms(&high);
|
||||
eq.process_frame(&mut high);
|
||||
let after = rms(&high);
|
||||
assert!(after > before * 1.6, "high shelf should boost high RMS: {before} -> {after}");
|
||||
assert!(
|
||||
after > before * 1.6,
|
||||
"high shelf should boost high RMS: {before} -> {after}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -275,7 +281,10 @@ mod tests {
|
||||
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
|
||||
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_Q),
|
||||
] {
|
||||
assert!(b.coeffs.all_finite(), "coefficients must be finite at {gain} dB");
|
||||
assert!(
|
||||
b.coeffs.all_finite(),
|
||||
"coefficients must be finite at {gain} dB"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,12 +298,11 @@ mod tests {
|
||||
});
|
||||
let mut frame = sine(1_000.0, 48_000, 30_000.0);
|
||||
eq.process_frame(&mut frame);
|
||||
let peak = frame
|
||||
.iter()
|
||||
.map(|&s| i32::from(s).abs())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
assert!(peak > 1_000, "processed signal should retain audible energy");
|
||||
let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0);
|
||||
assert!(
|
||||
peak > 1_000,
|
||||
"processed signal should retain audible energy"
|
||||
);
|
||||
assert!(
|
||||
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
||||
"a boosted sine should retain both polarities"
|
||||
|
||||
+51
-12
@@ -169,7 +169,10 @@ mod tests {
|
||||
assert!(g.process(&mut f, 0.05), "loud frame must transmit");
|
||||
last = peak(&f);
|
||||
}
|
||||
assert!(last >= 9900, "gain should reach ~1.0 on sustained loud input, got peak {last}");
|
||||
assert!(
|
||||
last >= 9900,
|
||||
"gain should reach ~1.0 on sustained loud input, got peak {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -179,8 +182,15 @@ mod tests {
|
||||
g.process(&mut f, 0.05);
|
||||
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
|
||||
// 0->1, so the early samples are well below full scale (no instant click).
|
||||
assert!(f[0].abs() < 5000, "attack should start near zero, got {}", f[0]);
|
||||
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame");
|
||||
assert!(
|
||||
f[0].abs() < 5000,
|
||||
"attack should start near zero, got {}",
|
||||
f[0]
|
||||
);
|
||||
assert!(
|
||||
f[FRAME - 1].abs() > 9000,
|
||||
"attack should complete within the frame"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,8 +203,14 @@ mod tests {
|
||||
}
|
||||
// First quiet frame right after speech: hold keeps it open (not chopped).
|
||||
let mut q = frame(50); // rms ~0.0015, below close (0.03)
|
||||
assert!(g.process(&mut q, 0.05), "first quiet frame must stay open (hangover)");
|
||||
assert!(peak(&q) > 0, "held-open frame must not be silenced immediately");
|
||||
assert!(
|
||||
g.process(&mut q, 0.05),
|
||||
"first quiet frame must stay open (hangover)"
|
||||
);
|
||||
assert!(
|
||||
peak(&q) > 0,
|
||||
"held-open frame must not be silenced immediately"
|
||||
);
|
||||
|
||||
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
|
||||
let mut closed = false;
|
||||
@@ -205,7 +221,10 @@ mod tests {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(closed, "gate must eventually close and stop transmitting after sustained silence");
|
||||
assert!(
|
||||
closed,
|
||||
"gate must eventually close and stop transmitting after sustained silence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -216,8 +235,14 @@ mod tests {
|
||||
g.process(&mut f, 0.05); // open=0.05, close=0.03
|
||||
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
|
||||
let mut mid = frame(1310);
|
||||
assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open");
|
||||
assert!(g.open, "hysteresis: gate stays open above the close threshold");
|
||||
assert!(
|
||||
g.process(&mut mid, 0.05),
|
||||
"between-threshold frame must keep an open gate open"
|
||||
);
|
||||
assert!(
|
||||
g.open,
|
||||
"hysteresis: gate stays open above the close threshold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -225,7 +250,10 @@ mod tests {
|
||||
let mut g = NoiseGate::new(SR);
|
||||
// Never opened; feed silence — should report don't-transmit promptly.
|
||||
let mut f = frame(0);
|
||||
assert!(!g.process(&mut f, 0.05), "an unopened gate on silence must not transmit");
|
||||
assert!(
|
||||
!g.process(&mut f, 0.05),
|
||||
"an unopened gate on silence must not transmit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -266,7 +294,11 @@ mod tests {
|
||||
|
||||
let mut f2 = frame(10000);
|
||||
assert!(g.process(&mut f2, 0.05)); // enabled
|
||||
assert!(f2[0].abs() > 9000, "expected first sample of enabled frame to have no fade-in, got {}", f2[0]);
|
||||
assert!(
|
||||
f2[0].abs() > 9000,
|
||||
"expected first sample of enabled frame to have no fade-in, got {}",
|
||||
f2[0]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -302,7 +334,10 @@ mod tests {
|
||||
let mut f = frame(1310);
|
||||
assert!(g.process(&mut f, 0.05));
|
||||
}
|
||||
assert!(g.open, "gate must stay open (hold refreshed by mid-level input)");
|
||||
assert!(
|
||||
g.open,
|
||||
"gate must stay open (hold refreshed by mid-level input)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -333,6 +368,10 @@ mod tests {
|
||||
last_peak = peak(&f);
|
||||
}
|
||||
assert!(g.open);
|
||||
assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak);
|
||||
assert!(
|
||||
last_peak >= 9900,
|
||||
"peak of the 3rd reopened frame must be >= 9900, got {}",
|
||||
last_peak
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+69
-15
@@ -123,7 +123,10 @@ mod tests {
|
||||
let out = lim.process(&loud, 1.0);
|
||||
let ceiling = lim.ceiling().ceil() as i16;
|
||||
for &s in &out {
|
||||
assert!(s > 0, "positive loud input stays positive (no wrap), got {s}");
|
||||
assert!(
|
||||
s > 0,
|
||||
"positive loud input stays positive (no wrap), got {s}"
|
||||
);
|
||||
assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}");
|
||||
}
|
||||
}
|
||||
@@ -175,7 +178,10 @@ mod tests {
|
||||
let out_pos = lim.process(&pos_loud, 1.0);
|
||||
for &s in &out_pos {
|
||||
assert!(s > 0, "positive input stays positive, got {s}");
|
||||
assert!(s <= ceiling_ceil, "positive sample {s} exceeded ceiling {ceiling_ceil}");
|
||||
assert!(
|
||||
s <= ceiling_ceil,
|
||||
"positive sample {s} exceeded ceiling {ceiling_ceil}"
|
||||
);
|
||||
}
|
||||
|
||||
// Sustained negative loud sum
|
||||
@@ -185,7 +191,10 @@ mod tests {
|
||||
let neg_ceiling = -ceiling_ceil;
|
||||
for &s in &out_neg {
|
||||
assert!(s < 0, "negative input stays negative, got {s}");
|
||||
assert!(s >= neg_ceiling, "negative sample {s} exceeded negative ceiling {neg_ceiling}");
|
||||
assert!(
|
||||
s >= neg_ceiling,
|
||||
"negative sample {s} exceeded negative ceiling {neg_ceiling}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +209,14 @@ mod tests {
|
||||
let out = lim.process(&input, 8.0);
|
||||
for &s in &out {
|
||||
assert!(s > 0, "positive stays positive");
|
||||
assert!(s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}");
|
||||
assert!((s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}");
|
||||
assert!(
|
||||
s <= ceiling_ceil,
|
||||
"sample {s} must be limited to ceiling {ceiling_ceil}"
|
||||
);
|
||||
assert!(
|
||||
(s - ceiling_ceil).abs() <= 2,
|
||||
"sample {s} should ride the ceiling {ceiling_ceil}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +228,10 @@ mod tests {
|
||||
let out = lim.process(&input, 0.5);
|
||||
for (i, &s) in out.iter().enumerate() {
|
||||
let expected = (input[i] as f32 * 0.5).round() as i16;
|
||||
assert!((s - expected).abs() <= 1, "sample {s} should be close to expected {expected}");
|
||||
assert!(
|
||||
(s - expected).abs() <= 1,
|
||||
"sample {s} should be close to expected {expected}"
|
||||
);
|
||||
}
|
||||
|
||||
// Subsequently feed a new sample at unity gain. It must be transparent,
|
||||
@@ -230,7 +248,12 @@ mod tests {
|
||||
|
||||
let loud = vec![200_000i32; 10];
|
||||
let out = lim.process(&loud, 1.0);
|
||||
assert!(out[0] <= ceiling_ceil, "first sample {} must not overshoot ceiling {}", out[0], ceiling_ceil);
|
||||
assert!(
|
||||
out[0] <= ceiling_ceil,
|
||||
"first sample {} must not overshoot ceiling {}",
|
||||
out[0],
|
||||
ceiling_ceil
|
||||
);
|
||||
}
|
||||
|
||||
/// 5. Release direction & monotonicity.
|
||||
@@ -247,13 +270,23 @@ mod tests {
|
||||
|
||||
// Output should be monotonic (non-decreasing)
|
||||
for i in 1..out.len() {
|
||||
assert!(out[i] >= out[i - 1], "output must be monotonic; index {} was {}, index {} was {}", i - 1, out[i - 1], i, out[i]);
|
||||
assert!(
|
||||
out[i] >= out[i - 1],
|
||||
"output must be monotonic; index {} was {}, index {} was {}",
|
||||
i - 1,
|
||||
out[i - 1],
|
||||
i,
|
||||
out[i]
|
||||
);
|
||||
}
|
||||
|
||||
// The end sample should be closer to the original input than the start sample
|
||||
let start_diff = (mid_val as i16 - out[0]).abs();
|
||||
let end_diff = (mid_val as i16 - *out.last().unwrap()).abs();
|
||||
assert!(end_diff < start_diff, "end diff {end_diff} should be smaller than start diff {start_diff}");
|
||||
assert!(
|
||||
end_diff < start_diff,
|
||||
"end diff {end_diff} should be smaller than start diff {start_diff}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 6. Release is gradual, not instantaneous.
|
||||
@@ -265,7 +298,11 @@ mod tests {
|
||||
|
||||
// Immediately follow with a sub-ceiling sample
|
||||
let out = lim.process(&[10_000i32], 1.0);
|
||||
assert!(out[0] < 10_000, "first quiet sample should still be attenuated (got {})", out[0]);
|
||||
assert!(
|
||||
out[0] < 10_000,
|
||||
"first quiet sample should still be attenuated (got {})",
|
||||
out[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// 7. State carries across process calls.
|
||||
@@ -287,7 +324,10 @@ mod tests {
|
||||
let mut out_split = out_split1;
|
||||
out_split.extend(&out_split2);
|
||||
|
||||
assert_eq!(out_single, out_split, "splitting process calls must produce identical output to a single call");
|
||||
assert_eq!(
|
||||
out_single, out_split,
|
||||
"splitting process calls must produce identical output to a single call"
|
||||
);
|
||||
|
||||
// Test 2: Pre-loaded limiter vs fresh limiter on the same input
|
||||
let mut lim_preloaded = SoftLimiter::new(SR);
|
||||
@@ -299,8 +339,16 @@ mod tests {
|
||||
let out_preloaded = lim_preloaded.process(&test_input, 1.0);
|
||||
let out_fresh = lim_fresh.process(&test_input, 1.0);
|
||||
|
||||
assert_ne!(out_preloaded, out_fresh, "pre-loaded and fresh limiter outputs should differ");
|
||||
assert!(out_preloaded[0] < out_fresh[0], "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", out_preloaded[0], out_fresh[0]);
|
||||
assert_ne!(
|
||||
out_preloaded, out_fresh,
|
||||
"pre-loaded and fresh limiter outputs should differ"
|
||||
);
|
||||
assert!(
|
||||
out_preloaded[0] < out_fresh[0],
|
||||
"pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}",
|
||||
out_preloaded[0],
|
||||
out_fresh[0]
|
||||
);
|
||||
}
|
||||
|
||||
/// 8. Empty input.
|
||||
@@ -320,7 +368,10 @@ mod tests {
|
||||
// Gain 0.0
|
||||
let out_zero = lim.process(&input, 0.0);
|
||||
assert_eq!(out_zero.len(), input.len());
|
||||
assert!(out_zero.iter().all(|&s| s == 0), "0.0 gain should result in all zeros");
|
||||
assert!(
|
||||
out_zero.iter().all(|&s| s == 0),
|
||||
"0.0 gain should result in all zeros"
|
||||
);
|
||||
|
||||
// Gain 1.0
|
||||
let out_unity = lim.process(&input, 1.0);
|
||||
@@ -354,6 +405,9 @@ mod tests {
|
||||
|
||||
let out = lim.process(&input, 1.0);
|
||||
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
|
||||
assert_eq!(out, expected, "below ceiling input must be bit-exact at unity gain");
|
||||
assert_eq!(
|
||||
out, expected,
|
||||
"below ceiling input must be bit-exact at unity gain"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Playback output channel count. Capture/encode/network remain mono; only the
|
||||
@@ -35,7 +35,11 @@ pub enum AudioError {
|
||||
pub trait AudioBackend: Send + Sync {
|
||||
/// Starts capturing raw PCM audio from the input device (microphone),
|
||||
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
|
||||
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError>;
|
||||
fn start_capture(
|
||||
&self,
|
||||
tx: Sender<Vec<i16>>,
|
||||
target_node: Option<String>,
|
||||
) -> Result<(), AudioError>;
|
||||
|
||||
/// Starts playing back raw PCM audio to the output device (speaker),
|
||||
/// reading mixed/incoming chunks of samples from the provided Receiver.
|
||||
@@ -65,16 +69,16 @@ pub mod pan;
|
||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
||||
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||
// backend wires it in.
|
||||
pub mod resample;
|
||||
#[cfg(windows)]
|
||||
pub mod cpal_impl;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod echo_cancel;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pipewire_impl;
|
||||
#[cfg(windows)]
|
||||
pub mod cpal_impl;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod pw_cli;
|
||||
pub mod recorder;
|
||||
pub mod resample;
|
||||
|
||||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||
/// identifier the backend uses to request the device (`target_node`);
|
||||
@@ -96,10 +100,10 @@ impl std::fmt::Display for AudioDevice {
|
||||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pw_cli::enumerate_audio_devices;
|
||||
#[cfg(windows)]
|
||||
pub use cpal_impl::enumerate_audio_devices;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub use pw_cli::enumerate_audio_devices;
|
||||
|
||||
/// The audio backend implementation for the current platform.
|
||||
///
|
||||
|
||||
+33
-7
@@ -108,7 +108,13 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
|
||||
let clean = crate::sanitize::sanitize_name(name);
|
||||
let mut slug: String = clean
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// Collapse runs of '-' and trim them off the ends.
|
||||
while slug.contains("--") {
|
||||
@@ -284,7 +290,10 @@ mod tests {
|
||||
let short: String = id.to_string().chars().take(8).collect();
|
||||
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
|
||||
// Spaces / punctuation collapse to single dashes, trimmed.
|
||||
assert_eq!(track_filename(" Bob the Builder! ", &id), format!("bob-the-builder-{short}.wav"));
|
||||
assert_eq!(
|
||||
track_filename(" Bob the Builder! ", &id),
|
||||
format!("bob-the-builder-{short}.wav")
|
||||
);
|
||||
// A name that sanitizes/slugs to nothing falls back to "peer".
|
||||
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
||||
}
|
||||
@@ -327,10 +336,18 @@ mod tests {
|
||||
rec.finalize().unwrap();
|
||||
|
||||
let expected = 3 * frame;
|
||||
assert_eq!(wav_samples(&dir.join("me.wav")), expected, "mic padded to full length");
|
||||
assert_eq!(
|
||||
wav_samples(&dir.join("me.wav")),
|
||||
expected,
|
||||
"mic padded to full length"
|
||||
);
|
||||
assert_eq!(wav_samples(&dir.join("mix.wav")), expected);
|
||||
assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), expected);
|
||||
assert_eq!(wav_samples(&dir.join(track_filename("p2", &p2))), expected, "silent peer still full length");
|
||||
assert_eq!(
|
||||
wav_samples(&dir.join(track_filename("p2", &p2))),
|
||||
expected,
|
||||
"silent peer still full length"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -357,8 +374,14 @@ mod tests {
|
||||
rec.finalize().unwrap();
|
||||
|
||||
// Both tracks are the full 5 cycles long (late one was back-padded).
|
||||
assert_eq!(wav_samples(&dir.join(track_filename("early", &early))), 5 * frame);
|
||||
assert_eq!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame);
|
||||
assert_eq!(
|
||||
wav_samples(&dir.join(track_filename("early", &early))),
|
||||
5 * frame
|
||||
);
|
||||
assert_eq!(
|
||||
wav_samples(&dir.join(track_filename("late", &late))),
|
||||
5 * frame
|
||||
);
|
||||
|
||||
// The late track's first 2 cycles are silence, then the real audio.
|
||||
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
|
||||
@@ -378,6 +401,9 @@ mod tests {
|
||||
rec.end_cycle().unwrap();
|
||||
rec.finalize().unwrap();
|
||||
assert!(dir.join("me.wav").exists());
|
||||
assert!(!dir.join("mix.wav").exists(), "no mix track in stems-only mode");
|
||||
assert!(
|
||||
!dir.join("mix.wav").exists(),
|
||||
"no mix track in stems-only mode"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+20
-5
@@ -23,7 +23,10 @@ pub fn pan_gains(pan: f32) -> (f32, f32) {
|
||||
/// still following the same equal-power curve as a peer is moved away from center.
|
||||
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
|
||||
let (left, right) = pan_gains(pan);
|
||||
(left * std::f32::consts::SQRT_2, right * std::f32::consts::SQRT_2)
|
||||
(
|
||||
left * std::f32::consts::SQRT_2,
|
||||
right * std::f32::consts::SQRT_2,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -36,8 +39,14 @@ mod tests {
|
||||
fn hard_left_and_right_are_endpoints() {
|
||||
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
|
||||
let (l, r) = pan_gains(1.0);
|
||||
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}");
|
||||
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}");
|
||||
assert!(
|
||||
l.abs() < EPS,
|
||||
"left at hard-right should be zero-ish, got {l}"
|
||||
);
|
||||
assert!(
|
||||
(r - 1.0).abs() < EPS,
|
||||
"right at hard-right should be one, got {r}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -55,8 +64,14 @@ mod tests {
|
||||
let mut prev_r = f32::NEG_INFINITY;
|
||||
for pan in pans {
|
||||
let (l, r) = pan_gains(pan);
|
||||
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right");
|
||||
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right");
|
||||
assert!(
|
||||
l <= prev_l + EPS,
|
||||
"left gain must not rise as pan moves right"
|
||||
);
|
||||
assert!(
|
||||
r >= prev_r - EPS,
|
||||
"right gain must not fall as pan moves right"
|
||||
);
|
||||
prev_l = l;
|
||||
prev_r = r;
|
||||
}
|
||||
|
||||
+66
-44
@@ -1,13 +1,16 @@
|
||||
use crate::audio::{AudioBackend, AudioError};
|
||||
use std::sync::mpsc::{Sender, Receiver, RecvTimeoutError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
use ringbuf::{
|
||||
HeapRb,
|
||||
traits::{Consumer, Producer, Split},
|
||||
};
|
||||
use spa::pod::Pod;
|
||||
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::{self, JoinHandle};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct PipeWireBackend {
|
||||
capture_state: Mutex<Option<CaptureState>>,
|
||||
@@ -41,7 +44,11 @@ impl PipeWireBackend {
|
||||
}
|
||||
|
||||
impl AudioBackend for PipeWireBackend {
|
||||
fn start_capture(&self, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> {
|
||||
fn start_capture(
|
||||
&self,
|
||||
tx: Sender<Vec<i16>>,
|
||||
target_node: Option<String>,
|
||||
) -> Result<(), AudioError> {
|
||||
let mut capture_guard = self.capture_state.lock().unwrap();
|
||||
if capture_guard.is_some() {
|
||||
return Err(AudioError::Stream("Capture already started".to_string()));
|
||||
@@ -108,12 +115,17 @@ impl AudioBackend for PipeWireBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_node: Option<String>) -> Result<(), AudioError> {
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None)
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
fn run_capture(
|
||||
cmd_rx: pw::channel::Receiver<()>,
|
||||
tx: Sender<Vec<i16>>,
|
||||
target_node: Option<String>,
|
||||
) -> Result<(), AudioError> {
|
||||
let mainloop =
|
||||
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None)
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
let core = context.connect_rc(None)
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
|
||||
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
|
||||
@@ -181,15 +193,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
||||
|
||||
let mut params = [Pod::from_bytes(&values).unwrap()];
|
||||
|
||||
stream.connect(
|
||||
spa::utils::Direction::Input,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Input,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
|
||||
// Spawn the worker thread to pop from consumer and send Vec<i16> frames
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
@@ -257,11 +270,7 @@ const WORKER_POLL: Duration = Duration::from_millis(100);
|
||||
/// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()`
|
||||
/// join the worker promptly instead of hanging on a parked blocking `recv()`
|
||||
/// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable.
|
||||
fn drain_loop(
|
||||
rx: &Receiver<Vec<i16>>,
|
||||
running: &AtomicBool,
|
||||
mut on_frame: impl FnMut(Vec<i16>),
|
||||
) {
|
||||
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
|
||||
while running.load(Ordering::Relaxed) {
|
||||
match rx.recv_timeout(WORKER_POLL) {
|
||||
Ok(frame) => on_frame(frame),
|
||||
@@ -293,7 +302,11 @@ fn publish_frame<P: Producer<Item = i16>>(
|
||||
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
||||
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
||||
const FALLBACK_FRAMES: usize = 1024;
|
||||
let want = if requested > 0 { requested } else { FALLBACK_FRAMES };
|
||||
let want = if requested > 0 {
|
||||
requested
|
||||
} else {
|
||||
FALLBACK_FRAMES
|
||||
};
|
||||
want.min(mapped_frames)
|
||||
}
|
||||
|
||||
@@ -303,11 +316,12 @@ fn run_playback(
|
||||
target_node: Option<String>,
|
||||
fill_gauge: Arc<AtomicUsize>,
|
||||
) -> Result<(), AudioError> {
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None)
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
let mainloop =
|
||||
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None)
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
let core = context.connect_rc(None)
|
||||
let core = context
|
||||
.connect_rc(None)
|
||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||
|
||||
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
||||
@@ -428,7 +442,9 @@ fn run_playback(
|
||||
}
|
||||
if starved > 0 {
|
||||
// One wait-free atomic add per quantum — RT-safe.
|
||||
user_data.underrun_samples.fetch_add(starved, Ordering::Relaxed);
|
||||
user_data
|
||||
.underrun_samples
|
||||
.fetch_add(starved, Ordering::Relaxed);
|
||||
}
|
||||
// Decrement the exact occupancy counter by the samples we
|
||||
// actually pulled (excluding underruns, which removed
|
||||
@@ -493,7 +509,11 @@ fn run_playback(
|
||||
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||
pw::spa::utils::Choice(
|
||||
pw::spa::utils::ChoiceFlags::empty(),
|
||||
pw::spa::utils::ChoiceEnum::Range { default: 8, min: 2, max: 64 },
|
||||
pw::spa::utils::ChoiceEnum::Range {
|
||||
default: 8,
|
||||
min: 2,
|
||||
max: 64,
|
||||
},
|
||||
),
|
||||
)),
|
||||
),
|
||||
@@ -524,15 +544,16 @@ fn run_playback(
|
||||
Pod::from_bytes(&buffers_values).unwrap(),
|
||||
];
|
||||
|
||||
stream.connect(
|
||||
spa::utils::Direction::Output,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Output,
|
||||
None,
|
||||
pw::stream::StreamFlags::AUTOCONNECT
|
||||
| pw::stream::StreamFlags::MAP_BUFFERS
|
||||
| pw::stream::StreamFlags::RT_PROCESS,
|
||||
&mut params,
|
||||
)
|
||||
.map_err(|e| AudioError::Stream(e.to_string()))?;
|
||||
|
||||
// Spawn a worker thread to read from rx and push to producer
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
@@ -607,7 +628,10 @@ fn run_playback(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
|
||||
use ringbuf::{HeapRb, traits::{Consumer, Producer, Split}};
|
||||
use ringbuf::{
|
||||
HeapRb,
|
||||
traits::{Consumer, Producer, Split},
|
||||
};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -647,9 +671,7 @@ mod tests {
|
||||
#[test]
|
||||
fn capture_size_larger_than_mapping_is_clamped() {
|
||||
let mut samples = Vec::new();
|
||||
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| {
|
||||
samples.push(sample)
|
||||
});
|
||||
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample));
|
||||
assert_eq!(samples, vec![1, 2]);
|
||||
}
|
||||
|
||||
|
||||
+31
-6
@@ -16,11 +16,20 @@ pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
|
||||
/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/*
|
||||
/// node, then resets the accumulators for the next block. Non-audio or
|
||||
/// incomplete blocks are dropped (but still reset).
|
||||
fn push_device(name: &mut String, desc: &mut String, class: &mut String, out: &mut Vec<AudioDevice>) {
|
||||
fn push_device(
|
||||
name: &mut String,
|
||||
desc: &mut String,
|
||||
class: &mut String,
|
||||
out: &mut Vec<AudioDevice>,
|
||||
) {
|
||||
if !name.is_empty() && class.starts_with("Audio/") {
|
||||
out.push(AudioDevice {
|
||||
name: name.clone(),
|
||||
description: if desc.is_empty() { name.clone() } else { desc.clone() },
|
||||
description: if desc.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
desc.clone()
|
||||
},
|
||||
is_input: class == "Audio/Source",
|
||||
});
|
||||
}
|
||||
@@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with("id ") {
|
||||
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices);
|
||||
push_device(
|
||||
&mut current_name,
|
||||
&mut current_desc,
|
||||
&mut current_class,
|
||||
&mut devices,
|
||||
);
|
||||
} else if let Some(val) = line.strip_prefix("node.name = \"") {
|
||||
current_name = val.trim_end_matches('"').to_string();
|
||||
} else if let Some(val) = line.strip_prefix("node.description = \"") {
|
||||
@@ -53,7 +67,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
|
||||
current_class = val.trim_end_matches('"').to_string();
|
||||
}
|
||||
}
|
||||
push_device(&mut current_name, &mut current_desc, &mut current_class, &mut devices);
|
||||
push_device(
|
||||
&mut current_name,
|
||||
&mut current_desc,
|
||||
&mut current_class,
|
||||
&mut devices,
|
||||
);
|
||||
|
||||
devices.sort_by(|a, b| a.description.cmp(&b.description));
|
||||
devices
|
||||
@@ -108,8 +127,14 @@ mod tests {
|
||||
fn source_is_input_sink_is_output() {
|
||||
let devices = parse_pw_nodes(SAMPLE_NODES);
|
||||
// Find devices by name or description to verify is_input
|
||||
let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap();
|
||||
let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap();
|
||||
let mic = devices
|
||||
.iter()
|
||||
.find(|d| d.name == "alsa_input.builtin")
|
||||
.unwrap();
|
||||
let speakers = devices
|
||||
.iter()
|
||||
.find(|d| d.name == "alsa_output.builtin")
|
||||
.unwrap();
|
||||
let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap();
|
||||
|
||||
assert!(mic.is_input);
|
||||
|
||||
+151
-43
@@ -2,21 +2,26 @@
|
||||
//!
|
||||
//! Records the **full call as you experienced it**: the mixed incoming audio
|
||||
//! (everyone you hear) summed with your own transmitted mic, into a single mono
|
||||
//! WAV. Writing is driven by the playout mixer (one [`Recorder::write_frame`]
|
||||
//! per produced 20ms frame, paced by the hardware clock); your mic arrives
|
||||
//! separately from the capture thread via [`Recorder::push_mic`] and is buffered
|
||||
//! in a small FIFO so the two independently-clocked streams stay roughly aligned.
|
||||
//! WAV. Mixing/enqueue is driven by the playout mixer (one
|
||||
//! [`Recorder::write_frame`] per produced 20ms frame, paced by the hardware
|
||||
//! clock), while disk writes happen on a dedicated writer thread; your mic
|
||||
//! arrives separately from the capture thread via [`Recorder::push_mic`] and is
|
||||
//! buffered in a small FIFO so the two independently-clocked streams stay
|
||||
//! roughly aligned.
|
||||
//! Minor clock drift just slowly grows/shrinks that FIFO (capped, so the lag
|
||||
//! between your voice and the recording is bounded) — harmless for a voice
|
||||
//! recording, no realtime crackle concern.
|
||||
//!
|
||||
//! No external crates: the WAV writer emits the 44-byte canonical header itself
|
||||
//! and patches the two size fields on [`Recorder::finalize`].
|
||||
//! and patches the two size fields on the writer thread during
|
||||
//! [`Recorder::finalize`].
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::io::{self, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{self, SyncSender, TrySendError};
|
||||
use std::thread::{self, JoinHandle};
|
||||
|
||||
/// Capture sample rate (mono, 48kHz, matching the rest of the audio path).
|
||||
const SAMPLE_RATE: u32 = 48_000;
|
||||
@@ -25,6 +30,8 @@ const CHANNELS: u16 = 1;
|
||||
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
||||
const MAX_NAME_ATTEMPTS: usize = 1_000;
|
||||
const WRITER_QUEUE_FRAMES: usize = 256;
|
||||
const DROP_LOG_INTERVAL_FRAMES: u64 = 256;
|
||||
|
||||
/// Cap on buffered mic samples (~200ms). Bounds how far recording lag can drift
|
||||
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||
@@ -118,13 +125,15 @@ impl WavWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/// A live call recorder: a [`WavWriter`] plus a small mic FIFO that aligns your
|
||||
/// transmitted mic with the playout mixer's incoming-mix frames.
|
||||
/// A live call recorder: a writer-thread queue plus a small mic FIFO that aligns
|
||||
/// your transmitted mic with the playout mixer's incoming-mix frames.
|
||||
pub struct Recorder {
|
||||
writer: WavWriter,
|
||||
frame_tx: SyncSender<Vec<i16>>,
|
||||
writer_thread: JoinHandle<io::Result<()>>,
|
||||
/// Your transmitted mic samples, awaiting alignment with the next mix frame.
|
||||
mic_fifo: VecDeque<i16>,
|
||||
path: PathBuf,
|
||||
dropped_frames: u64,
|
||||
}
|
||||
|
||||
impl Recorder {
|
||||
@@ -142,10 +151,15 @@ impl Recorder {
|
||||
let path = dir.join(name);
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(file) => {
|
||||
let writer = WavWriter::from_file(file)?;
|
||||
let (frame_tx, frame_rx) = mpsc::sync_channel(WRITER_QUEUE_FRAMES);
|
||||
let writer_thread = thread::spawn(move || writer_thread_main(writer, frame_rx));
|
||||
return Ok(Self {
|
||||
writer: WavWriter::from_file(file)?,
|
||||
frame_tx,
|
||||
writer_thread,
|
||||
mic_fifo: VecDeque::new(),
|
||||
path,
|
||||
dropped_frames: 0,
|
||||
});
|
||||
}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
@@ -179,21 +193,73 @@ impl Recorder {
|
||||
/// treated as silence (you weren't transmitting), so quiet stretches record
|
||||
/// the incoming mix alone.
|
||||
pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
|
||||
let mut out = Vec::with_capacity(mixed.len());
|
||||
for &m in mixed {
|
||||
let mic = self.mic_fifo.pop_front().unwrap_or(0);
|
||||
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
|
||||
out.push(sum as i16);
|
||||
let out = mix_with_mic(mixed, &mut self.mic_fifo);
|
||||
match self.frame_tx.try_send(out) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(TrySendError::Full(_)) => {
|
||||
self.dropped_frames = self.dropped_frames.saturating_add(1);
|
||||
if self.dropped_frames == 1
|
||||
|| self.dropped_frames.is_multiple_of(DROP_LOG_INTERVAL_FRAMES)
|
||||
{
|
||||
crate::log_msg(&format!(
|
||||
"recording: writer queue full; dropped {} frame(s)",
|
||||
self.dropped_frames
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
|
||||
io::ErrorKind::BrokenPipe,
|
||||
"recording writer thread stopped",
|
||||
)),
|
||||
}
|
||||
self.writer.write_samples(&out)
|
||||
}
|
||||
|
||||
/// Finish the file, patching its size fields. Consumes the recorder.
|
||||
pub fn finalize(self) -> io::Result<()> {
|
||||
self.writer.finalize()
|
||||
let Self {
|
||||
frame_tx,
|
||||
writer_thread,
|
||||
mic_fifo: _,
|
||||
path: _,
|
||||
dropped_frames: _,
|
||||
} = self;
|
||||
drop(frame_tx);
|
||||
writer_thread
|
||||
.join()
|
||||
.unwrap_or_else(|_| Err(io::Error::other("recording writer thread panicked")))
|
||||
}
|
||||
}
|
||||
|
||||
fn writer_thread_main(mut writer: WavWriter, frame_rx: mpsc::Receiver<Vec<i16>>) -> io::Result<()> {
|
||||
let mut first_write_error = None;
|
||||
|
||||
for frame in frame_rx {
|
||||
if first_write_error.is_none()
|
||||
&& let Err(e) = writer.write_samples(&frame)
|
||||
{
|
||||
first_write_error = Some(e);
|
||||
}
|
||||
}
|
||||
|
||||
let finalize_result = writer.finalize();
|
||||
if let Some(e) = first_write_error {
|
||||
Err(e)
|
||||
} else {
|
||||
finalize_result
|
||||
}
|
||||
}
|
||||
|
||||
fn mix_with_mic(mixed: &[i16], mic_fifo: &mut VecDeque<i16>) -> Vec<i16> {
|
||||
let mut out = Vec::with_capacity(mixed.len());
|
||||
for &m in mixed {
|
||||
let mic = mic_fifo.pop_front().unwrap_or(0);
|
||||
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
|
||||
out.push(sum as i16);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Civil date (year, month, day) from a count of days since the Unix epoch.
|
||||
/// Howard Hinnant's `civil_from_days`; valid across the whole practical range.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
@@ -222,6 +288,23 @@ pub fn timestamp_filename(unix_secs: u64) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn unique_temp_dir(prefix: &str) -> PathBuf {
|
||||
let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("{prefix}-{}-{id}", std::process::id()))
|
||||
}
|
||||
|
||||
fn read_wav_samples(path: &Path) -> (Vec<u8>, Vec<i16>) {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let samples = bytes[44..]
|
||||
.chunks_exact(2)
|
||||
.map(|sample| i16::from_le_bytes([sample[0], sample[1]]))
|
||||
.collect();
|
||||
(bytes, samples)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamp_filename_is_utc_and_padded() {
|
||||
@@ -236,10 +319,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn same_second_recordings_get_unique_files_without_truncation() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"peerspeak-collision-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir = unique_temp_dir("peerspeak-collision");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
@@ -258,6 +338,40 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recorder_thread_writes_mixed_samples_and_header_on_finalize() {
|
||||
let dir = unique_temp_dir("peerspeak-recorder-thread");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let mut recorder = Recorder::create(&dir, 1_700_000_123).unwrap();
|
||||
let path = recorder.path().to_path_buf();
|
||||
|
||||
recorder.push_mic(&[1000, i16::MAX, -1000, i16::MIN, 2222]);
|
||||
recorder.write_frame(&[10, 20, -32700]).unwrap();
|
||||
recorder.push_mic(&[300, -300]);
|
||||
recorder
|
||||
.write_frame(&[0, 1000, i16::MAX, i16::MIN])
|
||||
.unwrap();
|
||||
recorder.finalize().unwrap();
|
||||
|
||||
let expected = vec![1010, i16::MAX, i16::MIN, i16::MIN, 3222, i16::MAX, i16::MIN];
|
||||
let expected_data_bytes = u32::try_from(expected.len() * 2).unwrap();
|
||||
let (bytes, samples) = read_wav_samples(&path);
|
||||
|
||||
assert_eq!(&bytes[0..4], b"RIFF");
|
||||
assert_eq!(&bytes[8..12], b"WAVE");
|
||||
assert_eq!(&bytes[36..40], b"data");
|
||||
let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
|
||||
let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
|
||||
assert_eq!(data, expected_data_bytes);
|
||||
assert_eq!(riff, RIFF_DATA_OVERHEAD as u32 + expected_data_bytes);
|
||||
assert_eq!(bytes.len(), 44 + expected.len() * 2);
|
||||
assert_eq!(samples, expected);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wav_header_round_trips_sizes() {
|
||||
let dir = std::env::temp_dir();
|
||||
@@ -300,38 +414,32 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mic_is_summed_with_mix_when_present() {
|
||||
let dir = std::env::temp_dir();
|
||||
let mut r = Recorder {
|
||||
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
|
||||
.unwrap(),
|
||||
mic_fifo: VecDeque::new(),
|
||||
path: PathBuf::new(),
|
||||
};
|
||||
r.push_mic(&[1000, 2000, 3000]);
|
||||
// write_frame pops mic per-sample and sums; we can't read the file mid-stream,
|
||||
// so assert the FIFO drains exactly by frame length.
|
||||
r.write_frame(&[10, 20]).unwrap();
|
||||
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
|
||||
r.write_frame(&[0, 0]).unwrap();
|
||||
let mut mic_fifo = VecDeque::from([1000, 2000, 3000]);
|
||||
|
||||
let first = mix_with_mic(&[10, 20], &mut mic_fifo);
|
||||
assert_eq!(first, vec![1010, 2020]);
|
||||
assert_eq!(mic_fifo.len(), 1, "two samples consumed, one mic left");
|
||||
|
||||
let second = mix_with_mic(&[0, 0], &mut mic_fifo);
|
||||
assert_eq!(second, vec![3000, 0]);
|
||||
assert_eq!(
|
||||
r.mic_fifo.len(),
|
||||
mic_fifo.len(),
|
||||
0,
|
||||
"remaining mic sample consumed; rest is silence"
|
||||
);
|
||||
let _ = r.finalize();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mic_fifo_is_capped() {
|
||||
let dir = std::env::temp_dir();
|
||||
let mut r = Recorder {
|
||||
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
|
||||
.unwrap(),
|
||||
mic_fifo: VecDeque::new(),
|
||||
path: PathBuf::new(),
|
||||
};
|
||||
let dir = unique_temp_dir("peerspeak-cap");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let mut r = Recorder::create(&dir, 1_700_000_001).unwrap();
|
||||
r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
|
||||
assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap");
|
||||
let _ = r.finalize();
|
||||
r.finalize().unwrap();
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +145,10 @@ impl StereoPullResampler {
|
||||
self.frac -= 1.0;
|
||||
}
|
||||
let f = self.frac as f32;
|
||||
let out = (lerp(self.prev.0, self.cur.0, f), lerp(self.prev.1, self.cur.1, f));
|
||||
let out = (
|
||||
lerp(self.prev.0, self.cur.0, f),
|
||||
lerp(self.prev.1, self.cur.1, f),
|
||||
);
|
||||
self.frac += self.step;
|
||||
Some(out)
|
||||
}
|
||||
@@ -273,7 +276,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
// At step 2.0 we consume ~2 input frames per output frame.
|
||||
assert!(idx > emitted, "consumed {idx} input, emitted {emitted} output");
|
||||
assert!(
|
||||
idx > emitted,
|
||||
"consumed {idx} input, emitted {emitted} output"
|
||||
);
|
||||
}
|
||||
|
||||
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
||||
|
||||
+12
-3
@@ -206,7 +206,10 @@ pub struct ByteLru<V> {
|
||||
impl<V: Clone> ByteLru<V> {
|
||||
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
|
||||
pub fn new(cap: usize) -> Self {
|
||||
Self { cap: cap.max(1), entries: Vec::new() }
|
||||
Self {
|
||||
cap: cap.max(1),
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the cached value for these exact `bytes`, building and inserting it
|
||||
@@ -349,7 +352,10 @@ mod tests {
|
||||
fn preset_png_in_range_and_out_of_range() {
|
||||
// Every declared preset index resolves to embedded bytes.
|
||||
for i in 0..PRESET_COUNT {
|
||||
assert!(Avatar::Preset(i).preset_png().is_some(), "preset {i} missing");
|
||||
assert!(
|
||||
Avatar::Preset(i).preset_png().is_some(),
|
||||
"preset {i} missing"
|
||||
);
|
||||
}
|
||||
// Out-of-range index gracefully yields None (→ monogram fallback).
|
||||
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
|
||||
@@ -406,7 +412,10 @@ mod tests {
|
||||
#[test]
|
||||
fn sanitize_incoming_rejects_junk_and_oversize() {
|
||||
// Not valid base64 / not a PNG → downgraded to monogram.
|
||||
assert_eq!(Avatar::Custom("not base64!!!".into()).sanitize_incoming(), Avatar::Monogram);
|
||||
assert_eq!(
|
||||
Avatar::Custom("not base64!!!".into()).sanitize_incoming(),
|
||||
Avatar::Monogram
|
||||
);
|
||||
// Over the byte cap → downgraded without even decoding.
|
||||
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
|
||||
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
|
||||
|
||||
+4
-1
@@ -66,7 +66,10 @@ pub fn game_background_filename(game_id: &str) -> String {
|
||||
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||
/// per theme since `base` comes from the active palette.
|
||||
pub fn scrim_color(base: Color, dim: f32) -> Color {
|
||||
Color { a: dim.clamp(0.0, 1.0), ..base }
|
||||
Color {
|
||||
a: dim.clamp(0.0, 1.0),
|
||||
..base
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+58
-12
@@ -105,7 +105,11 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
|
||||
"pink" => generators::pink_noise(amp, len, seed),
|
||||
"impulse" => generators::impulse(amp, len),
|
||||
"silence" => generators::silence(len),
|
||||
other => return Err(format!("unknown kind {other:?} (sine sweep white pink impulse silence)")),
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown kind {other:?} (sine sweep white pink impulse silence)"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
wav::write(Path::new(out), &samples, SAMPLE_RATE)?;
|
||||
@@ -125,8 +129,12 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
|
||||
/// in which frequency range any residual lives.
|
||||
fn cmd_erle(args: &[String]) -> Result<(), String> {
|
||||
let (positional, flags) = parse_args(args);
|
||||
let before = positional.first().ok_or("erle needs <before.wav> <after.wav>")?;
|
||||
let after = positional.get(1).ok_or("erle needs <before.wav> <after.wav>")?;
|
||||
let before = positional
|
||||
.first()
|
||||
.ok_or("erle needs <before.wav> <after.wav>")?;
|
||||
let after = positional
|
||||
.get(1)
|
||||
.ok_or("erle needs <before.wav> <after.wav>")?;
|
||||
|
||||
let b = wav::read(Path::new(before))?;
|
||||
let a = wav::read(Path::new(after))?;
|
||||
@@ -239,17 +247,34 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
|
||||
1000.0 * tail as f32 / sr as f32,
|
||||
metrics::dbfs(atten),
|
||||
);
|
||||
println!(" filter: {taps} taps, mu {mu}{}", if has_near { " (with near-end / double-talk)" } else { "" });
|
||||
println!(
|
||||
" filter: {taps} taps, mu {mu}{}",
|
||||
if has_near {
|
||||
" (with near-end / double-talk)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
if has_near {
|
||||
let dtd = if flags.present("no-dtd") { "off" } else { "on" };
|
||||
println!(
|
||||
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
|
||||
100.0 * canceller.double_talk_rate(),
|
||||
if onset > 0 { format!(", near-end onset {:.1}s", onset as f32 / sr as f32) } else { String::new() },
|
||||
if onset > 0 {
|
||||
format!(", near-end onset {:.1}s", onset as f32 / sr as f32)
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
);
|
||||
}
|
||||
println!(" mic before: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&mic)));
|
||||
println!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual)));
|
||||
println!(
|
||||
" mic before: {:.1} dBFS rms",
|
||||
metrics::dbfs(metrics::rms(&mic))
|
||||
);
|
||||
println!(
|
||||
" residual echo after: {:.1} dBFS rms",
|
||||
metrics::dbfs(metrics::rms(&residual))
|
||||
);
|
||||
println!(" ERLE broadband: {broadband:+.1} dB");
|
||||
println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)");
|
||||
|
||||
@@ -278,9 +303,21 @@ fn cmd_aec(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
if flags.present("show") {
|
||||
println!("\n--- mic (echo present) ---");
|
||||
print!("{}", render::render(&stft::analyze(&mic, sr, 2048, 512), &render::RenderOpts::default()));
|
||||
print!(
|
||||
"{}",
|
||||
render::render(
|
||||
&stft::analyze(&mic, sr, 2048, 512),
|
||||
&render::RenderOpts::default()
|
||||
)
|
||||
);
|
||||
println!("\n--- cleaned (post-AEC) ---");
|
||||
print!("{}", render::render(&stft::analyze(&cleaned, sr, 2048, 512), &render::RenderOpts::default()));
|
||||
print!(
|
||||
"{}",
|
||||
render::render(
|
||||
&stft::analyze(&cleaned, sr, 2048, 512),
|
||||
&render::RenderOpts::default()
|
||||
)
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -339,13 +376,22 @@ impl Flags {
|
||||
self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
|
||||
}
|
||||
fn f32_or(&self, key: &str, default: f32) -> f32 {
|
||||
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
self.map
|
||||
.get(key)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
fn usize_or(&self, key: &str, default: usize) -> usize {
|
||||
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
self.map
|
||||
.get(key)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
fn u64_or(&self, key: &str, default: u64) -> u64 {
|
||||
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||
self.map
|
||||
.get(key)
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-7
@@ -1,9 +1,6 @@
|
||||
use peerspeak::network::{
|
||||
gossip::IrohGossipState,
|
||||
RoomState, PeerState,
|
||||
};
|
||||
use iroh::{Endpoint, endpoint::presets};
|
||||
use iroh_gossip::net::Gossip;
|
||||
use peerspeak::network::{PeerState, RoomState, gossip::IrohGossipState};
|
||||
use tokio::time::{self, Duration};
|
||||
|
||||
#[tokio::main]
|
||||
@@ -18,7 +15,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.address_lookup(lookup_a.clone())
|
||||
.bind()
|
||||
.await?;
|
||||
|
||||
|
||||
endpoint_a.online().await;
|
||||
println!("Node A online. ID: {}", endpoint_a.id());
|
||||
|
||||
@@ -27,7 +24,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
|
||||
.spawn();
|
||||
|
||||
let room_a = IrohGossipState::new(endpoint_a.clone(), gossip_a.clone(), lookup_a.clone(), secret_a);
|
||||
let room_a = IrohGossipState::new(
|
||||
endpoint_a.clone(),
|
||||
gossip_a.clone(),
|
||||
lookup_a.clone(),
|
||||
secret_a,
|
||||
);
|
||||
|
||||
// 2. Node B (Client) Setup
|
||||
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
|
||||
@@ -46,7 +48,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
|
||||
.spawn();
|
||||
|
||||
let room_b = IrohGossipState::new(endpoint_b.clone(), gossip_b.clone(), lookup_b.clone(), secret_b);
|
||||
let room_b = IrohGossipState::new(
|
||||
endpoint_b.clone(),
|
||||
gossip_b.clone(),
|
||||
lookup_b.clone(),
|
||||
secret_b,
|
||||
);
|
||||
|
||||
// 3. Create room on Node A
|
||||
let topic_id = rand::random();
|
||||
|
||||
+63
-17
@@ -1,5 +1,5 @@
|
||||
use crate::codec::{AudioEncoder, AudioDecoder, CodecError};
|
||||
use opus::{Encoder, Decoder, Application, Channels};
|
||||
use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
|
||||
use opus::{Application, Channels, Decoder, Encoder};
|
||||
|
||||
pub struct OpusEncoder {
|
||||
encoder: Encoder,
|
||||
@@ -8,7 +8,11 @@ pub struct OpusEncoder {
|
||||
impl OpusEncoder {
|
||||
/// Creates a new Opus encoder.
|
||||
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip
|
||||
pub fn new(sample_rate: u32, channels: Channels, application: Application) -> Result<Self, CodecError> {
|
||||
pub fn new(
|
||||
sample_rate: u32,
|
||||
channels: Channels,
|
||||
application: Application,
|
||||
) -> Result<Self, CodecError> {
|
||||
let encoder = Encoder::new(sample_rate, channels, application)
|
||||
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
|
||||
Ok(Self { encoder })
|
||||
@@ -20,9 +24,11 @@ impl AudioEncoder for OpusEncoder {
|
||||
// We allocate a buffer for the compressed output.
|
||||
// A maximum packet size of 4000 bytes is more than enough for a single voice frame.
|
||||
let mut compressed = vec![0u8; 4000];
|
||||
let len = self.encoder.encode(pcm, &mut compressed)
|
||||
let len = self
|
||||
.encoder
|
||||
.encode(pcm, &mut compressed)
|
||||
.map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?;
|
||||
|
||||
|
||||
compressed.truncate(len);
|
||||
Ok(compressed)
|
||||
}
|
||||
@@ -42,10 +48,18 @@ impl OpusDecoder {
|
||||
/// Creates a new Opus decoder.
|
||||
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
|
||||
/// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960).
|
||||
pub fn new(sample_rate: u32, channels: Channels, frame_samples: usize) -> Result<Self, CodecError> {
|
||||
pub fn new(
|
||||
sample_rate: u32,
|
||||
channels: Channels,
|
||||
frame_samples: usize,
|
||||
) -> Result<Self, CodecError> {
|
||||
let decoder = Decoder::new(sample_rate, channels)
|
||||
.map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?;
|
||||
Ok(Self { decoder, channels, frame_samples })
|
||||
Ok(Self {
|
||||
decoder,
|
||||
channels,
|
||||
frame_samples,
|
||||
})
|
||||
}
|
||||
|
||||
fn channels_count(&self) -> usize {
|
||||
@@ -73,7 +87,9 @@ impl AudioDecoder for OpusDecoder {
|
||||
}
|
||||
};
|
||||
|
||||
let decoded_per_channel = self.decoder.decode(input, &mut pcm, false)
|
||||
let decoded_per_channel = self
|
||||
.decoder
|
||||
.decode(input, &mut pcm, false)
|
||||
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
|
||||
|
||||
pcm.truncate(decoded_per_channel * channels_count);
|
||||
@@ -100,7 +116,10 @@ mod tests {
|
||||
|
||||
// encode it
|
||||
let compressed = encoder.encode(&pcm).unwrap();
|
||||
assert!(!compressed.is_empty(), "Compressed buffer should not be empty");
|
||||
assert!(
|
||||
!compressed.is_empty(),
|
||||
"Compressed buffer should not be empty"
|
||||
);
|
||||
assert!(
|
||||
compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
|
||||
"Compressed size ({}) should be smaller than raw PCM size ({})",
|
||||
@@ -110,13 +129,21 @@ mod tests {
|
||||
|
||||
// decode it
|
||||
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
||||
assert_eq!(decoded.len(), 960, "Decoded sample count should be exactly 960");
|
||||
assert_eq!(
|
||||
decoded.len(),
|
||||
960,
|
||||
"Decoded sample count should be exactly 960"
|
||||
);
|
||||
|
||||
// 2. Round-trip carries signal energy (not silence)
|
||||
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
|
||||
let rms = (sum_sq / decoded.len() as f64).sqrt();
|
||||
// Since input had amplitude ~10000, let's verify RMS is significantly above 0 (e.g. > 100.0)
|
||||
assert!(rms > 100.0, "Decoded signal should carry energy (RMS was {})", rms);
|
||||
assert!(
|
||||
rms > 100.0,
|
||||
"Decoded signal should carry energy (RMS was {})",
|
||||
rms
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -125,11 +152,19 @@ mod tests {
|
||||
|
||||
// decode(None) returns exactly frame_samples (960) samples
|
||||
let plc_none = decoder.decode(None).unwrap();
|
||||
assert_eq!(plc_none.len(), 960, "decode(None) should yield exactly 960 samples");
|
||||
assert_eq!(
|
||||
plc_none.len(),
|
||||
960,
|
||||
"decode(None) should yield exactly 960 samples"
|
||||
);
|
||||
|
||||
// decode(Some(&[])) (empty slice) does the same
|
||||
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
||||
assert_eq!(plc_empty.len(), 960, "decode(Some(&[])) should yield exactly 960 samples");
|
||||
assert_eq!(
|
||||
plc_empty.len(),
|
||||
960,
|
||||
"decode(Some(&[])) should yield exactly 960 samples"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -140,7 +175,11 @@ mod tests {
|
||||
let pcm = vec![0i16; 960];
|
||||
let compressed = encoder.encode(&pcm).unwrap();
|
||||
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
||||
assert_eq!(decoded.len(), 960, "Decoded sample count should match packet duration");
|
||||
assert_eq!(
|
||||
decoded.len(),
|
||||
960,
|
||||
"Decoded sample count should match packet duration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -149,11 +188,18 @@ mod tests {
|
||||
|
||||
// decode(None) returns exactly frame_samples * 2 (1920) samples
|
||||
let plc_none = decoder.decode(None).unwrap();
|
||||
assert_eq!(plc_none.len(), 960 * 2, "Stereo decode(None) should yield exactly 1920 samples");
|
||||
assert_eq!(
|
||||
plc_none.len(),
|
||||
960 * 2,
|
||||
"Stereo decode(None) should yield exactly 1920 samples"
|
||||
);
|
||||
|
||||
// decode(Some(&[])) (empty slice) does the same
|
||||
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
||||
assert_eq!(plc_empty.len(), 960 * 2, "Stereo decode(Some(&[])) should yield exactly 1920 samples");
|
||||
assert_eq!(
|
||||
plc_empty.len(),
|
||||
960 * 2,
|
||||
"Stereo decode(Some(&[])) should yield exactly 1920 samples"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+269
-31
@@ -1,9 +1,12 @@
|
||||
use crate::notify::Sound;
|
||||
use crate::theme::AppTheme;
|
||||
use anyhow::Context;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Relay/discovery posture, trading connectivity against how much the n0
|
||||
/// infrastructure learns about you. See the network module for details.
|
||||
@@ -24,8 +27,11 @@ pub enum NetworkMode {
|
||||
|
||||
impl NetworkMode {
|
||||
/// All variants, for presentation in a picker.
|
||||
pub const ALL: [NetworkMode; 3] =
|
||||
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
|
||||
pub const ALL: [NetworkMode; 3] = [
|
||||
NetworkMode::RelayNoDiscovery,
|
||||
NetworkMode::N0Full,
|
||||
NetworkMode::DirectOnly,
|
||||
];
|
||||
}
|
||||
|
||||
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
||||
@@ -42,8 +48,11 @@ pub enum RoomLayout {
|
||||
|
||||
impl RoomLayout {
|
||||
/// All variants, in picker display order.
|
||||
pub const ALL: [RoomLayout; 3] =
|
||||
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
|
||||
pub const ALL: [RoomLayout; 3] = [
|
||||
RoomLayout::ThreeColumn,
|
||||
RoomLayout::BottomDock,
|
||||
RoomLayout::Drawer,
|
||||
];
|
||||
}
|
||||
|
||||
/// What a call recording captures. `Mixed` is the original single-file behaviour;
|
||||
@@ -62,8 +71,11 @@ pub enum RecordingMode {
|
||||
|
||||
impl RecordingMode {
|
||||
/// All variants, in picker display order.
|
||||
pub const ALL: [RecordingMode; 3] =
|
||||
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both];
|
||||
pub const ALL: [RecordingMode; 3] = [
|
||||
RecordingMode::Mixed,
|
||||
RecordingMode::Multitrack,
|
||||
RecordingMode::Both,
|
||||
];
|
||||
|
||||
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
|
||||
pub fn is_multitrack(self) -> bool {
|
||||
@@ -138,6 +150,10 @@ fn default_chat_drawer_width() -> f32 {
|
||||
320.0
|
||||
}
|
||||
|
||||
fn default_playlist_drawer_width() -> f32 {
|
||||
320.0
|
||||
}
|
||||
|
||||
fn default_window_width() -> f32 {
|
||||
900.0
|
||||
}
|
||||
@@ -174,6 +190,9 @@ pub struct AppConfig {
|
||||
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
|
||||
#[serde(default)]
|
||||
pub music_broadcast: bool,
|
||||
/// Show the slim now-playing player bar in the room screen.
|
||||
#[serde(default = "default_true")]
|
||||
pub show_player_bar: bool,
|
||||
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||
/// its own (in-memory) level and the universal slider is inactive.
|
||||
#[serde(default = "default_true")]
|
||||
@@ -207,6 +226,9 @@ pub struct AppConfig {
|
||||
/// Chat drawer width for the drawer layout (px).
|
||||
#[serde(default = "default_chat_drawer_width")]
|
||||
pub chat_drawer_width: f32,
|
||||
/// Playlist drawer width for the room-screen right-edge music panel (px).
|
||||
#[serde(default = "default_playlist_drawer_width")]
|
||||
pub playlist_drawer_width: f32,
|
||||
/// Chosen arrangement of the in-call room screen.
|
||||
#[serde(default)]
|
||||
pub room_layout: RoomLayout,
|
||||
@@ -335,6 +357,13 @@ pub struct AppConfig {
|
||||
pub window_y: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LoadOutcome {
|
||||
Missing,
|
||||
Loaded,
|
||||
Recovered,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -348,6 +377,7 @@ impl Default for AppConfig {
|
||||
music_playlist: Vec::new(),
|
||||
music_volume: 1.0,
|
||||
music_broadcast: false,
|
||||
show_player_bar: true,
|
||||
clip_volume_universal: true,
|
||||
network_mode: NetworkMode::default(),
|
||||
presence_mode: crate::presence::PresenceMode::default(),
|
||||
@@ -358,6 +388,7 @@ impl Default for AppConfig {
|
||||
threecol_playlist_height: default_threecol_playlist_height(),
|
||||
controls_width: default_controls_width(),
|
||||
chat_drawer_width: default_chat_drawer_width(),
|
||||
playlist_drawer_width: default_playlist_drawer_width(),
|
||||
room_layout: RoomLayout::default(),
|
||||
theme: AppTheme::default(),
|
||||
avatar: crate::avatar::Avatar::default(),
|
||||
@@ -463,24 +494,115 @@ impl AppConfig {
|
||||
}
|
||||
|
||||
pub fn load() -> Self {
|
||||
if let Some(path) = Self::config_path()
|
||||
&& let Ok(contents) = fs::read_to_string(&path)
|
||||
&& let Ok(config) = serde_json::from_str(&contents) {
|
||||
return config;
|
||||
}
|
||||
Self::default()
|
||||
let Some(path) = Self::config_path() else {
|
||||
return Self::default();
|
||||
};
|
||||
let (config, _) = Self::load_from(&path);
|
||||
config
|
||||
}
|
||||
|
||||
pub fn save(&self) {
|
||||
if let Some(path) = Self::config_path() {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = fs::create_dir_all(dir);
|
||||
if let Err(e) = self.save_to(&path) {
|
||||
crate::log_msg(&format!("config: save failed: {e:#}"));
|
||||
}
|
||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
||||
let _ = fs::write(path, json);
|
||||
} else {
|
||||
crate::log_msg("config: save failed: could not determine a config directory");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_from(path: &Path) -> (Self, LoadOutcome) {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(contents) => match serde_json::from_str(&contents) {
|
||||
Ok(config) => (config, LoadOutcome::Loaded),
|
||||
Err(e) => {
|
||||
let backup = recover_corrupt_config(path, &format!("failed to parse: {e}"));
|
||||
(Self::default(), backup)
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
(Self::default(), LoadOutcome::Missing)
|
||||
}
|
||||
Err(e) => {
|
||||
let backup = recover_corrupt_config(path, &format!("failed to read: {e}"));
|
||||
(Self::default(), backup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_to(&self, path: &Path) -> anyhow::Result<()> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("config path has no parent directory")?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let json = serde_json::to_string_pretty(self).context("failed to encode config")?;
|
||||
let tmp = config_tmp_path(path)?;
|
||||
let result = (|| -> anyhow::Result<()> {
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
f.write_all(json.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, path).with_context(|| {
|
||||
format!("failed to rename {} -> {}", tmp.display(), path.display())
|
||||
})?;
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn config_tmp_path(path: &Path) -> anyhow::Result<PathBuf> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("config path has no parent directory")?;
|
||||
let mut name = path
|
||||
.file_name()
|
||||
.context("config path has no file name")?
|
||||
.to_os_string();
|
||||
name.push(format!(".tmp.{}", std::process::id()));
|
||||
Ok(parent.join(name))
|
||||
}
|
||||
|
||||
fn corrupt_backup_path(path: &Path) -> PathBuf {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_os_string())
|
||||
.unwrap_or_else(|| "config.json".into());
|
||||
name.push(format!(".corrupt.{secs}"));
|
||||
parent.join(name)
|
||||
}
|
||||
|
||||
fn recover_corrupt_config(path: &Path, reason: &str) -> LoadOutcome {
|
||||
let backup = corrupt_backup_path(path);
|
||||
match fs::rename(path, &backup) {
|
||||
Ok(()) => {
|
||||
crate::log_msg(&format!(
|
||||
"config: {reason}; moved damaged config to {}",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_msg(&format!(
|
||||
"config: {reason}; failed to move damaged config to {}: {e}",
|
||||
backup.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
LoadOutcome::Recovered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -495,14 +617,109 @@ mod tests {
|
||||
assert_eq!(original, deserialized);
|
||||
}
|
||||
|
||||
fn temp_config_path(tag: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!(
|
||||
"peerspeak-configtest-{}-{}",
|
||||
std::process::id(),
|
||||
tag
|
||||
));
|
||||
p.push("config.json");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_to_then_load_from_round_trips() {
|
||||
let path = temp_config_path("roundtrip");
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||
let cfg = AppConfig {
|
||||
username: "Ada".into(),
|
||||
input_device: "mic".into(),
|
||||
output_device: "speaker".into(),
|
||||
noise_gate_threshold: 0.42,
|
||||
..AppConfig::default()
|
||||
};
|
||||
|
||||
cfg.save_to(&path).unwrap();
|
||||
let (loaded, outcome) = AppConfig::load_from(&path);
|
||||
|
||||
assert_eq!(outcome, LoadOutcome::Loaded);
|
||||
assert_eq!(loaded, cfg);
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_missing_returns_default_without_corrupt_backup() {
|
||||
let path = temp_config_path("missing");
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||
|
||||
let (loaded, outcome) = AppConfig::load_from(&path);
|
||||
|
||||
assert_eq!(outcome, LoadOutcome::Missing);
|
||||
assert_eq!(loaded, AppConfig::default());
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_from_corrupt_file_preserves_original_bytes() {
|
||||
let path = temp_config_path("corrupt");
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let corrupt = b"{ this is not json";
|
||||
fs::write(&path, corrupt).unwrap();
|
||||
|
||||
let (loaded, outcome) = AppConfig::load_from(&path);
|
||||
|
||||
assert_eq!(outcome, LoadOutcome::Recovered);
|
||||
assert_eq!(loaded, AppConfig::default());
|
||||
assert_ne!(fs::read(&path).ok().as_deref(), Some(corrupt.as_slice()));
|
||||
let backups: Vec<_> = fs::read_dir(path.parent().unwrap())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("config.json.corrupt."))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(backups.len(), 1, "expected one corrupt backup");
|
||||
assert_eq!(fs::read(&backups[0]).unwrap(), corrupt);
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_to_leaves_no_tmp_file_after_success() {
|
||||
let path = temp_config_path("atomic");
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||
|
||||
AppConfig::default().save_to(&path).unwrap();
|
||||
|
||||
let tmp_files: Vec<_> = fs::read_dir(path.parent().unwrap())
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.contains(".tmp."))
|
||||
})
|
||||
.collect();
|
||||
assert!(tmp_files.is_empty(), "leftover temp files: {tmp_files:?}");
|
||||
let _ = fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backward_compat_default_fill() {
|
||||
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||
let deserialized: AppConfig = serde_json::from_str(minimal_json).unwrap();
|
||||
|
||||
|
||||
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
|
||||
// Configs predating the presence posture load as friends-only (no beacon).
|
||||
assert_eq!(deserialized.presence_mode, crate::presence::PresenceMode::Normal);
|
||||
assert_eq!(
|
||||
deserialized.presence_mode,
|
||||
crate::presence::PresenceMode::Normal
|
||||
);
|
||||
assert!(!deserialized.echo_cancellation_enabled);
|
||||
assert!(deserialized.notifications_enabled);
|
||||
// Configs predating the volume sliders must load at unity gain.
|
||||
@@ -517,6 +734,8 @@ mod tests {
|
||||
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
||||
assert_eq!(deserialized.controls_width, 280.0);
|
||||
assert_eq!(deserialized.chat_drawer_width, 320.0);
|
||||
assert_eq!(deserialized.playlist_drawer_width, 320.0);
|
||||
assert!(deserialized.show_player_bar);
|
||||
assert!(deserialized.custom_sound_self_join.is_none());
|
||||
assert!(deserialized.custom_sound_peer_join.is_none());
|
||||
assert!(deserialized.custom_sound_peer_leave.is_none());
|
||||
@@ -528,7 +747,10 @@ mod tests {
|
||||
// Configs predating the per-sound flags (W6) enable every chime, so an
|
||||
// upgrade is silent-change-free.
|
||||
for sound in Sound::ALL {
|
||||
assert!(deserialized.sound_enabled(sound), "{sound:?} should default on");
|
||||
assert!(
|
||||
deserialized.sound_enabled(sound),
|
||||
"{sound:?} should default on"
|
||||
);
|
||||
}
|
||||
// The accessor and mutator agree round-trip.
|
||||
let mut cfg = AppConfig::default();
|
||||
@@ -575,7 +797,10 @@ mod tests {
|
||||
}"#;
|
||||
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
||||
// The pre-existing single background survives untouched (still Option<String>).
|
||||
assert_eq!(cfg.background.as_deref(), Some("/home/eric/.config/peerspeak/background.png"));
|
||||
assert_eq!(
|
||||
cfg.background.as_deref(),
|
||||
Some("/home/eric/.config/peerspeak/background.png")
|
||||
);
|
||||
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
||||
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
||||
assert!(!cfg.game_presence_enabled);
|
||||
@@ -587,9 +812,12 @@ mod tests {
|
||||
fn test_game_maps_serialize_deterministically() {
|
||||
// BTreeMap ordering makes the serialized config stable across runs.
|
||||
let mut cfg = AppConfig::default();
|
||||
cfg.game_backgrounds.insert("steam:730".into(), "/a.png".into());
|
||||
cfg.game_backgrounds.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||
cfg.game_process_map.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||
cfg.game_backgrounds
|
||||
.insert("steam:730".into(), "/a.png".into());
|
||||
cfg.game_backgrounds
|
||||
.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||
cfg.game_process_map
|
||||
.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
// Keys appear in sorted order (exe: before steam:).
|
||||
let bg = json.find("game_backgrounds").unwrap();
|
||||
@@ -647,8 +875,7 @@ mod tests {
|
||||
recording_mode: RecordingMode::Both,
|
||||
..AppConfig::default()
|
||||
};
|
||||
let back: AppConfig =
|
||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||
let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||
assert_eq!(back.recording_mode, RecordingMode::Both);
|
||||
// is_multitrack() classifies correctly.
|
||||
assert!(!RecordingMode::Mixed.is_multitrack());
|
||||
@@ -701,6 +928,7 @@ mod tests {
|
||||
assert!(def.music_playlist.is_empty());
|
||||
assert_eq!(def.music_volume, 1.0);
|
||||
assert!(!def.music_broadcast);
|
||||
assert!(def.show_player_bar);
|
||||
assert!(def.clip_volume_universal);
|
||||
|
||||
// Missing in JSON → unity (serde default).
|
||||
@@ -712,6 +940,7 @@ mod tests {
|
||||
assert!(cfg_missing.music_playlist.is_empty());
|
||||
assert_eq!(cfg_missing.music_volume, 1.0);
|
||||
assert!(!cfg_missing.music_broadcast);
|
||||
assert!(cfg_missing.show_player_bar);
|
||||
// Configs predating the toggle default to universal mode.
|
||||
assert!(cfg_missing.clip_volume_universal);
|
||||
|
||||
@@ -723,6 +952,7 @@ mod tests {
|
||||
music_playlist: vec!["/tmp/song.ogg".to_string()],
|
||||
music_volume: 0.6,
|
||||
music_broadcast: true,
|
||||
show_player_bar: false,
|
||||
clip_volume_universal: false,
|
||||
..AppConfig::default()
|
||||
};
|
||||
@@ -731,15 +961,20 @@ mod tests {
|
||||
assert_eq!(round_tripped.input_volume, 1.5);
|
||||
assert_eq!(round_tripped.output_volume, 0.25);
|
||||
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||
assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]);
|
||||
assert_eq!(
|
||||
round_tripped.music_playlist,
|
||||
vec!["/tmp/song.ogg".to_string()]
|
||||
);
|
||||
assert_eq!(round_tripped.music_volume, 0.6);
|
||||
assert!(round_tripped.music_broadcast);
|
||||
assert!(!round_tripped.show_player_bar);
|
||||
assert!(!round_tripped.clip_volume_universal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_notifications_enabled_specifically() {
|
||||
let missing_notifications = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||
let missing_notifications =
|
||||
r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
|
||||
assert!(config_missing.notifications_enabled);
|
||||
|
||||
@@ -795,11 +1030,14 @@ mod tests {
|
||||
"unrecognized_field_xyz_123": "some_value"
|
||||
}"#;
|
||||
let deserialized_res: Result<AppConfig, _> = serde_json::from_str(json_with_extra);
|
||||
|
||||
|
||||
// Assert that deserialization succeeds even with unrecognized/unknown fields.
|
||||
// This confirms that serde does not reject unknown fields (i.e. default behavior).
|
||||
assert!(deserialized_res.is_ok(), "Config deserialization failed when an unknown field was present");
|
||||
|
||||
assert!(
|
||||
deserialized_res.is_ok(),
|
||||
"Config deserialization failed when an unknown field was present"
|
||||
);
|
||||
|
||||
let config = deserialized_res.unwrap();
|
||||
assert_eq!(config.input_device, "");
|
||||
assert_eq!(config.output_device, "");
|
||||
|
||||
+268
-44
@@ -9,7 +9,15 @@ pub enum CoreCommand {
|
||||
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
|
||||
/// share ticket to join. `room_name` is the creator's chosen cosmetic label
|
||||
/// for a NEW room; it's ignored when joining (the label rides in the ticket).
|
||||
Join { name: String, ticket: String, room_name: String, input_device: Option<String>, output_device: Option<String>, echo_cancellation: bool, avatar: crate::avatar::Avatar },
|
||||
Join {
|
||||
name: String,
|
||||
ticket: String,
|
||||
room_name: String,
|
||||
input_device: Option<String>,
|
||||
output_device: Option<String>,
|
||||
echo_cancellation: bool,
|
||||
avatar: crate::avatar::Avatar,
|
||||
},
|
||||
Leave,
|
||||
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
||||
/// audio/screen-share work, close the persistent network stack, then ack with
|
||||
@@ -41,7 +49,10 @@ pub enum CoreCommand {
|
||||
/// Start/stop a standalone capture-only stream that reports the raw mic
|
||||
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
|
||||
/// Ignored while a room session is active (the in-call meter covers that).
|
||||
SetMicMonitor { enabled: bool, input_device: Option<String> },
|
||||
SetMicMonitor {
|
||||
enabled: bool,
|
||||
input_device: Option<String>,
|
||||
},
|
||||
/// Set the relay/discovery posture. Takes effect on the next room join,
|
||||
/// since the endpoint is (re)built then.
|
||||
SetNetworkMode(NetworkMode),
|
||||
@@ -56,22 +67,40 @@ pub enum CoreCommand {
|
||||
/// Send a chat message carrying a file attachment. The app has already read +
|
||||
/// capped the file and built the descriptor; core makes the bytes available
|
||||
/// on the file plane and broadcasts the descriptor.
|
||||
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> },
|
||||
SendChatFile {
|
||||
text: String,
|
||||
attachment: crate::files::ChatAttachment,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// Fetch a received attachment's bytes from its sender over the file plane
|
||||
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
||||
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
||||
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
|
||||
FetchAttachment {
|
||||
from: EndpointId,
|
||||
attachment: crate::files::ChatAttachment,
|
||||
},
|
||||
/// Register `data` as fetchable under `id` for room members (the current
|
||||
/// broadcast track). Called once per track when broadcasting.
|
||||
ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc<Vec<u8>> },
|
||||
ServeMusicTrack {
|
||||
id: crate::files::AttachmentId,
|
||||
data: std::sync::Arc<Vec<u8>>,
|
||||
},
|
||||
/// Drop a music blob that is no longer current-or-next.
|
||||
ForgetMusicTrack(crate::files::AttachmentId),
|
||||
/// Set (or clear) our broadcast music timeline and re-announce presence.
|
||||
SetMusicPresence(Option<crate::network::MusicPresence>),
|
||||
/// Fetch a source peer's current track bytes after tuning into them.
|
||||
FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
|
||||
FetchMusic {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
size: u64,
|
||||
},
|
||||
/// Fetch a source peer's advertised next track bytes before it becomes current.
|
||||
PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
|
||||
PrefetchMusic {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
size: u64,
|
||||
},
|
||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||
/// Sent at startup so screen-share can resolve the binary.
|
||||
SetPixelpassPath(Option<String>),
|
||||
@@ -84,7 +113,9 @@ pub enum CoreCommand {
|
||||
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
||||
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
||||
/// whole desktop audio (the legacy behavior).
|
||||
StartScreenShare { audio_app: Option<String> },
|
||||
StartScreenShare {
|
||||
audio_app: Option<String>,
|
||||
},
|
||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||
/// ticket. No-op when not sharing.
|
||||
StopScreenShare,
|
||||
@@ -98,11 +129,19 @@ 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.
|
||||
@@ -126,6 +165,17 @@ pub enum DeliveryClass {
|
||||
BestEffort,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum CoalesceKey {
|
||||
InputVolume,
|
||||
OutputVolume,
|
||||
NoiseGate,
|
||||
PeerVolume(EndpointId),
|
||||
PeerPan(EndpointId),
|
||||
PeerGate(EndpointId),
|
||||
PeerEq(EndpointId),
|
||||
}
|
||||
|
||||
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
||||
/// actions are Reliable (must land). The only high-frequency commands are the
|
||||
/// continuous audio sliders, where dropping intermediate values is harmless;
|
||||
@@ -174,10 +224,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
from: _,
|
||||
attachment: _,
|
||||
}
|
||||
| CoreCommand::ServeMusicTrack {
|
||||
id: _,
|
||||
data: _,
|
||||
}
|
||||
| CoreCommand::ServeMusicTrack { id: _, data: _ }
|
||||
| CoreCommand::ForgetMusicTrack(_)
|
||||
| CoreCommand::SetMusicPresence(_)
|
||||
| CoreCommand::FetchMusic {
|
||||
@@ -203,6 +250,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
}
|
||||
| CoreCommand::RemoveFriend(_)
|
||||
| CoreCommand::RenameFriend(_, _)
|
||||
| CoreCommand::RefreshFriends
|
||||
| CoreCommand::SetPresenceMode(_)
|
||||
| CoreCommand::SetGamePresenceEnabled(_)
|
||||
| CoreCommand::SetGameOverride(_)
|
||||
@@ -210,57 +258,188 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||
}
|
||||
}
|
||||
|
||||
/// Coalescing bucket for high-frequency continuous controls. A key exists
|
||||
/// exactly for [`DeliveryClass::BestEffort`] commands.
|
||||
pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
||||
match cmd {
|
||||
CoreCommand::SetPeerVolume(peer_id, _) => Some(CoalesceKey::PeerVolume(*peer_id)),
|
||||
CoreCommand::SetPeerPan(peer_id, _) => Some(CoalesceKey::PeerPan(*peer_id)),
|
||||
CoreCommand::SetPeerGate(peer_id, _) => Some(CoalesceKey::PeerGate(*peer_id)),
|
||||
CoreCommand::SetPeerEq(peer_id, _) => Some(CoalesceKey::PeerEq(*peer_id)),
|
||||
CoreCommand::SetInputVolume(_) => Some(CoalesceKey::InputVolume),
|
||||
CoreCommand::SetOutputVolume(_) => Some(CoalesceKey::OutputVolume),
|
||||
CoreCommand::SetNoiseGateThreshold(_) => Some(CoalesceKey::NoiseGate),
|
||||
|
||||
CoreCommand::Join {
|
||||
name: _,
|
||||
ticket: _,
|
||||
room_name: _,
|
||||
input_device: _,
|
||||
output_device: _,
|
||||
echo_cancellation: _,
|
||||
avatar: _,
|
||||
}
|
||||
| CoreCommand::Leave
|
||||
| CoreCommand::Shutdown
|
||||
| CoreCommand::ToggleMute
|
||||
| CoreCommand::SetAvatar(_)
|
||||
| CoreCommand::ToggleDeafen
|
||||
| CoreCommand::SetPttMode(_)
|
||||
| CoreCommand::SetPttActive(_)
|
||||
| CoreCommand::SetPeerMuted(_, _)
|
||||
| CoreCommand::SetMicMonitor {
|
||||
enabled: _,
|
||||
input_device: _,
|
||||
}
|
||||
| CoreCommand::SetNetworkMode(_)
|
||||
| CoreCommand::SetRecording(_)
|
||||
| CoreCommand::SetRecordingMode(_)
|
||||
| CoreCommand::SendChat(_)
|
||||
| CoreCommand::SendChatFile {
|
||||
text: _,
|
||||
attachment: _,
|
||||
data: _,
|
||||
}
|
||||
| CoreCommand::FetchAttachment {
|
||||
from: _,
|
||||
attachment: _,
|
||||
}
|
||||
| CoreCommand::ServeMusicTrack { id: _, data: _ }
|
||||
| CoreCommand::ForgetMusicTrack(_)
|
||||
| CoreCommand::SetMusicPresence(_)
|
||||
| CoreCommand::FetchMusic {
|
||||
from: _,
|
||||
id: _,
|
||||
size: _,
|
||||
}
|
||||
| CoreCommand::PrefetchMusic {
|
||||
from: _,
|
||||
id: _,
|
||||
size: _,
|
||||
}
|
||||
| CoreCommand::SetPixelpassPath(_)
|
||||
| CoreCommand::ListAudioApps
|
||||
| CoreCommand::StartScreenShare { audio_app: _ }
|
||||
| CoreCommand::StopScreenShare
|
||||
| CoreCommand::ViewShare(_)
|
||||
| CoreCommand::RegenerateIdentity
|
||||
| CoreCommand::AddFriend {
|
||||
id: _,
|
||||
name: _,
|
||||
addr: _,
|
||||
}
|
||||
| CoreCommand::RemoveFriend(_)
|
||||
| CoreCommand::RenameFriend(_, _)
|
||||
| CoreCommand::RefreshFriends
|
||||
| CoreCommand::SetPresenceMode(_)
|
||||
| CoreCommand::SetGamePresenceEnabled(_)
|
||||
| CoreCommand::SetGameOverride(_)
|
||||
| CoreCommand::SetGameProcessMap(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UiEvent {
|
||||
RoomJoined { ticket: String, self_id: String },
|
||||
RoomJoined {
|
||||
ticket: String,
|
||||
self_id: String,
|
||||
},
|
||||
RoomLeft,
|
||||
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
||||
/// leave chime. The persistent identity remains unchanged.
|
||||
RoomReset,
|
||||
PeerJoined { id: EndpointId, state: PeerState },
|
||||
PeerLeft { id: EndpointId },
|
||||
PeerJoined {
|
||||
id: EndpointId,
|
||||
state: PeerState,
|
||||
},
|
||||
PeerLeft {
|
||||
id: EndpointId,
|
||||
},
|
||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||
/// has started. This is non-terminal and must not play the failure chime.
|
||||
PeerRecoveryStarted { id: EndpointId },
|
||||
PeerConnectionFailed { id: EndpointId },
|
||||
PeerUpdated { id: EndpointId, state: PeerState },
|
||||
PeerRecoveryStarted {
|
||||
id: EndpointId,
|
||||
},
|
||||
PeerConnectionFailed {
|
||||
id: EndpointId,
|
||||
},
|
||||
PeerUpdated {
|
||||
id: EndpointId,
|
||||
state: PeerState,
|
||||
},
|
||||
/// Audio link to a peer is being (re)established — show a connecting state.
|
||||
PeerConnecting { id: EndpointId },
|
||||
PeerConnecting {
|
||||
id: EndpointId,
|
||||
},
|
||||
/// Audio link to a peer is up and carrying audio.
|
||||
PeerConnected { id: EndpointId },
|
||||
PeerConnected {
|
||||
id: EndpointId,
|
||||
},
|
||||
AudioLevels(Vec<(EndpointId, f32)>),
|
||||
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
||||
/// for the settings level meter. Throttled to ~10/sec.
|
||||
MicLevel(f32),
|
||||
/// Call recording started; carries the absolute WAV path being written.
|
||||
RecordingStarted { path: String },
|
||||
RecordingStarted {
|
||||
path: String,
|
||||
},
|
||||
/// Call recording stopped; carries the finished WAV path.
|
||||
RecordingStopped { path: String },
|
||||
RecordingStopped {
|
||||
path: String,
|
||||
},
|
||||
/// A room text-chat message arrived from a peer (never our own — local
|
||||
/// messages are echoed by the UI on send). `from` is the sender's node id
|
||||
/// string, used to key their avatar (W4).
|
||||
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
|
||||
ChatMessage {
|
||||
from: String,
|
||||
name: String,
|
||||
text: String,
|
||||
attachment: Option<crate::files::ChatAttachment>,
|
||||
},
|
||||
/// An attachment's bytes are now available (auto-fetched for images, or
|
||||
/// fetched on demand for files). Keyed by `(from, id)`: the id is
|
||||
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
|
||||
/// disambiguates whose bytes these are and stops content aliasing (Tier C
|
||||
/// F-12).
|
||||
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||
AttachmentReady {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
||||
AttachmentFailed {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
error: String,
|
||||
},
|
||||
/// A tuned-in source's track bytes arrived; play them in the music sink.
|
||||
MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||
MusicReady {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
|
||||
MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
|
||||
MusicPrefetched {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
/// A music-track fetch failed (source gone, too large, etc.).
|
||||
MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
|
||||
MusicFetchFailed {
|
||||
from: EndpointId,
|
||||
id: crate::files::AttachmentId,
|
||||
error: String,
|
||||
},
|
||||
/// The apps currently producing audio, for the screen-share audio picker
|
||||
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
||||
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
||||
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
||||
AudioAppsListed { apps: Vec<String>, app_audio_supported: bool },
|
||||
AudioAppsListed {
|
||||
apps: Vec<String>,
|
||||
app_audio_supported: bool,
|
||||
},
|
||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||
ScreenShareStarted,
|
||||
/// Our own screen share stopped (or failed to start).
|
||||
@@ -273,29 +452,49 @@ pub enum UiEvent {
|
||||
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
||||
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
||||
/// sender-stamped timestamp relative to this machine's clock.
|
||||
ClockSkewWarning { skew_secs: u64, peer_ahead: bool },
|
||||
ClockSkewWarning {
|
||||
skew_secs: u64,
|
||||
peer_ahead: bool,
|
||||
},
|
||||
/// Our node identity (W7): the current node id string, and whether it is
|
||||
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
|
||||
/// `persisted = false` means the key file couldn't be read/written and we're
|
||||
/// running on an ephemeral fallback — a degraded state the UI must surface,
|
||||
/// since the id (and thus friend recognition) won't survive the next launch.
|
||||
/// `error` carries the reason when degraded, for the UI explainer.
|
||||
IdentityStatus { node_id: String, persisted: bool, error: Option<String> },
|
||||
IdentityStatus {
|
||||
node_id: String,
|
||||
persisted: bool,
|
||||
error: Option<String>,
|
||||
},
|
||||
/// The friends list (W7), now owned by core. Sent at startup (after load) and
|
||||
/// after every add/remove/rename so the GUI renders from this snapshot instead
|
||||
/// of owning the store. `read_only` is true when `friends.json` failed to load
|
||||
/// (malformed) — the GUI shows a degraded warning and disables edits so we never
|
||||
/// overwrite the damaged file (backlog A16).
|
||||
FriendsUpdated { friends: Vec<Friend>, read_only: bool },
|
||||
FriendsUpdated {
|
||||
friends: Vec<Friend>,
|
||||
read_only: bool,
|
||||
},
|
||||
/// A friend's live presence from a successful ping reply (W7): online, or in a
|
||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||
/// scheduler; absence of a recent event = treat as offline.
|
||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
||||
FriendPresence {
|
||||
id: EndpointId,
|
||||
presence: FriendPresence,
|
||||
},
|
||||
/// A manual "Rescan" pass finished (every friend has been probed and its
|
||||
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the
|
||||
/// transient "Rescanning…" status. Sent only for the on-demand button, not the
|
||||
/// periodic auto-refresh, so the status bar isn't churned every interval.
|
||||
FriendsRescanned,
|
||||
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||
PresenceModeReverted { mode: PresenceMode },
|
||||
PresenceModeReverted {
|
||||
mode: PresenceMode,
|
||||
},
|
||||
/// The locally-detected running game changed (game detection). Carries the
|
||||
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
|
||||
/// is detected. The GUI uses the stable `id` to switch the per-game background
|
||||
@@ -309,7 +508,7 @@ pub enum UiEvent {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{delivery_class, CoreCommand, DeliveryClass};
|
||||
use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class};
|
||||
use crate::audio::eq::EqSettings;
|
||||
use crate::presence::PresenceMode;
|
||||
use iroh::{EndpointId, SecretKey};
|
||||
@@ -322,17 +521,37 @@ mod tests {
|
||||
fn continuous_audio_controls_are_best_effort() {
|
||||
let peer = endpoint_id();
|
||||
let commands = [
|
||||
CoreCommand::SetPeerVolume(peer, 0.7),
|
||||
CoreCommand::SetPeerPan(peer, -0.2),
|
||||
CoreCommand::SetPeerGate(peer, 0.1),
|
||||
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
||||
CoreCommand::SetInputVolume(0.8),
|
||||
CoreCommand::SetOutputVolume(0.9),
|
||||
CoreCommand::SetNoiseGateThreshold(0.02),
|
||||
(
|
||||
CoreCommand::SetPeerVolume(peer, 0.7),
|
||||
CoalesceKey::PeerVolume(peer),
|
||||
),
|
||||
(
|
||||
CoreCommand::SetPeerPan(peer, -0.2),
|
||||
CoalesceKey::PeerPan(peer),
|
||||
),
|
||||
(
|
||||
CoreCommand::SetPeerGate(peer, 0.1),
|
||||
CoalesceKey::PeerGate(peer),
|
||||
),
|
||||
(
|
||||
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
||||
CoalesceKey::PeerEq(peer),
|
||||
),
|
||||
(CoreCommand::SetInputVolume(0.8), CoalesceKey::InputVolume),
|
||||
(CoreCommand::SetOutputVolume(0.9), CoalesceKey::OutputVolume),
|
||||
(
|
||||
CoreCommand::SetNoiseGateThreshold(0.02),
|
||||
CoalesceKey::NoiseGate,
|
||||
),
|
||||
];
|
||||
|
||||
for cmd in commands {
|
||||
for (cmd, key) in commands {
|
||||
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
||||
assert_eq!(coalesce_key(&cmd), Some(key));
|
||||
assert_eq!(
|
||||
coalesce_key(&cmd).is_some(),
|
||||
delivery_class(&cmd) == DeliveryClass::BestEffort
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +579,11 @@ mod tests {
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+606
-180
File diff suppressed because it is too large
Load Diff
+29
-7
@@ -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]
|
||||
|
||||
+4
-1
@@ -141,7 +141,10 @@ mod tests {
|
||||
buf[0] = Complex::new(1.0, 0.0);
|
||||
fft(&mut buf);
|
||||
for c in &buf {
|
||||
assert!(approx(c.magnitude(), 1.0, 1e-9), "expected flat 1.0, got {c:?}");
|
||||
assert!(
|
||||
approx(c.magnitude(), 1.0, 1e-9),
|
||||
"expected flat 1.0, got {c:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+25
-5
@@ -62,11 +62,31 @@ pub struct Band {
|
||||
|
||||
/// Voice-relevant bands for spotting *where* residual echo or noise lives.
|
||||
pub const VOICE_BANDS: &[Band] = &[
|
||||
Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0 },
|
||||
Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0 },
|
||||
Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0 },
|
||||
Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0 },
|
||||
Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.0 },
|
||||
Band {
|
||||
label: "low (80-300)",
|
||||
low_hz: 80.0,
|
||||
high_hz: 300.0,
|
||||
},
|
||||
Band {
|
||||
label: "low-mid (300-1k)",
|
||||
low_hz: 300.0,
|
||||
high_hz: 1000.0,
|
||||
},
|
||||
Band {
|
||||
label: "mid (1k-3k)",
|
||||
low_hz: 1000.0,
|
||||
high_hz: 3000.0,
|
||||
},
|
||||
Band {
|
||||
label: "high-mid (3k-6k)",
|
||||
low_hz: 3000.0,
|
||||
high_hz: 6000.0,
|
||||
},
|
||||
Band {
|
||||
label: "high (6k-12k)",
|
||||
low_hz: 6000.0,
|
||||
high_hz: 12000.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single
|
||||
|
||||
+7
-2
@@ -202,7 +202,8 @@ fn legend(opts: &RenderOpts) -> String {
|
||||
if opts.ascii {
|
||||
for i in 0..steps {
|
||||
let v = i as f32 / (steps - 1) as f32;
|
||||
let idx = ((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
|
||||
let idx =
|
||||
((v * (ASCII_RAMP.len() - 1) as f32).round() as usize).min(ASCII_RAMP.len() - 1);
|
||||
s.push(ASCII_RAMP[idx] as char);
|
||||
}
|
||||
} else {
|
||||
@@ -243,7 +244,11 @@ mod tests {
|
||||
fn render_produces_grid_of_expected_height() {
|
||||
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
|
||||
let spec = stft::analyze(&sig, 48_000, 1024, 512);
|
||||
let opts = RenderOpts { width: 40, height: 10, ..Default::default() };
|
||||
let opts = RenderOpts {
|
||||
width: 40,
|
||||
height: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let out = render(&spec, &opts);
|
||||
// Header + 10 body rows + time axis (2) + legend = non-trivial.
|
||||
let lines = out.lines().count();
|
||||
|
||||
+4
-1
@@ -94,7 +94,10 @@ mod tests {
|
||||
.unwrap()
|
||||
.0;
|
||||
let peak_hz = s.bin_hz(peak_bin);
|
||||
assert!((peak_hz - freq as f32).abs() < 100.0, "peak at {peak_hz} Hz, want {freq}");
|
||||
assert!(
|
||||
(peak_hz - freq as f32).abs() < 100.0,
|
||||
"peak at {peak_hz} Hz, want {freq}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+13
-3
@@ -34,7 +34,12 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
||||
let mut pos = 12usize;
|
||||
while pos + 8 <= bytes.len() {
|
||||
let id = &bytes[pos..pos + 4];
|
||||
let size = u32::from_le_bytes([bytes[pos + 4], bytes[pos + 5], bytes[pos + 6], bytes[pos + 7]]) as usize;
|
||||
let size = u32::from_le_bytes([
|
||||
bytes[pos + 4],
|
||||
bytes[pos + 5],
|
||||
bytes[pos + 6],
|
||||
bytes[pos + 7],
|
||||
]) as usize;
|
||||
let body_start = pos + 8;
|
||||
let body_end = (body_start + size).min(bytes.len());
|
||||
match id {
|
||||
@@ -45,7 +50,9 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
||||
sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
|
||||
bits = u16::from_le_bytes([fmt[14], fmt[15]]);
|
||||
if audio_format != 1 {
|
||||
return Err(format!("unsupported WAV format tag {audio_format} (need PCM=1)"));
|
||||
return Err(format!(
|
||||
"unsupported WAV format tag {audio_format} (need PCM=1)"
|
||||
));
|
||||
}
|
||||
}
|
||||
b"data" => {
|
||||
@@ -75,7 +82,10 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
||||
samples.push(avg / 32768.0);
|
||||
}
|
||||
|
||||
Ok(WavData { samples, sample_rate })
|
||||
Ok(WavData {
|
||||
samples,
|
||||
sample_rate,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by
|
||||
|
||||
+13
-7
@@ -76,10 +76,7 @@ pub fn sanitize_filename(raw: &str) -> String {
|
||||
.trim();
|
||||
|
||||
// Drop control chars; turn other whitespace into single spaces later.
|
||||
let cleaned: String = base
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect();
|
||||
let cleaned: String = base.chars().filter(|c| !c.is_control()).collect();
|
||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let collapsed = collapsed.trim_matches('.').trim();
|
||||
|
||||
@@ -145,7 +142,10 @@ pub fn looks_like_audio_name(name: &str) -> bool {
|
||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac")
|
||||
matches!(
|
||||
extension.to_ascii_lowercase().as_str(),
|
||||
"wav" | "mp3" | "ogg" | "oga" | "flac"
|
||||
)
|
||||
}
|
||||
|
||||
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
||||
@@ -240,7 +240,11 @@ mod tests {
|
||||
let long_stem = "x".repeat(200);
|
||||
let name = format!("{long_stem}.png");
|
||||
let out = sanitize_filename(&name);
|
||||
assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count());
|
||||
assert!(
|
||||
out.chars().count() <= MAX_FILENAME_LEN,
|
||||
"len was {}",
|
||||
out.chars().count()
|
||||
);
|
||||
assert!(out.ends_with(".png"), "extension preserved: {out}");
|
||||
}
|
||||
|
||||
@@ -254,7 +258,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn image_sniffing_recognizes_containers() {
|
||||
assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]));
|
||||
assert!(is_probably_image(&[
|
||||
0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0
|
||||
]));
|
||||
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
|
||||
assert!(is_probably_image(b"GIF89a...."));
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
|
||||
+22
-8
@@ -66,7 +66,11 @@ impl FriendStore {
|
||||
if self.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
self.friends.push(Friend { id, name, last_addr: addr });
|
||||
self.friends.push(Friend {
|
||||
id,
|
||||
name,
|
||||
last_addr: addr,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
@@ -118,21 +122,24 @@ pub fn friends_path() -> Option<PathBuf> {
|
||||
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
|
||||
/// overwritten with an empty list.
|
||||
pub fn load() -> Result<FriendStore> {
|
||||
let path = friends_path().context("could not determine a config directory for the friends list")?;
|
||||
let path =
|
||||
friends_path().context("could not determine a config directory for the friends list")?;
|
||||
load_at(&path)
|
||||
}
|
||||
|
||||
/// Save the store. Atomic via tempfile-in-same-dir + rename.
|
||||
pub fn save(store: &FriendStore) -> Result<()> {
|
||||
let path = friends_path().context("could not determine a config directory for the friends list")?;
|
||||
let path =
|
||||
friends_path().context("could not determine a config directory for the friends list")?;
|
||||
save_at(&path, store)
|
||||
}
|
||||
|
||||
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
|
||||
fn load_at(path: &Path) -> Result<FriendStore> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => serde_json::from_str(&s)
|
||||
.with_context(|| format!("failed to parse {}", path.display())),
|
||||
Ok(s) => {
|
||||
serde_json::from_str(&s).with_context(|| format!("failed to parse {}", path.display()))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
@@ -141,11 +148,14 @@ fn load_at(path: &Path) -> Result<FriendStore> {
|
||||
/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
|
||||
/// rename, so a crash mid-write can't leave a truncated list.
|
||||
fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
|
||||
let parent = path.parent().context("friends path has no parent directory")?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("friends path has no parent directory")?;
|
||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
let json = serde_json::to_string_pretty(store).context("failed to encode the friends list")?;
|
||||
let tmp = parent.join(format!(".friends.json.tmp.{}", std::process::id()));
|
||||
fs::write(&tmp, json.as_bytes()).with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
fs::write(&tmp, json.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
fs::rename(&tmp, path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
@@ -219,7 +229,11 @@ mod tests {
|
||||
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
|
||||
fn temp_path(tag: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("peerspeak-friendstest-{}-{}", std::process::id(), tag));
|
||||
p.push(format!(
|
||||
"peerspeak-friendstest-{}-{}",
|
||||
std::process::id(),
|
||||
tag
|
||||
));
|
||||
p.push("friends.json");
|
||||
p
|
||||
}
|
||||
|
||||
+45
-12
@@ -9,11 +9,9 @@
|
||||
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
||||
//! debounce is unit-tested without any I/O.
|
||||
|
||||
use super::{
|
||||
builtin_denylist, match_processes, resolve, Debouncer, DetectedGame, ManualOverride,
|
||||
};
|
||||
use super::scan;
|
||||
use super::steam::SteamProbe;
|
||||
use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve};
|
||||
use std::collections::BTreeMap;
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -145,9 +143,14 @@ fn worker_loop(
|
||||
let steam_game = steam.detect();
|
||||
let processes = scan::running_executables();
|
||||
|
||||
if let Some(new_current) =
|
||||
poll_once(&mut debouncer, &override_, steam_game, &processes, &process_map, &denylist)
|
||||
{
|
||||
if let Some(new_current) = poll_once(
|
||||
&mut debouncer,
|
||||
&override_,
|
||||
steam_game,
|
||||
&processes,
|
||||
&process_map,
|
||||
&denylist,
|
||||
) {
|
||||
// A closed receiver means core shut down; stop quietly.
|
||||
if tx.send(new_current).is_err() {
|
||||
return;
|
||||
@@ -169,11 +172,18 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
||||
DetectedGame { id: id.into(), name: Some(name.into()), source }
|
||||
DetectedGame {
|
||||
id: id.into(),
|
||||
name: Some(name.into()),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -185,17 +195,38 @@ mod tests {
|
||||
|
||||
// First poll: detected but not yet published (needs two hits).
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||
poll_once(
|
||||
&mut d,
|
||||
&ManualOverride::Auto,
|
||||
Some(steam.clone()),
|
||||
&[],
|
||||
&empty,
|
||||
&deny
|
||||
),
|
||||
None
|
||||
);
|
||||
// Second poll: published.
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(steam.clone()), &[], &empty, &deny),
|
||||
poll_once(
|
||||
&mut d,
|
||||
&ManualOverride::Auto,
|
||||
Some(steam.clone()),
|
||||
&[],
|
||||
&empty,
|
||||
&deny
|
||||
),
|
||||
Some(Some(steam))
|
||||
);
|
||||
// Third identical poll: no change event.
|
||||
assert_eq!(
|
||||
poll_once(&mut d, &ManualOverride::Auto, Some(game("steam:730", "CS2", GameSource::Steam)), &[], &empty, &deny),
|
||||
poll_once(
|
||||
&mut d,
|
||||
&ManualOverride::Auto,
|
||||
Some(game("steam:730", "CS2", GameSource::Steam)),
|
||||
&[],
|
||||
&empty,
|
||||
&deny
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -209,7 +240,9 @@ mod tests {
|
||||
|
||||
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||
let published = change.expect("should publish on second hit").expect("a game");
|
||||
let published = change
|
||||
.expect("should publish on second hit")
|
||||
.expect("a game");
|
||||
assert_eq!(published.id, "exe:hl2_linux");
|
||||
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
||||
}
|
||||
|
||||
+48
-17
@@ -92,11 +92,20 @@ pub fn resolve(
|
||||
processes: &[DetectedGame],
|
||||
) -> Resolution {
|
||||
match override_ {
|
||||
ManualOverride::ForceNone => Resolution { game: None, immediate: true },
|
||||
ManualOverride::Force(g) => Resolution { game: Some(g.clone()), immediate: true },
|
||||
ManualOverride::ForceNone => Resolution {
|
||||
game: None,
|
||||
immediate: true,
|
||||
},
|
||||
ManualOverride::Force(g) => Resolution {
|
||||
game: Some(g.clone()),
|
||||
immediate: true,
|
||||
},
|
||||
ManualOverride::Auto => {
|
||||
let game = steam.or_else(|| processes.first().cloned());
|
||||
Resolution { game, immediate: false }
|
||||
Resolution {
|
||||
game,
|
||||
immediate: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,7 +201,11 @@ impl Debouncer {
|
||||
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
||||
/// hypothetical `minecraft`), trims surrounding whitespace.
|
||||
pub fn normalize_exe(raw: &str) -> String {
|
||||
raw.rsplit(['/', '\\']).next().unwrap_or(raw).trim().to_lowercase()
|
||||
raw.rsplit(['/', '\\'])
|
||||
.next()
|
||||
.unwrap_or(raw)
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
||||
@@ -245,8 +258,10 @@ pub fn match_processes(
|
||||
denylist: &BTreeSet<&str>,
|
||||
) -> Vec<DetectedGame> {
|
||||
// Normalize the user map once so lookups are basename/case-insensitive.
|
||||
let normalized_map: BTreeMap<String, &String> =
|
||||
user_map.iter().map(|(k, v)| (normalize_exe(k), v)).collect();
|
||||
let normalized_map: BTreeMap<String, &String> = user_map
|
||||
.iter()
|
||||
.map(|(k, v)| (normalize_exe(k), v))
|
||||
.collect();
|
||||
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut out: Vec<DetectedGame> = Vec::new();
|
||||
@@ -288,8 +303,14 @@ mod tests {
|
||||
#[test]
|
||||
fn stable_ids_are_namespaced() {
|
||||
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
||||
assert_eq!(DetectedGame::exe_id("/usr/games/hl2_linux"), "exe:hl2_linux");
|
||||
assert_eq!(DetectedGame::exe_id("C:\\Games\\Minecraft.exe"), "exe:minecraft.exe");
|
||||
assert_eq!(
|
||||
DetectedGame::exe_id("/usr/games/hl2_linux"),
|
||||
"exe:hl2_linux"
|
||||
);
|
||||
assert_eq!(
|
||||
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
|
||||
"exe:minecraft.exe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -318,8 +339,16 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_falls_back_to_first_process_then_none() {
|
||||
let procs = vec![
|
||||
DetectedGame { id: "exe:a".into(), name: Some("A".into()), source: GameSource::Process },
|
||||
DetectedGame { id: "exe:b".into(), name: Some("B".into()), source: GameSource::Process },
|
||||
DetectedGame {
|
||||
id: "exe:a".into(),
|
||||
name: Some("A".into()),
|
||||
source: GameSource::Process,
|
||||
},
|
||||
DetectedGame {
|
||||
id: "exe:b".into(),
|
||||
name: Some("B".into()),
|
||||
source: GameSource::Process,
|
||||
},
|
||||
];
|
||||
let r = resolve(&ManualOverride::Auto, None, &procs);
|
||||
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
||||
@@ -426,7 +455,10 @@ mod tests {
|
||||
// --- process matching --------------------------------------------------
|
||||
|
||||
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -461,14 +493,13 @@ mod tests {
|
||||
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
||||
let deny = builtin_denylist();
|
||||
// Same game twice (two processes) + reverse discovery order.
|
||||
let running = vec![
|
||||
"/b/zed".into(),
|
||||
"/a/alpha".into(),
|
||||
"/c/alpha".into(),
|
||||
];
|
||||
let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
|
||||
let got = match_processes(&running, &user, &deny);
|
||||
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
||||
assert_eq!(got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(), vec!["exe:alpha", "exe:zed"]);
|
||||
assert_eq!(
|
||||
got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["exe:alpha", "exe:zed"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+12
-4
@@ -62,7 +62,7 @@ fn linux_proc_executables() -> Vec<String> {
|
||||
fn windows_toolhelp_executables() -> Vec<String> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||
TH32CS_SNAPPROCESS,
|
||||
};
|
||||
|
||||
@@ -78,7 +78,11 @@ fn windows_toolhelp_executables() -> Vec<String> {
|
||||
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||
while ok != 0 {
|
||||
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
|
||||
let end = entry.szExeFile.iter().position(|&c| c == 0).unwrap_or(entry.szExeFile.len());
|
||||
let end = entry
|
||||
.szExeFile
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(entry.szExeFile.len());
|
||||
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
|
||||
if !name.is_empty() {
|
||||
out.push(name);
|
||||
@@ -101,7 +105,10 @@ mod tests {
|
||||
// The test runner itself is a process, so /proc enumeration must be
|
||||
// non-empty and include something that normalizes to our own exe basename.
|
||||
let exes = running_executables();
|
||||
assert!(!exes.is_empty(), "expected to see running processes via /proc");
|
||||
assert!(
|
||||
!exes.is_empty(),
|
||||
"expected to see running processes via /proc"
|
||||
);
|
||||
// Our own /proc/self/exe basename should appear among them.
|
||||
let me = std::fs::read_link("/proc/self/exe")
|
||||
.ok()
|
||||
@@ -109,7 +116,8 @@ mod tests {
|
||||
if let Some(me) = me {
|
||||
let me_norm = super::super::normalize_exe(&me);
|
||||
assert!(
|
||||
exes.iter().any(|e| super::super::normalize_exe(e) == me_norm),
|
||||
exes.iter()
|
||||
.any(|e| super::super::normalize_exe(e) == me_norm),
|
||||
"running list should include our own executable {me_norm:?}"
|
||||
);
|
||||
}
|
||||
|
||||
+57
-20
@@ -25,8 +25,7 @@ const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn validate_reg_len(len: u32) -> Option<usize> {
|
||||
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES)
|
||||
.then_some(len as usize / 2)
|
||||
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
@@ -48,7 +47,14 @@ fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
||||
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
||||
let root = vdf::parse(registry_vdf).ok()?;
|
||||
let raw = root
|
||||
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||
.get_path(&[
|
||||
"Registry",
|
||||
"HKCU",
|
||||
"Software",
|
||||
"Valve",
|
||||
"Steam",
|
||||
"RunningAppID",
|
||||
])
|
||||
.and_then(Value::as_str)?;
|
||||
let id: u32 = raw.trim().parse().ok()?;
|
||||
(id != 0).then_some(id)
|
||||
@@ -196,7 +202,13 @@ impl SteamProbe {
|
||||
return cached.name.clone();
|
||||
}
|
||||
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
||||
self.manifests.insert(app_id, CachedManifest { mtime, name: name.clone() });
|
||||
self.manifests.insert(
|
||||
app_id,
|
||||
CachedManifest {
|
||||
mtime,
|
||||
name: name.clone(),
|
||||
},
|
||||
);
|
||||
name
|
||||
}
|
||||
|
||||
@@ -240,7 +252,11 @@ impl SteamProbe {
|
||||
paths.push(root.clone());
|
||||
}
|
||||
}
|
||||
self.libraries = CachedLibraries { source, mtime, paths: paths.clone() };
|
||||
self.libraries = CachedLibraries {
|
||||
source,
|
||||
mtime,
|
||||
paths: paths.clone(),
|
||||
};
|
||||
paths
|
||||
}
|
||||
}
|
||||
@@ -360,8 +376,8 @@ mod win {
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
RegCloseKey, RegOpenKeyExW, RegQueryValueExW, HKEY, HKEY_CURRENT_USER, KEY_READ,
|
||||
REG_DWORD, REG_SZ,
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
|
||||
RegQueryValueExW,
|
||||
};
|
||||
|
||||
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
||||
@@ -374,9 +390,8 @@ mod win {
|
||||
let subkey = wide("Software\\Valve\\Steam");
|
||||
let mut hkey: HKEY = std::ptr::null_mut();
|
||||
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
||||
let rc = unsafe {
|
||||
RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey)
|
||||
};
|
||||
let rc =
|
||||
unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
|
||||
(rc == ERROR_SUCCESS).then_some(hkey)
|
||||
}
|
||||
|
||||
@@ -463,13 +478,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn registry_string_lengths_are_bounded_and_trimmed() {
|
||||
assert_eq!(validate_reg_len(5), None, "odd byte lengths are invalid UTF-16");
|
||||
assert_eq!(
|
||||
validate_reg_len(5),
|
||||
None,
|
||||
"odd byte lengths are invalid UTF-16"
|
||||
);
|
||||
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
||||
assert_eq!(validate_reg_len(8), Some(4));
|
||||
|
||||
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
||||
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
||||
assert_eq!(decode_reg_sz(raw, returned_bytes).as_deref(), Some("C:\\Steam"));
|
||||
assert_eq!(
|
||||
decode_reg_sz(raw, returned_bytes).as_deref(),
|
||||
Some("C:\\Steam")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -495,10 +517,13 @@ mod tests {
|
||||
"contentstatsid" "12345"
|
||||
}"#;
|
||||
let got = parse_library_paths(current);
|
||||
assert_eq!(got, vec![
|
||||
PathBuf::from("/home/eric/.local/share/Steam"),
|
||||
PathBuf::from("/mnt/games/SteamLibrary"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
PathBuf::from("/home/eric/.local/share/Steam"),
|
||||
PathBuf::from("/mnt/games/SteamLibrary"),
|
||||
]
|
||||
);
|
||||
|
||||
// Legacy shape: numeric keys map straight to path strings.
|
||||
let legacy = r#""LibraryFolders" {
|
||||
@@ -522,13 +547,25 @@ mod tests {
|
||||
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
||||
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
||||
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"), None);
|
||||
assert_eq!(
|
||||
parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"),
|
||||
None
|
||||
);
|
||||
// Absent → None (a non-Steam process).
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"), None);
|
||||
assert_eq!(
|
||||
parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"),
|
||||
None
|
||||
);
|
||||
// Not fooled by a different var that merely contains the substring.
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"), None);
|
||||
assert_eq!(
|
||||
parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"),
|
||||
None
|
||||
);
|
||||
// Garbage value → None, no panic.
|
||||
assert_eq!(parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"), None);
|
||||
assert_eq!(
|
||||
parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+33
-7
@@ -234,10 +234,20 @@ mod tests {
|
||||
}
|
||||
"#;
|
||||
let root = parse(acf).unwrap();
|
||||
assert_eq!(root.get_path(&["AppState", "name"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||
assert_eq!(root.get_path(&["AppState", "appid"]).and_then(Value::as_str), Some("730"));
|
||||
assert_eq!(
|
||||
root.get_path(&["AppState", "name"]).and_then(Value::as_str),
|
||||
Some("Counter-Strike 2")
|
||||
);
|
||||
assert_eq!(
|
||||
root.get_path(&["AppState", "appid"])
|
||||
.and_then(Value::as_str),
|
||||
Some("730")
|
||||
);
|
||||
// Case-insensitive key lookup.
|
||||
assert_eq!(root.get_path(&["appstate", "NAME"]).and_then(Value::as_str), Some("Counter-Strike 2"));
|
||||
assert_eq!(
|
||||
root.get_path(&["appstate", "NAME"]).and_then(Value::as_str),
|
||||
Some("Counter-Strike 2")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -262,8 +272,14 @@ mod tests {
|
||||
"#;
|
||||
let root = parse(vdf).unwrap();
|
||||
let lf = root.get("libraryfolders").unwrap();
|
||||
assert_eq!(lf.get_path(&["0", "path"]).and_then(Value::as_str), Some(r"C:\Program Files (x86)\Steam"));
|
||||
assert_eq!(lf.get_path(&["1", "path"]).and_then(Value::as_str), Some("/home/eric/.local/share/Steam"));
|
||||
assert_eq!(
|
||||
lf.get_path(&["0", "path"]).and_then(Value::as_str),
|
||||
Some(r"C:\Program Files (x86)\Steam")
|
||||
);
|
||||
assert_eq!(
|
||||
lf.get_path(&["1", "path"]).and_then(Value::as_str),
|
||||
Some("/home/eric/.local/share/Steam")
|
||||
);
|
||||
// The library folder ids are iterable for discovery.
|
||||
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert_eq!(ids, vec!["0", "1"]);
|
||||
@@ -292,7 +308,14 @@ mod tests {
|
||||
"#;
|
||||
let root = parse(reg).unwrap();
|
||||
let appid = root
|
||||
.get_path(&["Registry", "HKCU", "Software", "Valve", "Steam", "RunningAppID"])
|
||||
.get_path(&[
|
||||
"Registry",
|
||||
"HKCU",
|
||||
"Software",
|
||||
"Valve",
|
||||
"Steam",
|
||||
"RunningAppID",
|
||||
])
|
||||
.and_then(Value::as_str);
|
||||
assert_eq!(appid, Some("570"));
|
||||
}
|
||||
@@ -301,7 +324,10 @@ mod tests {
|
||||
fn handles_comments_and_barewords() {
|
||||
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
||||
let root = parse(vdf).unwrap();
|
||||
assert_eq!(root.get_path(&["root", "barekey"]).and_then(Value::as_str), Some("barevalue"));
|
||||
assert_eq!(
|
||||
root.get_path(&["root", "barekey"]).and_then(Value::as_str),
|
||||
Some("barevalue")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+7
-4
@@ -89,9 +89,9 @@ impl HotkeyAction {
|
||||
|
||||
pub fn tier(self) -> HotkeyTier {
|
||||
match self {
|
||||
HotkeyAction::ToggleMute
|
||||
| HotkeyAction::ToggleDeafen
|
||||
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
|
||||
HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => {
|
||||
HotkeyTier::AppWide
|
||||
}
|
||||
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_single_character_case_folds() {
|
||||
assert_eq!(parse_binding("M"), Some(KeyBinding::Character("m".to_string())));
|
||||
assert_eq!(
|
||||
parse_binding("M"),
|
||||
Some(KeyBinding::Character("m".to_string()))
|
||||
);
|
||||
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
||||
}
|
||||
}
|
||||
|
||||
+11
-6
@@ -41,7 +41,8 @@ pub fn identity_path() -> Option<PathBuf> {
|
||||
/// A *missing* file (first ever run, or right after a reset) is the normal
|
||||
/// create path.
|
||||
pub fn load_or_create() -> Result<SecretKey> {
|
||||
let path = identity_path().context("could not determine a config directory for the identity key")?;
|
||||
let path =
|
||||
identity_path().context("could not determine a config directory for the identity key")?;
|
||||
load_or_create_at(&path)
|
||||
}
|
||||
|
||||
@@ -49,7 +50,8 @@ pub fn load_or_create() -> Result<SecretKey> {
|
||||
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and
|
||||
/// unrecoverable, so callers should confirm with the user first.
|
||||
pub fn regenerate() -> Result<SecretKey> {
|
||||
let path = identity_path().context("could not determine a config directory for the identity key")?;
|
||||
let path =
|
||||
identity_path().context("could not determine a config directory for the identity key")?;
|
||||
let key = SecretKey::generate();
|
||||
save_at(&path, &key)?;
|
||||
Ok(key)
|
||||
@@ -57,7 +59,8 @@ pub fn regenerate() -> Result<SecretKey> {
|
||||
|
||||
/// Atomic, `0600` write at the default identity path. See [`save_at`].
|
||||
pub fn save(key: &SecretKey) -> Result<()> {
|
||||
let path = identity_path().context("could not determine a config directory for the identity key")?;
|
||||
let path =
|
||||
identity_path().context("could not determine a config directory for the identity key")?;
|
||||
save_at(&path, key)
|
||||
}
|
||||
|
||||
@@ -80,13 +83,15 @@ fn load_or_create_at(path: &std::path::Path) -> Result<SecretKey> {
|
||||
/// perms are applied before the rename so the secret is never briefly
|
||||
/// world-readable.
|
||||
fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> {
|
||||
let parent = path.parent().context("identity path has no parent directory")?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("identity path has no parent directory")?;
|
||||
fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f =
|
||||
fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
+28
-21
@@ -1,27 +1,27 @@
|
||||
pub mod audio;
|
||||
pub mod codec;
|
||||
pub mod dsp;
|
||||
pub mod network;
|
||||
pub mod protocol;
|
||||
pub mod core;
|
||||
pub mod app;
|
||||
pub mod config;
|
||||
pub mod identity;
|
||||
pub mod friends;
|
||||
pub mod presence;
|
||||
pub mod presence_net;
|
||||
pub mod theme;
|
||||
pub mod notify;
|
||||
pub mod screenshare;
|
||||
pub mod sanitize;
|
||||
pub mod audio;
|
||||
pub mod avatar;
|
||||
pub mod background;
|
||||
pub mod recents;
|
||||
pub mod codec;
|
||||
pub mod config;
|
||||
pub mod core;
|
||||
pub mod discovery;
|
||||
pub mod hotkeys;
|
||||
pub mod dsp;
|
||||
pub mod files;
|
||||
pub mod playlist;
|
||||
pub mod friends;
|
||||
pub mod game;
|
||||
pub mod hotkeys;
|
||||
pub mod identity;
|
||||
pub mod network;
|
||||
pub mod notify;
|
||||
pub mod playlist;
|
||||
pub mod presence;
|
||||
pub mod presence_net;
|
||||
pub mod protocol;
|
||||
pub mod recents;
|
||||
pub mod sanitize;
|
||||
pub mod screenshare;
|
||||
pub mod theme;
|
||||
pub mod widget;
|
||||
|
||||
use std::fs::File;
|
||||
@@ -75,7 +75,8 @@ pub fn redact_for_log(value: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
||||
bytes.iter()
|
||||
bytes
|
||||
.iter()
|
||||
.take(6)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
@@ -83,7 +84,10 @@ pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
||||
}
|
||||
|
||||
fn rotated_log_path(path: &Path) -> PathBuf {
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log");
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("peerspeak.log");
|
||||
path.with_file_name(format!("{file_name}.1"))
|
||||
}
|
||||
|
||||
@@ -100,7 +104,10 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<F
|
||||
let rotated = rotated_log_path(path);
|
||||
let _ = std::fs::remove_file(&rotated);
|
||||
if std::fs::rename(path, &rotated).is_err() {
|
||||
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path);
|
||||
let _ = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+274
-95
@@ -1,14 +1,14 @@
|
||||
use crate::network::{RoomState, NetError, PeerState, RoomEvent, PeerSpeakTicket};
|
||||
use crate::network::{NetError, PeerSpeakTicket, PeerState, RoomEvent, RoomState};
|
||||
use async_trait::async_trait;
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, SecretKey, Signature, TransportAddr};
|
||||
use iroh_gossip::net::Gossip;
|
||||
use iroh_gossip::proto::TopicId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::{BTreeSet, HashMap, HashSet};
|
||||
use async_trait::async_trait;
|
||||
use tokio_stream::StreamExt;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Domain-separation tag mixed into every signed gossip payload so a signature
|
||||
/// can never be lifted out of this protocol/version into another context.
|
||||
@@ -78,7 +78,12 @@ fn signable_bytes(topic: &[u8; 32], author: &EndpointId, ts: u64, msg: &GossipMe
|
||||
fn sign_gossip(secret: &SecretKey, topic: &[u8; 32], ts: u64, msg: GossipMessage) -> GossipPayload {
|
||||
let author = secret.public();
|
||||
let sig = secret.sign(&signable_bytes(topic, &author, ts, &msg));
|
||||
GossipPayload { author, ts, msg, sig }
|
||||
GossipPayload {
|
||||
author,
|
||||
ts,
|
||||
msg,
|
||||
sig,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a received gossip payload was rejected (logging + tests).
|
||||
@@ -233,7 +238,10 @@ impl ClockSkewMonitor {
|
||||
}
|
||||
|
||||
fn drop_oldest_authors(&mut self) {
|
||||
let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
||||
let remove_count = self
|
||||
.authors
|
||||
.len()
|
||||
.saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
|
||||
let mut by_age: Vec<_> = self
|
||||
.authors
|
||||
.iter()
|
||||
@@ -339,7 +347,8 @@ fn verify_gossip(
|
||||
return Err(GossipReject::OutOfWindow);
|
||||
}
|
||||
if let GossipMessage::Announce(state) = &payload.msg
|
||||
&& state.addr.id != payload.author {
|
||||
&& state.addr.id != payload.author
|
||||
{
|
||||
return Err(GossipReject::AnnounceAddressMismatch);
|
||||
}
|
||||
Ok(())
|
||||
@@ -466,7 +475,8 @@ impl RoomState for IrohGossipState {
|
||||
// Make every dial target resolvable: the ticket host plus any retained
|
||||
// peers handed in (their addresses may have aged out of a fresh endpoint's
|
||||
// book even though the persistent lookup usually still holds them).
|
||||
self.address_lookup.add_endpoint_info(ticket.host_addr.clone());
|
||||
self.address_lookup
|
||||
.add_endpoint_info(ticket.host_addr.clone());
|
||||
for addr in &extra_bootstrap {
|
||||
self.address_lookup.add_endpoint_info(addr.clone());
|
||||
}
|
||||
@@ -478,7 +488,10 @@ impl RoomState for IrohGossipState {
|
||||
compute_bootstrap(self_state.addr.id, ticket.host_addr.id, &extra_bootstrap);
|
||||
crate::log_msg(&format!("Bootstrap peers for join: {:?}", bootstrap_peers));
|
||||
|
||||
let gossip_topic = self.gossip.subscribe(topic_id, bootstrap_peers).await
|
||||
let gossip_topic = self
|
||||
.gossip
|
||||
.subscribe(topic_id, bootstrap_peers)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let err = format!("Failed to join gossip topic: {}", e);
|
||||
crate::log_msg(&err);
|
||||
@@ -505,32 +518,44 @@ impl RoomState for IrohGossipState {
|
||||
let topic_bytes = ticket.topic_id;
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
|
||||
crate::log_msg(&format!(
|
||||
"Spawned gossip topic loop for self_id={:?}",
|
||||
self_id
|
||||
));
|
||||
let mut state_mutations_seen = HashMap::new();
|
||||
let mut clock_skew_monitor = ClockSkewMonitor::default();
|
||||
|
||||
// Broadcast initial state
|
||||
let initial_payload = {
|
||||
let guard = self_state_clone.lock().unwrap();
|
||||
guard.as_ref().map(|s| sign_gossip(
|
||||
&secret_key,
|
||||
&topic_bytes,
|
||||
now_millis(),
|
||||
GossipMessage::Announce(s.clone()),
|
||||
))
|
||||
guard.as_ref().map(|s| {
|
||||
sign_gossip(
|
||||
&secret_key,
|
||||
&topic_bytes,
|
||||
now_millis(),
|
||||
GossipMessage::Announce(s.clone()),
|
||||
)
|
||||
})
|
||||
};
|
||||
|
||||
if let Some(payload) = initial_payload
|
||||
&& let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
crate::log_msg(&format!("Broadcasting initial state from self_id={:?}", self_id));
|
||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||
}
|
||||
&& let Ok(bytes) = serde_json::to_vec(&payload)
|
||||
{
|
||||
crate::log_msg(&format!(
|
||||
"Broadcasting initial state from self_id={:?}",
|
||||
self_id
|
||||
));
|
||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||
}
|
||||
|
||||
// Stream topic messages
|
||||
while let Some(res) = gossip_receiver.next().await {
|
||||
match res {
|
||||
Ok(iroh_gossip::api::Event::Received(msg)) => {
|
||||
crate::log_msg(&format!("Gossip received Event::Received from delivery={:?}", msg.delivered_from));
|
||||
crate::log_msg(&format!(
|
||||
"Gossip received Event::Received from delivery={:?}",
|
||||
msg.delivered_from
|
||||
));
|
||||
// Reject oversized frames BEFORE deserializing: parsing
|
||||
// allocates, so a size check has to precede `from_slice` to
|
||||
// bound the memory a hostile peer can make us hold.
|
||||
@@ -577,13 +602,13 @@ impl RoomState for IrohGossipState {
|
||||
continue;
|
||||
}
|
||||
let our_id = {
|
||||
self_state_clone.lock().unwrap()
|
||||
.as_ref()
|
||||
.map(|s| s.addr.id)
|
||||
self_state_clone.lock().unwrap().as_ref().map(|s| s.addr.id)
|
||||
};
|
||||
|
||||
|
||||
if Some(payload.author) == our_id {
|
||||
crate::log_msg("Gossip Event::Received from ourselves; ignoring");
|
||||
crate::log_msg(
|
||||
"Gossip Event::Received from ourselves; ignoring",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -635,7 +660,9 @@ impl RoomState for IrohGossipState {
|
||||
// Screen-share tickets are capabilities and
|
||||
// peer-supplied: cap/validate once at ingest
|
||||
// so invalid offers never render a Watch button.
|
||||
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
|
||||
state.sharing = state
|
||||
.sharing
|
||||
.and_then(crate::screenshare::sanitize_ticket);
|
||||
// The game-presence label is untrusted
|
||||
// peer text like the name: sanitize +
|
||||
// length-cap at ingest (strip bidi/control,
|
||||
@@ -651,8 +678,11 @@ impl RoomState for IrohGossipState {
|
||||
// bounds a future fetch (reject anything
|
||||
// outside the attachment cap).
|
||||
state.music = state.music.and_then(|mut m| {
|
||||
let name = crate::sanitize::sanitize_game_label(&m.name);
|
||||
if name.is_empty() || !crate::files::size_within_cap(m.size) {
|
||||
let name =
|
||||
crate::sanitize::sanitize_game_label(&m.name);
|
||||
if name.is_empty()
|
||||
|| !crate::files::size_within_cap(m.size)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
m.name = name;
|
||||
@@ -679,8 +709,10 @@ impl RoomState for IrohGossipState {
|
||||
// cleared ONLY once re-admitted — clearing it before
|
||||
// a possible reject would orphan its recovery state
|
||||
// (Tier C F-01 audit).
|
||||
let is_reconnecting =
|
||||
disconnected_peers.lock().unwrap().contains(&payload.author);
|
||||
let is_reconnecting = disconnected_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&payload.author);
|
||||
let admitted = {
|
||||
let mut peer_map = peers.lock().unwrap();
|
||||
let is_new = !peer_map.contains_key(&payload.author);
|
||||
@@ -689,11 +721,17 @@ impl RoomState for IrohGossipState {
|
||||
// memory/tasks/dials without bound (Tier C
|
||||
// F-01). Existing-peer updates and reconnects
|
||||
// are exempt; only brand-new authors are gated.
|
||||
let subject_to_cap = announce_subject_to_cap(is_new, is_reconnecting);
|
||||
if !admit_into_roster(peer_map.len(), subject_to_cap, MAX_ACTIVE_PEERS) {
|
||||
let subject_to_cap =
|
||||
announce_subject_to_cap(is_new, is_reconnecting);
|
||||
if !admit_into_roster(
|
||||
peer_map.len(),
|
||||
subject_to_cap,
|
||||
MAX_ACTIVE_PEERS,
|
||||
) {
|
||||
None
|
||||
} else {
|
||||
let state_changed = peer_map.get(&payload.author) != Some(&state);
|
||||
let state_changed =
|
||||
peer_map.get(&payload.author) != Some(&state);
|
||||
if is_new || state_changed {
|
||||
peer_map.insert(payload.author, state.clone());
|
||||
}
|
||||
@@ -723,35 +761,54 @@ impl RoomState for IrohGossipState {
|
||||
// address set, so leave/re-announce cycles
|
||||
// can't accumulate attacker-supplied history
|
||||
// (Tier C F-01).
|
||||
let _ = address_lookup.set_endpoint_info(state.addr.clone());
|
||||
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
|
||||
let _ = address_lookup
|
||||
.set_endpoint_info(state.addr.clone());
|
||||
let _ = event_tx
|
||||
.send(RoomEvent::PeerJoined(payload.author, state))
|
||||
.await;
|
||||
} else if state_changed {
|
||||
crate::log_msg(&format!(
|
||||
"Gossip peer state updated: {}, state: {}",
|
||||
crate::short_id(&payload.author.to_string()),
|
||||
peer_state_for_log(&state)
|
||||
));
|
||||
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
|
||||
let _ = event_tx
|
||||
.send(RoomEvent::PeerUpdated(payload.author, state))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
GossipMessage::Leave => {
|
||||
crate::log_msg(&format!("Gossip peer leave request from author={:?}", payload.author));
|
||||
crate::log_msg(&format!(
|
||||
"Gossip peer leave request from author={:?}",
|
||||
payload.author
|
||||
));
|
||||
// Drop this id's address-lookup entry so cycling
|
||||
// distinct identities through Announce→Leave can't
|
||||
// grow the lookup for the room's lifetime (Tier C
|
||||
// F-01 audit). Re-announce re-populates it.
|
||||
let _ = address_lookup.remove_endpoint_info(payload.author);
|
||||
let removed = peers.lock().unwrap().remove(&payload.author).is_some();
|
||||
let removed =
|
||||
peers.lock().unwrap().remove(&payload.author).is_some();
|
||||
let was_disconnected = disconnected_peers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&payload.author);
|
||||
if removed || was_disconnected {
|
||||
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
|
||||
let _ = event_tx
|
||||
.send(RoomEvent::PeerLeft(payload.author))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
GossipMessage::Chat { name, text, ts, attachment } => {
|
||||
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author));
|
||||
GossipMessage::Chat {
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
attachment,
|
||||
} => {
|
||||
crate::log_msg(&format!(
|
||||
"Gossip chat from author={:?}",
|
||||
payload.author
|
||||
));
|
||||
// Defensively normalize an untrusted attachment
|
||||
// descriptor: sanitize the filename and drop it
|
||||
// entirely if it declares an out-of-cap size.
|
||||
@@ -762,18 +819,23 @@ impl RoomState for IrohGossipState {
|
||||
a.name = crate::files::sanitize_filename(&a.name);
|
||||
Some(a)
|
||||
});
|
||||
let _ = event_tx.send(RoomEvent::ChatMessage {
|
||||
from: payload.author,
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
attachment,
|
||||
}).await;
|
||||
let _ = event_tx
|
||||
.send(RoomEvent::ChatMessage {
|
||||
from: payload.author,
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
attachment,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
crate::log_msg(&format!("Gossip failed to deserialize payload: {:?}", e));
|
||||
crate::log_msg(&format!(
|
||||
"Gossip failed to deserialize payload: {:?}",
|
||||
e
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -782,18 +844,24 @@ impl RoomState for IrohGossipState {
|
||||
// Resend state on new neighbor connection to guarantee synchronization
|
||||
let payload_opt = {
|
||||
let guard = self_state_clone.lock().unwrap();
|
||||
guard.as_ref().map(|state| sign_gossip(
|
||||
&secret_key,
|
||||
&topic_bytes,
|
||||
now_millis(),
|
||||
GossipMessage::Announce(state.clone()),
|
||||
))
|
||||
guard.as_ref().map(|state| {
|
||||
sign_gossip(
|
||||
&secret_key,
|
||||
&topic_bytes,
|
||||
now_millis(),
|
||||
GossipMessage::Announce(state.clone()),
|
||||
)
|
||||
})
|
||||
};
|
||||
if let Some(payload) = payload_opt
|
||||
&& let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
crate::log_msg(&format!("Broadcasting state to new neighbor={:?}", peer_id));
|
||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||
}
|
||||
&& let Ok(bytes) = serde_json::to_vec(&payload)
|
||||
{
|
||||
crate::log_msg(&format!(
|
||||
"Broadcasting state to new neighbor={:?}",
|
||||
peer_id
|
||||
));
|
||||
let _ = gossip_sender_clone.broadcast(bytes.into()).await;
|
||||
}
|
||||
}
|
||||
Ok(iroh_gossip::api::Event::NeighborDown(peer_id)) => {
|
||||
crate::log_msg(&format!("Gossip event: NeighborDown={:?}", peer_id));
|
||||
@@ -807,7 +875,10 @@ impl RoomState for IrohGossipState {
|
||||
let removed = peers.lock().unwrap().remove(&peer_id).is_some();
|
||||
if removed {
|
||||
disconnected_peers.lock().unwrap().insert(peer_id);
|
||||
crate::log_msg(&format!("Peer connection lost (NeighborDown): {:?}", peer_id));
|
||||
crate::log_msg(&format!(
|
||||
"Peer connection lost (NeighborDown): {:?}",
|
||||
peer_id
|
||||
));
|
||||
let _ = event_tx.send(RoomEvent::PeerConnectionLost(peer_id)).await;
|
||||
}
|
||||
}
|
||||
@@ -844,7 +915,9 @@ impl RoomState for IrohGossipState {
|
||||
);
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
crate::log_msg("Broadcasting updated self state to gossip");
|
||||
sender.broadcast(bytes.into()).await
|
||||
sender
|
||||
.broadcast(bytes.into())
|
||||
.await
|
||||
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
@@ -909,10 +982,17 @@ impl RoomState for IrohGossipState {
|
||||
&self.secret_key,
|
||||
&topic,
|
||||
ts,
|
||||
GossipMessage::Chat { name, text, ts, attachment },
|
||||
GossipMessage::Chat {
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
attachment,
|
||||
},
|
||||
);
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
sender.broadcast(bytes.into()).await
|
||||
sender
|
||||
.broadcast(bytes.into())
|
||||
.await
|
||||
.map_err(|e| NetError::Gossip(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
@@ -934,12 +1014,7 @@ impl RoomState for IrohGossipState {
|
||||
|
||||
let sender_opt = self.active_sender.lock().unwrap().take();
|
||||
if let (Some(sender), Some(topic)) = (sender_opt, topic_opt) {
|
||||
let payload = sign_gossip(
|
||||
&self.secret_key,
|
||||
&topic,
|
||||
now_millis(),
|
||||
GossipMessage::Leave,
|
||||
);
|
||||
let payload = sign_gossip(&self.secret_key, &topic, now_millis(), GossipMessage::Leave);
|
||||
if let Ok(bytes) = serde_json::to_vec(&payload) {
|
||||
crate::log_msg("Broadcasting Leave message to gossip");
|
||||
let _ = sender.broadcast(bytes.into()).await;
|
||||
@@ -1060,7 +1135,9 @@ mod tests {
|
||||
#[test]
|
||||
fn reconnecting_and_existing_peers_are_exempt_from_the_cap() {
|
||||
// A brand-new author counts against the cap...
|
||||
assert!(announce_subject_to_cap(/* is_new */ true, /* is_reconnecting */ false));
|
||||
assert!(announce_subject_to_cap(
|
||||
/* is_new */ true, /* is_reconnecting */ false
|
||||
));
|
||||
// ...but an ordinary update from an in-roster peer does not...
|
||||
assert!(!announce_subject_to_cap(false, false));
|
||||
// ...and neither does a re-announce from a peer mid-reconnect, even
|
||||
@@ -1070,7 +1147,11 @@ mod tests {
|
||||
// Combined with admit_into_roster: a reconnecting author passes at a full
|
||||
// roster, a brand-new one does not.
|
||||
assert!(admit_into_roster(3, announce_subject_to_cap(true, true), 3));
|
||||
assert!(!admit_into_roster(3, announce_subject_to_cap(true, false), 3));
|
||||
assert!(!admit_into_roster(
|
||||
3,
|
||||
announce_subject_to_cap(true, false),
|
||||
3
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1105,7 +1186,10 @@ mod tests {
|
||||
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
|
||||
assert_eq!(
|
||||
monitor.observe(author, -123_000, 69_999),
|
||||
Some(ClockSkewWarning { author, skew_ms: -123_000 })
|
||||
Some(ClockSkewWarning {
|
||||
author,
|
||||
skew_ms: -123_000
|
||||
})
|
||||
);
|
||||
assert_eq!(monitor.observe(author, -124_000, 70_000), None);
|
||||
}
|
||||
@@ -1125,7 +1209,10 @@ mod tests {
|
||||
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
|
||||
assert_eq!(
|
||||
monitor.observe(author, 128_000, 320_000),
|
||||
Some(ClockSkewWarning { author, skew_ms: 128_000 })
|
||||
Some(ClockSkewWarning {
|
||||
author,
|
||||
skew_ms: 128_000
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1141,11 +1228,17 @@ mod tests {
|
||||
assert_eq!(monitor.observe(b, 121_000, 1_000), None);
|
||||
assert_eq!(
|
||||
monitor.observe(b, 121_000, 2_000),
|
||||
Some(ClockSkewWarning { author: b, skew_ms: 121_000 })
|
||||
Some(ClockSkewWarning {
|
||||
author: b,
|
||||
skew_ms: 121_000
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
monitor.observe(a, -121_000, 2_000),
|
||||
Some(ClockSkewWarning { author: a, skew_ms: -121_000 })
|
||||
Some(ClockSkewWarning {
|
||||
author: a,
|
||||
skew_ms: -121_000
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1202,7 +1295,10 @@ mod tests {
|
||||
assert!(long.as_str().len() > MAX_RELAY_URL_LEN);
|
||||
let addr = EndpointAddr::from_parts(
|
||||
id,
|
||||
[TransportAddr::Relay(short.clone()), TransportAddr::Relay(long)],
|
||||
[
|
||||
TransportAddr::Relay(short.clone()),
|
||||
TransportAddr::Relay(long),
|
||||
],
|
||||
);
|
||||
let out = sanitize_endpoint_addr(&addr);
|
||||
let relays: Vec<_> = out.relay_urls().cloned().collect();
|
||||
@@ -1222,7 +1318,12 @@ mod tests {
|
||||
let secret = SecretKey::generate();
|
||||
let topic = [9u8; 32];
|
||||
let peer_state = sample_peer_state_for(secret.public());
|
||||
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
|
||||
let payload = sign_gossip(
|
||||
&secret,
|
||||
&topic,
|
||||
1000,
|
||||
GossipMessage::Announce(peer_state.clone()),
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_string(&payload).unwrap();
|
||||
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
|
||||
@@ -1252,7 +1353,13 @@ mod tests {
|
||||
};
|
||||
let serialized = serde_json::to_string(&original).unwrap();
|
||||
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
|
||||
if let GossipMessage::Chat { name, text, ts, attachment } = deserialized {
|
||||
if let GossipMessage::Chat {
|
||||
name,
|
||||
text,
|
||||
ts,
|
||||
attachment,
|
||||
} = deserialized
|
||||
{
|
||||
assert_eq!(name, "Alice");
|
||||
assert_eq!(text, "Hello");
|
||||
assert_eq!(ts, 123456789);
|
||||
@@ -1305,7 +1412,10 @@ mod tests {
|
||||
// defaulting the attachment to None (serde(default)).
|
||||
let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
|
||||
let parsed: GossipMessage = serde_json::from_str(legacy).unwrap();
|
||||
if let GossipMessage::Chat { name, attachment, .. } = parsed {
|
||||
if let GossipMessage::Chat {
|
||||
name, attachment, ..
|
||||
} = parsed
|
||||
{
|
||||
assert_eq!(name, "Old");
|
||||
assert_eq!(attachment, None);
|
||||
} else {
|
||||
@@ -1368,7 +1478,10 @@ mod tests {
|
||||
let secret = SecretKey::generate();
|
||||
let topic = [1u8; 32];
|
||||
let p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
||||
assert_eq!(verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS), Ok(()));
|
||||
assert_eq!(
|
||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||
Ok(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1392,7 +1505,12 @@ mod tests {
|
||||
let secret = SecretKey::generate();
|
||||
let topic = [4u8; 32];
|
||||
let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
|
||||
p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None };
|
||||
p.msg = GossipMessage::Chat {
|
||||
name: "x".into(),
|
||||
text: "y".into(),
|
||||
ts: 5_000,
|
||||
attachment: None,
|
||||
};
|
||||
assert_eq!(
|
||||
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
|
||||
Err(GossipReject::BadSignature)
|
||||
@@ -1418,16 +1536,34 @@ mod tests {
|
||||
let p = sign_gossip(&secret, &topic, 1_000_000, GossipMessage::Leave);
|
||||
// Far in the past relative to "now" → stale (replay).
|
||||
assert_eq!(
|
||||
verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS + 1, GOSSIP_FRESHNESS_MS),
|
||||
verify_gossip(
|
||||
&p,
|
||||
&topic,
|
||||
1_000_000 + GOSSIP_FRESHNESS_MS + 1,
|
||||
GOSSIP_FRESHNESS_MS
|
||||
),
|
||||
Err(GossipReject::OutOfWindow)
|
||||
);
|
||||
// Implausibly future.
|
||||
assert_eq!(
|
||||
verify_gossip(&p, &topic, 1_000_000 - GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS),
|
||||
verify_gossip(
|
||||
&p,
|
||||
&topic,
|
||||
1_000_000 - GOSSIP_FRESHNESS_MS - 1,
|
||||
GOSSIP_FRESHNESS_MS
|
||||
),
|
||||
Err(GossipReject::OutOfWindow)
|
||||
);
|
||||
// Within the window (clock skew tolerance) → accepted.
|
||||
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
|
||||
assert!(
|
||||
verify_gossip(
|
||||
&p,
|
||||
&topic,
|
||||
1_000_000 + GOSSIP_FRESHNESS_MS - 1,
|
||||
GOSSIP_FRESHNESS_MS
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1449,10 +1585,30 @@ mod tests {
|
||||
let author = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
|
||||
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9));
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11));
|
||||
assert!(admit_state_mutation(
|
||||
&mut seen,
|
||||
author,
|
||||
&GossipMessage::Leave,
|
||||
10
|
||||
));
|
||||
assert!(!admit_state_mutation(
|
||||
&mut seen,
|
||||
author,
|
||||
&GossipMessage::Leave,
|
||||
10
|
||||
));
|
||||
assert!(!admit_state_mutation(
|
||||
&mut seen,
|
||||
author,
|
||||
&GossipMessage::Leave,
|
||||
9
|
||||
));
|
||||
assert!(admit_state_mutation(
|
||||
&mut seen,
|
||||
author,
|
||||
&GossipMessage::Leave,
|
||||
11
|
||||
));
|
||||
|
||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
|
||||
@@ -1465,13 +1621,26 @@ mod tests {
|
||||
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
|
||||
let author = fresh_id();
|
||||
let mut seen = HashMap::new();
|
||||
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200, attachment: None };
|
||||
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None };
|
||||
let later_chat = GossipMessage::Chat {
|
||||
name: "A".into(),
|
||||
text: "later".into(),
|
||||
ts: 200,
|
||||
attachment: None,
|
||||
};
|
||||
let earlier_chat = GossipMessage::Chat {
|
||||
name: "A".into(),
|
||||
text: "earlier".into(),
|
||||
ts: 100,
|
||||
attachment: None,
|
||||
};
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
|
||||
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
|
||||
assert!(seen.is_empty(), "chat must not populate the state-mutation replay map");
|
||||
assert!(
|
||||
seen.is_empty(),
|
||||
"chat must not populate the state-mutation replay map"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1481,8 +1650,18 @@ mod tests {
|
||||
let mut seen = HashMap::new();
|
||||
let announce = GossipMessage::Announce(sample_peer_state_for(author));
|
||||
|
||||
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5));
|
||||
assert!(admit_state_mutation(
|
||||
&mut seen,
|
||||
author,
|
||||
&GossipMessage::Leave,
|
||||
5
|
||||
));
|
||||
assert!(admit_state_mutation(&mut seen, author, &announce, 5));
|
||||
assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5));
|
||||
assert!(admit_state_mutation(
|
||||
&mut seen,
|
||||
other,
|
||||
&GossipMessage::Leave,
|
||||
5
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+71
-26
@@ -1,16 +1,16 @@
|
||||
use crate::network::{NetworkTransport, NetError, ConnEvent};
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use iroh::endpoint::{Connection, ConnectionError, VarInt};
|
||||
use crate::network::{ConnEvent, NetError, NetworkTransport};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use iroh::endpoint::{Connection, ConnectionError, VarInt};
|
||||
use iroh::{Endpoint, EndpointId};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
|
||||
use crate::files::{AttachmentId, ChatAttachment};
|
||||
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
|
||||
|
||||
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
|
||||
/// useless latency — keep it shallow and drop the oldest frame when full.
|
||||
@@ -111,7 +111,13 @@ impl Shared {
|
||||
let shared = self.clone();
|
||||
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
|
||||
let inbound_tx_ret = inbound_tx.clone();
|
||||
peers.insert(peer_id, PeerHandle { supervisor, inbound_tx });
|
||||
peers.insert(
|
||||
peer_id,
|
||||
PeerHandle {
|
||||
supervisor,
|
||||
inbound_tx,
|
||||
},
|
||||
);
|
||||
crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id));
|
||||
inbound_tx_ret
|
||||
}
|
||||
@@ -123,7 +129,10 @@ impl Shared {
|
||||
self.addrs.lock().unwrap().remove(&peer_id);
|
||||
if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
|
||||
handle.supervisor.abort();
|
||||
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
|
||||
crate::log_msg(&format!(
|
||||
"Transport: stopped supervising peer {:?}",
|
||||
peer_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,12 +217,15 @@ async fn supervise(
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
|
||||
// Show "connecting" until the first link is actually up.
|
||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
||||
let _ = shared
|
||||
.conn_events_tx
|
||||
.try_send(ConnEvent::Connecting(peer_id));
|
||||
|
||||
let mut conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
||||
Some(conn) => conn,
|
||||
None => return, // retired before we ever connected
|
||||
};
|
||||
let mut conn =
|
||||
match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
||||
Some(conn) => conn,
|
||||
None => return, // retired before we ever connected
|
||||
};
|
||||
|
||||
loop {
|
||||
// A healthy link resets the dialer's backoff for the next outage.
|
||||
@@ -223,8 +235,14 @@ async fn supervise(
|
||||
shared.senders.lock().unwrap().insert(peer_id, send_tx);
|
||||
// Publish the live connection so an intentional leave can close it with
|
||||
// the goodbye code.
|
||||
shared.live_conns.lock().unwrap().insert(peer_id, conn.clone());
|
||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connected(peer_id));
|
||||
shared
|
||||
.live_conns
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(peer_id, conn.clone());
|
||||
let _ = shared
|
||||
.conn_events_tx
|
||||
.try_send(ConnEvent::Connected(peer_id));
|
||||
crate::log_msg(&format!("Transport: peer {:?} link up", peer_id));
|
||||
|
||||
// Run until the link dies, a replacement arrives, or we're retired. The
|
||||
@@ -272,21 +290,36 @@ async fn supervise(
|
||||
match wake {
|
||||
Wake::Shutdown => return,
|
||||
Wake::Replacement(new_conn) => {
|
||||
crate::log_msg(&format!("Transport: peer {:?} replaced with new inbound link", peer_id));
|
||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
||||
crate::log_msg(&format!(
|
||||
"Transport: peer {:?} replaced with new inbound link",
|
||||
peer_id
|
||||
));
|
||||
let _ = shared
|
||||
.conn_events_tx
|
||||
.try_send(ConnEvent::Connecting(peer_id));
|
||||
conn = new_conn;
|
||||
}
|
||||
Wake::Closed(reason) => {
|
||||
// A graceful application close means the peer left on purpose —
|
||||
// don't reconnect; tell the core to evict it now.
|
||||
if is_graceful_leave(&reason) {
|
||||
crate::log_msg(&format!("Transport: peer {:?} left gracefully ({:?})", peer_id, reason));
|
||||
crate::log_msg(&format!(
|
||||
"Transport: peer {:?} left gracefully ({:?})",
|
||||
peer_id, reason
|
||||
));
|
||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id));
|
||||
return;
|
||||
}
|
||||
crate::log_msg(&format!("Transport: peer {:?} link dropped; reconnecting", peer_id));
|
||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
||||
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
||||
crate::log_msg(&format!(
|
||||
"Transport: peer {:?} link dropped; reconnecting",
|
||||
peer_id
|
||||
));
|
||||
let _ = shared
|
||||
.conn_events_tx
|
||||
.try_send(ConnEvent::Connecting(peer_id));
|
||||
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff)
|
||||
.await
|
||||
{
|
||||
Some(conn) => conn,
|
||||
None => return, // retired while reconnecting
|
||||
};
|
||||
@@ -405,7 +438,10 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
|
||||
// only happens if links are churning, and the supervisor gets the next one.
|
||||
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
||||
if inbound_tx.try_send(connection).is_err() {
|
||||
crate::log_msg(&format!("Transport: dropped inbound link from {:?} (queue full)", peer_id));
|
||||
crate::log_msg(&format!(
|
||||
"Transport: dropped inbound link from {:?} (queue full)",
|
||||
peer_id
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -545,7 +581,14 @@ impl IrohTransport {
|
||||
/// all supervisors so none linger redialing the about-to-close endpoint.
|
||||
/// Call this before shutting the router down.
|
||||
pub async fn leave(&self) {
|
||||
let conns: Vec<Connection> = self.shared.live_conns.lock().unwrap().drain().map(|(_, c)| c).collect();
|
||||
let conns: Vec<Connection> = self
|
||||
.shared
|
||||
.live_conns
|
||||
.lock()
|
||||
.unwrap()
|
||||
.drain()
|
||||
.map(|(_, c)| c)
|
||||
.collect();
|
||||
for conn in &conns {
|
||||
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
|
||||
}
|
||||
@@ -639,7 +682,9 @@ impl IrohTransport {
|
||||
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
||||
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
|
||||
if bytes.is_empty() {
|
||||
return Err(NetError::Other("file fetch: sender no longer has the file".to_string()));
|
||||
return Err(NetError::Other(
|
||||
"file fetch: sender no longer has the file".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
+53
-29
@@ -1,10 +1,10 @@
|
||||
use iroh::{EndpointId, EndpointAddr};
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use std::str::FromStr;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum NetError {
|
||||
@@ -148,7 +148,10 @@ pub enum RoomEvent {
|
||||
/// A validly signed gossip payload was rejected only because its timestamp is
|
||||
/// outside the replay-protection window. The peer is not in the roster yet,
|
||||
/// so this surfaces as a room-level warning instead of a peer-card state.
|
||||
ClockSkewSuspected { author: EndpointId, skew_ms: i64 },
|
||||
ClockSkewSuspected {
|
||||
author: EndpointId,
|
||||
skew_ms: i64,
|
||||
},
|
||||
/// A peer sent a room text-chat message. Carries the sender's id, their
|
||||
/// display name (embedded so it shows even without a presence entry), the
|
||||
/// text, and a sender-stamped millisecond timestamp.
|
||||
@@ -203,10 +206,12 @@ impl PeerSpeakTicket {
|
||||
/// is idempotent.
|
||||
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
|
||||
match ticket_str.parse::<PeerSpeakTicket>() {
|
||||
Ok(t) => {
|
||||
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name }
|
||||
.to_string()
|
||||
Ok(t) => PeerSpeakTicket {
|
||||
host_addr: my_addr,
|
||||
topic_id: t.topic_id,
|
||||
name: t.name,
|
||||
}
|
||||
.to_string(),
|
||||
Err(_) => ticket_str.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -215,7 +220,10 @@ impl PeerSpeakTicket {
|
||||
/// can't be parsed or carries no label. Pure; used to label the gathering both
|
||||
/// in the room UI and in the presence we report to friends.
|
||||
pub fn label_of(ticket_str: &str) -> String {
|
||||
ticket_str.parse::<PeerSpeakTicket>().map(|t| t.name).unwrap_or_default()
|
||||
ticket_str
|
||||
.parse::<PeerSpeakTicket>()
|
||||
.map(|t| t.name)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
|
||||
@@ -223,7 +231,10 @@ impl PeerSpeakTicket {
|
||||
/// the recents list (the host address and label change between members/sessions,
|
||||
/// but the topic uniquely identifies the gathering).
|
||||
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
|
||||
ticket_str.parse::<PeerSpeakTicket>().ok().map(|t| t.topic_id)
|
||||
ticket_str
|
||||
.parse::<PeerSpeakTicket>()
|
||||
.ok()
|
||||
.map(|t| t.topic_id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,8 +257,8 @@ impl FromStr for PeerSpeakTicket {
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
|
||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded)
|
||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||
let ticket: PeerSpeakTicket =
|
||||
serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||
Ok(ticket)
|
||||
}
|
||||
}
|
||||
@@ -327,13 +338,13 @@ pub trait RoomState: Send + Sync {
|
||||
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
|
||||
}
|
||||
|
||||
pub mod iroh_impl;
|
||||
pub mod gossip;
|
||||
pub mod iroh_impl;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iroh::{SecretKey, EndpointAddr};
|
||||
use iroh::{EndpointAddr, SecretKey};
|
||||
|
||||
fn sample_peer_state() -> PeerState {
|
||||
let secret = SecretKey::generate();
|
||||
@@ -371,9 +382,12 @@ mod tests {
|
||||
let host = SecretKey::generate().public();
|
||||
let topic_id = [3u8; 32];
|
||||
// A labelled ticket: restamp keeps the label, label_of reads it.
|
||||
let labelled =
|
||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
||||
.to_string();
|
||||
let labelled = PeerSpeakTicket {
|
||||
host_addr: EndpointAddr::from(host),
|
||||
topic_id,
|
||||
name: "HangOut".into(),
|
||||
}
|
||||
.to_string();
|
||||
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
|
||||
let member = SecretKey::generate().public();
|
||||
let restamped = PeerSpeakTicket::restamp(&labelled, EndpointAddr::from(member));
|
||||
@@ -410,10 +424,8 @@ mod tests {
|
||||
|
||||
// valid URL-safe-base64 that decodes to non-JSON bytes
|
||||
let bad_json = b"hello world";
|
||||
let encoded = base64::Engine::encode(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
||||
bad_json,
|
||||
);
|
||||
let encoded =
|
||||
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json);
|
||||
let res3 = encoded.parse::<PeerSpeakTicket>();
|
||||
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
|
||||
}
|
||||
@@ -424,9 +436,12 @@ mod tests {
|
||||
let host = SecretKey::generate().public();
|
||||
let member = SecretKey::generate().public();
|
||||
let topic_id = [42u8; 32];
|
||||
let original =
|
||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
||||
.to_string();
|
||||
let original = PeerSpeakTicket {
|
||||
host_addr: EndpointAddr::from(host),
|
||||
topic_id,
|
||||
name: "HangOut".into(),
|
||||
}
|
||||
.to_string();
|
||||
|
||||
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
|
||||
let restamped = restamped_str.parse::<PeerSpeakTicket>().unwrap();
|
||||
@@ -441,18 +456,27 @@ mod tests {
|
||||
fn test_restamp_is_idempotent_for_same_addr() {
|
||||
let me = SecretKey::generate().public();
|
||||
let topic_id = [7u8; 32];
|
||||
let mine =
|
||||
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() }
|
||||
.to_string();
|
||||
let mine = PeerSpeakTicket {
|
||||
host_addr: EndpointAddr::from(me),
|
||||
topic_id,
|
||||
name: String::new(),
|
||||
}
|
||||
.to_string();
|
||||
// Re-stamping my own ticket with my own addr changes nothing.
|
||||
assert_eq!(PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)), mine);
|
||||
assert_eq!(
|
||||
PeerSpeakTicket::restamp(&mine, EndpointAddr::from(me)),
|
||||
mine
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_restamp_passes_through_unparseable() {
|
||||
let me = SecretKey::generate().public();
|
||||
// A malformed ticket is returned unchanged (the join will fail anyway).
|
||||
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
||||
assert_eq!(
|
||||
PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)),
|
||||
"not-a-ticket"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+120
-14
@@ -11,12 +11,16 @@
|
||||
//! missing chime should never disrupt a call.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
||||
static TEMP_WAV_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
|
||||
/// toggle gates everything; these silence individual events while the master
|
||||
@@ -57,7 +61,6 @@ pub fn should_play(master_enabled: bool, sound_enabled: bool) -> bool {
|
||||
master_enabled && sound_enabled
|
||||
}
|
||||
|
||||
|
||||
/// A notification event with a distinct chime.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Sound {
|
||||
@@ -195,14 +198,38 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
||||
if let Some(path) = guard.get(sound.name()) {
|
||||
return Some(path.clone());
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name()));
|
||||
if std::fs::write(&path, sound.bytes()).is_err() {
|
||||
return None;
|
||||
}
|
||||
let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) {
|
||||
Ok(path) => path,
|
||||
Err(_) => return None,
|
||||
};
|
||||
guard.insert(sound.name(), path.clone());
|
||||
Some(path)
|
||||
}
|
||||
|
||||
fn write_private_wav(dir: &Path, stem: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
let counter = TEMP_WAV_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
let path = dir.join(format!(
|
||||
"peerspeak-{stem}-{}-{counter}-{nanos}.wav",
|
||||
std::process::id()
|
||||
));
|
||||
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
|
||||
let mut file = options.open(&path)?;
|
||||
file.write_all(bytes)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
@@ -249,6 +276,19 @@ fn spawn_player(path: &Path) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
static TEST_TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_wav_dir(tag: &str) -> PathBuf {
|
||||
let counter = TEST_TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"peerspeak-notifytest-{}-{tag}-{counter}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_play_truth_table() {
|
||||
// Plays only when BOTH the master and the per-sound flag are on.
|
||||
@@ -264,10 +304,7 @@ mod tests {
|
||||
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||
r"C:\Users\O''Brien\chime.wav"
|
||||
);
|
||||
assert_eq!(
|
||||
escape_powershell_single_quoted("a'b'c"),
|
||||
"a''b''c"
|
||||
);
|
||||
assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -297,7 +334,10 @@ mod tests {
|
||||
// bare `~` -> home dir
|
||||
assert_eq!(expand_tilde("~"), home);
|
||||
// `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav`
|
||||
assert_eq!(expand_tilde("~/sub/dir/file.wav"), home.join("sub/dir/file.wav"));
|
||||
assert_eq!(
|
||||
expand_tilde("~/sub/dir/file.wav"),
|
||||
home.join("sub/dir/file.wav")
|
||||
);
|
||||
}
|
||||
// absolute path (`/etc/foo.wav`) -> unchanged
|
||||
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
|
||||
@@ -310,10 +350,19 @@ mod tests {
|
||||
// leading/trailing whitespace is trimmed
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
assert_eq!(expand_tilde(" ~ "), home);
|
||||
assert_eq!(expand_tilde(" ~/sub/dir/file.wav "), home.join("sub/dir/file.wav"));
|
||||
assert_eq!(
|
||||
expand_tilde(" ~/sub/dir/file.wav "),
|
||||
home.join("sub/dir/file.wav")
|
||||
);
|
||||
}
|
||||
assert_eq!(expand_tilde(" /etc/foo.wav "), PathBuf::from("/etc/foo.wav"));
|
||||
assert_eq!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav"));
|
||||
assert_eq!(
|
||||
expand_tilde(" /etc/foo.wav "),
|
||||
PathBuf::from("/etc/foo.wav")
|
||||
);
|
||||
assert_eq!(
|
||||
expand_tilde(" foo/bar.wav "),
|
||||
PathBuf::from("foo/bar.wav")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -332,4 +381,61 @@ mod tests {
|
||||
// a `~`-prefixed path that resolves to a non-existent file -> Some(false)
|
||||
assert_eq!(validate_custom_path("~/non/existent/file.wav"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_private_wav_writes_exact_bytes() {
|
||||
let dir = temp_wav_dir("writes");
|
||||
let bytes = b"RIFFpeerspeak-test";
|
||||
|
||||
let path = write_private_wav(&dir, "unit", bytes).unwrap();
|
||||
|
||||
assert!(path.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_private_wav_creates_0600_file() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_wav_dir("mode");
|
||||
let path = write_private_wav(&dir, "unit", b"mode").unwrap();
|
||||
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_private_wav_uses_unique_paths() {
|
||||
let dir = temp_wav_dir("unique");
|
||||
|
||||
let first = write_private_wav(&dir, "same-stem", b"first").unwrap();
|
||||
let second = write_private_wav(&dir, "same-stem", b"second").unwrap();
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert!(first.exists());
|
||||
assert!(second.exists());
|
||||
assert_eq!(std::fs::read(&first).unwrap(), b"first");
|
||||
assert_eq!(std::fs::read(&second).unwrap(), b"second");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_new_refuses_existing_path() {
|
||||
let dir = temp_wav_dir("create-new");
|
||||
let path = dir.join("preexisting.wav");
|
||||
std::fs::write(&path, b"original").unwrap();
|
||||
|
||||
let err = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&path)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"original");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -49,9 +49,7 @@ pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Ve
|
||||
|
||||
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
|
||||
let lower = entry.to_ascii_lowercase();
|
||||
if lower.starts_with("http://")
|
||||
|| lower.starts_with("https://")
|
||||
|| lower.starts_with("ftp://")
|
||||
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
@@ -107,7 +105,10 @@ File3=/var/audio/two.MP3
|
||||
#[test]
|
||||
fn playlist_kind_is_case_insensitive() {
|
||||
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
|
||||
assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u));
|
||||
assert_eq!(
|
||||
playlist_kind(Path::new("mix.m3u8")),
|
||||
Some(PlaylistKind::M3u)
|
||||
);
|
||||
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
|
||||
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
|
||||
}
|
||||
|
||||
+113
-18
@@ -36,8 +36,11 @@ pub enum PresenceMode {
|
||||
|
||||
impl PresenceMode {
|
||||
/// All postures, default first — the option list for the Settings/home picker.
|
||||
pub const ALL: [PresenceMode; 3] =
|
||||
[PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable];
|
||||
pub const ALL: [PresenceMode; 3] = [
|
||||
PresenceMode::Normal,
|
||||
PresenceMode::Invisible,
|
||||
PresenceMode::Discoverable,
|
||||
];
|
||||
|
||||
/// Whether this posture publishes to discovery (the only mode that does).
|
||||
pub fn publishes_to_discovery(self) -> bool {
|
||||
@@ -100,7 +103,11 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
|
||||
mode.answers_pings() && friends.contains(from)
|
||||
}
|
||||
|
||||
/// What we learned about a friend from a successful ping reply.
|
||||
/// What we learned about a friend's reachability. `Online`/`InRoom` come from a
|
||||
/// successful ping reply (see [`interpret_pong`]); `Offline` is produced by the
|
||||
/// presence scheduler when a probe fails or the friend has no known address, so a
|
||||
/// friend who drops or leaves is *actively* downgraded rather than left showing a
|
||||
/// stale status. The UI also treats a missing entry as offline.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FriendPresence {
|
||||
/// Online, but not in a gathering we can join.
|
||||
@@ -108,6 +115,8 @@ pub enum FriendPresence {
|
||||
/// Online and in a joinable gathering (name already sanitized, ticket already
|
||||
/// validated as parseable).
|
||||
InRoom { name: String, ticket: String },
|
||||
/// Unreachable: the probe failed, or we have no address to probe yet.
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// Interpret a peer's reply defensively. `from` must be the connection's
|
||||
@@ -140,6 +149,20 @@ pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresen
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a single probe outcome to a definitive [`FriendPresence`], used by the
|
||||
/// presence scheduler. `Some((reply, from))` is a received message from the
|
||||
/// authenticated remote `from`; `None` means the probe failed (offline /
|
||||
/// unreachable / refused). Anything that doesn't interpret as a real presence —
|
||||
/// a probe error, or a non-`Pong` reply — becomes [`FriendPresence::Offline`], so
|
||||
/// a friend who drops is actively downgraded instead of keeping a stale status.
|
||||
/// Pure so the scheduler's downgrade behaviour is unit-testable without a network.
|
||||
pub fn presence_from_probe(reply: Option<(&ControlMsg, EndpointId)>) -> FriendPresence {
|
||||
match reply {
|
||||
Some((msg, from)) => interpret_pong(msg, from).unwrap_or(FriendPresence::Offline),
|
||||
None => FriendPresence::Offline,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -173,7 +196,11 @@ mod tests {
|
||||
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
|
||||
// Stranger is NEVER answered, in any mode.
|
||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
|
||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Discoverable));
|
||||
assert!(!should_answer(
|
||||
&stranger,
|
||||
&friends,
|
||||
PresenceMode::Discoverable
|
||||
));
|
||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
||||
}
|
||||
|
||||
@@ -194,7 +221,10 @@ mod tests {
|
||||
ControlMsg::Ping,
|
||||
ControlMsg::Pong { room: None },
|
||||
ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }),
|
||||
room: Some(RoomPresence {
|
||||
name: "HangOut".into(),
|
||||
ticket: "abc".into(),
|
||||
}),
|
||||
},
|
||||
];
|
||||
for msg in cases {
|
||||
@@ -225,19 +255,37 @@ mod tests {
|
||||
);
|
||||
// Valid ticket -> InRoom with a sanitized name.
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
||||
}, friend);
|
||||
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
|
||||
let got = interpret_pong(
|
||||
&ControlMsg::Pong {
|
||||
room: Some(RoomPresence {
|
||||
name: "HangOut".into(),
|
||||
ticket: t.clone(),
|
||||
}),
|
||||
},
|
||||
friend,
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
Some(FriendPresence::InRoom {
|
||||
name: "HangOut".into(),
|
||||
ticket: t
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
|
||||
// A friend reporting a room with an unparseable ticket is treated as just
|
||||
// Online — no dead/hostile Join button is surfaced.
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
|
||||
}, id());
|
||||
let got = interpret_pong(
|
||||
&ControlMsg::Pong {
|
||||
room: Some(RoomPresence {
|
||||
name: "Trap".into(),
|
||||
ticket: "not-a-ticket".into(),
|
||||
}),
|
||||
},
|
||||
id(),
|
||||
);
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
@@ -246,20 +294,67 @@ mod tests {
|
||||
let friend = id();
|
||||
let attacker = id();
|
||||
let t = valid_ticket(attacker);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
|
||||
}, friend);
|
||||
let got = interpret_pong(
|
||||
&ControlMsg::Pong {
|
||||
room: Some(RoomPresence {
|
||||
name: "Redirect".into(),
|
||||
ticket: t,
|
||||
}),
|
||||
},
|
||||
friend,
|
||||
);
|
||||
assert_eq!(got, Some(FriendPresence::Online));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_from_probe_maps_outcomes_to_definitive_status() {
|
||||
let friend = id();
|
||||
// A failed probe (no reply) is an explicit downgrade to Offline, so the UI
|
||||
// clears a friend who has dropped instead of keeping a stale status.
|
||||
assert_eq!(presence_from_probe(None), FriendPresence::Offline);
|
||||
// A successful Pong with no room is Online.
|
||||
assert_eq!(
|
||||
presence_from_probe(Some((&ControlMsg::Pong { room: None }, friend))),
|
||||
FriendPresence::Online
|
||||
);
|
||||
// A successful Pong advertising the friend's own room is InRoom.
|
||||
let t = valid_ticket(friend);
|
||||
assert_eq!(
|
||||
presence_from_probe(Some((
|
||||
&ControlMsg::Pong {
|
||||
room: Some(RoomPresence {
|
||||
name: "Den".into(),
|
||||
ticket: t.clone()
|
||||
})
|
||||
},
|
||||
friend,
|
||||
))),
|
||||
FriendPresence::InRoom {
|
||||
name: "Den".into(),
|
||||
ticket: t
|
||||
}
|
||||
);
|
||||
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
|
||||
assert_eq!(
|
||||
presence_from_probe(Some((&ControlMsg::Ping, friend))),
|
||||
FriendPresence::Offline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||
// Control/bidi characters in a peer-supplied name are stripped.
|
||||
let friend = id();
|
||||
let t = valid_ticket(friend);
|
||||
let got = interpret_pong(&ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
||||
}, friend);
|
||||
let got = interpret_pong(
|
||||
&ControlMsg::Pong {
|
||||
room: Some(RoomPresence {
|
||||
name: "Hang\u{202e}Out\u{0007}".into(),
|
||||
ticket: t.clone(),
|
||||
}),
|
||||
},
|
||||
friend,
|
||||
);
|
||||
match got {
|
||||
Some(FriendPresence::InRoom { name, .. }) => {
|
||||
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
||||
|
||||
+21
-8
@@ -54,7 +54,10 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
||||
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
||||
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
||||
/// also accepted (and used by hermetic tests).
|
||||
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<(EndpointId, ControlMsg)> {
|
||||
pub async fn probe(
|
||||
endpoint: &Endpoint,
|
||||
peer: impl Into<EndpointAddr>,
|
||||
) -> Result<(EndpointId, ControlMsg)> {
|
||||
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
||||
.await
|
||||
.context("timed out connecting to peer")?
|
||||
@@ -62,7 +65,10 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
|
||||
let from = conn.remote_id();
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.open_bi().await.context("failed to open control stream")?;
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.context("failed to open control stream")?;
|
||||
send.write_all(&encode(&ControlMsg::Ping)?)
|
||||
.await
|
||||
.context("failed to write ping")?;
|
||||
@@ -118,7 +124,10 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
|
||||
|
||||
let io = async {
|
||||
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
||||
let bytes = recv.read_to_end(MAX_MSG).await.context("failed to read ping")?;
|
||||
let bytes = recv
|
||||
.read_to_end(MAX_MSG)
|
||||
.await
|
||||
.context("failed to read ping")?;
|
||||
match decode(&bytes)? {
|
||||
ControlMsg::Ping => {}
|
||||
other => bail!("expected a ping, got {other:?}"),
|
||||
@@ -211,7 +220,10 @@ mod tests {
|
||||
let handler: Handler = Arc::new(move |from| {
|
||||
if from == allowed {
|
||||
Some(ControlMsg::Pong {
|
||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }),
|
||||
room: Some(RoomPresence {
|
||||
name: "HangOut".into(),
|
||||
ticket: "t".into(),
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
None // stranger -> no reply
|
||||
@@ -221,10 +233,11 @@ mod tests {
|
||||
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
||||
|
||||
// The allowed prober gets a Pong with the room.
|
||||
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
.await
|
||||
.expect("probe timed out")
|
||||
.expect("probe failed");
|
||||
let (from, pong) =
|
||||
tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||
.await
|
||||
.expect("probe timed out")
|
||||
.expect("probe failed");
|
||||
assert_eq!(from, server_addr.id);
|
||||
match pong {
|
||||
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
|
||||
|
||||
+21
-5
@@ -83,10 +83,22 @@ mod tests {
|
||||
/// so a version bump can't silently forget to update the wire string.
|
||||
#[test]
|
||||
fn alpns_match_their_proto_versions() {
|
||||
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
|
||||
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
|
||||
assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes());
|
||||
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
|
||||
assert_eq!(
|
||||
AUDIO_ALPN,
|
||||
format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
FRIENDS_ALPN,
|
||||
format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
FILES_ALPN,
|
||||
format!("peerspeak/files/{FILES_PROTO}").as_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
GOSSIP_SIG_DOMAIN,
|
||||
format!("peerspeak-gossip-v{GOSSIP_PROTO}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -95,7 +107,11 @@ mod tests {
|
||||
let mut b = a;
|
||||
b[5] = 10;
|
||||
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
|
||||
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct");
|
||||
assert_ne!(
|
||||
versioned_topic(a),
|
||||
versioned_topic(b),
|
||||
"distinct rooms stay distinct"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+14
-3
@@ -51,7 +51,14 @@ fn same_room(a: &str, b: &str) -> bool {
|
||||
/// supplies `now` (unix seconds) and persists the list afterwards.
|
||||
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
|
||||
list.retain(|r| !same_room(&r.ticket, &ticket));
|
||||
list.insert(0, Recent { name, ticket, joined_at: now });
|
||||
list.insert(
|
||||
0,
|
||||
Recent {
|
||||
name,
|
||||
ticket,
|
||||
joined_at: now,
|
||||
},
|
||||
);
|
||||
list.truncate(RECENTS_MAX);
|
||||
}
|
||||
|
||||
@@ -86,8 +93,12 @@ mod tests {
|
||||
/// Build a real, parseable ticket for a fresh room with the given label.
|
||||
fn ticket(name: &str, topic: [u8; 32]) -> String {
|
||||
let host = SecretKey::generate().public();
|
||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() }
|
||||
.to_string()
|
||||
PeerSpeakTicket {
|
||||
host_addr: EndpointAddr::from(host),
|
||||
topic_id: topic,
|
||||
name: name.into(),
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+27
-6
@@ -85,7 +85,10 @@ pub enum Segment {
|
||||
/// Trailing characters commonly adjacent to a URL in prose that should NOT be
|
||||
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
|
||||
fn is_url_trailing_punct(c: char) -> bool {
|
||||
matches!(c, '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\'')
|
||||
matches!(
|
||||
c,
|
||||
'.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '>' | '"' | '\''
|
||||
)
|
||||
}
|
||||
|
||||
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`,
|
||||
@@ -144,7 +147,10 @@ mod tests {
|
||||
fn strips_control_chars_and_collapses_whitespace() {
|
||||
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
|
||||
// collapse; ends trim.
|
||||
assert_eq!(sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "), "a b c d [31m");
|
||||
assert_eq!(
|
||||
sanitize_name(" a\u{0}b\r\nc\td\u{1b}[31m "),
|
||||
"a b c d [31m"
|
||||
);
|
||||
// A name that is only control/whitespace cleans to empty.
|
||||
assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
|
||||
}
|
||||
@@ -183,7 +189,10 @@ mod tests {
|
||||
let mid = "g".repeat(56);
|
||||
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
|
||||
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
|
||||
assert_eq!(sanitize_game_label(&long).chars().count(), GAME_LABEL_MAX_CHARS);
|
||||
assert_eq!(
|
||||
sanitize_game_label(&long).chars().count(),
|
||||
GAME_LABEL_MAX_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,7 +229,10 @@ mod tests {
|
||||
#[test]
|
||||
fn linkify_plain_text_has_no_links() {
|
||||
let segs = linkify("just a normal message, nothing here");
|
||||
assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]);
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![Segment::Text("just a normal message, nothing here".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -280,7 +292,12 @@ mod tests {
|
||||
fn linkify_only_matches_http_schemes() {
|
||||
// Non-web schemes and bare domains are NOT linkified (conservative).
|
||||
let segs = linkify("email me@x.com or ftp://x.com or visit x.com");
|
||||
assert_eq!(segs, vec![Segment::Text("email me@x.com or ftp://x.com or visit x.com".into())]);
|
||||
assert_eq!(
|
||||
segs,
|
||||
vec![Segment::Text(
|
||||
"email me@x.com or ftp://x.com or visit x.com".into()
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -293,7 +310,11 @@ mod tests {
|
||||
"weird))) http://c.com]]] tail",
|
||||
"unicode 世界 http://d.com/路径 more 世界",
|
||||
] {
|
||||
assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}");
|
||||
assert_eq!(
|
||||
reassemble(&linkify(msg)),
|
||||
msg,
|
||||
"roundtrip failed for {msg:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
-12
@@ -158,9 +158,8 @@ pub fn host_args(audio_app: Option<&str>) -> Vec<String> {
|
||||
/// "no valid app selected" — the caller then shares the whole desktop audio.
|
||||
pub fn sanitize_app_name(name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
let ok = !name.is_empty()
|
||||
&& name.len() <= MAX_APP_NAME_LEN
|
||||
&& !name.chars().any(|c| c.is_control());
|
||||
let ok =
|
||||
!name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control());
|
||||
ok.then(|| name.to_string())
|
||||
}
|
||||
|
||||
@@ -599,7 +598,10 @@ mod tests {
|
||||
// i.e. after the `--` end-of-options guard, never parsed as a flag.
|
||||
let args = viewer_args("--malicious-flag");
|
||||
assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last");
|
||||
let guard = args.iter().position(|a| a == "--").expect("`--` guard present");
|
||||
let guard = args
|
||||
.iter()
|
||||
.position(|a| a == "--")
|
||||
.expect("`--` guard present");
|
||||
let ticket = args.len() - 1;
|
||||
assert!(guard < ticket, "ticket must follow the `--` guard");
|
||||
// The real flags are parsed before the guard.
|
||||
@@ -629,7 +631,13 @@ mod tests {
|
||||
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
||||
assert_eq!(
|
||||
host_args(Some("Firefox")),
|
||||
vec!["--host", "--output", "json", "--app=Firefox", "--strict-audio"]
|
||||
vec![
|
||||
"--host",
|
||||
"--output",
|
||||
"json",
|
||||
"--app=Firefox",
|
||||
"--strict-audio"
|
||||
]
|
||||
);
|
||||
// The hyphen-leading name is still bound to --app as a single token;
|
||||
// --strict-audio is the trailing flag.
|
||||
@@ -643,12 +651,18 @@ mod tests {
|
||||
// An empty / whitespace / control-laden selection is sanitized away,
|
||||
// falling back to whole-desktop capture rather than a broken flag.
|
||||
assert_eq!(host_args(Some(" ")), vec!["--host", "--output", "json"]);
|
||||
assert_eq!(host_args(Some("bad\nname")), vec!["--host", "--output", "json"]);
|
||||
assert_eq!(
|
||||
host_args(Some("bad\nname")),
|
||||
vec!["--host", "--output", "json"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_app_name_trims_and_rejects_garbage() {
|
||||
assert_eq!(sanitize_app_name(" Firefox \n"), Some("Firefox".to_string()));
|
||||
assert_eq!(
|
||||
sanitize_app_name(" Firefox \n"),
|
||||
Some("Firefox".to_string())
|
||||
);
|
||||
assert_eq!(sanitize_app_name(""), None);
|
||||
assert_eq!(sanitize_app_name(" "), None);
|
||||
assert_eq!(sanitize_app_name("a\tb"), None);
|
||||
@@ -667,7 +681,11 @@ mod tests {
|
||||
]"#;
|
||||
assert_eq!(
|
||||
parse_audio_apps(stdout),
|
||||
vec!["Firefox".to_string(), "Spotify".to_string(), "mpv".to_string()]
|
||||
vec![
|
||||
"Firefox".to_string(),
|
||||
"Spotify".to_string(),
|
||||
"mpv".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -754,13 +772,19 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
#[test]
|
||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
||||
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
|
||||
assert_eq!(
|
||||
sanitize_ticket(format!(" {ticket}\n")),
|
||||
Some(ticket.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
||||
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
||||
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
|
||||
assert_eq!(
|
||||
sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))),
|
||||
None
|
||||
);
|
||||
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
||||
}
|
||||
|
||||
@@ -784,7 +808,9 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
fn parses_connected_url() {
|
||||
assert_eq!(
|
||||
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
|
||||
Some(PixelpassEvent::Connected("http://127.0.0.1:5500".to_string()))
|
||||
Some(PixelpassEvent::Connected(
|
||||
"http://127.0.0.1:5500".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -919,7 +945,10 @@ Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||
let dir = Path::new("bin");
|
||||
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
||||
#[cfg(windows)]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
|
||||
assert_eq!(
|
||||
candidates,
|
||||
vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]
|
||||
);
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||
}
|
||||
|
||||
+6
-2
@@ -211,7 +211,7 @@ impl AppTheme {
|
||||
overlay: hex(0x6272a4),
|
||||
text: hex(0xf8f8f2),
|
||||
subtext: hex(0xbdc0d4),
|
||||
blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent
|
||||
blue: hex(0xbd93f9), // Dracula's signature purple as the primary accent
|
||||
lavender: hex(0x8be9fd), // cyan
|
||||
red: hex(0xff5555),
|
||||
maroon: hex(0xff79c6), // pink
|
||||
@@ -400,7 +400,11 @@ mod tests {
|
||||
for theme in AppTheme::ALL {
|
||||
let p = theme.palette();
|
||||
let sub = contrast_ratio(p.subtext, p.base);
|
||||
assert!(sub >= 3.0, "{}: subtext contrast {sub:.2} < 3.0", theme.label());
|
||||
assert!(
|
||||
sub >= 3.0,
|
||||
"{}: subtext contrast {sub:.2} < 3.0",
|
||||
theme.label()
|
||||
);
|
||||
let accent = contrast_ratio(p.blue, p.base);
|
||||
assert!(
|
||||
accent >= 3.0,
|
||||
|
||||
+70
-75
@@ -9,8 +9,8 @@ use iced::advanced::widget::{self, Widget};
|
||||
use iced::advanced::{Layout, Shell};
|
||||
use iced::widget::text_input;
|
||||
use iced::{
|
||||
alignment, Background, Border, Color, Element, Event, Length, Padding,
|
||||
Pixels, Point, Rectangle, Shadow, Size, Vector,
|
||||
Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
|
||||
Size, Vector, alignment,
|
||||
};
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -27,11 +27,7 @@ pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
|
||||
(start != end).then(|| value.select(start, end).to_string())
|
||||
}
|
||||
|
||||
pub fn cut_selection(
|
||||
value: &str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (Edit, Option<String>) {
|
||||
pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option<String>) {
|
||||
let mut value = text_input::Value::new(value);
|
||||
let (start, end) = normalized_range(&value, start, end);
|
||||
|
||||
@@ -80,18 +76,13 @@ pub fn select_all_range(value: &str) -> (usize, usize) {
|
||||
(0, value.len())
|
||||
}
|
||||
|
||||
fn normalized_range(
|
||||
value: &text_input::Value,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> (usize, usize) {
|
||||
fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) {
|
||||
let len = value.len();
|
||||
|
||||
(start.min(end).min(len), start.max(end).min(len))
|
||||
}
|
||||
|
||||
type InputStyleFn<'a, Theme> =
|
||||
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
||||
type InputStyleFn<'a, Theme> = Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
||||
|
||||
pub fn context_input<'a, Message, Theme, Renderer>(
|
||||
placeholder: &str,
|
||||
@@ -119,12 +110,8 @@ where
|
||||
.locked(true)
|
||||
}
|
||||
|
||||
pub struct ContextInput<
|
||||
'a,
|
||||
Message,
|
||||
Theme = iced::Theme,
|
||||
Renderer = iced::Renderer,
|
||||
> where
|
||||
pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
|
||||
where
|
||||
Theme: text_input::Catalog,
|
||||
Renderer: text::Renderer,
|
||||
{
|
||||
@@ -137,8 +124,7 @@ pub struct ContextInput<
|
||||
style: Option<InputStyleFn<'a, Theme>>,
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
ContextInput<'a, Message, Theme, Renderer>
|
||||
impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
Theme: text_input::Catalog + 'a,
|
||||
@@ -172,16 +158,13 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_input(
|
||||
mut self,
|
||||
on_input: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_input);
|
||||
pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
|
||||
let on_input: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_input);
|
||||
let input_callback = Rc::clone(&on_input);
|
||||
|
||||
self.input =
|
||||
self.input.on_input(move |value| input_callback.as_ref()(value));
|
||||
self.input = self
|
||||
.input
|
||||
.on_input(move |value| input_callback.as_ref()(value));
|
||||
self.on_input = Some(on_input);
|
||||
self
|
||||
}
|
||||
@@ -196,16 +179,13 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_paste(
|
||||
mut self,
|
||||
on_paste: impl Fn(String) -> Message + 'a,
|
||||
) -> Self {
|
||||
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
|
||||
Rc::new(on_paste);
|
||||
pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
|
||||
let on_paste: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_paste);
|
||||
let paste_callback = Rc::clone(&on_paste);
|
||||
|
||||
self.input =
|
||||
self.input.on_paste(move |value| paste_callback.as_ref()(value));
|
||||
self.input = self
|
||||
.input
|
||||
.on_paste(move |value| paste_callback.as_ref()(value));
|
||||
self.on_paste = Some(on_paste);
|
||||
self
|
||||
}
|
||||
@@ -235,18 +215,12 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn line_height(
|
||||
mut self,
|
||||
line_height: impl Into<text::LineHeight>,
|
||||
) -> Self {
|
||||
pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
|
||||
self.input = self.input.line_height(line_height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_x(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Horizontal>,
|
||||
) -> Self {
|
||||
pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
|
||||
self.input = self.input.align_x(alignment);
|
||||
self
|
||||
}
|
||||
@@ -379,11 +353,9 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
tree.state.downcast_mut::<ContextInputState>().menu =
|
||||
cursor.position().map(|anchor| MenuState {
|
||||
anchor,
|
||||
selection,
|
||||
});
|
||||
tree.state.downcast_mut::<ContextInputState>().menu = cursor
|
||||
.position()
|
||||
.map(|anchor| MenuState { anchor, selection });
|
||||
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
@@ -477,8 +449,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Message, Theme, Renderer>
|
||||
From<ContextInput<'a, Message, Theme, Renderer>>
|
||||
impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
|
||||
for Element<'a, Message, Theme, Renderer>
|
||||
where
|
||||
Message: Clone + 'a,
|
||||
@@ -516,12 +487,7 @@ enum MenuAction {
|
||||
}
|
||||
|
||||
impl MenuAction {
|
||||
const ALL: [Self; 4] = [
|
||||
Self::Cut,
|
||||
Self::Copy,
|
||||
Self::Paste,
|
||||
Self::SelectAll,
|
||||
];
|
||||
const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -564,8 +530,7 @@ where
|
||||
cursor: mouse::Cursor,
|
||||
) {
|
||||
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
||||
let hovered_style =
|
||||
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
||||
let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
||||
let bounds = layout.bounds();
|
||||
let viewport = Rectangle::INFINITE;
|
||||
|
||||
@@ -640,9 +605,7 @@ where
|
||||
) {
|
||||
match event {
|
||||
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) => {
|
||||
self.close(shell);
|
||||
@@ -718,11 +681,7 @@ where
|
||||
)
|
||||
}
|
||||
|
||||
fn hit_action(
|
||||
&self,
|
||||
bounds: Rectangle,
|
||||
position: Point,
|
||||
) -> Option<MenuAction> {
|
||||
fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
|
||||
if !bounds.contains(position) {
|
||||
return None;
|
||||
}
|
||||
@@ -932,12 +891,48 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn locked_menu_allows_copy_and_select_all_only() {
|
||||
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
|
||||
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
|
||||
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
|
||||
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
|
||||
assert!(!menu_action_enabled(
|
||||
MenuAction::Cut,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(menu_action_enabled(
|
||||
MenuAction::Copy,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(!menu_action_enabled(
|
||||
MenuAction::Paste,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(menu_action_enabled(
|
||||
MenuAction::SelectAll,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
));
|
||||
|
||||
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
|
||||
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
|
||||
assert!(!menu_action_enabled(
|
||||
MenuAction::Copy,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
));
|
||||
assert!(!menu_action_enabled(
|
||||
MenuAction::SelectAll,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+46
-116
@@ -3,16 +3,15 @@ use iced::advanced::layout;
|
||||
use iced::advanced::mouse;
|
||||
use iced::advanced::renderer;
|
||||
use iced::advanced::text::{self as advanced_text, Paragraph, Span};
|
||||
use iced::advanced::widget::tree::{self, Tree};
|
||||
use iced::advanced::widget::Widget;
|
||||
use iced::advanced::widget::tree::{self, Tree};
|
||||
use iced::advanced::{Layout, Shell};
|
||||
use iced::widget::text::{
|
||||
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn,
|
||||
Wrapping,
|
||||
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn, Wrapping,
|
||||
};
|
||||
use iced::{
|
||||
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point,
|
||||
Rectangle, Size, Vector, keyboard,
|
||||
Background, Border, Color, Element, Event, Length, Pixels, Point, Rectangle, Size, Vector,
|
||||
alignment, keyboard,
|
||||
};
|
||||
|
||||
const DRAG_THRESHOLD: f32 = 3.0;
|
||||
@@ -28,11 +27,7 @@ const HIT_SEARCH_STEPS: usize = 24;
|
||||
// widget's per-line offsets would stop being global and selection/copy across
|
||||
// lines would break — revisit then.
|
||||
|
||||
pub fn selected_substring(
|
||||
text: &str,
|
||||
anchor: usize,
|
||||
cursor: usize,
|
||||
) -> Option<String> {
|
||||
pub fn selected_substring(text: &str, anchor: usize, cursor: usize) -> Option<String> {
|
||||
let (start, end) = normalized_byte_range(text, anchor, cursor);
|
||||
|
||||
(start != end).then(|| text[start..end].to_owned())
|
||||
@@ -42,11 +37,7 @@ pub fn select_all(text: &str) -> (usize, usize) {
|
||||
(0, text.len())
|
||||
}
|
||||
|
||||
fn normalized_byte_range(
|
||||
text: &str,
|
||||
anchor: usize,
|
||||
cursor: usize,
|
||||
) -> (usize, usize) {
|
||||
fn normalized_byte_range(text: &str, anchor: usize, cursor: usize) -> (usize, usize) {
|
||||
let start = clamp_to_char_boundary(text, anchor.min(cursor));
|
||||
let end = clamp_to_char_boundary(text, anchor.max(cursor));
|
||||
|
||||
@@ -75,13 +66,8 @@ where
|
||||
SelectableRichText::with_spans(spans)
|
||||
}
|
||||
|
||||
pub struct SelectableRichText<
|
||||
'a,
|
||||
Link,
|
||||
Message,
|
||||
Theme = iced::Theme,
|
||||
Renderer = iced::Renderer,
|
||||
> where
|
||||
pub struct SelectableRichText<'a, Link, Message, Theme = iced::Theme, Renderer = iced::Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
Renderer: advanced_text::Renderer,
|
||||
@@ -101,8 +87,7 @@ pub struct SelectableRichText<
|
||||
selection_color: Color,
|
||||
}
|
||||
|
||||
impl<'a, Link, Message, Theme, Renderer>
|
||||
SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
impl<'a, Link, Message, Theme, Renderer> SelectableRichText<'a, Link, Message, Theme, Renderer>
|
||||
where
|
||||
Link: Clone + 'static,
|
||||
Theme: Catalog,
|
||||
@@ -127,9 +112,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_spans(
|
||||
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
|
||||
) -> Self {
|
||||
pub fn with_spans(spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a) -> Self {
|
||||
Self {
|
||||
spans: Box::new(spans),
|
||||
..Self::new()
|
||||
@@ -166,10 +149,7 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn align_y(
|
||||
mut self,
|
||||
alignment: impl Into<alignment::Vertical>,
|
||||
) -> Self {
|
||||
pub fn align_y(mut self, alignment: impl Into<alignment::Vertical>) -> Self {
|
||||
self.align_y = alignment.into();
|
||||
self
|
||||
}
|
||||
@@ -179,10 +159,7 @@ where
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_link_click(
|
||||
mut self,
|
||||
on_link_click: impl Fn(Link) -> Message + 'a,
|
||||
) -> Self {
|
||||
pub fn on_link_click(mut self, on_link_click: impl Fn(Link) -> Message + 'a) -> Self {
|
||||
self.on_link_click = Some(Box::new(on_link_click));
|
||||
self
|
||||
}
|
||||
@@ -356,26 +333,16 @@ where
|
||||
}
|
||||
|
||||
for (index, span) in spans.iter().enumerate() {
|
||||
let is_hovered_link = self.on_link_click.is_some()
|
||||
&& Some(index) == self.hovered_link;
|
||||
let is_hovered_link = self.on_link_click.is_some() && Some(index) == self.hovered_link;
|
||||
|
||||
if span.highlight.is_some()
|
||||
|| span.underline
|
||||
|| span.strikethrough
|
||||
|| is_hovered_link
|
||||
{
|
||||
if span.highlight.is_some() || span.underline || span.strikethrough || is_hovered_link {
|
||||
let regions = state.paragraph.span_bounds(index);
|
||||
|
||||
if let Some(highlight) = span.highlight {
|
||||
for bounds in ®ions {
|
||||
let bounds = Rectangle::new(
|
||||
bounds.position()
|
||||
- Vector::new(
|
||||
span.padding.left,
|
||||
span.padding.top,
|
||||
),
|
||||
bounds.size()
|
||||
+ Size::new(span.padding.x(), span.padding.y()),
|
||||
bounds.position() - Vector::new(span.padding.left, span.padding.top),
|
||||
bounds.size() + Size::new(span.padding.x(), span.padding.y()),
|
||||
);
|
||||
|
||||
renderer.fill_quad(
|
||||
@@ -390,26 +357,17 @@ where
|
||||
}
|
||||
|
||||
if span.underline || span.strikethrough || is_hovered_link {
|
||||
let size = span
|
||||
.size
|
||||
.or(self.size)
|
||||
.unwrap_or(renderer.default_size());
|
||||
let size = span.size.or(self.size).unwrap_or(renderer.default_size());
|
||||
|
||||
let line_height = span
|
||||
.line_height
|
||||
.unwrap_or(self.line_height)
|
||||
.to_absolute(size);
|
||||
|
||||
let color = span
|
||||
.color
|
||||
.or(style.color)
|
||||
.unwrap_or(defaults.text_color);
|
||||
let color = span.color.or(style.color).unwrap_or(defaults.text_color);
|
||||
|
||||
let baseline = translation
|
||||
+ Vector::new(
|
||||
0.0,
|
||||
size.0 + (line_height.0 - size.0) / 2.0,
|
||||
);
|
||||
let baseline =
|
||||
translation + Vector::new(0.0, size.0 + (line_height.0 - size.0) / 2.0);
|
||||
|
||||
if span.underline || is_hovered_link {
|
||||
for bounds in ®ions {
|
||||
@@ -497,13 +455,10 @@ where
|
||||
state.dragging = true;
|
||||
state.press_position = Some(position);
|
||||
state.span_pressed = self.hovered_link;
|
||||
state.selection = state
|
||||
.paragraph
|
||||
.hit_test(position)
|
||||
.map(|hit| {
|
||||
let offset = hit.cursor().min(flat_text.len());
|
||||
(offset, offset)
|
||||
});
|
||||
state.selection = state.paragraph.hit_test(position).map(|hit| {
|
||||
let offset = hit.cursor().min(flat_text.len());
|
||||
(offset, offset)
|
||||
});
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
} else if state.active || state.selection.is_some() {
|
||||
@@ -521,8 +476,7 @@ where
|
||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||
&& let Some((anchor, _)) = state.selection
|
||||
{
|
||||
state.selection =
|
||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
shell.request_redraw();
|
||||
}
|
||||
}
|
||||
@@ -540,16 +494,14 @@ where
|
||||
&& let Some(hit) = state.paragraph.hit_test(position)
|
||||
&& let Some((anchor, _)) = state.selection
|
||||
{
|
||||
state.selection =
|
||||
Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
state.selection = Some((anchor, hit.cursor().min(flat_text.len())));
|
||||
}
|
||||
|
||||
if !dragged {
|
||||
if let (Some(on_link_clicked), Some(span)) =
|
||||
(&self.on_link_click, state.span_pressed)
|
||||
&& Some(span) == self.hovered_link
|
||||
&& let Some(link) =
|
||||
spans.get(span).and_then(|span| span.link.clone())
|
||||
&& let Some(link) = spans.get(span).and_then(|span| span.link.clone())
|
||||
{
|
||||
shell.publish(on_link_clicked(link));
|
||||
}
|
||||
@@ -570,25 +522,22 @@ where
|
||||
physical_key,
|
||||
modifiers,
|
||||
..
|
||||
}) if state.active && modifiers.command() => {
|
||||
match key.to_latin(*physical_key) {
|
||||
Some('c') | Some('C') => {
|
||||
if let Some((anchor, cursor)) = state.selection
|
||||
&& let Some(selected) =
|
||||
selected_substring(&flat_text, anchor, cursor)
|
||||
{
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
shell.capture_event();
|
||||
}
|
||||
}
|
||||
Some('a') | Some('A') => {
|
||||
state.selection = Some(select_all(&flat_text));
|
||||
}) if state.active && modifiers.command() => match key.to_latin(*physical_key) {
|
||||
Some('c') | Some('C') => {
|
||||
if let Some((anchor, cursor)) = state.selection
|
||||
&& let Some(selected) = selected_substring(&flat_text, anchor, cursor)
|
||||
{
|
||||
clipboard.write(clipboard::Kind::Standard, selected);
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some('a') | Some('A') => {
|
||||
state.selection = Some(select_all(&flat_text));
|
||||
shell.capture_event();
|
||||
shell.request_redraw();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -657,14 +606,8 @@ where
|
||||
};
|
||||
|
||||
if state.spans != config.spans {
|
||||
state.paragraph =
|
||||
Renderer::Paragraph::with_spans(text_with_spans());
|
||||
state.spans = config
|
||||
.spans
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Span::to_static)
|
||||
.collect();
|
||||
state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
|
||||
state.spans = config.spans.iter().cloned().map(Span::to_static).collect();
|
||||
} else {
|
||||
match state.paragraph.compare(advanced_text::Text {
|
||||
content: (),
|
||||
@@ -682,8 +625,7 @@ where
|
||||
state.paragraph.resize(bounds);
|
||||
}
|
||||
advanced_text::Difference::Shape => {
|
||||
state.paragraph =
|
||||
Renderer::Paragraph::with_spans(text_with_spans());
|
||||
state.paragraph = Renderer::Paragraph::with_spans(text_with_spans());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,13 +703,7 @@ fn selection_rect_for_line<P: Paragraph>(
|
||||
})
|
||||
}
|
||||
|
||||
fn x_for_offset<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
y: f32,
|
||||
offset: usize,
|
||||
left: f32,
|
||||
right: f32,
|
||||
) -> f32 {
|
||||
fn x_for_offset<P: Paragraph>(paragraph: &P, y: f32, offset: usize, left: f32, right: f32) -> f32 {
|
||||
let mut low = left;
|
||||
let mut high = right.max(left);
|
||||
|
||||
@@ -787,10 +723,7 @@ fn x_for_offset<P: Paragraph>(
|
||||
high
|
||||
}
|
||||
|
||||
fn visual_lines<P: Paragraph>(
|
||||
paragraph: &P,
|
||||
span_count: usize,
|
||||
) -> Vec<Rectangle> {
|
||||
fn visual_lines<P: Paragraph>(paragraph: &P, span_count: usize) -> Vec<Rectangle> {
|
||||
let mut lines: Vec<Rectangle> = Vec::new();
|
||||
|
||||
for span in 0..span_count {
|
||||
@@ -820,10 +753,7 @@ fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let right = (a.x + a.width).max(b.x + b.width);
|
||||
let bottom = (a.y + a.height).max(b.y + b.height);
|
||||
|
||||
Rectangle::new(
|
||||
Point::new(left, top),
|
||||
Size::new(right - left, bottom - top),
|
||||
)
|
||||
Rectangle::new(Point::new(left, top), Size::new(right - left, bottom - top))
|
||||
}
|
||||
|
||||
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
|
||||
|
||||
@@ -21,7 +21,7 @@ use iroh::endpoint::presets;
|
||||
use iroh::protocol::Router;
|
||||
use iroh::{Endpoint, RelayMode};
|
||||
|
||||
use peerspeak::files::{ChatAttachment, AttachmentKind};
|
||||
use peerspeak::files::{AttachmentKind, ChatAttachment};
|
||||
use peerspeak::network::NetworkTransport;
|
||||
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
||||
use peerspeak::protocol::FILES_ALPN;
|
||||
@@ -52,7 +52,12 @@ async fn spawn_node() -> Node {
|
||||
.accept(FILES_ALPN, file_router)
|
||||
.spawn();
|
||||
|
||||
Node { endpoint, transport, _router: router, lookup }
|
||||
Node {
|
||||
endpoint,
|
||||
transport,
|
||||
_router: router,
|
||||
lookup,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
||||
@@ -86,7 +91,9 @@ async fn loopback_attachment_round_trips_intact() {
|
||||
|
||||
let blob = big_blob();
|
||||
let id = [42u8; 32];
|
||||
server.transport.serve_attachment(id, Arc::new(blob.clone()));
|
||||
server
|
||||
.transport
|
||||
.serve_attachment(id, Arc::new(blob.clone()));
|
||||
|
||||
let att = ChatAttachment {
|
||||
name: "exterior-landscape.jpg".to_string(),
|
||||
|
||||
@@ -62,7 +62,7 @@ async fn evicted_within(
|
||||
Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true,
|
||||
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft
|
||||
Ok(None) => return false, // channel closed
|
||||
Err(_) => return false, // timed out — no eviction
|
||||
Err(_) => return false, // timed out — no eviction
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ async fn left_within(
|
||||
Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true,
|
||||
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected
|
||||
Ok(None) => return false, // channel closed
|
||||
Err(_) => return false, // timed out — no leave
|
||||
Err(_) => return false, // timed out — no leave
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-11
@@ -23,8 +23,8 @@ use iroh::protocol::{AcceptError, ProtocolHandler};
|
||||
use peerspeak::codec::AudioEncoder;
|
||||
use peerspeak::codec::opus_impl::OpusEncoder;
|
||||
use peerspeak::core::jitter::{FRAME_SAMPLES, JitterBuffer};
|
||||
use peerspeak::network::{ConnEvent, NetworkTransport};
|
||||
use peerspeak::network::iroh_impl::{AudioRouter, IrohTransport};
|
||||
use peerspeak::network::{ConnEvent, NetworkTransport};
|
||||
use peerspeak::protocol::AUDIO_ALPN;
|
||||
|
||||
struct Node {
|
||||
@@ -91,7 +91,12 @@ async fn spawn_capture_peer(secret: iroh::SecretKey) -> CapturePeer {
|
||||
.accept(AUDIO_ALPN, CaptureProtocol { conns_tx })
|
||||
.spawn();
|
||||
|
||||
CapturePeer { endpoint, _router: router, lookup, conns_rx }
|
||||
CapturePeer {
|
||||
endpoint,
|
||||
_router: router,
|
||||
lookup,
|
||||
conns_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a node with a specific secret key. Reusing a key gives the respawned
|
||||
@@ -208,7 +213,11 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
|
||||
received += 1;
|
||||
|
||||
if let Some(frame) = jitter.pop_frame() {
|
||||
assert_eq!(frame.len(), FRAME_SAMPLES, "decoded frame is one 20ms frame");
|
||||
assert_eq!(
|
||||
frame.len(),
|
||||
FRAME_SAMPLES,
|
||||
"decoded frame is one 20ms frame"
|
||||
);
|
||||
decoded_frames += 1;
|
||||
}
|
||||
if received >= N {
|
||||
@@ -290,7 +299,11 @@ async fn dialer_reconnects_after_link_drops() {
|
||||
.expect("timed out awaiting initial connection")
|
||||
.expect("connection channel closed");
|
||||
assert!(
|
||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await,
|
||||
await_reconnect(
|
||||
&mut conn_events,
|
||||
tokio::time::Instant::now() + Duration::from_secs(10)
|
||||
)
|
||||
.await,
|
||||
"initial link should report Connecting then Connected"
|
||||
);
|
||||
|
||||
@@ -306,7 +319,11 @@ async fn dialer_reconnects_after_link_drops() {
|
||||
.expect("timed out awaiting reconnect")
|
||||
.expect("connection channel closed");
|
||||
assert!(
|
||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await,
|
||||
await_reconnect(
|
||||
&mut conn_events,
|
||||
tokio::time::Instant::now() + Duration::from_secs(15)
|
||||
)
|
||||
.await,
|
||||
"dropped link should report Connecting (down) then Connected (recovered)"
|
||||
);
|
||||
|
||||
@@ -319,7 +336,12 @@ async fn dialer_reconnects_after_link_drops() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
|
||||
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await;
|
||||
let received = count_audio(
|
||||
&conn2,
|
||||
25,
|
||||
tokio::time::Instant::now() + Duration::from_secs(3),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
received >= 20,
|
||||
"audio should resume after reconnect; got {received} frames"
|
||||
@@ -359,7 +381,11 @@ async fn dialer_reports_left_on_graceful_close() {
|
||||
.expect("timed out awaiting initial connection")
|
||||
.expect("connection channel closed");
|
||||
assert!(
|
||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await,
|
||||
await_reconnect(
|
||||
&mut conn_events,
|
||||
tokio::time::Instant::now() + Duration::from_secs(10)
|
||||
)
|
||||
.await,
|
||||
"initial link should report Connecting then Connected"
|
||||
);
|
||||
|
||||
@@ -384,7 +410,10 @@ async fn dialer_reports_left_on_graceful_close() {
|
||||
|
||||
// And no re-dial reaches the peer within a short window.
|
||||
let redial = tokio::time::timeout(Duration::from_secs(2), peer.conns_rx.recv()).await;
|
||||
assert!(redial.is_err(), "supervisor must not re-dial after a graceful leave");
|
||||
assert!(
|
||||
redial.is_err(),
|
||||
"supervisor must not re-dial after a graceful leave"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -429,7 +458,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
|
||||
.expect("timed out awaiting initial connection (retained address path)")
|
||||
.expect("connection channel closed");
|
||||
assert!(
|
||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(10)).await,
|
||||
await_reconnect(
|
||||
&mut conn_events,
|
||||
tokio::time::Instant::now() + Duration::from_secs(10)
|
||||
)
|
||||
.await,
|
||||
"initial link should report Connecting then Connected"
|
||||
);
|
||||
|
||||
@@ -443,7 +476,11 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
|
||||
.expect("timed out awaiting reconnect (retained address path)")
|
||||
.expect("connection channel closed");
|
||||
assert!(
|
||||
await_reconnect(&mut conn_events, tokio::time::Instant::now() + Duration::from_secs(15)).await,
|
||||
await_reconnect(
|
||||
&mut conn_events,
|
||||
tokio::time::Instant::now() + Duration::from_secs(15)
|
||||
)
|
||||
.await,
|
||||
"reconnect should report Connecting then Connected with no lookup at all"
|
||||
);
|
||||
|
||||
@@ -455,7 +492,12 @@ async fn dialer_connects_and_reconnects_without_an_address_lookup() {
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
|
||||
let received = count_audio(&conn2, 25, tokio::time::Instant::now() + Duration::from_secs(3)).await;
|
||||
let received = count_audio(
|
||||
&conn2,
|
||||
25,
|
||||
tokio::time::Instant::now() + Duration::from_secs(3),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
received >= 20,
|
||||
"audio should resume after reconnecting via the retained address; got {received} frames"
|
||||
|
||||
Reference in New Issue
Block a user