Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d7b01c8a2 | ||
|
|
5f4eba1815 | ||
|
|
77d2bf2992 | ||
|
|
c8d0053431 | ||
|
|
1d038be9a0 | ||
|
|
554b613466 | ||
|
|
8898652349 | ||
|
|
5927148ee4 | ||
|
|
93f4954653 | ||
|
|
e6eb490939 | ||
|
|
e724167b03 | ||
|
|
7b9cb57003 | ||
|
|
af7a42a049 | ||
|
|
e78e7bc2a5 | ||
|
|
52ab374b74 | ||
|
|
8825707c17 | ||
|
|
76c62e5ac3 | ||
|
|
d2432740c1 | ||
|
|
99a4a336ad | ||
|
|
df45c0bfeb | ||
|
|
faad8ce26a | ||
|
|
e378b2e33b | ||
|
|
96e41de1b1 | ||
|
|
5c888f8357 | ||
|
|
074f004227 | ||
|
|
2d22036930 | ||
|
|
8014edf91c | ||
|
|
89d5218d25 | ||
|
|
f3c7aa7050 | ||
|
|
39b5dafd57 | ||
|
|
a78860db15 | ||
|
|
5f52aa1506 | ||
|
|
d92d0f6f6b | ||
|
|
551767f9f5 | ||
|
|
fa90cd3ce9 | ||
|
|
660261a9a5 | ||
|
|
3a74fd0230 | ||
|
|
2dbb1ea316 | ||
|
|
c902db2e90 | ||
|
|
83e5881768 | ||
|
|
8424b44dec | ||
|
|
33e49a8ca7 | ||
|
|
393c1c7f09 | ||
|
|
1bf14ba08f | ||
|
|
6f14d2668d | ||
|
|
49c3ce8c0a | ||
|
|
aec21a48d5 | ||
|
|
d0a16cb8b9 | ||
|
|
e0325d4590 | ||
|
|
e8a894be49 | ||
|
|
8c33b5c70f | ||
|
|
10bd15aeaa | ||
|
|
8ad0bea19d | ||
|
|
abb53af559 | ||
|
|
c0c1969332 | ||
|
|
d155091eed | ||
|
|
b553a94875 | ||
|
|
0aaf6be529 | ||
|
|
d059386aee | ||
|
|
79a091b1b3 | ||
|
|
c1de7efbc5 | ||
|
|
96e3e0ba10 | ||
|
|
bf4d9b100f | ||
|
|
91ef5b0a72 | ||
|
|
47be7c340d | ||
|
|
1c8c37b248 | ||
|
|
08792809d6 | ||
|
|
9ff7c7b99c | ||
|
|
3ff0945866 | ||
|
|
618a53027d | ||
|
|
f293181626 | ||
|
|
bca2ccd6a4 | ||
|
|
6054f0ecf9 | ||
|
|
c2e27c3367 | ||
|
|
3c678afaf7 | ||
|
|
8cfb5beff9 | ||
|
|
f21a027e78 | ||
|
|
663956deaa | ||
|
|
abc8531cfa | ||
|
|
f0961a2049 | ||
|
|
32ee00178e | ||
|
|
7d9ffbd3a4 | ||
|
|
01150ff249 | ||
|
|
a6d9a8cbd4 | ||
|
|
a4bb6ce0be | ||
|
|
ebfc39de46 | ||
|
|
e3ff778d5b | ||
|
|
9a059e1bb8 | ||
|
|
4dc1bcd546 | ||
|
|
067997f9ba | ||
|
|
660eb27a84 | ||
|
|
913b0b6b20 | ||
|
|
36fb8bfa9a | ||
|
|
2e9164745f | ||
|
|
3b640726d7 | ||
|
|
381e00bc0e | ||
|
|
1a3c481f4c | ||
|
|
f927567105 | ||
|
|
5c11947bd7 | ||
|
|
7349744d16 | ||
|
|
a6a88d15c0 | ||
|
|
a17b930524 | ||
|
|
6100abef33 | ||
|
|
49bd2ba687 | ||
|
|
6b0b23ef69 | ||
|
|
f422150c84 | ||
|
|
86d333d4dc | ||
|
|
fad65a4fcf | ||
|
|
3878e716dd | ||
|
|
961705ffa9 | ||
|
|
7d44808a5e | ||
|
|
e31d3db986 | ||
|
|
87a2209a85 | ||
|
|
9e8c8b4ace | ||
|
|
7e4f2f2127 | ||
|
|
b6eac330ca | ||
|
|
7fb1c96ca9 | ||
|
|
80a5b73e39 | ||
|
|
adf7d1c0e2 | ||
|
|
bcb597a0ea | ||
|
|
79b24fd567 | ||
|
|
efadc228eb |
@@ -0,0 +1,11 @@
|
|||||||
|
# cargo-audit configuration. Keep the ignore list in sync with deny.toml,
|
||||||
|
# which carries the full justification for each entry.
|
||||||
|
[advisories]
|
||||||
|
ignore = [
|
||||||
|
# quick-xml DoS advisories: build-time only, reached solely via the
|
||||||
|
# wayland-scanner proc-macro parsing trusted vendored protocol XML.
|
||||||
|
# Fix (0.41.0) is semver-incompatible with wayland-scanner's `^0.39`;
|
||||||
|
# drop once wayland-scanner bumps. See deny.toml.
|
||||||
|
"RUSTSEC-2026-0194",
|
||||||
|
"RUSTSEC-2026-0195",
|
||||||
|
]
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
name: cargo-deny
|
|
||||||
|
|
||||||
# Enforce the supply-chain policy in deny.toml (advisories / bans / licenses /
|
|
||||||
# sources) on every push to main and every PR. Runs on a *locked* tree so the
|
|
||||||
# pinned, vetted versions in Cargo.lock are exactly what get audited — see the
|
|
||||||
# deny.toml header and VERSIONING.md. A new poisoned release of a dependency
|
|
||||||
# cannot reach CI until Cargo.lock is deliberately updated.
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cargo-deny:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# rust:1 provides the cargo toolchain that cargo-deny shells out to for
|
|
||||||
# `cargo metadata`. Adjust the runner label if your act_runner uses a
|
|
||||||
# different one.
|
|
||||||
container: rust:1
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install cargo-deny (pinned prebuilt)
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
version=0.19.9
|
|
||||||
curl -sSfL \
|
|
||||||
"https://github.com/EmbarkStudios/cargo-deny/releases/download/${version}/cargo-deny-${version}-x86_64-unknown-linux-musl.tar.gz" \
|
|
||||||
| tar -xz -C /usr/local/bin --strip-components=1 --wildcards '*/cargo-deny'
|
|
||||||
cargo-deny --version
|
|
||||||
|
|
||||||
- name: cargo deny check
|
|
||||||
run: cargo deny --locked check
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
# Runs on the self-hosted host-mode runner on the desktop (label `arch`). The
|
||||||
|
# gitbutter VPS only queues the job; all compile/test compute happens locally.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: arch
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Toolchain versions
|
||||||
|
run: |
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
cargo clippy --version
|
||||||
|
cargo deny --version
|
||||||
|
cargo audit --version
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Clippy (all targets, warnings as errors)
|
||||||
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: cargo test --all-targets
|
||||||
|
|
||||||
|
- name: Doc tests
|
||||||
|
run: cargo test --doc
|
||||||
|
|
||||||
|
- name: cargo-deny (advisories, bans, licenses, sources)
|
||||||
|
# --locked so the pinned, vetted versions in Cargo.lock are exactly
|
||||||
|
# what get audited (the lockfile-as-review-checkpoint model).
|
||||||
|
run: cargo deny --locked check
|
||||||
|
|
||||||
|
- name: cargo-audit
|
||||||
|
run: cargo audit
|
||||||
@@ -7,11 +7,20 @@ name: windows-build
|
|||||||
# alias) so a Unix-only assumption can't sneak back in and break Windows.
|
# alias) so a Unix-only assumption can't sneak back in and break Windows.
|
||||||
#
|
#
|
||||||
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
|
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
|
||||||
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
|
# `windows-latest` label (a Linux-container approach does NOT apply here —
|
||||||
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
|
# Windows jobs run on the host, not a Linux container). If your runner
|
||||||
# runner advertises a different label, change `runs-on` below. Until a Windows
|
# advertises a different label, change `runs-on` below.
|
||||||
# runner exists this workflow is simply skipped/queued, not a failure of the
|
#
|
||||||
# Linux CI.
|
# MANUAL-ONLY until that runner exists: with push/PR triggers enabled, every
|
||||||
|
# push queued a run no runner could claim and Gitea auto-cancelled it ~24h
|
||||||
|
# later, littering the Actions page with cancelled runs. Restore the push/PR
|
||||||
|
# triggers when a Windows runner is registered:
|
||||||
|
#
|
||||||
|
# on:
|
||||||
|
# push:
|
||||||
|
# branches: [main, "windows-port-**"]
|
||||||
|
# pull_request:
|
||||||
|
# workflow_dispatch:
|
||||||
#
|
#
|
||||||
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
|
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
|
||||||
# peerspeak-windows-opus-spike.md):
|
# peerspeak-windows-opus-spike.md):
|
||||||
@@ -23,12 +32,7 @@ name: windows-build
|
|||||||
# must provide both.
|
# must provide both.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
# Manual runs from the Gitea Actions UI only — see the header comment.
|
||||||
# `main` plus the in-progress port branches, so the Windows path is exercised
|
|
||||||
# before merge rather than only after.
|
|
||||||
branches: [main, "windows-port-**"]
|
|
||||||
pull_request:
|
|
||||||
# Allow manual runs from the Gitea Actions UI.
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to PeerSpeak are documented here.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.4] — 2026-07-18
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Chat now tells you when a message didn't send.** A message that couldn't go
|
||||||
|
out — because you weren't in a room, or the broadcast failed — is marked
|
||||||
|
**"⚠ Not sent"** with a **Retry** button, instead of sitting in the transcript
|
||||||
|
looking delivered. A successful send shows nothing (PeerSpeak has no
|
||||||
|
delivery/read receipts, so anything else would be a false promise).
|
||||||
|
- **Fast typing no longer loses messages.** When you fire off a quick burst,
|
||||||
|
messages past the first few are held as **"queued…"** and sent a moment apart,
|
||||||
|
matching the rate other people's clients accept. Previously a fast burst could
|
||||||
|
look sent on your end while some messages silently never reached the room.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Tidier music playlist drawer.** The slide-out playlist no longer repeats the
|
||||||
|
play/skip controls already on the player bar, and the track list now grows to
|
||||||
|
fill the drawer instead of being boxed into a short scroll area, so you can see
|
||||||
|
more of your playlist at once.
|
||||||
|
- **Safer chat under the hood.** A round of chat hardening tightened how incoming
|
||||||
|
messages, display names, links, and file/image attachments are validated and
|
||||||
|
bounded, so a malformed or hostile message from a peer can't spoof a name,
|
||||||
|
replay, flood, or run the app out of memory. No change to how normal chat looks
|
||||||
|
or works.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Burst packet loss no longer splices the wrong audio into the gap.** Loss
|
||||||
|
concealment used Opus in-band FEC even when the next packet to arrive wasn't
|
||||||
|
the one immediately after the gap, so losing several packets in a row could
|
||||||
|
briefly play a later frame's audio in the wrong position. FEC now only
|
||||||
|
reconstructs a gap from its immediate successor packet; larger gaps are
|
||||||
|
concealed normally.
|
||||||
|
- **A failed network restart no longer silently kills the app.** Changing the
|
||||||
|
network mode (or regenerating your identity) rebuilds the connection stack;
|
||||||
|
if that rebuild failed — rare, but possible when the local socket can't
|
||||||
|
bind — PeerSpeak kept its window open but silently stopped responding to
|
||||||
|
every command. It now falls back to your previous network settings and says
|
||||||
|
so, and only gives up (with a clear error telling you to restart) if even
|
||||||
|
the fallback fails.
|
||||||
|
|
||||||
|
[0.6.4]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.4
|
||||||
|
|
||||||
|
## [0.6.3] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **In-app screen-sharing controls.** A new **Screen sharing** section in Settings, plus a per-call **quality picker** on the Share control, put the whole share pipeline under your control without editing config files. Encode side: quality preset, bitrate, framerate, maximum resolution, maximum viewers, a force-software-encode switch, and an escape hatch for extra pixelpass arguments. Playback side: choose **mpv or VLC**, toggle **hardware decoding**, pick a buffering posture (low-latency vs. smooth), set the demuxer cache, and pass extra mpv arguments. Everything is stored locally in your config and defaults are unchanged, so existing setups keep working as-is.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Shared video no longer freezes on the first frame while audio keeps playing.** Hardware decoding now defaults **off**; forcing `--hwdec=auto` stalled some viewers' hardware decoder on frame 1. You can re-enable hardware decoding from the new Screen sharing settings if your machine handles it well.
|
||||||
|
- **The per-call quality picker is now honored.** The inline quality dropdown next to the Share button was being reset to the saved default before a share started, so every share silently used the default quality regardless of what you picked.
|
||||||
|
- **VLC now respects your playback settings.** VLC hardware-decodes by default, so a VLC viewer previously ignored the hardware-decode toggle (and could hit the same frame-1 freeze) and the buffering posture. VLC viewers now map both settings onto VLC's own options.
|
||||||
|
|
||||||
|
[0.6.3]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.3
|
||||||
|
|
||||||
|
## [0.6.2] — 2026-07-03
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Manual "⟳ Rescan" button** on the Friends panel that refreshes everyone's presence immediately, instead of waiting for the next auto-refresh.
|
||||||
|
|
||||||
|
### Licensing
|
||||||
|
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
|
||||||
|
|
||||||
|
[0.6.2]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.2
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-06-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Shared music listening (W22).** A new **Playlist** panel lets you build a personal queue of local audio files and play them on a dedicated music player — Browse to add tracks, play/pause, previous/next, seek, per-track reorder, remove, and a local volume slider, all persisted across sessions. `.pls` and `.m3u` playlists can be imported (remote and non-audio entries are skipped).
|
||||||
|
- **Tune in to a friend's music.** Flip **"Let others tune in"** and peers see your current track under the **Public** tab; one click on **Listen** streams it to them. Playback is **timeline-synced** — play, pause, skip, and seek mirror across everyone with no drift — and the next track is **prefetched for gapless** transitions. Each listener gets an independent **per-source volume**, so music sits under voice at whatever level they like; voice chat stays fully audible throughout.
|
||||||
|
- **Standalone Playlist card in the 3-Column layout.** The playlist now lives in its own card stacked under the chat, with a draggable divider to resize it and its own scrollbar when space is tight. The other layouts keep the playlist in the Controls panel.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Shared-music metadata is treated as untrusted: the broadcast track name is sanitized and its size is cap-checked at gossip ingest, fetched bytes are confirmed to be audio before decoding, and only a small descriptor ever rides gossip — track bytes move point-to-point over the existing files plane, one fetch in flight at a time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Wire protocol bump (gossip v5).** Shared listening adds presence fields, so **0.6.0 peers cannot share a swarm with 0.5.x peers** — everyone in a room must update together.
|
||||||
|
|
||||||
|
[0.6.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.0
|
||||||
|
|
||||||
|
## [0.5.1] — 2026-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
|
||||||
|
|
||||||
|
## [0.5.0] — 2026-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Right-click context menu** (Cut / Copy / Paste / Select All) on every text-entry field. (A9)
|
||||||
|
- **Selectable, copyable text** for values that used to be read-only: your full node ID and the room ticket can now be click-selected and copied, and **chat messages are drag-selectable** (highlight + Ctrl+C / Ctrl+A) while clickable links keep working. (W21 Phase 1 + 2)
|
||||||
|
- **Clock-skew warning**: when a peer can't be seen because the two systems' clocks differ by more than the replay-protection window, PeerSpeak now shows a "your clocks are out of sync" banner instead of failing silently. (A25)
|
||||||
|
- **Per-application audio capture for screen-share**, removing the call-audio loopback echo when sharing a window, plus surfacing of pixelpass startup errors. (A23)
|
||||||
|
|
||||||
|
### Changed / Fixed
|
||||||
|
- **Critical commands are now delivered reliably under load** — muting, releasing push-to-talk, and leaving a room can no longer be silently dropped while a slider is being dragged (prevents a hot-mic state mismatch). (A15)
|
||||||
|
- Screen-share now passes `--strict-audio` and surfaces app-audio drop warnings; the source picker state machine and pactl handling were hardened. (A23)
|
||||||
|
- Per-peer volume control path verified and covered by regression tests. (A24)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Hardened against insider resource-exhaustion: bounded + author-keyed chat attachment cache, capped recovery-identity state, and other Tier-C caps (F-01 / F-02 / F-03 / F-12).
|
||||||
|
|
||||||
|
### Packaging
|
||||||
|
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
|
||||||
|
- Arch `PKGBUILD` clones over anonymous HTTPS.
|
||||||
|
|
||||||
|
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
|
||||||
|
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
|
||||||
|
|
||||||
|
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
|
||||||
@@ -117,6 +117,18 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alsa"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||||
|
dependencies = [
|
||||||
|
"alsa-sys",
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alsa-sys"
|
name = "alsa-sys"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -188,9 +200,9 @@ checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.103"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
@@ -1041,6 +1053,20 @@ dependencies = [
|
|||||||
"coreaudio-sys",
|
"coreaudio-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coreaudio-rs"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"libc",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coreaudio-sys"
|
name = "coreaudio-sys"
|
||||||
version = "0.2.18"
|
version = "0.2.18"
|
||||||
@@ -1080,14 +1106,14 @@ version = "0.15.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alsa",
|
"alsa 0.9.1",
|
||||||
"core-foundation-sys",
|
"core-foundation-sys",
|
||||||
"coreaudio-rs",
|
"coreaudio-rs 0.11.3",
|
||||||
"dasp_sample",
|
"dasp_sample",
|
||||||
"jni 0.21.1",
|
"jni 0.21.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"mach2",
|
"mach2 0.4.3",
|
||||||
"ndk 0.8.0",
|
"ndk 0.8.0",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"oboe",
|
"oboe",
|
||||||
@@ -1097,6 +1123,36 @@ dependencies = [
|
|||||||
"windows 0.54.0",
|
"windows 0.54.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpal"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||||
|
dependencies = [
|
||||||
|
"alsa 0.10.0",
|
||||||
|
"coreaudio-rs 0.13.0",
|
||||||
|
"dasp_sample",
|
||||||
|
"jni 0.21.1",
|
||||||
|
"js-sys",
|
||||||
|
"libc",
|
||||||
|
"mach2 0.5.0",
|
||||||
|
"ndk 0.9.0",
|
||||||
|
"ndk-context",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-audio-toolbox",
|
||||||
|
"objc2-avf-audio",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"windows 0.62.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1151,9 +1207,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.18"
|
version = "0.9.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
|
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
@@ -1564,6 +1620,15 @@ version = "0.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "encoding_rs"
|
||||||
|
version = "0.8.35"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "endi"
|
name = "endi"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -1699,6 +1764,12 @@ dependencies = [
|
|||||||
"zune-inflate",
|
"zune-inflate",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "extended"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.4.1"
|
||||||
@@ -2557,6 +2628,7 @@ dependencies = [
|
|||||||
"iced_core",
|
"iced_core",
|
||||||
"log",
|
"log",
|
||||||
"rustc-hash 2.1.2",
|
"rustc-hash 2.1.2",
|
||||||
|
"tokio",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasmtimer",
|
"wasmtimer",
|
||||||
]
|
]
|
||||||
@@ -3565,6 +3637,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mach2"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "malloc_buf"
|
name = "malloc_buf"
|
||||||
version = "0.0.6"
|
version = "0.0.6"
|
||||||
@@ -3601,9 +3682,9 @@ checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.10"
|
version = "0.9.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
|
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
@@ -4252,6 +4333,31 @@ dependencies = [
|
|||||||
"objc2-quartz-core 0.3.2",
|
"objc2-quartz-core 0.3.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-audio-toolbox"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"libc",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-audio",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-avf-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||||
|
dependencies = [
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-cloud-kit"
|
name = "objc2-cloud-kit"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4287,6 +4393,29 @@ dependencies = [
|
|||||||
"objc2-foundation 0.2.2",
|
"objc2-foundation 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||||
|
dependencies = [
|
||||||
|
"dispatch2",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
"objc2-core-audio-types",
|
||||||
|
"objc2-core-foundation",
|
||||||
|
"objc2-foundation 0.3.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "objc2-core-audio-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"objc2 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "objc2-core-data"
|
name = "objc2-core-data"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -4742,13 +4871,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.6.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cpal",
|
"cpal 0.15.3",
|
||||||
"dirs",
|
"dirs",
|
||||||
"iced",
|
"iced",
|
||||||
"image",
|
"image",
|
||||||
@@ -4759,11 +4888,14 @@ dependencies = [
|
|||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
"rfd",
|
"rfd",
|
||||||
"ringbuf",
|
"ringbuf",
|
||||||
|
"rodio",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
|
"url",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5198,6 +5330,16 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_distr"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"rand 0.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_pcg"
|
name = "rand_pcg"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -5487,12 +5629,34 @@ dependencies = [
|
|||||||
"portable-atomic-util",
|
"portable-atomic-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rodio"
|
||||||
|
version = "0.22.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||||
|
dependencies = [
|
||||||
|
"cpal 0.17.1",
|
||||||
|
"dasp_sample",
|
||||||
|
"num-rational",
|
||||||
|
"rand 0.10.1",
|
||||||
|
"rand_distr",
|
||||||
|
"rtrb",
|
||||||
|
"symphonia",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "roxmltree"
|
name = "roxmltree"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtrb"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -6156,6 +6320,153 @@ dependencies = [
|
|||||||
"zeno",
|
"zeno",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"symphonia-bundle-flac",
|
||||||
|
"symphonia-bundle-mp3",
|
||||||
|
"symphonia-codec-aac",
|
||||||
|
"symphonia-codec-pcm",
|
||||||
|
"symphonia-codec-vorbis",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-format-isomp4",
|
||||||
|
"symphonia-format-ogg",
|
||||||
|
"symphonia-format-riff",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-flac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-bundle-mp3"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-aac"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-pcm"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-codec-vorbis"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-core"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"bytemuck",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-isomp4"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-ogg"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
"symphonia-utils-xiph",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-format-riff"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||||
|
dependencies = [
|
||||||
|
"extended",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-metadata"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||||
|
dependencies = [
|
||||||
|
"encoding_rs",
|
||||||
|
"lazy_static",
|
||||||
|
"log",
|
||||||
|
"symphonia-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "symphonia-utils-xiph"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||||
|
dependencies = [
|
||||||
|
"symphonia-core",
|
||||||
|
"symphonia-metadata",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.117"
|
version = "2.0.117"
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
version = "0.3.0"
|
version = "0.6.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
# cargo-deny's [licenses.private] skip the missing-license check.
|
license = "MIT"
|
||||||
|
# Application crate, not published to crates.io — refuse `cargo publish`.
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
# Debian/Ubuntu packaging (cargo-deb). Mirrors packaging/PKGBUILD: only the main
|
||||||
|
# `peerspeak` binary ships (not test_net/specview), plus the desktop entry and the
|
||||||
|
# hicolor icon set. Runtime shared-lib deps (libpipewire, libopus, libc, …) are
|
||||||
|
# resolved by dpkg-shlibdeps via `depends = "$auto"`. Build inside a Debian/Ubuntu
|
||||||
|
# distrobox so the binary links that distro's glibc, then `cargo deb --no-build`.
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "mollusk <jitty+lc1iz0dc@protonmail.com>"
|
||||||
|
copyright = "2026, mollusk. MIT License."
|
||||||
|
section = "net"
|
||||||
|
priority = "optional"
|
||||||
|
depends = "$auto"
|
||||||
|
# pixelpass = in-room screen sharing; mpv = the screen-share viewer (vlc fallback).
|
||||||
|
recommends = "pixelpass, mpv"
|
||||||
|
extended-description = "Decentralized peer-to-peer voice chat over iroh (QUIC) with PipeWire audio, the Opus codec, and an iced GUI. Full-mesh, no central server."
|
||||||
|
assets = [
|
||||||
|
["target/release/peerspeak", "usr/bin/", "755"],
|
||||||
|
["packaging/peerspeak.desktop", "usr/share/applications/", "644"],
|
||||||
|
["assets/icons/peerspeak.svg", "usr/share/icons/hicolor/scalable/apps/peerspeak.svg", "644"],
|
||||||
|
["assets/icons/peerspeak-16.png", "usr/share/icons/hicolor/16x16/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-24.png", "usr/share/icons/hicolor/24x24/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-32.png", "usr/share/icons/hicolor/32x32/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-48.png", "usr/share/icons/hicolor/48x48/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-64.png", "usr/share/icons/hicolor/64x64/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-128.png", "usr/share/icons/hicolor/128x128/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-256.png", "usr/share/icons/hicolor/256x256/apps/peerspeak.png", "644"],
|
||||||
|
["assets/icons/peerspeak-512.png", "usr/share/icons/hicolor/512x512/apps/peerspeak.png", "644"],
|
||||||
|
["LICENSE", "usr/share/doc/peerspeak/", "644"],
|
||||||
|
["THIRD_PARTY_LICENSES", "usr/share/doc/peerspeak/", "644"],
|
||||||
|
]
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "peerspeak"
|
name = "peerspeak"
|
||||||
path = "src/lib.rs"
|
path = "src/lib.rs"
|
||||||
@@ -28,7 +59,7 @@ async-trait = "0.1.89"
|
|||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
bytes = "1.11.1"
|
bytes = "1.11.1"
|
||||||
dirs = "6.0.0"
|
dirs = "6.0.0"
|
||||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] }
|
||||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||||
@@ -38,11 +69,16 @@ iroh-gossip = "0.99.0"
|
|||||||
opus = "0.3.1"
|
opus = "0.3.1"
|
||||||
rand = "0.10.1"
|
rand = "0.10.1"
|
||||||
ringbuf = "0.5.0"
|
ringbuf = "0.5.0"
|
||||||
|
rodio = "0.22.2"
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
serde_json = "1.0.150"
|
serde_json = "1.0.150"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.18"
|
||||||
tokio = { version = "1.52.3", features = ["full"] }
|
tokio = { version = "1.52.3", features = ["full"] }
|
||||||
tokio-stream = "0.1.18"
|
tokio-stream = "0.1.18"
|
||||||
|
# Chat link policy: parse + validate clickable URL candidates (scheme/host/
|
||||||
|
# userinfo checks in `sanitize::is_safe_web_url`). Already in the tree
|
||||||
|
# transitively via iroh — this only promotes it to a direct dependency.
|
||||||
|
url = "2.5"
|
||||||
|
|
||||||
# --- Platform-specific dependencies -----------------------------------------
|
# --- Platform-specific dependencies -----------------------------------------
|
||||||
# Audio and the native file-picker backends differ per OS. Everything else in the
|
# Audio and the native file-picker backends differ per OS. Everything else in the
|
||||||
@@ -64,3 +100,12 @@ rfd = { version = "0.17", default-features = false }
|
|||||||
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
|
||||||
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
|
||||||
cpal = "0.15"
|
cpal = "0.15"
|
||||||
|
# Win32 FFI for game detection (no new crate: windows-sys is already pulled in
|
||||||
|
# transitively by cpal/rfd). Registry reads the Steam RunningAppID + install path;
|
||||||
|
# Toolhelp enumerates running processes for the non-Steam process-scan fallback.
|
||||||
|
windows-sys = { version = "0.61", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_System_Registry",
|
||||||
|
"Win32_System_Diagnostics_ToolHelp",
|
||||||
|
"Win32_System_Threading",
|
||||||
|
] }
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
# Example entry in an antigravity.toml configuration file
|
|
||||||
[agent]
|
|
||||||
model = "gemini-3.5-flash"
|
|
||||||
system_instruction = """
|
|
||||||
You are a senior-level, terminal-native Rust systems engineer and an expert programming assistant. Your goal is to help me design, build, and refactor a decentralized, peer-to-peer (P2P) voice communication application modeled after Mumble, utilizing the Iroh network stack for NAT holepunching and QUIC stream orchestration.
|
|
||||||
|
|
||||||
### 0. How You Work — Operating Principles (read first)
|
|
||||||
Capability is not the constraint here; judgment is. These govern HOW you approach every task in this repo, and the project-specific sections below make them concrete.
|
|
||||||
- **Understand before you act.** Read the actual code and the local docs (Sections 1, 8) before changing anything — never reason from memory about an API, type, or signature; open it and confirm. This is Rule 1 made operational. Orient in the codebase (Section 5) and honor its trait boundaries and idioms — you are editing a mature codebase, not starting fresh, so match its naming, error-handling, and comment density.
|
|
||||||
- **Measure before you theorize — the single most important habit.** When debugging, get EVIDENCE before asserting a cause: instrument it, log it, reproduce it, read the real output. A plausible-sounding mechanism is a hypothesis, not a diagnosis. If the data contradicts your theory, drop the theory — do not bend the evidence to fit it. (The playback-crackle bug was only solved once the actual per-cycle PipeWire quantum was measured; every "reasoned" guess before that missed.)
|
|
||||||
- **Root-cause, don't patch symptoms.** Trace a bug to the exact mechanism that produces it; a fix you cannot explain is a coincidence waiting to break. Make the smallest change that addresses the real cause — don't expand scope or refactor unasked. Flag adjacent problems; don't silently fold them in.
|
|
||||||
- **"Compiles" and "tests pass" are NOT "it works."** These are three separate claims — builds-clean, tests-green, and field-verified-by-running-it — and you must state which you have actually reached (this reinforces Section 7). For this app, "verified" means a real run/call was observed behaving correctly (clean audio heard by ear, a reconnect watched in the logs), not that the suite passed. Never announce a fix as working on tests alone; explicitly label untested or tests-only work as "unverified."
|
|
||||||
- **Surface the forks on real decisions.** When a task has genuine tradeoffs (architecture, a new dependency, an irreversible change), lay out the realistic options with their costs and let me choose BEFORE you build. For a choice with an obvious default and no downside, just pick it, say what you picked, and proceed — don't manufacture decisions.
|
|
||||||
- **Report honestly.** If it failed, say so and show the evidence. If you assumed or skipped something, say that. When something is genuinely done and verified, say so plainly without hedging. If new evidence contradicts something you stated confidently, correct yourself explicitly. "I verified X" and "I believe X" are different claims — use the right one. Never fabricate APIs, file paths, or results; if unsure, say "I'm not sure" and go confirm (Rule 1).
|
|
||||||
- **Treat dependencies as a liability.** Prefer the standard library, tools already on the system, or a few lines of your own over pulling in a crate — I vet dependencies for supply-chain risk. Justify any addition, and default to safe Rust (Rule 3).
|
|
||||||
- **Know when NOT to do what I ask.** Doing the task is the default, but stop and confirm or push back when: the action is hard to reverse or outward-facing — pushing, publishing, deploying the binary to the other machine, deleting/overwriting files you did not create (confirm first; for git commits specifically, see Rule 4); the request rests on a false premise or contradicts what you find in the code (surface that instead of plowing ahead); compliance would introduce real risk — data loss, a security/privacy regression (e.g. changing the `RelayNoDiscovery` default, see Section 6), an `unsafe` block, or a heavy dependency (name the risk and offer a safer path); or the scope is ambiguous (confirm rather than over-building — build X, not X plus extras). Don't merely comply and don't merely refuse — offer the better route.
|
|
||||||
- **Work in checkpoints; keep state durable.** Give a short plan and a rough scope/effort estimate up front so I can redirect or defer (I watch a daily usage budget). Phase large work so it can pause cleanly. The handoff log (Section 8) is the durable record across sessions — read it first, update it when you finish meaningful work.
|
|
||||||
- **Follow the collaboration protocol (read first, every task).** Before starting any task, read `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/operating-agreement.md`. It defines how we work as a team: a senior engineer designs and reviews the work, tasks are assigned to you in `next-task.md`, and you report back in `task-report.md`. It is in force this session and every session until that file says otherwise.
|
|
||||||
|
|
||||||
### 1. Context and Knowledge Base
|
|
||||||
You have immediate, local access to the definitive Rust documentation suite located at the absolute path: `/home/mollusk/Documents/rust_docs/`.
|
|
||||||
Before answering highly complex questions, writing macros, or optimizing code, you must reference these specific resources:
|
|
||||||
- Syntax, language invariants, and semantics: `/home/mollusk/Documents/rust_docs/rust-reference/`
|
|
||||||
- Idiomatic structural choices, patterns, and logic: `/home/mollusk/Documents/rust_docs/the-book/`
|
|
||||||
- Pointer manipulation, data layout, and undefined behavior: `/home/mollusk/Documents/rust_docs/rust-nomicon/`
|
|
||||||
- API design, trait implementations, and naming conventions: `/home/mollusk/Documents/rust_docs/rust-api-guidelines/`
|
|
||||||
|
|
||||||
### 2. Specialized Architectural Constraints
|
|
||||||
- **P2P Audio Boundary Isolation:** We are utilizing a decoupled architecture. The asynchronous network runtime (Tokio + Iroh) must be kept strictly separated from the real-time audio thread pool (PipeWire). Communication between the Iroh network consumers and the PipeWire audio streams must happen exclusively via bounded, lock-free SPSC (Single-Producer Single-Consumer) or MPSC ring buffers.
|
|
||||||
- **The "No-Alloc" Audio Rule:** Code generated for the audio processing callback or multi-stream mixer must be strictly safe and real-time safe. It must contain zero heap allocations, zero blocking synchronization primitives (no standard Mutex/RwLock), and zero blocking file/network I/O.
|
|
||||||
- **Iroh Topology:** We handle voice channels by treating every peer node as a full-mesh target. Leverage Iroh's unreliable QUIC Datagrams for raw, low-latency audio packet delivery and Iroh-Gossip (or bi-directional streams) for state synchronization (room mapping, mute states, and peer metadata).
|
|
||||||
|
|
||||||
### 3. Behavioral Boundaries and Accuracy
|
|
||||||
- **Rule 1 (Absolute Ground Truth):** Never guess or hallucinate syntax rules, compiler behavior, or API surfaces. If you are not 100% sure about a specific language feature, macro expansion, standard library behavior, or dependency change, stop and explicitly state: "I'm actually not sure about that."
|
|
||||||
- **Rule 2 (No "C in Rust"):** Do not write C-style logic wrapped in Rust syntax. Prioritize idiomatic Rust patterns (e.g., using algebraic data types, proper trait bounds, combinators like `.map()` or `.and_then()`, and precise error handling with `Result` and `Option`).
|
|
||||||
- **Rule 3 (Safe by Default):** Always default to safe, idiomatic Rust code. Do not introduce an `unsafe` block unless it is explicitly requested, or unless you can rigorously prove using *The Rustonomicon* constraints that safe Rust cannot achieve the required performance boundary.
|
|
||||||
- **Rule 4 (Git Commit Policy):** When a feature is completed, you must always ask the user for permission before committing files to git. Never commit files automatically.
|
|
||||||
|
|
||||||
### 4. Output Requirements
|
|
||||||
- **Contextual Clarity:** When providing a solution that relies on advanced language mechanics (like complex lifetimes, custom traits, or macro rules), briefly cite which local resource or module layout you used to verify the approach.
|
|
||||||
- **Code Generation:** Provide clean, production-ready code with minimal boilerplate. Use standard formatting rules (`rustfmt` styles). Include brief, high-value comments for complex borrowing logic or lifetime annotations.
|
|
||||||
- **Error Resolution:** If asked to fix a compiler or borrow-checker error, explain *why* the error occurred in terms of Rust's core memory model (ownership/borrowing/lifetimes) before providing the refactored code.
|
|
||||||
|
|
||||||
### 5. Project Map — Where Things Live
|
|
||||||
This is a mature codebase, not a greenfield project. Orient yourself in it before editing. The architecture is trait-based so implementations stay swappable; honor the boundaries.
|
|
||||||
- `src/network/mod.rs` — the `NetworkTransport` and `RoomState` traits + shared types (`PeerState`, `RoomEvent`, `ConnEvent`, `PeerSpeakTicket`). Start here to understand the seams.
|
|
||||||
- `src/network/iroh_impl.rs` — the audio transport. Per-peer **supervisor** tasks own each connection's whole lifecycle; QUIC datagrams carry audio. This is the most subtle file — see Section 6.
|
|
||||||
- `src/network/gossip.rs` — `iroh-gossip` room state: presence roster, mute/metadata sync, join/leave, address announcements feeding the `MemoryLookup`.
|
|
||||||
- `src/core/mod.rs` — the coordinator: wires capture→encode→broadcast and receive→jitter→decode→mix→playback, and bridges room/transport events to the UI. Runs on its own Tokio runtime thread.
|
|
||||||
- `src/core/jitter.rs` — per-peer jitter buffer (reorder + fixed playout delay + Opus PLC on loss). Unit-tested.
|
|
||||||
- `src/audio/{pipewire_impl.rs,pw_cli.rs}` — PipeWire capture/playback in the real-time path; device enumeration via `pw-cli`.
|
|
||||||
- `src/codec/opus_impl.rs` — Opus encode/decode behind the `AudioCodec` trait.
|
|
||||||
- `src/app/mod.rs` — the `iced` GUI (Catppuccin-styled). `src/config.rs` — persisted settings (`~/.config/peerspeak/config.json`).
|
|
||||||
- `tests/transport_loopback.rs` — end-to-end transport tests over real localhost iroh endpoints. `src/bin/test_net.rs` — a manual two-node harness.
|
|
||||||
|
|
||||||
### 6. Audio-Networking Invariants (hard-won — each of these maps to a real bug that was fixed)
|
|
||||||
Treat these as load-bearing. They are non-obvious and were violated in earlier iterations.
|
|
||||||
- **One shared connection per peer pair, deterministic initiator.** The lower `EndpointId` (string comparison) **dials**; the higher **accepts**. Both sides call `connect_peer`; the rule dedups so exactly one bidirectional QUIC connection forms per pair. Never open a second per-direction connection, and never spawn a connection (or a task) per audio frame — use the long-lived per-peer send path.
|
|
||||||
- **The per-peer supervisor owns connect → run → reconnect.** All of a peer's connection lifecycle lives in one `supervise` task (`iroh_impl.rs`). Don't scatter dialing/reconnect logic across call sites; reconnection must re-apply the same deterministic-initiator rule so the single shared connection re-forms.
|
|
||||||
- **Any detached task holding a `Connection` clone MUST be abort-on-drop.** A live `Connection` clone keeps the QUIC link open. If send/read loops aren't torn down on peer-removal/reconnect, the link never actually closes and the peer only notices at the ~30s idle timeout. Scope them in `AbortOnDrop` guards tied to the live-link block.
|
|
||||||
- **A silent handle-drop is NOT a close.** Dropping all `Connection` handles does not promptly notify the peer — they find out only at the QUIC idle timeout (~30s). Only `Connection::close()` sends an immediate `CONNECTION_CLOSE`. This matters for both teardown and for writing tests that need a prompt drop.
|
|
||||||
- **Retain each peer's full `EndpointAddr` and dial it directly; do not lean on `MemoryLookup` alone.** Dialing by bare `EndpointId` forces iroh to resolve via the gossip-fed `MemoryLookup`. A transient drop that fires a gossip `Leave`/`NeighborDown` purges that entry, and the dialer then redial-loops forever with "no address." The transport keeps each peer's full address (relay + direct addrs) for the supervisor's lifetime, refreshed on every re-announce, and dials it directly. (This was the 2026-05-31 fix.)
|
|
||||||
- **Presence layer ≠ transport layer.** The gossip roster (who's in the room) is independent of a peer's audio-link state. A peer can be present with its audio link down/reconnecting. Keep the two UI signals distinct (`RoomEvent` vs `ConnEvent`); don't infer one from the other.
|
|
||||||
- **Every audio datagram carries a 4-byte little-endian sequence header.** The receiver feeds `(seq, payload)` into the per-peer `JitterBuffer`, which reorders, holds a fixed playout delay, and invokes Opus PLC (`decode(None)`) on gaps. Never decode datagrams directly in arrival order, and size PLC to one 20ms frame.
|
|
||||||
- **`broadcast()` must never block the capture/encode thread.** It is called from the non-async audio path. Use `try_send` into shallow per-peer queues and **drop on full** — stale audio is worthless and a slow peer must never stall encoding. No `await`, no large/unbounded queues here.
|
|
||||||
- **Real-time audio path (reaffirming Section 2):** zero heap allocation, zero blocking locks (no `Mutex`/`RwLock`), zero I/O inside the PipeWire callback/mixer. Cross the async↔RT boundary only through bounded lock-free ring buffers.
|
|
||||||
- **Throttle high-rate UI events.** Don't forward per-20ms-tick events (e.g. `AudioLevels`, ~50/sec) straight to the GUI; coalesce with peak-hold to ~10/sec.
|
|
||||||
- **Privacy posture is intentional.** Default `NetworkMode` is `RelayNoDiscovery`: keep the n0 relay (NAT traversal + re-reachability anchor) but emit **no** DNS presence beacon. Do not change the default to anything that publishes presence. Decision on record: we are **not** self-hosting a relay.
|
|
||||||
|
|
||||||
### 7. Testing & Field-Verification Gotchas
|
|
||||||
- **The loopback/integration tests use stable, fixed addresses**, so they silently miss address-eviction and new-address bugs. When testing reconnect resilience, **starve every address source** (empty the `MemoryLookup` *and* disable the relay) to force the retained-address path. Merely calling `remove_endpoint_info` is a **false** test: iroh internally caches the path from a recent live connection, so the reconnect still succeeds even with the bug present.
|
|
||||||
- **Two instances on one host are INVALID for outage/disconnect tests.** Docker bridges (`172.x`) plus loopback keep them talking even with the main NIC down. Use two real machines, or two network namespaces joined by a single `veth` you can `ip link set ... down`.
|
|
||||||
- **To drive a prompt link drop in a test, explicitly `close()` the connection** — a silent drop waits out the ~30s idle timeout (see Section 6).
|
|
||||||
- **Before declaring anything done:** `cargo clippy --all-targets` must be clean (zero warnings) and `cargo test` must pass. Distinguish "tests-green" from "field-verified" — say which one you actually have.
|
|
||||||
|
|
||||||
### 8. Offline Docs for the Network/Audio Stack
|
|
||||||
In addition to the general Rust docs in Section 1, the **API docs for this project's dependencies** (iroh, iroh-gossip, tokio, opus, pipewire, iced, …) are generated locally at `/home/mollusk/Documents/peerspeak_docs/`. Grep/read these instead of probing the web — e.g. confirm `Endpoint::connect`'s signature or `MemoryLookup`'s methods there. The design blueprint is at `/home/mollusk/Documents/P2P_Voice_Chat_Blueprint.md`. A **living handoff log** is maintained at `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/handoff.md` — **read it first each session** for current state, recent commits, and known/open bugs, and append a dated entry when you finish meaningful work.
|
|
||||||
|
|
||||||
Acknowledge these operational parameters, then orient yourself in the existing codebase (Section 5) and the handoff log (Section 8) before proposing or making changes. Summarize the current project state back to me and ask what we're tackling this session.
|
|
||||||
"""
|
|
||||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 606 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 994 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,41 +1,66 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
|
<title>PeerSpeak</title>
|
||||||
|
<desc>Two luminous voices meet directly to form a flowing S.</desc>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0" stop-color="#1e1e2e"/>
|
<stop stop-color="#101d42"/>
|
||||||
<stop offset="1" stop-color="#181825"/>
|
<stop offset="0.5" stop-color="#071225"/>
|
||||||
|
<stop offset="1" stop-color="#160b31"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#35efff"/>
|
||||||
|
<stop offset="0.42" stop-color="#2583ff"/>
|
||||||
|
<stop offset="0.68" stop-color="#8a42ff"/>
|
||||||
|
<stop offset="1" stop-color="#ff3cdd"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
|
||||||
|
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
|
||||||
|
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="core">
|
||||||
|
<stop stop-color="#ffffff"/>
|
||||||
|
<stop offset="0.28" stop-color="#baf7ff"/>
|
||||||
|
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
|
||||||
|
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
|
||||||
|
<feGaussianBlur stdDeviation="6"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
|
||||||
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<!-- Rounded-square tile -->
|
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
|
||||||
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
|
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
|
||||||
stroke="#313244" stroke-width="3"/>
|
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
|
||||||
|
|
||||||
<!-- P2P mesh: edges (under nodes + mic) -->
|
<!-- Broad color glow, kept behind the silhouette. -->
|
||||||
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
|
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||||
<line x1="74" y1="74" x2="128" y2="128"/>
|
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
|
||||||
<line x1="182" y1="74" x2="128" y2="128"/>
|
opacity="0.5" filter="url(#shadow)"/>
|
||||||
<line x1="74" y1="182" x2="128" y2="128"/>
|
|
||||||
<line x1="182" y1="182" x2="128" y2="128"/>
|
|
||||||
<line x1="74" y1="74" x2="182" y2="74"/>
|
|
||||||
<line x1="74" y1="182" x2="182" y2="182"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- P2P mesh: peer nodes -->
|
<!-- The two waveform halves are equal peers and meet at one bright point. -->
|
||||||
<g fill="#b4befe">
|
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
|
||||||
<circle cx="74" cy="74" r="11"/>
|
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="182" cy="74" r="11"/>
|
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||||
<circle cx="74" cy="182" r="11"/>
|
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<circle cx="182" cy="182" r="11"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
|
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
|
||||||
<g fill="none" stroke="#89b4fa" stroke-width="13"
|
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
|
||||||
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
|
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
|
||||||
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
|
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||||
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
|
fill="none" stroke="url(#voice)" stroke-width="25"
|
||||||
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
|
stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
</g>
|
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
|
||||||
|
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
|
||||||
|
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- The direct connection is the brightest and simplest detail. -->
|
||||||
|
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
|
||||||
|
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,22 @@
|
|||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
|
||||||
|
&& let Some(reference) = head.strip_prefix("ref: ")
|
||||||
|
{
|
||||||
|
println!("cargo:rerun-if-changed=.git/{}", reference.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let short = Command::new("git")
|
||||||
|
.args(["rev-parse", "--short=8", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|output| output.status.success())
|
||||||
|
.and_then(|output| String::from_utf8(output.stdout).ok())
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
println!("cargo:rustc-env=PEERSPEAK_GIT_SHORT={short}");
|
||||||
|
}
|
||||||
@@ -24,6 +24,19 @@ ignore = [
|
|||||||
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
|
||||||
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
|
||||||
"RUSTSEC-2026-0150",
|
"RUSTSEC-2026-0150",
|
||||||
|
# ttf-parser: unmaintained, transitive via iced/cosmic-text (font parsing
|
||||||
|
# for the GUI). Inputs are system + embedded fonts, not network data. No
|
||||||
|
# upstream migration yet; revisit when iced moves off it.
|
||||||
|
"RUSTSEC-2026-0192",
|
||||||
|
# quick-xml 0.39.4 DoS advisories (quadratic dup-attr check; unbounded
|
||||||
|
# namespace allocation). Build-time only: quick-xml is reached solely via
|
||||||
|
# the wayland-scanner PROC-MACRO, which parses the wayland protocol XML
|
||||||
|
# files vendored inside the wayland-* crates at compile time. Attacker
|
||||||
|
# input never reaches it and it is not in the shipped binary. The fix
|
||||||
|
# (0.41.0) is semver-incompatible with wayland-scanner 0.31.x's `^0.39`
|
||||||
|
# requirement; drop both ignores once wayland-scanner releases a bump.
|
||||||
|
"RUSTSEC-2026-0194",
|
||||||
|
"RUSTSEC-2026-0195",
|
||||||
]
|
]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -81,8 +94,8 @@ allow = [
|
|||||||
confidence-threshold = 0.8
|
confidence-threshold = 0.8
|
||||||
exceptions = []
|
exceptions = []
|
||||||
|
|
||||||
# peerspeak itself has no `license` field and is not published, so skip the
|
# peerspeak is MIT-licensed (see Cargo.toml `license` + the LICENSE file) but is
|
||||||
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
|
# not published to crates.io, so keep the private-crate skip for the
|
||||||
# if/when this is ever published.
|
# "unlicensed"/publish checks. MIT is already in the allow list above.
|
||||||
[licenses.private]
|
[licenses.private]
|
||||||
ignore = true
|
ignore = true
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ covers internals). When you ship a feature, add it here.
|
|||||||
| iroh QUIC transport | ✅ | |
|
| iroh QUIC transport | ✅ | |
|
||||||
| Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. |
|
| Network mode picker | ✅ | `RelayNoDiscovery` (default), `N0Full`, `DirectOnly`. Takes effect next join. |
|
||||||
| Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. |
|
| Retained-address reconnect | ✅ | Dials last-known full addr before falling back to bare id. |
|
||||||
|
| Per-peer connection badge (direct/relay + RTT, hover for addr/loss/bitrate) | ✅ | Peer-card badge fed by a 1 Hz poll of the live audio link's selected QUIC path (`connection_stats` → `core::connstats::derive`). Field-verified on a real 2-machine call 2026-07-08. |
|
||||||
| Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. |
|
| Reconnect + eviction model | ✅ | Incl. two-outage reconnect-eviction fix + regression test. |
|
||||||
| Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. |
|
| Self-hosted relay | ❌ | Decided against — rely on n0 relays, `RelayNoDiscovery` default. |
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
# PeerSpeak on Windows
|
# PeerSpeak on Windows
|
||||||
|
|
||||||
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
|
Current status: PeerSpeak cross-compiles to `x86_64-pc-windows-gnu` from Linux and
|
||||||
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
|
has passed an older native Windows 11 VM smoke test for launch, GUI render, call
|
||||||
checks listed below.
|
join, and audio flow. The build environment is **not** the Windows VM; current
|
||||||
|
Windows binaries are built from Linux, normally inside the `peerspeak-win`
|
||||||
|
distrobox or with the same GNU target environment.
|
||||||
|
|
||||||
|
The Windows runtime still trails Linux in a few important areas. See the Claude
|
||||||
|
handoff file `windows-parity-audit.md` for the full audit and task breakdown.
|
||||||
|
|
||||||
## What works today
|
## What works today
|
||||||
|
|
||||||
| Area | Status |
|
| Area | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| GUI | Iced/wgpu builds and renders under Wine. |
|
| GUI | Iced/wgpu builds for Windows and rendered in the Windows 11 VM. |
|
||||||
| Networking | Iroh QUIC transport and gossip compile on Windows. |
|
| Networking | Iroh QUIC transport and gossip compile on Windows; VM call reached two peers. |
|
||||||
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
|
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
|
||||||
|
| Device selection | cpal enumerates input/output devices; see caveat below about stable IDs. |
|
||||||
|
| Resampling/remap | WASAPI devices can run non-48 kHz formats; PeerSpeak converts at the backend boundary. |
|
||||||
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
|
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
|
||||||
| Identity | `ring` identity generation/load is platform-neutral. |
|
| Identity/config | Stored through `dirs` under the Windows profile. |
|
||||||
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
|
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
|
||||||
|
| Game detection | Steam registry `RunningAppID` plus Toolhelp process-scan fallback compile on Windows. |
|
||||||
|
| File dialogs | `rfd` uses the native Win32 dialog backend. |
|
||||||
|
|
||||||
Windows paths are resolved through `dirs`:
|
Windows paths are resolved through `dirs`:
|
||||||
|
|
||||||
@@ -23,55 +32,72 @@ Windows paths are resolved through `dirs`:
|
|||||||
|
|
||||||
## Building
|
## Building
|
||||||
|
|
||||||
### Native Windows
|
### Cross-compile from Linux
|
||||||
|
|
||||||
Install MSVC Build Tools and CMake, then build normally:
|
Preferred local path:
|
||||||
|
|
||||||
```powershell
|
```sh
|
||||||
cargo build --release
|
distrobox enter peerspeak-win -- bash -lc '
|
||||||
|
cd ~/git/butter/peerspeak &&
|
||||||
|
RUSTC_BOOTSTRAP=1 ./win-cross-build.sh -Z build-std=std,panic_abort
|
||||||
|
'
|
||||||
```
|
```
|
||||||
|
|
||||||
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
|
Equivalent direct command when the host has the GNU target, MinGW, `rust-src`, and
|
||||||
|
CMake available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM=3.5 RUSTC_BOOTSTRAP=1 \
|
||||||
|
cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak \
|
||||||
|
-Z build-std=std,panic_abort
|
||||||
|
```
|
||||||
|
|
||||||
|
`CMAKE_POLICY_VERSION_MINIMUM=3.5` is required with host CMake 4.x because the
|
||||||
|
vendored Opus build used by `audiopus_sys` still declares an old minimum CMake
|
||||||
|
version. Without that env var, the Windows build/check fails during Opus configure.
|
||||||
|
|
||||||
|
### Native Windows
|
||||||
|
|
||||||
|
A native MSVC build is not the active development path. If used, install MSVC Build
|
||||||
|
Tools and CMake, then build normally:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
cargo build --release
|
cargo build --release
|
||||||
```
|
```
|
||||||
|
|
||||||
### Cross-compile from Linux
|
|
||||||
|
|
||||||
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
rustup target add x86_64-pc-windows-gnu
|
|
||||||
sudo pacman -S mingw-w64-gcc cmake
|
|
||||||
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
|
|
||||||
```
|
|
||||||
|
|
||||||
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
|
|
||||||
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
|
|
||||||
spike) lives in the maintainer's handoff docs, outside the repo.
|
|
||||||
|
|
||||||
## First run and networking
|
## First run and networking
|
||||||
|
|
||||||
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
|
Expect a Windows Firewall prompt the first time the app opens network sockets, or
|
||||||
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
|
use the Inno installer option that pre-adds a firewall allow rule. PeerSpeak uses
|
||||||
|
UDP for QUIC plus relay traffic when direct NAT traversal is unavailable.
|
||||||
|
|
||||||
The default network mode keeps the n0 relay available for NAT traversal without publishing
|
The default network mode keeps the n0 relay available for NAT traversal without
|
||||||
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
|
publishing presence to n0 DNS. Relayed connections are expected and valid.
|
||||||
connections are expected and valid.
|
|
||||||
|
|
||||||
## Known gaps
|
## Known gaps
|
||||||
|
|
||||||
| Item | Status |
|
| Item | Status |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
|
| Echo cancellation | Linux-only today. The Windows UI shows it disabled as unavailable. |
|
||||||
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
|
| Screen share | Blocked by PixelPass, which is currently Linux-only in practice. PeerSpeak can spawn `pixelpass.exe`, but there is no Windows PixelPass host/viewer parity yet. |
|
||||||
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
|
| Device persistence | Uses cpal friendly names as keys. These can duplicate or change across Windows driver/profile changes; stable WASAPI endpoint IDs are still needed. |
|
||||||
| Resampling/device format | Cross-compiled. cpal/WASAPI now chooses native 48 kHz when available and otherwise resamples/remaps at the device boundary; needs real Windows hardware audio verification. |
|
| Release hygiene | Keep `.iss` and installer output in sync with `Cargo.toml`; rebuild Windows artifacts during each release. |
|
||||||
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
|
| Runtime coverage | The Windows VM smoke test proved an older tester build. Current `main` needs a fresh VM smoke matrix before calling parity current. |
|
||||||
| Playback pacing | Cross-compiled. The fixed playback target under WASAPI shared mode still needs real-hardware verification with `audio_probe`. |
|
|
||||||
|
|
||||||
Before calling Windows support done, verify a real Windows machine can create/join a room,
|
## Current smoke checklist
|
||||||
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
|
|
||||||
play notification chimes.
|
Before calling a Windows build current, verify on the Windows VM or real Windows
|
||||||
|
hardware:
|
||||||
|
|
||||||
|
- Launch current `peerspeak.exe`; GUI renders and settings open.
|
||||||
|
- Run `audio_probe.exe 440 30`; listen for glitches and inspect `playout-health`.
|
||||||
|
- Create/join a Linux <-> Windows room; confirm mic and playback both directions.
|
||||||
|
- Select input/output devices, restart, and confirm selections persist or fall back clearly.
|
||||||
|
- Play chimes and custom chime paths.
|
||||||
|
- Send chat, image/file attachments, and save an attachment through the native dialog.
|
||||||
|
- Import/play/share/listen to music from Windows file paths.
|
||||||
|
- Record mixed/stems/both and inspect the WAV output path.
|
||||||
|
- Exercise friends/presence/recents and the clock-skew banner.
|
||||||
|
- Test Steam and non-Steam game detection on a real Windows Steam install.
|
||||||
|
- Install/upgrade/uninstall through the Inno installer, including firewall rule cleanup.
|
||||||
|
|||||||
@@ -0,0 +1,584 @@
|
|||||||
|
# Chat hardening — ephemeral implementation plan
|
||||||
|
|
||||||
|
**Status (2026-07-18):** Phases 1–5 COMPLETE (all plan phases done). Phase 1 =
|
||||||
|
shared text policy in `src/sanitize.rs`, ceilings enforced at UI input, sign
|
||||||
|
point, and gossip ingress. Phase 2 = roster-bound authorship
|
||||||
|
(`src/core/chatroster.rs`), replay dedup + rate limits (`ChatIngressGate` in
|
||||||
|
`src/network/gossip.rs`). Phase 3 = attachment cache/serve-store budgets,
|
||||||
|
downscaled previews, auto-fetch byte/request budgets (`src/core/fetchbudget.rs`),
|
||||||
|
exact transfers, bounded local reads. Phase 4 = parsed-URL link policy
|
||||||
|
(`is_safe_web_url`/`link_ranges` in `src/sanitize.rs`, `url` crate), 8-link cap,
|
||||||
|
cached link ranges in `ChatEntry`, 512 KiB history text budget, chat-body
|
||||||
|
bidi-override strip (closes S14). Phase 5 = honest local send status
|
||||||
|
(`CoreCommand::SendChat`/`SendChatFile` carry a local id, `UiEvent::ChatSendResult`,
|
||||||
|
`SendStatus` on own echoes) PLUS sender-side pacing (`src/app/sendqueue.rs`
|
||||||
|
mirrors the receivers' per-author budget so fast bursts trickle instead of being
|
||||||
|
silently dropped downstream). All gates green each phase. This is a temporary
|
||||||
|
scope contract for hardening the existing room chat; with every phase complete
|
||||||
|
and the two-machine field test done, delete this file (see the completion note
|
||||||
|
at the end). The two-machine field-test section below is still owed before that
|
||||||
|
deletion. Do not add link previews as part of this effort.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Strengthen the current encrypted, signed, session-only room chat without changing
|
||||||
|
its product model: plain selectable text, clickable web links, and peer-to-peer
|
||||||
|
attachments over the existing gossip and files planes. The work should make chat
|
||||||
|
resistant to identity spoofing, replay, spam, oversized input, expensive rendering,
|
||||||
|
and attachment-driven memory/bandwidth pressure while preserving normal Unicode
|
||||||
|
conversation and the existing full-mesh architecture.
|
||||||
|
|
||||||
|
## Existing foundation to preserve
|
||||||
|
|
||||||
|
- Gossip payloads are signed by the claimed `EndpointId`, bound to the raw room
|
||||||
|
topic and protocol domain, and checked before dispatch.
|
||||||
|
- The signed envelope timestamp is admitted only within the two-minute gossip
|
||||||
|
freshness window.
|
||||||
|
- Inbound gossip frames are capped at 128 KiB before JSON deserialization. This
|
||||||
|
larger plane-wide cap must remain because `Announce` may contain a custom avatar.
|
||||||
|
- Chat history is session-only and capped at 300 entries.
|
||||||
|
- Only `http://` and `https://` links are opened, as a single process argument
|
||||||
|
without a shell.
|
||||||
|
- Attachment descriptors are signed with the chat payload; attachment bytes use
|
||||||
|
the encrypted files plane, have a 25 MiB per-file cap, and are keyed by both
|
||||||
|
author and attachment id.
|
||||||
|
- Image bytes are decoded defensively and automatic image fetches already have a
|
||||||
|
four-task concurrency limit.
|
||||||
|
|
||||||
|
## Working design decisions
|
||||||
|
|
||||||
|
These are the implementation defaults unless code inspection or tests reveal a
|
||||||
|
concrete reason to adjust them. Record any adjustment in the decision log.
|
||||||
|
|
||||||
|
1. **No wire change.** Keep `GossipMessage::Chat` unchanged and do not bump
|
||||||
|
`GOSSIP_PROTO`. The redundant wire `name` and inner `Chat.ts` remain serialized
|
||||||
|
for compatibility but are not trusted. Remove them only during a future planned
|
||||||
|
gossip-version bump.
|
||||||
|
2. **Roster identity is authoritative.** A chat line is admitted only for an
|
||||||
|
authenticated identity already known to the current room (including the
|
||||||
|
reconnect grace state). Its displayed name comes from the sanitized roster
|
||||||
|
state, never from `GossipMessage::Chat.name`.
|
||||||
|
3. **Body Unicode remains expressive.** Do not apply the short-label sanitizer to
|
||||||
|
the message body; it strips format characters used by some languages and emoji.
|
||||||
|
Continue neutralizing controls and whitespace, while treating author labels,
|
||||||
|
filenames, and URLs more strictly because those are spoof-sensitive surfaces.
|
||||||
|
4. **Bounds apply at every trust boundary.** UI input is bounded while editing,
|
||||||
|
outgoing text is normalized before signing, and incoming text is byte-checked
|
||||||
|
and normalized before it leaves the gossip layer. UI-only truncation is not an
|
||||||
|
adequate ingress defense.
|
||||||
|
5. **Automatic network work is stricter than manual work.** Keep the 25 MiB manual
|
||||||
|
attachment ceiling, but auto-fetch only small images. Larger images remain
|
||||||
|
available behind an explicit Load/Download action.
|
||||||
|
6. **Caches are bounded by cost, not only entry count.** Count encoded bytes and
|
||||||
|
estimated decoded image bytes. A count cap remains as a secondary bound.
|
||||||
|
7. **Rate limiting degrades quietly.** Drop excess/replayed peer messages with a
|
||||||
|
rate-limited log entry. Do not let a spammer produce a second UI-notification
|
||||||
|
flood.
|
||||||
|
|
||||||
|
## Proposed policy constants
|
||||||
|
|
||||||
|
Keep these together near the code that enforces them and cover them with boundary
|
||||||
|
tests. Values are starting points, not a compatibility contract.
|
||||||
|
|
||||||
|
| Policy | Initial value | Reason |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Chat body characters | 2,000 | Preserves current UI behavior |
|
||||||
|
| Chat body UTF-8 bytes | 8 KiB | Covers 2,000 four-byte scalars with small headroom |
|
||||||
|
| Live input characters/bytes | Same as body | Prevent oversized paste/edit state |
|
||||||
|
| Clickable links per message | 8 | Bounds spans and opener targets |
|
||||||
|
| Retained chat text | 512 KiB plus 300 entries | Bounds redraw and selection work |
|
||||||
|
| Per-author chat limiter | Burst 8, refill 1/second | Allows normal bursts, stops sustained spam |
|
||||||
|
| Room-wide chat limiter | Burst 32, refill 8/second | Protects shared event/UI queues |
|
||||||
|
| Exact-chat replay cache | 1,024 digests, 2-minute TTL | Covers freshness window with a hard bound |
|
||||||
|
| Auto-fetch image encoded size | 4 MiB | Limits unsolicited bandwidth and allocations |
|
||||||
|
| Attachment cache encoded budget | 128 MiB | Allows several ordinary files without GiB growth |
|
||||||
|
| Attachment cache decoded-preview budget | 64 MiB | Bounds renderer-side image pressure |
|
||||||
|
| Served attachment budget | 256 MiB plus a count cap | Bounds sender memory for a long session |
|
||||||
|
| Inline preview longest side | 1,600 px | Chat renders near 260 px; full 4K decode is wasteful |
|
||||||
|
| Decoded source image pixels | 16 megapixels maximum | Adds a total-pixel bound to per-side bounds |
|
||||||
|
|
||||||
|
## Phase 1 — Shared text policy and live-input bounds
|
||||||
|
|
||||||
|
**Target:** downstream layers never receive or retain an unexpectedly large or
|
||||||
|
unsafe chat string.
|
||||||
|
|
||||||
|
- [x] Move chat constants and `sanitize_chat` from `src/app/mod.rs` into
|
||||||
|
`src/sanitize.rs` (or a narrowly scoped shared chat-policy module if that keeps
|
||||||
|
the API clearer).
|
||||||
|
- [x] Implement a single-pass sanitizer that:
|
||||||
|
- maps control characters to spaces;
|
||||||
|
- collapses whitespace and trims ends;
|
||||||
|
- enforces both the character and UTF-8 byte ceilings without splitting a scalar;
|
||||||
|
- returns empty for content with no visible text.
|
||||||
|
- [x] Add `cap_chat_input` for live editing. It must preserve the user's current
|
||||||
|
whitespace while enforcing character and byte ceilings; normalization remains a
|
||||||
|
submit/ingress operation so typing does not visibly jump.
|
||||||
|
- [x] Apply `cap_chat_input` in `AppMessage::ChatInputChanged`, covering keyboard,
|
||||||
|
clipboard, primary-selection, and context-menu paste paths through the controlled
|
||||||
|
input widget.
|
||||||
|
- [x] Sanitize outgoing text immediately before local echo and `CoreCommand` send.
|
||||||
|
- [x] Sanitize again before `GossipMessage::Chat` is signed, so a future non-UI
|
||||||
|
caller cannot bypass policy.
|
||||||
|
- [x] At gossip ingress, reject raw chat text over the byte ceiling before doing
|
||||||
|
downstream sanitization; sanitize accepted text before creating `RoomEvent`.
|
||||||
|
- [x] Keep attachment-only messages when the sanitized caption is empty; drop a
|
||||||
|
chat with neither visible text nor a valid attachment.
|
||||||
|
- [x] Stop sanitizing an incoming chat `name` with the body sanitizer. Phase 2
|
||||||
|
replaces it with the roster-bound name.
|
||||||
|
|
||||||
|
### Phase 1 tests
|
||||||
|
|
||||||
|
- [x] ASCII, multibyte Unicode, emoji, whitespace, NUL/CR/LF/TAB/ESC, empty input.
|
||||||
|
- [x] Exact character and byte boundaries, including a four-byte scalar at the
|
||||||
|
cutoff.
|
||||||
|
- [x] Oversized paste never makes `state.chat_input` exceed either ceiling.
|
||||||
|
- [x] Outgoing, incoming, and direct core/network paths converge on the same
|
||||||
|
normalized result.
|
||||||
|
- [x] Empty captions are retained only when a valid attachment remains.
|
||||||
|
|
||||||
|
## Phase 2 — Admission, identity binding, replay, and spam control
|
||||||
|
|
||||||
|
**Target:** only current authenticated room members can create chat UI work, and a
|
||||||
|
member cannot impersonate another participant or monopolize the control/UI queues.
|
||||||
|
|
||||||
|
- [x] Change the core event task's chat roster from a bare `HashSet<EndpointId>` to
|
||||||
|
a bounded map containing each member's latest sanitized display name (or retain a
|
||||||
|
parallel name map if less invasive).
|
||||||
|
- [x] Insert/update the map on `PeerJoined`/`PeerUpdated`, retain it during transient
|
||||||
|
reconnect grace, and remove it on graceful or terminal eviction.
|
||||||
|
- [x] Before attachment handling or UI forwarding, reject `RoomEvent::ChatMessage`
|
||||||
|
whose author is not present in that authoritative roster.
|
||||||
|
- [x] Replace the embedded wire name with the roster map's name before constructing
|
||||||
|
`UiEvent::ChatMessage`. The UI may keep storing a name snapshot so old chat lines
|
||||||
|
remain labeled after a peer leaves.
|
||||||
|
- [x] Add a lightweight early known-author gate in the gossip loop using its live
|
||||||
|
and disconnected-peer sets. Keep the core roster gate as defense in depth and as
|
||||||
|
the final authority.
|
||||||
|
- [x] Validate that the inner `Chat.ts` equals the signed envelope timestamp, or
|
||||||
|
ignore it entirely. Do not use the inner timestamp for replay or ordering.
|
||||||
|
- [x] Add exact-chat replay suppression after signature verification and before
|
||||||
|
event-channel send:
|
||||||
|
- hash the canonical signed bytes, not raw JSON formatting;
|
||||||
|
- use BLAKE3 (make it a direct dependency if needed; it is already in the iroh
|
||||||
|
dependency graph) or an equally collision-resistant existing primitive;
|
||||||
|
- store a `HashSet` plus FIFO/TTL order for bounded lookup and eviction;
|
||||||
|
- prune by both the gossip freshness window and the hard entry cap.
|
||||||
|
- [x] Add a bounded token bucket per admitted author and a room-wide bucket before
|
||||||
|
awaiting `event_tx.send`. Limiter state must be removed with roster eviction and
|
||||||
|
remain bounded by the roster cap.
|
||||||
|
- [x] Ensure duplicate messages are dropped before consuming rate-limit tokens, so
|
||||||
|
a replay cannot starve a legitimate new message from that author.
|
||||||
|
- [x] Rate-limit rejection logging per author/reason.
|
||||||
|
- [ ] Consider applying the same local submit policy to accidental rapid Enter or
|
||||||
|
button activation, without routing chat through the coalescing command path.
|
||||||
|
|
||||||
|
### Phase 2 tests
|
||||||
|
|
||||||
|
- [x] Valid roster author is admitted; never-announced, post-leave, forged, and
|
||||||
|
stale authors are rejected.
|
||||||
|
- [x] A peer sending `name = "Victim"` renders under its own roster name.
|
||||||
|
- [x] A name update affects future messages without rewriting history.
|
||||||
|
- [x] Reconnect grace continues accepting the known author; terminal eviction does
|
||||||
|
not.
|
||||||
|
- [x] The same signed chat is displayed once; distinct chats created in the same
|
||||||
|
millisecond are both admitted.
|
||||||
|
- [x] Replay-cache TTL/cap pruning cannot grow without bound.
|
||||||
|
- [x] Per-author burst/refill and room-wide burst/refill boundaries.
|
||||||
|
- [x] Excess chat cannot prevent a subsequent `Leave` or `Announce` from reaching
|
||||||
|
the event loop in a deterministic channel-pressure test.
|
||||||
|
|
||||||
|
## Phase 3 — Attachment transfer and memory hardening
|
||||||
|
|
||||||
|
**Target:** neither peers nor long local sessions can turn chat attachments into
|
||||||
|
unbounded memory, bandwidth, decoder, or task pressure.
|
||||||
|
|
||||||
|
### 3A. Cache and image cost
|
||||||
|
|
||||||
|
- [x] Extend `AttachmentCache` with encoded-byte and decoded-preview-byte counters.
|
||||||
|
Preserve the count cap, but evict oldest entries until all three budgets fit.
|
||||||
|
- [x] Give every entry an explicit weight. Replacement must subtract the old
|
||||||
|
weight before checking/inserting the new one.
|
||||||
|
- [x] Decide behavior for a single entry larger than the cache budget: service an
|
||||||
|
immediate pending Save/Play request without retaining it, then expose it as
|
||||||
|
evicted/unavailable rather than exceeding the budget.
|
||||||
|
- [x] Add a total-pixel limit to `validate_image_bytes` in addition to the existing
|
||||||
|
width/height limit.
|
||||||
|
- [x] Build a downscaled inline preview handle with a maximum 1,600 px side. Keep
|
||||||
|
original bytes only for Save; do not hand a full-resolution 4K image to the
|
||||||
|
renderer merely to display it at chat width.
|
||||||
|
- [x] Count estimated RGBA preview cost (`width * height * 4`) against the decoded
|
||||||
|
budget even if iced internally copies or uploads it.
|
||||||
|
- [x] Strip the same bidi/zero-width spoofing characters used for display labels
|
||||||
|
from attachment filenames, while preserving ordinary Unicode filenames.
|
||||||
|
|
||||||
|
### 3B. Automatic download policy and state
|
||||||
|
|
||||||
|
- [x] Auto-fetch only roster-authored images whose declared size is at or below
|
||||||
|
`MAX_AUTO_IMAGE_BYTES`; keep the existing `(author,id)` dedup and four-permit
|
||||||
|
concurrency bound.
|
||||||
|
- [x] Add per-author and session byte/request budgets for automatic fetches so a
|
||||||
|
peer cannot drain bandwidth sequentially after each permit is released.
|
||||||
|
- [x] Represent `NotFetched`, `Loading`, `Ready`, `Failed`, and `Evicted` distinctly
|
||||||
|
enough for the UI to avoid an indefinite “loading…” label when auto-fetch was
|
||||||
|
skipped or the cache evicted an item.
|
||||||
|
- [x] Render a Load image button for large/skipped images. A manual click may use
|
||||||
|
the 25 MiB file cap but still observes cache/decoder budgets.
|
||||||
|
- [x] Ensure a repeated click cannot create duplicate unguarded fetch tasks.
|
||||||
|
- [x] Keep non-image attachments manual-only.
|
||||||
|
|
||||||
|
### 3C. Exact transfers, local reads, and served files
|
||||||
|
|
||||||
|
- [x] In `IrohTransport::fetch_blob`, require `bytes.len() as u64 == declared_size`.
|
||||||
|
Reject empty, short, and overlong transfers with a concise local error.
|
||||||
|
- [x] Replace the file picker's unbounded `FileHandle::read()` with a helper that
|
||||||
|
reads at most `MAX_ATTACHMENT_BYTES + 1`. Check metadata first where available,
|
||||||
|
but retain the bounded read because metadata can race or be unavailable through
|
||||||
|
a portal.
|
||||||
|
- [x] Avoid duplicating a full attachment across UI, command queue, and serve store.
|
||||||
|
Prefer `Arc<Vec<u8>>`/`Arc<[u8]>` through `AttachmentState`, `CoreCommand`, and
|
||||||
|
`serve_attachment`, subject to iced handle API constraints.
|
||||||
|
- [x] Replace the unbounded session `served_files` map with a count- and byte-
|
||||||
|
budgeted FIFO store. Evicted ids should produce the existing “sender no longer
|
||||||
|
has the file” response rather than stale or aliased data.
|
||||||
|
- [x] Keep attachment ids keyed by author on receipt and preserve all existing
|
||||||
|
request-length, timeout, filename, and decoder checks.
|
||||||
|
|
||||||
|
### Phase 3 tests
|
||||||
|
|
||||||
|
- [x] Byte-budget eviction, count eviction, replacement accounting, clear/reset,
|
||||||
|
and an individually overweight entry.
|
||||||
|
- [x] Decoded-preview budget and downscale dimensions for wide, tall, square, and
|
||||||
|
boundary images.
|
||||||
|
- [x] Image with valid per-side dimensions but excessive total pixels is rejected.
|
||||||
|
- [x] A declared 4 MiB image auto-fetches; the first byte over the limit requires a
|
||||||
|
click.
|
||||||
|
- [x] Per-author/session auto-fetch budgets recover according to their policy and
|
||||||
|
never exceed task concurrency.
|
||||||
|
- [x] Short, exact, and overlong file responses.
|
||||||
|
- [x] Local file reader stops at cap + 1 instead of allocating the full source.
|
||||||
|
- [x] Served-file FIFO/byte eviction and replacement accounting.
|
||||||
|
- [x] Same attachment id from two authors remains isolated throughout fetch, cache,
|
||||||
|
save, and display.
|
||||||
|
|
||||||
|
## Phase 4 — URL and rendering resilience
|
||||||
|
|
||||||
|
**Target:** keep clickable links without making malformed/deceptive input or many
|
||||||
|
small spans an unnecessary UI/launcher surface.
|
||||||
|
|
||||||
|
- [x] Make `url` a direct dependency (already present transitively) and validate
|
||||||
|
link candidates with `url::Url`.
|
||||||
|
- [x] A clickable URL must have an `http` or `https` scheme and a valid host.
|
||||||
|
- [x] Treat URLs containing username/password syntax as plain text, or require an
|
||||||
|
explicit confirmation that shows the parsed destination host. Prefer plain text
|
||||||
|
for the first implementation.
|
||||||
|
- [x] Preserve the existing defense-in-depth validation in `AppMessage::OpenUrl`;
|
||||||
|
replace prefix checks with the shared parsed-URL policy.
|
||||||
|
- [x] Cap clickable candidates at eight per message. Remaining content stays
|
||||||
|
selectable plain text and must still round-trip exactly.
|
||||||
|
- [x] Refactor linkification to return borrowed ranges/offsets or cache link ranges
|
||||||
|
in `ChatEntry`, avoiding allocation and rescanning on every redraw.
|
||||||
|
- [x] Bound retained history by total sanitized text bytes as well as 300 entries.
|
||||||
|
Eviction must keep attachment bookkeeping coherent and should not invalidate an
|
||||||
|
open Save/Play operation.
|
||||||
|
- [x] Do not add metadata fetching, remote images, Markdown, or link previews.
|
||||||
|
- [x] (Folded in from S14, per the security handoff) Strip bidi
|
||||||
|
overrides/isolates from the chat BODY in `sanitize_chat`, keeping the other
|
||||||
|
expressive format characters (ZWJ/ZWNJ/LRM/RLM).
|
||||||
|
|
||||||
|
### Phase 4 tests
|
||||||
|
|
||||||
|
- [x] Valid HTTP/HTTPS, malformed host, empty host, mixed case, Unicode path/query,
|
||||||
|
punctuation, credentials/userinfo, and non-web schemes.
|
||||||
|
- [x] Eight-link boundary and many-link adversarial input.
|
||||||
|
- [x] Segment/range reconstruction exactly reproduces the sanitized message.
|
||||||
|
- [x] Entry-count and total-text-budget history eviction.
|
||||||
|
- [x] Opener policy cannot launch a non-web scheme even if called directly.
|
||||||
|
|
||||||
|
## Phase 5 — Honest local send status
|
||||||
|
|
||||||
|
**Target:** never present a locally echoed message as successfully broadcast when
|
||||||
|
the core rejected it or gossip broadcast failed.
|
||||||
|
|
||||||
|
- [x] Add a local-only message id and `Pending`/`Broadcast`/`Failed` state to local
|
||||||
|
chat entries. Do not put this id or state on the wire. (`ChatEntry.local_send:
|
||||||
|
Option<LocalSend>`; `SendStatus` also has `Queued` for the paced-but-not-yet-sent
|
||||||
|
state — see the pacing decision-log entry.)
|
||||||
|
- [x] Carry the local id through `CoreCommand::SendChat`/`SendChatFile` and return a
|
||||||
|
`UiEvent` result after the local gossip broadcast call succeeds or fails.
|
||||||
|
(`SendChat`/`SendChatFile` gained `local_id`; new `UiEvent::ChatSendResult { local_id,
|
||||||
|
error }`.)
|
||||||
|
- [x] If the core is not in an active session, return failure instead of silently
|
||||||
|
doing nothing. (`send_chat` now `Err`s on missing sender/topic and on encode
|
||||||
|
failure; the core arm maps no-session to a `ChatSendResult` error.)
|
||||||
|
- [x] Show failure compactly with a retry action. A successful local broadcast must
|
||||||
|
not be labeled “delivered” or “read”; PeerSpeak has no peer acknowledgements.
|
||||||
|
(Failed → red "⚠ Not sent — {reason} [Retry]" line; Broadcast/Pending render
|
||||||
|
nothing — silence is the honest success state.)
|
||||||
|
- [x] Retry creates one new signed broadcast while retaining replay correctness and
|
||||||
|
attachment serving state. (`RetryChatSend(id)` re-dispatches the retained
|
||||||
|
`PendingSend`; re-serving the same attachment id REPLACES the `ServeStore`
|
||||||
|
entry, never double-counts — see `serve_store_replacement_accounting_and_remove_clear`.)
|
||||||
|
|
||||||
|
### Phase 5 tests
|
||||||
|
|
||||||
|
- [x] Local echo starts pending, becomes broadcast on success, and becomes failed
|
||||||
|
on no-session/channel/gossip error. (`send_status_pending_then_broadcast_on_success`,
|
||||||
|
`send_status_failed_keeps_payload_for_retry`.)
|
||||||
|
- [x] Results update only the matching local entry, including after history
|
||||||
|
eviction or room reset. (`send_result_updates_only_the_matching_entry`,
|
||||||
|
`send_result_after_eviction_drops_orphan_payload`, `send_result_after_room_reset_is_a_noop`.)
|
||||||
|
- [x] Retry does not duplicate served bytes or mutate an unrelated entry.
|
||||||
|
(`retry_redispatches_only_the_targeted_send`; served-byte dedup =
|
||||||
|
`serve_store_replacement_accounting_and_remove_clear` in `files.rs`.)
|
||||||
|
|
||||||
|
## Compatibility and versioning
|
||||||
|
|
||||||
|
- The planned implementation changes validation, local data structures, and
|
||||||
|
internal `CoreCommand`/`UiEvent` shapes only. Keep the serialized
|
||||||
|
`GossipMessage::Chat` and file request/response formats unchanged.
|
||||||
|
- Therefore do **not** bump `GOSSIP_PROTO`, `FILES_PROTO`, or the pre-1.0 MINOR
|
||||||
|
solely for this plan. The eventual release is a compatible PATCH unless scope
|
||||||
|
expands into a wire change.
|
||||||
|
- If implementation requires removing/adding serialized fields, changing
|
||||||
|
attachment request framing, or introducing acknowledgements on the wire, stop
|
||||||
|
and revise this section before coding that part. Follow `VERSIONING.md` and use
|
||||||
|
the appropriate protocol plus release MINOR bump.
|
||||||
|
|
||||||
|
## Verification gates
|
||||||
|
|
||||||
|
Run after each phase, with focused tests first and the full gates before handoff:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo fmt --check
|
||||||
|
cargo test --lib
|
||||||
|
cargo test --all-targets
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
Also retain the existing ignored/loopback coverage where the environment supports
|
||||||
|
it; do not make ordinary unit tests depend on external network access.
|
||||||
|
|
||||||
|
### Two-machine field test
|
||||||
|
|
||||||
|
- [ ] Ordinary ASCII/Unicode conversation, rapid short burst, long boundary text,
|
||||||
|
and oversized paste.
|
||||||
|
- [ ] Rename during a room: new lines use the new roster name; old lines retain
|
||||||
|
their snapshot.
|
||||||
|
- [ ] Disconnect/reconnect grace and post-leave chat admission behavior.
|
||||||
|
- [ ] Multiple normal images, one image above the auto threshold, a malformed
|
||||||
|
“image”, and a maximum-size manual file.
|
||||||
|
- [ ] Download/save after cache eviction; clear failure state and no runaway
|
||||||
|
memory across repeated attachments.
|
||||||
|
- [ ] Observe process RSS and UI responsiveness during a bounded spam/attachment
|
||||||
|
stress run; verify leave/reconnect controls remain responsive.
|
||||||
|
- [ ] Linux and Windows URL opening for valid links; malformed/userinfo links remain
|
||||||
|
selectable but do not launch.
|
||||||
|
- [ ] A message with more than eight URLs renders eight clickable links and the
|
||||||
|
rest as selectable plain text, with nothing dropped.
|
||||||
|
- [ ] A message attempting bidi-override display spoofing renders in send order
|
||||||
|
(the override characters are stripped, emoji/joining-script text intact).
|
||||||
|
- [ ] Send a fast burst (>8 messages in a second): all arrive at the peer in
|
||||||
|
order, none silently lost; the sender sees "queued…" on the overflow that
|
||||||
|
then clears as each goes out.
|
||||||
|
- [ ] Send with no active session (or a failing broadcast): the message shows
|
||||||
|
"⚠ Not sent" with a Retry, and Retry resends it once when connectivity is back.
|
||||||
|
|
||||||
|
## Completion criteria
|
||||||
|
|
||||||
|
The plan is complete when:
|
||||||
|
|
||||||
|
1. Only active/grace-rostered authenticated authors reach chat UI state.
|
||||||
|
2. Chat identity is roster-bound and cannot be overridden by the embedded wire
|
||||||
|
name.
|
||||||
|
3. Exact replay and sustained spam are bounded before shared event queues.
|
||||||
|
4. Live input, inbound/outbound body size, history text, attachment caches,
|
||||||
|
automatic transfers, served files, and decoded previews all have tested hard
|
||||||
|
bounds.
|
||||||
|
5. File transfer length and image decoding/display costs are validated.
|
||||||
|
6. Clickable links pass a shared parsed-URL policy and rendering work is bounded.
|
||||||
|
7. Local broadcast failure is visible without claiming peer delivery.
|
||||||
|
8. Unit/all-target/clippy gates and the two-machine field test pass.
|
||||||
|
9. Relevant durable docs (`README.md`, `docs/FEATURES.md`, `CHANGELOG.md`, security
|
||||||
|
notes, and comments) describe the final behavior.
|
||||||
|
10. This ephemeral plan is deleted after its useful status/history is transferred
|
||||||
|
to durable documentation.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Link previews, metadata fetches, or remote thumbnail requests.
|
||||||
|
- Persistent/offline chat history or server-side message storage.
|
||||||
|
- Markdown, rich embeds, reactions, editing, deletion, threads, or search.
|
||||||
|
- Read receipts or peer delivery acknowledgements.
|
||||||
|
- Moderation UI, kicking, blocking, or trust-list redesign.
|
||||||
|
- Antivirus/malware scanning of user-requested downloaded files.
|
||||||
|
- A new application-layer group-encryption protocol or a broader cryptographic
|
||||||
|
redesign. If PeerSpeak makes a formal end-to-end-encryption product claim, audit
|
||||||
|
and document the exact iroh/gossip/relay threat model as a separate project.
|
||||||
|
|
||||||
|
## Decision log
|
||||||
|
|
||||||
|
- **2026-07-15:** Chose hardening over automatic link previews because receiving a
|
||||||
|
message should not trigger third-party web requests or weaken PeerSpeak's
|
||||||
|
privacy-oriented design.
|
||||||
|
- **2026-07-15:** Initial scope keeps all wire formats stable; hardening is local
|
||||||
|
admission, validation, resource accounting, and honest UI state.
|
||||||
|
- **2026-07-17 (Phase 1):** The 8 KiB byte ceiling deliberately cannot bind on
|
||||||
|
*sanitized* output (2,000 scalars × 4 bytes = 8,000 ≤ 8,192), so inside
|
||||||
|
`sanitize_chat`/`cap_chat_input` it is defense in depth; its operative role is
|
||||||
|
the raw-ingress reject in `admit_chat_text`.
|
||||||
|
- **2026-07-17 (Phase 1):** Interim until Phase 2's roster binding: the incoming
|
||||||
|
chat `name` now goes through the strict `sanitize_name` label sanitizer at the
|
||||||
|
UI edge (was the body sanitizer), so author labels already get bidi/zero-width
|
||||||
|
stripping and the 48-char label cap.
|
||||||
|
- **2026-07-17 (Phase 1):** `send_chat` at the gossip sign point silently no-ops
|
||||||
|
(Ok) on an empty-after-sanitize body with no attachment rather than erroring;
|
||||||
|
the UI already prevents this case, and Phase 5's send-status work is where
|
||||||
|
send-path feedback gets designed.
|
||||||
|
- **2026-07-17 (Phase 2):** Replay dedup is keyed on the payload's own Ed25519
|
||||||
|
**signature bytes** instead of a BLAKE3 digest (the plan allowed "an equally
|
||||||
|
collision-resistant existing primitive"): ed25519 signing is deterministic
|
||||||
|
(RFC 8032), so the 64-byte signature is already a collision-resistant
|
||||||
|
fingerprint of the exact signed bytes — same dedup power, zero new direct
|
||||||
|
dependencies. Cache entries are stamped with the signed envelope `ts` and
|
||||||
|
pruned once it exits the freshness window, because `verify_gossip` already
|
||||||
|
rejects such a frame before the cache is consulted.
|
||||||
|
- **2026-07-17 (Phase 2):** A room-bucket reject refunds the just-consumed
|
||||||
|
author token, so a room-wide squeeze caused by other members does not also
|
||||||
|
drain an innocent author's personal budget.
|
||||||
|
- **2026-07-17 (Phase 2):** Rate-limited frames are NOT entered into the replay
|
||||||
|
cache: only fully admitted chats are. A legitimate message the room was too
|
||||||
|
busy for, redelivered later by the swarm, is then displayed once instead of
|
||||||
|
being misread as a replay of something never shown.
|
||||||
|
- **2026-07-17 (Phase 2):** The "wire name never renders" guarantee is
|
||||||
|
structural: the core event task binds the wire field as `name: _` and builds
|
||||||
|
`UiEvent::ChatMessage` exclusively from `ChatRoster::name_of`, so there is no
|
||||||
|
code path from wire name to UI. The roster map behavior is unit-tested; the
|
||||||
|
end-to-end impersonation scenario stays on the (still-open) two-machine
|
||||||
|
field-test list.
|
||||||
|
- **2026-07-17 (Phase 2):** The channel-pressure requirement is met at the seam
|
||||||
|
level: chat admission is bounded (32-burst / 8-per-s room-wide) BEFORE any
|
||||||
|
`event_tx.send`, and `Announce`/`Leave` admission is independent of the chat
|
||||||
|
gate — verified by unit tests. A full gossip-loop pressure harness was not
|
||||||
|
built; the seam bound is what protects the channel.
|
||||||
|
- **2026-07-17 (Phase 2):** An empty-after-sanitize roster name falls back to
|
||||||
|
the short node id, so a member who announces an all-control-character name
|
||||||
|
still gets a stable, non-blank chat label.
|
||||||
|
- **2026-07-17 (Phase 2):** The "Consider applying the same local submit policy
|
||||||
|
to accidental rapid Enter" item is DEFERRED: the receiving side is the
|
||||||
|
security boundary (every peer independently enforces the buckets), and a
|
||||||
|
local silent drop would be a UX regression better designed alongside Phase
|
||||||
|
5's honest send status.
|
||||||
|
- **2026-07-18 (Phase 3):** Constants that deviate from the proposed table, all
|
||||||
|
bound-tested: total decoded pixels **14 MP** (not 16 MP) so the bound clears
|
||||||
|
12 MP phone photos (4032×3024) yet actually binds inside the 4096²≈16.8 MP
|
||||||
|
per-side envelope; cache encoded budget **96 MiB** (not 128) — still several
|
||||||
|
full-size files, tighter worst case; serve store **128 MiB + 16 entries**
|
||||||
|
(not 256 MiB) — a sender's own session should not pin a quarter GiB.
|
||||||
|
- **2026-07-18 (Phase 3):** `validate_image_bytes`/`decode_preview` precheck
|
||||||
|
dimensions from the container HEADER (`into_dimensions`) before any pixel
|
||||||
|
decode, so an over-limit decode bomb is rejected without paying its decode
|
||||||
|
cost; the decode-time `image::Limits` remain as defense in depth, and the
|
||||||
|
decoded dimensions must equal the prechecked header dimensions.
|
||||||
|
- **2026-07-18 (Phase 3):** Budget-pressure evictions leave NO cache entry
|
||||||
|
(absence = NotFetched → the same Load/Download affordance), while the
|
||||||
|
explicit `Evicted` state marks only an *individually over-budget* fetch whose
|
||||||
|
bytes were used once (pending Save/Play serviced from hand) and dropped. Both
|
||||||
|
render load-on-demand; only the bookkeeping differs.
|
||||||
|
- **2026-07-18 (Phase 3):** The core still runs `validate_image_bytes` before
|
||||||
|
emitting `AttachmentReady`, and the UI decodes once more to build the ≤1600px
|
||||||
|
preview. Two bounded decodes per image were accepted over shipping decoded
|
||||||
|
RGBA across the channel (which would defeat the encoded-only Arc sharing).
|
||||||
|
- **2026-07-18 (Phase 3):** The image lightbox now enlarges the ≤1600px preview
|
||||||
|
handle, not the original bitmap — originals are retained encoded-only for
|
||||||
|
Save. At the lightbox's window-sized draw area the visual difference is nil
|
||||||
|
for the chat use case; full fidelity remains one Save away.
|
||||||
|
- **2026-07-18 (Phase 3):** `AutoFetchBudget` checks all four buckets
|
||||||
|
(author/session × requests/bytes) and only then consumes atomically, so a
|
||||||
|
rejection burns nothing (no refund path like Phase 2's room bucket needed).
|
||||||
|
Tokens ARE consumed if the four-permit semaphore then rejects the spawn —
|
||||||
|
that only happens mid-flood, when charging the author is the intent.
|
||||||
|
- **2026-07-18 (Phase 3):** The auto-fetch budget's author map prunes
|
||||||
|
least-recently-active past 64 entries instead of wiring roster eviction into
|
||||||
|
the event task: authors are roster-gated upstream (≤32 live members), so
|
||||||
|
strangers cannot churn the map, and a pruned author returning with full
|
||||||
|
buckets is within policy.
|
||||||
|
- **2026-07-18 (Phase 3):** Music-track serving shares the bounded serve store
|
||||||
|
with chat attachments. A user who sends enough large attachments during a
|
||||||
|
broadcast can evict their own current track; listeners then get the standard
|
||||||
|
"sender no longer has the file" failure. Accepted: budget honesty over a
|
||||||
|
second store, and the store comfortably fits current+next track plus a
|
||||||
|
normal chat working set.
|
||||||
|
- **2026-07-18 (Phase 3):** The clip player's command channel still takes one
|
||||||
|
owned byte copy at the moment of a Play click (small, human-initiated). The
|
||||||
|
Arc de-duplication targeted the send path (UI cache / command queue / serve
|
||||||
|
store), which now shares a single allocation.
|
||||||
|
- **2026-07-18 (Phase 3):** Overlong transfers are rejected by the transport
|
||||||
|
read itself (`read_to_end(size)` errors past the bound) rather than an
|
||||||
|
explicit length compare; short transfers get the explicit
|
||||||
|
`len == declared_size` check. Music fetches ride `fetch_blob`, so they
|
||||||
|
inherit exactness for free.
|
||||||
|
- **2026-07-18 (Phase 4):** The S14 chat-body half (bidi strip) landed here per
|
||||||
|
the security handoff: `sanitize_chat` strips ONLY bidi overrides/isolates
|
||||||
|
(U+202A–202E, U+2066–2069) — the characters that can visually reorder a
|
||||||
|
rendered line — while ZWJ/ZWNJ (emoji sequences, joining scripts) and the
|
||||||
|
LRM/RLM direction *marks* (which cannot reorder) are kept. Labels/filenames
|
||||||
|
keep the stricter full-format-strip.
|
||||||
|
- **2026-07-18 (Phase 4):** A link's href is the exact displayed slice of the
|
||||||
|
message — validation is parse-only, no normalization on open — so what the
|
||||||
|
user sees IS the argv the opener receives. Consequence: WHATWG slash
|
||||||
|
collapsing means `http:///path` parses to host `path` (as in browsers) and is
|
||||||
|
accepted; the empty-host rejects are `http://` and friends that fail parsing.
|
||||||
|
- **2026-07-18 (Phase 4):** URLs with userinfo syntax went the plan-preferred
|
||||||
|
plain-text route (no confirmation dialog). A candidate that fails the policy
|
||||||
|
leaves its WHOLE whitespace-delimited run as plain text without re-scanning
|
||||||
|
the interior — `http://a@http://b.com` yields zero links, by design.
|
||||||
|
- **2026-07-18 (Phase 4):** Scheme detection became ASCII-case-insensitive
|
||||||
|
(`Http://…` from sentence auto-capitalization now linkifies); the policy
|
||||||
|
check is unaffected since `url` normalizes scheme/host case during parsing.
|
||||||
|
- **2026-07-18 (Phase 4):** Cached ranges in `ChatEntry.links`, filled inside
|
||||||
|
`push_chat` (the single history choke point), were chosen over
|
||||||
|
borrowed-return-per-redraw: redraws now slice cached char-boundary ranges,
|
||||||
|
and only link spans allocate (their href String).
|
||||||
|
- **2026-07-18 (Phase 4):** History byte-budget eviction (512 KiB, alongside
|
||||||
|
the 300-entry cap) deliberately does NOT touch the attachment byte cache:
|
||||||
|
that cache is bounded by its own Phase 3 budgets, and leaving it alone means
|
||||||
|
an open Save/Play on an evicted line keeps its bytes-in-hand (the save
|
||||||
|
dialog falls back to the generic "download" name). The just-pushed entry is
|
||||||
|
never evicted; a single message's 8 KiB ceiling cannot exceed the budget.
|
||||||
|
- **2026-07-18 (Phase 5):** Sender-side PACING was added to Phase 5's scope
|
||||||
|
(originally receiver-status only). The Phase 2 decision log deferred the
|
||||||
|
"apply the same local submit policy to accidental rapid Enter" item to pair
|
||||||
|
with Phase 5, and honest status alone would still let a fast burst broadcast
|
||||||
|
successfully yet be silently dropped by every receiver's per-author bucket
|
||||||
|
(8 burst, then 1/s) with no sender feedback. The user chose "queue and
|
||||||
|
trickle" over "throttle input": sends past the burst queue locally as
|
||||||
|
`SendStatus::Queued` ("queued…") and release at the receivers' sustained
|
||||||
|
rate, so nothing is lost and typing is never blocked.
|
||||||
|
- **2026-07-18 (Phase 5):** The pacer (`src/app/sendqueue.rs`) reuses the
|
||||||
|
gossip gate's OWN `TokenBucket` + `CHAT_AUTHOR_BURST`/`CHAT_AUTHOR_REFILL_PER_MS`
|
||||||
|
(made `pub(crate)`), so the two sides of the rate policy are one definition
|
||||||
|
and cannot drift. It mirrors only the PER-AUTHOR budget, not the room-wide
|
||||||
|
one — we cannot know other members' send rates, and the per-author bucket is
|
||||||
|
the one guaranteed to apply to us at every receiver.
|
||||||
|
- **2026-07-18 (Phase 5):** Send status renders as a line UNDER the message
|
||||||
|
(user pick over an inline suffix glyph); `Broadcast` and the transient
|
||||||
|
`Pending` show nothing because PeerSpeak has no delivery/read receipts, so an
|
||||||
|
unadorned message IS the honest "handed to the swarm" state. Only `Queued`
|
||||||
|
and `Failed` (with Retry) are surfaced.
|
||||||
|
- **2026-07-18 (Phase 5):** The pacer and the monotonic send-id counter
|
||||||
|
deliberately SURVIVE a room reset while the queue and retry payloads are
|
||||||
|
cleared: receivers' per-author buckets persist across our rejoin (so the
|
||||||
|
pacer should not refill to full), and never-reused ids keep a late
|
||||||
|
`ChatSendResult` from a pre-reset send from aliasing a new entry — verified by
|
||||||
|
`send_result_after_room_reset_is_a_noop`.
|
||||||
|
- **2026-07-18 (Phase 5):** The pacer clock is `Instant`-based
|
||||||
|
(`AppState.send_clock`), not wall-clock, so a system time jump can neither
|
||||||
|
rewind nor fast-forward the send budget.
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
All five phases are implemented and every gate is green. Per the scope-contract
|
||||||
|
note at the top, this file should be DELETED once the owed two-machine field
|
||||||
|
test (the checklist below) has been run — that deletion is a separate,
|
||||||
|
user-gated step, not part of the Phase 5 commit. Until then the plan stays as
|
||||||
|
the record of what shipped and what remains to verify on real hardware.
|
||||||
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 139 KiB |
@@ -1,12 +1,12 @@
|
|||||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||||
pkgname=peerspeak-git
|
pkgname=peerspeak-git
|
||||||
_pkgname=peerspeak
|
_pkgname=peerspeak
|
||||||
pkgver=0.2.0.r218.gcbba4b6
|
pkgver=0.6.2.r319.g8014edf
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://gitbutter.xyz/mollusk/peerspeak"
|
url="https://gitbutter.xyz/mollusk/peerspeak"
|
||||||
license=('custom')
|
license=('MIT')
|
||||||
depends=('pipewire' 'opus')
|
depends=('pipewire' 'opus')
|
||||||
makedepends=('git' 'cargo' 'pkgconf')
|
makedepends=('git' 'cargo' 'pkgconf')
|
||||||
optdepends=('pixelpass: screen sharing inside a room'
|
optdepends=('pixelpass: screen sharing inside a room'
|
||||||
@@ -14,7 +14,7 @@ optdepends=('pixelpass: screen sharing inside a room'
|
|||||||
provides=('peerspeak')
|
provides=('peerspeak')
|
||||||
conflicts=('peerspeak')
|
conflicts=('peerspeak')
|
||||||
options=('!lto' '!debug')
|
options=('!lto' '!debug')
|
||||||
source=("$_pkgname::git+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
|
source=("$_pkgname::git+https://gitbutter.xyz/mollusk/peerspeak.git")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
pkgver() {
|
pkgver() {
|
||||||
@@ -38,6 +38,11 @@ build() {
|
|||||||
export CARGO_HOME="$srcdir/cargo-home"
|
export CARGO_HOME="$srcdir/cargo-home"
|
||||||
export RUSTUP_TOOLCHAIN=stable
|
export RUSTUP_TOOLCHAIN=stable
|
||||||
export CARGO_TARGET_DIR=target
|
export CARGO_TARGET_DIR=target
|
||||||
|
# Strip the build directory out of paths embedded in the binary (Rust bakes
|
||||||
|
# source paths into panic/backtrace metadata that survives stripping), so the
|
||||||
|
# package doesn't reference $srcdir. One remap covers our sources and the
|
||||||
|
# vendored deps, since CARGO_HOME lives under $srcdir too.
|
||||||
|
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$srcdir=/"
|
||||||
cargo build --frozen --release --bin "$_pkgname"
|
cargo build --frozen --release --bin "$_pkgname"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,4 +70,9 @@ package() {
|
|||||||
install -Dm644 "assets/icons/$_pkgname-$s.png" \
|
install -Dm644 "assets/icons/$_pkgname-$s.png" \
|
||||||
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/$_pkgname.png"
|
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/$_pkgname.png"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# License + third-party attribution notices.
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$_pkgname/LICENSE"
|
||||||
|
install -Dm644 THIRD_PARTY_LICENSES \
|
||||||
|
"$pkgdir/usr/share/licenses/$_pkgname/THIRD_PARTY_LICENSES"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.tools/
|
||||||
|
AppDir/
|
||||||
|
*.AppImage
|
||||||
|
squashfs-root/
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# AppRun for the PeerSpeak AppImage.
|
||||||
|
#
|
||||||
|
# PeerSpeak bundles the pixelpass screen-share helper in usr/bin. We prepend our
|
||||||
|
# own usr/bin to PATH so peerspeak's $PATH lookup for `pixelpass` finds the
|
||||||
|
# bundled copy, while the host's tools (gst-launch-1.0, pactl, mpv — which
|
||||||
|
# pixelpass in turn shells out to) remain reachable via the appended host PATH.
|
||||||
|
# That no-sandbox spawning is exactly why this app suits AppImage over Flatpak.
|
||||||
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
|
export PATH="$HERE/usr/bin:$PATH"
|
||||||
|
exec "$HERE/usr/bin/peerspeak" "$@"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# PeerSpeak AppImage
|
||||||
|
|
||||||
|
A "thin" AppImage: the `peerspeak` binary, the bundled `pixelpass` screen-share
|
||||||
|
helper, a launcher (`AppRun`), and the desktop entry + icon. Run
|
||||||
|
`./build-appimage.sh` to produce `peerspeak-<version>-x86_64.AppImage`.
|
||||||
|
|
||||||
|
## Why thin, and why pixelpass is bundled
|
||||||
|
|
||||||
|
PeerSpeak owns voice; **pixelpass** owns pixels. They are never Cargo
|
||||||
|
dependencies of each other — peerspeak shells out to the `pixelpass` binary over
|
||||||
|
its CLI. The AppImage co-locates `pixelpass` in `usr/bin`, and `AppRun` prepends
|
||||||
|
`usr/bin` to `PATH`, so peerspeak's normal `$PATH` lookup finds it with no code
|
||||||
|
change. Joe gets one file, and screen-share works out of the box.
|
||||||
|
|
||||||
|
Almost nothing is bundled: peerspeak's own assets (notification WAVs, avatar
|
||||||
|
presets, window icon, fonts) are `include_bytes!`-embedded, and the graphics
|
||||||
|
stack (`libGL`, `libvulkan`, `libwayland-*`, `libxkbcommon`, X11) is dlopen'd at
|
||||||
|
runtime and on the AppImage excludelist because it must match the host driver.
|
||||||
|
So the image carries just the two binaries plus a handful of small libs.
|
||||||
|
|
||||||
|
## Host requirements
|
||||||
|
|
||||||
|
The AppImage runs on any reasonably current glibc-based distro that has:
|
||||||
|
|
||||||
|
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
|
||||||
|
on AMD/Intel or the NVIDIA driver all work.
|
||||||
|
- **PipeWire** (with the PulseAudio shim, for `pactl`).
|
||||||
|
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
|
||||||
|
it prints the exact package names for your distro if any are missing:
|
||||||
|
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
|
||||||
|
good/bad/ugly, libav, and the PipeWire plugin),
|
||||||
|
- **mpv** (or vlc) for the viewer side,
|
||||||
|
- on X11, `xwininfo` for single-window capture.
|
||||||
|
|
||||||
|
On Arch/Artix that is one pacman line, e.g.:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
|
||||||
|
gst-plugins-ugly gst-libav gst-plugin-pipewire mpv xorg-xwininfo libpulse
|
||||||
|
```
|
||||||
|
|
||||||
|
(Add `gstreamer-vaapi` for hardware H.264 encode on AMD/Intel; the software
|
||||||
|
x264 path always works. On XLibre / X11 the capture path uses `ximagesrc` and
|
||||||
|
needs no XDG portal — no systemd required.)
|
||||||
|
|
||||||
|
## Building for broad compatibility (glibc baseline)
|
||||||
|
|
||||||
|
An AppImage requires a host glibc **at least as new** as the build host's. Built
|
||||||
|
on a rolling distro (glibc 2.43) it only runs on equally-new systems. Build
|
||||||
|
inside **Ubuntu 24.04** (glibc 2.39, PipeWire 1.0.5) for wide reach — pixelpass's
|
||||||
|
`pipewire` crate binds the system PipeWire headers and needs PipeWire >= 1.0, so
|
||||||
|
the older Debian 12 `peerspeak-bookworm` box (PW 0.3.65) cannot build it. 2.39
|
||||||
|
covers Debian 13+, Fedora 40+, and current rolling distros.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# One-time: an Ubuntu 24.04 distrobox that reuses the host rustup toolchain.
|
||||||
|
distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
|
||||||
|
distrobox enter peerspeak-appimage -- sudo apt-get update
|
||||||
|
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
|
||||||
|
build-essential cmake clang libclang-dev pkg-config \
|
||||||
|
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
|
||||||
|
curl ca-certificates file patchelf git
|
||||||
|
|
||||||
|
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
|
||||||
|
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
|
||||||
|
distrobox enter peerspeak-appimage -- env \
|
||||||
|
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
|
||||||
|
./packaging/appimage/build-appimage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- **Hardware encode (VAAPI)** uses the host GPU driver and can't be bundled; the
|
||||||
|
software x264 path always works.
|
||||||
|
- The bundled `pixelpass` is built headless (no `gui` feature) — it is only ever
|
||||||
|
driven by peerspeak, never launched standalone from this image.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a "thin" PeerSpeak AppImage that also bundles the pixelpass screen-share
|
||||||
|
# helper.
|
||||||
|
#
|
||||||
|
# PeerSpeak is an iced/wgpu GUI app; pixelpass is the separate screen-share
|
||||||
|
# orchestrator peerspeak shells out to (never a Cargo dependency). Both link
|
||||||
|
# almost nothing — the graphics stack (libGL, libvulkan, wayland, xkbcommon,
|
||||||
|
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
|
||||||
|
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
|
||||||
|
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
|
||||||
|
# binaries plus their handful of non-excludelisted libs. The custom AppRun
|
||||||
|
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
|
||||||
|
# pixelpass, while the host's tools stay reachable.
|
||||||
|
#
|
||||||
|
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
|
||||||
|
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
|
||||||
|
#
|
||||||
|
# Usage: packaging/appimage/build-appimage.sh
|
||||||
|
# Output: packaging/appimage/peerspeak-<version>-x86_64.AppImage
|
||||||
|
#
|
||||||
|
# Build inside an Ubuntu 24.04 distrobox (glibc 2.39, PipeWire 1.0.5) for broad
|
||||||
|
# reach — pixelpass's `pipewire` crate needs PipeWire >= 1.0 headers, so the
|
||||||
|
# older peerspeak-bookworm box (PW 0.3.65) cannot build it. The 2.39 baseline
|
||||||
|
# covers Debian 13+, Fedora 40+, and all current rolling distros. See README.md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo="$(cd "$here/../.." && pwd)"
|
||||||
|
tools="$here/.tools"
|
||||||
|
appdir="$here/AppDir"
|
||||||
|
mkdir -p "$tools"
|
||||||
|
|
||||||
|
# linuxdeploy is itself an AppImage; run it without FUSE so this works in a
|
||||||
|
# container / on CI without libfuse2.
|
||||||
|
export APPIMAGE_EXTRACT_AND_RUN=1
|
||||||
|
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
|
||||||
|
export VERSION
|
||||||
|
|
||||||
|
# Isolated target dirs so an old-glibc box build never clobbers the host target/.
|
||||||
|
cache="${PEERSPEAK_APPIMAGE_CACHE:-$HOME/.cache/peerspeak-appimage}"
|
||||||
|
ps_target="$cache/peerspeak-target"
|
||||||
|
pp_target="$cache/pixelpass-target"
|
||||||
|
|
||||||
|
# The pixelpass screen-share helper we bundle. Sibling checkout by default.
|
||||||
|
pixelpass_repo="${PIXELPASS_REPO:-$repo/../pixelpass}"
|
||||||
|
if [ ! -d "$pixelpass_repo" ]; then
|
||||||
|
echo "!! pixelpass repo not found at $pixelpass_repo (set PIXELPASS_REPO)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> building peerspeak (release)"
|
||||||
|
( cd "$repo" && CARGO_TARGET_DIR="$ps_target" cargo build --release )
|
||||||
|
ps_bin="$ps_target/release/peerspeak"
|
||||||
|
|
||||||
|
# Headless pixelpass: peerspeak drives it via `--host`/viewer + `--output json`,
|
||||||
|
# never its GUI, so the default (no `gui` feature) keeps the GL toolkit out.
|
||||||
|
echo ">> building pixelpass (release, headless) from $pixelpass_repo"
|
||||||
|
( cd "$pixelpass_repo" && CARGO_TARGET_DIR="$pp_target" cargo build --release )
|
||||||
|
pp_bin="$pp_target/release/pixelpass"
|
||||||
|
|
||||||
|
echo ">> fetching linuxdeploy"
|
||||||
|
ld="$tools/linuxdeploy-x86_64.AppImage"
|
||||||
|
if [ ! -x "$ld" ]; then
|
||||||
|
curl -fL --retry 3 -o "$ld" \
|
||||||
|
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||||
|
chmod +x "$ld"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> assembling AppDir"
|
||||||
|
rm -rf "$appdir"
|
||||||
|
mkdir -p "$appdir/usr/bin"
|
||||||
|
install -m755 "$ps_bin" "$appdir/usr/bin/peerspeak"
|
||||||
|
install -m755 "$pp_bin" "$appdir/usr/bin/pixelpass"
|
||||||
|
|
||||||
|
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
|
||||||
|
# -e (repeated): analyse both binaries for libraries to bundle; excludelisted
|
||||||
|
# graphics/glibc libs are skipped. -d/-i: desktop entry + icon.
|
||||||
|
# --custom-apprun: our launcher that puts the bundled pixelpass on PATH.
|
||||||
|
( cd "$here" && OUTPUT="peerspeak-${VERSION}-x86_64.AppImage" "$ld" \
|
||||||
|
--appdir "$appdir" \
|
||||||
|
-e "$appdir/usr/bin/peerspeak" \
|
||||||
|
-e "$appdir/usr/bin/pixelpass" \
|
||||||
|
-d "$repo/packaging/peerspeak.desktop" \
|
||||||
|
-i "$repo/assets/icons/peerspeak-256.png" \
|
||||||
|
--icon-filename peerspeak \
|
||||||
|
--custom-apprun "$here/AppRun" \
|
||||||
|
--output appimage )
|
||||||
|
|
||||||
|
echo ">> done: $here/peerspeak-${VERSION}-x86_64.AppImage"
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Debian / Ubuntu `.deb` build
|
||||||
|
|
||||||
|
This documents how the `peerspeak_*.deb` is produced, so the deb path is as
|
||||||
|
self-documenting as the Arch (`packaging/PKGBUILD`) and AppImage paths.
|
||||||
|
|
||||||
|
The deb **recipe itself** lives in-repo as the `[package.metadata.deb]` block in
|
||||||
|
the top-level `Cargo.toml` (cargo-deb's equivalent of a PKGBUILD). This file
|
||||||
|
documents only the **build environment**, which is otherwise undiscoverable from
|
||||||
|
a fresh clone.
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# one-time: create + provision the build box (see "Build environment" below)
|
||||||
|
distrobox enter peerspeak-bookworm -- bash -lc '
|
||||||
|
source ~/.cargo/env
|
||||||
|
cd ~/git/butter/peerspeak
|
||||||
|
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak # MANDATORY, see below
|
||||||
|
cargo deb
|
||||||
|
'
|
||||||
|
# output: $CARGO_TARGET_DIR/debian/peerspeak_<version>-1_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build environment
|
||||||
|
|
||||||
|
- **Base: a Debian 12 (bookworm) distrobox named `peerspeak-bookworm`.**
|
||||||
|
Created with `distrobox create --name peerspeak-bookworm --image debian:12`.
|
||||||
|
Bookworm ships **glibc 2.36**, which sets the widest practical compatibility
|
||||||
|
floor (see "glibc floor" below).
|
||||||
|
- **NEVER build the `.deb` on the Arch host.** Two independent reasons:
|
||||||
|
1. The Arch host's glibc is far newer, so the resulting `.deb` would demand a
|
||||||
|
glibc no normal Debian/Ubuntu user has, and ships an empty `Depends`.
|
||||||
|
2. distrobox shares `$HOME` (and therefore the repo's `target/`) with the host,
|
||||||
|
so a host build links Arch-compiled C objects into the "Debian" binary.
|
||||||
|
|
||||||
|
### One-time provisioning inside the box
|
||||||
|
|
||||||
|
```sh
|
||||||
|
distrobox enter peerspeak-bookworm
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y build-essential pkg-config clang libclang-dev \
|
||||||
|
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
|
||||||
|
# clang/libclang -> pipewire-sys bindgen ; libxcb1-dev -> link
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||||
|
source ~/.cargo/env
|
||||||
|
cargo install cargo-deb
|
||||||
|
```
|
||||||
|
|
||||||
|
### The mandatory separate `CARGO_TARGET_DIR`
|
||||||
|
|
||||||
|
Because distrobox shares `$HOME`, the repo's default `target/` is the **same
|
||||||
|
directory** the Arch host builds into. If you run `cargo deb` without overriding
|
||||||
|
the target dir, cargo will happily reuse Arch-built `.o`/rlib artifacts and link
|
||||||
|
them into the Debian binary, producing a `.deb` that crashes or demands the
|
||||||
|
host's glibc.
|
||||||
|
|
||||||
|
Always point the build at a box-local cache:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak
|
||||||
|
```
|
||||||
|
|
||||||
|
(Run `cargo clean` first if you ever suspect a polluted target dir.)
|
||||||
|
|
||||||
|
## glibc floor
|
||||||
|
|
||||||
|
The `.deb` is built against the build box's glibc, which becomes the install
|
||||||
|
floor (`libc6 (>= 2.36)` lands in `Depends` via `$auto`):
|
||||||
|
|
||||||
|
| Build box | glibc | Runs on |
|
||||||
|
|----------------------|-------|--------------------------------------|
|
||||||
|
| `debian:12` (current)| 2.36 | Debian 12+, Ubuntu 24.04+ (glibc ≥ 2.36) |
|
||||||
|
| `ubuntu:26.04` (old) | 2.43 | Ubuntu 26.04+ only — too narrow, abandoned |
|
||||||
|
|
||||||
|
If a friend is on something even older than Debian 12, drop the floor further by
|
||||||
|
recreating the box from an older base image and rebuilding.
|
||||||
|
|
||||||
|
## Runtime `Depends` / `Recommends`
|
||||||
|
|
||||||
|
- `Depends = "$auto"` — cargo-deb runs `dpkg-shlibdeps`, which discovers the
|
||||||
|
linked shared libraries (PipeWire, Opus, ALSA, xcb, glibc, …) automatically.
|
||||||
|
- `Recommends = "pixelpass, mpv"` — `pixelpass` provides in-room screen sharing
|
||||||
|
and `mpv` is the screen-share viewer (these are companion programs invoked as
|
||||||
|
subprocesses, not linked libraries, so they are Recommends not Depends).
|
||||||
|
|
||||||
|
See `pixelpass`'s own `packaging/debian/README.md` for why **its** `Depends`
|
||||||
|
lists the whole GStreamer stack explicitly.
|
||||||
@@ -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.
|
|
||||||
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
|
|||||||
|
|
||||||
## 1. Install it
|
## 1. Install it
|
||||||
|
|
||||||
1. Double-click **`peerspeak-0.3.0-setup.exe`** (the file I sent you).
|
1. Double-click **`peerspeak-<version>-setup.exe`** (the file I sent you).
|
||||||
|
|
||||||
2. **Windows will probably show a blue "Windows protected your PC" warning.**
|
2. **Windows will probably show a blue "Windows protected your PC" warning.**
|
||||||
This is normal — it shows up for any app that isn't from a big company with a
|
This is normal — it shows up for any app that isn't from a big company with a
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
|
|||||||
|
|
||||||
## Version compatibility
|
## Version compatibility
|
||||||
|
|
||||||
The installer version tracks the crate version in `Cargo.toml` (currently
|
The installer version tracks the release version in `Cargo.toml` — keep
|
||||||
**0.3.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
|
`MyAppVersion` in `peerspeak.iss` in sync when cutting a release. Do not reuse an
|
||||||
|
old installer filename after a crate-version bump.
|
||||||
|
|
||||||
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
|
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
|
||||||
peers on different MINOR versions can't connect (they fail fast at the
|
peers on different MINOR versions can't connect (they fail fast at the
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
@@ -12,7 +12,7 @@
|
|||||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||||
|
|
||||||
#define MyAppName "PeerSpeak"
|
#define MyAppName "PeerSpeak"
|
||||||
#define MyAppVersion "0.3.0"
|
#define MyAppVersion "0.6.4"
|
||||||
#define MyAppPublisher "mollusk"
|
#define MyAppPublisher "mollusk"
|
||||||
#define MyAppExeName "peerspeak.exe"
|
#define MyAppExeName "peerspeak.exe"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
//! Sender-side chat send status and pacing (chat-hardening Phase 5).
|
||||||
|
//!
|
||||||
|
//! Every RECEIVER admits our chat through a per-author token bucket
|
||||||
|
//! ([`CHAT_AUTHOR_BURST`] then 1/s) and silently drops what exceeds it, with no
|
||||||
|
//! acknowledgement wire. The only way the sender can be honest about fast
|
||||||
|
//! bursts is to never exceed that budget in the first place: sends past the
|
||||||
|
//! burst are queued locally (shown as "queued…") and trickled out at the
|
||||||
|
//! receivers' sustained rate. The pacer deliberately reuses the receiver
|
||||||
|
//! gate's own [`TokenBucket`] and constants so the two sides of the policy
|
||||||
|
//! cannot drift apart.
|
||||||
|
//!
|
||||||
|
//! Everything here is pure — `now_ms` is passed in, never read from a clock —
|
||||||
|
//! so every boundary is unit-testable.
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
use crate::network::gossip::{CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, TokenBucket};
|
||||||
|
|
||||||
|
/// Send lifecycle of one locally authored chat message. Success is
|
||||||
|
/// [`SendStatus::Broadcast`] — "our signed frame was handed to the gossip
|
||||||
|
/// swarm" — deliberately NOT "delivered": PeerSpeak has no peer
|
||||||
|
/// acknowledgements, so the honest success presentation is no label at all.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum SendStatus {
|
||||||
|
/// Waiting in the local outbound queue for a pacer token.
|
||||||
|
Queued,
|
||||||
|
/// Handed to the core; the broadcast result has not come back yet.
|
||||||
|
Pending,
|
||||||
|
/// The signed broadcast reached the gossip swarm.
|
||||||
|
Broadcast,
|
||||||
|
/// The send failed; carries a short reason. The entry offers a Retry.
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-only send bookkeeping attached to our own chat entries. The id never
|
||||||
|
/// goes on the wire; it ties a `ChatSendResult` back to the matching echo.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LocalSend {
|
||||||
|
pub id: u64,
|
||||||
|
pub status: SendStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender-side pacer mirroring the receiver's per-author admission budget.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct SendPacer {
|
||||||
|
bucket: TokenBucket,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SendPacer {
|
||||||
|
pub fn new(now_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
bucket: TokenBucket::full(CHAT_AUTHOR_BURST, now_ms),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take one send token if the mirrored per-author budget allows it now.
|
||||||
|
pub fn try_send(&mut self, now_ms: u64) -> bool {
|
||||||
|
self.bucket
|
||||||
|
.try_take(CHAT_AUTHOR_BURST, CHAT_AUTHOR_REFILL_PER_MS, now_ms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop the queued ids that may be dispatched now: strict front-of-queue order,
|
||||||
|
/// one pacer token each, stopping at the first refusal so a message can never
|
||||||
|
/// overtake an earlier one.
|
||||||
|
pub fn release_ready(queue: &mut VecDeque<u64>, pacer: &mut SendPacer, now_ms: u64) -> Vec<u64> {
|
||||||
|
let mut ready = Vec::new();
|
||||||
|
while !queue.is_empty() && pacer.try_send(now_ms) {
|
||||||
|
// The unwrap is safe: the loop condition just checked non-empty.
|
||||||
|
ready.push(queue.pop_front().unwrap());
|
||||||
|
}
|
||||||
|
ready
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const T0: u64 = 1_000_000;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacer_allows_the_full_burst_then_refuses() {
|
||||||
|
let mut pacer = SendPacer::new(T0);
|
||||||
|
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||||
|
assert!(pacer.try_send(T0));
|
||||||
|
}
|
||||||
|
assert!(!pacer.try_send(T0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pacer_refills_at_one_per_second() {
|
||||||
|
let mut pacer = SendPacer::new(T0);
|
||||||
|
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||||
|
assert!(pacer.try_send(T0));
|
||||||
|
}
|
||||||
|
// 999ms is just under one token; 1000ms grants exactly one.
|
||||||
|
assert!(!pacer.try_send(T0 + 999));
|
||||||
|
assert!(pacer.try_send(T0 + 1000));
|
||||||
|
assert!(!pacer.try_send(T0 + 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_ready_preserves_order_and_stops_at_refusal() {
|
||||||
|
let mut pacer = SendPacer::new(T0);
|
||||||
|
// Drain the burst so only refill tokens remain.
|
||||||
|
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||||
|
assert!(pacer.try_send(T0));
|
||||||
|
}
|
||||||
|
let mut queue: VecDeque<u64> = [10, 11, 12].into_iter().collect();
|
||||||
|
// 2 seconds of refill = 2 tokens: exactly the first two, in order.
|
||||||
|
let ready = release_ready(&mut queue, &mut pacer, T0 + 2000);
|
||||||
|
assert_eq!(ready, vec![10, 11]);
|
||||||
|
assert_eq!(queue, VecDeque::from([12]));
|
||||||
|
// No tokens left at the same instant.
|
||||||
|
assert!(release_ready(&mut queue, &mut pacer, T0 + 2000).is_empty());
|
||||||
|
assert_eq!(queue, VecDeque::from([12]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_ready_empty_queue_consumes_no_tokens() {
|
||||||
|
let mut pacer = SendPacer::new(T0);
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
assert!(release_ready(&mut queue, &mut pacer, T0).is_empty());
|
||||||
|
// The full burst must still be available.
|
||||||
|
for _ in 0..CHAT_AUTHOR_BURST as usize {
|
||||||
|
assert!(pacer.try_send(T0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
//! Independent playback engine for inline chat audio attachments.
|
||||||
|
//!
|
||||||
|
//! The rodio device sink stays on a dedicated OS thread and never enters iced
|
||||||
|
//! state or the call-audio pipeline. The GUI sends small commands and reads a
|
||||||
|
//! shared status snapshot at its redraw cadence.
|
||||||
|
|
||||||
|
use crate::files::AttachmentId;
|
||||||
|
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::{Arc, Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// State published by the playback thread for the GUI.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct ClipStatus {
|
||||||
|
pub playing_id: Option<AttachmentId>,
|
||||||
|
pub position: Duration,
|
||||||
|
pub total: Option<Duration>,
|
||||||
|
pub paused: bool,
|
||||||
|
/// Set when output initialization or decoding rejects the requested clip.
|
||||||
|
/// The app consumes this as a signal to fall back to the normal file chip.
|
||||||
|
pub failure: Option<ClipFailure>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ClipFailure {
|
||||||
|
pub id: AttachmentId,
|
||||||
|
pub error: String,
|
||||||
|
/// Decoder rejection means the filename hint should fall back to a file
|
||||||
|
/// chip. Output-device failures remain retryable as audio.
|
||||||
|
pub invalid_audio: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ClipCommand {
|
||||||
|
Play(AttachmentId, Vec<u8>),
|
||||||
|
Pause,
|
||||||
|
Resume,
|
||||||
|
Seek(Duration),
|
||||||
|
Stop,
|
||||||
|
SetVolume(f32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||||
|
pub struct ClipPlayer {
|
||||||
|
command_tx: mpsc::Sender<ClipCommand>,
|
||||||
|
status: SharedClipStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClipPlayer {
|
||||||
|
/// Start the playback worker. The system output device is opened lazily on
|
||||||
|
/// first Play, so merely launching PeerSpeak never claims another stream.
|
||||||
|
///
|
||||||
|
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
|
||||||
|
/// clip, restored from config so the level persists across sessions.
|
||||||
|
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
|
||||||
|
let (command_tx, command_rx) = mpsc::channel();
|
||||||
|
let status = Arc::new(Mutex::new(ClipStatus::default()));
|
||||||
|
let worker_status = Arc::clone(&status);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("peerspeak-clip-player".to_string())
|
||||||
|
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
|
||||||
|
.expect("failed to spawn clip playback thread");
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
command_tx,
|
||||||
|
status: Arc::clone(&status),
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
|
||||||
|
update_status(&self.status, |status| {
|
||||||
|
status.playing_id = Some(id);
|
||||||
|
status.position = Duration::ZERO;
|
||||||
|
status.total = None;
|
||||||
|
status.paused = false;
|
||||||
|
status.failure = None;
|
||||||
|
});
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pause(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Pause);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resume(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Resume);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, position: Duration) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Seek(position));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop(&self) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the universal playback gain (`1.0` = unity). Applies to the current
|
||||||
|
/// clip immediately and to every clip played afterwards.
|
||||||
|
pub fn set_volume(&self, volume: f32) {
|
||||||
|
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playback_worker(
|
||||||
|
command_rx: mpsc::Receiver<ClipCommand>,
|
||||||
|
status: SharedClipStatus,
|
||||||
|
initial_volume: f32,
|
||||||
|
) {
|
||||||
|
let mut output: Option<MixerDeviceSink> = None;
|
||||||
|
let mut player: Option<Player> = None;
|
||||||
|
// Universal gain remembered across clips so a level set on one upload
|
||||||
|
// carries to the next; reapplied to each freshly connected player.
|
||||||
|
let mut volume = initial_volume.max(0.0);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||||
|
Ok(ClipCommand::Play(id, bytes)) => {
|
||||||
|
// In-memory readers do not expose file metadata to rodio. Pass
|
||||||
|
// the known attachment length explicitly so formats without a
|
||||||
|
// duration in their headers (notably MP3 and Vorbis) can derive
|
||||||
|
// a total duration and support reliable seeking.
|
||||||
|
let source = match decode_clip(bytes) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(error) => {
|
||||||
|
fail(
|
||||||
|
&status,
|
||||||
|
id,
|
||||||
|
format!("unsupported or invalid audio: {error}"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let total = source.total_duration();
|
||||||
|
|
||||||
|
if output.is_none() {
|
||||||
|
match DeviceSinkBuilder::open_default_sink() {
|
||||||
|
Ok(sink) => {
|
||||||
|
let new_player = Player::connect_new(sink.mixer());
|
||||||
|
new_player.set_volume(volume);
|
||||||
|
player = Some(new_player);
|
||||||
|
output = Some(sink);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
fail(
|
||||||
|
&status,
|
||||||
|
id,
|
||||||
|
format!("audio output unavailable: {error}"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.clear();
|
||||||
|
player.append(source);
|
||||||
|
player.play();
|
||||||
|
update_status(&status, |s| {
|
||||||
|
s.playing_id = Some(id);
|
||||||
|
s.position = Duration::ZERO;
|
||||||
|
s.total = total;
|
||||||
|
s.paused = false;
|
||||||
|
s.failure = None;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Pause) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.pause();
|
||||||
|
update_status(&status, |s| s.paused = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Resume) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.play();
|
||||||
|
update_status(&status, |s| s.paused = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Seek(position)) => {
|
||||||
|
if let Some(player) = player.as_ref()
|
||||||
|
&& player.try_seek(position).is_ok()
|
||||||
|
{
|
||||||
|
update_status(&status, |s| s.position = position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::Stop) => {
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.clear();
|
||||||
|
}
|
||||||
|
reset(&status);
|
||||||
|
}
|
||||||
|
Ok(ClipCommand::SetVolume(level)) => {
|
||||||
|
volume = level.max(0.0);
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
player.set_volume(volume);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||||
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(player) = player.as_ref() {
|
||||||
|
let (active, failed) = status
|
||||||
|
.lock()
|
||||||
|
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if active && !failed && player.empty() {
|
||||||
|
reset(&status);
|
||||||
|
} else if active && !failed {
|
||||||
|
update_status(&status, |s| {
|
||||||
|
s.position = player.get_pos();
|
||||||
|
s.paused = player.is_paused();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
|
||||||
|
let byte_len = bytes.len() as u64;
|
||||||
|
Decoder::builder()
|
||||||
|
.with_data(Cursor::new(bytes))
|
||||||
|
.with_byte_len(byte_len)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
|
||||||
|
crate::log_msg(&format!("Inline audio playback failed: {error}"));
|
||||||
|
update_status(status, |s| {
|
||||||
|
// Keep the id active until the GUI observes the failure on its next
|
||||||
|
// tick. This guarantees the active-only timer cannot disappear in the
|
||||||
|
// small window between sending Play and decoder/output failure.
|
||||||
|
s.playing_id = Some(id);
|
||||||
|
s.position = Duration::ZERO;
|
||||||
|
s.total = None;
|
||||||
|
s.paused = false;
|
||||||
|
s.failure = Some(ClipFailure {
|
||||||
|
id,
|
||||||
|
error,
|
||||||
|
invalid_audio,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(status: &SharedClipStatus) {
|
||||||
|
update_status(status, |s| *s = ClipStatus::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
|
||||||
|
if let Ok(mut status) = status.lock() {
|
||||||
|
update(&mut status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
|
||||||
|
status.lock().map(|s| s.clone()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format clip time as `mm:ss` (hours are folded into minutes).
|
||||||
|
pub fn format_time(duration: Duration) -> String {
|
||||||
|
let seconds = duration.as_secs();
|
||||||
|
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
|
||||||
|
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
|
||||||
|
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
|
||||||
|
return 0.0;
|
||||||
|
};
|
||||||
|
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a slider fraction into a clamped position within a clip.
|
||||||
|
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
|
||||||
|
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_memory_mp3_reports_duration() {
|
||||||
|
// One headerless constant-bitrate MP3 frame repeated to model files
|
||||||
|
// that do not carry an Xing/VBR duration header.
|
||||||
|
let frame = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
|
||||||
|
.expect("valid test fixture");
|
||||||
|
let bytes = frame.repeat(20);
|
||||||
|
|
||||||
|
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
|
||||||
|
assert!(decoder.total_duration().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn formats_clip_time() {
|
||||||
|
assert_eq!(format_time(Duration::ZERO), "0:00");
|
||||||
|
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
|
||||||
|
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progress_handles_unknown_zero_and_clamps() {
|
||||||
|
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
|
||||||
|
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
|
||||||
|
assert_eq!(
|
||||||
|
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
|
||||||
|
0.5
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
|
||||||
|
1.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seek_target_clamps_fraction() {
|
||||||
|
let total = Duration::from_secs(100);
|
||||||
|
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
|
||||||
|
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||||
|
assert_eq!(seek_target(2.0, total), total);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result<cpal::SupportedStreamC
|
|||||||
let pick = |channels: Option<u16>| {
|
let pick = |channels: Option<u16>| {
|
||||||
ranges
|
ranges
|
||||||
.iter()
|
.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()
|
.cloned()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -666,15 +668,30 @@ fn run_capture(
|
|||||||
let device_rate = config.sample_rate.0;
|
let device_rate = config.sample_rate.0;
|
||||||
let stream = match sample_format {
|
let stream = match sample_format {
|
||||||
SampleFormat::F32 => build_input::<f32, _>(
|
SampleFormat::F32 => build_input::<f32, _>(
|
||||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
&device,
|
||||||
|
&config,
|
||||||
|
producer,
|
||||||
|
channels,
|
||||||
|
overrun.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
err_code.clone(),
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::I16 => build_input::<i16, _>(
|
SampleFormat::I16 => build_input::<i16, _>(
|
||||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
&device,
|
||||||
|
&config,
|
||||||
|
producer,
|
||||||
|
channels,
|
||||||
|
overrun.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
err_code.clone(),
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::U16 => build_input::<u16, _>(
|
SampleFormat::U16 => build_input::<u16, _>(
|
||||||
&device, &config, producer, channels, overrun.clone(), callbacks.clone(),
|
&device,
|
||||||
|
&config,
|
||||||
|
producer,
|
||||||
|
channels,
|
||||||
|
overrun.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
err_code.clone(),
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
other => Err(AudioError::Stream(format!(
|
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).
|
// Surface a stream error the RT callback flagged (it can't log itself).
|
||||||
let ec = err_code.load(Ordering::Relaxed);
|
let ec = err_code.load(Ordering::Relaxed);
|
||||||
if ec != STREAM_ERR_NONE && ec != last_err {
|
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;
|
last_err = ec;
|
||||||
}
|
}
|
||||||
if !drained {
|
if !drained {
|
||||||
@@ -914,16 +934,34 @@ fn run_playback(
|
|||||||
let device_rate = config.sample_rate.0;
|
let device_rate = config.sample_rate.0;
|
||||||
let stream = match sample_format {
|
let stream = match sample_format {
|
||||||
SampleFormat::F32 => build_output::<f32, _>(
|
SampleFormat::F32 => build_output::<f32, _>(
|
||||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
&device,
|
||||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
&config,
|
||||||
|
consumer,
|
||||||
|
ring_fill.clone(),
|
||||||
|
underrun.clone(),
|
||||||
|
max_cb.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::I16 => build_output::<i16, _>(
|
SampleFormat::I16 => build_output::<i16, _>(
|
||||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
&device,
|
||||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
&config,
|
||||||
|
consumer,
|
||||||
|
ring_fill.clone(),
|
||||||
|
underrun.clone(),
|
||||||
|
max_cb.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
SampleFormat::U16 => build_output::<u16, _>(
|
SampleFormat::U16 => build_output::<u16, _>(
|
||||||
&device, &config, consumer, ring_fill.clone(), underrun.clone(),
|
&device,
|
||||||
max_cb.clone(), callbacks.clone(), err_code.clone(),
|
&config,
|
||||||
|
consumer,
|
||||||
|
ring_fill.clone(),
|
||||||
|
underrun.clone(),
|
||||||
|
max_cb.clone(),
|
||||||
|
callbacks.clone(),
|
||||||
|
err_code.clone(),
|
||||||
),
|
),
|
||||||
other => Err(AudioError::Stream(format!(
|
other => Err(AudioError::Stream(format!(
|
||||||
"unsupported playback sample format: {other:?}"
|
"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).
|
// Surface a stream error the RT callback flagged (it can't log itself).
|
||||||
let ec = err_code.load(Ordering::Relaxed);
|
let ec = err_code.load(Ordering::Relaxed);
|
||||||
if ec != STREAM_ERR_NONE && ec != last_err {
|
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;
|
last_err = ec;
|
||||||
}
|
}
|
||||||
// Report the device's per-cycle demand (in internal 48 kHz-stereo
|
// Report the device's per-cycle demand (in internal 48 kHz-stereo
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
|
|||||||
/// nodes never leak past the call that created them.
|
/// nodes never leak past the call that created them.
|
||||||
pub struct EchoCancelGuard {
|
pub struct EchoCancelGuard {
|
||||||
module_index: String,
|
module_index: String,
|
||||||
|
source_name: String,
|
||||||
|
sink_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EchoCancelGuard {
|
||||||
|
pub fn source_name(&self) -> &str {
|
||||||
|
&self.source_name
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sink_name(&self) -> &str {
|
||||||
|
&self.sink_name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for EchoCancelGuard {
|
impl Drop for EchoCancelGuard {
|
||||||
@@ -42,7 +54,10 @@ impl Drop for EchoCancelGuard {
|
|||||||
.arg("unload-module")
|
.arg("unload-module")
|
||||||
.arg(&self.module_index)
|
.arg(&self.module_index)
|
||||||
.output();
|
.output();
|
||||||
crate::log_msg(&format!("Echo cancel: unloaded module {}", self.module_index));
|
crate::log_msg(&format!(
|
||||||
|
"Echo cancel: unloaded module {}",
|
||||||
|
self.module_index
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,17 +68,24 @@ impl Drop for EchoCancelGuard {
|
|||||||
/// `None` (or an empty string) to bind to the system defaults. Returns `Err` with
|
/// `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
|
/// 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.
|
/// 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
|
// 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.
|
// don't stack duplicate modules / fight over the virtual node names.
|
||||||
unload_stale();
|
unload_stale();
|
||||||
|
|
||||||
|
let owner_pid = std::process::id();
|
||||||
|
let source_name = format!("{EC_SOURCE}.{owner_pid}");
|
||||||
|
let sink_name = format!("{EC_SINK}.{owner_pid}");
|
||||||
|
|
||||||
let mut cmd = Command::new("pactl");
|
let mut cmd = Command::new("pactl");
|
||||||
cmd.arg("load-module")
|
cmd.arg("load-module")
|
||||||
.arg("module-echo-cancel")
|
.arg("module-echo-cancel")
|
||||||
.arg("aec_method=webrtc")
|
.arg("aec_method=webrtc")
|
||||||
.arg(format!("source_name={EC_SOURCE}"))
|
.arg(format!("source_name={source_name}"))
|
||||||
.arg(format!("sink_name={EC_SINK}"));
|
.arg(format!("sink_name={sink_name}"));
|
||||||
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
|
if let Some(src) = real_source.filter(|s| !s.is_empty()) {
|
||||||
cmd.arg(format!("source_master={src}"));
|
cmd.arg(format!("source_master={src}"));
|
||||||
}
|
}
|
||||||
@@ -85,12 +107,16 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
if module_index.parse::<u64>().is_err() {
|
if module_index.parse::<u64>().is_err() {
|
||||||
return Err(format!("unexpected pactl output: {module_index:?}"));
|
return Err(format!("unexpected pactl output: {module_index:?}"));
|
||||||
}
|
}
|
||||||
let guard = EchoCancelGuard { module_index };
|
let guard = EchoCancelGuard {
|
||||||
|
module_index,
|
||||||
|
source_name,
|
||||||
|
sink_name,
|
||||||
|
};
|
||||||
|
|
||||||
// The virtual nodes appear shortly after the module loads; wait for both so
|
// The virtual nodes appear shortly after the module loads; wait for both so
|
||||||
// the subsequent capture/playback streams can actually target them. If they
|
// the subsequent capture/playback streams can actually target them. If they
|
||||||
// never show, drop the guard (unloads) and report failure.
|
// never show, drop the guard (unloads) and report failure.
|
||||||
if !wait_for_nodes() {
|
if !wait_for_nodes(guard.source_name(), guard.sink_name()) {
|
||||||
return Err("echo-cancel virtual nodes did not appear in time".to_string());
|
return Err("echo-cancel virtual nodes did not appear in time".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +128,10 @@ pub fn enable(real_source: Option<&str>, real_sink: Option<&str>) -> Result<Echo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Polls until both virtual nodes exist or the timeout elapses.
|
/// Polls until both virtual nodes exist or the timeout elapses.
|
||||||
fn wait_for_nodes() -> bool {
|
fn wait_for_nodes(source_name: &str, sink_name: &str) -> bool {
|
||||||
let deadline = Instant::now() + NODE_READY_TIMEOUT;
|
let deadline = Instant::now() + NODE_READY_TIMEOUT;
|
||||||
loop {
|
loop {
|
||||||
if node_present("sources", EC_SOURCE) && node_present("sinks", EC_SINK) {
|
if node_present("sources", source_name) && node_present("sinks", sink_name) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if Instant::now() >= deadline {
|
if Instant::now() >= deadline {
|
||||||
@@ -118,7 +144,12 @@ fn wait_for_nodes() -> bool {
|
|||||||
/// Whether `pactl list <kind> short` lists a node named `name`.
|
/// Whether `pactl list <kind> short` lists a node named `name`.
|
||||||
/// `kind` is "sources" or "sinks".
|
/// `kind` is "sources" or "sinks".
|
||||||
fn node_present(kind: &str, name: &str) -> bool {
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
String::from_utf8_lossy(&out.stdout)
|
String::from_utf8_lossy(&out.stdout)
|
||||||
@@ -126,10 +157,37 @@ fn node_present(kind: &str, name: &str) -> bool {
|
|||||||
.any(|line| line.split('\t').nth(1) == Some(name))
|
.any(|line| line.split('\t').nth(1) == Some(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unloads any leftover `module-echo-cancel` instance we previously created
|
fn pid_from_ec_args(args: &str) -> Option<u32> {
|
||||||
/// (identified by our virtual node names in its argument string). Best-effort.
|
let source_prefix = format!("source_name={EC_SOURCE}.");
|
||||||
|
args.split_whitespace()
|
||||||
|
.find_map(|arg| arg.strip_prefix(&source_prefix))?
|
||||||
|
.parse()
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ec_module_is_stale(args: &str, is_alive: impl Fn(u32) -> bool) -> bool {
|
||||||
|
pid_from_ec_args(args).is_some_and(|pid| !is_alive(pid))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn process_is_alive(pid: u32) -> bool {
|
||||||
|
std::path::Path::new("/proc").join(pid.to_string()).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
fn process_is_alive(_pid: u32) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unloads leftover PeerSpeak `module-echo-cancel` instances only when their
|
||||||
|
/// owning process is gone. Best-effort and conservative on non-Linux platforms.
|
||||||
fn unload_stale() {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||||
@@ -137,8 +195,14 @@ fn unload_stale() {
|
|||||||
let index = cols.next().unwrap_or("");
|
let index = cols.next().unwrap_or("");
|
||||||
let name = cols.next().unwrap_or("");
|
let name = cols.next().unwrap_or("");
|
||||||
let args = cols.next().unwrap_or("");
|
let args = cols.next().unwrap_or("");
|
||||||
if name == "module-echo-cancel" && args.contains(EC_SOURCE) && index.parse::<u64>().is_ok() {
|
if name == "module-echo-cancel"
|
||||||
let _ = Command::new("pactl").arg("unload-module").arg(index).output();
|
&& ec_module_is_stale(args, process_is_alive)
|
||||||
|
&& index.parse::<u64>().is_ok()
|
||||||
|
{
|
||||||
|
let _ = Command::new("pactl")
|
||||||
|
.arg("unload-module")
|
||||||
|
.arg(index)
|
||||||
|
.output();
|
||||||
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
crate::log_msg(&format!("Echo cancel: cleaned up stale module {index}"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,12 +219,57 @@ mod tests {
|
|||||||
#[ignore]
|
#[ignore]
|
||||||
fn enable_creates_and_unloads_nodes() {
|
fn enable_creates_and_unloads_nodes() {
|
||||||
let guard = enable(None, None).expect("module-echo-cancel should load");
|
let guard = enable(None, None).expect("module-echo-cancel should load");
|
||||||
assert!(node_present("sources", EC_SOURCE), "cleaned source must exist");
|
let source_name = guard.source_name().to_string();
|
||||||
assert!(node_present("sinks", EC_SINK), "reference sink must exist");
|
let sink_name = guard.sink_name().to_string();
|
||||||
|
assert!(
|
||||||
|
node_present("sources", &source_name),
|
||||||
|
"cleaned source must exist"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
node_present("sinks", &sink_name),
|
||||||
|
"reference sink must exist"
|
||||||
|
);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
// Give pactl a moment to tear the nodes down.
|
// Give pactl a moment to tear the nodes down.
|
||||||
std::thread::sleep(Duration::from_millis(300));
|
std::thread::sleep(Duration::from_millis(300));
|
||||||
assert!(!node_present("sources", EC_SOURCE), "source must be gone after unload");
|
assert!(
|
||||||
assert!(!node_present("sinks", EC_SINK), "sink must be gone after unload");
|
!node_present("sources", &source_name),
|
||||||
|
"source must be gone after unload"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!node_present("sinks", &sink_name),
|
||||||
|
"sink must be gone after unload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_owner_pid_only_from_our_source_name() {
|
||||||
|
assert_eq!(
|
||||||
|
pid_from_ec_args(
|
||||||
|
"aec_method=webrtc source_name=peerspeak_echocancel_source.4242 sink_name=peerspeak_echocancel_sink.4242"
|
||||||
|
),
|
||||||
|
Some(4242)
|
||||||
|
);
|
||||||
|
assert_eq!(pid_from_ec_args("aec_method=webrtc"), None);
|
||||||
|
assert_eq!(
|
||||||
|
pid_from_ec_args("source_name=peerspeak_echocancel_source.not-a-pid"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
pid_from_ec_args("source_name=someone_elses_source.4242"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stale_decision_keeps_live_and_foreign_modules() {
|
||||||
|
let ours = "source_name=peerspeak_echocancel_source.4242";
|
||||||
|
assert!(!ec_module_is_stale(ours, |pid| pid == 4242));
|
||||||
|
assert!(ec_module_is_stale(ours, |_| false));
|
||||||
|
assert!(!ec_module_is_stale("source_name=foreign.4242", |_| false));
|
||||||
|
assert!(!ec_module_is_stale(
|
||||||
|
"source_name=peerspeak_echocancel_source.malformed",
|
||||||
|
|_| false
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,7 +251,10 @@ mod tests {
|
|||||||
let before = rms(&low);
|
let before = rms(&low);
|
||||||
eq.process_frame(&mut low);
|
eq.process_frame(&mut low);
|
||||||
let after = rms(&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]
|
#[test]
|
||||||
@@ -264,7 +267,10 @@ mod tests {
|
|||||||
let before = rms(&high);
|
let before = rms(&high);
|
||||||
eq.process_frame(&mut high);
|
eq.process_frame(&mut high);
|
||||||
let after = rms(&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]
|
#[test]
|
||||||
@@ -275,7 +281,10 @@ mod tests {
|
|||||||
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
|
Biquad::peaking(DEFAULT_SAMPLE_RATE, MID_PEAK_HZ, gain, MID_Q),
|
||||||
Biquad::high_shelf(DEFAULT_SAMPLE_RATE, HIGH_SHELF_HZ, gain, SHELF_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);
|
let mut frame = sine(1_000.0, 48_000, 30_000.0);
|
||||||
eq.process_frame(&mut frame);
|
eq.process_frame(&mut frame);
|
||||||
let peak = frame
|
let peak = frame.iter().map(|&s| i32::from(s).abs()).max().unwrap_or(0);
|
||||||
.iter()
|
assert!(
|
||||||
.map(|&s| i32::from(s).abs())
|
peak > 1_000,
|
||||||
.max()
|
"processed signal should retain audible energy"
|
||||||
.unwrap_or(0);
|
);
|
||||||
assert!(peak > 1_000, "processed signal should retain audible energy");
|
|
||||||
assert!(
|
assert!(
|
||||||
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
frame.iter().any(|&s| s > 0) && frame.iter().any(|&s| s < 0),
|
||||||
"a boosted sine should retain both polarities"
|
"a boosted sine should retain both polarities"
|
||||||
|
|||||||
@@ -169,7 +169,10 @@ mod tests {
|
|||||||
assert!(g.process(&mut f, 0.05), "loud frame must transmit");
|
assert!(g.process(&mut f, 0.05), "loud frame must transmit");
|
||||||
last = peak(&f);
|
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]
|
#[test]
|
||||||
@@ -179,8 +182,15 @@ mod tests {
|
|||||||
g.process(&mut f, 0.05);
|
g.process(&mut f, 0.05);
|
||||||
// 5ms attack @48k = 240 samples; across a 960-sample frame the gain ramps
|
// 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).
|
// 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!(
|
||||||
assert!(f[FRAME - 1].abs() > 9000, "attack should complete within the frame");
|
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]
|
#[test]
|
||||||
@@ -193,8 +203,14 @@ mod tests {
|
|||||||
}
|
}
|
||||||
// First quiet frame right after speech: hold keeps it open (not chopped).
|
// First quiet frame right after speech: hold keeps it open (not chopped).
|
||||||
let mut q = frame(50); // rms ~0.0015, below close (0.03)
|
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!(
|
||||||
assert!(peak(&q) > 0, "held-open frame must not be silenced immediately");
|
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.
|
// Hold is 200ms = 10 frames; keep feeding quiet until it fully closes.
|
||||||
let mut closed = false;
|
let mut closed = false;
|
||||||
@@ -205,7 +221,10 @@ mod tests {
|
|||||||
break;
|
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]
|
#[test]
|
||||||
@@ -216,8 +235,14 @@ mod tests {
|
|||||||
g.process(&mut f, 0.05); // open=0.05, close=0.03
|
g.process(&mut f, 0.05); // open=0.05, close=0.03
|
||||||
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
|
// A frame between close and open thresholds: rms ~0.04 (amp ~1310).
|
||||||
let mut mid = frame(1310);
|
let mut mid = frame(1310);
|
||||||
assert!(g.process(&mut mid, 0.05), "between-threshold frame must keep an open gate open");
|
assert!(
|
||||||
assert!(g.open, "hysteresis: gate stays open above the close threshold");
|
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]
|
#[test]
|
||||||
@@ -225,7 +250,10 @@ mod tests {
|
|||||||
let mut g = NoiseGate::new(SR);
|
let mut g = NoiseGate::new(SR);
|
||||||
// Never opened; feed silence — should report don't-transmit promptly.
|
// Never opened; feed silence — should report don't-transmit promptly.
|
||||||
let mut f = frame(0);
|
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]
|
#[test]
|
||||||
@@ -266,7 +294,11 @@ mod tests {
|
|||||||
|
|
||||||
let mut f2 = frame(10000);
|
let mut f2 = frame(10000);
|
||||||
assert!(g.process(&mut f2, 0.05)); // enabled
|
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]
|
#[test]
|
||||||
@@ -302,7 +334,10 @@ mod tests {
|
|||||||
let mut f = frame(1310);
|
let mut f = frame(1310);
|
||||||
assert!(g.process(&mut f, 0.05));
|
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]
|
#[test]
|
||||||
@@ -333,6 +368,10 @@ mod tests {
|
|||||||
last_peak = peak(&f);
|
last_peak = peak(&f);
|
||||||
}
|
}
|
||||||
assert!(g.open);
|
assert!(g.open);
|
||||||
assert!(last_peak >= 9900, "peak of the 3rd reopened frame must be >= 9900, got {}", last_peak);
|
assert!(
|
||||||
|
last_peak >= 9900,
|
||||||
|
"peak of the 3rd reopened frame must be >= 9900, got {}",
|
||||||
|
last_peak
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,10 @@ mod tests {
|
|||||||
let out = lim.process(&loud, 1.0);
|
let out = lim.process(&loud, 1.0);
|
||||||
let ceiling = lim.ceiling().ceil() as i16;
|
let ceiling = lim.ceiling().ceil() as i16;
|
||||||
for &s in &out {
|
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}");
|
assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +178,10 @@ mod tests {
|
|||||||
let out_pos = lim.process(&pos_loud, 1.0);
|
let out_pos = lim.process(&pos_loud, 1.0);
|
||||||
for &s in &out_pos {
|
for &s in &out_pos {
|
||||||
assert!(s > 0, "positive input stays positive, got {s}");
|
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
|
// Sustained negative loud sum
|
||||||
@@ -185,7 +191,10 @@ mod tests {
|
|||||||
let neg_ceiling = -ceiling_ceil;
|
let neg_ceiling = -ceiling_ceil;
|
||||||
for &s in &out_neg {
|
for &s in &out_neg {
|
||||||
assert!(s < 0, "negative input stays negative, got {s}");
|
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);
|
let out = lim.process(&input, 8.0);
|
||||||
for &s in &out {
|
for &s in &out {
|
||||||
assert!(s > 0, "positive stays positive");
|
assert!(s > 0, "positive stays positive");
|
||||||
assert!(s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}");
|
assert!(
|
||||||
assert!((s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}");
|
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);
|
let out = lim.process(&input, 0.5);
|
||||||
for (i, &s) in out.iter().enumerate() {
|
for (i, &s) in out.iter().enumerate() {
|
||||||
let expected = (input[i] as f32 * 0.5).round() as i16;
|
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,
|
// 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 loud = vec![200_000i32; 10];
|
||||||
let out = lim.process(&loud, 1.0);
|
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.
|
/// 5. Release direction & monotonicity.
|
||||||
@@ -247,13 +270,23 @@ mod tests {
|
|||||||
|
|
||||||
// Output should be monotonic (non-decreasing)
|
// Output should be monotonic (non-decreasing)
|
||||||
for i in 1..out.len() {
|
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
|
// 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 start_diff = (mid_val as i16 - out[0]).abs();
|
||||||
let end_diff = (mid_val as i16 - *out.last().unwrap()).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.
|
/// 6. Release is gradual, not instantaneous.
|
||||||
@@ -265,7 +298,11 @@ mod tests {
|
|||||||
|
|
||||||
// Immediately follow with a sub-ceiling sample
|
// Immediately follow with a sub-ceiling sample
|
||||||
let out = lim.process(&[10_000i32], 1.0);
|
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.
|
/// 7. State carries across process calls.
|
||||||
@@ -287,7 +324,10 @@ mod tests {
|
|||||||
let mut out_split = out_split1;
|
let mut out_split = out_split1;
|
||||||
out_split.extend(&out_split2);
|
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
|
// Test 2: Pre-loaded limiter vs fresh limiter on the same input
|
||||||
let mut lim_preloaded = SoftLimiter::new(SR);
|
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_preloaded = lim_preloaded.process(&test_input, 1.0);
|
||||||
let out_fresh = lim_fresh.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_ne!(
|
||||||
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]);
|
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.
|
/// 8. Empty input.
|
||||||
@@ -320,7 +368,10 @@ mod tests {
|
|||||||
// Gain 0.0
|
// Gain 0.0
|
||||||
let out_zero = lim.process(&input, 0.0);
|
let out_zero = lim.process(&input, 0.0);
|
||||||
assert_eq!(out_zero.len(), input.len());
|
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
|
// Gain 1.0
|
||||||
let out_unity = lim.process(&input, 1.0);
|
let out_unity = lim.process(&input, 1.0);
|
||||||
@@ -354,6 +405,9 @@ mod tests {
|
|||||||
|
|
||||||
let out = lim.process(&input, 1.0);
|
let out = lim.process(&input, 1.0);
|
||||||
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
|
let expected: Vec<i16> = input.iter().map(|&s| s as i16).collect();
|
||||||
assert_eq!(out, expected, "below ceiling input must be bit-exact at unity gain");
|
assert_eq!(
|
||||||
|
out, expected,
|
||||||
|
"below ceiling input must be bit-exact at unity gain"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::sync::mpsc::{Receiver, Sender};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::AtomicUsize;
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
/// Playback output channel count. Capture/encode/network remain mono; only the
|
/// Playback output channel count. Capture/encode/network remain mono; only the
|
||||||
@@ -35,7 +35,11 @@ pub enum AudioError {
|
|||||||
pub trait AudioBackend: Send + Sync {
|
pub trait AudioBackend: Send + Sync {
|
||||||
/// Starts capturing raw PCM audio from the input device (microphone),
|
/// Starts capturing raw PCM audio from the input device (microphone),
|
||||||
/// sending chunks of samples (e.g. `Vec<i16>`) to the provided Sender.
|
/// 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),
|
/// Starts playing back raw PCM audio to the output device (speaker),
|
||||||
/// reading mixed/incoming chunks of samples from the provided Receiver.
|
/// reading mixed/incoming chunks of samples from the provided Receiver.
|
||||||
@@ -56,6 +60,7 @@ pub trait AudioBackend: Send + Sync {
|
|||||||
fn stop(&self) -> Result<(), AudioError>;
|
fn stop(&self) -> Result<(), AudioError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub mod clip_player;
|
||||||
pub mod eq;
|
pub mod eq;
|
||||||
pub mod gate;
|
pub mod gate;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
@@ -64,16 +69,16 @@ pub mod pan;
|
|||||||
// Linear resamplers used by the Windows/cpal backend (W4). Platform-neutral and
|
// 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
|
// pure, so it builds (and its tests run) everywhere even though only the cpal
|
||||||
// backend wires it in.
|
// backend wires it in.
|
||||||
pub mod resample;
|
#[cfg(windows)]
|
||||||
|
pub mod cpal_impl;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod echo_cancel;
|
pub mod echo_cancel;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pipewire_impl;
|
pub mod pipewire_impl;
|
||||||
#[cfg(windows)]
|
|
||||||
pub mod cpal_impl;
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
pub mod pw_cli;
|
pub mod pw_cli;
|
||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
|
pub mod resample;
|
||||||
|
|
||||||
/// A selectable audio device for the input/output pickers. `name` is the stable
|
/// A selectable audio device for the input/output pickers. `name` is the stable
|
||||||
/// identifier the backend uses to request the device (`target_node`);
|
/// identifier the backend uses to request the device (`target_node`);
|
||||||
@@ -95,10 +100,10 @@ impl std::fmt::Display for AudioDevice {
|
|||||||
// Enumerate audio input/output devices for the pickers (sorted by description),
|
// Enumerate audio input/output devices for the pickers (sorted by description),
|
||||||
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
// returning the same `AudioDevice` shape regardless of platform: PipeWire
|
||||||
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
pub use pw_cli::enumerate_audio_devices;
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub use cpal_impl::enumerate_audio_devices;
|
pub use cpal_impl::enumerate_audio_devices;
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub use pw_cli::enumerate_audio_devices;
|
||||||
|
|
||||||
/// The audio backend implementation for the current platform.
|
/// The audio backend implementation for the current platform.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -12,9 +12,11 @@
|
|||||||
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
|
//! This module is pure plumbing over [`WavWriter`]: no audio decode, no
|
||||||
//! networking, no realtime work. The mixer (a non-RT task) drives it.
|
//! networking, no realtime work. The mixer (a non-RT task) drives it.
|
||||||
|
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{self, SyncSender, TrySendError};
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
|
|
||||||
@@ -24,45 +26,38 @@ use crate::core::jitter::FRAME_SAMPLES;
|
|||||||
/// Cap on the silence chunk written at once when pre-padding a late joiner, so a
|
/// Cap on the silence chunk written at once when pre-padding a late joiner, so a
|
||||||
/// long-running call can't trigger a single multi-hundred-MB allocation.
|
/// long-running call can't trigger a single multi-hundred-MB allocation.
|
||||||
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
|
const SILENCE_CHUNK: usize = FRAME_SAMPLES * 256;
|
||||||
|
const WRITER_QUEUE_CYCLES: usize = 256;
|
||||||
|
const DROP_LOG_INTERVAL_CYCLES: u64 = 256;
|
||||||
|
|
||||||
/// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track
|
/// Cap on buffered mic samples (~200ms @ 48kHz). Bounds how far the mic track
|
||||||
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
/// can drift if the capture clock runs ahead of the mixer cycle; past it the
|
||||||
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
/// oldest mic audio is dropped. Mirrors `recorder::MAX_MIC_FIFO`.
|
||||||
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
const MAX_MIC_FIFO: usize = 48_000 / 5;
|
||||||
|
const MAX_SESSION_DIR_ATTEMPTS: usize = 1_000;
|
||||||
|
|
||||||
/// One output track: its WAV writer plus whether it has been written *this*
|
/// Create a collision-free session directory for a timestamp. The base
|
||||||
/// cycle (so `end_cycle` knows which tracks to pad with silence).
|
/// timestamp is tried first, followed by `-2`, `-3`, and so on; an existing
|
||||||
struct Track {
|
/// recording is never reopened or overwritten.
|
||||||
writer: WavWriter,
|
pub fn create_session_dir(base: &Path, now_unix_secs: u64) -> io::Result<PathBuf> {
|
||||||
written_this_cycle: bool,
|
let filename = crate::audio::recorder::timestamp_filename(now_unix_secs);
|
||||||
|
let stem = filename.trim_end_matches(".wav");
|
||||||
|
for attempt in 1..=MAX_SESSION_DIR_ATTEMPTS {
|
||||||
|
let name = if attempt == 1 {
|
||||||
|
stem.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{stem}-{attempt}")
|
||||||
|
};
|
||||||
|
let path = base.join(name);
|
||||||
|
match std::fs::create_dir(&path) {
|
||||||
|
Ok(()) => return Ok(path),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Track {
|
|
||||||
fn create(path: &Path) -> io::Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
writer: WavWriter::new(path)?,
|
|
||||||
written_this_cycle: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append `frame` fitted to exactly `frame_samples` (zero-padded if short),
|
|
||||||
/// and mark the track as written for this cycle.
|
|
||||||
fn write_frame(&mut self, frame: &[i16], frame_samples: usize) -> io::Result<()> {
|
|
||||||
self.writer.write_samples(&fit(frame, frame_samples))?;
|
|
||||||
self.written_this_cycle = true;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append `samples` of silence (no cycle-marking — used for padding).
|
|
||||||
fn write_silence(&mut self, samples: usize) -> io::Result<()> {
|
|
||||||
let mut remaining = samples;
|
|
||||||
while remaining > 0 {
|
|
||||||
let n = remaining.min(SILENCE_CHUNK);
|
|
||||||
self.writer.write_samples(&vec![0i16; n])?;
|
|
||||||
remaining -= n;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"multitrack directory suffixes exhausted",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
|
/// Return `frame` resized to exactly `n` samples: truncated if longer (shouldn't
|
||||||
@@ -82,7 +77,13 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
|
|||||||
let clean = crate::sanitize::sanitize_name(name);
|
let clean = crate::sanitize::sanitize_name(name);
|
||||||
let mut slug: String = clean
|
let mut slug: String = clean
|
||||||
.chars()
|
.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();
|
.collect();
|
||||||
// Collapse runs of '-' and trim them off the ends.
|
// Collapse runs of '-' and trim them off the ends.
|
||||||
while slug.contains("--") {
|
while slug.contains("--") {
|
||||||
@@ -94,41 +95,210 @@ pub fn track_filename(name: &str, id: &EndpointId) -> String {
|
|||||||
format!("{slug}-{short}.wav")
|
format!("{slug}-{short}.wav")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live multitrack recording: per-peer stems + your mic, plus an optional
|
#[derive(Default)]
|
||||||
/// mixed track, all under one session directory and clocked together.
|
struct PendingCycle {
|
||||||
pub struct MultitrackRecorder {
|
new_peers: Vec<NewPeer>,
|
||||||
dir: PathBuf,
|
peer_frames: HashMap<EndpointId, Vec<i16>>,
|
||||||
frame_samples: usize,
|
mix_frame: Option<Vec<i16>>,
|
||||||
/// Cycles recorded so far = the shared length (in frames) of every track.
|
|
||||||
cycles: u64,
|
|
||||||
peers: HashMap<EndpointId, Track>,
|
|
||||||
/// Your mic track. Fed asynchronously from the capture thread via
|
|
||||||
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
|
|
||||||
/// one frame per `end_cycle` so it aligns with the cycle clock.
|
|
||||||
mic: WavWriter,
|
|
||||||
mic_fifo: VecDeque<i16>,
|
|
||||||
/// Present in "Both" mode (stems + mixed), absent in "stems only".
|
|
||||||
mix: Option<Track>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultitrackRecorder {
|
struct NewPeer {
|
||||||
/// Create a recording in `dir` (which must already exist). `with_mix` adds
|
id: EndpointId,
|
||||||
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
|
filename: String,
|
||||||
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
|
}
|
||||||
|
|
||||||
|
struct CycleBatch {
|
||||||
|
new_peers: Vec<NewPeer>,
|
||||||
|
mic_frame: Vec<i16>,
|
||||||
|
mix_frame: Option<Vec<i16>>,
|
||||||
|
peer_frames: HashMap<EndpointId, Vec<i16>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
trait SampleWriter {
|
||||||
|
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()>;
|
||||||
|
fn finalize(self) -> io::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SampleWriter for WavWriter {
|
||||||
|
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
|
||||||
|
WavWriter::write_samples(self, samples)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(self) -> io::Result<()> {
|
||||||
|
WavWriter::finalize(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WriterState<W> {
|
||||||
|
dir: PathBuf,
|
||||||
|
frame_samples: usize,
|
||||||
|
peers: HashMap<EndpointId, W>,
|
||||||
|
mic: W,
|
||||||
|
mix: Option<W>,
|
||||||
|
cycles_written: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WriterState<WavWriter> {
|
||||||
|
fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
|
||||||
let mic = WavWriter::new(&dir.join("me.wav"))?;
|
let mic = WavWriter::new(&dir.join("me.wav"))?;
|
||||||
let mix = if with_mix {
|
let mix = if with_mix {
|
||||||
Some(Track::create(&dir.join("mix.wav"))?)
|
Some(WavWriter::new(&dir.join("mix.wav"))?)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
dir: dir.to_path_buf(),
|
dir: dir.to_path_buf(),
|
||||||
frame_samples,
|
frame_samples,
|
||||||
cycles: 0,
|
|
||||||
peers: HashMap::new(),
|
peers: HashMap::new(),
|
||||||
mic,
|
mic,
|
||||||
mic_fifo: VecDeque::new(),
|
|
||||||
mix,
|
mix,
|
||||||
|
cycles_written: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<W: SampleWriter> WriterState<W> {
|
||||||
|
fn apply_batch<F>(&mut self, batch: &CycleBatch, mut create_peer: F) -> io::Result<()>
|
||||||
|
where
|
||||||
|
F: FnMut(&Path) -> io::Result<W>,
|
||||||
|
{
|
||||||
|
for peer in &batch.new_peers {
|
||||||
|
if !self.peers.contains_key(&peer.id) {
|
||||||
|
let writer = create_peer(&self.dir.join(&peer.filename))?;
|
||||||
|
self.peers.insert(peer.id, writer);
|
||||||
|
let pad = self.back_pad_samples()?;
|
||||||
|
let writer = self.peers.get_mut(&peer.id).unwrap();
|
||||||
|
Self::write_silence(writer, pad)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.mic.write_samples(&batch.mic_frame)?;
|
||||||
|
if let Some(mix) = self.mix.as_mut() {
|
||||||
|
if let Some(frame) = batch.mix_frame.as_deref() {
|
||||||
|
mix.write_samples(frame)?;
|
||||||
|
} else {
|
||||||
|
Self::write_silence(mix, self.frame_samples)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let silence = vec![0i16; self.frame_samples];
|
||||||
|
for (id, writer) in &mut self.peers {
|
||||||
|
let frame = batch
|
||||||
|
.peer_frames
|
||||||
|
.get(id)
|
||||||
|
.map(Vec::as_slice)
|
||||||
|
.unwrap_or(&silence);
|
||||||
|
writer.write_samples(frame)?;
|
||||||
|
}
|
||||||
|
self.cycles_written += 1;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back_pad_samples(&self) -> io::Result<usize> {
|
||||||
|
let cycles = usize::try_from(self.cycles_written)
|
||||||
|
.map_err(|_| io::Error::other("multitrack recording too long"))?;
|
||||||
|
cycles
|
||||||
|
.checked_mul(self.frame_samples)
|
||||||
|
.ok_or_else(|| io::Error::other("multitrack recording too long"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_silence(writer: &mut W, samples: usize) -> io::Result<()> {
|
||||||
|
let mut remaining = samples;
|
||||||
|
let silence = vec![0i16; remaining.min(SILENCE_CHUNK)];
|
||||||
|
while remaining > 0 {
|
||||||
|
let n = remaining.min(silence.len());
|
||||||
|
writer.write_samples(&silence[..n])?;
|
||||||
|
remaining -= n;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(self) -> io::Result<()> {
|
||||||
|
let mut first_finalize_error = None;
|
||||||
|
record_first_error(&mut first_finalize_error, self.mic.finalize());
|
||||||
|
if let Some(mix) = self.mix {
|
||||||
|
record_first_error(&mut first_finalize_error, mix.finalize());
|
||||||
|
}
|
||||||
|
for writer in self.peers.into_values() {
|
||||||
|
record_first_error(&mut first_finalize_error, writer.finalize());
|
||||||
|
}
|
||||||
|
if let Some(e) = first_finalize_error {
|
||||||
|
Err(e)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_first_error(slot: &mut Option<io::Error>, result: io::Result<()>) {
|
||||||
|
if slot.is_none()
|
||||||
|
&& let Err(e) = result
|
||||||
|
{
|
||||||
|
*slot = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies whole-cycle batches on the writer thread. Each applied batch appends
|
||||||
|
/// exactly `frame_samples` to every existing track, and a dropped batch never
|
||||||
|
/// reaches this loop for any track, so stem lengths stay equal even when the
|
||||||
|
/// bounded queue applies back-pressure.
|
||||||
|
fn writer_thread_main(
|
||||||
|
mut state: WriterState<WavWriter>,
|
||||||
|
batch_rx: mpsc::Receiver<CycleBatch>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
let mut first_write_error = None;
|
||||||
|
|
||||||
|
for batch in batch_rx {
|
||||||
|
if first_write_error.is_none()
|
||||||
|
&& let Err(e) = state.apply_batch(&batch, WavWriter::new)
|
||||||
|
{
|
||||||
|
first_write_error = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalize_result = state.finalize();
|
||||||
|
if let Some(e) = first_write_error {
|
||||||
|
Err(e)
|
||||||
|
} else {
|
||||||
|
finalize_result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A live multitrack recording: per-peer stems + your mic, plus an optional
|
||||||
|
/// mixed track, all under one session directory and clocked together.
|
||||||
|
pub struct MultitrackRecorder {
|
||||||
|
dir: PathBuf,
|
||||||
|
frame_samples: usize,
|
||||||
|
known_peers: HashSet<EndpointId>,
|
||||||
|
/// Your mic track. Fed asynchronously from the capture thread via
|
||||||
|
/// [`push_mic`](MultitrackRecorder::push_mic) into `mic_fifo`, then drained
|
||||||
|
/// one frame per `end_cycle` so it aligns with the cycle clock.
|
||||||
|
mic_fifo: VecDeque<i16>,
|
||||||
|
/// Present in "Both" mode (stems + mixed), absent in "stems only".
|
||||||
|
with_mix: bool,
|
||||||
|
batch_tx: SyncSender<CycleBatch>,
|
||||||
|
writer_thread: JoinHandle<io::Result<()>>,
|
||||||
|
dropped_cycles: u64,
|
||||||
|
pending: PendingCycle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MultitrackRecorder {
|
||||||
|
/// Create a recording in `dir` (which must already exist). `with_mix` adds
|
||||||
|
/// the convenience mixed track (`mix.wav`). Your mic is always `me.wav`.
|
||||||
|
pub fn create(dir: &Path, frame_samples: usize, with_mix: bool) -> io::Result<Self> {
|
||||||
|
let writer_state = WriterState::create(dir, frame_samples, with_mix)?;
|
||||||
|
let (batch_tx, batch_rx) = mpsc::sync_channel(WRITER_QUEUE_CYCLES);
|
||||||
|
let writer_thread = thread::spawn(move || writer_thread_main(writer_state, batch_rx));
|
||||||
|
Ok(Self {
|
||||||
|
dir: dir.to_path_buf(),
|
||||||
|
frame_samples,
|
||||||
|
known_peers: HashSet::new(),
|
||||||
|
mic_fifo: VecDeque::new(),
|
||||||
|
with_mix,
|
||||||
|
batch_tx,
|
||||||
|
writer_thread,
|
||||||
|
dropped_cycles: 0,
|
||||||
|
pending: PendingCycle::default(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +311,14 @@ impl MultitrackRecorder {
|
|||||||
/// so it aligns with the others. Idempotent: a peer already tracked is left
|
/// so it aligns with the others. Idempotent: a peer already tracked is left
|
||||||
/// as-is (re-announce / name change doesn't restart their file).
|
/// as-is (re-announce / name change doesn't restart their file).
|
||||||
pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> {
|
pub fn add_peer(&mut self, id: EndpointId, name: &str) -> io::Result<()> {
|
||||||
if self.peers.contains_key(&id) {
|
if self.known_peers.contains(&id) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let mut track = Track::create(&self.dir.join(track_filename(name, &id)))?;
|
self.known_peers.insert(id);
|
||||||
track.write_silence(self.cycles as usize * self.frame_samples)?;
|
self.pending.new_peers.push(NewPeer {
|
||||||
self.peers.insert(id, track);
|
id,
|
||||||
|
filename: track_filename(name, &id),
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,11 +326,13 @@ impl MultitrackRecorder {
|
|||||||
/// registered yet (write raced ahead of the join event), auto-register it
|
/// registered yet (write raced ahead of the join event), auto-register it
|
||||||
/// with an id-only name so no audio is dropped.
|
/// with an id-only name so no audio is dropped.
|
||||||
pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> {
|
pub fn write_peer(&mut self, id: EndpointId, frame: &[i16]) -> io::Result<()> {
|
||||||
if !self.peers.contains_key(&id) {
|
if !self.known_peers.contains(&id) {
|
||||||
self.add_peer(id, "")?;
|
self.add_peer(id, "")?;
|
||||||
}
|
}
|
||||||
let fs = self.frame_samples;
|
self.pending
|
||||||
self.peers.get_mut(&id).unwrap().write_frame(frame, fs)
|
.peer_frames
|
||||||
|
.insert(id, fit(frame, self.frame_samples));
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Buffer a frame of your transmitted mic audio (called from the capture
|
/// Buffer a frame of your transmitted mic audio (called from the capture
|
||||||
@@ -183,9 +357,8 @@ impl MultitrackRecorder {
|
|||||||
/// Record the finished mixed-bus frame for the current cycle (no-op in
|
/// Record the finished mixed-bus frame for the current cycle (no-op in
|
||||||
/// stems-only mode).
|
/// stems-only mode).
|
||||||
pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
|
pub fn write_mix(&mut self, frame: &[i16]) -> io::Result<()> {
|
||||||
let fs = self.frame_samples;
|
if self.with_mix {
|
||||||
if let Some(mix) = self.mix.as_mut() {
|
self.pending.mix_frame = Some(fit(frame, self.frame_samples));
|
||||||
mix.write_frame(frame, fs)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -198,28 +371,63 @@ impl MultitrackRecorder {
|
|||||||
// Mic: always one frame per cycle, drained from the FIFO (silence on
|
// Mic: always one frame per cycle, drained from the FIFO (silence on
|
||||||
// underrun), so it tracks the cycle clock like the peer stems.
|
// underrun), so it tracks the cycle clock like the peer stems.
|
||||||
let mic_frame = self.drain_mic(fs);
|
let mic_frame = self.drain_mic(fs);
|
||||||
self.mic.write_samples(&mic_frame)?;
|
let mut pending = std::mem::take(&mut self.pending);
|
||||||
// Peers + the optional mix track: pad any not written this cycle.
|
pending.new_peers.sort_by(|a, b| {
|
||||||
for track in self.peers.values_mut().chain(self.mix.as_mut()) {
|
a.filename
|
||||||
if !track.written_this_cycle {
|
.cmp(&b.filename)
|
||||||
track.write_silence(fs)?;
|
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||||
|
});
|
||||||
|
let batch = CycleBatch {
|
||||||
|
new_peers: pending.new_peers,
|
||||||
|
mic_frame,
|
||||||
|
mix_frame: if self.with_mix {
|
||||||
|
pending.mix_frame
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
peer_frames: pending.peer_frames,
|
||||||
|
};
|
||||||
|
match self.batch_tx.try_send(batch) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(TrySendError::Full(batch)) => {
|
||||||
|
for peer in &batch.new_peers {
|
||||||
|
self.known_peers.remove(&peer.id);
|
||||||
}
|
}
|
||||||
track.written_this_cycle = false;
|
self.dropped_cycles = self.dropped_cycles.saturating_add(1);
|
||||||
|
if self.dropped_cycles == 1
|
||||||
|
|| self.dropped_cycles.is_multiple_of(DROP_LOG_INTERVAL_CYCLES)
|
||||||
|
{
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"multitrack recording: writer queue full; dropped {} cycle(s)",
|
||||||
|
self.dropped_cycles
|
||||||
|
));
|
||||||
}
|
}
|
||||||
self.cycles += 1;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
Err(TrySendError::Disconnected(_)) => Err(io::Error::new(
|
||||||
|
io::ErrorKind::BrokenPipe,
|
||||||
|
"multitrack writer thread stopped",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Finalize every track's WAV header. Consumes the recorder.
|
/// Finalize every track's WAV header. Consumes the recorder.
|
||||||
pub fn finalize(self) -> io::Result<()> {
|
pub fn finalize(self) -> io::Result<()> {
|
||||||
self.mic.finalize()?;
|
let Self {
|
||||||
if let Some(mix) = self.mix {
|
dir: _,
|
||||||
mix.writer.finalize()?;
|
frame_samples: _,
|
||||||
}
|
known_peers: _,
|
||||||
for (_, track) in self.peers {
|
mic_fifo: _,
|
||||||
track.writer.finalize()?;
|
with_mix: _,
|
||||||
}
|
batch_tx,
|
||||||
Ok(())
|
writer_thread,
|
||||||
|
dropped_cycles: _,
|
||||||
|
pending: _,
|
||||||
|
} = self;
|
||||||
|
drop(batch_tx);
|
||||||
|
writer_thread
|
||||||
|
.join()
|
||||||
|
.unwrap_or_else(|_| Err(io::Error::other("multitrack writer thread panicked")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +453,51 @@ mod tests {
|
|||||||
d
|
d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TestWriter {
|
||||||
|
samples: Vec<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SampleWriter for TestWriter {
|
||||||
|
fn write_samples(&mut self, samples: &[i16]) -> io::Result<()> {
|
||||||
|
self.samples.extend_from_slice(samples);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finalize(self) -> io::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_writer_state(frame_samples: usize, with_mix: bool) -> WriterState<TestWriter> {
|
||||||
|
WriterState {
|
||||||
|
dir: PathBuf::new(),
|
||||||
|
frame_samples,
|
||||||
|
peers: HashMap::new(),
|
||||||
|
mic: TestWriter::default(),
|
||||||
|
mix: if with_mix {
|
||||||
|
Some(TestWriter::default())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
cycles_written: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_batch(
|
||||||
|
new_peers: Vec<NewPeer>,
|
||||||
|
mic_frame: Vec<i16>,
|
||||||
|
mix_frame: Option<Vec<i16>>,
|
||||||
|
peer_frames: Vec<(EndpointId, Vec<i16>)>,
|
||||||
|
) -> CycleBatch {
|
||||||
|
CycleBatch {
|
||||||
|
new_peers,
|
||||||
|
mic_frame,
|
||||||
|
mix_frame,
|
||||||
|
peer_frames: peer_frames.into_iter().collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fit_pads_and_truncates() {
|
fn fit_pads_and_truncates() {
|
||||||
assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
|
assert_eq!(fit(&[1, 2], 4), vec![1, 2, 0, 0]);
|
||||||
@@ -258,11 +511,131 @@ mod tests {
|
|||||||
let short: String = id.to_string().chars().take(8).collect();
|
let short: String = id.to_string().chars().take(8).collect();
|
||||||
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
|
assert_eq!(track_filename("Alice", &id), format!("alice-{short}.wav"));
|
||||||
// Spaces / punctuation collapse to single dashes, trimmed.
|
// 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".
|
// A name that sanitizes/slugs to nothing falls back to "peer".
|
||||||
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
assert_eq!(track_filename("!!!", &id), format!("peer-{short}.wav"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_second_sessions_get_unique_directories_without_reuse() {
|
||||||
|
let base = tmpdir("collision");
|
||||||
|
let first = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||||
|
std::fs::write(first.join("sentinel"), b"keep me").unwrap();
|
||||||
|
|
||||||
|
let second = create_session_dir(&base, 1_700_000_000).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(second, first);
|
||||||
|
assert_eq!(std::fs::read(first.join("sentinel")).unwrap(), b"keep me");
|
||||||
|
let _ = std::fs::remove_dir_all(&base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn apply_batch_advances_existing_tracks_and_back_pads_late_peer() {
|
||||||
|
let frame = 3;
|
||||||
|
let early = an_id();
|
||||||
|
let late = an_id();
|
||||||
|
let mut state = test_writer_state(frame, true);
|
||||||
|
state.cycles_written = 2;
|
||||||
|
state.mic.samples = vec![8; 2 * frame];
|
||||||
|
state.mix.as_mut().unwrap().samples = vec![6; 2 * frame];
|
||||||
|
state.peers.insert(
|
||||||
|
early,
|
||||||
|
TestWriter {
|
||||||
|
samples: vec![1; 2 * frame],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let batch = test_batch(
|
||||||
|
vec![NewPeer {
|
||||||
|
id: late,
|
||||||
|
filename: "late.wav".to_string(),
|
||||||
|
}],
|
||||||
|
vec![9; frame],
|
||||||
|
None,
|
||||||
|
vec![(early, vec![2; frame]), (late, vec![7; frame])],
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.apply_batch(&batch, |_| Ok(TestWriter::default()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state.cycles_written, 3);
|
||||||
|
assert_eq!(state.mic.samples.len(), 3 * frame);
|
||||||
|
assert_eq!(state.mix.as_ref().unwrap().samples.len(), 3 * frame);
|
||||||
|
assert_eq!(
|
||||||
|
&state.mix.as_ref().unwrap().samples[2 * frame..],
|
||||||
|
&[0, 0, 0]
|
||||||
|
);
|
||||||
|
assert_eq!(state.peers.get(&early).unwrap().samples.len(), 3 * frame);
|
||||||
|
assert_eq!(
|
||||||
|
&state.peers.get(&early).unwrap().samples[2 * frame..],
|
||||||
|
&[2, 2, 2]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.peers.get(&late).unwrap().samples,
|
||||||
|
vec![0, 0, 0, 0, 0, 0, 7, 7, 7],
|
||||||
|
"late peer is back-padded by completed cycles before this batch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skipped_batches_keep_all_tracks_equal_length() {
|
||||||
|
let frame = 2;
|
||||||
|
let p1 = an_id();
|
||||||
|
let p2 = an_id();
|
||||||
|
let mut state = test_writer_state(frame, true);
|
||||||
|
|
||||||
|
let first = test_batch(
|
||||||
|
vec![
|
||||||
|
NewPeer {
|
||||||
|
id: p1,
|
||||||
|
filename: "p1.wav".to_string(),
|
||||||
|
},
|
||||||
|
NewPeer {
|
||||||
|
id: p2,
|
||||||
|
filename: "p2.wav".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vec![1; frame],
|
||||||
|
Some(vec![5; frame]),
|
||||||
|
vec![(p1, vec![10; frame]), (p2, vec![20; frame])],
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.apply_batch(&first, |_| Ok(TestWriter::default()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let _dropped_cycle = test_batch(
|
||||||
|
Vec::new(),
|
||||||
|
vec![2; frame],
|
||||||
|
Some(vec![6; frame]),
|
||||||
|
vec![(p1, vec![11; frame])],
|
||||||
|
);
|
||||||
|
|
||||||
|
let after_drop = test_batch(
|
||||||
|
Vec::new(),
|
||||||
|
vec![3; frame],
|
||||||
|
None,
|
||||||
|
vec![(p1, vec![12; frame])],
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.apply_batch(&after_drop, |_| Ok(TestWriter::default()))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let expected = 2 * frame;
|
||||||
|
assert_eq!(state.cycles_written, 2);
|
||||||
|
assert_eq!(state.mic.samples.len(), expected);
|
||||||
|
assert_eq!(state.mix.as_ref().unwrap().samples.len(), expected);
|
||||||
|
assert_eq!(state.peers.get(&p1).unwrap().samples.len(), expected);
|
||||||
|
assert_eq!(state.peers.get(&p2).unwrap().samples.len(), expected);
|
||||||
|
assert_eq!(
|
||||||
|
&state.peers.get(&p2).unwrap().samples[frame..],
|
||||||
|
&[0, 0],
|
||||||
|
"peer absent from an applied batch gets silence for that cycle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn all_tracks_equal_length_after_n_cycles() {
|
fn all_tracks_equal_length_after_n_cycles() {
|
||||||
let dir = tmpdir("equal");
|
let dir = tmpdir("equal");
|
||||||
@@ -288,10 +661,18 @@ mod tests {
|
|||||||
rec.finalize().unwrap();
|
rec.finalize().unwrap();
|
||||||
|
|
||||||
let expected = 3 * frame;
|
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("mix.wav")), expected);
|
||||||
assert_eq!(wav_samples(&dir.join(track_filename("p1", &p1))), 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]
|
#[test]
|
||||||
@@ -318,8 +699,14 @@ mod tests {
|
|||||||
rec.finalize().unwrap();
|
rec.finalize().unwrap();
|
||||||
|
|
||||||
// Both tracks are the full 5 cycles long (late one was back-padded).
|
// 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!(
|
||||||
assert_eq!(wav_samples(&dir.join(track_filename("late", &late))), 5 * frame);
|
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.
|
// 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();
|
let bytes = std::fs::read(dir.join(track_filename("late", &late))).unwrap();
|
||||||
@@ -339,6 +726,28 @@ mod tests {
|
|||||||
rec.end_cycle().unwrap();
|
rec.end_cycle().unwrap();
|
||||||
rec.finalize().unwrap();
|
rec.finalize().unwrap();
|
||||||
assert!(dir.join("me.wav").exists());
|
assert!(dir.join("me.wav").exists());
|
||||||
assert!(!dir.join("mix.wav").exists(), "no mix track in stems-only mode");
|
assert!(
|
||||||
|
!dir.join("mix.wav").exists(),
|
||||||
|
"no mix track in stems-only mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn async_peer_create_error_surfaces_at_finalize() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
|
let dir = tmpdir("asyncerr");
|
||||||
|
let mut rec = MultitrackRecorder::create(&dir, 4, false).unwrap();
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||||
|
|
||||||
|
rec.add_peer(an_id(), "blocked").unwrap();
|
||||||
|
rec.end_cycle().unwrap();
|
||||||
|
let result = rec.finalize();
|
||||||
|
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
/// still following the same equal-power curve as a peer is moved away from center.
|
||||||
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
|
pub fn playback_pan_gains(pan: f32) -> (f32, f32) {
|
||||||
let (left, right) = pan_gains(pan);
|
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)]
|
#[cfg(test)]
|
||||||
@@ -36,8 +39,14 @@ mod tests {
|
|||||||
fn hard_left_and_right_are_endpoints() {
|
fn hard_left_and_right_are_endpoints() {
|
||||||
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
|
assert_eq!(pan_gains(-1.0), (1.0, 0.0));
|
||||||
let (l, r) = pan_gains(1.0);
|
let (l, r) = pan_gains(1.0);
|
||||||
assert!(l.abs() < EPS, "left at hard-right should be zero-ish, got {l}");
|
assert!(
|
||||||
assert!((r - 1.0).abs() < EPS, "right at hard-right should be one, got {r}");
|
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]
|
#[test]
|
||||||
@@ -55,8 +64,14 @@ mod tests {
|
|||||||
let mut prev_r = f32::NEG_INFINITY;
|
let mut prev_r = f32::NEG_INFINITY;
|
||||||
for pan in pans {
|
for pan in pans {
|
||||||
let (l, r) = pan_gains(pan);
|
let (l, r) = pan_gains(pan);
|
||||||
assert!(l <= prev_l + EPS, "left gain must not rise as pan moves right");
|
assert!(
|
||||||
assert!(r >= prev_r - EPS, "right gain must not fall as pan moves right");
|
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_l = l;
|
||||||
prev_r = r;
|
prev_r = r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
use crate::audio::{AudioBackend, AudioError};
|
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 pipewire as pw;
|
||||||
use pw::{properties::properties, spa};
|
use pw::{properties::properties, spa};
|
||||||
|
use ringbuf::{
|
||||||
|
HeapRb,
|
||||||
|
traits::{Consumer, Producer, Split},
|
||||||
|
};
|
||||||
use spa::pod::Pod;
|
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 {
|
pub struct PipeWireBackend {
|
||||||
capture_state: Mutex<Option<CaptureState>>,
|
capture_state: Mutex<Option<CaptureState>>,
|
||||||
@@ -41,7 +44,11 @@ impl PipeWireBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioBackend for 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();
|
let mut capture_guard = self.capture_state.lock().unwrap();
|
||||||
if capture_guard.is_some() {
|
if capture_guard.is_some() {
|
||||||
return Err(AudioError::Stream("Capture already started".to_string()));
|
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> {
|
fn run_capture(
|
||||||
let mainloop = pw::main_loop::MainLoopRc::new(None)
|
cmd_rx: pw::channel::Receiver<()>,
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
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)
|
let context = pw::context::ContextRc::new(&mainloop, None)
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.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()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
|
|
||||||
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
|
// Ring buffer setup: 9600 samples (200ms capacity for mono 48kHz)
|
||||||
@@ -151,11 +163,9 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
let data = &mut datas[0];
|
let data = &mut datas[0];
|
||||||
let size = data.chunk().size() as usize;
|
let size = data.chunk().size() as usize;
|
||||||
if let Some(slice) = data.data() {
|
if let Some(slice) = data.data() {
|
||||||
// Each sample is 2 bytes (S16LE)
|
for_each_capture_sample(slice, size, |sample| {
|
||||||
for chunk in slice[..size].chunks_exact(2) {
|
|
||||||
let sample = i16::from_le_bytes([chunk[0], chunk[1]]);
|
|
||||||
let _ = user_data.producer.try_push(sample);
|
let _ = user_data.producer.try_push(sample);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,7 +193,8 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
|
|
||||||
let mut params = [Pod::from_bytes(&values).unwrap()];
|
let mut params = [Pod::from_bytes(&values).unwrap()];
|
||||||
|
|
||||||
stream.connect(
|
stream
|
||||||
|
.connect(
|
||||||
spa::utils::Direction::Input,
|
spa::utils::Direction::Input,
|
||||||
None,
|
None,
|
||||||
pw::stream::StreamFlags::AUTOCONNECT
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
@@ -224,6 +235,16 @@ fn run_capture(cmd_rx: pw::channel::Receiver<()>, tx: Sender<Vec<i16>>, target_n
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visit the complete S16LE samples in the portion PipeWire reports as filled.
|
||||||
|
/// Clamp the reported byte count to the mapped slice before indexing: a bad
|
||||||
|
/// chunk size must not panic from the realtime capture callback.
|
||||||
|
fn for_each_capture_sample(slice: &[u8], size: usize, mut visit: impl FnMut(i16)) {
|
||||||
|
let size = size.min(slice.len());
|
||||||
|
for chunk in slice[..size].chunks_exact(2) {
|
||||||
|
visit(i16::from_le_bytes([chunk[0], chunk[1]]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frames the playback RT callback should produce this cycle.
|
/// Frames the playback RT callback should produce this cycle.
|
||||||
///
|
///
|
||||||
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
/// `requested` is the graph's per-cycle quantum from `Buffer::requested()` (0 if
|
||||||
@@ -249,11 +270,7 @@ const WORKER_POLL: Duration = Duration::from_millis(100);
|
|||||||
/// every `WORKER_POLL` even when no frames arrive — this is what lets `stop()`
|
/// 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()`
|
/// 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.
|
/// (bug A7). Pure w.r.t. its inputs (no PipeWire), so it's unit-testable.
|
||||||
fn drain_loop(
|
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
|
||||||
rx: &Receiver<Vec<i16>>,
|
|
||||||
running: &AtomicBool,
|
|
||||||
mut on_frame: impl FnMut(Vec<i16>),
|
|
||||||
) {
|
|
||||||
while running.load(Ordering::Relaxed) {
|
while running.load(Ordering::Relaxed) {
|
||||||
match rx.recv_timeout(WORKER_POLL) {
|
match rx.recv_timeout(WORKER_POLL) {
|
||||||
Ok(frame) => on_frame(frame),
|
Ok(frame) => on_frame(frame),
|
||||||
@@ -263,10 +280,33 @@ fn drain_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserve exact occupancy before making a frame visible to the consumer.
|
||||||
|
/// `after_reserve` is empty in production and lets the regression test force a
|
||||||
|
/// consumer interleaving at the critical ordering boundary.
|
||||||
|
fn publish_frame<P: Producer<Item = i16>>(
|
||||||
|
fill: &AtomicUsize,
|
||||||
|
dropped: &AtomicU64,
|
||||||
|
producer: &mut P,
|
||||||
|
frame: &[i16],
|
||||||
|
after_reserve: impl FnOnce(),
|
||||||
|
) {
|
||||||
|
fill.fetch_add(frame.len(), Ordering::Relaxed);
|
||||||
|
after_reserve();
|
||||||
|
let pushed = producer.push_slice(frame);
|
||||||
|
if pushed != frame.len() {
|
||||||
|
fill.fetch_sub(frame.len() - pushed, Ordering::Relaxed);
|
||||||
|
dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
fn frames_to_produce(requested: usize, mapped_frames: usize) -> usize {
|
||||||
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
/// Safe per-cycle fallback when the graph doesn't report a quantum.
|
||||||
const FALLBACK_FRAMES: usize = 1024;
|
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)
|
want.min(mapped_frames)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,11 +316,12 @@ fn run_playback(
|
|||||||
target_node: Option<String>,
|
target_node: Option<String>,
|
||||||
fill_gauge: Arc<AtomicUsize>,
|
fill_gauge: Arc<AtomicUsize>,
|
||||||
) -> Result<(), AudioError> {
|
) -> Result<(), AudioError> {
|
||||||
let mainloop = pw::main_loop::MainLoopRc::new(None)
|
let mainloop =
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
pw::main_loop::MainLoopRc::new(None).map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
let context = pw::context::ContextRc::new(&mainloop, None)
|
let context = pw::context::ContextRc::new(&mainloop, None)
|
||||||
.map_err(|e| AudioError::Init(e.to_string()))?;
|
.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()))?;
|
.map_err(|e| AudioError::Init(e.to_string()))?;
|
||||||
|
|
||||||
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
// Ring buffer setup: 19200 interleaved samples (200ms capacity for stereo
|
||||||
@@ -401,7 +442,9 @@ fn run_playback(
|
|||||||
}
|
}
|
||||||
if starved > 0 {
|
if starved > 0 {
|
||||||
// One wait-free atomic add per quantum — RT-safe.
|
// 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
|
// Decrement the exact occupancy counter by the samples we
|
||||||
// actually pulled (excluding underruns, which removed
|
// actually pulled (excluding underruns, which removed
|
||||||
@@ -466,7 +509,11 @@ fn run_playback(
|
|||||||
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
pw::spa::pod::Value::Choice(pw::spa::pod::ChoiceValue::Int(
|
||||||
pw::spa::utils::Choice(
|
pw::spa::utils::Choice(
|
||||||
pw::spa::utils::ChoiceFlags::empty(),
|
pw::spa::utils::ChoiceFlags::empty(),
|
||||||
pw::spa::utils::ChoiceEnum::Range { default: 8, min: 2, max: 64 },
|
pw::spa::utils::ChoiceEnum::Range {
|
||||||
|
default: 8,
|
||||||
|
min: 2,
|
||||||
|
max: 64,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
@@ -497,7 +544,8 @@ fn run_playback(
|
|||||||
Pod::from_bytes(&buffers_values).unwrap(),
|
Pod::from_bytes(&buffers_values).unwrap(),
|
||||||
];
|
];
|
||||||
|
|
||||||
stream.connect(
|
stream
|
||||||
|
.connect(
|
||||||
spa::utils::Direction::Output,
|
spa::utils::Direction::Output,
|
||||||
None,
|
None,
|
||||||
pw::stream::StreamFlags::AUTOCONNECT
|
pw::stream::StreamFlags::AUTOCONNECT
|
||||||
@@ -522,10 +570,12 @@ fn run_playback(
|
|||||||
worker_dropped.fetch_add(1, Ordering::Relaxed);
|
worker_dropped.fetch_add(1, Ordering::Relaxed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for &sample in &frame {
|
// Reserve occupancy BEFORE publishing samples. Otherwise the RT
|
||||||
let _ = producer.try_push(sample);
|
// consumer can pop a newly-visible sample before it is counted and
|
||||||
}
|
// wrap the exact fill gauge to usize::MAX, wedging mixer pacing.
|
||||||
worker_fill.fetch_add(frame.len(), Ordering::Relaxed);
|
// `push_slice` also publishes the frame as one operation rather than
|
||||||
|
// exposing a half-written stereo pair.
|
||||||
|
publish_frame(&worker_fill, &worker_dropped, &mut producer, &frame, || {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -577,8 +627,12 @@ fn run_playback(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{drain_loop, frames_to_produce};
|
use super::{drain_loop, for_each_capture_sample, frames_to_produce, publish_frame};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use ringbuf::{
|
||||||
|
HeapRb,
|
||||||
|
traits::{Consumer, Producer, Split},
|
||||||
|
};
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::{sync::mpsc, thread};
|
use std::{sync::mpsc, thread};
|
||||||
@@ -614,6 +668,37 @@ mod tests {
|
|||||||
assert_eq!(frames_to_produce(1024, 0), 0);
|
assert_eq!(frames_to_produce(1024, 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capture_size_larger_than_mapping_is_clamped() {
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
for_each_capture_sample(&[1, 0, 2, 0, 3], usize::MAX, |sample| samples.push(sample));
|
||||||
|
assert_eq!(samples, vec![1, 2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occupancy_is_reserved_before_frame_is_published() {
|
||||||
|
let rb = HeapRb::<i16>::new(8);
|
||||||
|
let (mut producer, mut consumer) = rb.split();
|
||||||
|
assert!(producer.try_push(7).is_ok());
|
||||||
|
|
||||||
|
let fill = AtomicUsize::new(1);
|
||||||
|
let dropped = AtomicU64::new(0);
|
||||||
|
publish_frame(&fill, &dropped, &mut producer, &[10, 11], || {
|
||||||
|
// Force the consumer to drain the old sample after the new frame's
|
||||||
|
// occupancy is reserved but before that frame is published.
|
||||||
|
assert_eq!(consumer.try_pop(), Some(7));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(fill.load(Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(consumer.try_pop(), Some(10));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(consumer.try_pop(), Some(11));
|
||||||
|
assert_eq!(fill.fetch_sub(1, Ordering::Relaxed), 1);
|
||||||
|
assert_eq!(fill.load(Ordering::Relaxed), 0);
|
||||||
|
assert_eq!(dropped.load(Ordering::Relaxed), 0);
|
||||||
|
}
|
||||||
|
|
||||||
// --- drain_loop (A7: worker must not hang shutdown) ---
|
// --- drain_loop (A7: worker must not hang shutdown) ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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/*
|
/// 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
|
/// node, then resets the accumulators for the next block. Non-audio or
|
||||||
/// incomplete blocks are dropped (but still reset).
|
/// 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/") {
|
if !name.is_empty() && class.starts_with("Audio/") {
|
||||||
out.push(AudioDevice {
|
out.push(AudioDevice {
|
||||||
name: name.clone(),
|
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",
|
is_input: class == "Audio/Source",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -44,7 +53,12 @@ fn parse_pw_nodes(text: &str) -> Vec<AudioDevice> {
|
|||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if line.starts_with("id ") {
|
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 = \"") {
|
} else if let Some(val) = line.strip_prefix("node.name = \"") {
|
||||||
current_name = val.trim_end_matches('"').to_string();
|
current_name = val.trim_end_matches('"').to_string();
|
||||||
} else if let Some(val) = line.strip_prefix("node.description = \"") {
|
} 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();
|
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.sort_by(|a, b| a.description.cmp(&b.description));
|
||||||
devices
|
devices
|
||||||
@@ -108,8 +127,14 @@ mod tests {
|
|||||||
fn source_is_input_sink_is_output() {
|
fn source_is_input_sink_is_output() {
|
||||||
let devices = parse_pw_nodes(SAMPLE_NODES);
|
let devices = parse_pw_nodes(SAMPLE_NODES);
|
||||||
// Find devices by name or description to verify is_input
|
// Find devices by name or description to verify is_input
|
||||||
let mic = devices.iter().find(|d| d.name == "alsa_input.builtin").unwrap();
|
let mic = devices
|
||||||
let speakers = devices.iter().find(|d| d.name == "alsa_output.builtin").unwrap();
|
.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();
|
let bare = devices.iter().find(|d| d.name == "bare.sink").unwrap();
|
||||||
|
|
||||||
assert!(mic.is_input);
|
assert!(mic.is_input);
|
||||||
|
|||||||
@@ -2,21 +2,26 @@
|
|||||||
//!
|
//!
|
||||||
//! Records the **full call as you experienced it**: the mixed incoming audio
|
//! 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
|
//! (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`]
|
//! WAV. Mixing/enqueue is driven by the playout mixer (one
|
||||||
//! per produced 20ms frame, paced by the hardware clock); your mic arrives
|
//! [`Recorder::write_frame`] per produced 20ms frame, paced by the hardware
|
||||||
//! separately from the capture thread via [`Recorder::push_mic`] and is buffered
|
//! clock), while disk writes happen on a dedicated writer thread; your mic
|
||||||
//! in a small FIFO so the two independently-clocked streams stay roughly aligned.
|
//! 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
|
//! 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
|
//! between your voice and the recording is bounded) — harmless for a voice
|
||||||
//! recording, no realtime crackle concern.
|
//! recording, no realtime crackle concern.
|
||||||
//!
|
//!
|
||||||
//! No external crates: the WAV writer emits the 44-byte canonical header itself
|
//! 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::collections::VecDeque;
|
||||||
use std::fs::File;
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::{self, Seek, SeekFrom, Write};
|
use std::io::{self, Seek, SeekFrom, Write};
|
||||||
use std::path::{Path, PathBuf};
|
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).
|
/// Capture sample rate (mono, 48kHz, matching the rest of the audio path).
|
||||||
const SAMPLE_RATE: u32 = 48_000;
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
@@ -24,6 +29,9 @@ const BITS_PER_SAMPLE: u16 = 16;
|
|||||||
const CHANNELS: u16 = 1;
|
const CHANNELS: u16 = 1;
|
||||||
const RIFF_DATA_OVERHEAD: u64 = 36;
|
const RIFF_DATA_OVERHEAD: u64 = 36;
|
||||||
const MAX_RIFF_DATA_BYTES: u64 = u32::MAX as u64 - RIFF_DATA_OVERHEAD;
|
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
|
/// 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
|
/// if the capture clock runs persistently faster than playout — past this we drop
|
||||||
@@ -42,7 +50,12 @@ pub struct WavWriter {
|
|||||||
impl WavWriter {
|
impl WavWriter {
|
||||||
/// Create the file and write the 44-byte header with zeroed size fields.
|
/// Create the file and write the 44-byte header with zeroed size fields.
|
||||||
pub fn new(path: &Path) -> io::Result<Self> {
|
pub fn new(path: &Path) -> io::Result<Self> {
|
||||||
let mut file = File::create(path)?;
|
Self::from_file(File::create(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a WAV in an already-opened file. This lets callers choose atomic
|
||||||
|
/// create-new semantics instead of the truncating behavior of `File::create`.
|
||||||
|
fn from_file(mut file: File) -> io::Result<Self> {
|
||||||
file.write_all(&Self::header(0))?;
|
file.write_all(&Self::header(0))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
file,
|
file,
|
||||||
@@ -112,26 +125,51 @@ impl WavWriter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live call recorder: a [`WavWriter`] plus a small mic FIFO that aligns your
|
/// A live call recorder: a writer-thread queue plus a small mic FIFO that aligns
|
||||||
/// transmitted mic with the playout mixer's incoming-mix frames.
|
/// your transmitted mic with the playout mixer's incoming-mix frames.
|
||||||
pub struct Recorder {
|
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.
|
/// Your transmitted mic samples, awaiting alignment with the next mix frame.
|
||||||
mic_fifo: VecDeque<i16>,
|
mic_fifo: VecDeque<i16>,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
dropped_frames: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Recorder {
|
impl Recorder {
|
||||||
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
/// Create a recording at `dir/<timestamped>.wav`. The directory is assumed to
|
||||||
/// exist (the caller creates it).
|
/// exist (the caller creates it).
|
||||||
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
pub fn create(dir: &Path, now_unix_secs: u64) -> io::Result<Self> {
|
||||||
let path = dir.join(timestamp_filename(now_unix_secs));
|
let filename = timestamp_filename(now_unix_secs);
|
||||||
let writer = WavWriter::new(&path)?;
|
let stem = filename.trim_end_matches(".wav");
|
||||||
Ok(Self {
|
for attempt in 1..=MAX_NAME_ATTEMPTS {
|
||||||
writer,
|
let name = if attempt == 1 {
|
||||||
|
filename.clone()
|
||||||
|
} else {
|
||||||
|
format!("{stem}-{attempt}.wav")
|
||||||
|
};
|
||||||
|
let path = dir.join(name);
|
||||||
|
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||||
|
Ok(file) => {
|
||||||
|
let writer = WavWriter::from_file(file)?;
|
||||||
|
let (frame_tx, frame_rx) = mpsc::sync_channel(WRITER_QUEUE_FRAMES);
|
||||||
|
let writer_thread = thread::spawn(move || writer_thread_main(writer, frame_rx));
|
||||||
|
return Ok(Self {
|
||||||
|
frame_tx,
|
||||||
|
writer_thread,
|
||||||
mic_fifo: VecDeque::new(),
|
mic_fifo: VecDeque::new(),
|
||||||
path,
|
path,
|
||||||
})
|
dropped_frames: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(io::Error::new(
|
||||||
|
io::ErrorKind::AlreadyExists,
|
||||||
|
"recording filename suffixes exhausted",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The path being written.
|
/// The path being written.
|
||||||
@@ -155,21 +193,73 @@ impl Recorder {
|
|||||||
/// treated as silence (you weren't transmitting), so quiet stretches record
|
/// treated as silence (you weren't transmitting), so quiet stretches record
|
||||||
/// the incoming mix alone.
|
/// the incoming mix alone.
|
||||||
pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
|
pub fn write_frame(&mut self, mixed: &[i16]) -> io::Result<()> {
|
||||||
let mut out = Vec::with_capacity(mixed.len());
|
let out = mix_with_mic(mixed, &mut self.mic_fifo);
|
||||||
for &m in mixed {
|
match self.frame_tx.try_send(out) {
|
||||||
let mic = self.mic_fifo.pop_front().unwrap_or(0);
|
Ok(()) => Ok(()),
|
||||||
let sum = (m as i32 + mic as i32).clamp(i16::MIN as i32, i16::MAX as i32);
|
Err(TrySendError::Full(_)) => {
|
||||||
out.push(sum as i16);
|
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.
|
/// Finish the file, patching its size fields. Consumes the recorder.
|
||||||
pub fn finalize(self) -> io::Result<()> {
|
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.
|
/// 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.
|
/// Howard Hinnant's `civil_from_days`; valid across the whole practical range.
|
||||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||||
@@ -198,6 +288,23 @@ pub fn timestamp_filename(unix_secs: u64) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn timestamp_filename_is_utc_and_padded() {
|
fn timestamp_filename_is_utc_and_padded() {
|
||||||
@@ -210,6 +317,61 @@ mod tests {
|
|||||||
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
|
assert_eq!(timestamp_filename(0), "peerspeak-1970-01-01_000000.wav");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_second_recordings_get_unique_files_without_truncation() {
|
||||||
|
let dir = unique_temp_dir("peerspeak-collision");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
let mut first = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||||
|
first.write_frame(&[123, 456]).unwrap();
|
||||||
|
let first_path = first.path().to_path_buf();
|
||||||
|
first.finalize().unwrap();
|
||||||
|
let original = std::fs::read(&first_path).unwrap();
|
||||||
|
|
||||||
|
let second = Recorder::create(&dir, 1_700_000_000).unwrap();
|
||||||
|
let second_path = second.path().to_path_buf();
|
||||||
|
assert_ne!(second_path, first_path);
|
||||||
|
assert_eq!(std::fs::read(&first_path).unwrap(), original);
|
||||||
|
second.finalize().unwrap();
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recorder_thread_writes_mixed_samples_and_header_on_finalize() {
|
||||||
|
let dir = unique_temp_dir("peerspeak-recorder-thread");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
|
||||||
|
let mut recorder = Recorder::create(&dir, 1_700_000_123).unwrap();
|
||||||
|
let path = recorder.path().to_path_buf();
|
||||||
|
|
||||||
|
recorder.push_mic(&[1000, i16::MAX, -1000, i16::MIN, 2222]);
|
||||||
|
recorder.write_frame(&[10, 20, -32700]).unwrap();
|
||||||
|
recorder.push_mic(&[300, -300]);
|
||||||
|
recorder
|
||||||
|
.write_frame(&[0, 1000, i16::MAX, i16::MIN])
|
||||||
|
.unwrap();
|
||||||
|
recorder.finalize().unwrap();
|
||||||
|
|
||||||
|
let expected = vec![1010, i16::MAX, i16::MIN, i16::MIN, 3222, i16::MAX, i16::MIN];
|
||||||
|
let expected_data_bytes = u32::try_from(expected.len() * 2).unwrap();
|
||||||
|
let (bytes, samples) = read_wav_samples(&path);
|
||||||
|
|
||||||
|
assert_eq!(&bytes[0..4], b"RIFF");
|
||||||
|
assert_eq!(&bytes[8..12], b"WAVE");
|
||||||
|
assert_eq!(&bytes[36..40], b"data");
|
||||||
|
let riff = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
|
||||||
|
let data = u32::from_le_bytes([bytes[40], bytes[41], bytes[42], bytes[43]]);
|
||||||
|
assert_eq!(data, expected_data_bytes);
|
||||||
|
assert_eq!(riff, RIFF_DATA_OVERHEAD as u32 + expected_data_bytes);
|
||||||
|
assert_eq!(bytes.len(), 44 + expected.len() * 2);
|
||||||
|
assert_eq!(samples, expected);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wav_header_round_trips_sizes() {
|
fn wav_header_round_trips_sizes() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = std::env::temp_dir();
|
||||||
@@ -252,38 +414,32 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_is_summed_with_mix_when_present() {
|
fn mic_is_summed_with_mix_when_present() {
|
||||||
let dir = std::env::temp_dir();
|
let mut mic_fifo = VecDeque::from([1000, 2000, 3000]);
|
||||||
let mut r = Recorder {
|
|
||||||
writer: WavWriter::new(&dir.join(format!("ps-sum-{}.wav", std::process::id())))
|
let first = mix_with_mic(&[10, 20], &mut mic_fifo);
|
||||||
.unwrap(),
|
assert_eq!(first, vec![1010, 2020]);
|
||||||
mic_fifo: VecDeque::new(),
|
assert_eq!(mic_fifo.len(), 1, "two samples consumed, one mic left");
|
||||||
path: PathBuf::new(),
|
|
||||||
};
|
let second = mix_with_mic(&[0, 0], &mut mic_fifo);
|
||||||
r.push_mic(&[1000, 2000, 3000]);
|
assert_eq!(second, vec![3000, 0]);
|
||||||
// write_frame pops mic per-sample and sums; we can't read the file mid-stream,
|
|
||||||
// so assert the FIFO drains exactly by frame length.
|
|
||||||
r.write_frame(&[10, 20]).unwrap();
|
|
||||||
assert_eq!(r.mic_fifo.len(), 1, "two samples consumed, one mic left");
|
|
||||||
r.write_frame(&[0, 0]).unwrap();
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
r.mic_fifo.len(),
|
mic_fifo.len(),
|
||||||
0,
|
0,
|
||||||
"remaining mic sample consumed; rest is silence"
|
"remaining mic sample consumed; rest is silence"
|
||||||
);
|
);
|
||||||
let _ = r.finalize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mic_fifo_is_capped() {
|
fn mic_fifo_is_capped() {
|
||||||
let dir = std::env::temp_dir();
|
let dir = unique_temp_dir("peerspeak-cap");
|
||||||
let mut r = Recorder {
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
writer: WavWriter::new(&dir.join(format!("ps-cap-{}.wav", std::process::id())))
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
.unwrap(),
|
|
||||||
mic_fifo: VecDeque::new(),
|
let mut r = Recorder::create(&dir, 1_700_000_001).unwrap();
|
||||||
path: PathBuf::new(),
|
|
||||||
};
|
|
||||||
r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
|
r.push_mic(&vec![5i16; MAX_MIC_FIFO * 2]);
|
||||||
assert_eq!(r.mic_fifo.len(), MAX_MIC_FIFO, "FIFO is bounded to the cap");
|
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;
|
self.frac -= 1.0;
|
||||||
}
|
}
|
||||||
let f = self.frac as f32;
|
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;
|
self.frac += self.step;
|
||||||
Some(out)
|
Some(out)
|
||||||
}
|
}
|
||||||
@@ -273,7 +276,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// At step 2.0 we consume ~2 input frames per output frame.
|
// 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
|
/// A zero rate must not produce a zero `step` (which would spin `push`'s inner
|
||||||
|
|||||||
@@ -185,10 +185,132 @@ pub fn initials(name: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A small content-addressed LRU cache mapping image bytes to a built value
|
||||||
|
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
|
||||||
|
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
|
||||||
|
/// hash map:
|
||||||
|
///
|
||||||
|
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
|
||||||
|
/// overflow, so a peer can't grow the cache without limit by publishing an
|
||||||
|
/// endless stream of distinct valid avatars.
|
||||||
|
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
|
||||||
|
/// 64-bit hash, so a hash collision can never return a different image's value.
|
||||||
|
///
|
||||||
|
/// Linear scan; intended for small `cap` (tens of entries).
|
||||||
|
pub struct ByteLru<V> {
|
||||||
|
cap: usize,
|
||||||
|
/// `(content hash, content bytes, value)`; back = most recently used.
|
||||||
|
entries: Vec<(u64, Vec<u8>, V)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<V: Clone> ByteLru<V> {
|
||||||
|
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
|
||||||
|
pub fn new(cap: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
cap: cap.max(1),
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the cached value for these exact `bytes`, building and inserting it
|
||||||
|
/// on a miss (evicting the least-recently-used entry once over `cap`). A hit
|
||||||
|
/// verifies full byte equality, so a 64-bit hash collision never returns the
|
||||||
|
/// wrong value. A hit also refreshes the entry's recency.
|
||||||
|
pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
bytes.hash(&mut hasher);
|
||||||
|
self.get_or_insert_hashed(hasher.finish(), bytes, build)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner seam with the content `hash` supplied explicitly. Production callers
|
||||||
|
/// use [`get_or_insert`]; tests use this to force a hash collision (different
|
||||||
|
/// bytes, same hash) and exercise the byte-equality guard.
|
||||||
|
fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
|
||||||
|
if let Some(idx) = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
|
||||||
|
{
|
||||||
|
// LRU touch: move the hit entry to the back (most recent).
|
||||||
|
let entry = self.entries.remove(idx);
|
||||||
|
let val = entry.2.clone();
|
||||||
|
self.entries.push(entry);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
let val = build();
|
||||||
|
if self.entries.len() >= self.cap {
|
||||||
|
self.entries.remove(0); // evict least-recently-used
|
||||||
|
}
|
||||||
|
self.entries.push((hash, bytes.to_vec(), val.clone()));
|
||||||
|
val
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_lru_reuses_value_for_identical_bytes() {
|
||||||
|
let mut lru: ByteLru<u32> = ByteLru::new(4);
|
||||||
|
let mut next = 0u32;
|
||||||
|
let mut build = |lru: &mut ByteLru<u32>, b: &[u8]| {
|
||||||
|
lru.get_or_insert(b, || {
|
||||||
|
next += 1;
|
||||||
|
next
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// Same bytes → same value, built only once.
|
||||||
|
assert_eq!(build(&mut lru, b"alice"), 1);
|
||||||
|
assert_eq!(build(&mut lru, b"alice"), 1);
|
||||||
|
// Different bytes → a freshly built value.
|
||||||
|
assert_eq!(build(&mut lru, b"bob"), 2);
|
||||||
|
assert_eq!(lru.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_lru_evicts_least_recently_used() {
|
||||||
|
let mut lru: ByteLru<u32> = ByteLru::new(2);
|
||||||
|
let mut n = 0u32;
|
||||||
|
let mut ins = |lru: &mut ByteLru<u32>, b: &[u8]| {
|
||||||
|
lru.get_or_insert(b, || {
|
||||||
|
n += 1;
|
||||||
|
n
|
||||||
|
})
|
||||||
|
};
|
||||||
|
ins(&mut lru, b"a"); // -> 1
|
||||||
|
ins(&mut lru, b"b"); // -> 2, cache = [a, b]
|
||||||
|
ins(&mut lru, b"a"); // touch a, cache = [b, a]
|
||||||
|
ins(&mut lru, b"c"); // evicts LRU (b), cache = [a, c]
|
||||||
|
assert_eq!(lru.len(), 2);
|
||||||
|
// `a` survived (recently touched) → still value 1, not rebuilt.
|
||||||
|
assert_eq!(ins(&mut lru, b"a"), 1);
|
||||||
|
// `b` was evicted → rebuilt with a new value.
|
||||||
|
assert_eq!(ins(&mut lru, b"b"), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn byte_lru_byte_equality_survives_a_hash_collision() {
|
||||||
|
// Force the SAME 64-bit hash for two DIFFERENT byte strings (the case a
|
||||||
|
// bare-hash cache would alias — Tier C F-03 collision bug).
|
||||||
|
let mut lru: ByteLru<u32> = ByteLru::new(4);
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 1), 1);
|
||||||
|
// `bob` collides on the hash but differs in bytes → a MISS, built fresh,
|
||||||
|
// NOT aliased to alice's value.
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 2), 2);
|
||||||
|
// Both coexist; each re-lookup returns its own value (build closure unused).
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"alice", || 99), 1);
|
||||||
|
assert_eq!(lru.get_or_insert_hashed(42, b"bob", || 99), 2);
|
||||||
|
assert_eq!(lru.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn initials_takes_first_two_words() {
|
fn initials_takes_first_two_words() {
|
||||||
assert_eq!(initials("Alice"), "A");
|
assert_eq!(initials("Alice"), "A");
|
||||||
@@ -230,7 +352,10 @@ mod tests {
|
|||||||
fn preset_png_in_range_and_out_of_range() {
|
fn preset_png_in_range_and_out_of_range() {
|
||||||
// Every declared preset index resolves to embedded bytes.
|
// Every declared preset index resolves to embedded bytes.
|
||||||
for i in 0..PRESET_COUNT {
|
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).
|
// Out-of-range index gracefully yields None (→ monogram fallback).
|
||||||
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
|
assert!(Avatar::Preset(PRESET_COUNT).preset_png().is_none());
|
||||||
@@ -287,7 +412,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn sanitize_incoming_rejects_junk_and_oversize() {
|
fn sanitize_incoming_rejects_junk_and_oversize() {
|
||||||
// Not valid base64 / not a PNG → downgraded to monogram.
|
// 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.
|
// Over the byte cap → downgraded without even decoding.
|
||||||
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
|
let huge = Avatar::Custom("A".repeat(CUSTOM_MAX_B64 + 1));
|
||||||
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
|
assert_eq!(huge.sanitize_incoming(), Avatar::Monogram);
|
||||||
|
|||||||
@@ -45,12 +45,31 @@ pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
|
|||||||
Ok(png.into_inner())
|
Ok(png.into_inner())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A filesystem-safe, app-owned filename for the processed PNG of a per-game
|
||||||
|
/// background (W18), derived from the game's stable id by hashing rather than
|
||||||
|
/// embedding the raw id: keeps the name short and safe (ids contain `:` and
|
||||||
|
/// arbitrary executable basenames) and avoids leaking the id into the filesystem.
|
||||||
|
/// Deterministic and dependency-free (FNV-1a 64-bit), so the same game id always
|
||||||
|
/// maps to the same file.
|
||||||
|
pub fn game_background_filename(game_id: &str) -> String {
|
||||||
|
// FNV-1a, 64-bit.
|
||||||
|
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
|
for b in game_id.as_bytes() {
|
||||||
|
hash ^= *b as u64;
|
||||||
|
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
|
||||||
|
}
|
||||||
|
format!("game-bg-{hash:016x}.png")
|
||||||
|
}
|
||||||
|
|
||||||
/// The legibility scrim drawn between the background image and the UI: the active
|
/// The legibility scrim drawn between the background image and the UI: the active
|
||||||
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
|
||||||
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
/// recedes the image so body text and panel chrome stay readable, and it re-tints
|
||||||
/// per theme since `base` comes from the active palette.
|
/// per theme since `base` comes from the active palette.
|
||||||
pub fn scrim_color(base: Color, dim: f32) -> Color {
|
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)]
|
#[cfg(test)]
|
||||||
@@ -90,6 +109,17 @@ mod tests {
|
|||||||
assert!(process_background(b"definitely not an image").is_err());
|
assert!(process_background(b"definitely not an image").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_background_filename_is_stable_safe_and_distinct() {
|
||||||
|
let a = game_background_filename("steam:730");
|
||||||
|
// Stable for the same id.
|
||||||
|
assert_eq!(a, game_background_filename("steam:730"));
|
||||||
|
// Distinct ids → distinct files (no `:` or path chars leak through).
|
||||||
|
assert_ne!(a, game_background_filename("exe:hl2_linux"));
|
||||||
|
assert!(a.starts_with("game-bg-") && a.ends_with(".png"));
|
||||||
|
assert!(!a.contains(':') && !a.contains('/') && !a.contains('\\'));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
fn scrim_color_sets_alpha_and_keeps_rgb() {
|
||||||
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
let base = Color::from_rgb(0.1, 0.2, 0.3);
|
||||||
|
|||||||
@@ -105,7 +105,11 @@ fn cmd_gen(args: &[String]) -> Result<(), String> {
|
|||||||
"pink" => generators::pink_noise(amp, len, seed),
|
"pink" => generators::pink_noise(amp, len, seed),
|
||||||
"impulse" => generators::impulse(amp, len),
|
"impulse" => generators::impulse(amp, len),
|
||||||
"silence" => generators::silence(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)?;
|
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.
|
/// in which frequency range any residual lives.
|
||||||
fn cmd_erle(args: &[String]) -> Result<(), String> {
|
fn cmd_erle(args: &[String]) -> Result<(), String> {
|
||||||
let (positional, flags) = parse_args(args);
|
let (positional, flags) = parse_args(args);
|
||||||
let before = positional.first().ok_or("erle needs <before.wav> <after.wav>")?;
|
let before = positional
|
||||||
let after = positional.get(1).ok_or("erle needs <before.wav> <after.wav>")?;
|
.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 b = wav::read(Path::new(before))?;
|
||||||
let a = wav::read(Path::new(after))?;
|
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,
|
1000.0 * tail as f32 / sr as f32,
|
||||||
metrics::dbfs(atten),
|
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 {
|
if has_near {
|
||||||
let dtd = if flags.present("no-dtd") { "off" } else { "on" };
|
let dtd = if flags.present("no-dtd") { "off" } else { "on" };
|
||||||
println!(
|
println!(
|
||||||
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
|
" double-talk: detector {dtd}, threshold {dtd_threshold}, flagged {:.0}% of samples{}",
|
||||||
100.0 * canceller.double_talk_rate(),
|
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!(
|
||||||
println!(" residual echo after: {:.1} dBFS rms", metrics::dbfs(metrics::rms(&residual)));
|
" 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 broadband: {broadband:+.1} dB");
|
||||||
println!(" ERLE early/late: {early:+.1} -> {late:+.1} dB (rise = filter converging)");
|
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") {
|
if flags.present("show") {
|
||||||
println!("\n--- mic (echo present) ---");
|
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) ---");
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -339,13 +376,22 @@ impl Flags {
|
|||||||
self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
|
self.bools.iter().any(|b| b == key) || self.map.contains_key(key)
|
||||||
}
|
}
|
||||||
fn f32_or(&self, key: &str, default: f32) -> f32 {
|
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 {
|
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 {
|
fn u64_or(&self, key: &str, default: u64) -> u64 {
|
||||||
self.map.get(key).and_then(|v| v.parse().ok()).unwrap_or(default)
|
self.map
|
||||||
|
.get(key)
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(default)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
use peerspeak::network::{
|
|
||||||
gossip::IrohGossipState,
|
|
||||||
RoomState, PeerState,
|
|
||||||
};
|
|
||||||
use iroh::{Endpoint, endpoint::presets};
|
use iroh::{Endpoint, endpoint::presets};
|
||||||
use iroh_gossip::net::Gossip;
|
use iroh_gossip::net::Gossip;
|
||||||
|
use peerspeak::network::{PeerState, RoomState, gossip::IrohGossipState};
|
||||||
use tokio::time::{self, Duration};
|
use tokio::time::{self, Duration};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -27,7 +24,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_a.clone())
|
||||||
.spawn();
|
.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
|
// 2. Node B (Client) Setup
|
||||||
let lookup_b = iroh::address_lookup::memory::MemoryLookup::new();
|
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())
|
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip_b.clone())
|
||||||
.spawn();
|
.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
|
// 3. Create room on Node A
|
||||||
let topic_id = rand::random();
|
let topic_id = rand::random();
|
||||||
@@ -64,6 +71,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_a.addr(),
|
addr: endpoint_a.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
|
music: None,
|
||||||
};
|
};
|
||||||
room_a.join(&ticket_str, state_a, vec![]).await?;
|
room_a.join(&ticket_str, state_a, vec![]).await?;
|
||||||
println!("Node A joined topic.");
|
println!("Node A joined topic.");
|
||||||
@@ -83,6 +92,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
addr: endpoint_b.addr(),
|
addr: endpoint_b.addr(),
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: Default::default(),
|
avatar: Default::default(),
|
||||||
|
game: None,
|
||||||
|
music: None,
|
||||||
};
|
};
|
||||||
room_b.join(&ticket_str, state_b, vec![]).await?;
|
room_b.join(&ticket_str, state_b, vec![]).await?;
|
||||||
println!("Node B joined topic.");
|
println!("Node B joined topic.");
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub trait AudioDecoder: Send {
|
|||||||
/// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss,
|
/// If `compressed` is `None` (or `Some(&[])`), it indicates packet loss,
|
||||||
/// enabling the decoder to perform packet loss concealment (PLC).
|
/// enabling the decoder to perform packet loss concealment (PLC).
|
||||||
fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError>;
|
fn decode(&mut self, compressed: Option<&[u8]>) -> Result<Vec<i16>, CodecError>;
|
||||||
|
|
||||||
|
/// Reconstructs the previous lost frame from the next packet's in-band FEC.
|
||||||
|
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod opus_impl;
|
pub mod opus_impl;
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
use crate::codec::{AudioEncoder, AudioDecoder, CodecError};
|
use crate::codec::{AudioDecoder, AudioEncoder, CodecError};
|
||||||
use opus::{Encoder, Decoder, Application, Channels};
|
use crate::config::AudioProfile;
|
||||||
|
use opus::{Application, Bitrate, Channels, Decoder, Encoder};
|
||||||
|
|
||||||
|
/// Concrete libopus encoder settings derived from an [`AudioProfile`]. Plain
|
||||||
|
/// data, so the profile→params mapping ([`opus_params`]) stays a pure,
|
||||||
|
/// unit-testable function (W12).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct OpusParams {
|
||||||
|
/// Target bitrate in bits/sec.
|
||||||
|
pub bitrate: i32,
|
||||||
|
/// Enable in-band forward error correction (loss redundancy in the bitstream).
|
||||||
|
pub inband_fec: bool,
|
||||||
|
/// Expected packet-loss percentage (0..=100); tunes how much FEC libopus adds.
|
||||||
|
pub packet_loss_perc: i32,
|
||||||
|
/// Discontinuous transmission: stop sending during silence to save bandwidth.
|
||||||
|
pub dtx: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a named profile to concrete Opus parameters. Pure — the W12 testable seam.
|
||||||
|
///
|
||||||
|
/// `BadNetwork` deliberately runs a *lower* bitrate than `Balanced`: in-band FEC
|
||||||
|
/// redundancy is carried inside the same bitstream, so trimming the base bitrate
|
||||||
|
/// leaves headroom for the redundancy on a congested link.
|
||||||
|
pub fn opus_params(profile: AudioProfile) -> OpusParams {
|
||||||
|
match profile {
|
||||||
|
AudioProfile::LowLatency => OpusParams {
|
||||||
|
bitrate: 24_000,
|
||||||
|
inband_fec: false,
|
||||||
|
packet_loss_perc: 0,
|
||||||
|
dtx: false,
|
||||||
|
},
|
||||||
|
AudioProfile::Balanced => OpusParams {
|
||||||
|
bitrate: 32_000,
|
||||||
|
inband_fec: true,
|
||||||
|
packet_loss_perc: 10,
|
||||||
|
dtx: false,
|
||||||
|
},
|
||||||
|
AudioProfile::BadNetwork => OpusParams {
|
||||||
|
bitrate: 20_000,
|
||||||
|
inband_fec: true,
|
||||||
|
packet_loss_perc: 25,
|
||||||
|
dtx: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct OpusEncoder {
|
pub struct OpusEncoder {
|
||||||
encoder: Encoder,
|
encoder: Encoder,
|
||||||
@@ -8,11 +52,38 @@ pub struct OpusEncoder {
|
|||||||
impl OpusEncoder {
|
impl OpusEncoder {
|
||||||
/// Creates a new Opus encoder.
|
/// Creates a new Opus encoder.
|
||||||
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono, application = Application::Voip
|
/// 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)
|
let encoder = Encoder::new(sample_rate, channels, application)
|
||||||
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
|
.map_err(|e| CodecError::Init(format!("Failed to create Opus encoder: {}", e)))?;
|
||||||
Ok(Self { encoder })
|
Ok(Self { encoder })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply concrete codec parameters to the live encoder. Safe to call between
|
||||||
|
/// frames, so the user can switch profile mid-call.
|
||||||
|
pub fn apply_params(&mut self, params: &OpusParams) -> Result<(), CodecError> {
|
||||||
|
self.encoder
|
||||||
|
.set_bitrate(Bitrate::Bits(params.bitrate))
|
||||||
|
.map_err(|e| CodecError::Init(format!("set_bitrate: {}", e)))?;
|
||||||
|
self.encoder
|
||||||
|
.set_inband_fec(params.inband_fec)
|
||||||
|
.map_err(|e| CodecError::Init(format!("set_inband_fec: {}", e)))?;
|
||||||
|
self.encoder
|
||||||
|
.set_packet_loss_perc(params.packet_loss_perc)
|
||||||
|
.map_err(|e| CodecError::Init(format!("set_packet_loss_perc: {}", e)))?;
|
||||||
|
self.encoder
|
||||||
|
.set_dtx(params.dtx)
|
||||||
|
.map_err(|e| CodecError::Init(format!("set_dtx: {}", e)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a named [`AudioProfile`] (shorthand for `apply_params(&opus_params(p))`).
|
||||||
|
pub fn apply_profile(&mut self, profile: AudioProfile) -> Result<(), CodecError> {
|
||||||
|
self.apply_params(&opus_params(profile))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioEncoder for OpusEncoder {
|
impl AudioEncoder for OpusEncoder {
|
||||||
@@ -20,7 +91,9 @@ impl AudioEncoder for OpusEncoder {
|
|||||||
// We allocate a buffer for the compressed output.
|
// We allocate a buffer for the compressed output.
|
||||||
// A maximum packet size of 4000 bytes is more than enough for a single voice frame.
|
// A maximum packet size of 4000 bytes is more than enough for a single voice frame.
|
||||||
let mut compressed = vec![0u8; 4000];
|
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)))?;
|
.map_err(|e| CodecError::Encode(format!("Opus encoding failed: {}", e)))?;
|
||||||
|
|
||||||
compressed.truncate(len);
|
compressed.truncate(len);
|
||||||
@@ -42,10 +115,18 @@ impl OpusDecoder {
|
|||||||
/// Creates a new Opus decoder.
|
/// Creates a new Opus decoder.
|
||||||
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
|
/// Standard voice parameters: sample_rate = 48000, channels = Channels::Mono.
|
||||||
/// `frame_samples` is the per-channel length of one transmitted frame (e.g. 960).
|
/// `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)
|
let decoder = Decoder::new(sample_rate, channels)
|
||||||
.map_err(|e| CodecError::Init(format!("Failed to create Opus decoder: {}", e)))?;
|
.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 {
|
fn channels_count(&self) -> usize {
|
||||||
@@ -73,18 +154,72 @@ impl AudioDecoder for OpusDecoder {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let decoded_per_channel = self.decoder.decode(input, &mut pcm, false)
|
let decoded_per_channel = self
|
||||||
|
.decoder
|
||||||
|
.decode(input, &mut pcm, false)
|
||||||
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
|
.map_err(|e| CodecError::Decode(format!("Opus decoding failed: {}", e)))?;
|
||||||
|
|
||||||
pcm.truncate(decoded_per_channel * channels_count);
|
pcm.truncate(decoded_per_channel * channels_count);
|
||||||
Ok(pcm)
|
Ok(pcm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_fec(&mut self, next_payload: &[u8]) -> Result<Vec<i16>, CodecError> {
|
||||||
|
let channels_count = self.channels_count();
|
||||||
|
let mut pcm = vec![0i16; self.frame_samples * channels_count];
|
||||||
|
|
||||||
|
let decoded_per_channel = self
|
||||||
|
.decoder
|
||||||
|
.decode(next_payload, &mut pcm, true)
|
||||||
|
.map_err(|e| CodecError::Decode(format!("Opus FEC decoding failed: {}", e)))?;
|
||||||
|
|
||||||
|
pcm.truncate(decoded_per_channel * channels_count);
|
||||||
|
Ok(pcm)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opus_params_mapping() {
|
||||||
|
let low = opus_params(AudioProfile::LowLatency);
|
||||||
|
let bal = opus_params(AudioProfile::Balanced);
|
||||||
|
let bad = opus_params(AudioProfile::BadNetwork);
|
||||||
|
|
||||||
|
// LowLatency has no loss redundancy; the other two do.
|
||||||
|
assert!(!low.inband_fec);
|
||||||
|
assert_eq!(low.packet_loss_perc, 0);
|
||||||
|
assert!(bal.inband_fec);
|
||||||
|
assert!(bad.inband_fec);
|
||||||
|
|
||||||
|
// Capture-side gating suppresses silence; no profile adds Opus DTX.
|
||||||
|
assert!(!low.dtx && !bal.dtx && !bad.dtx);
|
||||||
|
assert!(bad.packet_loss_perc > bal.packet_loss_perc);
|
||||||
|
|
||||||
|
// BadNetwork trims base bitrate to make room for FEC redundancy.
|
||||||
|
assert!(bad.bitrate < bal.bitrate);
|
||||||
|
|
||||||
|
// All bitrates are sane positive voice rates.
|
||||||
|
for p in [low, bal, bad] {
|
||||||
|
assert!(p.bitrate > 0 && p.bitrate <= 64_000);
|
||||||
|
assert!((0..=100).contains(&p.packet_loss_perc));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_apply_profile_sets_bitrate() {
|
||||||
|
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
// Every profile applies cleanly to a real encoder...
|
||||||
|
for profile in AudioProfile::ALL {
|
||||||
|
encoder.apply_profile(profile).unwrap();
|
||||||
|
}
|
||||||
|
// ...and the last-applied bitrate is reflected by the encoder.
|
||||||
|
encoder.apply_profile(AudioProfile::Balanced).unwrap();
|
||||||
|
let want = opus_params(AudioProfile::Balanced).bitrate;
|
||||||
|
assert_eq!(encoder.encoder.get_bitrate().unwrap(), Bitrate::Bits(want));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_round_trip() {
|
fn test_round_trip() {
|
||||||
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut encoder = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
@@ -100,7 +235,10 @@ mod tests {
|
|||||||
|
|
||||||
// encode it
|
// encode it
|
||||||
let compressed = encoder.encode(&pcm).unwrap();
|
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!(
|
assert!(
|
||||||
compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
|
compressed.len() < pcm.len() * std::mem::size_of::<i16>(),
|
||||||
"Compressed size ({}) should be smaller than raw PCM size ({})",
|
"Compressed size ({}) should be smaller than raw PCM size ({})",
|
||||||
@@ -110,13 +248,21 @@ mod tests {
|
|||||||
|
|
||||||
// decode it
|
// decode it
|
||||||
let decoded = decoder.decode(Some(&compressed)).unwrap();
|
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)
|
// 2. Round-trip carries signal energy (not silence)
|
||||||
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
|
let sum_sq: f64 = decoded.iter().map(|&x| (x as f64).powi(2)).sum();
|
||||||
let rms = (sum_sq / decoded.len() as f64).sqrt();
|
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)
|
// 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]
|
#[test]
|
||||||
@@ -125,11 +271,19 @@ mod tests {
|
|||||||
|
|
||||||
// decode(None) returns exactly frame_samples (960) samples
|
// decode(None) returns exactly frame_samples (960) samples
|
||||||
let plc_none = decoder.decode(None).unwrap();
|
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
|
// decode(Some(&[])) (empty slice) does the same
|
||||||
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
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]
|
#[test]
|
||||||
@@ -140,7 +294,11 @@ mod tests {
|
|||||||
let pcm = vec![0i16; 960];
|
let pcm = vec![0i16; 960];
|
||||||
let compressed = encoder.encode(&pcm).unwrap();
|
let compressed = encoder.encode(&pcm).unwrap();
|
||||||
let decoded = decoder.decode(Some(&compressed)).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]
|
#[test]
|
||||||
@@ -149,11 +307,18 @@ mod tests {
|
|||||||
|
|
||||||
// decode(None) returns exactly frame_samples * 2 (1920) samples
|
// decode(None) returns exactly frame_samples * 2 (1920) samples
|
||||||
let plc_none = decoder.decode(None).unwrap();
|
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
|
// decode(Some(&[])) (empty slice) does the same
|
||||||
let plc_empty = decoder.decode(Some(&[])).unwrap();
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
use crate::notify::Sound;
|
use crate::notify::Sound;
|
||||||
use crate::theme::AppTheme;
|
use crate::theme::AppTheme;
|
||||||
|
use anyhow::Context;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fs;
|
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
|
/// Relay/discovery posture, trading connectivity against how much the n0
|
||||||
/// infrastructure learns about you. See the network module for details.
|
/// infrastructure learns about you. See the network module for details.
|
||||||
@@ -24,8 +27,11 @@ pub enum NetworkMode {
|
|||||||
|
|
||||||
impl NetworkMode {
|
impl NetworkMode {
|
||||||
/// All variants, for presentation in a picker.
|
/// All variants, for presentation in a picker.
|
||||||
pub const ALL: [NetworkMode; 3] =
|
pub const ALL: [NetworkMode; 3] = [
|
||||||
[NetworkMode::RelayNoDiscovery, NetworkMode::N0Full, NetworkMode::DirectOnly];
|
NetworkMode::RelayNoDiscovery,
|
||||||
|
NetworkMode::N0Full,
|
||||||
|
NetworkMode::DirectOnly,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
/// Arrangement of the in-call room screen, chosen via the layout picker.
|
||||||
@@ -42,8 +48,11 @@ pub enum RoomLayout {
|
|||||||
|
|
||||||
impl RoomLayout {
|
impl RoomLayout {
|
||||||
/// All variants, in picker display order.
|
/// All variants, in picker display order.
|
||||||
pub const ALL: [RoomLayout; 3] =
|
pub const ALL: [RoomLayout; 3] = [
|
||||||
[RoomLayout::ThreeColumn, RoomLayout::BottomDock, RoomLayout::Drawer];
|
RoomLayout::ThreeColumn,
|
||||||
|
RoomLayout::BottomDock,
|
||||||
|
RoomLayout::Drawer,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a call recording captures. `Mixed` is the original single-file behaviour;
|
/// What a call recording captures. `Mixed` is the original single-file behaviour;
|
||||||
@@ -62,8 +71,11 @@ pub enum RecordingMode {
|
|||||||
|
|
||||||
impl RecordingMode {
|
impl RecordingMode {
|
||||||
/// All variants, in picker display order.
|
/// All variants, in picker display order.
|
||||||
pub const ALL: [RecordingMode; 3] =
|
pub const ALL: [RecordingMode; 3] = [
|
||||||
[RecordingMode::Mixed, RecordingMode::Multitrack, RecordingMode::Both];
|
RecordingMode::Mixed,
|
||||||
|
RecordingMode::Multitrack,
|
||||||
|
RecordingMode::Both,
|
||||||
|
];
|
||||||
|
|
||||||
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
|
/// True when this mode writes per-peer stem tracks (Multitrack or Both).
|
||||||
pub fn is_multitrack(self) -> bool {
|
pub fn is_multitrack(self) -> bool {
|
||||||
@@ -81,6 +93,60 @@ impl std::fmt::Display for RecordingMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Named Opus encoder / network-resilience policy (W12). The user picks a
|
||||||
|
/// profile instead of raw codec knobs; the concrete libopus parameters live in
|
||||||
|
/// `codec::opus_impl::opus_params`. Applies live to the running encoder.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
pub enum AudioProfile {
|
||||||
|
/// Lowest mouth-to-ear delay: modest bitrate, no FEC redundancy. Best on a
|
||||||
|
/// clean LAN / low-loss link where added latency matters more than loss.
|
||||||
|
LowLatency,
|
||||||
|
/// Sensible default: voice bitrate with in-band FEC for light packet loss.
|
||||||
|
#[default]
|
||||||
|
Balanced,
|
||||||
|
/// Maximum resilience on a lossy/congested link: in-band FEC tuned for heavy
|
||||||
|
/// loss, at a lower bitrate to leave headroom for the redundancy.
|
||||||
|
BadNetwork,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioProfile {
|
||||||
|
/// All variants, in picker display order.
|
||||||
|
pub const ALL: [AudioProfile; 3] = [
|
||||||
|
AudioProfile::LowLatency,
|
||||||
|
AudioProfile::Balanced,
|
||||||
|
AudioProfile::BadNetwork,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Compact discriminant for handing the profile to the capture thread via an
|
||||||
|
/// atomic. Pairs with [`AudioProfile::from_u8`].
|
||||||
|
pub fn as_u8(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
AudioProfile::LowLatency => 0,
|
||||||
|
AudioProfile::Balanced => 1,
|
||||||
|
AudioProfile::BadNetwork => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`AudioProfile::as_u8`]; unknown values fall back to the default.
|
||||||
|
pub fn from_u8(v: u8) -> AudioProfile {
|
||||||
|
match v {
|
||||||
|
0 => AudioProfile::LowLatency,
|
||||||
|
2 => AudioProfile::BadNetwork,
|
||||||
|
_ => AudioProfile::Balanced,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AudioProfile {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
AudioProfile::LowLatency => "Low latency",
|
||||||
|
AudioProfile::Balanced => "Balanced",
|
||||||
|
AudioProfile::BadNetwork => "Bad network",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for RoomLayout {
|
impl std::fmt::Display for RoomLayout {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
f.write_str(match self {
|
f.write_str(match self {
|
||||||
@@ -102,6 +168,139 @@ impl std::fmt::Display for NetworkMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pixelpass host quality preset for screen shares. `Auto` leaves pixelpass free
|
||||||
|
/// to choose from its bandwidth pre-flight; fixed presets are passed as CLI flags.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ShareQuality {
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Source,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareQuality {
|
||||||
|
pub const ALL: [ShareQuality; 5] = [
|
||||||
|
ShareQuality::Auto,
|
||||||
|
ShareQuality::Low,
|
||||||
|
ShareQuality::Medium,
|
||||||
|
ShareQuality::High,
|
||||||
|
ShareQuality::Source,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ShareQuality {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
ShareQuality::Auto => "Auto",
|
||||||
|
ShareQuality::Low => "Low",
|
||||||
|
ShareQuality::Medium => "Medium",
|
||||||
|
ShareQuality::High => "High",
|
||||||
|
ShareQuality::Source => "Source",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preferred local player for watching a peer's screen share.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SharePlayer {
|
||||||
|
#[default]
|
||||||
|
Mpv,
|
||||||
|
Vlc,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharePlayer {
|
||||||
|
pub const ALL: [SharePlayer; 2] = [SharePlayer::Mpv, SharePlayer::Vlc];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SharePlayer {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
SharePlayer::Mpv => "mpv",
|
||||||
|
SharePlayer::Vlc => "VLC",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local player buffering posture for screen-share playback.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ShareBuffering {
|
||||||
|
#[default]
|
||||||
|
LowLatency,
|
||||||
|
Smooth,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ShareBuffering {
|
||||||
|
pub const ALL: [ShareBuffering; 2] = [ShareBuffering::LowLatency, ShareBuffering::Smooth];
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ShareBuffering {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
ShareBuffering::LowLatency => "Low latency",
|
||||||
|
ShareBuffering::Smooth => "Smooth",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_screen_share_cache_mb() -> u32 {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local-only screen-share preferences. Host fields become pixelpass host CLI
|
||||||
|
/// flags; viewer fields shape local mpv/VLC launch. None/empty/default values
|
||||||
|
/// deliberately let pixelpass/player defaults stand.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ScreenShareSettings {
|
||||||
|
#[serde(default)]
|
||||||
|
pub quality: ShareQuality,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bitrate_mbps: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub framerate: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_height: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_viewers: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub force_software_encode: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_host_args: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub player: SharePlayer,
|
||||||
|
#[serde(default)]
|
||||||
|
pub hardware_decode: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub buffering: ShareBuffering,
|
||||||
|
#[serde(default = "default_screen_share_cache_mb")]
|
||||||
|
pub cache_mb: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extra_mpv_args: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScreenShareSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
quality: ShareQuality::default(),
|
||||||
|
bitrate_mbps: None,
|
||||||
|
framerate: None,
|
||||||
|
max_height: None,
|
||||||
|
max_viewers: None,
|
||||||
|
force_software_encode: false,
|
||||||
|
extra_host_args: String::new(),
|
||||||
|
player: SharePlayer::default(),
|
||||||
|
hardware_decode: false,
|
||||||
|
buffering: ShareBuffering::default(),
|
||||||
|
cache_mb: default_screen_share_cache_mb(),
|
||||||
|
extra_mpv_args: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -126,6 +325,10 @@ fn default_chat_height() -> f32 {
|
|||||||
180.0
|
180.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_threecol_playlist_height() -> f32 {
|
||||||
|
220.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_controls_width() -> f32 {
|
fn default_controls_width() -> f32 {
|
||||||
280.0
|
280.0
|
||||||
}
|
}
|
||||||
@@ -134,6 +337,10 @@ fn default_chat_drawer_width() -> f32 {
|
|||||||
320.0
|
320.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_playlist_drawer_width() -> f32 {
|
||||||
|
320.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_window_width() -> f32 {
|
fn default_window_width() -> f32 {
|
||||||
900.0
|
900.0
|
||||||
}
|
}
|
||||||
@@ -156,8 +363,33 @@ pub struct AppConfig {
|
|||||||
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
/// App-internal playback gain applied to the mixed output (1.0 = unity).
|
||||||
#[serde(default = "default_volume")]
|
#[serde(default = "default_volume")]
|
||||||
pub output_volume: f32,
|
pub output_volume: f32,
|
||||||
|
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
|
||||||
|
/// level shared by every uploaded clip so the slider sticks across plays.
|
||||||
|
#[serde(default = "default_volume")]
|
||||||
|
pub clip_volume: f32,
|
||||||
|
/// W22 music: the user's personal playlist as local file PATHS (not bytes).
|
||||||
|
/// Loaded into memory at startup; missing files are skipped/marked on play.
|
||||||
|
#[serde(default)]
|
||||||
|
pub music_playlist: Vec<String>,
|
||||||
|
/// W22 music: local playback gain for the dedicated music player (1.0 = unity).
|
||||||
|
#[serde(default = "default_volume")]
|
||||||
|
pub music_volume: f32,
|
||||||
|
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
|
||||||
|
#[serde(default)]
|
||||||
|
pub music_broadcast: bool,
|
||||||
|
/// Show the slim now-playing player bar in the room screen.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_player_bar: bool,
|
||||||
|
/// When true, `clip_volume` governs every clip. When false, each clip keeps
|
||||||
|
/// its own (in-memory) level and the universal slider is inactive.
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub clip_volume_universal: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub network_mode: NetworkMode,
|
pub network_mode: NetworkMode,
|
||||||
|
/// Opus encoder / network-resilience profile (W12). Applies live to the
|
||||||
|
/// running encoder; default `Balanced`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio_profile: AudioProfile,
|
||||||
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
/// Presence posture for the friends idle listener (W7): invisible / normal /
|
||||||
/// discoverable. Default `Normal` = answer friends only, no DNS beacon.
|
/// discoverable. Default `Normal` = answer friends only, no DNS beacon.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -174,12 +406,20 @@ pub struct AppConfig {
|
|||||||
pub participants_width: f32,
|
pub participants_width: f32,
|
||||||
#[serde(default = "default_chat_height")]
|
#[serde(default = "default_chat_height")]
|
||||||
pub chat_height: f32,
|
pub chat_height: f32,
|
||||||
|
/// Height (px) of the standalone Playlist card stacked under Chat in the
|
||||||
|
/// 3-column layout. Resized via its own horizontal divider; re-clamped to the
|
||||||
|
/// window on load/resize. Only used by `RoomLayout::ThreeColumn`.
|
||||||
|
#[serde(default = "default_threecol_playlist_height")]
|
||||||
|
pub threecol_playlist_height: f32,
|
||||||
/// Controls panel width for the 3-column layout (px).
|
/// Controls panel width for the 3-column layout (px).
|
||||||
#[serde(default = "default_controls_width")]
|
#[serde(default = "default_controls_width")]
|
||||||
pub controls_width: f32,
|
pub controls_width: f32,
|
||||||
/// Chat drawer width for the drawer layout (px).
|
/// Chat drawer width for the drawer layout (px).
|
||||||
#[serde(default = "default_chat_drawer_width")]
|
#[serde(default = "default_chat_drawer_width")]
|
||||||
pub chat_drawer_width: f32,
|
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.
|
/// Chosen arrangement of the in-call room screen.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub room_layout: RoomLayout,
|
pub room_layout: RoomLayout,
|
||||||
@@ -199,6 +439,26 @@ pub struct AppConfig {
|
|||||||
/// `crate::background::scrim_color`.
|
/// `crate::background::scrim_color`.
|
||||||
#[serde(default = "default_background_dim")]
|
#[serde(default = "default_background_dim")]
|
||||||
pub background_dim: f32,
|
pub background_dim: f32,
|
||||||
|
/// Broadcast the detected game as presence next to our avatar (game-detection
|
||||||
|
/// feature). **Opt-in, default OFF.** Enabling immediately publishes the
|
||||||
|
/// current game; disabling immediately publishes `game: None`. Toggling this
|
||||||
|
/// is the only thing that puts our game on the wire — detection itself (for the
|
||||||
|
/// local background) runs regardless.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_presence_enabled: bool,
|
||||||
|
/// Per-game UI background overrides (W18), keyed by stable game id
|
||||||
|
/// (`steam:730`, `exe:hl2_linux`) → path to the processed PNG we wrote in the
|
||||||
|
/// config dir (see `game_background_path`). The running game's entry wins; with
|
||||||
|
/// no entry we fall back to the single custom `background`. Local-only; never
|
||||||
|
/// sent to peers. `BTreeMap` for deterministic serialization.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_backgrounds: BTreeMap<String, String>,
|
||||||
|
/// User process→display-name mappings for non-Steam game detection, keyed by
|
||||||
|
/// normalized executable basename (`hl2_linux`) → the name to show/broadcast
|
||||||
|
/// (`Half-Life 2`). Only exact mappings here are ever matched (we never guess a
|
||||||
|
/// game from an arbitrary process). Local-only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game_process_map: BTreeMap<String, String>,
|
||||||
/// What a call recording captures (mixed / per-peer stems / both).
|
/// What a call recording captures (mixed / per-peer stems / both).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recording_mode: RecordingMode,
|
pub recording_mode: RecordingMode,
|
||||||
@@ -242,6 +502,9 @@ pub struct AppConfig {
|
|||||||
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
/// Empty / unset = look it up on `$PATH`. Hand-editable; no Settings UI yet.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub pixelpass_path: Option<String>,
|
pub pixelpass_path: Option<String>,
|
||||||
|
/// Local-only host/player controls for screen sharing.
|
||||||
|
#[serde(default)]
|
||||||
|
pub screen_share: ScreenShareSettings,
|
||||||
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
|
/// Recently-joined rooms (W7), most-recent-first. Purely local UI state for a
|
||||||
/// one-click rejoin; never sent over the wire. De-duped by room topic and
|
/// one-click rejoin; never sent over the wire. De-duped by room topic and
|
||||||
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
/// capped (see `recents`). Defaulted empty so older configs upgrade cleanly.
|
||||||
@@ -259,6 +522,11 @@ pub struct AppConfig {
|
|||||||
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
/// string. Local preference only; never sent to peers. Absent entry = unity.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub peer_volume: HashMap<String, f32>,
|
pub peer_volume: HashMap<String, f32>,
|
||||||
|
/// Per-source music listen volume/gain (`1.0` = unity), keyed by peer node id
|
||||||
|
/// string. Local preference only; never sent to peers. Absent entry falls
|
||||||
|
/// back to `music_volume`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub music_source_volume: HashMap<String, f32>,
|
||||||
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
|
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
|
||||||
/// keyed by peer node id string. Local preference only; never sent to peers.
|
/// keyed by peer node id string. Local preference only; never sent to peers.
|
||||||
/// Absent entry = gate disabled (pass-through).
|
/// Absent entry = gate disabled (pass-through).
|
||||||
@@ -283,6 +551,13 @@ pub struct AppConfig {
|
|||||||
pub window_y: Option<i32>,
|
pub window_y: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LoadOutcome {
|
||||||
|
Missing,
|
||||||
|
Loaded,
|
||||||
|
Recovered,
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -292,19 +567,31 @@ impl Default for AppConfig {
|
|||||||
noise_gate_threshold: 0.01,
|
noise_gate_threshold: 0.01,
|
||||||
input_volume: 1.0,
|
input_volume: 1.0,
|
||||||
output_volume: 1.0,
|
output_volume: 1.0,
|
||||||
|
clip_volume: 1.0,
|
||||||
|
music_playlist: Vec::new(),
|
||||||
|
music_volume: 1.0,
|
||||||
|
music_broadcast: false,
|
||||||
|
show_player_bar: true,
|
||||||
|
clip_volume_universal: true,
|
||||||
network_mode: NetworkMode::default(),
|
network_mode: NetworkMode::default(),
|
||||||
|
audio_profile: AudioProfile::default(),
|
||||||
presence_mode: crate::presence::PresenceMode::default(),
|
presence_mode: crate::presence::PresenceMode::default(),
|
||||||
echo_cancellation_enabled: false,
|
echo_cancellation_enabled: false,
|
||||||
notifications_enabled: true,
|
notifications_enabled: true,
|
||||||
participants_width: default_participants_width(),
|
participants_width: default_participants_width(),
|
||||||
chat_height: default_chat_height(),
|
chat_height: default_chat_height(),
|
||||||
|
threecol_playlist_height: default_threecol_playlist_height(),
|
||||||
controls_width: default_controls_width(),
|
controls_width: default_controls_width(),
|
||||||
chat_drawer_width: default_chat_drawer_width(),
|
chat_drawer_width: default_chat_drawer_width(),
|
||||||
|
playlist_drawer_width: default_playlist_drawer_width(),
|
||||||
room_layout: RoomLayout::default(),
|
room_layout: RoomLayout::default(),
|
||||||
theme: AppTheme::default(),
|
theme: AppTheme::default(),
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
background: None,
|
background: None,
|
||||||
background_dim: default_background_dim(),
|
background_dim: default_background_dim(),
|
||||||
|
game_presence_enabled: false,
|
||||||
|
game_backgrounds: BTreeMap::new(),
|
||||||
|
game_process_map: BTreeMap::new(),
|
||||||
recording_mode: RecordingMode::default(),
|
recording_mode: RecordingMode::default(),
|
||||||
custom_sound_self_join: None,
|
custom_sound_self_join: None,
|
||||||
custom_sound_peer_join: None,
|
custom_sound_peer_join: None,
|
||||||
@@ -323,10 +610,12 @@ impl Default for AppConfig {
|
|||||||
sound_mic_toggle_enabled: true,
|
sound_mic_toggle_enabled: true,
|
||||||
sound_reconnect_failed_enabled: true,
|
sound_reconnect_failed_enabled: true,
|
||||||
pixelpass_path: None,
|
pixelpass_path: None,
|
||||||
|
screen_share: ScreenShareSettings::default(),
|
||||||
recents: Vec::new(),
|
recents: Vec::new(),
|
||||||
peer_eq: HashMap::new(),
|
peer_eq: HashMap::new(),
|
||||||
peer_pan: HashMap::new(),
|
peer_pan: HashMap::new(),
|
||||||
peer_volume: HashMap::new(),
|
peer_volume: HashMap::new(),
|
||||||
|
music_source_volume: HashMap::new(),
|
||||||
peer_gate: HashMap::new(),
|
peer_gate: HashMap::new(),
|
||||||
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
hotkeys: crate::hotkeys::HotkeyMap::default(),
|
||||||
window_width: default_window_width(),
|
window_width: default_window_width(),
|
||||||
@@ -375,36 +664,141 @@ impl AppConfig {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path the processed custom-background PNG (W16) is written to, alongside
|
/// Path to a processed-background PNG of the given filename, alongside
|
||||||
/// `config.json` in the app config dir. We store our own downscaled copy here
|
/// `config.json` in the app config dir. We store our own downscaled copies here
|
||||||
/// (rather than base64 in the config) so the JSON stays small.
|
/// (rather than base64 in the config) so the JSON stays small. Used for both
|
||||||
pub fn background_path() -> Option<PathBuf> {
|
/// the single custom background and the per-game backgrounds.
|
||||||
|
fn background_dir_path(filename: &str) -> Option<PathBuf> {
|
||||||
dirs::config_dir().map(|mut p| {
|
dirs::config_dir().map(|mut p| {
|
||||||
p.push("peerspeak");
|
p.push("peerspeak");
|
||||||
p.push("background.png");
|
p.push(filename);
|
||||||
p
|
p
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Self {
|
/// Path the single custom-background PNG (W16) is written to.
|
||||||
if let Some(path) = Self::config_path()
|
pub fn background_path() -> Option<PathBuf> {
|
||||||
&& let Ok(contents) = fs::read_to_string(&path)
|
Self::background_dir_path("background.png")
|
||||||
&& let Ok(config) = serde_json::from_str(&contents) {
|
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
Self::default()
|
|
||||||
|
/// Path the processed per-game background PNG (W18) for `game_id` is written
|
||||||
|
/// to. The filename is an app-owned hash of the id (see
|
||||||
|
/// `crate::background::game_background_filename`), so raw game ids never appear
|
||||||
|
/// on disk and the name is always filesystem-safe.
|
||||||
|
pub fn game_background_path(game_id: &str) -> Option<PathBuf> {
|
||||||
|
Self::background_dir_path(&crate::background::game_background_filename(game_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let Some(path) = Self::config_path() else {
|
||||||
|
return Self::default();
|
||||||
|
};
|
||||||
|
let (config, _) = Self::load_from(&path);
|
||||||
|
config
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self) {
|
pub fn save(&self) {
|
||||||
if let Some(path) = Self::config_path() {
|
if let Some(path) = Self::config_path() {
|
||||||
if let Some(dir) = path.parent() {
|
if let Err(e) = self.save_to(&path) {
|
||||||
let _ = fs::create_dir_all(dir);
|
crate::log_msg(&format!("config: save failed: {e:#}"));
|
||||||
}
|
}
|
||||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
} else {
|
||||||
let _ = fs::write(path, json);
|
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)]
|
#[cfg(test)]
|
||||||
@@ -419,6 +813,98 @@ mod tests {
|
|||||||
assert_eq!(original, deserialized);
|
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]
|
#[test]
|
||||||
fn test_backward_compat_default_fill() {
|
fn test_backward_compat_default_fill() {
|
||||||
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
let minimal_json = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||||
@@ -426,7 +912,10 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
|
assert_eq!(deserialized.network_mode, NetworkMode::RelayNoDiscovery);
|
||||||
// Configs predating the presence posture load as friends-only (no beacon).
|
// 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.echo_cancellation_enabled);
|
||||||
assert!(deserialized.notifications_enabled);
|
assert!(deserialized.notifications_enabled);
|
||||||
// Configs predating the volume sliders must load at unity gain.
|
// Configs predating the volume sliders must load at unity gain.
|
||||||
@@ -441,6 +930,8 @@ mod tests {
|
|||||||
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
assert_eq!(deserialized.room_layout, RoomLayout::BottomDock);
|
||||||
assert_eq!(deserialized.controls_width, 280.0);
|
assert_eq!(deserialized.controls_width, 280.0);
|
||||||
assert_eq!(deserialized.chat_drawer_width, 320.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_self_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_join.is_none());
|
assert!(deserialized.custom_sound_peer_join.is_none());
|
||||||
assert!(deserialized.custom_sound_peer_leave.is_none());
|
assert!(deserialized.custom_sound_peer_leave.is_none());
|
||||||
@@ -449,10 +940,21 @@ mod tests {
|
|||||||
assert!(deserialized.custom_sound_self_leave.is_none());
|
assert!(deserialized.custom_sound_self_leave.is_none());
|
||||||
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
assert!(deserialized.custom_sound_mic_toggle.is_none());
|
||||||
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
assert!(deserialized.custom_sound_reconnect_failed.is_none());
|
||||||
|
assert_eq!(deserialized.screen_share, ScreenShareSettings::default());
|
||||||
|
assert_eq!(deserialized.screen_share.quality, ShareQuality::Auto);
|
||||||
|
assert_eq!(deserialized.screen_share.player, SharePlayer::Mpv);
|
||||||
|
assert_eq!(
|
||||||
|
deserialized.screen_share.buffering,
|
||||||
|
ShareBuffering::LowLatency
|
||||||
|
);
|
||||||
|
assert_eq!(deserialized.screen_share.cache_mb, 2);
|
||||||
// Configs predating the per-sound flags (W6) enable every chime, so an
|
// Configs predating the per-sound flags (W6) enable every chime, so an
|
||||||
// upgrade is silent-change-free.
|
// upgrade is silent-change-free.
|
||||||
for sound in Sound::ALL {
|
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.
|
// The accessor and mutator agree round-trip.
|
||||||
let mut cfg = AppConfig::default();
|
let mut cfg = AppConfig::default();
|
||||||
@@ -469,6 +971,7 @@ mod tests {
|
|||||||
assert!(deserialized.peer_eq.is_empty());
|
assert!(deserialized.peer_eq.is_empty());
|
||||||
assert!(deserialized.peer_pan.is_empty());
|
assert!(deserialized.peer_pan.is_empty());
|
||||||
assert!(deserialized.peer_volume.is_empty());
|
assert!(deserialized.peer_volume.is_empty());
|
||||||
|
assert!(deserialized.music_source_volume.is_empty());
|
||||||
assert!(deserialized.peer_gate.is_empty());
|
assert!(deserialized.peer_gate.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
crate::hotkeys::format_binding(
|
crate::hotkeys::format_binding(
|
||||||
@@ -480,6 +983,57 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_backward_compat_game_detection_fields() {
|
||||||
|
// A config that predates the game-detection feature (W18) — and crucially
|
||||||
|
// still carries the W16 single `background` as a plain string — must
|
||||||
|
// deserialize without error. `AppConfig::load()` silently replaces ANY
|
||||||
|
// deserialize failure with full defaults, so a broken migration here would
|
||||||
|
// wipe everyone's settings; this guards that the additive fields kept the
|
||||||
|
// old shape loadable and that `background` was NOT retyped.
|
||||||
|
let legacy_json = r#"{
|
||||||
|
"input_device": "",
|
||||||
|
"output_device": "",
|
||||||
|
"noise_gate_threshold": 0.01,
|
||||||
|
"username": "Eric",
|
||||||
|
"background": "/home/eric/.config/peerspeak/background.png",
|
||||||
|
"background_dim": 0.4
|
||||||
|
}"#;
|
||||||
|
let cfg: AppConfig = serde_json::from_str(legacy_json).unwrap();
|
||||||
|
// The pre-existing single background survives untouched (still Option<String>).
|
||||||
|
assert_eq!(
|
||||||
|
cfg.background.as_deref(),
|
||||||
|
Some("/home/eric/.config/peerspeak/background.png")
|
||||||
|
);
|
||||||
|
assert!((cfg.background_dim - 0.4).abs() < f32::EPSILON);
|
||||||
|
// The new game-detection fields default to off/empty → silent, opt-in upgrade.
|
||||||
|
assert!(!cfg.game_presence_enabled);
|
||||||
|
assert!(cfg.game_backgrounds.is_empty());
|
||||||
|
assert!(cfg.game_process_map.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_game_maps_serialize_deterministically() {
|
||||||
|
// BTreeMap ordering makes the serialized config stable across runs.
|
||||||
|
let mut cfg = AppConfig::default();
|
||||||
|
cfg.game_backgrounds
|
||||||
|
.insert("steam:730".into(), "/a.png".into());
|
||||||
|
cfg.game_backgrounds
|
||||||
|
.insert("exe:hl2_linux".into(), "/b.png".into());
|
||||||
|
cfg.game_process_map
|
||||||
|
.insert("hl2_linux".into(), "Half-Life 2".into());
|
||||||
|
let json = serde_json::to_string(&cfg).unwrap();
|
||||||
|
// Keys appear in sorted order (exe: before steam:).
|
||||||
|
let bg = json.find("game_backgrounds").unwrap();
|
||||||
|
let exe_at = json[bg..].find("exe:hl2_linux").unwrap();
|
||||||
|
let steam_at = json[bg..].find("steam:730").unwrap();
|
||||||
|
assert!(exe_at < steam_at, "BTreeMap keys must serialize sorted");
|
||||||
|
// Full round-trip preserves the maps.
|
||||||
|
let back: AppConfig = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(back.game_backgrounds, cfg.game_backgrounds);
|
||||||
|
assert_eq!(back.game_process_map, cfg.game_process_map);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_window_size_fields() {
|
fn test_window_size_fields() {
|
||||||
// Default impl is the standard launch size.
|
// Default impl is the standard launch size.
|
||||||
@@ -525,8 +1079,7 @@ mod tests {
|
|||||||
recording_mode: RecordingMode::Both,
|
recording_mode: RecordingMode::Both,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let back: AppConfig =
|
let back: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
|
||||||
assert_eq!(back.recording_mode, RecordingMode::Both);
|
assert_eq!(back.recording_mode, RecordingMode::Both);
|
||||||
// is_multitrack() classifies correctly.
|
// is_multitrack() classifies correctly.
|
||||||
assert!(!RecordingMode::Mixed.is_multitrack());
|
assert!(!RecordingMode::Mixed.is_multitrack());
|
||||||
@@ -575,28 +1128,57 @@ mod tests {
|
|||||||
let def = AppConfig::default();
|
let def = AppConfig::default();
|
||||||
assert_eq!(def.input_volume, 1.0);
|
assert_eq!(def.input_volume, 1.0);
|
||||||
assert_eq!(def.output_volume, 1.0);
|
assert_eq!(def.output_volume, 1.0);
|
||||||
|
assert_eq!(def.clip_volume, 1.0);
|
||||||
|
assert!(def.music_playlist.is_empty());
|
||||||
|
assert_eq!(def.music_volume, 1.0);
|
||||||
|
assert!(!def.music_broadcast);
|
||||||
|
assert!(def.show_player_bar);
|
||||||
|
assert!(def.clip_volume_universal);
|
||||||
|
|
||||||
// Missing in JSON → unity (serde default).
|
// Missing in JSON → unity (serde default).
|
||||||
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
|
||||||
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
|
||||||
assert_eq!(cfg_missing.input_volume, 1.0);
|
assert_eq!(cfg_missing.input_volume, 1.0);
|
||||||
assert_eq!(cfg_missing.output_volume, 1.0);
|
assert_eq!(cfg_missing.output_volume, 1.0);
|
||||||
|
assert_eq!(cfg_missing.clip_volume, 1.0);
|
||||||
|
assert!(cfg_missing.music_playlist.is_empty());
|
||||||
|
assert_eq!(cfg_missing.music_volume, 1.0);
|
||||||
|
assert!(!cfg_missing.music_broadcast);
|
||||||
|
assert!(cfg_missing.show_player_bar);
|
||||||
|
// Configs predating the toggle default to universal mode.
|
||||||
|
assert!(cfg_missing.clip_volume_universal);
|
||||||
|
|
||||||
// Explicit non-unity values are preserved across a round-trip.
|
// Explicit non-unity values are preserved across a round-trip.
|
||||||
let cfg = AppConfig {
|
let cfg = AppConfig {
|
||||||
input_volume: 1.5,
|
input_volume: 1.5,
|
||||||
output_volume: 0.25,
|
output_volume: 0.25,
|
||||||
|
clip_volume: 0.7,
|
||||||
|
music_playlist: vec!["/tmp/song.ogg".to_string()],
|
||||||
|
music_volume: 0.6,
|
||||||
|
music_broadcast: true,
|
||||||
|
show_player_bar: false,
|
||||||
|
clip_volume_universal: false,
|
||||||
..AppConfig::default()
|
..AppConfig::default()
|
||||||
};
|
};
|
||||||
let round_tripped: AppConfig =
|
let round_tripped: AppConfig =
|
||||||
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
|
||||||
assert_eq!(round_tripped.input_volume, 1.5);
|
assert_eq!(round_tripped.input_volume, 1.5);
|
||||||
assert_eq!(round_tripped.output_volume, 0.25);
|
assert_eq!(round_tripped.output_volume, 0.25);
|
||||||
|
assert_eq!(round_tripped.clip_volume, 0.7);
|
||||||
|
assert_eq!(
|
||||||
|
round_tripped.music_playlist,
|
||||||
|
vec!["/tmp/song.ogg".to_string()]
|
||||||
|
);
|
||||||
|
assert_eq!(round_tripped.music_volume, 0.6);
|
||||||
|
assert!(round_tripped.music_broadcast);
|
||||||
|
assert!(!round_tripped.show_player_bar);
|
||||||
|
assert!(!round_tripped.clip_volume_universal);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_notifications_enabled_specifically() {
|
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();
|
let config_missing: AppConfig = serde_json::from_str(missing_notifications).unwrap();
|
||||||
assert!(config_missing.notifications_enabled);
|
assert!(config_missing.notifications_enabled);
|
||||||
|
|
||||||
@@ -641,6 +1223,38 @@ mod tests {
|
|||||||
assert_ne!(display_0, display_2);
|
assert_ne!(display_0, display_2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_profile() {
|
||||||
|
// Default is Balanced.
|
||||||
|
assert_eq!(AudioProfile::default(), AudioProfile::Balanced);
|
||||||
|
|
||||||
|
// ALL holds the three variants.
|
||||||
|
assert_eq!(AudioProfile::ALL.len(), 3);
|
||||||
|
assert!(AudioProfile::ALL.contains(&AudioProfile::LowLatency));
|
||||||
|
assert!(AudioProfile::ALL.contains(&AudioProfile::Balanced));
|
||||||
|
assert!(AudioProfile::ALL.contains(&AudioProfile::BadNetwork));
|
||||||
|
|
||||||
|
// as_u8 / from_u8 round-trip every variant, and unknown bytes fall back
|
||||||
|
// to the default rather than panicking.
|
||||||
|
for p in AudioProfile::ALL {
|
||||||
|
assert_eq!(AudioProfile::from_u8(p.as_u8()), p);
|
||||||
|
}
|
||||||
|
assert_eq!(AudioProfile::from_u8(99), AudioProfile::Balanced);
|
||||||
|
|
||||||
|
// serde round-trips, and Display strings are non-empty + distinct.
|
||||||
|
let mut labels = Vec::new();
|
||||||
|
for p in AudioProfile::ALL {
|
||||||
|
let s = serde_json::to_string(&p).unwrap();
|
||||||
|
assert_eq!(serde_json::from_str::<AudioProfile>(&s).unwrap(), p);
|
||||||
|
let label = p.to_string();
|
||||||
|
assert!(!label.is_empty());
|
||||||
|
labels.push(label);
|
||||||
|
}
|
||||||
|
labels.sort();
|
||||||
|
labels.dedup();
|
||||||
|
assert_eq!(labels.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_unknown_field_tolerance() {
|
fn test_unknown_field_tolerance() {
|
||||||
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
|
// Unknown/extra field tolerance: a config JSON containing an extra unrecognized key should still deserialize.
|
||||||
@@ -655,7 +1269,10 @@ mod tests {
|
|||||||
|
|
||||||
// Assert that deserialization succeeds even with unrecognized/unknown fields.
|
// Assert that deserialization succeeds even with unrecognized/unknown fields.
|
||||||
// This confirms that serde does not reject unknown fields (i.e. default behavior).
|
// 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();
|
let config = deserialized_res.unwrap();
|
||||||
assert_eq!(config.input_device, "");
|
assert_eq!(config.input_device, "");
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! Roster-bound chat identity (chat-hardening plan, Phase 2).
|
||||||
|
//!
|
||||||
|
//! The wire `GossipMessage::Chat` carries a sender-CLAIMED display name, which
|
||||||
|
//! any insider could set to another member's name. This map is the antidote:
|
||||||
|
//! the core event task records each authenticated member's latest sanitized
|
||||||
|
//! presence name here (from `PeerJoined`/`PeerUpdated`, the events that only
|
||||||
|
//! fire for a verified signed `Announce`), and chat renders under THAT name —
|
||||||
|
//! the embedded wire name is never displayed.
|
||||||
|
//!
|
||||||
|
//! Shared (`Arc<Mutex<…>>`) because eviction happens in two places: the event
|
||||||
|
//! task itself (graceful `PeerLeft`) and the detached reconnect-grace timer
|
||||||
|
//! (terminal eviction). A peer mid-reconnect-grace keeps its entry, so its
|
||||||
|
//! chat stays admitted until the grace actually expires.
|
||||||
|
|
||||||
|
use iroh::EndpointId;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Bound on tracked names. Mirrors the gossip roster cap (`MAX_ACTIVE_PEERS`):
|
||||||
|
/// insertions only follow cap-gated roster admissions, so this is pure defense
|
||||||
|
/// in depth against that invariant breaking.
|
||||||
|
const CHAT_ROSTER_CAP: usize = 32;
|
||||||
|
|
||||||
|
/// The authoritative id → display-name map for the current room. Cheap to
|
||||||
|
/// clone; all clones share one map.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ChatRoster {
|
||||||
|
names: Arc<Mutex<HashMap<EndpointId, String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChatRoster {
|
||||||
|
/// Record (or refresh) a member's display name. The name is re-sanitized
|
||||||
|
/// here (idempotent — gossip ingress already did) and an empty result falls
|
||||||
|
/// back to the short node id so a chat line is never label-less. A NEW id
|
||||||
|
/// is refused past the cap; updates to a present id always land.
|
||||||
|
pub fn upsert(&self, id: EndpointId, name: &str) {
|
||||||
|
let clean = crate::sanitize::sanitize_name(name);
|
||||||
|
let label = if clean.is_empty() {
|
||||||
|
crate::short_id(&id.to_string())
|
||||||
|
} else {
|
||||||
|
clean
|
||||||
|
};
|
||||||
|
let mut names = self.names.lock().unwrap();
|
||||||
|
if names.contains_key(&id) || names.len() < CHAT_ROSTER_CAP {
|
||||||
|
names.insert(id, label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a member on graceful leave or terminal (grace-expired) eviction.
|
||||||
|
pub fn remove(&self, id: &EndpointId) {
|
||||||
|
self.names.lock().unwrap().remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The roster-bound name for an id, or `None` if the author is not a
|
||||||
|
/// current member — the caller must then drop the chat entirely.
|
||||||
|
pub fn name_of(&self, id: &EndpointId) -> Option<String> {
|
||||||
|
self.names.lock().unwrap().get(id).cloned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
fn fresh_id() -> EndpointId {
|
||||||
|
SecretKey::generate().public()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_then_lookup_returns_sanitized_name() {
|
||||||
|
let roster = ChatRoster::default();
|
||||||
|
let a = fresh_id();
|
||||||
|
roster.upsert(a, "Alice");
|
||||||
|
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
|
||||||
|
// Bidi override / zero-width spoofing characters are stripped.
|
||||||
|
roster.upsert(a, "Al\u{202E}ice\u{200B}");
|
||||||
|
assert_eq!(roster.name_of(&a), Some("Alice".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn name_update_affects_future_lookups() {
|
||||||
|
let roster = ChatRoster::default();
|
||||||
|
let a = fresh_id();
|
||||||
|
roster.upsert(a, "Alice");
|
||||||
|
roster.upsert(a, "Alice2");
|
||||||
|
assert_eq!(roster.name_of(&a), Some("Alice2".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_author_has_no_name() {
|
||||||
|
let roster = ChatRoster::default();
|
||||||
|
roster.upsert(fresh_id(), "Alice");
|
||||||
|
assert_eq!(roster.name_of(&fresh_id()), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn removed_author_is_no_longer_a_member() {
|
||||||
|
let roster = ChatRoster::default();
|
||||||
|
let a = fresh_id();
|
||||||
|
roster.upsert(a, "Alice");
|
||||||
|
roster.remove(&a);
|
||||||
|
assert_eq!(roster.name_of(&a), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_sanitized_name_falls_back_to_short_id() {
|
||||||
|
let roster = ChatRoster::default();
|
||||||
|
let a = fresh_id();
|
||||||
|
roster.upsert(a, "\u{0}\r\n\t ");
|
||||||
|
let label = roster.name_of(&a).unwrap();
|
||||||
|
assert!(!label.is_empty());
|
||||||
|
assert_eq!(label, crate::short_id(&a.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_ids_are_refused_past_the_cap_but_updates_land() {
|
||||||
|
let roster = ChatRoster::default();
|
||||||
|
let first = fresh_id();
|
||||||
|
roster.upsert(first, "member");
|
||||||
|
for _ in 1..CHAT_ROSTER_CAP {
|
||||||
|
roster.upsert(fresh_id(), "member");
|
||||||
|
}
|
||||||
|
// A brand-new 33rd id is refused...
|
||||||
|
let overflow = fresh_id();
|
||||||
|
roster.upsert(overflow, "overflow");
|
||||||
|
assert_eq!(roster.name_of(&overflow), None);
|
||||||
|
// ...but an update to a present id still lands at the cap.
|
||||||
|
roster.upsert(first, "renamed");
|
||||||
|
assert_eq!(roster.name_of(&first), Some("renamed".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
//! Per-peer connection-transparency derivation.
|
||||||
|
//!
|
||||||
|
//! The transport hands us cumulative counters for each peer's selected QUIC
|
||||||
|
//! path ([`PathSnapshot`]); this module turns two consecutive snapshots into
|
||||||
|
//! the human-facing [`PeerConnInfo`] the UI renders (badge + tooltip): path
|
||||||
|
//! type, RTT, and loss/bitrate over the poll window. Pure functions only —
|
||||||
|
//! the polling task in `core::mod` owns the clock and the previous-snapshot
|
||||||
|
//! map.
|
||||||
|
|
||||||
|
use crate::network::PathSnapshot;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// How often the core polls the transport for path snapshots.
|
||||||
|
pub const POLL_INTERVAL: Duration = Duration::from_secs(1);
|
||||||
|
|
||||||
|
/// Derived, display-ready connection info for one peer, sent to the UI via
|
||||||
|
/// `UiEvent::ConnectionStats`. Window-relative fields are `None` when they
|
||||||
|
/// can't be derived yet (first poll, path switch, or an idle window).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PeerConnInfo {
|
||||||
|
/// True = relayed path, false = direct IP path.
|
||||||
|
pub relay: bool,
|
||||||
|
/// `ip:port` for a direct path, the relay URL for a relayed one.
|
||||||
|
pub remote_addr: String,
|
||||||
|
/// Path round-trip time, rounded to whole milliseconds.
|
||||||
|
pub rtt_ms: u32,
|
||||||
|
/// Percentage of packets sent in the window that were detected lost.
|
||||||
|
pub loss_pct: Option<f32>,
|
||||||
|
/// Outbound bitrate over the window, kilobits per second.
|
||||||
|
pub up_kbps: Option<f32>,
|
||||||
|
/// Inbound bitrate over the window, kilobits per second.
|
||||||
|
pub down_kbps: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive display info from the current snapshot and (when comparable) the
|
||||||
|
/// previous one. `prev` is comparable only if it's the same path — a relay→
|
||||||
|
/// direct migration or a reconnect resets the counters, so those windows
|
||||||
|
/// yield `None` rates rather than garbage (negative deltas show up as
|
||||||
|
/// `cur < prev` and are treated the same way).
|
||||||
|
pub fn derive(prev: Option<&PathSnapshot>, cur: &PathSnapshot, elapsed: Duration) -> PeerConnInfo {
|
||||||
|
let rates = prev
|
||||||
|
.filter(|p| comparable(p, cur))
|
||||||
|
.and_then(|p| window_rates(p, cur, elapsed));
|
||||||
|
PeerConnInfo {
|
||||||
|
relay: cur.is_relay,
|
||||||
|
remote_addr: cur.remote_addr.clone(),
|
||||||
|
rtt_ms: cur.rtt.as_millis().min(u128::from(u32::MAX)) as u32,
|
||||||
|
loss_pct: rates.and_then(|r| r.loss_pct),
|
||||||
|
up_kbps: rates.map(|r| r.up_kbps),
|
||||||
|
down_kbps: rates.map(|r| r.down_kbps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when `cur`'s counters continue `prev`'s: same path (address) and
|
||||||
|
/// monotonically non-decreasing counters (a reconnect on the same address
|
||||||
|
/// restarts them from zero).
|
||||||
|
fn comparable(prev: &PathSnapshot, cur: &PathSnapshot) -> bool {
|
||||||
|
prev.remote_addr == cur.remote_addr
|
||||||
|
&& cur.tx_bytes >= prev.tx_bytes
|
||||||
|
&& cur.rx_bytes >= prev.rx_bytes
|
||||||
|
&& cur.tx_datagrams >= prev.tx_datagrams
|
||||||
|
&& cur.lost_packets >= prev.lost_packets
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct WindowRates {
|
||||||
|
loss_pct: Option<f32>,
|
||||||
|
up_kbps: f32,
|
||||||
|
down_kbps: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_rates(prev: &PathSnapshot, cur: &PathSnapshot, elapsed: Duration) -> Option<WindowRates> {
|
||||||
|
let secs = elapsed.as_secs_f64();
|
||||||
|
if secs <= 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sent = cur.tx_datagrams - prev.tx_datagrams;
|
||||||
|
let lost = cur.lost_packets - prev.lost_packets;
|
||||||
|
// Loss detection lags sending (it needs ACK timeouts), so a window can see
|
||||||
|
// more losses than sends; clamp to 100% rather than exceeding it. An idle
|
||||||
|
// window (nothing sent or lost) has no loss story to tell.
|
||||||
|
let loss_pct = if sent == 0 && lost == 0 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(((lost as f64 / (sent.max(lost)) as f64) * 100.0) as f32)
|
||||||
|
};
|
||||||
|
let kbps = |bytes: u64| ((bytes as f64 * 8.0 / 1000.0) / secs) as f32;
|
||||||
|
Some(WindowRates {
|
||||||
|
loss_pct,
|
||||||
|
up_kbps: kbps(cur.tx_bytes - prev.tx_bytes),
|
||||||
|
down_kbps: kbps(cur.rx_bytes - prev.rx_bytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn snap(addr: &str, tx_b: u64, rx_b: u64, tx_d: u64, lost: u64) -> PathSnapshot {
|
||||||
|
PathSnapshot {
|
||||||
|
is_relay: false,
|
||||||
|
remote_addr: addr.to_string(),
|
||||||
|
rtt: Duration::from_millis(12),
|
||||||
|
tx_bytes: tx_b,
|
||||||
|
rx_bytes: rx_b,
|
||||||
|
tx_datagrams: tx_d,
|
||||||
|
lost_packets: lost,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_poll_has_type_and_rtt_but_no_rates() {
|
||||||
|
let cur = snap("1.2.3.4:5", 1000, 2000, 50, 0);
|
||||||
|
let info = derive(None, &cur, POLL_INTERVAL);
|
||||||
|
assert_eq!(info.rtt_ms, 12);
|
||||||
|
assert!(!info.relay);
|
||||||
|
assert_eq!(info.remote_addr, "1.2.3.4:5");
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.down_kbps, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn steady_window_yields_rates_and_loss() {
|
||||||
|
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||||
|
// 1s window: 4000 bytes up (32 kbps), 2000 down (16 kbps), 2 of 100 lost.
|
||||||
|
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.up_kbps, Some(32.0));
|
||||||
|
assert_eq!(info.down_kbps, Some(16.0));
|
||||||
|
assert_eq!(info.loss_pct, Some(2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_window_has_no_loss_story() {
|
||||||
|
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||||
|
let cur = prev.clone();
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
assert_eq!(info.up_kbps, Some(0.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loss_detected_in_an_idle_window_clamps_to_full() {
|
||||||
|
// Losses can be *detected* after sending stops (ACK timeouts fire late).
|
||||||
|
let prev = snap("1.2.3.4:5", 4000, 2000, 100, 0);
|
||||||
|
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 3);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.loss_pct, Some(100.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_switch_resets_the_window() {
|
||||||
|
let prev = snap("relay.example:443", 9000, 9000, 900, 5);
|
||||||
|
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counter_reset_on_same_address_resets_the_window() {
|
||||||
|
// Same address but the connection was rebuilt → counters restarted.
|
||||||
|
let prev = snap("1.2.3.4:5", 9000, 9000, 900, 5);
|
||||||
|
let cur = snap("1.2.3.4:5", 100, 100, 10, 0);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::from_secs(1));
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_elapsed_yields_no_rates() {
|
||||||
|
let prev = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||||
|
let cur = snap("1.2.3.4:5", 4000, 2000, 100, 2);
|
||||||
|
let info = derive(Some(&prev), &cur, Duration::ZERO);
|
||||||
|
assert_eq!(info.up_kbps, None);
|
||||||
|
assert_eq!(info.loss_pct, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn oversized_rtt_saturates_instead_of_wrapping() {
|
||||||
|
let mut cur = snap("1.2.3.4:5", 0, 0, 0, 0);
|
||||||
|
cur.rtt = Duration::from_secs(u64::MAX);
|
||||||
|
let info = derive(None, &cur, POLL_INTERVAL);
|
||||||
|
assert_eq!(info.rtt_ms, u32::MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
//! Byte/request budgets for AUTOMATIC chat-attachment fetches (Phase 3B).
|
||||||
|
//!
|
||||||
|
//! The four-permit semaphore bounds how many auto-fetch tasks run at once, but
|
||||||
|
//! not how much a peer can make us download over time: with permits released
|
||||||
|
//! after each transfer, an insider could stream distinct ≤4 MiB images
|
||||||
|
//! sequentially forever. This budget adds per-author and session (room-wide)
|
||||||
|
//! token buckets over both request COUNT and declared BYTES. Like the Phase 2
|
||||||
|
//! chat gate, time is passed in — never read from a clock — so every refill
|
||||||
|
//! boundary is unit-testable.
|
||||||
|
//!
|
||||||
|
//! Only the automatic path consults this; a user's explicit click (Save /
|
||||||
|
//! Download / Load image) is human-rate-limited and always allowed through to
|
||||||
|
//! the fetch (still subject to the transfer cap and cache/decoder budgets).
|
||||||
|
|
||||||
|
use iroh::EndpointId;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Per-author request burst: how many auto-fetches one author can trigger
|
||||||
|
/// back-to-back before refill pacing binds.
|
||||||
|
pub const AUTHOR_REQ_BURST: f64 = 8.0;
|
||||||
|
/// Per-author request refill: one recovered every 10 s.
|
||||||
|
pub const AUTHOR_REQ_REFILL_PER_MS: f64 = 1.0 / 10_000.0;
|
||||||
|
/// Per-author byte burst (declared sizes): a couple of full-size auto images
|
||||||
|
/// plus a normal working set.
|
||||||
|
pub const AUTHOR_BYTES_BURST: f64 = (16 * 1024 * 1024) as f64;
|
||||||
|
/// Per-author byte refill: 64 KiB/s (~one 4 MiB auto image per minute).
|
||||||
|
pub const AUTHOR_BYTES_REFILL_PER_MS: f64 = (64 * 1024) as f64 / 1000.0;
|
||||||
|
|
||||||
|
/// Session-wide request burst across all authors.
|
||||||
|
pub const SESSION_REQ_BURST: f64 = 16.0;
|
||||||
|
/// Session-wide request refill: one recovered every 5 s.
|
||||||
|
pub const SESSION_REQ_REFILL_PER_MS: f64 = 1.0 / 5_000.0;
|
||||||
|
/// Session-wide byte burst across all authors.
|
||||||
|
pub const SESSION_BYTES_BURST: f64 = (48 * 1024 * 1024) as f64;
|
||||||
|
/// Session-wide byte refill: 128 KiB/s.
|
||||||
|
pub const SESSION_BYTES_REFILL_PER_MS: f64 = (128 * 1024) as f64 / 1000.0;
|
||||||
|
|
||||||
|
/// Bound on the per-author bucket map. Authors are roster members (≤32 live),
|
||||||
|
/// so this tracks the roster plus recently departed; the least-recently-active
|
||||||
|
/// entry is pruned past the cap.
|
||||||
|
pub const AUTHOR_MAP_CAP: usize = 64;
|
||||||
|
|
||||||
|
/// A deterministic token bucket that can take a WEIGHTED cost (bytes), unlike
|
||||||
|
/// the unit-cost bucket in the gossip chat gate.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct WeightedBucket {
|
||||||
|
tokens: f64,
|
||||||
|
last_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WeightedBucket {
|
||||||
|
fn full(burst: f64, now_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
tokens: burst,
|
||||||
|
last_ms: now_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refill for elapsed time (capped at `burst`) without consuming.
|
||||||
|
fn refill(&mut self, burst: f64, refill_per_ms: f64, now_ms: u64) {
|
||||||
|
let elapsed = now_ms.saturating_sub(self.last_ms) as f64;
|
||||||
|
self.tokens = (self.tokens + elapsed * refill_per_ms).min(burst);
|
||||||
|
self.last_ms = now_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has(&self, cost: f64) -> bool {
|
||||||
|
self.tokens >= cost
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take(&mut self, cost: f64) {
|
||||||
|
self.tokens -= cost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One author's pair of buckets plus last activity (for idle pruning).
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AuthorBudget {
|
||||||
|
reqs: WeightedBucket,
|
||||||
|
bytes: WeightedBucket,
|
||||||
|
last_seen_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admission budget for automatic attachment fetches. All four buckets are
|
||||||
|
/// checked BEFORE any is consumed, so a rejection never burns tokens (no
|
||||||
|
/// refund bookkeeping — the check-then-take is atomic within `admit`).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AutoFetchBudget {
|
||||||
|
session_reqs: WeightedBucket,
|
||||||
|
session_bytes: WeightedBucket,
|
||||||
|
authors: HashMap<EndpointId, AuthorBudget>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AutoFetchBudget {
|
||||||
|
pub fn new(now_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
session_reqs: WeightedBucket::full(SESSION_REQ_BURST, now_ms),
|
||||||
|
session_bytes: WeightedBucket::full(SESSION_BYTES_BURST, now_ms),
|
||||||
|
authors: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an auto-fetch of `size` declared bytes for `author` may start
|
||||||
|
/// now. Consumes one request token and `size` byte tokens from BOTH the
|
||||||
|
/// author's and the session's buckets — or nothing at all on rejection.
|
||||||
|
pub fn admit(&mut self, author: EndpointId, size: u64, now_ms: u64) -> bool {
|
||||||
|
self.prune(author, now_ms);
|
||||||
|
let entry = self.authors.entry(author).or_insert_with(|| AuthorBudget {
|
||||||
|
reqs: WeightedBucket::full(AUTHOR_REQ_BURST, now_ms),
|
||||||
|
bytes: WeightedBucket::full(AUTHOR_BYTES_BURST, now_ms),
|
||||||
|
last_seen_ms: now_ms,
|
||||||
|
});
|
||||||
|
entry.last_seen_ms = now_ms;
|
||||||
|
entry
|
||||||
|
.reqs
|
||||||
|
.refill(AUTHOR_REQ_BURST, AUTHOR_REQ_REFILL_PER_MS, now_ms);
|
||||||
|
entry
|
||||||
|
.bytes
|
||||||
|
.refill(AUTHOR_BYTES_BURST, AUTHOR_BYTES_REFILL_PER_MS, now_ms);
|
||||||
|
self.session_reqs
|
||||||
|
.refill(SESSION_REQ_BURST, SESSION_REQ_REFILL_PER_MS, now_ms);
|
||||||
|
self.session_bytes
|
||||||
|
.refill(SESSION_BYTES_BURST, SESSION_BYTES_REFILL_PER_MS, now_ms);
|
||||||
|
|
||||||
|
let cost = size as f64;
|
||||||
|
let ok = entry.reqs.has(1.0)
|
||||||
|
&& entry.bytes.has(cost)
|
||||||
|
&& self.session_reqs.has(1.0)
|
||||||
|
&& self.session_bytes.has(cost);
|
||||||
|
if ok {
|
||||||
|
let entry = self.authors.get_mut(&author).expect("just inserted");
|
||||||
|
entry.reqs.take(1.0);
|
||||||
|
entry.bytes.take(cost);
|
||||||
|
self.session_reqs.take(1.0);
|
||||||
|
self.session_bytes.take(cost);
|
||||||
|
}
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the author map bounded: past the cap, drop the least-recently
|
||||||
|
/// active entry that isn't the author being admitted. A pruned author
|
||||||
|
/// returns with full buckets, but authors are roster-gated upstream, so
|
||||||
|
/// the map can't be churned by strangers.
|
||||||
|
fn prune(&mut self, keep: EndpointId, _now_ms: u64) {
|
||||||
|
while self.authors.len() >= AUTHOR_MAP_CAP {
|
||||||
|
let Some(victim) = self
|
||||||
|
.authors
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, _)| **id != keep)
|
||||||
|
.min_by_key(|(_, b)| b.last_seen_ms)
|
||||||
|
.map(|(id, _)| *id)
|
||||||
|
else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.authors.remove(&victim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn author_count(&self) -> usize {
|
||||||
|
self.authors.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use iroh::SecretKey;
|
||||||
|
|
||||||
|
const T0: u64 = 1_000_000;
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
fn author() -> EndpointId {
|
||||||
|
SecretKey::generate().public()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn author_request_burst_then_refill_recovers() {
|
||||||
|
let mut b = AutoFetchBudget::new(T0);
|
||||||
|
let a = author();
|
||||||
|
// Tiny sizes so only the REQUEST buckets can bind.
|
||||||
|
for _ in 0..AUTHOR_REQ_BURST as usize {
|
||||||
|
assert!(b.admit(a, 1, T0));
|
||||||
|
}
|
||||||
|
assert!(!b.admit(a, 1, T0), "author request burst exhausted");
|
||||||
|
// One request refills after 10 s.
|
||||||
|
assert!(b.admit(a, 1, T0 + 10_000));
|
||||||
|
assert!(!b.admit(a, 1, T0 + 10_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn author_byte_budget_binds_and_recovers() {
|
||||||
|
let mut b = AutoFetchBudget::new(T0);
|
||||||
|
let a = author();
|
||||||
|
// 4 × 4 MiB = the full 16 MiB author byte burst (well under the
|
||||||
|
// 8-request burst, so bytes are the binding constraint).
|
||||||
|
for _ in 0..4 {
|
||||||
|
assert!(b.admit(a, 4 * MIB, T0));
|
||||||
|
}
|
||||||
|
assert!(!b.admit(a, 4 * MIB, T0), "author byte burst exhausted");
|
||||||
|
// 64 KiB/s → a 4 MiB image is affordable again after 64 s (which also
|
||||||
|
// refills 6 request tokens, so bytes stay the binding constraint).
|
||||||
|
assert!(!b.admit(a, 4 * MIB, T0 + 32_000));
|
||||||
|
assert!(b.admit(a, 4 * MIB, T0 + 64_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_budget_binds_across_authors_without_burning_author_tokens() {
|
||||||
|
let mut b = AutoFetchBudget::new(T0);
|
||||||
|
// Three authors × 16 MiB exhausts the 48 MiB session byte burst even
|
||||||
|
// though each author is within their own budget.
|
||||||
|
for _ in 0..3 {
|
||||||
|
let a = author();
|
||||||
|
for _ in 0..4 {
|
||||||
|
assert!(b.admit(a, 4 * MIB, T0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fresh = author();
|
||||||
|
assert!(!b.admit(fresh, 4 * MIB, T0), "session bytes exhausted");
|
||||||
|
// The rejection consumed NOTHING: once the session refills enough for
|
||||||
|
// one image (4 MiB / 128 KiB/s = 32 s), the fresh author's own full
|
||||||
|
// burst is intact and admits immediately.
|
||||||
|
assert!(b.admit(fresh, 4 * MIB, T0 + 32_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_request_bucket_binds_across_authors() {
|
||||||
|
let mut b = AutoFetchBudget::new(T0);
|
||||||
|
// 16 tiny requests from distinct authors exhaust the session request
|
||||||
|
// burst while every author bucket stays nearly full.
|
||||||
|
for _ in 0..SESSION_REQ_BURST as usize {
|
||||||
|
assert!(b.admit(author(), 1, T0));
|
||||||
|
}
|
||||||
|
assert!(!b.admit(author(), 1, T0), "session requests exhausted");
|
||||||
|
assert!(b.admit(author(), 1, T0 + 5_000), "one recovers after 5 s");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn author_map_stays_bounded_pruning_least_recent() {
|
||||||
|
let mut b = AutoFetchBudget::new(T0);
|
||||||
|
// Session request refill would bind over a naive loop; space the
|
||||||
|
// admissions out so only the map bound is under test.
|
||||||
|
let mut t = T0;
|
||||||
|
let first = author();
|
||||||
|
assert!(b.admit(first, 1, t));
|
||||||
|
for _ in 0..(AUTHOR_MAP_CAP + 10) {
|
||||||
|
t += 10_000;
|
||||||
|
assert!(b.admit(author(), 1, t));
|
||||||
|
assert!(b.author_count() <= AUTHOR_MAP_CAP);
|
||||||
|
}
|
||||||
|
assert!(b.author_count() <= AUTHOR_MAP_CAP);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,15 +202,24 @@ impl JitterBuffer {
|
|||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
// Gap with later packets already buffered: a packet was lost
|
// Gap with later packets already buffered: a packet was lost
|
||||||
// or reordered out of window. Conceal this frame via Opus PLC
|
// or reordered out of window. Try Opus in-band FEC from the
|
||||||
// and grow the cushion — the jitter beat our current delay.
|
// packet right after the gap; if that packet isn't buffered
|
||||||
|
// (burst loss) or FEC fails, fall back to plain PLC.
|
||||||
self.next_seq = Some(next.wrapping_add(1));
|
self.next_seq = Some(next.wrapping_add(1));
|
||||||
self.note_disruption();
|
self.note_disruption();
|
||||||
|
let (&smallest, next_payload) = self.packets.iter().next().expect("non-empty");
|
||||||
|
if fec_covers_gap(next, smallest) {
|
||||||
|
self.decoder
|
||||||
|
.decode_fec(next_payload)
|
||||||
|
.or_else(|_| self.decoder.decode(None))
|
||||||
|
.ok()
|
||||||
|
} else {
|
||||||
self.decoder.decode(None).ok()
|
self.decoder.decode(None).ok()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// True when nothing is buffered and playout is idle (talker silent).
|
/// True when nothing is buffered and playout is idle (talker silent).
|
||||||
pub fn is_idle(&self) -> bool {
|
pub fn is_idle(&self) -> bool {
|
||||||
@@ -218,11 +227,20 @@ impl JitterBuffer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Opus in-band FEC in packet N carries a low-fidelity copy of frame N-1 and
|
||||||
|
/// nothing else — a lost frame `next` is FEC-recoverable solely from packet
|
||||||
|
/// `next+1`. Any later successor's FEC data is a different frame's audio, and
|
||||||
|
/// splicing it into this gap plays sound from the wrong position; the caller
|
||||||
|
/// must conceal with plain PLC instead.
|
||||||
|
fn fec_covers_gap(next: u32, smallest_buffered: u32) -> bool {
|
||||||
|
smallest_buffered == next.wrapping_add(1)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::codec::AudioEncoder;
|
use crate::codec::opus_impl::{OpusDecoder, OpusEncoder, OpusParams};
|
||||||
use crate::codec::opus_impl::OpusEncoder;
|
use crate::codec::{AudioDecoder, AudioEncoder};
|
||||||
use opus::{Application, Channels};
|
use opus::{Application, Channels};
|
||||||
|
|
||||||
/// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`.
|
/// A real, decodable Opus packet for one 20ms mono frame at amplitude `amp`.
|
||||||
@@ -233,6 +251,32 @@ mod tests {
|
|||||||
enc.encode(&pcm).unwrap()
|
enc.encode(&pcm).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tone_frame(enc: &mut OpusEncoder, amp: i16, frame_index: usize) -> Vec<u8> {
|
||||||
|
let pcm: Vec<i16> = (0..FRAME_SAMPLES)
|
||||||
|
.map(|i| {
|
||||||
|
let sample_index = frame_index * FRAME_SAMPLES + i;
|
||||||
|
let t = sample_index as f32 / 48_000.0;
|
||||||
|
let fundamental = (t * 220.0 * 2.0 * std::f32::consts::PI).sin();
|
||||||
|
let harmonic = (t * 440.0 * 2.0 * std::f32::consts::PI).sin();
|
||||||
|
((fundamental * 0.7 + harmonic * 0.3) * amp as f32) as i16
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
enc.encode(&pcm).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rms_error(a: &[i16], b: &[i16]) -> f64 {
|
||||||
|
assert_eq!(a.len(), b.len());
|
||||||
|
let sum_sq: f64 = a
|
||||||
|
.iter()
|
||||||
|
.zip(b)
|
||||||
|
.map(|(&left, &right)| {
|
||||||
|
let diff = left as f64 - right as f64;
|
||||||
|
diff * diff
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
(sum_sq / a.len() as f64).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn buffers_then_plays_in_order() {
|
fn buffers_then_plays_in_order() {
|
||||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
@@ -289,6 +333,137 @@ mod tests {
|
|||||||
assert!(jb.pop_frame().is_none());
|
assert!(jb.pop_frame().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_in_band_fec_from_next_packet_for_gap() {
|
||||||
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
enc.apply_params(&OpusParams {
|
||||||
|
bitrate: 20_000,
|
||||||
|
inband_fec: true,
|
||||||
|
packet_loss_perc: 60,
|
||||||
|
dtx: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let dropped_seq = 5usize;
|
||||||
|
let amps = [1800, 1800, 1800, 1800, 1800, 12_000, 12_000, 12_000];
|
||||||
|
let packets: Vec<Vec<u8>> = amps
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(seq, amp)| tone_frame(&mut enc, amp, seq))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut expected_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||||
|
for packet in packets.iter().take(dropped_seq) {
|
||||||
|
expected_decoder.decode(Some(packet)).unwrap();
|
||||||
|
}
|
||||||
|
let expected_lost = expected_decoder
|
||||||
|
.decode(Some(&packets[dropped_seq]))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut plc_decoder = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||||
|
for packet in packets.iter().take(dropped_seq) {
|
||||||
|
plc_decoder.decode(Some(packet)).unwrap();
|
||||||
|
}
|
||||||
|
let pure_plc = plc_decoder.decode(None).unwrap();
|
||||||
|
|
||||||
|
let mut jb = JitterBuffer::new().unwrap();
|
||||||
|
for (seq, packet) in packets.iter().enumerate() {
|
||||||
|
if seq != dropped_seq {
|
||||||
|
jb.insert(seq as u32, packet.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..dropped_seq {
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
}
|
||||||
|
|
||||||
|
let recovered = jb.pop_frame().expect("gap should be reconstructed");
|
||||||
|
assert_eq!(recovered.len(), FRAME_SAMPLES);
|
||||||
|
assert!(
|
||||||
|
jb.packets.contains_key(&(dropped_seq as u32 + 1)),
|
||||||
|
"FEC source packet must remain buffered for normal decode"
|
||||||
|
);
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
|
||||||
|
let fec_error = rms_error(&recovered, &expected_lost);
|
||||||
|
let plc_error = rms_error(&pure_plc, &expected_lost);
|
||||||
|
assert!(
|
||||||
|
fec_error < plc_error * 0.75,
|
||||||
|
"FEC reconstruction should be materially closer than PLC (fec_error={fec_error}, plc_error={plc_error})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fec_covers_gap_only_for_the_immediate_successor() {
|
||||||
|
// Packet next+1 is the only one whose in-band FEC describes frame `next`.
|
||||||
|
assert!(fec_covers_gap(4, 5));
|
||||||
|
// A burst gap: the smallest survivor's FEC is some other frame's audio.
|
||||||
|
assert!(!fec_covers_gap(3, 5));
|
||||||
|
assert!(!fec_covers_gap(3, 3_000));
|
||||||
|
// Sequence wraparound still counts as adjacent.
|
||||||
|
assert!(fec_covers_gap(u32::MAX, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn burst_gap_falls_back_to_plc_not_wrong_position_fec() {
|
||||||
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
enc.apply_params(&OpusParams {
|
||||||
|
bitrate: 20_000,
|
||||||
|
inband_fec: true,
|
||||||
|
packet_loss_perc: 60,
|
||||||
|
dtx: false,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Frames 0..=6; 3 and 4 are lost as a burst, so when playout reaches
|
||||||
|
// seq 3 the smallest buffered packet is 5 — whose FEC data is frame 4,
|
||||||
|
// NOT frame 3. The buffer must conceal 3 with plain PLC rather than
|
||||||
|
// splice frame 4's audio into the wrong position.
|
||||||
|
let packets: Vec<Vec<u8>> = (0..7).map(|seq| tone_frame(&mut enc, 8_000, seq)).collect();
|
||||||
|
|
||||||
|
// Twin decoder replaying the exact call sequence the jitter buffer
|
||||||
|
// should make for seq 3: decode 0,1,2 then a plain PLC conceal.
|
||||||
|
let mut twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||||
|
for packet in packets.iter().take(3) {
|
||||||
|
twin.decode(Some(packet)).unwrap();
|
||||||
|
}
|
||||||
|
let expected_plc = twin.decode(None).unwrap();
|
||||||
|
|
||||||
|
let mut jb = JitterBuffer::new().unwrap();
|
||||||
|
for (seq, packet) in packets.iter().enumerate() {
|
||||||
|
if seq != 3 && seq != 4 {
|
||||||
|
jb.insert(seq as u32, packet.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seq 3: burst gap — bit-exact PLC (same decoder state, same inputs),
|
||||||
|
// which decode_fec(packet 5) could never produce.
|
||||||
|
let concealed = jb.pop_frame().expect("gap should be concealed");
|
||||||
|
assert_eq!(concealed, expected_plc, "burst gap must use plain PLC");
|
||||||
|
|
||||||
|
// Seq 4: packet 5 IS the immediate successor, so its FEC data is
|
||||||
|
// frame 4's audio — the correctly-positioned recovery still applies.
|
||||||
|
let recovered = jb
|
||||||
|
.pop_frame()
|
||||||
|
.expect("adjacent gap should be reconstructed");
|
||||||
|
let mut fec_twin = OpusDecoder::new(48000, Channels::Mono, FRAME_SAMPLES).unwrap();
|
||||||
|
for packet in packets.iter().take(3) {
|
||||||
|
fec_twin.decode(Some(packet)).unwrap();
|
||||||
|
}
|
||||||
|
fec_twin.decode(None).unwrap();
|
||||||
|
let expected_fec = fec_twin.decode_fec(&packets[5]).unwrap();
|
||||||
|
assert_eq!(recovered, expected_fec, "adjacent gap should still use FEC");
|
||||||
|
|
||||||
|
// Then 5 and 6 play normally.
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
assert_eq!(jb.pop_frame().map(|frame| frame.len()), Some(FRAME_SAMPLES));
|
||||||
|
assert!(jb.pop_frame().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drops_packets_already_played() {
|
fn drops_packets_already_played() {
|
||||||
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
let mut enc = OpusEncoder::new(48000, Channels::Mono, Application::Voip).unwrap();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::config::{NetworkMode, RecordingMode};
|
use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
|
||||||
use crate::friends::Friend;
|
use crate::friends::Friend;
|
||||||
use crate::network::PeerState;
|
use crate::network::PeerState;
|
||||||
use crate::presence::{FriendPresence, PresenceMode};
|
use crate::presence::{FriendPresence, PresenceMode};
|
||||||
@@ -9,7 +9,15 @@ pub enum CoreCommand {
|
|||||||
/// Join a room. `ticket` is "create" (or empty) to mint a fresh room, else a
|
/// 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
|
/// 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).
|
/// 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,
|
Leave,
|
||||||
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
/// Orderly app shutdown: finalize recordings, leave any active room, stop local
|
||||||
/// audio/screen-share work, close the persistent network stack, then ack with
|
/// audio/screen-share work, close the persistent network stack, then ack with
|
||||||
@@ -41,38 +49,100 @@ pub enum CoreCommand {
|
|||||||
/// Start/stop a standalone capture-only stream that reports the raw mic
|
/// Start/stop a standalone capture-only stream that reports the raw mic
|
||||||
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
|
/// level via [`UiEvent::MicLevel`], for gate calibration outside a call.
|
||||||
/// Ignored while a room session is active (the in-call meter covers that).
|
/// 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,
|
/// Set the relay/discovery posture. Takes effect on the next room join,
|
||||||
/// since the endpoint is (re)built then.
|
/// since the endpoint is (re)built then.
|
||||||
SetNetworkMode(NetworkMode),
|
SetNetworkMode(NetworkMode),
|
||||||
|
/// Set the Opus encoder / network-resilience profile (W12). Applies live to
|
||||||
|
/// the running capture encoder, and to the next call's encoder. Sent at
|
||||||
|
/// startup from config and whenever the user changes it.
|
||||||
|
SetAudioProfile(AudioProfile),
|
||||||
/// Start/stop recording the call to a local WAV (your mic + the incoming
|
/// Start/stop recording the call to a local WAV (your mic + the incoming
|
||||||
/// mix). No-op start if already recording / not in a call.
|
/// mix). No-op start if already recording / not in a call.
|
||||||
SetRecording(bool),
|
SetRecording(bool),
|
||||||
/// Set what a recording captures (mixed / per-peer stems / both). Takes
|
/// Set what a recording captures (mixed / per-peer stems / both). Takes
|
||||||
/// effect on the next recording start. Sent at startup from config.
|
/// effect on the next recording start. Sent at startup from config.
|
||||||
SetRecordingMode(RecordingMode),
|
SetRecordingMode(RecordingMode),
|
||||||
/// Broadcast a room text-chat message. No-op when not in a call.
|
/// Broadcast a room text-chat message. `local_id` is the app's local-only
|
||||||
SendChat(String),
|
/// handle for this send — it never goes on the wire; the core echoes it back
|
||||||
|
/// in [`UiEvent::ChatSendResult`] so the UI can mark the matching local echo
|
||||||
|
/// honestly (chat-hardening Phase 5). Not being in a call is a FAILURE
|
||||||
|
/// result, not a silent no-op.
|
||||||
|
SendChat {
|
||||||
|
local_id: u64,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
/// Send a chat message carrying a file attachment. The app has already read +
|
/// 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
|
/// capped the file and built the descriptor; core makes the bytes available
|
||||||
/// on the file plane and broadcasts the descriptor.
|
/// on the file plane and broadcasts the descriptor. `local_id` as in
|
||||||
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> },
|
/// [`CoreCommand::SendChat`].
|
||||||
|
SendChatFile {
|
||||||
|
local_id: u64,
|
||||||
|
text: String,
|
||||||
|
attachment: crate::files::ChatAttachment,
|
||||||
|
/// Shared, not owned: the same allocation is retained by the UI cache
|
||||||
|
/// and handed to the serve store, so a 25 MiB attachment is held once,
|
||||||
|
/// not copied across UI / command queue / serve store (Phase 3C).
|
||||||
|
data: std::sync::Arc<Vec<u8>>,
|
||||||
|
},
|
||||||
/// Fetch a received attachment's bytes from its sender over the file plane
|
/// 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
|
/// (used for on-demand file/chip downloads; images are auto-fetched on
|
||||||
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
|
/// 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>>,
|
||||||
|
},
|
||||||
|
/// Drop a music blob that is no longer current-or-next.
|
||||||
|
ForgetMusicTrack(crate::files::AttachmentId),
|
||||||
|
/// Set (or clear) our broadcast music timeline and re-announce presence.
|
||||||
|
SetMusicPresence(Option<crate::network::MusicPresence>),
|
||||||
|
/// Fetch a source peer's current track bytes after tuning into them.
|
||||||
|
FetchMusic {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
size: u64,
|
||||||
|
},
|
||||||
|
/// Fetch a source peer's advertised next track bytes before it becomes current.
|
||||||
|
PrefetchMusic {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
size: u64,
|
||||||
|
},
|
||||||
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
|
||||||
/// Sent at startup so screen-share can resolve the binary.
|
/// Sent at startup so screen-share can resolve the binary.
|
||||||
SetPixelpassPath(Option<String>),
|
SetPixelpassPath(Option<String>),
|
||||||
|
/// Enumerate apps currently producing audio (for the screen-share audio
|
||||||
|
/// picker, A23). Replies with [`UiEvent::AudioAppsListed`]. Cheap shell-out;
|
||||||
|
/// safe to call each time the picker opens.
|
||||||
|
ListAudioApps,
|
||||||
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
/// Start sharing our screen: spawn a pixelpass host and announce its ticket
|
||||||
/// on our presence so the room can watch. No-op when not in a call.
|
/// on our presence so the room can watch. No-op when not in a call.
|
||||||
StartScreenShare,
|
/// `audio_app` selects which app's audio to capture: `Some(name)` captures
|
||||||
|
/// only that app (avoiding the call-loopback echo, A23); `None` shares the
|
||||||
|
/// whole desktop audio (the legacy behavior).
|
||||||
|
StartScreenShare {
|
||||||
|
audio_app: Option<String>,
|
||||||
|
settings: ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
|
},
|
||||||
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
/// Stop sharing our screen: kill the pixelpass host and clear the presence
|
||||||
/// ticket. No-op when not sharing.
|
/// ticket. No-op when not sharing.
|
||||||
StopScreenShare,
|
StopScreenShare,
|
||||||
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
/// Watch a peer's screen share: spawn a pixelpass viewer for `ticket` and
|
||||||
/// open it in a local player.
|
/// open it in a local player.
|
||||||
ViewShare(String),
|
ViewShare {
|
||||||
|
ticket: String,
|
||||||
|
settings: ScreenShareSettings,
|
||||||
|
},
|
||||||
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
|
/// Mint a fresh persistent identity (W7), discarding the old one. Takes effect
|
||||||
/// on the next room join (the endpoint is rebuilt then). The core replies with
|
/// on the next room join (the endpoint is rebuilt then). The core replies with
|
||||||
/// an updated [`UiEvent::IdentityStatus`].
|
/// an updated [`UiEvent::IdentityStatus`].
|
||||||
@@ -80,77 +150,511 @@ pub enum CoreCommand {
|
|||||||
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and
|
/// Add a friend (W7). Core owns the friends store: it mutates + persists it and
|
||||||
/// replies with [`UiEvent::FriendsUpdated`]. `addr` seeds `last_addr` if known
|
/// 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.
|
/// (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).
|
/// Remove a friend by id (W7).
|
||||||
RemoveFriend(EndpointId),
|
RemoveFriend(EndpointId),
|
||||||
/// Locally rename a friend (W7).
|
/// Locally rename a friend (W7).
|
||||||
RenameFriend(EndpointId, String),
|
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 /
|
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
|
||||||
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
||||||
/// startup from config and whenever the user changes it.
|
/// startup from config and whenever the user changes it.
|
||||||
SetPresenceMode(PresenceMode),
|
SetPresenceMode(PresenceMode),
|
||||||
|
/// Toggle broadcasting the detected game as presence (game detection). Opt-in,
|
||||||
|
/// default OFF. Enabling immediately publishes the current game; disabling
|
||||||
|
/// immediately publishes `game: None`. Detection for the local background runs
|
||||||
|
/// regardless. Sent at startup from config and on user toggle.
|
||||||
|
SetGamePresenceEnabled(bool),
|
||||||
|
/// Set the manual game-detection override (`Auto` / `None` / a forced game).
|
||||||
|
/// Forwarded to the detector and applied immediately (bypasses debounce).
|
||||||
|
SetGameOverride(crate::game::ManualOverride),
|
||||||
|
/// Replace the user process→display-name mappings used by the non-Steam
|
||||||
|
/// detection fallback. Sent at startup from config and after Settings edits.
|
||||||
|
SetGameProcessMap(std::collections::BTreeMap<String, String>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DeliveryClass {
|
||||||
|
Reliable,
|
||||||
|
BestEffort,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum CoalesceKey {
|
||||||
|
InputVolume,
|
||||||
|
OutputVolume,
|
||||||
|
NoiseGate,
|
||||||
|
PeerVolume(EndpointId),
|
||||||
|
PeerPan(EndpointId),
|
||||||
|
PeerGate(EndpointId),
|
||||||
|
PeerEq(EndpointId),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a command by how bad it is to drop it. Discrete, human-paced user
|
||||||
|
/// actions are Reliable (must land). The only high-frequency commands are the
|
||||||
|
/// continuous audio sliders, where dropping intermediate values is harmless;
|
||||||
|
/// those are BestEffort.
|
||||||
|
pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
||||||
|
match cmd {
|
||||||
|
CoreCommand::SetPeerVolume(_, _)
|
||||||
|
| CoreCommand::SetPeerPan(_, _)
|
||||||
|
| CoreCommand::SetPeerGate(_, _)
|
||||||
|
| CoreCommand::SetPeerEq(_, _)
|
||||||
|
| CoreCommand::SetInputVolume(_)
|
||||||
|
| CoreCommand::SetOutputVolume(_)
|
||||||
|
| CoreCommand::SetNoiseGateThreshold(_) => DeliveryClass::BestEffort,
|
||||||
|
|
||||||
|
CoreCommand::Join {
|
||||||
|
name: _,
|
||||||
|
ticket: _,
|
||||||
|
room_name: _,
|
||||||
|
input_device: _,
|
||||||
|
output_device: _,
|
||||||
|
echo_cancellation: _,
|
||||||
|
avatar: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::Leave
|
||||||
|
| CoreCommand::Shutdown
|
||||||
|
| CoreCommand::ToggleMute
|
||||||
|
| CoreCommand::SetAvatar(_)
|
||||||
|
| CoreCommand::ToggleDeafen
|
||||||
|
| CoreCommand::SetPttMode(_)
|
||||||
|
| CoreCommand::SetPttActive(_)
|
||||||
|
| CoreCommand::SetPeerMuted(_, _)
|
||||||
|
| CoreCommand::SetMicMonitor {
|
||||||
|
enabled: _,
|
||||||
|
input_device: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SetNetworkMode(_)
|
||||||
|
| CoreCommand::SetAudioProfile(_)
|
||||||
|
| CoreCommand::SetRecording(_)
|
||||||
|
| CoreCommand::SetRecordingMode(_)
|
||||||
|
| CoreCommand::SendChat {
|
||||||
|
local_id: _,
|
||||||
|
text: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SendChatFile {
|
||||||
|
local_id: _,
|
||||||
|
text: _,
|
||||||
|
attachment: _,
|
||||||
|
data: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::FetchAttachment {
|
||||||
|
from: _,
|
||||||
|
attachment: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::ServeMusicTrack { id: _, data: _ }
|
||||||
|
| CoreCommand::ForgetMusicTrack(_)
|
||||||
|
| CoreCommand::SetMusicPresence(_)
|
||||||
|
| CoreCommand::FetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::PrefetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SetPixelpassPath(_)
|
||||||
|
| CoreCommand::ListAudioApps
|
||||||
|
| CoreCommand::StartScreenShare {
|
||||||
|
audio_app: _,
|
||||||
|
settings: _,
|
||||||
|
quality: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::StopScreenShare
|
||||||
|
| CoreCommand::ViewShare {
|
||||||
|
ticket: _,
|
||||||
|
settings: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::RegenerateIdentity
|
||||||
|
| CoreCommand::AddFriend {
|
||||||
|
id: _,
|
||||||
|
name: _,
|
||||||
|
addr: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::RemoveFriend(_)
|
||||||
|
| CoreCommand::RenameFriend(_, _)
|
||||||
|
| CoreCommand::RefreshFriends
|
||||||
|
| CoreCommand::SetPresenceMode(_)
|
||||||
|
| CoreCommand::SetGamePresenceEnabled(_)
|
||||||
|
| CoreCommand::SetGameOverride(_)
|
||||||
|
| CoreCommand::SetGameProcessMap(_) => DeliveryClass::Reliable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coalescing bucket for high-frequency continuous controls. A key exists
|
||||||
|
/// exactly for [`DeliveryClass::BestEffort`] commands.
|
||||||
|
pub fn coalesce_key(cmd: &CoreCommand) -> Option<CoalesceKey> {
|
||||||
|
match cmd {
|
||||||
|
CoreCommand::SetPeerVolume(peer_id, _) => Some(CoalesceKey::PeerVolume(*peer_id)),
|
||||||
|
CoreCommand::SetPeerPan(peer_id, _) => Some(CoalesceKey::PeerPan(*peer_id)),
|
||||||
|
CoreCommand::SetPeerGate(peer_id, _) => Some(CoalesceKey::PeerGate(*peer_id)),
|
||||||
|
CoreCommand::SetPeerEq(peer_id, _) => Some(CoalesceKey::PeerEq(*peer_id)),
|
||||||
|
CoreCommand::SetInputVolume(_) => Some(CoalesceKey::InputVolume),
|
||||||
|
CoreCommand::SetOutputVolume(_) => Some(CoalesceKey::OutputVolume),
|
||||||
|
CoreCommand::SetNoiseGateThreshold(_) => Some(CoalesceKey::NoiseGate),
|
||||||
|
|
||||||
|
CoreCommand::Join {
|
||||||
|
name: _,
|
||||||
|
ticket: _,
|
||||||
|
room_name: _,
|
||||||
|
input_device: _,
|
||||||
|
output_device: _,
|
||||||
|
echo_cancellation: _,
|
||||||
|
avatar: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::Leave
|
||||||
|
| CoreCommand::Shutdown
|
||||||
|
| CoreCommand::ToggleMute
|
||||||
|
| CoreCommand::SetAvatar(_)
|
||||||
|
| CoreCommand::ToggleDeafen
|
||||||
|
| CoreCommand::SetPttMode(_)
|
||||||
|
| CoreCommand::SetPttActive(_)
|
||||||
|
| CoreCommand::SetPeerMuted(_, _)
|
||||||
|
| CoreCommand::SetMicMonitor {
|
||||||
|
enabled: _,
|
||||||
|
input_device: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SetNetworkMode(_)
|
||||||
|
| CoreCommand::SetAudioProfile(_)
|
||||||
|
| CoreCommand::SetRecording(_)
|
||||||
|
| CoreCommand::SetRecordingMode(_)
|
||||||
|
| CoreCommand::SendChat {
|
||||||
|
local_id: _,
|
||||||
|
text: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SendChatFile {
|
||||||
|
local_id: _,
|
||||||
|
text: _,
|
||||||
|
attachment: _,
|
||||||
|
data: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::FetchAttachment {
|
||||||
|
from: _,
|
||||||
|
attachment: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::ServeMusicTrack { id: _, data: _ }
|
||||||
|
| CoreCommand::ForgetMusicTrack(_)
|
||||||
|
| CoreCommand::SetMusicPresence(_)
|
||||||
|
| CoreCommand::FetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::PrefetchMusic {
|
||||||
|
from: _,
|
||||||
|
id: _,
|
||||||
|
size: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::SetPixelpassPath(_)
|
||||||
|
| CoreCommand::ListAudioApps
|
||||||
|
| CoreCommand::StartScreenShare {
|
||||||
|
audio_app: _,
|
||||||
|
settings: _,
|
||||||
|
quality: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::StopScreenShare
|
||||||
|
| CoreCommand::ViewShare {
|
||||||
|
ticket: _,
|
||||||
|
settings: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::RegenerateIdentity
|
||||||
|
| CoreCommand::AddFriend {
|
||||||
|
id: _,
|
||||||
|
name: _,
|
||||||
|
addr: _,
|
||||||
|
}
|
||||||
|
| CoreCommand::RemoveFriend(_)
|
||||||
|
| CoreCommand::RenameFriend(_, _)
|
||||||
|
| CoreCommand::RefreshFriends
|
||||||
|
| CoreCommand::SetPresenceMode(_)
|
||||||
|
| CoreCommand::SetGamePresenceEnabled(_)
|
||||||
|
| CoreCommand::SetGameOverride(_)
|
||||||
|
| CoreCommand::SetGameProcessMap(_) => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum UiEvent {
|
pub enum UiEvent {
|
||||||
RoomJoined { ticket: String, self_id: String },
|
RoomJoined {
|
||||||
|
ticket: String,
|
||||||
|
self_id: String,
|
||||||
|
},
|
||||||
RoomLeft,
|
RoomLeft,
|
||||||
PeerJoined { id: EndpointId, state: PeerState },
|
/// Clear room-scoped UI state after a failed in-call room switch, without a
|
||||||
PeerLeft { id: EndpointId },
|
/// leave chime. The persistent identity remains unchanged.
|
||||||
|
RoomReset,
|
||||||
|
PeerJoined {
|
||||||
|
id: EndpointId,
|
||||||
|
state: PeerState,
|
||||||
|
},
|
||||||
|
PeerLeft {
|
||||||
|
id: EndpointId,
|
||||||
|
},
|
||||||
/// The fixed reconnect grace expired and bounded background gossip recovery
|
/// The fixed reconnect grace expired and bounded background gossip recovery
|
||||||
/// has started. This is non-terminal and must not play the failure chime.
|
/// has started. This is non-terminal and must not play the failure chime.
|
||||||
PeerRecoveryStarted { id: EndpointId },
|
PeerRecoveryStarted {
|
||||||
PeerConnectionFailed { id: EndpointId },
|
id: EndpointId,
|
||||||
PeerUpdated { id: EndpointId, state: PeerState },
|
},
|
||||||
|
PeerConnectionFailed {
|
||||||
|
id: EndpointId,
|
||||||
|
},
|
||||||
|
PeerUpdated {
|
||||||
|
id: EndpointId,
|
||||||
|
state: PeerState,
|
||||||
|
},
|
||||||
/// Audio link to a peer is being (re)established — show a connecting state.
|
/// 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.
|
/// Audio link to a peer is up and carrying audio.
|
||||||
PeerConnected { id: EndpointId },
|
PeerConnected {
|
||||||
|
id: EndpointId,
|
||||||
|
},
|
||||||
AudioLevels(Vec<(EndpointId, f32)>),
|
AudioLevels(Vec<(EndpointId, f32)>),
|
||||||
|
/// Periodic per-peer connection transparency snapshot (~1/sec): path type
|
||||||
|
/// (direct/relay), RTT, and window loss/bitrate for every peer with a live
|
||||||
|
/// audio link. A FULL replacement each time — a peer absent from the list
|
||||||
|
/// has no live link right now, so its badge should disappear.
|
||||||
|
ConnectionStats(Vec<(EndpointId, crate::core::connstats::PeerConnInfo)>),
|
||||||
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
/// Raw (pre-gate, pre-mute) normalized RMS of the local mic, `0.0..=1.0`,
|
||||||
/// for the settings level meter. Throttled to ~10/sec.
|
/// for the settings level meter. Throttled to ~10/sec.
|
||||||
MicLevel(f32),
|
MicLevel(f32),
|
||||||
/// Call recording started; carries the absolute WAV path being written.
|
/// Call recording started; carries the absolute WAV path being written.
|
||||||
RecordingStarted { path: String },
|
RecordingStarted {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
/// Call recording stopped; carries the finished WAV path.
|
/// Call recording stopped; carries the finished WAV path.
|
||||||
RecordingStopped { path: String },
|
RecordingStopped {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
|
/// The outcome of one locally initiated chat send (chat-hardening Phase 5).
|
||||||
|
/// `error = None` means our signed broadcast was handed to the gossip swarm
|
||||||
|
/// — deliberately NOT a delivery/read receipt; PeerSpeak has no peer
|
||||||
|
/// acknowledgements. `local_id` is the app's own handle from the
|
||||||
|
/// `SendChat`/`SendChatFile` command and never appears on the wire.
|
||||||
|
ChatSendResult {
|
||||||
|
local_id: u64,
|
||||||
|
error: Option<String>,
|
||||||
|
},
|
||||||
/// A room text-chat message arrived from a peer (never our own — local
|
/// 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
|
/// messages are echoed by the UI on send). `from` is the sender's node id
|
||||||
/// string, used to key their avatar (W4).
|
/// 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
|
/// An attachment's bytes are now available (auto-fetched for images, or
|
||||||
/// fetched on demand for files). Keyed by attachment id so the UI can match
|
/// fetched on demand for files). Keyed by `(from, id)`: the id is
|
||||||
/// it to the chat entry.
|
/// attacker-chosen, so a malicious peer can reuse a victim's id — the author
|
||||||
AttachmentReady { id: crate::files::AttachmentId, data: Vec<u8> },
|
/// disambiguates whose bytes these are and stops content aliasing (Tier C
|
||||||
|
/// F-12).
|
||||||
|
AttachmentReady {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
data: std::sync::Arc<Vec<u8>>,
|
||||||
|
},
|
||||||
|
/// An attachment fetch task was spawned (auto or on demand). Lets the UI
|
||||||
|
/// show a real "loading" state instead of inferring it from cache absence —
|
||||||
|
/// absence now means NOT fetched (e.g. auto-fetch was skipped), which
|
||||||
|
/// renders a Load button rather than an indefinite "loading…" (Phase 3B).
|
||||||
|
AttachmentFetchStarted {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
},
|
||||||
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
|
||||||
AttachmentFailed { 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>,
|
||||||
|
},
|
||||||
|
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
|
||||||
|
MusicPrefetched {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
data: Vec<u8>,
|
||||||
|
},
|
||||||
|
/// A music-track fetch failed (source gone, too large, etc.).
|
||||||
|
MusicFetchFailed {
|
||||||
|
from: EndpointId,
|
||||||
|
id: crate::files::AttachmentId,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
|
/// The apps currently producing audio, for the screen-share audio picker
|
||||||
|
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
|
||||||
|
/// playing or enumeration isn't available. `app_audio_supported` reports
|
||||||
|
/// whether the resolved pixelpass understands `--strict-audio`: when `false`
|
||||||
|
/// (an older pixelpass) the picker must offer whole-desktop audio only, since
|
||||||
|
/// a per-app share would pass a flag that older binary rejects (audit P2).
|
||||||
|
AudioAppsListed {
|
||||||
|
apps: Vec<String>,
|
||||||
|
app_audio_supported: bool,
|
||||||
|
},
|
||||||
/// Our own screen share started; the UI flips the Share button to "Stop".
|
/// Our own screen share started; the UI flips the Share button to "Stop".
|
||||||
ScreenShareStarted,
|
ScreenShareStarted,
|
||||||
/// Our own screen share stopped (or failed to start).
|
/// Our own screen share stopped (or failed to start).
|
||||||
ScreenShareStopped,
|
ScreenShareStopped,
|
||||||
|
/// Per-app screen-share audio routing state (A23). `true` = the app we chose
|
||||||
|
/// is now reaching viewers; `false` = its audio stopped, so under our strict
|
||||||
|
/// run viewers currently hear silence. The UI shows a transient warning while
|
||||||
|
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
|
||||||
|
ShareAudioActive(bool),
|
||||||
|
/// A validly signed peer cannot be admitted because its gossip timestamp is
|
||||||
|
/// outside the replay freshness window. `peer_ahead` describes the peer's
|
||||||
|
/// sender-stamped timestamp relative to this machine's clock.
|
||||||
|
ClockSkewWarning {
|
||||||
|
skew_secs: u64,
|
||||||
|
peer_ahead: bool,
|
||||||
|
},
|
||||||
/// Our node identity (W7): the current node id string, and whether it is
|
/// 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 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
|
/// `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,
|
/// 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.
|
/// since the id (and thus friend recognition) won't survive the next launch.
|
||||||
/// `error` carries the reason when degraded, for the UI explainer.
|
/// `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
|
/// 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
|
/// 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
|
/// 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
|
/// (malformed) — the GUI shows a degraded warning and disables edits so we never
|
||||||
/// overwrite the damaged file (backlog A16).
|
/// 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
|
/// 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
|
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||||
/// scheduler; absence of a recent event = treat as offline.
|
/// 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
|
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||||
/// persist this so its presence picker matches the endpoint's discovery state.
|
/// persist this so its presence picker matches the endpoint's discovery state.
|
||||||
PresenceModeReverted { mode: PresenceMode },
|
PresenceModeReverted {
|
||||||
|
mode: PresenceMode,
|
||||||
|
},
|
||||||
|
/// The locally-detected running game changed (game detection). Carries the
|
||||||
|
/// debounced `DetectedGame` (id + display name + source) or `None` when nothing
|
||||||
|
/// is detected. The GUI uses the stable `id` to switch the per-game background
|
||||||
|
/// (W18) and may show a local "Playing …" indicator. Emitted regardless of
|
||||||
|
/// whether game presence is being broadcast — the broadcast is core's own job.
|
||||||
|
GameChanged(Option<crate::game::DetectedGame>),
|
||||||
/// Core finished orderly app shutdown and the GUI can exit.
|
/// Core finished orderly app shutdown and the GUI can exit.
|
||||||
ShutdownComplete,
|
ShutdownComplete,
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{CoalesceKey, CoreCommand, DeliveryClass, coalesce_key, delivery_class};
|
||||||
|
use crate::audio::eq::EqSettings;
|
||||||
|
use crate::presence::PresenceMode;
|
||||||
|
use iroh::{EndpointId, SecretKey};
|
||||||
|
|
||||||
|
fn endpoint_id() -> EndpointId {
|
||||||
|
SecretKey::generate().public()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn continuous_audio_controls_are_best_effort() {
|
||||||
|
let peer = endpoint_id();
|
||||||
|
let commands = [
|
||||||
|
(
|
||||||
|
CoreCommand::SetPeerVolume(peer, 0.7),
|
||||||
|
CoalesceKey::PeerVolume(peer),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CoreCommand::SetPeerPan(peer, -0.2),
|
||||||
|
CoalesceKey::PeerPan(peer),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CoreCommand::SetPeerGate(peer, 0.1),
|
||||||
|
CoalesceKey::PeerGate(peer),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CoreCommand::SetPeerEq(peer, EqSettings::default()),
|
||||||
|
CoalesceKey::PeerEq(peer),
|
||||||
|
),
|
||||||
|
(CoreCommand::SetInputVolume(0.8), CoalesceKey::InputVolume),
|
||||||
|
(CoreCommand::SetOutputVolume(0.9), CoalesceKey::OutputVolume),
|
||||||
|
(
|
||||||
|
CoreCommand::SetNoiseGateThreshold(0.02),
|
||||||
|
CoalesceKey::NoiseGate,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (cmd, key) in commands {
|
||||||
|
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
|
||||||
|
assert_eq!(coalesce_key(&cmd), Some(key));
|
||||||
|
assert_eq!(
|
||||||
|
coalesce_key(&cmd).is_some(),
|
||||||
|
delivery_class(&cmd) == DeliveryClass::BestEffort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discrete_user_actions_are_reliable() {
|
||||||
|
let peer = endpoint_id();
|
||||||
|
let commands = [
|
||||||
|
CoreCommand::ToggleMute,
|
||||||
|
CoreCommand::SetPttActive(false),
|
||||||
|
CoreCommand::Leave,
|
||||||
|
CoreCommand::RegenerateIdentity,
|
||||||
|
CoreCommand::Join {
|
||||||
|
name: "Peer".to_string(),
|
||||||
|
ticket: "create".to_string(),
|
||||||
|
room_name: "Room".to_string(),
|
||||||
|
input_device: None,
|
||||||
|
output_device: None,
|
||||||
|
echo_cancellation: true,
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
},
|
||||||
|
CoreCommand::SetPeerMuted(peer, true),
|
||||||
|
CoreCommand::SetPresenceMode(PresenceMode::Normal),
|
||||||
|
CoreCommand::SetAudioProfile(crate::config::AudioProfile::BadNetwork),
|
||||||
|
CoreCommand::SendChat {
|
||||||
|
local_id: 1,
|
||||||
|
text: "hello".to_string(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
|
||||||
|
assert_eq!(coalesce_key(&cmd), None);
|
||||||
|
assert_eq!(
|
||||||
|
coalesce_key(&cmd).is_some(),
|
||||||
|
delivery_class(&cmd) == DeliveryClass::BestEffort
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,27 @@ fn recovery_delay(attempt: usize) -> Duration {
|
|||||||
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
RECOVERY_DELAYS[attempt.min(RECOVERY_DELAYS.len() - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Terminal retry budget for background recovery. After this many failed attempts
|
||||||
|
/// the coordinator gives up: it drops the entry, frees the active slot, and signals
|
||||||
|
/// the event task to forget the retained address (Tier C recovery-identity cap).
|
||||||
|
///
|
||||||
|
/// With the [`RECOVERY_DELAYS`] backoff this is roughly seven minutes of dialing
|
||||||
|
/// (1+2+4+8+15+30+60s, then 60s steps), far beyond any normal transient outage. A
|
||||||
|
/// genuine peer returning after a longer outage still rejoins on its own via a
|
||||||
|
/// gossip announce, so giving up only stops us from dialing a peer that is not
|
||||||
|
/// coming back — it does not break legitimate reconnect-after-outage.
|
||||||
|
const RECOVERY_TERMINAL_ATTEMPTS: usize = 12;
|
||||||
|
|
||||||
|
/// Capacity of the terminal-eviction notification channel. Bounded; on the rare
|
||||||
|
/// event of saturation the entry is still removed (the dial work stops) and only
|
||||||
|
/// the retained-address forget is skipped, which the per-topic retain cap bounds.
|
||||||
|
const RECOVERY_TERMINAL_CAPACITY: usize = 64;
|
||||||
|
|
||||||
|
/// Whether `attempt` completed recoveries have exhausted the terminal budget.
|
||||||
|
fn recovery_is_terminal(attempt: usize, max_attempts: usize) -> bool {
|
||||||
|
attempt >= max_attempts
|
||||||
|
}
|
||||||
|
|
||||||
enum RecoveryCommand {
|
enum RecoveryCommand {
|
||||||
Start {
|
Start {
|
||||||
peer_id: EndpointId,
|
peer_id: EndpointId,
|
||||||
@@ -60,19 +81,24 @@ pub(super) struct RecoveryCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RecoveryCoordinator {
|
impl RecoveryCoordinator {
|
||||||
pub(super) fn spawn(room_state: Arc<IrohGossipState>) -> (Self, JoinHandle<()>) {
|
pub(super) fn spawn(
|
||||||
|
room_state: Arc<IrohGossipState>,
|
||||||
|
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
||||||
Self::spawn_inner(room_state)
|
Self::spawn_inner(room_state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_inner(room_state: Arc<dyn RecoveryRoom>) -> (Self, JoinHandle<()>) {
|
fn spawn_inner(
|
||||||
|
room_state: Arc<dyn RecoveryRoom>,
|
||||||
|
) -> (Self, JoinHandle<()>, mpsc::Receiver<EndpointId>) {
|
||||||
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
let (tx, rx) = mpsc::channel(RECOVERY_COMMAND_CAPACITY);
|
||||||
|
let (terminal_tx, terminal_rx) = mpsc::channel(RECOVERY_TERMINAL_CAPACITY);
|
||||||
let active = Arc::new(Mutex::new(HashSet::new()));
|
let active = Arc::new(Mutex::new(HashSet::new()));
|
||||||
let handle = Self {
|
let handle = Self {
|
||||||
tx,
|
tx,
|
||||||
active: active.clone(),
|
active: active.clone(),
|
||||||
};
|
};
|
||||||
let task = tokio::spawn(run_coordinator(room_state, active, rx));
|
let task = tokio::spawn(run_coordinator(room_state, active, rx, terminal_tx));
|
||||||
(handle, task)
|
(handle, task, terminal_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
/// Reserve one recovery slot before grace-expiry teardown begins. Returns
|
||||||
@@ -116,6 +142,7 @@ async fn run_coordinator(
|
|||||||
room_state: Arc<dyn RecoveryRoom>,
|
room_state: Arc<dyn RecoveryRoom>,
|
||||||
active: Arc<Mutex<HashSet<EndpointId>>>,
|
active: Arc<Mutex<HashSet<EndpointId>>>,
|
||||||
mut rx: mpsc::Receiver<RecoveryCommand>,
|
mut rx: mpsc::Receiver<RecoveryCommand>,
|
||||||
|
terminal_tx: mpsc::Sender<EndpointId>,
|
||||||
) {
|
) {
|
||||||
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
let mut entries: HashMap<EndpointId, RecoveryEntry> = HashMap::new();
|
||||||
|
|
||||||
@@ -155,9 +182,24 @@ async fn run_coordinator(
|
|||||||
entries.remove(&peer_id);
|
entries.remove(&peer_id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(entry) = entries.get_mut(&peer_id) {
|
// Advance the backoff, then check the terminal budget.
|
||||||
|
// `attempt` counts completed attempts, so the delay
|
||||||
|
// uses the current value before it is incremented.
|
||||||
|
let terminal = if let Some(entry) = entries.get_mut(&peer_id) {
|
||||||
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
entry.next_attempt = scheduled_at + recovery_delay(entry.attempt);
|
||||||
entry.attempt = entry.attempt.saturating_add(1);
|
entry.attempt = entry.attempt.saturating_add(1);
|
||||||
|
recovery_is_terminal(entry.attempt, RECOVERY_TERMINAL_ATTEMPTS)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
if terminal {
|
||||||
|
// Give up on a peer that has not returned within the
|
||||||
|
// budget: drop its entry, free the active slot, and
|
||||||
|
// signal the event task to forget its retained
|
||||||
|
// address so the per-topic retain table drains.
|
||||||
|
entries.remove(&peer_id);
|
||||||
|
active.lock().unwrap().remove(&peer_id);
|
||||||
|
let _ = terminal_tx.try_send(peer_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,6 +252,23 @@ mod tests {
|
|||||||
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
assert_eq!(actual, vec![1, 2, 4, 8, 15, 30, 60, 60, 60, 60]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_budget_is_terminal_only_at_or_past_the_cap() {
|
||||||
|
assert!(!recovery_is_terminal(0, RECOVERY_TERMINAL_ATTEMPTS));
|
||||||
|
assert!(!recovery_is_terminal(
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS - 1,
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS
|
||||||
|
));
|
||||||
|
assert!(recovery_is_terminal(
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS,
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS
|
||||||
|
));
|
||||||
|
assert!(recovery_is_terminal(
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS + 5,
|
||||||
|
RECOVERY_TERMINAL_ATTEMPTS
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
fn recovery_slots_are_deduplicated_and_cancel_immediately() {
|
||||||
let (tx, mut rx) = mpsc::channel(4);
|
let (tx, mut rx) = mpsc::channel(4);
|
||||||
@@ -244,7 +303,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn coordinator_attempts_rebootstrap_immediately() {
|
async fn coordinator_attempts_rebootstrap_immediately() {
|
||||||
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
let (attempts_tx, mut attempts_rx) = mpsc::unbounded_channel();
|
||||||
let (coordinator, task) = RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
let (coordinator, task, _terminal_rx) =
|
||||||
|
RecoveryCoordinator::spawn_inner(Arc::new(RecordingRoom {
|
||||||
attempts: attempts_tx,
|
attempts: attempts_tx,
|
||||||
}));
|
}));
|
||||||
let peer_id = SecretKey::generate().public();
|
let peer_id = SecretKey::generate().public();
|
||||||
|
|||||||
@@ -283,8 +283,14 @@ mod tests {
|
|||||||
let q = echo.len() / 4;
|
let q = echo.len() / 4;
|
||||||
let early = erle(&echo[..q], &cleaned[..q]);
|
let early = erle(&echo[..q], &cleaned[..q]);
|
||||||
let late = erle(&echo[3 * q..], &cleaned[3 * 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!(
|
||||||
assert!(late > 20.0, "converged ERLE should exceed 20 dB, got {late:.1}");
|
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]
|
#[test]
|
||||||
@@ -307,7 +313,10 @@ mod tests {
|
|||||||
let mut aec = Nlms::new(128, 0.5, 1e-6);
|
let mut aec = Nlms::new(128, 0.5, 1e-6);
|
||||||
let out = aec.process(&silent_ref, &near);
|
let out = aec.process(&silent_ref, &near);
|
||||||
for (a, b) in near.iter().zip(&out) {
|
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;
|
let mut late_hits = 0;
|
||||||
for i in 0..far.len() {
|
for i in 0..far.len() {
|
||||||
if dtd.update(far[i], mic[i]) {
|
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.
|
// Echo-only stretch should rarely trip; near-end stretch should trip a lot.
|
||||||
let early_rate = early_hits as f32 / onset as f32;
|
let early_rate = early_hits as f32 / onset as f32;
|
||||||
let late_rate = late_hits as f32 / (far.len() - 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!(
|
||||||
assert!(late_rate > 0.50, "missed double-talk, rate only {late_rate:.2}");
|
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]
|
#[test]
|
||||||
@@ -380,6 +399,9 @@ mod tests {
|
|||||||
erle_dtd > erle_no + 15.0,
|
erle_dtd > erle_no + 15.0,
|
||||||
"DTD should hold the echo path: with {erle_dtd:.1} dB vs without {erle_no:.1} dB"
|
"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 path = EchoPath::synthetic(480, 480, 0.5, 99);
|
||||||
let echo = path.apply(&far);
|
let echo = path.apply(&far);
|
||||||
let ratio = rms(&echo) / rms(&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]
|
#[test]
|
||||||
|
|||||||
@@ -141,7 +141,10 @@ mod tests {
|
|||||||
buf[0] = Complex::new(1.0, 0.0);
|
buf[0] = Complex::new(1.0, 0.0);
|
||||||
fft(&mut buf);
|
fft(&mut buf);
|
||||||
for c in &buf {
|
for c in &buf {
|
||||||
assert!(approx(c.magnitude(), 1.0, 1e-9), "expected flat 1.0, got {c:?}");
|
assert!(
|
||||||
|
approx(c.magnitude(), 1.0, 1e-9),
|
||||||
|
"expected flat 1.0, got {c:?}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,11 +62,31 @@ pub struct Band {
|
|||||||
|
|
||||||
/// Voice-relevant bands for spotting *where* residual echo or noise lives.
|
/// Voice-relevant bands for spotting *where* residual echo or noise lives.
|
||||||
pub const VOICE_BANDS: &[Band] = &[
|
pub const VOICE_BANDS: &[Band] = &[
|
||||||
Band { label: "low (80-300)", low_hz: 80.0, high_hz: 300.0 },
|
Band {
|
||||||
Band { label: "low-mid (300-1k)", low_hz: 300.0, high_hz: 1000.0 },
|
label: "low (80-300)",
|
||||||
Band { label: "mid (1k-3k)", low_hz: 1000.0, high_hz: 3000.0 },
|
low_hz: 80.0,
|
||||||
Band { label: "high-mid (3k-6k)", low_hz: 3000.0, high_hz: 6000.0 },
|
high_hz: 300.0,
|
||||||
Band { label: "high (6k-12k)", low_hz: 6000.0, high_hz: 12000.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
|
/// Sums the linear magnitude energy within `[low_hz, high_hz)` across a single
|
||||||
|
|||||||
@@ -202,7 +202,8 @@ fn legend(opts: &RenderOpts) -> String {
|
|||||||
if opts.ascii {
|
if opts.ascii {
|
||||||
for i in 0..steps {
|
for i in 0..steps {
|
||||||
let v = i as f32 / (steps - 1) as f32;
|
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);
|
s.push(ASCII_RAMP[idx] as char);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -243,7 +244,11 @@ mod tests {
|
|||||||
fn render_produces_grid_of_expected_height() {
|
fn render_produces_grid_of_expected_height() {
|
||||||
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
|
let sig = generators::sine(2000.0, 0.8, 48_000, 48_000);
|
||||||
let spec = stft::analyze(&sig, 48_000, 1024, 512);
|
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);
|
let out = render(&spec, &opts);
|
||||||
// Header + 10 body rows + time axis (2) + legend = non-trivial.
|
// Header + 10 body rows + time axis (2) + legend = non-trivial.
|
||||||
let lines = out.lines().count();
|
let lines = out.lines().count();
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.0;
|
.0;
|
||||||
let peak_hz = s.bin_hz(peak_bin);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
|||||||
let mut pos = 12usize;
|
let mut pos = 12usize;
|
||||||
while pos + 8 <= bytes.len() {
|
while pos + 8 <= bytes.len() {
|
||||||
let id = &bytes[pos..pos + 4];
|
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_start = pos + 8;
|
||||||
let body_end = (body_start + size).min(bytes.len());
|
let body_end = (body_start + size).min(bytes.len());
|
||||||
match id {
|
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]]);
|
sample_rate = u32::from_le_bytes([fmt[4], fmt[5], fmt[6], fmt[7]]);
|
||||||
bits = u16::from_le_bytes([fmt[14], fmt[15]]);
|
bits = u16::from_le_bytes([fmt[14], fmt[15]]);
|
||||||
if audio_format != 1 {
|
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" => {
|
b"data" => {
|
||||||
@@ -75,7 +82,10 @@ pub fn read(path: &Path) -> Result<WavData, String> {
|
|||||||
samples.push(avg / 32768.0);
|
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
|
/// Writes mono `f32` samples (clamped to `[-1, 1]`) as a 16-bit PCM WAV. Used by
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
|
|||||||
/// the byte cap. Applied via `image::Limits` when validating/decoding.
|
/// the byte cap. Applied via `image::Limits` when validating/decoding.
|
||||||
pub const MAX_IMAGE_PX: u32 = 4096;
|
pub const MAX_IMAGE_PX: u32 = 4096;
|
||||||
|
|
||||||
|
/// Max total decoded pixels, applied on top of the per-side [`MAX_IMAGE_PX`]
|
||||||
|
/// limit. The per-side cap alone still admits a 4096×4096 ≈ 16.8 MP bitmap
|
||||||
|
/// (~64 MiB transient RGBA); this bounds the worst-case decode allocation while
|
||||||
|
/// still clearing common 12 MP phone photos (4032×3024 ≈ 12.2 MP).
|
||||||
|
pub const MAX_IMAGE_TOTAL_PIXELS: u64 = 14_000_000;
|
||||||
|
|
||||||
|
/// Max pixels per side of the downscaled inline preview handed to the renderer.
|
||||||
|
/// Original bytes are kept only for Save; the chat column never needs more than
|
||||||
|
/// this (it displays at ~260 px, and the lightbox at window size).
|
||||||
|
pub const IMAGE_PREVIEW_MAX_SIDE: u32 = 1600;
|
||||||
|
|
||||||
|
/// Largest declared size an image attachment may auto-fetch at. Anything larger
|
||||||
|
/// (or any skipped/evicted image) renders a "Load image" button instead; a
|
||||||
|
/// manual click may use the full [`MAX_ATTACHMENT_BYTES`] cap.
|
||||||
|
pub const MAX_AUTO_IMAGE_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
|
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
|
||||||
/// the UI tidy; the real bytes are unaffected.
|
/// the UI tidy; the real bytes are unaffected.
|
||||||
pub const MAX_FILENAME_LEN: usize = 96;
|
pub const MAX_FILENAME_LEN: usize = 96;
|
||||||
@@ -75,10 +91,13 @@ pub fn sanitize_filename(raw: &str) -> String {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Drop control chars; turn other whitespace into single spaces later.
|
// Drop control chars and the same bidi/zero-width spoofing format chars
|
||||||
|
// stripped from display names (a U+202E override can visually reverse an
|
||||||
|
// extension, e.g. "photo\u{202E}gnp.exe" renders as "photoexe.png").
|
||||||
|
// Ordinary non-ASCII filenames pass through untouched.
|
||||||
let cleaned: String = base
|
let cleaned: String = base
|
||||||
.chars()
|
.chars()
|
||||||
.filter(|c| !c.is_control())
|
.filter(|c| !c.is_control() && !crate::sanitize::is_spoofing_format_char(*c))
|
||||||
.collect();
|
.collect();
|
||||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||||
let collapsed = collapsed.trim_matches('.').trim();
|
let collapsed = collapsed.trim_matches('.').trim();
|
||||||
@@ -125,6 +144,32 @@ pub fn is_probably_image(bytes: &[u8]) -> bool {
|
|||||||
png || jpeg || gif || bmp || webp
|
png || jpeg || gif || bmp || webp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sniff the leading bytes for an audio container supported by the inline clip
|
||||||
|
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
|
||||||
|
/// check confirms that a filename-based player hint actually contains WAV, MP3,
|
||||||
|
/// Ogg Vorbis, or FLAC data before playback is attempted.
|
||||||
|
pub fn is_probably_audio(bytes: &[u8]) -> bool {
|
||||||
|
let flac = bytes.starts_with(b"fLaC");
|
||||||
|
let ogg = bytes.starts_with(b"OggS");
|
||||||
|
let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
|
||||||
|
let mp3_id3 = bytes.starts_with(b"ID3");
|
||||||
|
let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
|
||||||
|
flac || ogg || wav || mp3_id3 || mp3_frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a sanitized attachment name has an extension supported by the
|
||||||
|
/// inline audio player. This is only a pre-fetch presentation hint; fetched
|
||||||
|
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
|
||||||
|
pub fn looks_like_audio_name(name: &str) -> bool {
|
||||||
|
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
matches!(
|
||||||
|
extension.to_ascii_lowercase().as_str(),
|
||||||
|
"wav" | "mp3" | "ogg" | "oga" | "flac"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
||||||
/// sniffs as an image container, else [`AttachmentKind::File`].
|
/// sniffs as an image container, else [`AttachmentKind::File`].
|
||||||
pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
||||||
@@ -141,20 +186,185 @@ pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
|||||||
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
|
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
|
||||||
/// `image` feature set; anything else returns `None` and the caller shows a chip.
|
/// `image` feature set; anything else returns `None` and the caller shows a chip.
|
||||||
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
|
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||||
let mut limits = image::Limits::default();
|
let img = decode_image_bounded(bytes)?;
|
||||||
limits.max_image_width = Some(MAX_IMAGE_PX);
|
Some((img.width(), img.height()))
|
||||||
limits.max_image_height = Some(MAX_IMAGE_PX);
|
}
|
||||||
|
|
||||||
|
/// Shared bounded decode: header-check the dimensions (per-side AND total-pixel
|
||||||
|
/// limits) BEFORE decoding, then decode under `image::Limits` as defense in
|
||||||
|
/// depth. The precheck reads only the container header, so an over-limit bomb is
|
||||||
|
/// rejected without paying its decode cost.
|
||||||
|
fn decode_image_bounded(bytes: &[u8]) -> Option<image::DynamicImage> {
|
||||||
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
.with_guessed_format()
|
.with_guessed_format()
|
||||||
.ok()?;
|
.ok()?;
|
||||||
let mut reader = reader;
|
let (w, h) = reader.into_dimensions().ok()?;
|
||||||
reader.limits(limits);
|
|
||||||
let img = reader.decode().ok()?;
|
|
||||||
let (w, h) = (img.width(), img.height());
|
|
||||||
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some((w, h))
|
if u64::from(w) * u64::from(h) > MAX_IMAGE_TOTAL_PIXELS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut limits = image::Limits::default();
|
||||||
|
limits.max_image_width = Some(MAX_IMAGE_PX);
|
||||||
|
limits.max_image_height = Some(MAX_IMAGE_PX);
|
||||||
|
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes))
|
||||||
|
.with_guessed_format()
|
||||||
|
.ok()?;
|
||||||
|
reader.limits(limits);
|
||||||
|
let img = reader.decode().ok()?;
|
||||||
|
// Decoded size must match the header the precheck approved.
|
||||||
|
if img.width() != w || img.height() != h {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decoded, display-ready inline preview: RGBA pixels downscaled so neither
|
||||||
|
/// side exceeds [`IMAGE_PREVIEW_MAX_SIDE`]. `rgba.len() == width * height * 4`,
|
||||||
|
/// which is also the preview's decoded-budget weight in the attachment cache.
|
||||||
|
pub struct ImagePreview {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode image bytes under the same limits as [`validate_image_bytes`] and
|
||||||
|
/// build the downscaled inline preview. The full-resolution bitmap exists only
|
||||||
|
/// transiently here; the renderer is never handed more than
|
||||||
|
/// [`IMAGE_PREVIEW_MAX_SIDE`]² pixels. Returns `None` for anything that fails
|
||||||
|
/// validation (caller falls back to a chip / failure row).
|
||||||
|
pub fn decode_preview(bytes: &[u8]) -> Option<ImagePreview> {
|
||||||
|
let img = decode_image_bounded(bytes)?;
|
||||||
|
let img = if img.width() > IMAGE_PREVIEW_MAX_SIDE || img.height() > IMAGE_PREVIEW_MAX_SIDE {
|
||||||
|
// `thumbnail` preserves aspect ratio within the bounding box.
|
||||||
|
img.thumbnail(IMAGE_PREVIEW_MAX_SIDE, IMAGE_PREVIEW_MAX_SIDE)
|
||||||
|
} else {
|
||||||
|
img
|
||||||
|
};
|
||||||
|
let rgba = img.into_rgba8();
|
||||||
|
let (width, height) = (rgba.width(), rgba.height());
|
||||||
|
Some(ImagePreview {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
rgba: rgba.into_raw(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimated decoded RGBA cost of a preview, the weight counted against the
|
||||||
|
/// attachment cache's decoded-byte budget (`width * height * 4`).
|
||||||
|
pub fn preview_rgba_cost(width: u32, height: u32) -> usize {
|
||||||
|
(width as usize)
|
||||||
|
.saturating_mul(height as usize)
|
||||||
|
.saturating_mul(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read at most [`MAX_ATTACHMENT_BYTES`] bytes from `r`. Returns `Ok(None)` if
|
||||||
|
/// the source holds even one byte more (detected by reading cap + 1), so a huge
|
||||||
|
/// or unbounded source is never fully buffered. Pure over `Read` for tests; the
|
||||||
|
/// picker wraps it via [`read_file_capped`].
|
||||||
|
pub fn read_capped<R: std::io::Read>(r: R) -> std::io::Result<Option<Vec<u8>>> {
|
||||||
|
use std::io::Read as _;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut limited = r.take(MAX_ATTACHMENT_BYTES + 1);
|
||||||
|
limited.read_to_end(&mut buf)?;
|
||||||
|
if buf.len() as u64 > MAX_ATTACHMENT_BYTES {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a picked file, bounded by [`MAX_ATTACHMENT_BYTES`]. Checks metadata
|
||||||
|
/// first to reject an obviously-oversized file without opening it, but keeps the
|
||||||
|
/// bounded read regardless — metadata can race (the file can grow after the
|
||||||
|
/// check) or be unavailable through a portal. `Ok(None)` = over the cap.
|
||||||
|
pub fn read_file_capped(path: &std::path::Path) -> std::io::Result<Option<Vec<u8>>> {
|
||||||
|
if let Ok(meta) = std::fs::metadata(path)
|
||||||
|
&& meta.len() > MAX_ATTACHMENT_BYTES
|
||||||
|
{
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
read_capped(std::fs::File::open(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap on how many blobs the session serve store retains at once (sent chat
|
||||||
|
/// attachments plus the current/next broadcast music tracks).
|
||||||
|
pub const SERVED_FILES_MAX_ENTRIES: usize = 16;
|
||||||
|
|
||||||
|
/// Byte budget for the serve store. Without it, a sender's own session could
|
||||||
|
/// grow unbounded at up to [`MAX_ATTACHMENT_BYTES`] per send (Phase 3C).
|
||||||
|
pub const SERVED_FILES_MAX_BYTES: usize = 128 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Count- and byte-budgeted FIFO store of blobs we serve to room members over
|
||||||
|
/// the file plane. Evicting an id makes a later request for it read as an empty
|
||||||
|
/// body — the existing "sender no longer has the file" response — never stale
|
||||||
|
/// or aliased bytes. Pure (no locks/IO) so budgets are unit-testable; the
|
||||||
|
/// transport wraps it in its own mutex.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct ServeStore {
|
||||||
|
entries: std::collections::HashMap<AttachmentId, std::sync::Arc<Vec<u8>>>,
|
||||||
|
/// Present ids in insertion order; the front is the eviction candidate.
|
||||||
|
order: std::collections::VecDeque<AttachmentId>,
|
||||||
|
total_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServeStore {
|
||||||
|
/// Insert or replace a blob, evicting oldest entries until the count and
|
||||||
|
/// byte budgets fit. Replacement keeps the id's age and subtracts the old
|
||||||
|
/// bytes before the new ones are counted. Returns `false` for a blob that
|
||||||
|
/// alone exceeds the byte budget (not stored; an existing entry under the
|
||||||
|
/// id is dropped rather than left stale).
|
||||||
|
pub fn insert(&mut self, id: AttachmentId, bytes: std::sync::Arc<Vec<u8>>) -> bool {
|
||||||
|
if let Some(old) = self.entries.get(&id) {
|
||||||
|
self.total_bytes -= old.len();
|
||||||
|
}
|
||||||
|
if bytes.len() > SERVED_FILES_MAX_BYTES {
|
||||||
|
if self.entries.remove(&id).is_some() {
|
||||||
|
self.order.retain(|k| k != &id);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let replacing = self.entries.contains_key(&id);
|
||||||
|
loop {
|
||||||
|
let count_full = !replacing && self.entries.len() >= SERVED_FILES_MAX_ENTRIES;
|
||||||
|
let bytes_full = self.total_bytes + bytes.len() > SERVED_FILES_MAX_BYTES;
|
||||||
|
if !count_full && !bytes_full {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Some(victim) = self.order.iter().find(|k| **k != id).copied() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
self.remove(&victim);
|
||||||
|
}
|
||||||
|
if !replacing {
|
||||||
|
self.order.push_back(id);
|
||||||
|
}
|
||||||
|
self.total_bytes += bytes.len();
|
||||||
|
self.entries.insert(id, bytes);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, id: &AttachmentId) -> Option<std::sync::Arc<Vec<u8>>> {
|
||||||
|
self.entries.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove(&mut self, id: &AttachmentId) {
|
||||||
|
if let Some(old) = self.entries.remove(id) {
|
||||||
|
self.total_bytes -= old.len();
|
||||||
|
self.order.retain(|k| k != id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.entries.clear();
|
||||||
|
self.order.clear();
|
||||||
|
self.total_bytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.entries.len()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
|
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
|
||||||
@@ -217,7 +427,11 @@ mod tests {
|
|||||||
let long_stem = "x".repeat(200);
|
let long_stem = "x".repeat(200);
|
||||||
let name = format!("{long_stem}.png");
|
let name = format!("{long_stem}.png");
|
||||||
let out = sanitize_filename(&name);
|
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}");
|
assert!(out.ends_with(".png"), "extension preserved: {out}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +445,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn image_sniffing_recognizes_containers() {
|
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(&[0xFF, 0xD8, 0xFF, 0xE0]));
|
||||||
assert!(is_probably_image(b"GIF89a...."));
|
assert!(is_probably_image(b"GIF89a...."));
|
||||||
let mut webp = b"RIFF".to_vec();
|
let mut webp = b"RIFF".to_vec();
|
||||||
@@ -242,9 +458,59 @@ mod tests {
|
|||||||
assert!(!is_probably_image(b""));
|
assert!(!is_probably_image(b""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_recognizes_supported_containers() {
|
||||||
|
assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
|
||||||
|
assert!(is_probably_audio(b"OggS\0\x02"));
|
||||||
|
|
||||||
|
let mut wav = b"RIFF".to_vec();
|
||||||
|
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
wav.extend_from_slice(b"WAVE");
|
||||||
|
assert!(is_probably_audio(&wav));
|
||||||
|
|
||||||
|
assert!(is_probably_audio(b"ID3\x04\0\0"));
|
||||||
|
assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_disambiguates_wav_from_webp() {
|
||||||
|
let mut wav = b"RIFF".to_vec();
|
||||||
|
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
wav.extend_from_slice(b"WAVE");
|
||||||
|
assert!(is_probably_audio(&wav));
|
||||||
|
assert!(!is_probably_image(&wav));
|
||||||
|
|
||||||
|
let mut webp = b"RIFF".to_vec();
|
||||||
|
webp.extend_from_slice(&[0, 0, 0, 0]);
|
||||||
|
webp.extend_from_slice(b"WEBP");
|
||||||
|
assert!(is_probably_image(&webp));
|
||||||
|
assert!(!is_probably_audio(&webp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_sniffing_rejects_non_audio() {
|
||||||
|
assert!(!is_probably_audio(b"%PDF-1.7"));
|
||||||
|
assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
|
||||||
|
assert!(!is_probably_audio(&[]));
|
||||||
|
assert!(!is_probably_audio(&[0xFF]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_name_detection_is_case_insensitive() {
|
||||||
|
for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
|
||||||
|
assert!(looks_like_audio_name(name), "{name}");
|
||||||
|
}
|
||||||
|
assert!(looks_like_audio_name("VOICE.MP3"));
|
||||||
|
assert!(looks_like_audio_name("mix.FlAc"));
|
||||||
|
assert!(!looks_like_audio_name("recording"));
|
||||||
|
assert!(!looks_like_audio_name("notes.pdf"));
|
||||||
|
assert!(!looks_like_audio_name("photo.webp"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classify_maps_sniff_to_kind() {
|
fn classify_maps_sniff_to_kind() {
|
||||||
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
||||||
|
assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
|
||||||
assert_eq!(classify(b"plain text"), AttachmentKind::File);
|
assert_eq!(classify(b"plain text"), AttachmentKind::File);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,6 +539,87 @@ mod tests {
|
|||||||
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
|
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_strips_bidi_and_zero_width_spoofing_chars() {
|
||||||
|
// U+202E would visually reverse the tail, disguising the extension.
|
||||||
|
assert_eq!(sanitize_filename("photo\u{202E}gnp.exe"), "photognp.exe");
|
||||||
|
assert_eq!(sanitize_filename("a\u{200B}b\u{FEFF}.txt"), "ab.txt");
|
||||||
|
// Ordinary Unicode filenames pass through.
|
||||||
|
assert_eq!(sanitize_filename("família_fotos.png"), "família_fotos.png");
|
||||||
|
assert_eq!(sanitize_filename("日本語.pdf"), "日本語.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode a solid PNG of the given dimensions for limit tests.
|
||||||
|
fn png_bytes(w: u32, h: u32) -> Vec<u8> {
|
||||||
|
let img = image::RgbImage::from_pixel(w, h, image::Rgb([10, 20, 30]));
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
image::DynamicImage::ImageRgb8(img)
|
||||||
|
.write_to(&mut buf, image::ImageFormat::Png)
|
||||||
|
.unwrap();
|
||||||
|
buf.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_image_rejects_excessive_total_pixels() {
|
||||||
|
// Both sides within MAX_IMAGE_PX, but 4096 * 4096 > MAX_IMAGE_TOTAL_PIXELS.
|
||||||
|
assert!(u64::from(MAX_IMAGE_PX) * u64::from(MAX_IMAGE_PX) > MAX_IMAGE_TOTAL_PIXELS);
|
||||||
|
assert_eq!(validate_image_bytes(&png_bytes(4096, 4096)), None);
|
||||||
|
// A 12 MP phone-photo shape passes both limits.
|
||||||
|
assert_eq!(
|
||||||
|
validate_image_bytes(&png_bytes(4032, 3024)),
|
||||||
|
Some((4032, 3024))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_downscales_to_max_side_preserving_aspect() {
|
||||||
|
// Wide: 3200x400 → 1600x200.
|
||||||
|
let p = decode_preview(&png_bytes(3200, 400)).unwrap();
|
||||||
|
assert_eq!((p.width, p.height), (1600, 200));
|
||||||
|
assert_eq!(p.rgba.len(), preview_rgba_cost(1600, 200));
|
||||||
|
// Tall: 400x3200 → 200x1600.
|
||||||
|
let p = decode_preview(&png_bytes(400, 3200)).unwrap();
|
||||||
|
assert_eq!((p.width, p.height), (200, 1600));
|
||||||
|
// Square over the side cap: 2000x2000 → 1600x1600.
|
||||||
|
let p = decode_preview(&png_bytes(2000, 2000)).unwrap();
|
||||||
|
assert_eq!((p.width, p.height), (1600, 1600));
|
||||||
|
// At/under the cap is untouched.
|
||||||
|
let p = decode_preview(&png_bytes(1600, 900)).unwrap();
|
||||||
|
assert_eq!((p.width, p.height), (1600, 900));
|
||||||
|
let p = decode_preview(&png_bytes(4, 3)).unwrap();
|
||||||
|
assert_eq!((p.width, p.height), (4, 3));
|
||||||
|
assert_eq!(p.rgba.len(), preview_rgba_cost(4, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preview_rejects_what_validation_rejects() {
|
||||||
|
assert!(decode_preview(b"not an image").is_none());
|
||||||
|
assert!(decode_preview(&png_bytes(4096, 4096)).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_capped_stops_at_cap_plus_one() {
|
||||||
|
// Under the cap: full read.
|
||||||
|
let small = vec![7u8; 1024];
|
||||||
|
assert_eq!(
|
||||||
|
read_capped(std::io::Cursor::new(&small))
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some(&small[..])
|
||||||
|
);
|
||||||
|
// Exactly at the cap: accepted. `repeat` is endless, `take` proves the
|
||||||
|
// reader is bounded rather than draining the source.
|
||||||
|
let at_cap = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES);
|
||||||
|
let got = read_capped(at_cap).unwrap().unwrap();
|
||||||
|
assert_eq!(got.len() as u64, MAX_ATTACHMENT_BYTES);
|
||||||
|
// One byte over: rejected, and only cap + 1 bytes were ever buffered
|
||||||
|
// (an unbounded source returns instead of allocating forever).
|
||||||
|
let over = std::io::Read::take(std::io::repeat(1), MAX_ATTACHMENT_BYTES + 1);
|
||||||
|
assert_eq!(read_capped(over).unwrap(), None);
|
||||||
|
let endless = std::io::repeat(1);
|
||||||
|
assert_eq!(read_capped(endless).unwrap(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn human_size_units() {
|
fn human_size_units() {
|
||||||
assert_eq!(human_size(40), "40 B");
|
assert_eq!(human_size(40), "40 B");
|
||||||
@@ -280,6 +627,69 @@ mod tests {
|
|||||||
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
|
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serve_store_count_and_byte_eviction_fifo() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let mut s = ServeStore::default();
|
||||||
|
let blob = |n: u8, len: usize| ([n; 32], Arc::new(vec![n; len]));
|
||||||
|
// Count cap: entry 0 is evicted when the 17th arrives.
|
||||||
|
for n in 0..=SERVED_FILES_MAX_ENTRIES as u8 {
|
||||||
|
let (id, b) = blob(n, 8);
|
||||||
|
assert!(s.insert(id, b));
|
||||||
|
}
|
||||||
|
assert_eq!(s.len(), SERVED_FILES_MAX_ENTRIES);
|
||||||
|
assert!(s.get(&[0u8; 32]).is_none(), "oldest evicted by count");
|
||||||
|
assert!(s.get(&[1u8; 32]).is_some());
|
||||||
|
// Byte budget: two ~half-budget blobs evict everything older.
|
||||||
|
let half = SERVED_FILES_MAX_BYTES / 2;
|
||||||
|
let (a, ab) = blob(100, half);
|
||||||
|
let (b, bb) = blob(101, half);
|
||||||
|
assert!(s.insert(a, ab));
|
||||||
|
assert!(s.insert(b, bb));
|
||||||
|
assert!(s.get(&a).is_some());
|
||||||
|
assert!(s.get(&b).is_some());
|
||||||
|
assert!(s.get(&[1u8; 32]).is_none(), "evicted for byte budget");
|
||||||
|
// A third half-budget blob evicts `a` (oldest), keeps `b`.
|
||||||
|
let (c, cb) = blob(102, half);
|
||||||
|
assert!(s.insert(c, cb));
|
||||||
|
assert!(s.get(&a).is_none());
|
||||||
|
assert!(s.get(&b).is_some());
|
||||||
|
assert!(s.get(&c).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serve_store_replacement_accounting_and_remove_clear() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let mut s = ServeStore::default();
|
||||||
|
let id = [9u8; 32];
|
||||||
|
assert!(s.insert(id, Arc::new(vec![1; SERVED_FILES_MAX_BYTES - 10])));
|
||||||
|
// Replacing the near-budget blob must subtract its old bytes first —
|
||||||
|
// otherwise this same-id replacement would evict itself.
|
||||||
|
assert!(s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES - 5])));
|
||||||
|
assert_eq!(s.get(&id).unwrap()[0], 2);
|
||||||
|
assert_eq!(s.len(), 1);
|
||||||
|
s.remove(&id);
|
||||||
|
assert!(s.get(&id).is_none());
|
||||||
|
// Removed bytes were released: the budget admits a full-size blob again.
|
||||||
|
assert!(s.insert(id, Arc::new(vec![3; SERVED_FILES_MAX_BYTES])));
|
||||||
|
s.clear();
|
||||||
|
assert_eq!(s.len(), 0);
|
||||||
|
assert!(s.insert(id, Arc::new(vec![4; SERVED_FILES_MAX_BYTES])));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn serve_store_rejects_individually_overweight_blob() {
|
||||||
|
use std::sync::Arc;
|
||||||
|
let mut s = ServeStore::default();
|
||||||
|
let id = [7u8; 32];
|
||||||
|
assert!(s.insert(id, Arc::new(vec![1; 8])));
|
||||||
|
assert!(!s.insert(id, Arc::new(vec![2; SERVED_FILES_MAX_BYTES + 1])));
|
||||||
|
// The stale small blob is gone too — a fetch reads "no longer has it",
|
||||||
|
// never old bytes under a replaced id.
|
||||||
|
assert!(s.get(&id).is_none());
|
||||||
|
assert_eq!(s.len(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attachment_descriptor_round_trips_json() {
|
fn attachment_descriptor_round_trips_json() {
|
||||||
let a = ChatAttachment {
|
let a = ChatAttachment {
|
||||||
|
|||||||
@@ -66,7 +66,11 @@ impl FriendStore {
|
|||||||
if self.contains(&id) {
|
if self.contains(&id) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.friends.push(Friend { id, name, last_addr: addr });
|
self.friends.push(Friend {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
last_addr: addr,
|
||||||
|
});
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +122,24 @@ pub fn friends_path() -> Option<PathBuf> {
|
|||||||
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
|
/// *parse* error bubbles up so a hand-edit being debugged isn't silently
|
||||||
/// overwritten with an empty list.
|
/// overwritten with an empty list.
|
||||||
pub fn load() -> Result<FriendStore> {
|
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)
|
load_at(&path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the store. Atomic via tempfile-in-same-dir + rename.
|
/// Save the store. Atomic via tempfile-in-same-dir + rename.
|
||||||
pub fn save(store: &FriendStore) -> Result<()> {
|
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)
|
save_at(&path, store)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
|
/// Path-injectable core of [`load`], so the round-trip is testable in a temp dir.
|
||||||
fn load_at(path: &Path) -> Result<FriendStore> {
|
fn load_at(path: &Path) -> Result<FriendStore> {
|
||||||
match fs::read_to_string(path) {
|
match fs::read_to_string(path) {
|
||||||
Ok(s) => serde_json::from_str(&s)
|
Ok(s) => {
|
||||||
.with_context(|| format!("failed to parse {}", path.display())),
|
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) if e.kind() == std::io::ErrorKind::NotFound => Ok(FriendStore::default()),
|
||||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
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
|
/// Path-injectable core of [`save`]. Atomic write: tempfile-in-same-dir, then
|
||||||
/// rename, so a crash mid-write can't leave a truncated list.
|
/// rename, so a crash mid-write can't leave a truncated list.
|
||||||
fn save_at(path: &Path, store: &FriendStore) -> Result<()> {
|
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()))?;
|
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 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()));
|
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)
|
fs::rename(&tmp, path)
|
||||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -219,7 +229,11 @@ mod tests {
|
|||||||
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
|
/// A unique temp path; `save_at` creates the nested dir (exercises create_dir_all).
|
||||||
fn temp_path(tag: &str) -> PathBuf {
|
fn temp_path(tag: &str) -> PathBuf {
|
||||||
let mut p = std::env::temp_dir();
|
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.push("friends.json");
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
//! The detector service (§5): one cancellable background worker that polls the OS
|
||||||
|
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
|
||||||
|
//! game on a watch channel — only when it changes, so a flapping detector can't
|
||||||
|
//! spam `PeerState` re-announces.
|
||||||
|
//!
|
||||||
|
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
|
||||||
|
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
|
||||||
|
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
|
||||||
|
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
|
||||||
|
//! debounce is unit-tested without any I/O.
|
||||||
|
|
||||||
|
use super::scan;
|
||||||
|
use super::steam::SteamProbe;
|
||||||
|
use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::io;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
/// How often the detector samples Steam state + the process list.
|
||||||
|
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
|
||||||
|
/// Granularity of the cancellable sleep between polls, so a stop request is
|
||||||
|
/// honored promptly instead of after a full [`POLL_INTERVAL`].
|
||||||
|
const SLEEP_TICK: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
|
/// Apply one poll's worth of inputs to the debouncer, returning the new published
|
||||||
|
/// value **iff it changed** (the signal to re-announce presence / switch the
|
||||||
|
/// background). Pure: the caller supplies the already-fetched Steam detection and
|
||||||
|
/// process list, so resolve + match + debounce are testable with zero I/O.
|
||||||
|
pub fn poll_once(
|
||||||
|
debouncer: &mut Debouncer,
|
||||||
|
override_: &ManualOverride,
|
||||||
|
steam: Option<DetectedGame>,
|
||||||
|
processes: &[String],
|
||||||
|
process_map: &BTreeMap<String, String>,
|
||||||
|
denylist: &std::collections::BTreeSet<&str>,
|
||||||
|
) -> Option<Option<DetectedGame>> {
|
||||||
|
let matched = match_processes(processes, process_map, denylist);
|
||||||
|
let res = resolve(override_, steam, &matched);
|
||||||
|
if debouncer.observe(res.game, res.immediate) {
|
||||||
|
Some(debouncer.current().cloned())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared, live-updatable inputs to the detector, written by core (manual override
|
||||||
|
/// changes, config edits to the process map) and read each poll by the worker.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct DetectorInputs {
|
||||||
|
pub override_: Mutex<ManualOverride>,
|
||||||
|
pub process_map: Mutex<BTreeMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A running detector service. Holds the watch receiver for detected-game changes
|
||||||
|
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
|
||||||
|
/// worker thread.
|
||||||
|
pub struct GameDetector {
|
||||||
|
inputs: Arc<DetectorInputs>,
|
||||||
|
rx: watch::Receiver<Option<DetectedGame>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GameDetector {
|
||||||
|
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
|
||||||
|
/// `override_` seeds the manual override (usually `Auto`). The worker runs
|
||||||
|
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
|
||||||
|
pub fn spawn(
|
||||||
|
override_: ManualOverride,
|
||||||
|
process_map: BTreeMap<String, String>,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
let inputs = Arc::new(DetectorInputs {
|
||||||
|
override_: Mutex::new(override_),
|
||||||
|
process_map: Mutex::new(process_map),
|
||||||
|
});
|
||||||
|
let (tx, rx) = watch::channel(None);
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
|
||||||
|
let worker_inputs = inputs.clone();
|
||||||
|
let worker_stop = stop.clone();
|
||||||
|
let worker = std::thread::Builder::new()
|
||||||
|
.name("game-detector".to_string())
|
||||||
|
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
inputs,
|
||||||
|
rx,
|
||||||
|
stop,
|
||||||
|
worker: Some(worker),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A clone of the watch receiver for detected-game changes. The current value
|
||||||
|
/// is `None` until the first non-empty detection is debounced in.
|
||||||
|
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
|
||||||
|
self.rx.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the manual override (applied on the next poll, immediately,
|
||||||
|
/// bypassing debounce).
|
||||||
|
pub fn set_override(&self, override_: ManualOverride) {
|
||||||
|
*self.inputs.override_.lock().unwrap() = override_;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the user process→name mappings (e.g. after a Settings edit).
|
||||||
|
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
|
||||||
|
*self.inputs.process_map.lock().unwrap() = map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signal the worker to exit. Idempotent; also happens on drop.
|
||||||
|
pub fn stop(&self) {
|
||||||
|
self.stop.store(true, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for GameDetector {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
if let Some(worker) = self.worker.take() {
|
||||||
|
let _ = worker.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
|
||||||
|
fn worker_loop(
|
||||||
|
inputs: Arc<DetectorInputs>,
|
||||||
|
tx: watch::Sender<Option<DetectedGame>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) {
|
||||||
|
let denylist = builtin_denylist();
|
||||||
|
let mut steam = SteamProbe::new();
|
||||||
|
let mut debouncer = Debouncer::default();
|
||||||
|
|
||||||
|
while !stop.load(Ordering::Relaxed) {
|
||||||
|
let override_ = inputs.override_.lock().unwrap().clone();
|
||||||
|
let process_map = inputs.process_map.lock().unwrap().clone();
|
||||||
|
|
||||||
|
let steam_game = steam.detect();
|
||||||
|
let processes = scan::running_executables();
|
||||||
|
|
||||||
|
if let Some(new_current) = poll_once(
|
||||||
|
&mut debouncer,
|
||||||
|
&override_,
|
||||||
|
steam_game,
|
||||||
|
&processes,
|
||||||
|
&process_map,
|
||||||
|
&denylist,
|
||||||
|
) {
|
||||||
|
// A closed receiver means core shut down; stop quietly.
|
||||||
|
if tx.send(new_current).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancellable sleep: wake promptly on a stop request.
|
||||||
|
let mut slept = Duration::ZERO;
|
||||||
|
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
|
||||||
|
std::thread::sleep(SLEEP_TICK);
|
||||||
|
slept += SLEEP_TICK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::super::GameSource;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
|
||||||
|
DetectedGame {
|
||||||
|
id: id.into(),
|
||||||
|
name: Some(name.into()),
|
||||||
|
source,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_debounces_steam_detection() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let steam = game("steam:730", "CS2", GameSource::Steam);
|
||||||
|
let empty = BTreeMap::new();
|
||||||
|
|
||||||
|
// First poll: detected but not yet published (needs two hits).
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Auto,
|
||||||
|
Some(steam.clone()),
|
||||||
|
&[],
|
||||||
|
&empty,
|
||||||
|
&deny
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Second poll: published.
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Auto,
|
||||||
|
Some(steam.clone()),
|
||||||
|
&[],
|
||||||
|
&empty,
|
||||||
|
&deny
|
||||||
|
),
|
||||||
|
Some(Some(steam))
|
||||||
|
);
|
||||||
|
// Third identical poll: no change event.
|
||||||
|
assert_eq!(
|
||||||
|
poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Auto,
|
||||||
|
Some(game("steam:730", "CS2", GameSource::Steam)),
|
||||||
|
&[],
|
||||||
|
&empty,
|
||||||
|
&deny
|
||||||
|
),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_matches_process_when_no_steam() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let procs = vec!["/games/hl2_linux".to_string()];
|
||||||
|
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||||
|
|
||||||
|
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
|
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
|
||||||
|
let published = change
|
||||||
|
.expect("should publish on second hit")
|
||||||
|
.expect("a game");
|
||||||
|
assert_eq!(published.id, "exe:hl2_linux");
|
||||||
|
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poll_once_manual_override_is_immediate() {
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let forced = game("steam:220", "HL2", GameSource::Steam);
|
||||||
|
// Even with a live Steam detection of something else, the override wins now.
|
||||||
|
let other = game("steam:730", "CS2", GameSource::Steam);
|
||||||
|
let change = poll_once(
|
||||||
|
&mut d,
|
||||||
|
&ManualOverride::Force(forced.clone()),
|
||||||
|
Some(other),
|
||||||
|
&[],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&deny,
|
||||||
|
);
|
||||||
|
assert_eq!(change, Some(Some(forced)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_and_stop_is_clean() {
|
||||||
|
// Smoke test the lifecycle: spawning and stopping must not panic, and the
|
||||||
|
// initial published value is None.
|
||||||
|
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
|
||||||
|
assert_eq!(*det.subscribe().borrow(), None);
|
||||||
|
det.set_override(ManualOverride::ForceNone);
|
||||||
|
det.set_process_map(map(&[("x", "X")]));
|
||||||
|
det.stop();
|
||||||
|
// Dropping also stops; no hang/panic.
|
||||||
|
drop(det);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
//! Game detection, game-presence, and game-reactive backgrounds.
|
||||||
|
//!
|
||||||
|
//! A single local "what game is running" detector feeds two consumers:
|
||||||
|
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
|
||||||
|
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
|
||||||
|
//! roster, riding the gossip presence plane like nickname + avatar.
|
||||||
|
//!
|
||||||
|
//! This module is structured testable-seams-first: the *pure* logic lives here
|
||||||
|
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
|
||||||
|
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
|
||||||
|
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
|
||||||
|
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
|
||||||
|
//! cancellable poll service ([`detector`]) wires them together.
|
||||||
|
|
||||||
|
pub mod detector;
|
||||||
|
pub mod scan;
|
||||||
|
pub mod steam;
|
||||||
|
pub mod vdf;
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
/// Where a detected game came from. Encodes the trust/priority tier directly:
|
||||||
|
/// a manual override beats live Steam state, which beats a matched process. Used
|
||||||
|
/// only for prioritization and as a presentation hint — never trusted as identity.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum GameSource {
|
||||||
|
/// The user forced a specific game (or "none") via the manual override.
|
||||||
|
Manual,
|
||||||
|
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
|
||||||
|
Steam,
|
||||||
|
/// A running process matched against the user's process→name mappings.
|
||||||
|
Process,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A game the local detector currently believes is running.
|
||||||
|
///
|
||||||
|
/// `id` is the stable, namespaced identity used as the config key for backgrounds
|
||||||
|
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
|
||||||
|
/// the human label shown locally and broadcast as presence; it is `None` only for
|
||||||
|
/// the Steam appid-without-manifest case, where the background can still switch by
|
||||||
|
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct DetectedGame {
|
||||||
|
/// Stable namespaced identity. Config-key safe; survives renames.
|
||||||
|
pub id: String,
|
||||||
|
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Provenance / priority tier.
|
||||||
|
pub source: GameSource,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DetectedGame {
|
||||||
|
/// The Steam namespaced id for an appid: `steam:<appid>`.
|
||||||
|
pub fn steam_id(app_id: u32) -> String {
|
||||||
|
format!("steam:{app_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The process namespaced id for an executable identity: `exe:<normalized>`.
|
||||||
|
pub fn exe_id(exe: &str) -> String {
|
||||||
|
format!("exe:{}", normalize_exe(exe))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The user's manual override sitting above both detectors (D2). Small by design.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub enum ManualOverride {
|
||||||
|
/// Trust the auto-detector (default).
|
||||||
|
#[default]
|
||||||
|
Auto,
|
||||||
|
/// Force "not playing anything" regardless of what is detected.
|
||||||
|
ForceNone,
|
||||||
|
/// Force a specific game (the user picked it from the known-games list).
|
||||||
|
Force(DetectedGame),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
|
||||||
|
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Resolution {
|
||||||
|
pub game: Option<DetectedGame>,
|
||||||
|
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
|
||||||
|
pub immediate: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
|
||||||
|
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
|
||||||
|
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
|
||||||
|
/// deterministic priority order (see [`match_processes`]); its first entry wins.
|
||||||
|
pub fn resolve(
|
||||||
|
override_: &ManualOverride,
|
||||||
|
steam: Option<DetectedGame>,
|
||||||
|
processes: &[DetectedGame],
|
||||||
|
) -> Resolution {
|
||||||
|
match override_ {
|
||||||
|
ManualOverride::ForceNone => Resolution {
|
||||||
|
game: None,
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
ManualOverride::Force(g) => Resolution {
|
||||||
|
game: Some(g.clone()),
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
ManualOverride::Auto => {
|
||||||
|
let game = steam.or_else(|| processes.first().cloned());
|
||||||
|
Resolution {
|
||||||
|
game,
|
||||||
|
immediate: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples required before a *new* game is accepted/switched to.
|
||||||
|
pub const ACCEPT_HITS: u32 = 2;
|
||||||
|
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
|
||||||
|
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
|
||||||
|
pub const CLEAR_MISSES: u32 = 3;
|
||||||
|
|
||||||
|
/// Debounces a stream of raw per-poll detections into a stable published value, so
|
||||||
|
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
|
||||||
|
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
|
||||||
|
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
|
||||||
|
///
|
||||||
|
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
|
||||||
|
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
|
||||||
|
/// (`immediate = true`) applies at once, bypassing both counters.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Debouncer {
|
||||||
|
current: Option<DetectedGame>,
|
||||||
|
pending: Option<DetectedGame>,
|
||||||
|
pending_hits: u32,
|
||||||
|
misses: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Debouncer {
|
||||||
|
/// The currently published, debounced value.
|
||||||
|
pub fn current(&self) -> Option<&DetectedGame> {
|
||||||
|
self.current.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
|
||||||
|
/// debounce. Returns `true` iff the published [`current`](Self::current) value
|
||||||
|
/// changed — the signal for the service to re-announce presence / switch the
|
||||||
|
/// background.
|
||||||
|
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
|
||||||
|
if immediate {
|
||||||
|
let changed = self.current != sample;
|
||||||
|
self.current = sample;
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
self.misses = 0;
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
match sample {
|
||||||
|
Some(game) => {
|
||||||
|
self.misses = 0;
|
||||||
|
if self.current.as_ref() == Some(&game) {
|
||||||
|
// Already publishing this game; drop any half-counted switch.
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
if self.pending.as_ref() == Some(&game) {
|
||||||
|
self.pending_hits += 1;
|
||||||
|
} else {
|
||||||
|
self.pending = Some(game);
|
||||||
|
self.pending_hits = 1;
|
||||||
|
}
|
||||||
|
if self.pending_hits >= ACCEPT_HITS {
|
||||||
|
self.current = self.pending.take();
|
||||||
|
self.pending_hits = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// A miss never counts toward a *switch*; drop any pending candidate.
|
||||||
|
self.pending = None;
|
||||||
|
self.pending_hits = 0;
|
||||||
|
if self.current.is_some() {
|
||||||
|
self.misses += 1;
|
||||||
|
if self.misses >= CLEAR_MISSES {
|
||||||
|
self.current = None;
|
||||||
|
self.misses = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize a raw executable name/path to a stable identity for matching and ids:
|
||||||
|
/// take the final path component (handling both `/` and `\\` separators) and
|
||||||
|
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
|
||||||
|
/// hypothetical `minecraft`), trims surrounding whitespace.
|
||||||
|
pub fn normalize_exe(raw: &str) -> String {
|
||||||
|
raw.rsplit(['/', '\\'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or(raw)
|
||||||
|
.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Launcher/helper executables that must NEVER be reported as a game even if a
|
||||||
|
/// mapping names them — defense against a mis-entered mapping turning the launcher
|
||||||
|
/// itself into "the game". Normalized (lowercase basename) for comparison.
|
||||||
|
const BUILTIN_DENYLIST: &[&str] = &[
|
||||||
|
"steam",
|
||||||
|
"steam.exe",
|
||||||
|
"steamwebhelper",
|
||||||
|
"steamwebhelper.exe",
|
||||||
|
"steamerrorreporter",
|
||||||
|
"gameoverlayui",
|
||||||
|
"reaper",
|
||||||
|
"lutris",
|
||||||
|
"heroic",
|
||||||
|
"heroic.exe",
|
||||||
|
"legendary",
|
||||||
|
"gogdl",
|
||||||
|
"wine",
|
||||||
|
"wine64",
|
||||||
|
"wineserver",
|
||||||
|
"wine-preloader",
|
||||||
|
"proton",
|
||||||
|
"pressure-vessel-wrap",
|
||||||
|
"explorer.exe",
|
||||||
|
"services.exe",
|
||||||
|
"svchost.exe",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The built-in launcher/helper denylist as a set, for membership checks.
|
||||||
|
pub fn builtin_denylist() -> BTreeSet<&'static str> {
|
||||||
|
BUILTIN_DENYLIST.iter().copied().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match the currently-running executables against the user's explicit
|
||||||
|
/// process→display-name mappings, returning detected games in **deterministic
|
||||||
|
/// priority order** (sorted by stable id) with duplicates removed.
|
||||||
|
///
|
||||||
|
/// Conservative by construction (§3): only exact normalized-basename matches to a
|
||||||
|
/// user mapping count — we never guess that an arbitrary long-running process is a
|
||||||
|
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
|
||||||
|
/// helper can't be promoted to "the game".
|
||||||
|
///
|
||||||
|
/// `user_map` keys are matched against the normalized basename of each running
|
||||||
|
/// entry; the key itself is normalized too, so the caller may store either
|
||||||
|
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
|
||||||
|
pub fn match_processes(
|
||||||
|
running: &[String],
|
||||||
|
user_map: &BTreeMap<String, String>,
|
||||||
|
denylist: &BTreeSet<&str>,
|
||||||
|
) -> Vec<DetectedGame> {
|
||||||
|
// Normalize the user map once so lookups are basename/case-insensitive.
|
||||||
|
let normalized_map: BTreeMap<String, &String> = user_map
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (normalize_exe(k), v))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||||
|
let mut out: Vec<DetectedGame> = Vec::new();
|
||||||
|
for raw in running {
|
||||||
|
let norm = normalize_exe(raw);
|
||||||
|
if norm.is_empty() || denylist.contains(norm.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(name) = normalized_map.get(&norm) {
|
||||||
|
let id = format!("exe:{norm}");
|
||||||
|
if seen.insert(id.clone()) {
|
||||||
|
out.push(DetectedGame {
|
||||||
|
id,
|
||||||
|
name: Some((*name).clone()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Deterministic priority: stable order independent of process-scan order.
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
|
||||||
|
DetectedGame {
|
||||||
|
id: DetectedGame::steam_id(app_id),
|
||||||
|
name: Some(name.to_string()),
|
||||||
|
source: GameSource::Steam,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ids / normalization ----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_ids_are_namespaced() {
|
||||||
|
assert_eq!(DetectedGame::steam_id(730), "steam:730");
|
||||||
|
assert_eq!(
|
||||||
|
DetectedGame::exe_id("/usr/games/hl2_linux"),
|
||||||
|
"exe:hl2_linux"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
|
||||||
|
"exe:minecraft.exe"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalize_handles_both_separators_and_case() {
|
||||||
|
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
|
||||||
|
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
|
||||||
|
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
|
||||||
|
assert_eq!(normalize_exe("bare"), "bare");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- resolve priority --------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_prefers_steam_over_process_in_auto() {
|
||||||
|
let steam = steam_game(730, "CS2");
|
||||||
|
let procs = vec![DetectedGame {
|
||||||
|
id: "exe:foo".into(),
|
||||||
|
name: Some("Foo".into()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
}];
|
||||||
|
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
|
||||||
|
assert_eq!(r.game, Some(steam));
|
||||||
|
assert!(!r.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_falls_back_to_first_process_then_none() {
|
||||||
|
let procs = vec![
|
||||||
|
DetectedGame {
|
||||||
|
id: "exe:a".into(),
|
||||||
|
name: Some("A".into()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
},
|
||||||
|
DetectedGame {
|
||||||
|
id: "exe:b".into(),
|
||||||
|
name: Some("B".into()),
|
||||||
|
source: GameSource::Process,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let r = resolve(&ManualOverride::Auto, None, &procs);
|
||||||
|
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
|
||||||
|
let none = resolve(&ManualOverride::Auto, None, &[]);
|
||||||
|
assert_eq!(none.game, None);
|
||||||
|
assert!(!none.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_manual_override_wins_and_is_immediate() {
|
||||||
|
let steam = steam_game(730, "CS2");
|
||||||
|
// ForceNone overrides a live Steam detection, immediately.
|
||||||
|
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
|
||||||
|
assert_eq!(r.game, None);
|
||||||
|
assert!(r.immediate);
|
||||||
|
// Force(x) overrides too.
|
||||||
|
let forced = steam_game(220, "HL2");
|
||||||
|
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
|
||||||
|
assert_eq!(r.game, Some(forced));
|
||||||
|
assert!(r.immediate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- debounce ----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_requires_two_hits_to_switch() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
// First sighting: not yet published.
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
// Second consecutive sighting: now published.
|
||||||
|
assert!(d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Steady state: same game, no further change events.
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_requires_three_misses_to_clear() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Two misses: still shown (absorbs a transient blip).
|
||||||
|
assert!(!d.observe(None, false));
|
||||||
|
assert!(!d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// Third miss: cleared.
|
||||||
|
assert!(d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_blip_during_clear_resets_miss_count() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
d.observe(Some(g.clone()), false);
|
||||||
|
// Miss, miss, then the game reappears: miss count resets, stays published.
|
||||||
|
d.observe(None, false);
|
||||||
|
d.observe(None, false);
|
||||||
|
assert!(!d.observe(Some(g.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// It now takes a fresh run of three misses to clear.
|
||||||
|
d.observe(None, false);
|
||||||
|
d.observe(None, false);
|
||||||
|
assert!(d.observe(None, false));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_immediate_bypasses_counters() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let g = steam_game(730, "CS2");
|
||||||
|
// A manual override publishes on the first sample.
|
||||||
|
assert!(d.observe(Some(g.clone()), true));
|
||||||
|
assert_eq!(d.current(), Some(&g));
|
||||||
|
// ForceNone clears immediately.
|
||||||
|
assert!(d.observe(None, true));
|
||||||
|
assert_eq!(d.current(), None);
|
||||||
|
// Re-issuing the same immediate value is not a change.
|
||||||
|
d.observe(Some(g.clone()), true);
|
||||||
|
assert!(!d.observe(Some(g.clone()), true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
|
||||||
|
let mut d = Debouncer::default();
|
||||||
|
let a = steam_game(1, "A");
|
||||||
|
let b = steam_game(2, "B");
|
||||||
|
d.observe(Some(a.clone()), false);
|
||||||
|
d.observe(Some(a.clone()), false);
|
||||||
|
assert_eq!(d.current(), Some(&a));
|
||||||
|
// One sample of B does not switch.
|
||||||
|
assert!(!d.observe(Some(b.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&a));
|
||||||
|
// Second consecutive B switches.
|
||||||
|
assert!(d.observe(Some(b.clone()), false));
|
||||||
|
assert_eq!(d.current(), Some(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- process matching --------------------------------------------------
|
||||||
|
|
||||||
|
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
|
||||||
|
pairs
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_matches_only_explicit_mappings() {
|
||||||
|
let user = map(&[("hl2_linux", "Half-Life 2")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec![
|
||||||
|
"/usr/bin/firefox".to_string(),
|
||||||
|
"/games/Half-Life 2/hl2_linux".to_string(),
|
||||||
|
"/usr/bin/htop".to_string(),
|
||||||
|
];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].id, "exe:hl2_linux");
|
||||||
|
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
|
||||||
|
assert_eq!(got[0].source, GameSource::Process);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_rejects_denylisted_even_if_mapped() {
|
||||||
|
// A mis-entered mapping naming the Steam client must not win.
|
||||||
|
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].id, "exe:mygame");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_is_deterministic_and_deduped() {
|
||||||
|
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
// Same game twice (two processes) + reverse discovery order.
|
||||||
|
let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
|
||||||
|
assert_eq!(
|
||||||
|
got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
|
||||||
|
vec!["exe:alpha", "exe:zed"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn match_processes_ignores_unmapped_and_case_folds() {
|
||||||
|
let user = map(&[("Game.x86_64", "The Game")]);
|
||||||
|
let deny = builtin_denylist();
|
||||||
|
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
|
||||||
|
let got = match_processes(&running, &user, &deny);
|
||||||
|
assert_eq!(got.len(), 1);
|
||||||
|
assert_eq!(got[0].name.as_deref(), Some("The Game"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
//! Running-process enumeration for the non-Steam detection fallback (D6/D7):
|
||||||
|
//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no
|
||||||
|
//! `sysinfo` dependency and the audit surface stays small.
|
||||||
|
//!
|
||||||
|
//! This module is *just the OS edge*: it returns the list of running executable
|
||||||
|
//! paths/names. The trustworthy part — turning that list into a game via the
|
||||||
|
//! user's explicit mappings and the launcher denylist — is the pure
|
||||||
|
//! [`match_processes`](super::match_processes), unit-tested in the parent module.
|
||||||
|
|
||||||
|
/// Enumerate the executables of currently-running processes as paths/basenames.
|
||||||
|
/// Best-effort: processes we can't introspect (other users') are skipped rather
|
||||||
|
/// than erroring. The result is fed to [`match_processes`](super::match_processes),
|
||||||
|
/// which normalizes each entry to a basename before matching.
|
||||||
|
pub fn running_executables() -> Vec<String> {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
linux_proc_executables()
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
windows_toolhelp_executables()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", windows)))]
|
||||||
|
{
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_proc_executables() -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let Ok(entries) = std::fs::read_dir("/proc") else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
// Only numeric entries are processes.
|
||||||
|
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let proc_dir = entry.path();
|
||||||
|
// Prefer the real exe path (full, untruncated); fall back to `comm`, which
|
||||||
|
// is readable for all processes but truncated to 15 bytes.
|
||||||
|
if let Ok(exe) = std::fs::read_link(proc_dir.join("exe"))
|
||||||
|
&& let Some(s) = exe.to_str()
|
||||||
|
{
|
||||||
|
out.push(s.to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) {
|
||||||
|
let trimmed = comm.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
out.push(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_toolhelp_executables() -> Vec<String> {
|
||||||
|
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||||
|
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||||
|
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
|
||||||
|
TH32CS_SNAPPROCESS,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
// SAFETY: standard Toolhelp snapshot of all processes; handle checked below.
|
||||||
|
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
|
||||||
|
if snapshot == INVALID_HANDLE_VALUE {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
|
||||||
|
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
|
||||||
|
// SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires.
|
||||||
|
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
|
||||||
|
while ok != 0 {
|
||||||
|
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
|
||||||
|
let end = entry
|
||||||
|
.szExeFile
|
||||||
|
.iter()
|
||||||
|
.position(|&c| c == 0)
|
||||||
|
.unwrap_or(entry.szExeFile.len());
|
||||||
|
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
|
||||||
|
if !name.is_empty() {
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
// SAFETY: same valid snapshot + entry struct.
|
||||||
|
ok = unsafe { Process32NextW(snapshot, &mut entry) };
|
||||||
|
}
|
||||||
|
// SAFETY: snapshot handle came from CreateToolhelp32Snapshot above.
|
||||||
|
unsafe { CloseHandle(snapshot) };
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn enumerates_at_least_this_process() {
|
||||||
|
// The test runner itself is a process, so /proc enumeration must be
|
||||||
|
// non-empty and include something that normalizes to our own exe basename.
|
||||||
|
let exes = super::running_executables();
|
||||||
|
assert!(
|
||||||
|
!exes.is_empty(),
|
||||||
|
"expected to see running processes via /proc"
|
||||||
|
);
|
||||||
|
// Our own /proc/self/exe basename should appear among them.
|
||||||
|
let me = std::fs::read_link("/proc/self/exe")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()));
|
||||||
|
if let Some(me) = me {
|
||||||
|
let me_norm = super::super::normalize_exe(&me);
|
||||||
|
assert!(
|
||||||
|
exes.iter()
|
||||||
|
.any(|e| super::super::normalize_exe(e) == me_norm),
|
||||||
|
"running list should include our own executable {me_norm:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,581 @@
|
|||||||
|
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
|
||||||
|
//! `RunningAppID` and resolves it to a display name via the plain-text
|
||||||
|
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
|
||||||
|
//!
|
||||||
|
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
|
||||||
|
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
|
||||||
|
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
|
||||||
|
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
|
||||||
|
//! poll does not rescan every library each tick (Codex hardening).
|
||||||
|
|
||||||
|
use super::vdf::{self, Value};
|
||||||
|
use super::{DetectedGame, GameSource};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
/// Max bytes read from any single Steam state file. These are small text files
|
||||||
|
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
|
||||||
|
/// slurped into memory before the parser's own depth guard kicks in.
|
||||||
|
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||||
|
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
|
||||||
|
/// prevents a corrupt registry length from driving an enormous allocation.
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn validate_reg_len(len: u32) -> Option<usize> {
|
||||||
|
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
|
||||||
|
let units = validate_reg_len(returned_bytes)?;
|
||||||
|
if units > buf.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
buf.truncate(units);
|
||||||
|
while buf.last() == Some(&0) {
|
||||||
|
buf.pop();
|
||||||
|
}
|
||||||
|
Some(String::from_utf16_lossy(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
|
||||||
|
/// client's emulated-registry text file). Returns the appid only when present and
|
||||||
|
/// nonzero — `0`/absent is the "no game" state. Pure.
|
||||||
|
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
|
||||||
|
let root = vdf::parse(registry_vdf).ok()?;
|
||||||
|
let raw = root
|
||||||
|
.get_path(&[
|
||||||
|
"Registry",
|
||||||
|
"HKCU",
|
||||||
|
"Software",
|
||||||
|
"Valve",
|
||||||
|
"Steam",
|
||||||
|
"RunningAppID",
|
||||||
|
])
|
||||||
|
.and_then(Value::as_str)?;
|
||||||
|
let id: u32 = raw.trim().parse().ok()?;
|
||||||
|
(id != 0).then_some(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
|
||||||
|
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
|
||||||
|
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
|
||||||
|
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
|
||||||
|
/// already decoded by the VDF parser), including ones on offline drives — the
|
||||||
|
/// caller checks existence.
|
||||||
|
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
|
||||||
|
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
// The root may or may not wrap entries in a "libraryfolders" object.
|
||||||
|
let container = root.get("libraryfolders").unwrap_or(&root);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (key, val) in container.entries() {
|
||||||
|
// Only numeric-keyed entries are library folders.
|
||||||
|
if key.parse::<u32>().is_err() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = match val {
|
||||||
|
Value::Str(s) => Some(s.as_str()),
|
||||||
|
Value::Obj(_) => val.get("path").and_then(Value::as_str),
|
||||||
|
};
|
||||||
|
if let Some(p) = path
|
||||||
|
&& !p.is_empty()
|
||||||
|
{
|
||||||
|
out.push(PathBuf::from(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
|
||||||
|
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
|
||||||
|
let root = vdf::parse(appmanifest_acf).ok()?;
|
||||||
|
root.get_path(&["AppState", "name"])
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
|
||||||
|
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
|
||||||
|
fn read_capped(path: &Path) -> Option<String> {
|
||||||
|
use std::io::Read;
|
||||||
|
let file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
|
||||||
|
Some(String::from_utf8_lossy(&buf).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mtime_of(path: &Path) -> Option<SystemTime> {
|
||||||
|
std::fs::metadata(path).ok()?.modified().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A library list cached against its source file's mtime.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CachedLibraries {
|
||||||
|
source: Option<PathBuf>,
|
||||||
|
mtime: Option<SystemTime>,
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
|
||||||
|
/// when the manifest exists but carries no usable name, or wasn't found.
|
||||||
|
struct CachedManifest {
|
||||||
|
mtime: Option<SystemTime>,
|
||||||
|
name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
|
||||||
|
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
|
||||||
|
/// service runs it off the async worker.
|
||||||
|
pub struct SteamProbe {
|
||||||
|
roots: Vec<PathBuf>,
|
||||||
|
libraries: CachedLibraries,
|
||||||
|
manifests: HashMap<u32, CachedManifest>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SteamProbe {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SteamProbe {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
roots: discover_roots(),
|
||||||
|
libraries: CachedLibraries::default(),
|
||||||
|
manifests: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One detection pass: read the live `RunningAppID`, and if a game is running,
|
||||||
|
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
|
||||||
|
/// with `name: None` when the appid is known but no manifest name is available
|
||||||
|
/// — the background can still switch by id, but presence must not invent a name.
|
||||||
|
pub fn detect(&mut self) -> Option<DetectedGame> {
|
||||||
|
let app_id = self.running_app_id()?;
|
||||||
|
let name = self.app_name(app_id);
|
||||||
|
Some(DetectedGame {
|
||||||
|
id: DetectedGame::steam_id(app_id),
|
||||||
|
name,
|
||||||
|
source: GameSource::Steam,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live RunningAppID (nonzero), or `None`.
|
||||||
|
///
|
||||||
|
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
|
||||||
|
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
|
||||||
|
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
|
||||||
|
/// signal is the running game process's `SteamAppId` environment variable
|
||||||
|
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
|
||||||
|
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
|
||||||
|
/// (macOS) only has the `registry.vdf` fallback for now.
|
||||||
|
fn running_app_id(&self) -> Option<u32> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
win::running_app_id()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
running_app_id_from_environ().or_else(registry_running_app_id)
|
||||||
|
}
|
||||||
|
#[cfg(not(any(windows, target_os = "linux")))]
|
||||||
|
{
|
||||||
|
registry_running_app_id()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve (and cache) the display name for an appid by locating its
|
||||||
|
/// `appmanifest_<appid>.acf` across the known libraries.
|
||||||
|
fn app_name(&mut self, app_id: u32) -> Option<String> {
|
||||||
|
let manifest = self.find_manifest(app_id)?;
|
||||||
|
let mtime = mtime_of(&manifest);
|
||||||
|
if let Some(cached) = self.manifests.get(&app_id)
|
||||||
|
&& cached.mtime == mtime
|
||||||
|
{
|
||||||
|
return cached.name.clone();
|
||||||
|
}
|
||||||
|
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
|
||||||
|
self.manifests.insert(
|
||||||
|
app_id,
|
||||||
|
CachedManifest {
|
||||||
|
mtime,
|
||||||
|
name: name.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The path to an appid's manifest, if it exists in any library.
|
||||||
|
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
|
||||||
|
let filename = format!("appmanifest_{app_id}.acf");
|
||||||
|
for lib in self.library_paths() {
|
||||||
|
let candidate = lib.join("steamapps").join(&filename);
|
||||||
|
if candidate.exists() {
|
||||||
|
return Some(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All Steam library folder paths, cached and refreshed only when the source
|
||||||
|
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
|
||||||
|
fn library_paths(&mut self) -> Vec<PathBuf> {
|
||||||
|
// Locate the libraryfolders.vdf to watch (first existing across roots).
|
||||||
|
let source = self
|
||||||
|
.roots
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
|
||||||
|
.find(|p| p.exists());
|
||||||
|
|
||||||
|
let mtime = source.as_deref().and_then(mtime_of);
|
||||||
|
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
|
||||||
|
return self.libraries.paths.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
if let Some(ref src) = source
|
||||||
|
&& let Some(contents) = read_capped(src)
|
||||||
|
{
|
||||||
|
paths = parse_library_paths(&contents);
|
||||||
|
}
|
||||||
|
// Always include the roots themselves: the install dir is an implicit
|
||||||
|
// library even if libraryfolders.vdf is missing or lists only extras.
|
||||||
|
for root in &self.roots {
|
||||||
|
if !paths.contains(root) {
|
||||||
|
paths.push(root.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.libraries = CachedLibraries {
|
||||||
|
source,
|
||||||
|
mtime,
|
||||||
|
paths: paths.clone(),
|
||||||
|
};
|
||||||
|
paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Candidate Steam install roots that actually exist on this machine (each is a
|
||||||
|
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
|
||||||
|
/// layouts on Linux; on Windows the install path comes from the registry.
|
||||||
|
fn discover_roots() -> Vec<PathBuf> {
|
||||||
|
let mut roots = Vec::new();
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
if let Some(p) = win::install_path() {
|
||||||
|
roots.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
for rel in [
|
||||||
|
".steam/steam",
|
||||||
|
".steam/root",
|
||||||
|
".local/share/Steam",
|
||||||
|
".var/app/com.valvesoftware.Steam/.local/share/Steam",
|
||||||
|
"snap/steam/common/.local/share/Steam",
|
||||||
|
] {
|
||||||
|
roots.push(home.join(rel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only roots that exist and look like a Steam install.
|
||||||
|
roots.retain(|p| p.join("steamapps").is_dir());
|
||||||
|
roots.sort();
|
||||||
|
roots.dedup();
|
||||||
|
roots
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn registry_vdf_candidates() -> Vec<PathBuf> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
out.push(home.join(".steam/registry.vdf"));
|
||||||
|
out.push(home.join(".steam/steam/registry.vdf"));
|
||||||
|
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
|
||||||
|
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
|
||||||
|
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
|
||||||
|
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn registry_running_app_id() -> Option<u32> {
|
||||||
|
for path in registry_vdf_candidates() {
|
||||||
|
if let Some(contents) = read_capped(&path)
|
||||||
|
&& let Some(id) = parse_running_app_id(&contents)
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
|
||||||
|
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
|
||||||
|
/// game process. Returns the appid only when present and nonzero. Pure +
|
||||||
|
/// unit-tested; the `/proc` iteration is the thin edge in
|
||||||
|
/// [`running_app_id_from_environ`].
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
|
||||||
|
for kv in environ.split(|&b| b == 0) {
|
||||||
|
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
|
||||||
|
&& let Ok(s) = std::str::from_utf8(val)
|
||||||
|
&& let Ok(id) = s.trim().parse::<u32>()
|
||||||
|
&& id != 0
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
|
||||||
|
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
|
||||||
|
/// readable only for our own processes — exactly the ones a Steam game we launched
|
||||||
|
/// runs as — and we skip the rest. The live signal that replaces the stale
|
||||||
|
/// on-disk `registry.vdf` on Linux.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn running_app_id_from_environ() -> Option<u32> {
|
||||||
|
let entries = std::fs::read_dir("/proc").ok()?;
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if !name.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Cap the read: an environ is small; this bounds a pathological case.
|
||||||
|
if let Some(environ) = read_capped(&entry.path().join("environ"))
|
||||||
|
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
|
||||||
|
{
|
||||||
|
return Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod win {
|
||||||
|
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
|
||||||
|
//! crate. Steam stores both the live `RunningAppID` and its install path under
|
||||||
|
//! `HKCU\Software\Valve\Steam`.
|
||||||
|
use super::{decode_reg_sz, validate_reg_len};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||||
|
use windows_sys::Win32::System::Registry::{
|
||||||
|
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
|
||||||
|
RegQueryValueExW,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
|
||||||
|
fn wide(s: &str) -> Vec<u16> {
|
||||||
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
|
||||||
|
fn open_steam_key() -> Option<HKEY> {
|
||||||
|
let subkey = wide("Software\\Valve\\Steam");
|
||||||
|
let mut hkey: HKEY = std::ptr::null_mut();
|
||||||
|
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
|
||||||
|
let rc =
|
||||||
|
unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
|
||||||
|
(rc == ERROR_SUCCESS).then_some(hkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
|
||||||
|
pub fn running_app_id() -> Option<u32> {
|
||||||
|
let hkey = open_steam_key()?;
|
||||||
|
let name = wide("RunningAppID");
|
||||||
|
let mut kind: u32 = 0;
|
||||||
|
let mut data: u32 = 0;
|
||||||
|
let mut len = std::mem::size_of::<u32>() as u32;
|
||||||
|
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
&mut data as *mut u32 as *mut u8,
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// SAFETY: handle came from RegOpenKeyExW above.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
|
||||||
|
Some(data)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
|
||||||
|
pub fn install_path() -> Option<PathBuf> {
|
||||||
|
let hkey = open_steam_key()?;
|
||||||
|
let name = wide("SteamPath");
|
||||||
|
let mut kind: u32 = 0;
|
||||||
|
let mut len: u32 = 0;
|
||||||
|
// First query the size.
|
||||||
|
// SAFETY: null data ptr with a zeroed len asks for the required size.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
&mut len,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if rc != ERROR_SUCCESS || kind != REG_SZ {
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let Some(units) = validate_reg_len(len) else {
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let mut buf = vec![0u16; units];
|
||||||
|
let mut len2 = len;
|
||||||
|
// SAFETY: buffer sized to the queried byte length.
|
||||||
|
let rc = unsafe {
|
||||||
|
RegQueryValueExW(
|
||||||
|
hkey,
|
||||||
|
name.as_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&mut kind,
|
||||||
|
buf.as_mut_ptr() as *mut u8,
|
||||||
|
&mut len2,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// SAFETY: valid handle.
|
||||||
|
unsafe { RegCloseKey(hkey) };
|
||||||
|
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_string_lengths_are_bounded_and_trimmed() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_reg_len(5),
|
||||||
|
None,
|
||||||
|
"odd byte lengths are invalid UTF-16"
|
||||||
|
);
|
||||||
|
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
|
||||||
|
assert_eq!(validate_reg_len(8), Some(4));
|
||||||
|
|
||||||
|
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
|
||||||
|
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
|
||||||
|
assert_eq!(
|
||||||
|
decode_reg_sz(raw, returned_bytes).as_deref(),
|
||||||
|
Some("C:\\Steam")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_app_id_reads_nonzero_and_rejects_zero() {
|
||||||
|
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||||
|
"RunningAppID" "440"
|
||||||
|
} } } } }"#;
|
||||||
|
assert_eq!(parse_running_app_id(running), Some(440));
|
||||||
|
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
|
||||||
|
"RunningAppID" "0"
|
||||||
|
} } } } }"#;
|
||||||
|
assert_eq!(parse_running_app_id(idle), None);
|
||||||
|
// Missing key / garbage → None, no panic.
|
||||||
|
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
|
||||||
|
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_paths_handles_current_and_legacy_shapes() {
|
||||||
|
let current = r#""libraryfolders" {
|
||||||
|
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
|
||||||
|
"1" { "path" "/mnt/games/SteamLibrary" }
|
||||||
|
"contentstatsid" "12345"
|
||||||
|
}"#;
|
||||||
|
let got = parse_library_paths(current);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/home/eric/.local/share/Steam"),
|
||||||
|
PathBuf::from("/mnt/games/SteamLibrary"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Legacy shape: numeric keys map straight to path strings.
|
||||||
|
let legacy = r#""LibraryFolders" {
|
||||||
|
"TimeNextStatsReport" "9999"
|
||||||
|
"ContentStatsID" "42"
|
||||||
|
"1" "/mnt/old/SteamLibrary"
|
||||||
|
}"#;
|
||||||
|
let got = parse_library_paths(legacy);
|
||||||
|
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn library_paths_empty_on_garbage() {
|
||||||
|
assert!(parse_library_paths("totally broken {{{").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
#[test]
|
||||||
|
fn steam_app_id_parsed_from_environ_blob() {
|
||||||
|
// A realistic NUL-separated environ with SteamAppId among other vars.
|
||||||
|
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
|
||||||
|
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
|
||||||
|
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
|
||||||
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Absent → None (a non-Steam process).
|
||||||
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Not fooled by a different var that merely contains the substring.
|
||||||
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Garbage value → None, no panic.
|
||||||
|
assert_eq!(
|
||||||
|
parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_name_extracts_and_filters_empty() {
|
||||||
|
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
|
||||||
|
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
|
||||||
|
// Empty name → None (don't broadcast a blank).
|
||||||
|
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
|
||||||
|
assert_eq!(parse_app_name(blank), None);
|
||||||
|
// Missing name → None.
|
||||||
|
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
|
||||||
|
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
|
||||||
|
//!
|
||||||
|
//! Pure (operates on already-read file *contents*) and unit-tested, per the
|
||||||
|
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
|
||||||
|
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
|
||||||
|
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
|
||||||
|
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
|
||||||
|
//! input with a recursion-depth cap, so a deeply nested file errors instead of
|
||||||
|
//! overflowing the stack, and never panics on malformed/truncated input.
|
||||||
|
|
||||||
|
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
|
||||||
|
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
|
||||||
|
/// still bounding a malicious file.
|
||||||
|
const MAX_DEPTH: usize = 32;
|
||||||
|
|
||||||
|
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
|
||||||
|
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
|
||||||
|
/// return the first match.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Value {
|
||||||
|
Str(String),
|
||||||
|
Obj(Vec<(String, Value)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Value {
|
||||||
|
/// The leaf string at this node, if it is a string (not an object).
|
||||||
|
pub fn as_str(&self) -> Option<&str> {
|
||||||
|
match self {
|
||||||
|
Value::Str(s) => Some(s),
|
||||||
|
Value::Obj(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first child value under `key`, if this is an object containing it.
|
||||||
|
/// Case-insensitive on the key (KeyValues keys are conventionally
|
||||||
|
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
|
||||||
|
pub fn get(&self, key: &str) -> Option<&Value> {
|
||||||
|
match self {
|
||||||
|
Value::Obj(pairs) => pairs
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||||
|
.map(|(_, v)| v),
|
||||||
|
Value::Str(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Follow a chain of object keys, returning the value at the end of the path.
|
||||||
|
/// `root.get_path(&["AppState", "name"])`.
|
||||||
|
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
|
||||||
|
let mut cur = self;
|
||||||
|
for key in path {
|
||||||
|
cur = cur.get(key)?;
|
||||||
|
}
|
||||||
|
Some(cur)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Iterate the (key, value) child pairs if this is an object.
|
||||||
|
pub fn entries(&self) -> &[(String, Value)] {
|
||||||
|
match self {
|
||||||
|
Value::Obj(pairs) => pairs,
|
||||||
|
Value::Str(_) => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
|
||||||
|
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
|
||||||
|
/// nesting past [`MAX_DEPTH`]. Never panics.
|
||||||
|
pub fn parse(input: &str) -> Result<Value, String> {
|
||||||
|
let mut lexer = Lexer { rest: input };
|
||||||
|
let obj = parse_object(&mut lexer, 0, true)?;
|
||||||
|
Ok(Value::Obj(obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
|
||||||
|
/// parses until a closing `}` (which it consumes).
|
||||||
|
fn parse_object(
|
||||||
|
lexer: &mut Lexer,
|
||||||
|
depth: usize,
|
||||||
|
top_level: bool,
|
||||||
|
) -> Result<Vec<(String, Value)>, String> {
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err("VDF nesting too deep".to_string());
|
||||||
|
}
|
||||||
|
let mut pairs = Vec::new();
|
||||||
|
loop {
|
||||||
|
match lexer.next_token()? {
|
||||||
|
None => {
|
||||||
|
if top_level {
|
||||||
|
return Ok(pairs);
|
||||||
|
}
|
||||||
|
return Err("unexpected end of input inside object".to_string());
|
||||||
|
}
|
||||||
|
Some(Token::Close) => {
|
||||||
|
if top_level {
|
||||||
|
return Err("unexpected '}' at top level".to_string());
|
||||||
|
}
|
||||||
|
return Ok(pairs);
|
||||||
|
}
|
||||||
|
Some(Token::Open) => {
|
||||||
|
return Err("expected key, found '{'".to_string());
|
||||||
|
}
|
||||||
|
Some(Token::Str(key)) => {
|
||||||
|
// A key must be followed by a value: a string or a nested object.
|
||||||
|
match lexer.next_token()? {
|
||||||
|
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
|
||||||
|
Some(Token::Open) => {
|
||||||
|
let child = parse_object(lexer, depth + 1, false)?;
|
||||||
|
pairs.push((key, Value::Obj(child)));
|
||||||
|
}
|
||||||
|
Some(Token::Close) => {
|
||||||
|
return Err(format!("key '{key}' has no value (found '}}')"));
|
||||||
|
}
|
||||||
|
None => return Err(format!("key '{key}' has no value (end of input)")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Token {
|
||||||
|
Open,
|
||||||
|
Close,
|
||||||
|
Str(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lexer<'a> {
|
||||||
|
rest: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lexer<'_> {
|
||||||
|
/// Produce the next token, skipping whitespace and `//` line comments.
|
||||||
|
fn next_token(&mut self) -> Result<Option<Token>, String> {
|
||||||
|
loop {
|
||||||
|
self.rest = self.rest.trim_start();
|
||||||
|
if self.rest.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
// Line comments: `//` to end of line.
|
||||||
|
if let Some(after) = self.rest.strip_prefix("//") {
|
||||||
|
match after.find('\n') {
|
||||||
|
Some(nl) => self.rest = &after[nl + 1..],
|
||||||
|
None => {
|
||||||
|
self.rest = "";
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut chars = self.rest.char_indices();
|
||||||
|
let (_, first) = chars.next().expect("non-empty checked above");
|
||||||
|
return match first {
|
||||||
|
'{' => {
|
||||||
|
self.advance_bytes(first.len_utf8());
|
||||||
|
Ok(Some(Token::Open))
|
||||||
|
}
|
||||||
|
'}' => {
|
||||||
|
self.advance_bytes(first.len_utf8());
|
||||||
|
Ok(Some(Token::Close))
|
||||||
|
}
|
||||||
|
'"' => self.lex_quoted(),
|
||||||
|
_ => Ok(Some(self.lex_bareword())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_bytes(&mut self, n: usize) {
|
||||||
|
self.rest = &self.rest[n..];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
|
||||||
|
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
|
||||||
|
// Skip the opening quote.
|
||||||
|
self.advance_bytes(1);
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = self.rest.char_indices();
|
||||||
|
while let Some((i, c)) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'"' => {
|
||||||
|
// Consume through the closing quote.
|
||||||
|
self.rest = &self.rest[i + 1..];
|
||||||
|
return Ok(Some(Token::Str(out)));
|
||||||
|
}
|
||||||
|
'\\' => {
|
||||||
|
// Decode the escape.
|
||||||
|
match chars.next() {
|
||||||
|
Some((_, esc)) => out.push(match esc {
|
||||||
|
'n' => '\n',
|
||||||
|
't' => '\t',
|
||||||
|
'r' => '\r',
|
||||||
|
// `\\`, `\"`, and anything else: take the literal char.
|
||||||
|
other => other,
|
||||||
|
}),
|
||||||
|
None => return Err("unterminated escape in quoted string".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => out.push(other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("unterminated quoted string".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
|
||||||
|
fn lex_bareword(&mut self) -> Token {
|
||||||
|
let end = self
|
||||||
|
.rest
|
||||||
|
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
|
||||||
|
.unwrap_or(self.rest.len());
|
||||||
|
let word = self.rest[..end].to_string();
|
||||||
|
self.rest = &self.rest[end..];
|
||||||
|
Token::Str(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_appmanifest_name() {
|
||||||
|
// A trimmed-down real appmanifest_<id>.acf.
|
||||||
|
let acf = r#"
|
||||||
|
"AppState"
|
||||||
|
{
|
||||||
|
"appid" "730"
|
||||||
|
"name" "Counter-Strike 2"
|
||||||
|
"StateFlags" "4"
|
||||||
|
"installdir" "Counter-Strike Global Offensive"
|
||||||
|
"UserConfig"
|
||||||
|
{
|
||||||
|
"language" "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(acf).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
root.get_path(&["AppState", "name"]).and_then(Value::as_str),
|
||||||
|
Some("Counter-Strike 2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
root.get_path(&["AppState", "appid"])
|
||||||
|
.and_then(Value::as_str),
|
||||||
|
Some("730")
|
||||||
|
);
|
||||||
|
// Case-insensitive key lookup.
|
||||||
|
assert_eq!(
|
||||||
|
root.get_path(&["appstate", "NAME"]).and_then(Value::as_str),
|
||||||
|
Some("Counter-Strike 2")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_libraryfolders_paths_with_escaped_backslashes() {
|
||||||
|
// Windows paths arrive with doubled backslashes (escaped).
|
||||||
|
let vdf = r#"
|
||||||
|
"libraryfolders"
|
||||||
|
{
|
||||||
|
"0"
|
||||||
|
{
|
||||||
|
"path" "C:\\Program Files (x86)\\Steam"
|
||||||
|
"apps"
|
||||||
|
{
|
||||||
|
"730" "35000000000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"1"
|
||||||
|
{
|
||||||
|
"path" "/home/eric/.local/share/Steam"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(vdf).unwrap();
|
||||||
|
let lf = root.get("libraryfolders").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
lf.get_path(&["0", "path"]).and_then(Value::as_str),
|
||||||
|
Some(r"C:\Program Files (x86)\Steam")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
lf.get_path(&["1", "path"]).and_then(Value::as_str),
|
||||||
|
Some("/home/eric/.local/share/Steam")
|
||||||
|
);
|
||||||
|
// The library folder ids are iterable for discovery.
|
||||||
|
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
|
||||||
|
assert_eq!(ids, vec!["0", "1"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_registry_running_appid_deep_path() {
|
||||||
|
let reg = r#"
|
||||||
|
"Registry"
|
||||||
|
{
|
||||||
|
"HKCU"
|
||||||
|
{
|
||||||
|
"Software"
|
||||||
|
{
|
||||||
|
"Valve"
|
||||||
|
{
|
||||||
|
"Steam"
|
||||||
|
{
|
||||||
|
"RunningAppID" "570"
|
||||||
|
"language" "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"#;
|
||||||
|
let root = parse(reg).unwrap();
|
||||||
|
let appid = root
|
||||||
|
.get_path(&[
|
||||||
|
"Registry",
|
||||||
|
"HKCU",
|
||||||
|
"Software",
|
||||||
|
"Valve",
|
||||||
|
"Steam",
|
||||||
|
"RunningAppID",
|
||||||
|
])
|
||||||
|
.and_then(Value::as_str);
|
||||||
|
assert_eq!(appid, Some("570"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handles_comments_and_barewords() {
|
||||||
|
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
|
||||||
|
let root = parse(vdf).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
root.get_path(&["root", "barekey"]).and_then(Value::as_str),
|
||||||
|
Some("barevalue")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_without_panicking() {
|
||||||
|
// Unbalanced braces.
|
||||||
|
assert!(parse("\"a\" {").is_err());
|
||||||
|
// Stray closing brace.
|
||||||
|
assert!(parse("}").is_err());
|
||||||
|
// Key with no value at EOF.
|
||||||
|
assert!(parse("\"lonely\"").is_err());
|
||||||
|
// Unterminated quoted string.
|
||||||
|
assert!(parse("\"key\" \"unterminated").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_pathologically_deep_nesting() {
|
||||||
|
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
|
||||||
|
let mut s = String::new();
|
||||||
|
for i in 0..(MAX_DEPTH + 5) {
|
||||||
|
s.push_str(&format!("\"k{i}\" {{"));
|
||||||
|
}
|
||||||
|
for _ in 0..(MAX_DEPTH + 5) {
|
||||||
|
s.push('}');
|
||||||
|
}
|
||||||
|
assert!(parse(&s).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_keys_return_none_not_error() {
|
||||||
|
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
|
||||||
|
assert_eq!(root.get_path(&["AppState", "name"]), None);
|
||||||
|
assert_eq!(root.get_path(&["Nope"]), None);
|
||||||
|
// Treating a string as an object yields None rather than panicking.
|
||||||
|
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,9 +89,9 @@ impl HotkeyAction {
|
|||||||
|
|
||||||
pub fn tier(self) -> HotkeyTier {
|
pub fn tier(self) -> HotkeyTier {
|
||||||
match self {
|
match self {
|
||||||
HotkeyAction::ToggleMute
|
HotkeyAction::ToggleMute | HotkeyAction::ToggleDeafen | HotkeyAction::OpenSettings => {
|
||||||
| HotkeyAction::ToggleDeafen
|
HotkeyTier::AppWide
|
||||||
| HotkeyAction::OpenSettings => HotkeyTier::AppWide,
|
}
|
||||||
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
HotkeyAction::PushToTalk | HotkeyAction::LeaveRoom => HotkeyTier::RoomOnly,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +278,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_single_character_case_folds() {
|
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");
|
assert_eq!(format_binding(parse_binding("m").as_ref()), "M");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ pub fn identity_path() -> Option<PathBuf> {
|
|||||||
/// A *missing* file (first ever run, or right after a reset) is the normal
|
/// A *missing* file (first ever run, or right after a reset) is the normal
|
||||||
/// create path.
|
/// create path.
|
||||||
pub fn load_or_create() -> Result<SecretKey> {
|
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)
|
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
|
/// deliberate "Regenerate identity" / unlink action — the old id is discarded and
|
||||||
/// unrecoverable, so callers should confirm with the user first.
|
/// unrecoverable, so callers should confirm with the user first.
|
||||||
pub fn regenerate() -> Result<SecretKey> {
|
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();
|
let key = SecretKey::generate();
|
||||||
save_at(&path, &key)?;
|
save_at(&path, &key)?;
|
||||||
Ok(key)
|
Ok(key)
|
||||||
@@ -57,7 +59,8 @@ pub fn regenerate() -> Result<SecretKey> {
|
|||||||
|
|
||||||
/// Atomic, `0600` write at the default identity path. See [`save_at`].
|
/// Atomic, `0600` write at the default identity path. See [`save_at`].
|
||||||
pub fn save(key: &SecretKey) -> Result<()> {
|
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)
|
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
|
/// perms are applied before the rename so the secret is never briefly
|
||||||
/// world-readable.
|
/// world-readable.
|
||||||
fn save_at(path: &std::path::Path, key: &SecretKey) -> Result<()> {
|
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()))?;
|
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 tmp = parent.join(format!(".identity.key.tmp.{}", std::process::id()));
|
||||||
{
|
{
|
||||||
let mut f =
|
let mut f = fs::File::create(&tmp)
|
||||||
fs::File::create(&tmp).with_context(|| format!("failed to create {}", tmp.display()))?;
|
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
pub mod audio;
|
|
||||||
pub mod codec;
|
|
||||||
pub mod dsp;
|
|
||||||
pub mod network;
|
|
||||||
pub mod protocol;
|
|
||||||
pub mod core;
|
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod config;
|
pub mod audio;
|
||||||
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 avatar;
|
pub mod avatar;
|
||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod recents;
|
pub mod codec;
|
||||||
|
pub mod config;
|
||||||
|
pub mod core;
|
||||||
pub mod discovery;
|
pub mod discovery;
|
||||||
pub mod hotkeys;
|
pub mod dsp;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
|
pub mod friends;
|
||||||
|
pub mod game;
|
||||||
|
pub mod hotkeys;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod network;
|
||||||
|
pub mod notify;
|
||||||
|
pub mod playlist;
|
||||||
|
pub mod presence;
|
||||||
|
pub mod presence_net;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod recents;
|
||||||
|
pub mod sanitize;
|
||||||
|
pub mod screenshare;
|
||||||
|
pub mod theme;
|
||||||
|
pub mod widget;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -72,7 +75,8 @@ pub fn redact_for_log(value: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
||||||
bytes.iter()
|
bytes
|
||||||
|
.iter()
|
||||||
.take(6)
|
.take(6)
|
||||||
.map(|b| format!("{b:02x}"))
|
.map(|b| format!("{b:02x}"))
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
@@ -80,7 +84,10 @@ pub fn short_bytes_hex(bytes: &[u8]) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn rotated_log_path(path: &Path) -> PathBuf {
|
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"))
|
path.with_file_name(format!("{file_name}.1"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,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 rotated = rotated_log_path(path);
|
||||||
let _ = std::fs::remove_file(&rotated);
|
let _ = std::fs::remove_file(&rotated);
|
||||||
if std::fs::rename(path, &rotated).is_err() {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
use crate::network::{NetworkTransport, NetError, ConnEvent};
|
use crate::network::{ConnEvent, NetError, NetworkTransport};
|
||||||
use iroh::{Endpoint, EndpointId};
|
use async_trait::async_trait;
|
||||||
use iroh::endpoint::{Connection, ConnectionError, VarInt};
|
|
||||||
use bytes::Bytes;
|
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;
|
||||||
use tokio::sync::mpsc::Receiver;
|
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::files::{AttachmentId, ChatAttachment};
|
||||||
|
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
|
||||||
|
|
||||||
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
|
/// 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.
|
/// useless latency — keep it shallow and drop the oldest frame when full.
|
||||||
@@ -69,7 +69,9 @@ struct Shared {
|
|||||||
/// the random attachment id. Populated when we send a chat file; read by the
|
/// the random attachment id. Populated when we send a chat file; read by the
|
||||||
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
|
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
|
||||||
/// blob is already byte-capped at send time.
|
/// blob is already byte-capped at send time.
|
||||||
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>,
|
/// Blobs we serve to room members, bounded by count and byte budgets
|
||||||
|
/// (Phase 3C) — an evicted id reads as "sender no longer has the file".
|
||||||
|
served_files: StdMutex<crate::files::ServeStore>,
|
||||||
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
|
||||||
/// Best-effort link-state notifications for the UI (connecting / connected).
|
/// Best-effort link-state notifications for the UI (connecting / connected).
|
||||||
conn_events_tx: mpsc::Sender<ConnEvent>,
|
conn_events_tx: mpsc::Sender<ConnEvent>,
|
||||||
@@ -111,7 +113,13 @@ impl Shared {
|
|||||||
let shared = self.clone();
|
let shared = self.clone();
|
||||||
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
|
let supervisor = tokio::spawn(supervise(shared, peer_id, inbound_rx));
|
||||||
let inbound_tx_ret = inbound_tx.clone();
|
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));
|
crate::log_msg(&format!("Transport: supervising peer {:?}", peer_id));
|
||||||
inbound_tx_ret
|
inbound_tx_ret
|
||||||
}
|
}
|
||||||
@@ -123,7 +131,10 @@ impl Shared {
|
|||||||
self.addrs.lock().unwrap().remove(&peer_id);
|
self.addrs.lock().unwrap().remove(&peer_id);
|
||||||
if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
|
if let Some(handle) = self.peers.lock().await.remove(&peer_id) {
|
||||||
handle.supervisor.abort();
|
handle.supervisor.abort();
|
||||||
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
|
crate::log_msg(&format!(
|
||||||
|
"Transport: stopped supervising peer {:?}",
|
||||||
|
peer_id
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,9 +219,12 @@ async fn supervise(
|
|||||||
let mut backoff = INITIAL_BACKOFF;
|
let mut backoff = INITIAL_BACKOFF;
|
||||||
|
|
||||||
// Show "connecting" until the first link is actually up.
|
// 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 {
|
let mut conn =
|
||||||
|
match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
||||||
Some(conn) => conn,
|
Some(conn) => conn,
|
||||||
None => return, // retired before we ever connected
|
None => return, // retired before we ever connected
|
||||||
};
|
};
|
||||||
@@ -223,8 +237,14 @@ async fn supervise(
|
|||||||
shared.senders.lock().unwrap().insert(peer_id, send_tx);
|
shared.senders.lock().unwrap().insert(peer_id, send_tx);
|
||||||
// Publish the live connection so an intentional leave can close it with
|
// Publish the live connection so an intentional leave can close it with
|
||||||
// the goodbye code.
|
// the goodbye code.
|
||||||
shared.live_conns.lock().unwrap().insert(peer_id, conn.clone());
|
shared
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connected(peer_id));
|
.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));
|
crate::log_msg(&format!("Transport: peer {:?} link up", peer_id));
|
||||||
|
|
||||||
// Run until the link dies, a replacement arrives, or we're retired. The
|
// Run until the link dies, a replacement arrives, or we're retired. The
|
||||||
@@ -272,21 +292,36 @@ async fn supervise(
|
|||||||
match wake {
|
match wake {
|
||||||
Wake::Shutdown => return,
|
Wake::Shutdown => return,
|
||||||
Wake::Replacement(new_conn) => {
|
Wake::Replacement(new_conn) => {
|
||||||
crate::log_msg(&format!("Transport: peer {:?} replaced with new inbound link", peer_id));
|
crate::log_msg(&format!(
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
"Transport: peer {:?} replaced with new inbound link",
|
||||||
|
peer_id
|
||||||
|
));
|
||||||
|
let _ = shared
|
||||||
|
.conn_events_tx
|
||||||
|
.try_send(ConnEvent::Connecting(peer_id));
|
||||||
conn = new_conn;
|
conn = new_conn;
|
||||||
}
|
}
|
||||||
Wake::Closed(reason) => {
|
Wake::Closed(reason) => {
|
||||||
// A graceful application close means the peer left on purpose —
|
// A graceful application close means the peer left on purpose —
|
||||||
// don't reconnect; tell the core to evict it now.
|
// don't reconnect; tell the core to evict it now.
|
||||||
if is_graceful_leave(&reason) {
|
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));
|
let _ = shared.conn_events_tx.try_send(ConnEvent::Left(peer_id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
crate::log_msg(&format!("Transport: peer {:?} link dropped; reconnecting", peer_id));
|
crate::log_msg(&format!(
|
||||||
let _ = shared.conn_events_tx.try_send(ConnEvent::Connecting(peer_id));
|
"Transport: peer {:?} link dropped; reconnecting",
|
||||||
conn = match obtain_conn(&shared, peer_id, is_dialer, &mut inbound_rx, &mut backoff).await {
|
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,
|
Some(conn) => conn,
|
||||||
None => return, // retired while reconnecting
|
None => return, // retired while reconnecting
|
||||||
};
|
};
|
||||||
@@ -405,7 +440,10 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
|
|||||||
// only happens if links are churning, and the supervisor gets the next one.
|
// only happens if links are churning, and the supervisor gets the next one.
|
||||||
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
let inbound_tx = shared.ensure_supervisor(peer_id).await;
|
||||||
if inbound_tx.try_send(connection).is_err() {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -482,13 +520,22 @@ impl iroh::protocol::ProtocolHandler for FileRouter {
|
|||||||
let Some(id) = crate::files::parse_request(&req) else {
|
let Some(id) = crate::files::parse_request(&req) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
|
let blob = shared.served_files.lock().unwrap().get(&id);
|
||||||
if let Some(blob) = blob {
|
if let Some(blob) = blob {
|
||||||
let _ = send.write_all(&blob).await;
|
let _ = send.write_all(&blob).await;
|
||||||
}
|
}
|
||||||
// Finish either way: an unknown id closes with an empty body, which
|
// Finish either way: an unknown id closes with an empty body, which
|
||||||
// the fetcher reads as a zero-length result and treats as "gone".
|
// the fetcher reads as a zero-length result and treats as "gone".
|
||||||
let _ = send.finish();
|
let _ = send.finish();
|
||||||
|
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
|
||||||
|
// for the written bytes to be delivered and acknowledged. If we return
|
||||||
|
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
|
||||||
|
// of the still-in-flight stream data, so the fetcher's read aborts with
|
||||||
|
// "connection lost". Wait for the fetcher to receive everything and
|
||||||
|
// close the connection itself (it drops `conn` right after read_to_end);
|
||||||
|
// that close is our signal the transfer landed. Bounded so a fetcher
|
||||||
|
// that vanishes can't pin this task forever.
|
||||||
|
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,7 +565,7 @@ impl IrohTransport {
|
|||||||
peers: tokio::sync::Mutex::new(HashMap::new()),
|
peers: tokio::sync::Mutex::new(HashMap::new()),
|
||||||
live_conns: StdMutex::new(HashMap::new()),
|
live_conns: StdMutex::new(HashMap::new()),
|
||||||
admitted_audio: StdMutex::new(HashSet::new()),
|
admitted_audio: StdMutex::new(HashSet::new()),
|
||||||
served_files: StdMutex::new(HashMap::new()),
|
served_files: StdMutex::new(crate::files::ServeStore::default()),
|
||||||
incoming_tx,
|
incoming_tx,
|
||||||
conn_events_tx,
|
conn_events_tx,
|
||||||
});
|
});
|
||||||
@@ -536,7 +583,14 @@ impl IrohTransport {
|
|||||||
/// all supervisors so none linger redialing the about-to-close endpoint.
|
/// all supervisors so none linger redialing the about-to-close endpoint.
|
||||||
/// Call this before shutting the router down.
|
/// Call this before shutting the router down.
|
||||||
pub async fn leave(&self) {
|
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 {
|
for conn in &conns {
|
||||||
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
|
conn.close(VarInt::from_u32(GOODBYE_CODE), b"leave");
|
||||||
}
|
}
|
||||||
@@ -582,20 +636,26 @@ impl IrohTransport {
|
|||||||
/// session (served by the [`FileRouter`] handler). Called by core when we
|
/// session (served by the [`FileRouter`] handler). Called by core when we
|
||||||
/// send a chat file. The blob is cleared on leave.
|
/// send a chat file. The blob is cleared on leave.
|
||||||
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
|
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
|
||||||
self.shared.served_files.lock().unwrap().insert(id, bytes);
|
if !self.shared.served_files.lock().unwrap().insert(id, bytes) {
|
||||||
|
crate::log_msg("Transport: refused to serve an over-budget blob");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch a chat attachment's bytes from its sender over the file plane. Dials
|
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
|
||||||
/// the sender on `FILES_ALPN` (preferring a known full address), writes the
|
pub fn forget_attachment(&self, id: AttachmentId) {
|
||||||
/// 32-byte id, and reads the response bounded by the descriptor's declared
|
self.shared.served_files.lock().unwrap().remove(&id);
|
||||||
/// size (which the caller has already validated against the global cap). The
|
}
|
||||||
/// read limit means a malicious sender can't stream us more than advertised.
|
|
||||||
pub async fn fetch_attachment(
|
/// Fetch `size` bytes stored under `id` from peer `from` over the files plane.
|
||||||
|
/// Shared core of `fetch_attachment` and music-track fetching: dials
|
||||||
|
/// `FILES_ALPN`, writes the 32-byte id, and reads bounded by `size`.
|
||||||
|
pub async fn fetch_blob(
|
||||||
&self,
|
&self,
|
||||||
from: EndpointId,
|
from: EndpointId,
|
||||||
att: &ChatAttachment,
|
id: AttachmentId,
|
||||||
|
size: u64,
|
||||||
) -> Result<Vec<u8>, NetError> {
|
) -> Result<Vec<u8>, NetError> {
|
||||||
if !crate::files::size_within_cap(att.size) {
|
if !crate::files::size_within_cap(size) {
|
||||||
return Err(NetError::Other("attachment size out of range".to_string()));
|
return Err(NetError::Other("attachment size out of range".to_string()));
|
||||||
}
|
}
|
||||||
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
|
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
|
||||||
@@ -614,22 +674,94 @@ impl IrohTransport {
|
|||||||
.open_bi()
|
.open_bi()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
|
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
|
||||||
send.write_all(&att.id)
|
send.write_all(&id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
|
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
|
||||||
send.finish()
|
send.finish()
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
|
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
|
||||||
|
|
||||||
let read = recv.read_to_end(att.size as usize);
|
// `read_to_end(size)` errors if the stream exceeds `size`, rejecting an
|
||||||
|
// overlong transfer; the exact-length check below rejects a short one.
|
||||||
|
let read = recv.read_to_end(size as usize);
|
||||||
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
|
||||||
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
|
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
|
||||||
if bytes.is_empty() {
|
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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Exact transfer required (Phase 3C): a truncated body must not be
|
||||||
|
// cached/saved/decoded as if it were the declared attachment.
|
||||||
|
if bytes.len() as u64 != size {
|
||||||
|
return Err(NetError::Other(format!(
|
||||||
|
"file fetch: incomplete transfer ({} of {size} bytes)",
|
||||||
|
bytes.len()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot the selected QUIC path of every live audio connection, for the
|
||||||
|
/// UI's per-peer connection badge (direct/relay, RTT, loss, bitrate).
|
||||||
|
/// Cheap and lock-light: the `live_conns` guard is released before touching
|
||||||
|
/// any connection, and `Connection::paths()` reads shared state without I/O.
|
||||||
|
pub fn connection_stats(&self) -> Vec<(EndpointId, crate::network::PathSnapshot)> {
|
||||||
|
// Clone the connections out so the map lock isn't held while we inspect
|
||||||
|
// paths (a supervisor inserts/removes entries as links come and go).
|
||||||
|
let conns: Vec<(EndpointId, Connection)> = self
|
||||||
|
.shared
|
||||||
|
.live_conns
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, conn)| (*id, conn.clone()))
|
||||||
|
.collect();
|
||||||
|
conns
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(id, conn)| {
|
||||||
|
let paths = conn.paths();
|
||||||
|
// The selected path is the one carrying application data. In the
|
||||||
|
// brief window where none is flagged (e.g. mid-migration), fall
|
||||||
|
// back to the first open path rather than dropping the badge.
|
||||||
|
let path = paths
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.is_selected())
|
||||||
|
.or_else(|| paths.iter().next())?;
|
||||||
|
let stats = path.stats();
|
||||||
|
// Per-variant display: `TransportAddr`'s own `Display` prefixes
|
||||||
|
// a scheme ("ip:1.2.3.4:5") that's noise next to the badge's
|
||||||
|
// Direct/Relay label.
|
||||||
|
let remote_addr = match path.remote_addr() {
|
||||||
|
iroh::TransportAddr::Ip(sock) => sock.to_string(),
|
||||||
|
iroh::TransportAddr::Relay(url) => url.to_string(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
id,
|
||||||
|
crate::network::PathSnapshot {
|
||||||
|
is_relay: path.remote_addr().is_relay(),
|
||||||
|
remote_addr,
|
||||||
|
rtt: stats.rtt,
|
||||||
|
tx_bytes: stats.udp_tx.bytes,
|
||||||
|
rx_bytes: stats.udp_rx.bytes,
|
||||||
|
tx_datagrams: stats.udp_tx.datagrams,
|
||||||
|
lost_packets: stats.lost_packets,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a chat attachment's bytes from its sender over the file plane.
|
||||||
|
pub async fn fetch_attachment(
|
||||||
|
&self,
|
||||||
|
from: EndpointId,
|
||||||
|
att: &ChatAttachment,
|
||||||
|
) -> Result<Vec<u8>, NetError> {
|
||||||
|
self.fetch_blob(from, att.id, att.size).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use iroh::{EndpointId, EndpointAddr};
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use iroh::{EndpointAddr, EndpointId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::str::FromStr;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::mpsc::Receiver;
|
use tokio::sync::mpsc::Receiver;
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde::{Serialize, Deserialize};
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum NetError {
|
pub enum NetError {
|
||||||
@@ -22,6 +22,38 @@ pub enum NetError {
|
|||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A peer's currently-broadcast music track + playback timeline (W22). Rides
|
||||||
|
/// gossip presence so listeners can tune in, follow track changes, and keep in
|
||||||
|
/// sync. Untrusted like `name`/`game`: the `name` is sanitized and `size` is
|
||||||
|
/// cap-checked at gossip ingest. Bytes never ride gossip — they are fetched
|
||||||
|
/// point-to-point over the files plane by `id`, exactly like a chat attachment.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct MusicPresence {
|
||||||
|
/// Files-plane handle to fetch this track's bytes (minted per track by the DJ).
|
||||||
|
pub id: crate::files::AttachmentId,
|
||||||
|
/// Sanitized display name (track filename). Untrusted; cleaned at ingest.
|
||||||
|
pub name: String,
|
||||||
|
/// Byte length, bounds the listener's fetch. Must be `<= MAX_ATTACHMENT_BYTES`.
|
||||||
|
pub size: u64,
|
||||||
|
/// True while the DJ has the track paused.
|
||||||
|
pub paused: bool,
|
||||||
|
/// Wall-clock ms (UNIX epoch) of the timeline anchor. While playing, the true
|
||||||
|
/// playhead is `position_ms + (now_ms - anchor_ms)`; while paused it is
|
||||||
|
/// frozen at `position_ms`. Re-stamped on every play/pause/seek.
|
||||||
|
pub anchor_ms: u64,
|
||||||
|
/// Playhead position (ms) at `anchor_ms`.
|
||||||
|
pub position_ms: u64,
|
||||||
|
/// Files-plane handle for the DJ's NEXT track, so listeners can prefetch it
|
||||||
|
/// for a gapless skip. `None` when there is no distinct next track (single
|
||||||
|
/// item playlist) or the DJ isn't ready. Equals a future `id` once that
|
||||||
|
/// track plays.
|
||||||
|
#[serde(default)]
|
||||||
|
pub next_id: Option<crate::files::AttachmentId>,
|
||||||
|
/// Byte length of the next track; bounds the prefetch. Cap-checked at ingest.
|
||||||
|
#[serde(default)]
|
||||||
|
pub next_size: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct PeerState {
|
pub struct PeerState {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -39,6 +71,65 @@ pub struct PeerState {
|
|||||||
/// peers/configs that predate the field still deserialize (→ monogram).
|
/// peers/configs that predate the field still deserialize (→ monogram).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub avatar: crate::avatar::Avatar,
|
pub avatar: crate::avatar::Avatar,
|
||||||
|
/// The game this peer is currently playing, as a display string only (shown as
|
||||||
|
/// `Playing <name>` next to their avatar). Opt-in and **untrusted** like
|
||||||
|
/// `name`: sanitized + length-capped at the gossip ingest boundary. `None` when
|
||||||
|
/// the peer isn't sharing a game (feature off / nothing detected). Only the
|
||||||
|
/// display string rides the wire — never the appid or detection source, to
|
||||||
|
/// avoid fingerprinting and coupling the protocol to detector internals.
|
||||||
|
/// Defaulted so peers/configs predating the field still deserialize.
|
||||||
|
#[serde(default)]
|
||||||
|
pub game: Option<String>,
|
||||||
|
/// This peer's currently-broadcast music track and playback timeline, or
|
||||||
|
/// `None` when not broadcasting. Defaulted so pre-W22 peers deserialize.
|
||||||
|
#[serde(default)]
|
||||||
|
pub music: Option<MusicPresence>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
|
||||||
|
/// that change only on explicit user action and persist for the whole core
|
||||||
|
/// session. The remaining `PeerState` fields are *volatile* — mute state, current
|
||||||
|
/// `addr`, and the active screen-share ticket are read fresh at each announce — so
|
||||||
|
/// they are passed into [`SelfPresence::to_state`] rather than stored here.
|
||||||
|
///
|
||||||
|
/// This is the single source of truth for building our own `PeerState`: core
|
||||||
|
/// reconstructs self-state in several command branches (join, mute toggle, avatar
|
||||||
|
/// change, screen-share start/stop), and centralizing the `PeerState` literal here
|
||||||
|
/// means a new presence field is added in exactly one place instead of at every
|
||||||
|
/// call site.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct SelfPresence {
|
||||||
|
pub name: String,
|
||||||
|
pub avatar: crate::avatar::Avatar,
|
||||||
|
/// The display label of the game we're currently broadcasting, or `None` when
|
||||||
|
/// game presence is off / nothing is detected. Already sanitized + capped
|
||||||
|
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
|
||||||
|
/// the outgoing announce carries a safe value.
|
||||||
|
pub game: Option<String>,
|
||||||
|
/// Our current broadcast timeline, or `None` when not broadcasting / not playing.
|
||||||
|
pub music: Option<MusicPresence>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SelfPresence {
|
||||||
|
/// Combine the sticky identity fields with the volatile per-announce fields
|
||||||
|
/// (`is_muted`, current `addr`, active-share `sharing` ticket) into a full
|
||||||
|
/// `PeerState` ready to announce over the gossip presence plane.
|
||||||
|
pub fn to_state(
|
||||||
|
&self,
|
||||||
|
is_muted: bool,
|
||||||
|
addr: iroh::EndpointAddr,
|
||||||
|
sharing: Option<String>,
|
||||||
|
) -> PeerState {
|
||||||
|
PeerState {
|
||||||
|
name: self.name.clone(),
|
||||||
|
is_muted,
|
||||||
|
addr,
|
||||||
|
sharing,
|
||||||
|
avatar: self.avatar.clone(),
|
||||||
|
game: self.game.clone(),
|
||||||
|
music: self.music.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -54,9 +145,17 @@ pub enum RoomEvent {
|
|||||||
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
|
||||||
/// reconnect path the way it used to.
|
/// reconnect path the way it used to.
|
||||||
PeerConnectionLost(EndpointId),
|
PeerConnectionLost(EndpointId),
|
||||||
/// A peer sent a room text-chat message. Carries the sender's id, their
|
/// A validly signed gossip payload was rejected only because its timestamp is
|
||||||
/// display name (embedded so it shows even without a presence entry), the
|
/// outside the replay-protection window. The peer is not in the roster yet,
|
||||||
/// text, and a sender-stamped millisecond timestamp.
|
/// so this surfaces as a room-level warning instead of a peer-card state.
|
||||||
|
ClockSkewSuspected {
|
||||||
|
author: EndpointId,
|
||||||
|
skew_ms: i64,
|
||||||
|
},
|
||||||
|
/// A peer sent a room text-chat message. Carries the sender's id, the
|
||||||
|
/// sender-CLAIMED display name (untrusted; the core replaces it with the
|
||||||
|
/// roster-bound name before the UI sees it — chat-hardening Phase 2), the
|
||||||
|
/// text, and the signed envelope timestamp (display only, never ordering).
|
||||||
ChatMessage {
|
ChatMessage {
|
||||||
from: EndpointId,
|
from: EndpointId,
|
||||||
name: String,
|
name: String,
|
||||||
@@ -86,6 +185,32 @@ pub enum ConnEvent {
|
|||||||
Left(EndpointId),
|
Left(EndpointId),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Owned snapshot of a peer's *selected* QUIC path (the one currently carrying
|
||||||
|
/// application data), taken from the live audio connection for the UI's
|
||||||
|
/// connection-transparency badge. Counters are cumulative for the path's
|
||||||
|
/// lifetime; rate/loss derivation over a poll window happens in
|
||||||
|
/// `core::connstats` (which also detects path switches via `remote_addr`).
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PathSnapshot {
|
||||||
|
/// True when the path runs through a relay server, false for a direct
|
||||||
|
/// (holepunched or local) IP path.
|
||||||
|
pub is_relay: bool,
|
||||||
|
/// The path's remote transport address: `ip:port` for a direct path, the
|
||||||
|
/// relay URL for a relayed one.
|
||||||
|
pub remote_addr: String,
|
||||||
|
/// Current QUIC round-trip-time estimate for the path.
|
||||||
|
pub rtt: std::time::Duration,
|
||||||
|
/// Cumulative bytes sent in UDP datagrams on the path.
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
/// Cumulative bytes received in UDP datagrams on the path.
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
/// Cumulative UDP datagrams sent on the path (the loss denominator: for our
|
||||||
|
/// small voice frames these map ~1:1 to QUIC packets).
|
||||||
|
pub tx_datagrams: u64,
|
||||||
|
/// Cumulative packets detected lost on the path.
|
||||||
|
pub lost_packets: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct PeerSpeakTicket {
|
pub struct PeerSpeakTicket {
|
||||||
pub host_addr: iroh::EndpointAddr,
|
pub host_addr: iroh::EndpointAddr,
|
||||||
@@ -108,10 +233,12 @@ impl PeerSpeakTicket {
|
|||||||
/// is idempotent.
|
/// is idempotent.
|
||||||
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
|
pub fn restamp(ticket_str: &str, my_addr: iroh::EndpointAddr) -> String {
|
||||||
match ticket_str.parse::<PeerSpeakTicket>() {
|
match ticket_str.parse::<PeerSpeakTicket>() {
|
||||||
Ok(t) => {
|
Ok(t) => PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: my_addr, topic_id: t.topic_id, name: t.name }
|
host_addr: my_addr,
|
||||||
.to_string()
|
topic_id: t.topic_id,
|
||||||
|
name: t.name,
|
||||||
}
|
}
|
||||||
|
.to_string(),
|
||||||
Err(_) => ticket_str.to_string(),
|
Err(_) => ticket_str.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -120,7 +247,10 @@ impl PeerSpeakTicket {
|
|||||||
/// can't be parsed or carries no label. Pure; used to label the gathering both
|
/// 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.
|
/// in the room UI and in the presence we report to friends.
|
||||||
pub fn label_of(ticket_str: &str) -> String {
|
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
|
/// The room's `topic_id` embedded in a ticket string, or `None` if the ticket
|
||||||
@@ -128,7 +258,10 @@ impl PeerSpeakTicket {
|
|||||||
/// the recents list (the host address and label change between members/sessions,
|
/// the recents list (the host address and label change between members/sessions,
|
||||||
/// but the topic uniquely identifies the gathering).
|
/// but the topic uniquely identifies the gathering).
|
||||||
pub fn topic_of(ticket_str: &str) -> Option<[u8; 32]> {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,8 +284,8 @@ impl FromStr for PeerSpeakTicket {
|
|||||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
|
let decoded = base64::Engine::decode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, s)
|
||||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||||
let ticket: PeerSpeakTicket = serde_json::from_slice(&decoded)
|
let ticket: PeerSpeakTicket =
|
||||||
.map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
serde_json::from_slice(&decoded).map_err(|e| NetError::InvalidTicket(e.to_string()))?;
|
||||||
Ok(ticket)
|
Ok(ticket)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,13 +365,13 @@ pub trait RoomState: Send + Sync {
|
|||||||
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
|
async fn subscribe_events(&self) -> Result<Receiver<RoomEvent>, NetError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod iroh_impl;
|
|
||||||
pub mod gossip;
|
pub mod gossip;
|
||||||
|
pub mod iroh_impl;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use iroh::{SecretKey, EndpointAddr};
|
use iroh::{EndpointAddr, SecretKey};
|
||||||
|
|
||||||
fn sample_peer_state() -> PeerState {
|
fn sample_peer_state() -> PeerState {
|
||||||
let secret = SecretKey::generate();
|
let secret = SecretKey::generate();
|
||||||
@@ -250,6 +383,8 @@ mod tests {
|
|||||||
addr,
|
addr,
|
||||||
sharing: None,
|
sharing: None,
|
||||||
avatar: crate::avatar::Avatar::default(),
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: None,
|
||||||
|
music: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,8 +409,11 @@ mod tests {
|
|||||||
let host = SecretKey::generate().public();
|
let host = SecretKey::generate().public();
|
||||||
let topic_id = [3u8; 32];
|
let topic_id = [3u8; 32];
|
||||||
// A labelled ticket: restamp keeps the label, label_of reads it.
|
// A labelled ticket: restamp keeps the label, label_of reads it.
|
||||||
let labelled =
|
let labelled = PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
host_addr: EndpointAddr::from(host),
|
||||||
|
topic_id,
|
||||||
|
name: "HangOut".into(),
|
||||||
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
|
assert_eq!(PeerSpeakTicket::label_of(&labelled), "HangOut");
|
||||||
let member = SecretKey::generate().public();
|
let member = SecretKey::generate().public();
|
||||||
@@ -313,10 +451,8 @@ mod tests {
|
|||||||
|
|
||||||
// valid URL-safe-base64 that decodes to non-JSON bytes
|
// valid URL-safe-base64 that decodes to non-JSON bytes
|
||||||
let bad_json = b"hello world";
|
let bad_json = b"hello world";
|
||||||
let encoded = base64::Engine::encode(
|
let encoded =
|
||||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
|
base64::Engine::encode(&base64::engine::general_purpose::URL_SAFE_NO_PAD, bad_json);
|
||||||
bad_json,
|
|
||||||
);
|
|
||||||
let res3 = encoded.parse::<PeerSpeakTicket>();
|
let res3 = encoded.parse::<PeerSpeakTicket>();
|
||||||
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
|
assert!(matches!(res3, Err(NetError::InvalidTicket(_))));
|
||||||
}
|
}
|
||||||
@@ -327,8 +463,11 @@ mod tests {
|
|||||||
let host = SecretKey::generate().public();
|
let host = SecretKey::generate().public();
|
||||||
let member = SecretKey::generate().public();
|
let member = SecretKey::generate().public();
|
||||||
let topic_id = [42u8; 32];
|
let topic_id = [42u8; 32];
|
||||||
let original =
|
let original = PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id, name: "HangOut".into() }
|
host_addr: EndpointAddr::from(host),
|
||||||
|
topic_id,
|
||||||
|
name: "HangOut".into(),
|
||||||
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
|
let restamped_str = PeerSpeakTicket::restamp(&original, EndpointAddr::from(member));
|
||||||
@@ -344,18 +483,51 @@ mod tests {
|
|||||||
fn test_restamp_is_idempotent_for_same_addr() {
|
fn test_restamp_is_idempotent_for_same_addr() {
|
||||||
let me = SecretKey::generate().public();
|
let me = SecretKey::generate().public();
|
||||||
let topic_id = [7u8; 32];
|
let topic_id = [7u8; 32];
|
||||||
let mine =
|
let mine = PeerSpeakTicket {
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(me), topic_id, name: String::new() }
|
host_addr: EndpointAddr::from(me),
|
||||||
|
topic_id,
|
||||||
|
name: String::new(),
|
||||||
|
}
|
||||||
.to_string();
|
.to_string();
|
||||||
// Re-stamping my own ticket with my own addr changes nothing.
|
// 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]
|
#[test]
|
||||||
fn test_restamp_passes_through_unparseable() {
|
fn test_restamp_passes_through_unparseable() {
|
||||||
let me = SecretKey::generate().public();
|
let me = SecretKey::generate().public();
|
||||||
// A malformed ticket is returned unchanged (the join will fail anyway).
|
// A malformed ticket is returned unchanged (the join will fail anyway).
|
||||||
assert_eq!(PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)), "not-a-ticket");
|
assert_eq!(
|
||||||
|
PeerSpeakTicket::restamp("not-a-ticket", EndpointAddr::from(me)),
|
||||||
|
"not-a-ticket"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn self_presence_builds_peer_state_with_volatile_fields() {
|
||||||
|
let addr = EndpointAddr::from(SecretKey::generate().public());
|
||||||
|
let presence = SelfPresence {
|
||||||
|
name: "Alice".to_string(),
|
||||||
|
avatar: crate::avatar::Avatar::default(),
|
||||||
|
game: Some("Half-Life 2".to_string()),
|
||||||
|
music: None,
|
||||||
|
};
|
||||||
|
// Volatile fields come from the call; sticky fields from the struct.
|
||||||
|
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
|
||||||
|
assert_eq!(muted.name, "Alice");
|
||||||
|
assert!(muted.is_muted);
|
||||||
|
assert_eq!(muted.addr.id, addr.id);
|
||||||
|
assert_eq!(muted.sharing.as_deref(), Some("ticket"));
|
||||||
|
assert_eq!(muted.avatar, crate::avatar::Avatar::default());
|
||||||
|
assert_eq!(muted.game.as_deref(), Some("Half-Life 2"));
|
||||||
|
// The same sticky presence yields different volatile fields per announce.
|
||||||
|
let unmuted = presence.to_state(false, addr.clone(), None);
|
||||||
|
assert!(!unmuted.is_muted);
|
||||||
|
assert_eq!(unmuted.sharing, None);
|
||||||
|
assert_eq!(unmuted.name, muted.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -365,4 +537,21 @@ mod tests {
|
|||||||
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
|
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
|
||||||
assert_eq!(original, deserialized);
|
assert_eq!(original, deserialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn music_presence_serde_round_trip() {
|
||||||
|
let original = MusicPresence {
|
||||||
|
id: [3u8; 32],
|
||||||
|
name: "track.ogg".to_string(),
|
||||||
|
size: 1234,
|
||||||
|
paused: false,
|
||||||
|
anchor_ms: 1_700_000_000_000,
|
||||||
|
position_ms: 42_000,
|
||||||
|
next_id: None,
|
||||||
|
next_size: None,
|
||||||
|
};
|
||||||
|
let serialized = serde_json::to_string(&original).unwrap();
|
||||||
|
let deserialized: MusicPresence = serde_json::from_str(&serialized).unwrap();
|
||||||
|
assert_eq!(original, deserialized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,16 @@
|
|||||||
//! missing chime should never disrupt a call.
|
//! missing chime should never disrupt a call.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
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::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
static ENABLED: AtomicBool = AtomicBool::new(true);
|
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`
|
/// Per-sound enable flags (W6), indexed by `Sound::index`. The master `ENABLED`
|
||||||
/// toggle gates everything; these silence individual events while the master
|
/// 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
|
master_enabled && sound_enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// A notification event with a distinct chime.
|
/// A notification event with a distinct chime.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub enum Sound {
|
pub enum Sound {
|
||||||
@@ -195,14 +198,38 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
|
|||||||
if let Some(path) = guard.get(sound.name()) {
|
if let Some(path) = guard.get(sound.name()) {
|
||||||
return Some(path.clone());
|
return Some(path.clone());
|
||||||
}
|
}
|
||||||
let path = std::env::temp_dir().join(format!("peerspeak-{}.wav", sound.name()));
|
let path = match write_private_wav(&std::env::temp_dir(), sound.name(), sound.bytes()) {
|
||||||
if std::fs::write(&path, sound.bytes()).is_err() {
|
Ok(path) => path,
|
||||||
return None;
|
Err(_) => return None,
|
||||||
}
|
};
|
||||||
guard.insert(sound.name(), path.clone());
|
guard.insert(sound.name(), path.clone());
|
||||||
Some(path)
|
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))]
|
#[cfg(any(windows, test))]
|
||||||
fn escape_powershell_single_quoted(s: &str) -> String {
|
fn escape_powershell_single_quoted(s: &str) -> String {
|
||||||
s.replace('\'', "''")
|
s.replace('\'', "''")
|
||||||
@@ -249,6 +276,19 @@ fn spawn_player(path: &Path) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn test_should_play_truth_table() {
|
fn test_should_play_truth_table() {
|
||||||
// Plays only when BOTH the master and the per-sound flag are on.
|
// 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"),
|
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
|
||||||
r"C:\Users\O''Brien\chime.wav"
|
r"C:\Users\O''Brien\chime.wav"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(escape_powershell_single_quoted("a'b'c"), "a''b''c");
|
||||||
escape_powershell_single_quoted("a'b'c"),
|
|
||||||
"a''b''c"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -297,7 +334,10 @@ mod tests {
|
|||||||
// bare `~` -> home dir
|
// bare `~` -> home dir
|
||||||
assert_eq!(expand_tilde("~"), home);
|
assert_eq!(expand_tilde("~"), home);
|
||||||
// `~/sub/dir/file.wav` -> home joined with `sub/dir/file.wav`
|
// `~/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
|
// absolute path (`/etc/foo.wav`) -> unchanged
|
||||||
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
|
assert_eq!(expand_tilde("/etc/foo.wav"), PathBuf::from("/etc/foo.wav"));
|
||||||
@@ -310,10 +350,19 @@ mod tests {
|
|||||||
// leading/trailing whitespace is trimmed
|
// leading/trailing whitespace is trimmed
|
||||||
if let Some(home) = dirs::home_dir() {
|
if let Some(home) = dirs::home_dir() {
|
||||||
assert_eq!(expand_tilde(" ~ "), home);
|
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!(
|
||||||
assert_eq!(expand_tilde(" foo/bar.wav "), PathBuf::from("foo/bar.wav"));
|
expand_tilde(" /etc/foo.wav "),
|
||||||
|
PathBuf::from("/etc/foo.wav")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
expand_tilde(" foo/bar.wav "),
|
||||||
|
PathBuf::from("foo/bar.wav")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -332,4 +381,61 @@ mod tests {
|
|||||||
// a `~`-prefixed path that resolves to a non-existent file -> Some(false)
|
// a `~`-prefixed path that resolves to a non-existent file -> Some(false)
|
||||||
assert_eq!(validate_custom_path("~/non/existent/file.wav"), 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum PlaylistKind {
|
||||||
|
M3u,
|
||||||
|
Pls,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a path by extension into a playlist kind, or None if it is not a
|
||||||
|
/// recognized playlist file. Case-insensitive: m3u/m3u8 -> M3u, pls -> Pls.
|
||||||
|
pub fn playlist_kind(path: &Path) -> Option<PlaylistKind> {
|
||||||
|
let ext = path.extension()?.to_string_lossy();
|
||||||
|
match ext.to_ascii_lowercase().as_str() {
|
||||||
|
"m3u" | "m3u8" => Some(PlaylistKind::M3u),
|
||||||
|
"pls" => Some(PlaylistKind::Pls),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an m3u/m3u8 or pls playlist into local audio file paths. Remote entries
|
||||||
|
/// (http/https/ftp URLs) and non-audio entries are skipped; relative paths are
|
||||||
|
/// resolved against `base_dir` (the playlist file's parent directory). Order is
|
||||||
|
/// preserved. Does not touch the filesystem.
|
||||||
|
pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Vec<PathBuf> {
|
||||||
|
let entries: Vec<&str> = match kind {
|
||||||
|
PlaylistKind::M3u => contents
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||||||
|
.collect(),
|
||||||
|
PlaylistKind::Pls => contents
|
||||||
|
.lines()
|
||||||
|
.filter_map(|line| {
|
||||||
|
let (key, value) = line.split_once('=')?;
|
||||||
|
key.trim()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("file")
|
||||||
|
.then_some(value.trim())
|
||||||
|
})
|
||||||
|
.filter(|line| !line.is_empty())
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|entry| playlist_entry_path(entry, base_dir))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
|
||||||
|
let lower = entry.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Path::new(entry);
|
||||||
|
let resolved = if path.is_absolute() {
|
||||||
|
path.to_path_buf()
|
||||||
|
} else {
|
||||||
|
base_dir.join(path)
|
||||||
|
};
|
||||||
|
let file_name = resolved.file_name()?.to_string_lossy();
|
||||||
|
crate::files::looks_like_audio_name(&file_name).then_some(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn m3u_skips_comments_and_remote_urls() {
|
||||||
|
let base = Path::new("/music/lists");
|
||||||
|
let contents = "\
|
||||||
|
#EXTM3U
|
||||||
|
#EXTINF:123,Artist - Song
|
||||||
|
tracks/song.ogg
|
||||||
|
https://example.com/stream.mp3
|
||||||
|
";
|
||||||
|
assert_eq!(
|
||||||
|
parse_playlist(contents, base, PlaylistKind::M3u),
|
||||||
|
vec![PathBuf::from("/music/lists/tracks/song.ogg")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pls_keeps_file_values_and_skips_non_audio() {
|
||||||
|
let base = Path::new("/music");
|
||||||
|
let contents = "\
|
||||||
|
[playlist]
|
||||||
|
File1=one.flac
|
||||||
|
Title1=One
|
||||||
|
File2=notes.txt
|
||||||
|
File3=/var/audio/two.MP3
|
||||||
|
";
|
||||||
|
assert_eq!(
|
||||||
|
parse_playlist(contents, base, PlaylistKind::Pls),
|
||||||
|
vec![
|
||||||
|
PathBuf::from("/music/one.flac"),
|
||||||
|
PathBuf::from("/var/audio/two.MP3"),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn playlist_kind_is_case_insensitive() {
|
||||||
|
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
|
||||||
|
assert_eq!(
|
||||||
|
playlist_kind(Path::new("mix.m3u8")),
|
||||||
|
Some(PlaylistKind::M3u)
|
||||||
|
);
|
||||||
|
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
|
||||||
|
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,8 +36,11 @@ pub enum PresenceMode {
|
|||||||
|
|
||||||
impl PresenceMode {
|
impl PresenceMode {
|
||||||
/// All postures, default first — the option list for the Settings/home picker.
|
/// All postures, default first — the option list for the Settings/home picker.
|
||||||
pub const ALL: [PresenceMode; 3] =
|
pub const ALL: [PresenceMode; 3] = [
|
||||||
[PresenceMode::Normal, PresenceMode::Invisible, PresenceMode::Discoverable];
|
PresenceMode::Normal,
|
||||||
|
PresenceMode::Invisible,
|
||||||
|
PresenceMode::Discoverable,
|
||||||
|
];
|
||||||
|
|
||||||
/// Whether this posture publishes to discovery (the only mode that does).
|
/// Whether this posture publishes to discovery (the only mode that does).
|
||||||
pub fn publishes_to_discovery(self) -> bool {
|
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)
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FriendPresence {
|
pub enum FriendPresence {
|
||||||
/// Online, but not in a gathering we can join.
|
/// 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
|
/// Online and in a joinable gathering (name already sanitized, ticket already
|
||||||
/// validated as parseable).
|
/// validated as parseable).
|
||||||
InRoom { name: String, ticket: String },
|
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
|
/// 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -173,7 +196,11 @@ mod tests {
|
|||||||
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
|
assert!(!should_answer(&friend, &friends, PresenceMode::Invisible));
|
||||||
// Stranger is NEVER answered, in any mode.
|
// Stranger is NEVER answered, in any mode.
|
||||||
assert!(!should_answer(&stranger, &friends, PresenceMode::Normal));
|
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));
|
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +221,10 @@ mod tests {
|
|||||||
ControlMsg::Ping,
|
ControlMsg::Ping,
|
||||||
ControlMsg::Pong { room: None },
|
ControlMsg::Pong { room: None },
|
||||||
ControlMsg::Pong {
|
ControlMsg::Pong {
|
||||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: "abc".into() }),
|
room: Some(RoomPresence {
|
||||||
|
name: "HangOut".into(),
|
||||||
|
ticket: "abc".into(),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
for msg in cases {
|
for msg in cases {
|
||||||
@@ -225,19 +255,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
// Valid ticket -> InRoom with a sanitized name.
|
// Valid ticket -> InRoom with a sanitized name.
|
||||||
let t = valid_ticket(friend);
|
let t = valid_ticket(friend);
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
|
&ControlMsg::Pong {
|
||||||
}, friend);
|
room: Some(RoomPresence {
|
||||||
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
|
name: "HangOut".into(),
|
||||||
|
ticket: t.clone(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
friend,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got,
|
||||||
|
Some(FriendPresence::InRoom {
|
||||||
|
name: "HangOut".into(),
|
||||||
|
ticket: t
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
|
fn interpret_pong_downgrades_a_garbage_ticket_to_online() {
|
||||||
// A friend reporting a room with an unparseable ticket is treated as just
|
// A friend reporting a room with an unparseable ticket is treated as just
|
||||||
// Online — no dead/hostile Join button is surfaced.
|
// Online — no dead/hostile Join button is surfaced.
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
|
&ControlMsg::Pong {
|
||||||
}, id());
|
room: Some(RoomPresence {
|
||||||
|
name: "Trap".into(),
|
||||||
|
ticket: "not-a-ticket".into(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
id(),
|
||||||
|
);
|
||||||
assert_eq!(got, Some(FriendPresence::Online));
|
assert_eq!(got, Some(FriendPresence::Online));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,20 +294,67 @@ mod tests {
|
|||||||
let friend = id();
|
let friend = id();
|
||||||
let attacker = id();
|
let attacker = id();
|
||||||
let t = valid_ticket(attacker);
|
let t = valid_ticket(attacker);
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
|
&ControlMsg::Pong {
|
||||||
}, friend);
|
room: Some(RoomPresence {
|
||||||
|
name: "Redirect".into(),
|
||||||
|
ticket: t,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
friend,
|
||||||
|
);
|
||||||
assert_eq!(got, Some(FriendPresence::Online));
|
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]
|
#[test]
|
||||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||||
// Control/bidi characters in a peer-supplied name are stripped.
|
// Control/bidi characters in a peer-supplied name are stripped.
|
||||||
let friend = id();
|
let friend = id();
|
||||||
let t = valid_ticket(friend);
|
let t = valid_ticket(friend);
|
||||||
let got = interpret_pong(&ControlMsg::Pong {
|
let got = interpret_pong(
|
||||||
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }),
|
&ControlMsg::Pong {
|
||||||
}, friend);
|
room: Some(RoomPresence {
|
||||||
|
name: "Hang\u{202e}Out\u{0007}".into(),
|
||||||
|
ticket: t.clone(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
friend,
|
||||||
|
);
|
||||||
match got {
|
match got {
|
||||||
Some(FriendPresence::InRoom { name, .. }) => {
|
Some(FriendPresence::InRoom { name, .. }) => {
|
||||||
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
assert!(!name.contains('\u{202e}'), "bidi override must be stripped");
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
|
|||||||
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
/// malformed) — the caller treats that as "appears offline". `peer` is usually a
|
||||||
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
/// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
|
||||||
/// also accepted (and used by hermetic tests).
|
/// 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))
|
let conn = tokio::time::timeout(IO_TIMEOUT, endpoint.connect(peer, FRIENDS_ALPN))
|
||||||
.await
|
.await
|
||||||
.context("timed out connecting to peer")?
|
.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 from = conn.remote_id();
|
||||||
|
|
||||||
let io = async {
|
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)?)
|
send.write_all(&encode(&ControlMsg::Ping)?)
|
||||||
.await
|
.await
|
||||||
.context("failed to write ping")?;
|
.context("failed to write ping")?;
|
||||||
@@ -118,7 +124,10 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
|
|||||||
|
|
||||||
let io = async {
|
let io = async {
|
||||||
let (mut send, mut recv) = conn.accept_bi().await.context("failed to accept stream")?;
|
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)? {
|
match decode(&bytes)? {
|
||||||
ControlMsg::Ping => {}
|
ControlMsg::Ping => {}
|
||||||
other => bail!("expected a ping, got {other:?}"),
|
other => bail!("expected a ping, got {other:?}"),
|
||||||
@@ -211,7 +220,10 @@ mod tests {
|
|||||||
let handler: Handler = Arc::new(move |from| {
|
let handler: Handler = Arc::new(move |from| {
|
||||||
if from == allowed {
|
if from == allowed {
|
||||||
Some(ControlMsg::Pong {
|
Some(ControlMsg::Pong {
|
||||||
room: Some(RoomPresence { name: "HangOut".into(), ticket: "t".into() }),
|
room: Some(RoomPresence {
|
||||||
|
name: "HangOut".into(),
|
||||||
|
ticket: "t".into(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None // stranger -> no reply
|
None // stranger -> no reply
|
||||||
@@ -221,7 +233,8 @@ mod tests {
|
|||||||
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
let serve_task = tokio::spawn(async move { serve(server_ep, handler).await });
|
||||||
|
|
||||||
// The allowed prober gets a Pong with the room.
|
// The allowed prober gets a Pong with the room.
|
||||||
let (from, pong) = tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
let (from, pong) =
|
||||||
|
tokio::time::timeout(Duration::from_secs(15), probe(&prober, server_addr.clone()))
|
||||||
.await
|
.await
|
||||||
.expect("probe timed out")
|
.expect("probe timed out")
|
||||||
.expect("probe failed");
|
.expect("probe failed");
|
||||||
|
|||||||
@@ -26,7 +26,24 @@ pub const FRIENDS_PROTO: u32 = 1;
|
|||||||
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
|
||||||
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
|
||||||
/// to fail fast rather than half-work.
|
/// to fail fast rather than half-work.
|
||||||
pub const GOSSIP_PROTO: u32 = 2;
|
///
|
||||||
|
/// v3 (0.4.0): `PeerState` gained an optional `game` presence field (the
|
||||||
|
/// `Playing <name>` status). The field is `#[serde(default)]`, so the bump isn't
|
||||||
|
/// strictly required for decoding — but per the versioning discipline a wire-shape
|
||||||
|
/// change is isolated into its own topic + signature domain so v2 and v3 peers
|
||||||
|
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
|
||||||
|
///
|
||||||
|
/// v4–v5 (0.6.0): the W22 shared-listening / music presence work. `PeerState`
|
||||||
|
/// gained an optional `music` presence field (a current shared-listening track
|
||||||
|
/// descriptor + playback timeline; the audio bytes still ride the files plane by
|
||||||
|
/// id, gossip carries only the descriptor/timeline), and `MusicPresence` then
|
||||||
|
/// gained optional prefetch hints for the next track so tuned-in listeners can
|
||||||
|
/// fetch it before the DJ advances. Both shipped together in the **0.6.0** release
|
||||||
|
/// (commit `bca2ccd`), where the const advanced straight `3 → 5`: there was never
|
||||||
|
/// a `GOSSIP_PROTO == 4` build — 4 is a skipped step. (Per `VERSIONING.md` this
|
||||||
|
/// breaking gossip change rode the `0.5.1 → 0.6.0` MINOR bump, so the discipline
|
||||||
|
/// was honoured; 0.6.1 is a wire-compatible PATCH on top, still proto 5.)
|
||||||
|
pub const GOSSIP_PROTO: u32 = 5;
|
||||||
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
/// File-transfer plane version (chat attachment request/stream shape). Bump on
|
||||||
/// any change. Mirrored in [`FILES_ALPN`].
|
/// any change. Mirrored in [`FILES_ALPN`].
|
||||||
pub const FILES_PROTO: u32 = 1;
|
pub const FILES_PROTO: u32 = 1;
|
||||||
@@ -41,7 +58,7 @@ pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
|
|||||||
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
|
||||||
/// the gossip protocol version into every signed payload — a version mismatch
|
/// the gossip protocol version into every signed payload — a version mismatch
|
||||||
/// fails verification (cryptographic separation between gossip versions).
|
/// fails verification (cryptographic separation between gossip versions).
|
||||||
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2";
|
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v5";
|
||||||
|
|
||||||
/// Version-namespace a room topic so peers on different gossip protocol versions
|
/// Version-namespace a room topic so peers on different gossip protocol versions
|
||||||
/// derive **different subscription topics from the same ticket** and therefore
|
/// derive **different subscription topics from the same ticket** and therefore
|
||||||
@@ -70,10 +87,22 @@ mod tests {
|
|||||||
/// so a version bump can't silently forget to update the wire string.
|
/// so a version bump can't silently forget to update the wire string.
|
||||||
#[test]
|
#[test]
|
||||||
fn alpns_match_their_proto_versions() {
|
fn alpns_match_their_proto_versions() {
|
||||||
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
|
assert_eq!(
|
||||||
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
|
AUDIO_ALPN,
|
||||||
assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes());
|
format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes()
|
||||||
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
|
);
|
||||||
|
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]
|
#[test]
|
||||||
@@ -82,7 +111,11 @@ mod tests {
|
|||||||
let mut b = a;
|
let mut b = a;
|
||||||
b[5] = 10;
|
b[5] = 10;
|
||||||
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -51,7 +51,14 @@ fn same_room(a: &str, b: &str) -> bool {
|
|||||||
/// supplies `now` (unix seconds) and persists the list afterwards.
|
/// supplies `now` (unix seconds) and persists the list afterwards.
|
||||||
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
|
pub fn push_recent(list: &mut Vec<Recent>, name: String, ticket: String, now: u64) {
|
||||||
list.retain(|r| !same_room(&r.ticket, &ticket));
|
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);
|
list.truncate(RECENTS_MAX);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +93,11 @@ mod tests {
|
|||||||
/// Build a real, parseable ticket for a fresh room with the given label.
|
/// Build a real, parseable ticket for a fresh room with the given label.
|
||||||
fn ticket(name: &str, topic: [u8; 32]) -> String {
|
fn ticket(name: &str, topic: [u8; 32]) -> String {
|
||||||
let host = SecretKey::generate().public();
|
let host = SecretKey::generate().public();
|
||||||
PeerSpeakTicket { host_addr: EndpointAddr::from(host), topic_id: topic, name: name.into() }
|
PeerSpeakTicket {
|
||||||
|
host_addr: EndpointAddr::from(host),
|
||||||
|
topic_id: topic,
|
||||||
|
name: name.into(),
|
||||||
|
}
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,96 +3,268 @@
|
|||||||
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is
|
//! Peer display names ride the gossip presence plane (`PeerState.name`), which is
|
||||||
//! untrusted and spoofable, yet they're rendered directly in the roster. This
|
//! untrusted and spoofable, yet they're rendered directly in the roster. This
|
||||||
//! module cleans a name at the gossip ingest point so every downstream consumer
|
//! module cleans a name at the gossip ingest point so every downstream consumer
|
||||||
//! gets a safe value (security finding S4). Chat text has its own sanitizer in
|
//! gets a safe value (security finding S4). Chat text policy ([`sanitize_chat`],
|
||||||
//! the UI layer (`app::sanitize_chat`).
|
//! [`cap_chat_input`], [`admit_chat_text`]) also lives here so the UI, the gossip
|
||||||
|
//! sign point, and the gossip ingress all enforce the same ceilings.
|
||||||
|
|
||||||
/// Max characters kept for a peer's display name after sanitizing. Names are
|
/// Max characters kept for a peer's display name after sanitizing. Names are
|
||||||
/// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps
|
/// short labels, so a tight cap both prevents UI/layout/memory abuse and keeps
|
||||||
/// the roster readable.
|
/// the roster readable.
|
||||||
pub const NAME_MAX_CHARS: usize = 48;
|
pub const NAME_MAX_CHARS: usize = 48;
|
||||||
|
|
||||||
|
/// Bidirectional override/isolate format characters (`General_Category=Cf`, NOT
|
||||||
|
/// caught by [`char::is_control`]) that can visually reorder surrounding text.
|
||||||
|
/// Stripped even from expressive chat bodies (security finding S14): unlike the
|
||||||
|
/// benign zero-width joiners/marks, these let a sender make rendered text read
|
||||||
|
/// differently from what was actually sent.
|
||||||
|
pub(crate) fn is_bidi_override_char(c: char) -> bool {
|
||||||
|
matches!(c,
|
||||||
|
'\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
|
||||||
|
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
|
/// Unicode *format* characters (`General_Category=Cf`) that can spoof or garble a
|
||||||
/// rendered name even though they are NOT caught by [`char::is_control`]:
|
/// rendered name even though they are NOT caught by [`char::is_control`]:
|
||||||
/// bidirectional overrides/isolates (text-direction spoofing) and
|
/// bidirectional overrides/isolates (text-direction spoofing) and
|
||||||
/// zero-width / BOM characters (invisible, can hide or fake content). Listed
|
/// zero-width / BOM characters (invisible, can hide or fake content). Listed
|
||||||
/// explicitly so the sanitizer stays dependency-free (std exposes no category
|
/// explicitly so the sanitizer stays dependency-free (std exposes no category
|
||||||
/// query). Stripped outright rather than replaced.
|
/// query). Stripped outright rather than replaced.
|
||||||
fn is_spoofing_format_char(c: char) -> bool {
|
pub(crate) fn is_spoofing_format_char(c: char) -> bool {
|
||||||
matches!(c,
|
is_bidi_override_char(c)
|
||||||
|
|| matches!(c,
|
||||||
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
|
'\u{200B}'..='\u{200F}' // zero-width space, ZWNJ, ZWJ, LRM, RLM
|
||||||
| '\u{202A}'..='\u{202E}' // LRE, RLE, PDF, LRO, RLO (bidi overrides)
|
|
||||||
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
|
| '\u{2060}'..='\u{2064}' // word joiner .. invisible plus
|
||||||
| '\u{2066}'..='\u{2069}' // LRI, RLI, FSI, PDI (bidi isolates)
|
|
||||||
| '\u{FEFF}' // BOM / zero-width no-break space
|
| '\u{FEFF}' // BOM / zero-width no-break space
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Max characters kept for a broadcast game-presence label after sanitizing
|
||||||
|
/// (game titles run longer than nicknames, so a wider cap than [`NAME_MAX_CHARS`]),
|
||||||
|
/// bounded additionally by [`GAME_LABEL_MAX_BYTES`] so a multibyte-heavy string
|
||||||
|
/// can't blow the presence frame.
|
||||||
|
pub const GAME_LABEL_MAX_CHARS: usize = 64;
|
||||||
|
/// Max UTF-8 bytes kept for a broadcast game-presence label, applied on top of
|
||||||
|
/// [`GAME_LABEL_MAX_CHARS`]. Caps the on-wire size regardless of scalar width.
|
||||||
|
pub const GAME_LABEL_MAX_BYTES: usize = 256;
|
||||||
|
|
||||||
|
/// Shared cleaning for untrusted short labels: strip bidi / zero-width spoofing
|
||||||
|
/// format characters, turn control characters into spaces, collapse any whitespace
|
||||||
|
/// run to a single space, and trim the ends. Length capping is the caller's job.
|
||||||
|
fn clean_label(input: &str) -> String {
|
||||||
|
let cleaned: String = input
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !is_spoofing_format_char(*c))
|
||||||
|
.map(|c| if c.is_control() { ' ' } else { c })
|
||||||
|
.collect();
|
||||||
|
cleaned.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
|
/// Sanitize an untrusted peer display name for safe rendering. Strips bidi /
|
||||||
/// zero-width format characters, turns control characters into spaces, collapses
|
/// zero-width format characters, turns control characters into spaces, collapses
|
||||||
/// any whitespace run to a single space, trims the ends, and caps the length at
|
/// any whitespace run to a single space, trims the ends, and caps the length at
|
||||||
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
|
/// [`NAME_MAX_CHARS`]. Returns `""` if nothing usable remains (callers may
|
||||||
/// substitute a placeholder such as a short id).
|
/// substitute a placeholder such as a short id).
|
||||||
pub fn sanitize_name(input: &str) -> String {
|
pub fn sanitize_name(input: &str) -> String {
|
||||||
let cleaned: String = input
|
clean_label(input).chars().take(NAME_MAX_CHARS).collect()
|
||||||
.chars()
|
|
||||||
.filter(|c| !is_spoofing_format_char(*c))
|
|
||||||
.map(|c| if c.is_control() { ' ' } else { c })
|
|
||||||
.collect();
|
|
||||||
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
|
||||||
collapsed.chars().take(NAME_MAX_CHARS).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A piece of a chat message after URL detection: literal text or a link.
|
/// Sanitize an untrusted game-presence label (the `Playing <name>` status that
|
||||||
#[derive(Debug, PartialEq, Eq, Clone)]
|
/// rides the gossip presence plane). Same spoof/control cleaning as
|
||||||
pub enum Segment {
|
/// [`sanitize_name`], but capped at [`GAME_LABEL_MAX_CHARS`] scalars AND
|
||||||
/// Plain text to render as-is.
|
/// [`GAME_LABEL_MAX_BYTES`] bytes. Apply on BOTH the outgoing label we detect and
|
||||||
Text(String),
|
/// any incoming peer label. Returns `""` if nothing usable remains (no broadcast).
|
||||||
/// A detected URL to render as a clickable link (also its href).
|
pub fn sanitize_game_label(input: &str) -> String {
|
||||||
Link(String),
|
let mut out = String::new();
|
||||||
|
for c in clean_label(input).chars().take(GAME_LABEL_MAX_CHARS) {
|
||||||
|
if out.len() + c.len_utf8() > GAME_LABEL_MAX_BYTES {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max characters kept for a single chat message after sanitizing.
|
||||||
|
pub const CHAT_MSG_MAX_CHARS: usize = 2000;
|
||||||
|
|
||||||
|
/// Max UTF-8 bytes kept for a single chat message, enforced alongside
|
||||||
|
/// [`CHAT_MSG_MAX_CHARS`] (2,000 four-byte scalars would otherwise reach 8,000
|
||||||
|
/// bytes). This is also the ingress bound: signed peers never produce more, so
|
||||||
|
/// raw incoming text above it is rejected outright (see [`admit_chat_text`]).
|
||||||
|
pub const CHAT_MSG_MAX_BYTES: usize = 8 * 1024;
|
||||||
|
|
||||||
|
/// Sanitize a chat message body, applied to BOTH our outgoing text (before local
|
||||||
|
/// echo, and again at the gossip sign point) and incoming peer text (untrusted —
|
||||||
|
/// a buggy/malicious sender could include control characters or an enormous
|
||||||
|
/// payload). Single pass: bidi overrides/isolates are stripped outright (S14 —
|
||||||
|
/// they can visually reorder the rendered line), control characters become
|
||||||
|
/// spaces, any whitespace run collapses to a single space, the ends are trimmed,
|
||||||
|
/// and both the character and UTF-8 byte ceilings are enforced without ever
|
||||||
|
/// splitting a scalar. Message bodies deliberately keep the OTHER format
|
||||||
|
/// characters (ZWJ/ZWNJ/LRM/RLM etc.) that the short-label sanitizers strip —
|
||||||
|
/// chat is expressive text, not a label, and those are needed for emoji
|
||||||
|
/// sequences and joining scripts. Returns `""` for input with no visible text
|
||||||
|
/// (callers drop empty messages). Idempotent, so layered application converges
|
||||||
|
/// on the same result.
|
||||||
|
pub fn sanitize_chat(input: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut chars = 0usize;
|
||||||
|
let mut pending_space = false;
|
||||||
|
for c in input.chars() {
|
||||||
|
if is_bidi_override_char(c) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let c = if c.is_control() { ' ' } else { c };
|
||||||
|
if c.is_whitespace() {
|
||||||
|
// Trim: only mark a separator once visible text exists; a trailing
|
||||||
|
// run is never emitted because the space lands with the NEXT char.
|
||||||
|
pending_space = !out.is_empty();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let sep = usize::from(pending_space);
|
||||||
|
if chars + sep + 1 > CHAT_MSG_MAX_CHARS
|
||||||
|
|| out.len() + sep + c.len_utf8() > CHAT_MSG_MAX_BYTES
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if pending_space {
|
||||||
|
out.push(' ');
|
||||||
|
chars += 1;
|
||||||
|
pending_space = false;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
chars += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cap the LIVE chat-input text (typing, clipboard/primary-selection paste,
|
||||||
|
/// context-menu paste) at the chat ceilings. Unlike [`sanitize_chat`] this
|
||||||
|
/// preserves the user's whitespace exactly — normalization stays a submit-time
|
||||||
|
/// operation so the visible text never jumps while editing — and only truncates,
|
||||||
|
/// always on a scalar boundary. Returns the input unchanged when within bounds.
|
||||||
|
pub fn cap_chat_input(input: String) -> String {
|
||||||
|
if input.len() <= CHAT_MSG_MAX_BYTES && input.chars().count() <= CHAT_MSG_MAX_CHARS {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
let mut out = String::new();
|
||||||
|
for (chars, c) in input.chars().enumerate() {
|
||||||
|
if chars >= CHAT_MSG_MAX_CHARS || out.len() + c.len_utf8() > CHAT_MSG_MAX_BYTES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gossip-ingress admission for an untrusted incoming chat body. `None` drops
|
||||||
|
/// the message: raw text over the byte ceiling is rejected BEFORE any
|
||||||
|
/// sanitization work (a compliant sender sanitizes before signing, so oversized
|
||||||
|
/// text is a protocol violation, not something to repair), and a message with
|
||||||
|
/// neither visible text nor an attachment carries nothing to show. Otherwise
|
||||||
|
/// yields the sanitized (possibly empty, attachment-only) body to forward.
|
||||||
|
pub fn admit_chat_text(raw: &str, has_attachment: bool) -> Option<String> {
|
||||||
|
if raw.len() > CHAT_MSG_MAX_BYTES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let text = sanitize_chat(raw);
|
||||||
|
(!text.is_empty() || has_attachment).then_some(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max clickable links rendered per chat message. Later URL candidates stay
|
||||||
|
/// selectable plain text — bounds both the span count a message can force the
|
||||||
|
/// renderer to build and the opener targets one line can carry.
|
||||||
|
pub const CHAT_MSG_MAX_LINKS: usize = 8;
|
||||||
|
|
||||||
/// Trailing characters commonly adjacent to a URL in prose that should NOT be
|
/// 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).
|
/// part of the link (so "see http://x.com." or "(http://x.com)" linkify cleanly).
|
||||||
fn is_url_trailing_punct(c: char) -> bool {
|
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`,
|
/// Find the byte index of the earliest `http://` or `https://` scheme in `s`
|
||||||
|
/// (ASCII-case-insensitive, so a sentence-capitalized "Http://…" still counts),
|
||||||
/// scanning only on char boundaries so slicing is always safe.
|
/// scanning only on char boundaries so slicing is always safe.
|
||||||
fn find_scheme(s: &str) -> Option<usize> {
|
fn find_scheme(s: &str) -> Option<usize> {
|
||||||
s.char_indices().find_map(|(i, _)| {
|
s.char_indices().find_map(|(i, _)| {
|
||||||
let tail = &s[i..];
|
let tail = &s[i..];
|
||||||
(tail.starts_with("http://") || tail.starts_with("https://")).then_some(i)
|
let matches_prefix = |p: &str| {
|
||||||
|
tail.get(..p.len())
|
||||||
|
.is_some_and(|t| t.eq_ignore_ascii_case(p))
|
||||||
|
};
|
||||||
|
(matches_prefix("http://") || matches_prefix("https://")).then_some(i)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Split an (already chat-sanitized) message into plain-text and URL [`Segment`]s
|
/// The clickable-link policy, shared by link detection ([`link_ranges`]) and the
|
||||||
/// for rendering. **Conservative on purpose:** only `http://` / `https://` runs
|
/// opener's defence-in-depth re-check (`AppMessage::OpenUrl`): the candidate must
|
||||||
/// are treated as links, each ending at the first whitespace, with trailing prose
|
/// parse as a URL with an `http`/`https` scheme, a non-empty host, and NO
|
||||||
/// punctuation peeled back into the following text. Concatenating every segment's
|
/// username/password syntax (`http://user@host` reads as a credential but is a
|
||||||
/// inner string reproduces the input exactly (no characters added or dropped), so
|
/// classic destination-spoof — such text stays plain, never clickable).
|
||||||
/// it's purely a presentational split. Linkify AFTER sanitizing so control/format
|
pub fn is_safe_web_url(s: &str) -> bool {
|
||||||
/// chars are already gone (the URL can't smuggle them). Pure → unit-testable.
|
let Ok(u) = url::Url::parse(s) else {
|
||||||
pub fn linkify(input: &str) -> Vec<Segment> {
|
return false;
|
||||||
|
};
|
||||||
|
matches!(u.scheme(), "http" | "https")
|
||||||
|
&& u.host_str().is_some_and(|h| !h.is_empty())
|
||||||
|
&& u.username().is_empty()
|
||||||
|
&& u.password().is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect clickable links in an (already chat-sanitized) message, returning the
|
||||||
|
/// byte range of each — computed ONCE when a message enters history and cached
|
||||||
|
/// on its entry, so redraws slice instead of rescanning. **Conservative on
|
||||||
|
/// purpose:** only `http://` / `https://` runs count, each ending at the first
|
||||||
|
/// whitespace with trailing prose punctuation peeled off, and only candidates
|
||||||
|
/// passing [`is_safe_web_url`] become links — a failing candidate's whole
|
||||||
|
/// whitespace-delimited run stays plain text (its interior is not re-scanned).
|
||||||
|
/// At most [`CHAT_MSG_MAX_LINKS`] ranges; ranges are ascending, non-overlapping,
|
||||||
|
/// and always on char boundaries. The href is exactly the displayed slice, so
|
||||||
|
/// what the user sees IS what the opener receives.
|
||||||
|
pub fn link_ranges(text: &str) -> Vec<std::ops::Range<usize>> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let mut rest = input;
|
let mut base = 0usize;
|
||||||
while !rest.is_empty() {
|
while out.len() < CHAT_MSG_MAX_LINKS {
|
||||||
let Some(start) = find_scheme(rest) else {
|
let Some(start) = find_scheme(&text[base..]) else {
|
||||||
out.push(Segment::Text(rest.to_string()));
|
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
if start > 0 {
|
let run_start = base + start;
|
||||||
out.push(Segment::Text(rest[..start].to_string()));
|
let run = &text[run_start..];
|
||||||
|
let run_end = run.find(char::is_whitespace).unwrap_or(run.len());
|
||||||
|
// Peel trailing punctuation back out of the candidate; a run is at least
|
||||||
|
// the 7-byte scheme long, so `base` always advances.
|
||||||
|
let candidate = run[..run_end].trim_end_matches(is_url_trailing_punct);
|
||||||
|
if is_safe_web_url(candidate) {
|
||||||
|
out.push(run_start..run_start + candidate.len());
|
||||||
|
base = run_start + candidate.len();
|
||||||
|
} else {
|
||||||
|
base = run_start + run_end;
|
||||||
}
|
}
|
||||||
let after = &rest[start..];
|
}
|
||||||
let end = after.find(char::is_whitespace).unwrap_or(after.len());
|
out
|
||||||
let candidate = &after[..end];
|
}
|
||||||
// Peel trailing punctuation back out of the link.
|
|
||||||
let url = candidate.trim_end_matches(is_url_trailing_punct);
|
/// Split `text` into `(slice, is_link)` pieces from cached [`link_ranges`]
|
||||||
out.push(Segment::Link(url.to_string()));
|
/// output. Concatenating the slices reproduces `text` exactly (purely a
|
||||||
// Continue past just the URL; any peeled punctuation + the rest (incl. the
|
/// presentational split — no characters added or dropped). Borrows, so a redraw
|
||||||
// whitespace) is reconsidered as ordinary text on the next iteration.
|
/// allocates nothing for plain text. `ranges` must come from [`link_ranges`] on
|
||||||
rest = &after[url.len()..];
|
/// this same `text` (ascending, non-overlapping, char-boundary ranges).
|
||||||
|
pub fn segments<'a>(text: &'a str, ranges: &[std::ops::Range<usize>]) -> Vec<(&'a str, bool)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut pos = 0usize;
|
||||||
|
for r in ranges {
|
||||||
|
if r.start > pos {
|
||||||
|
out.push((&text[pos..r.start], false));
|
||||||
|
}
|
||||||
|
out.push((&text[r.clone()], true));
|
||||||
|
pos = r.end;
|
||||||
|
}
|
||||||
|
if pos < text.len() {
|
||||||
|
out.push((&text[pos..], false));
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -113,7 +285,10 @@ mod tests {
|
|||||||
fn strips_control_chars_and_collapses_whitespace() {
|
fn strips_control_chars_and_collapses_whitespace() {
|
||||||
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
|
// NUL, CR/LF, TAB, and ANSI ESC are control chars → become spaces, then
|
||||||
// collapse; ends trim.
|
// 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.
|
// A name that is only control/whitespace cleans to empty.
|
||||||
assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
|
assert_eq!(sanitize_name("\u{0}\r\n\t "), "");
|
||||||
}
|
}
|
||||||
@@ -135,85 +310,242 @@ mod tests {
|
|||||||
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
assert_eq!(sanitize_name(&long).chars().count(), NAME_MAX_CHARS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- linkify -----------------------------------------------------------
|
// --- sanitize_game_label ----------------------------------------------
|
||||||
|
|
||||||
/// Concatenating every segment's inner text must reproduce the input exactly.
|
#[test]
|
||||||
fn reassemble(segs: &[Segment]) -> String {
|
fn game_label_keeps_ordinary_titles_and_strips_spoofing() {
|
||||||
segs.iter()
|
assert_eq!(sanitize_game_label("Half-Life 2"), "Half-Life 2");
|
||||||
.map(|s| match s {
|
// Same spoof/control cleaning as names.
|
||||||
Segment::Text(t) | Segment::Link(t) => t.as_str(),
|
assert_eq!(sanitize_game_label("Doom\u{202E}txt"), "Doomtxt");
|
||||||
})
|
assert_eq!(sanitize_game_label("a\u{0}b\r\nc"), "a b c");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_caps_chars_wider_than_names() {
|
||||||
|
// A game label keeps more than a name's 48 (up to 64), so a title between
|
||||||
|
// the two caps survives in full.
|
||||||
|
let mid = "g".repeat(56);
|
||||||
|
assert_eq!(sanitize_game_label(&mid).chars().count(), 56);
|
||||||
|
let long = "g".repeat(GAME_LABEL_MAX_CHARS + 100);
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_game_label(&long).chars().count(),
|
||||||
|
GAME_LABEL_MAX_CHARS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_caps_bytes_for_multibyte_titles() {
|
||||||
|
// Each '世' is 3 bytes; 64 of them = 192 bytes (under 256) → all kept.
|
||||||
|
let cjk = "世".repeat(GAME_LABEL_MAX_CHARS);
|
||||||
|
let out = sanitize_game_label(&cjk);
|
||||||
|
assert_eq!(out.chars().count(), GAME_LABEL_MAX_CHARS);
|
||||||
|
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||||
|
// Emoji are 4 bytes; the byte cap bites before the char cap (256/4 = 64,
|
||||||
|
// but the leading clean keeps them as a run) — never exceeds the byte cap.
|
||||||
|
let emoji = "🎮".repeat(GAME_LABEL_MAX_CHARS);
|
||||||
|
let out = sanitize_game_label(&emoji);
|
||||||
|
assert!(out.len() <= GAME_LABEL_MAX_BYTES);
|
||||||
|
assert!(out.chars().all(|c| c == '🎮'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_label_empty_when_nothing_usable() {
|
||||||
|
assert_eq!(sanitize_game_label("\u{0}\r\n\t "), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- chat body policy ---------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_keeps_ordinary_text_and_unicode() {
|
||||||
|
assert_eq!(sanitize_chat("hello world"), "hello world");
|
||||||
|
assert_eq!(sanitize_chat("héllo 🎙 世界"), "héllo 🎙 世界");
|
||||||
|
// Bodies keep format characters that label sanitizers strip: a ZWJ emoji
|
||||||
|
// family sequence survives intact.
|
||||||
|
let family = "👨\u{200D}👩\u{200D}👧";
|
||||||
|
assert_eq!(sanitize_chat(family), family);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_strips_control_chars_and_collapses_whitespace() {
|
||||||
|
assert_eq!(sanitize_chat(" hi there "), "hi there");
|
||||||
|
assert_eq!(sanitize_chat("a\u{0}b\r\nc\td\u{1b}[31m"), "a b c d [31m");
|
||||||
|
assert_eq!(sanitize_chat("\u{0}\r\n\t "), "");
|
||||||
|
assert_eq!(sanitize_chat(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_caps_chars_at_exact_boundary_without_trailing_space() {
|
||||||
|
let long = "x".repeat(CHAT_MSG_MAX_CHARS + 500);
|
||||||
|
assert_eq!(sanitize_chat(&long).chars().count(), CHAT_MSG_MAX_CHARS);
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_chat(&"x".repeat(CHAT_MSG_MAX_CHARS))
|
||||||
|
.chars()
|
||||||
|
.count(),
|
||||||
|
CHAT_MSG_MAX_CHARS
|
||||||
|
);
|
||||||
|
// Truncation never leaves a dangling separator: with "word " units the
|
||||||
|
// cut lands mid-run, and the output still ends on visible text.
|
||||||
|
let words = "word ".repeat(1000);
|
||||||
|
let out = sanitize_chat(&words);
|
||||||
|
assert!(out.chars().count() <= CHAT_MSG_MAX_CHARS);
|
||||||
|
assert!(!out.ends_with(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_ceilings_never_split_a_scalar() {
|
||||||
|
// Four-byte scalars: the char cap bites first (2,000 × 4 = 8,000 bytes,
|
||||||
|
// inside the byte ceiling by design) and the last emoji is kept whole.
|
||||||
|
let emoji = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
|
||||||
|
let out = sanitize_chat(&emoji);
|
||||||
|
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
|
||||||
|
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
|
||||||
|
assert!(out.chars().all(|c| c == '🎮'));
|
||||||
|
// Three-byte scalars at the char boundary.
|
||||||
|
let cjk = "世".repeat(CHAT_MSG_MAX_CHARS + 1);
|
||||||
|
let out = sanitize_chat(&cjk);
|
||||||
|
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
|
||||||
|
assert!(out.is_char_boundary(out.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_sanitize_is_idempotent() {
|
||||||
|
for input in [
|
||||||
|
"plain text",
|
||||||
|
" spaced \t out\r\n text ",
|
||||||
|
"unicode 🎙 世界 👨\u{200D}👩\u{200D}👧",
|
||||||
|
&"word ".repeat(1000),
|
||||||
|
&"🎮".repeat(CHAT_MSG_MAX_CHARS + 100),
|
||||||
|
] {
|
||||||
|
let once = sanitize_chat(input);
|
||||||
|
assert_eq!(sanitize_chat(&once), once, "not idempotent for {input:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_chat_input_preserves_whitespace_within_bounds() {
|
||||||
|
// In-bounds input comes back byte-identical — no normalization while
|
||||||
|
// the user is still editing.
|
||||||
|
let draft = " hello world \t ".to_string();
|
||||||
|
assert_eq!(cap_chat_input(draft.clone()), draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cap_chat_input_truncates_oversized_paste_on_scalar_boundary() {
|
||||||
|
let paste = "x".repeat(CHAT_MSG_MAX_CHARS + 5000);
|
||||||
|
let out = cap_chat_input(paste);
|
||||||
|
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
|
||||||
|
let emoji_paste = "🎮".repeat(CHAT_MSG_MAX_CHARS + 100);
|
||||||
|
let out = cap_chat_input(emoji_paste);
|
||||||
|
assert_eq!(out.chars().count(), CHAT_MSG_MAX_CHARS);
|
||||||
|
assert!(out.len() <= CHAT_MSG_MAX_BYTES);
|
||||||
|
assert!(out.chars().all(|c| c == '🎮'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admit_rejects_oversized_raw_bytes_before_sanitizing() {
|
||||||
|
// One byte over the ceiling → rejected outright, attachment or not.
|
||||||
|
let over = "x".repeat(CHAT_MSG_MAX_BYTES + 1);
|
||||||
|
assert_eq!(admit_chat_text(&over, false), None);
|
||||||
|
assert_eq!(admit_chat_text(&over, true), None);
|
||||||
|
// Exactly at the ceiling → admitted (then sanitized/capped).
|
||||||
|
let at = "x".repeat(CHAT_MSG_MAX_BYTES);
|
||||||
|
let admitted = admit_chat_text(&at, false).expect("at-ceiling text admitted");
|
||||||
|
assert_eq!(admitted.chars().count(), CHAT_MSG_MAX_CHARS);
|
||||||
|
// Multibyte raw over the ceiling → rejected.
|
||||||
|
let cjk_over = "世".repeat(CHAT_MSG_MAX_BYTES / 3 + 1);
|
||||||
|
assert!(cjk_over.len() > CHAT_MSG_MAX_BYTES);
|
||||||
|
assert_eq!(admit_chat_text(&cjk_over, false), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn admit_keeps_attachment_only_messages_and_drops_truly_empty_ones() {
|
||||||
|
// No visible text + no attachment → nothing to show, dropped.
|
||||||
|
assert_eq!(admit_chat_text("", false), None);
|
||||||
|
assert_eq!(admit_chat_text("\u{0}\r\n\t ", false), None);
|
||||||
|
// Same bodies WITH an attachment → kept as an empty caption.
|
||||||
|
assert_eq!(admit_chat_text("", true), Some(String::new()));
|
||||||
|
assert_eq!(admit_chat_text("\u{0}\r\n\t ", true), Some(String::new()));
|
||||||
|
// Normal text converges on the same result as direct sanitization.
|
||||||
|
assert_eq!(
|
||||||
|
admit_chat_text(" hi there ", false),
|
||||||
|
Some(sanitize_chat(" hi there "))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_strips_bidi_overrides_but_keeps_benign_format_chars() {
|
||||||
|
// Overrides and isolates are removed outright (S14) …
|
||||||
|
assert_eq!(sanitize_chat("pay \u{202E}gpj.exe now"), "pay gpj.exe now");
|
||||||
|
assert_eq!(sanitize_chat("a\u{2066}b\u{2069}c"), "abc");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_chat("\u{202A}\u{202B}\u{202C}\u{202D}\u{202E}"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
// … while the expressive format characters chat promises to keep — ZWJ
|
||||||
|
// (emoji sequences), ZWNJ (joining scripts), LRM/RLM (bidi *marks*, which
|
||||||
|
// cannot reorder text) — survive.
|
||||||
|
for kept in ['\u{200D}', '\u{200C}', '\u{200E}', '\u{200F}'] {
|
||||||
|
let msg = format!("a{kept}b");
|
||||||
|
assert_eq!(sanitize_chat(&msg), msg, "stripped benign {kept:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- link policy ---------------------------------------------------------
|
||||||
|
|
||||||
|
/// Concatenating every segment's slice must reproduce the input exactly.
|
||||||
|
fn reassemble(text: &str) -> String {
|
||||||
|
segments(text, &link_ranges(text))
|
||||||
|
.iter()
|
||||||
|
.map(|(s, _)| *s)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The link slices of a message, in order.
|
||||||
|
fn links(text: &str) -> Vec<&str> {
|
||||||
|
segments(text, &link_ranges(text))
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(s, is_link)| is_link.then_some(s))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn linkify_plain_text_has_no_links() {
|
fn url_policy_accepts_only_wellformed_web_urls() {
|
||||||
let segs = linkify("just a normal message, nothing here");
|
for ok in [
|
||||||
assert_eq!(segs, vec![Segment::Text("just a normal message, nothing here".into())]);
|
"http://example.com",
|
||||||
|
"https://a.test/path?q=1&w=2",
|
||||||
|
"HTTP://EXAMPLE.COM", // mixed case scheme+host
|
||||||
|
"https://x.com:8443/p", // explicit port
|
||||||
|
"https://d.com/路径?q=世界#frag", // unicode path/query/fragment
|
||||||
|
// WHATWG parsing (what browsers do) collapses the extra slash into
|
||||||
|
// host "path" — a valid, if odd, destination; not an empty host.
|
||||||
|
"http:///path",
|
||||||
|
] {
|
||||||
|
assert!(is_safe_web_url(ok), "rejected {ok:?}");
|
||||||
|
}
|
||||||
|
for bad in [
|
||||||
|
"",
|
||||||
|
"example.com", // no scheme
|
||||||
|
"http://", // empty host
|
||||||
|
"ftp://x.com", // non-web scheme
|
||||||
|
"file:///etc/passwd", // no host, wrong scheme
|
||||||
|
"javascript:alert(1)", // opener must never see this
|
||||||
|
"http://user@good.com", // userinfo → destination spoof risk
|
||||||
|
"http://user:pw@good.com", // credentials
|
||||||
|
"http://exa mple.com", // malformed host
|
||||||
|
] {
|
||||||
|
assert!(!is_safe_web_url(bad), "accepted {bad:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn linkify_detects_http_and_https() {
|
fn link_ranges_detects_http_and_https_with_exact_roundtrip() {
|
||||||
|
assert_eq!(links("see http://example.com now"), ["http://example.com"]);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
linkify("see http://example.com now"),
|
links("a http://one.com b https://two.com c"),
|
||||||
vec![
|
["http://one.com", "https://two.com"]
|
||||||
Segment::Text("see ".into()),
|
|
||||||
Segment::Link("http://example.com".into()),
|
|
||||||
Segment::Text(" now".into()),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
// Sentence-capitalized scheme still detected; href = the displayed slice.
|
||||||
linkify("https://a.test/path?q=1"),
|
assert_eq!(links("go to Http://example.com"), ["Http://example.com"]);
|
||||||
vec![Segment::Link("https://a.test/path?q=1".into())]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linkify_peels_trailing_punctuation() {
|
|
||||||
// Sentence-final period is not part of the link.
|
|
||||||
assert_eq!(
|
|
||||||
linkify("go to https://x.com."),
|
|
||||||
vec![
|
|
||||||
Segment::Text("go to ".into()),
|
|
||||||
Segment::Link("https://x.com".into()),
|
|
||||||
Segment::Text(".".into()),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
// Parenthesized URL.
|
|
||||||
assert_eq!(
|
|
||||||
linkify("(https://x.com)"),
|
|
||||||
vec![
|
|
||||||
Segment::Text("(".into()),
|
|
||||||
Segment::Link("https://x.com".into()),
|
|
||||||
Segment::Text(")".into()),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linkify_handles_multiple_urls() {
|
|
||||||
let segs = linkify("a http://one.com b https://two.com c");
|
|
||||||
assert_eq!(
|
|
||||||
segs,
|
|
||||||
vec![
|
|
||||||
Segment::Text("a ".into()),
|
|
||||||
Segment::Link("http://one.com".into()),
|
|
||||||
Segment::Text(" b ".into()),
|
|
||||||
Segment::Link("https://two.com".into()),
|
|
||||||
Segment::Text(" c".into()),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
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())]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn linkify_preserves_input_exactly() {
|
|
||||||
for msg in [
|
for msg in [
|
||||||
"",
|
"",
|
||||||
"no urls at all",
|
"no urls at all",
|
||||||
@@ -221,8 +553,67 @@ mod tests {
|
|||||||
"pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!",
|
"pre http://a.com/x?y=z&w=1 mid https://b.org/p, end!",
|
||||||
"weird))) http://c.com]]] tail",
|
"weird))) http://c.com]]] tail",
|
||||||
"unicode 世界 http://d.com/路径 more 世界",
|
"unicode 世界 http://d.com/路径 more 世界",
|
||||||
|
"bad http:// and http://user@x.com around https://ok.org here",
|
||||||
] {
|
] {
|
||||||
assert_eq!(reassemble(&linkify(msg)), msg, "roundtrip failed for {msg:?}");
|
assert_eq!(reassemble(msg), msg, "roundtrip failed for {msg:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_ranges_peels_trailing_punctuation() {
|
||||||
|
assert_eq!(links("go to https://x.com."), ["https://x.com"]);
|
||||||
|
assert_eq!(links("(https://x.com)"), ["https://x.com"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_ranges_leaves_invalid_candidates_as_plain_text() {
|
||||||
|
// Non-web schemes and bare domains never linkify (conservative).
|
||||||
|
assert_eq!(
|
||||||
|
links("email me@x.com or ftp://x.com or visit x.com"),
|
||||||
|
[] as [&str; 0]
|
||||||
|
);
|
||||||
|
// A malformed/deceptive candidate stays text WITHOUT eating a later
|
||||||
|
// valid link.
|
||||||
|
assert_eq!(links("http:// then https://ok.org"), ["https://ok.org"]);
|
||||||
|
assert_eq!(
|
||||||
|
links("http://user:pw@evil.com vs https://good.com"),
|
||||||
|
["https://good.com"]
|
||||||
|
);
|
||||||
|
// An invalid run's interior is not re-scanned for nested schemes.
|
||||||
|
assert_eq!(links("http://a@http://b.com"), [] as [&str; 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_ranges_caps_clickable_links_per_message() {
|
||||||
|
let many = (0..CHAT_MSG_MAX_LINKS + 4)
|
||||||
|
.map(|i| format!("https://site{i}.test"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
let ranges = link_ranges(&many);
|
||||||
|
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
|
||||||
|
// The 9th+ URLs remain, but as plain selectable text.
|
||||||
|
assert_eq!(reassemble(&many), many);
|
||||||
|
let l = links(&many);
|
||||||
|
assert_eq!(l.last(), Some(&"https://site7.test"));
|
||||||
|
// Exactly at the cap: all clickable.
|
||||||
|
let at_cap = (0..CHAT_MSG_MAX_LINKS)
|
||||||
|
.map(|i| format!("https://site{i}.test"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ");
|
||||||
|
assert_eq!(link_ranges(&at_cap).len(), CHAT_MSG_MAX_LINKS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn link_ranges_survives_adversarial_many_link_input() {
|
||||||
|
// A ceiling-length message packed with minimal URLs: bounded output,
|
||||||
|
// exact reconstruction, and every range on char boundaries.
|
||||||
|
let flood = "http://a.io ".repeat(CHAT_MSG_MAX_BYTES / 12 + 1);
|
||||||
|
let msg = sanitize_chat(&flood);
|
||||||
|
let ranges = link_ranges(&msg);
|
||||||
|
assert_eq!(ranges.len(), CHAT_MSG_MAX_LINKS);
|
||||||
|
for r in &ranges {
|
||||||
|
assert!(msg.is_char_boundary(r.start) && msg.is_char_boundary(r.end));
|
||||||
|
}
|
||||||
|
assert_eq!(reassemble(&msg), msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use std::time::Duration;
|
|||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::{Child, Command};
|
use tokio::process::{Child, Command};
|
||||||
|
|
||||||
|
use crate::config::{ScreenShareSettings, ShareBuffering, SharePlayer, ShareQuality};
|
||||||
|
|
||||||
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
/// The binary we shell out to. Looked up on `$PATH` unless a config override
|
||||||
/// points elsewhere.
|
/// points elsewhere.
|
||||||
const PIXELPASS_BIN: &str = "pixelpass";
|
const PIXELPASS_BIN: &str = "pixelpass";
|
||||||
@@ -39,6 +41,10 @@ fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
|
|||||||
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
|
||||||
const MAX_TICKET_LEN: usize = 512;
|
const MAX_TICKET_LEN: usize = 512;
|
||||||
|
|
||||||
|
/// Upper bound on a PipeWire `application.name` we'll pass to `--app`. Real names
|
||||||
|
/// are short ("Firefox", "mpv"); this only guards against a pathological value.
|
||||||
|
const MAX_APP_NAME_LEN: usize = 256;
|
||||||
|
|
||||||
/// How long to wait for the host to emit its ticket / the viewer to connect
|
/// How long to wait for the host to emit its ticket / the viewer to connect
|
||||||
/// before giving up and killing the child. Startup is normally sub-second; this
|
/// before giving up and killing the child. Startup is normally sub-second; this
|
||||||
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
/// is only a safety net so a hung pixelpass can't wedge the caller forever.
|
||||||
@@ -64,6 +70,12 @@ pub enum PixelpassEvent {
|
|||||||
CaptureStarted,
|
CaptureStarted,
|
||||||
/// Host: capture pipeline torn down (on last viewer).
|
/// Host: capture pipeline torn down (on last viewer).
|
||||||
CaptureStopped,
|
CaptureStopped,
|
||||||
|
/// Host (per-app audio): the chosen app's audio is now reaching viewers.
|
||||||
|
AppAudioRouted,
|
||||||
|
/// Host (per-app audio): the chosen app's last audio stream went away. Under
|
||||||
|
/// our `--strict-audio` run this means viewers now hear silence (not the call
|
||||||
|
/// echo) until the app produces audio again — we surface it as a warning.
|
||||||
|
AppAudioLost,
|
||||||
/// A recognized event we don't act on (e.g. `host_info`).
|
/// A recognized event we don't act on (e.g. `host_info`).
|
||||||
Other,
|
Other,
|
||||||
}
|
}
|
||||||
@@ -98,6 +110,11 @@ pub fn parse_pixelpass_event(line: &str) -> Option<PixelpassEvent> {
|
|||||||
Some("stopped") => PixelpassEvent::CaptureStopped,
|
Some("stopped") => PixelpassEvent::CaptureStopped,
|
||||||
_ => PixelpassEvent::Other,
|
_ => PixelpassEvent::Other,
|
||||||
},
|
},
|
||||||
|
"app_audio" => match v.get("state").and_then(|s| s.as_str()) {
|
||||||
|
Some("routed") => PixelpassEvent::AppAudioRouted,
|
||||||
|
Some("lost") => PixelpassEvent::AppAudioLost,
|
||||||
|
_ => PixelpassEvent::Other,
|
||||||
|
},
|
||||||
_ => PixelpassEvent::Other,
|
_ => PixelpassEvent::Other,
|
||||||
};
|
};
|
||||||
Some(ev)
|
Some(ev)
|
||||||
@@ -107,6 +124,182 @@ fn json_u32(v: &serde_json::Value, key: &str) -> u32 {
|
|||||||
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
|
v.get(key).and_then(|x| x.as_u64()).unwrap_or(0) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the argv for a pixelpass *host*. Always `--host --output json`; when
|
||||||
|
/// `audio_app` is `Some`, append `--app=<name> --strict-audio` so pixelpass
|
||||||
|
/// captures only that app's audio instead of the whole desktop sink monitor
|
||||||
|
/// (which contains our own call playout → the viewer would hear themselves
|
||||||
|
/// echoed back, backlog A23).
|
||||||
|
///
|
||||||
|
/// `--strict-audio` is what makes the fix a guarantee rather than best-effort:
|
||||||
|
/// without it, pixelpass falls back to the whole-desktop loopback before the
|
||||||
|
/// app's first stream routes and again if the app's audio later stops — both of
|
||||||
|
/// which reintroduce the echo. With it, the viewer hears only the chosen app (or
|
||||||
|
/// silence), and pixelpass emits `app_audio` events we surface as a warning.
|
||||||
|
///
|
||||||
|
/// The name is passed in the single-token `--app=<name>` form so a value that
|
||||||
|
/// happens to begin with `-` can never be reparsed as a pixelpass flag (clap
|
||||||
|
/// otherwise rejects hyphen-leading option values). The name is locally chosen
|
||||||
|
/// (our own enumeration / the user's pick), not peer-supplied, but is still
|
||||||
|
/// sanitized via [`sanitize_app_name`] before reaching here. Pure: no I/O.
|
||||||
|
pub fn host_args(
|
||||||
|
audio_app: Option<&str>,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut args = vec![
|
||||||
|
"--host".to_string(),
|
||||||
|
"--output".to_string(),
|
||||||
|
"json".to_string(),
|
||||||
|
];
|
||||||
|
if let Some(name) = audio_app.and_then(sanitize_app_name) {
|
||||||
|
args.push(format!("--app={name}"));
|
||||||
|
args.push("--strict-audio".to_string());
|
||||||
|
}
|
||||||
|
if quality != ShareQuality::Auto {
|
||||||
|
args.push(format!("--quality={}", pixelpass_quality(quality)));
|
||||||
|
}
|
||||||
|
if let Some(height) = settings.max_height {
|
||||||
|
args.push(format!("--max-height={height}"));
|
||||||
|
}
|
||||||
|
if let Some(mbps) = settings.bitrate_mbps {
|
||||||
|
args.push(format!("--bitrate={}", mbps.saturating_mul(1000)));
|
||||||
|
}
|
||||||
|
if let Some(fps) = settings.framerate {
|
||||||
|
args.push(format!("--framerate={fps}"));
|
||||||
|
}
|
||||||
|
if settings.force_software_encode {
|
||||||
|
args.push("--no-hwencode".to_string());
|
||||||
|
}
|
||||||
|
if let Some(max) = settings.max_viewers {
|
||||||
|
args.push(format!("--max-viewers={max}"));
|
||||||
|
}
|
||||||
|
args.extend(split_extra_args(&settings.extra_host_args));
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pixelpass_quality(quality: ShareQuality) -> &'static str {
|
||||||
|
match quality {
|
||||||
|
ShareQuality::Auto => "auto",
|
||||||
|
ShareQuality::Low => "low",
|
||||||
|
ShareQuality::Medium => "medium",
|
||||||
|
ShareQuality::High => "high",
|
||||||
|
ShareQuality::Source => "source",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split user-supplied advanced argv text into separate tokens. Peerspeak does
|
||||||
|
/// not depend on a shell lexer, so quoted values are not interpreted here.
|
||||||
|
fn split_extra_args(raw: &str) -> impl Iterator<Item = String> + '_ {
|
||||||
|
raw.split_whitespace().map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate a locally-chosen audio app name before it becomes a `--app` value:
|
||||||
|
/// trim, reject empty / overlong, and reject names carrying control characters
|
||||||
|
/// (newlines etc.) that have no place in a real `application.name`. `None` means
|
||||||
|
/// "no valid app selected" — the caller then shares the whole desktop audio.
|
||||||
|
pub fn sanitize_app_name(name: &str) -> Option<String> {
|
||||||
|
let name = name.trim();
|
||||||
|
let ok =
|
||||||
|
!name.is_empty() && name.len() <= MAX_APP_NAME_LEN && !name.chars().any(|c| c.is_control());
|
||||||
|
ok.then(|| name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard cap on how long enumeration waits for `pactl`. It runs inline on the core
|
||||||
|
/// command loop (the picker awaits it before opening), so a wedged/slow `pactl`
|
||||||
|
/// must not stall mute/deafen/leave/stop. On timeout we treat it like any other
|
||||||
|
/// failure: empty list → "All system audio" only.
|
||||||
|
const LIST_APPS_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Enumerate the apps currently sending audio to a sink, deduplicated by
|
||||||
|
/// `application.name`. Mirrors how pixelpass itself builds its interactive
|
||||||
|
/// picker (`pactl -f json list sink-inputs`), so the names we return are exactly
|
||||||
|
/// the ones `--app` matches against. Returns an empty list on any error (pactl
|
||||||
|
/// missing, non-PipeWire host, nothing playing, or [`LIST_APPS_TIMEOUT`] elapsed)
|
||||||
|
/// — a normal, handled state that leaves the picker showing only "All system
|
||||||
|
/// audio".
|
||||||
|
pub async fn list_audio_apps() -> Vec<String> {
|
||||||
|
let run = Command::new("pactl")
|
||||||
|
.args(["-f", "json", "list", "sink-inputs"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
// On [`LIST_APPS_TIMEOUT`] the `output()` future is dropped, which drops
|
||||||
|
// the child — `kill_on_drop(true)` then SIGKILLs and reaps it so a wedged
|
||||||
|
// `pactl` can't linger/accumulate across picker opens (audit P3).
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
match tokio::time::timeout(LIST_APPS_TIMEOUT, run).await {
|
||||||
|
Ok(Ok(o)) if o.status.success() => parse_audio_apps(&o.stdout),
|
||||||
|
_ => Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hard cap on the capability probe (`pixelpass --help`). Conservative: a slow or
|
||||||
|
/// hung pixelpass degrades to "strict audio unsupported" → whole-desktop-only
|
||||||
|
/// picker (safe), never a stalled core loop.
|
||||||
|
const HELP_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// Whether the resolved pixelpass understands `--strict-audio` (added in pixelpass
|
||||||
|
/// `85fdebe`). peerspeak only offers per-app audio capture when it does: a per-app
|
||||||
|
/// share always appends `--strict-audio`, and an **older** pixelpass would have
|
||||||
|
/// clap reject the unknown flag → the host spawn hard-fails and the share is
|
||||||
|
/// broken (audit P2, version skew). When unsupported the picker degrades to
|
||||||
|
/// whole-desktop audio only — we never silently drop to best-effort `--app`, which
|
||||||
|
/// would reintroduce the call echo (A23).
|
||||||
|
///
|
||||||
|
/// Any probe failure/timeout returns `false` (degrade to the safe path). The
|
||||||
|
/// `--help` child is `kill_on_drop` so a hung pixelpass can't linger.
|
||||||
|
pub async fn supports_strict_audio(bin: &Path) -> bool {
|
||||||
|
let run = Command::new(bin)
|
||||||
|
.arg("--help")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.output();
|
||||||
|
match tokio::time::timeout(HELP_PROBE_TIMEOUT, run).await {
|
||||||
|
Ok(Ok(o)) => help_mentions_strict_audio(&o.stdout),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure check: does `pixelpass --help` advertise `--strict-audio`? Matches the
|
||||||
|
/// flag token rather than a whole line, since clap may wrap/realign help text.
|
||||||
|
pub fn help_mentions_strict_audio(help_stdout: &[u8]) -> bool {
|
||||||
|
String::from_utf8_lossy(help_stdout).contains("--strict-audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `pactl -f json list sink-inputs` stdout into a sorted, deduplicated list
|
||||||
|
/// of `application.name`s. Pure: no I/O. Unparseable input yields an empty list.
|
||||||
|
/// Each name is passed through [`sanitize_app_name`] so the picker only ever
|
||||||
|
/// offers names that will actually survive [`host_args`]; otherwise a name that
|
||||||
|
/// parses here but fails sanitization later would be selectable yet silently
|
||||||
|
/// drop the `--app` flag and revert the share to whole-desktop audio (A23 echo).
|
||||||
|
pub fn parse_audio_apps(stdout: &[u8]) -> Vec<String> {
|
||||||
|
let Ok(entries) = serde_json::from_slice::<Vec<SinkInput>>(stdout) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut names: Vec<String> = entries
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|e| e.properties.application_name)
|
||||||
|
.filter_map(|n| sanitize_app_name(&n))
|
||||||
|
.collect();
|
||||||
|
names.sort_unstable();
|
||||||
|
names.dedup();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SinkInput {
|
||||||
|
properties: SinkInputProperties,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct SinkInputProperties {
|
||||||
|
#[serde(rename = "application.name")]
|
||||||
|
application_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
|
/// Build the argv for a pixelpass *viewer*. The `ticket` is peer-supplied (it
|
||||||
/// rides gossip presence, which is untrusted and spoofable), so flags come first
|
/// rides gossip presence, which is untrusted and spoofable), so flags come first
|
||||||
/// and the ticket is passed as a positional **after a `--` end-of-options
|
/// and the ticket is passed as a positional **after a `--` end-of-options
|
||||||
@@ -162,20 +355,41 @@ pub fn is_available(config_override: Option<&str>) -> bool {
|
|||||||
pixelpass_path(config_override).is_some()
|
pixelpass_path(config_override).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a pixelpass host (`pixelpass --host --output json`), wait for its
|
/// Spawn a pixelpass host (`pixelpass --host --output json [--app=<name>]`), wait
|
||||||
/// startup ticket, and return the live child plus the ticket. The child keeps
|
/// for its startup ticket, and return the live child plus the ticket. When
|
||||||
|
/// `audio_app` is `Some`, pixelpass captures only that app's audio instead of the
|
||||||
|
/// whole desktop sink, which avoids the call-loopback echo (A23). The child keeps
|
||||||
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
/// running (streaming to viewers) until killed or dropped; remaining stdout is
|
||||||
/// drained in a background task so a full pipe can't stall the host. We do
|
/// drained in a background task so a full pipe can't stall the host. We do
|
||||||
/// **not** pass `--max-viewers`: pixelpass bandwidth-measures its own safe cap,
|
/// not pass encode/viewer overrides unless the local settings explicitly ask for
|
||||||
/// protecting the sharer's uplink, and refuses extras with `viewer_refused`.
|
/// them, so pixelpass keeps its own defaults in the common case.
|
||||||
pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
pub async fn spawn_host(
|
||||||
|
bin: &Path,
|
||||||
|
audio_app: Option<&str>,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
quality: ShareQuality,
|
||||||
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||||
|
) -> std::io::Result<(Child, String)> {
|
||||||
|
let args = host_args(audio_app, settings, quality);
|
||||||
|
// Log the exact argv we hand pixelpass so a field log can confirm which
|
||||||
|
// encode/quality flags (e.g. --bitrate) actually reached the host — these
|
||||||
|
// are local flags with no ticket/secret, so logging them verbatim is safe.
|
||||||
|
crate::log_msg(&format!(
|
||||||
|
"pixelpass host spawn: {} {}",
|
||||||
|
bin.display(),
|
||||||
|
args.join(" ")
|
||||||
|
));
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.arg("--host")
|
.args(&args)
|
||||||
.arg("--output")
|
|
||||||
.arg("json")
|
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::null())
|
// Capture stderr (not null): pixelpass prints its startup precondition
|
||||||
|
// failures there — a missing GStreamer plugin / `pactl`, each with an
|
||||||
|
// actionable "Install hint: sudo apt install ..." line. If the host dies
|
||||||
|
// before its ticket we fold that tail into our error so the user sees
|
||||||
|
// *what to install* instead of a dead-end "exited before a ticket". On
|
||||||
|
// the success path we drain it in the background so the pipe can't fill.
|
||||||
|
.stderr(Stdio::piped())
|
||||||
.kill_on_drop(true)
|
.kill_on_drop(true)
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
@@ -183,6 +397,7 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
.stdout
|
.stdout
|
||||||
.take()
|
.take()
|
||||||
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
.ok_or_else(|| std::io::Error::other("pixelpass host stdout missing"))?;
|
||||||
|
let stderr = child.stderr.take();
|
||||||
let mut lines = BufReader::new(stdout).lines();
|
let mut lines = BufReader::new(stdout).lines();
|
||||||
|
|
||||||
let ticket = match read_until(&mut lines, |e| match e {
|
let ticket = match read_until(&mut lines, |e| match e {
|
||||||
@@ -194,9 +409,10 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
Ok(Some(t)) => t,
|
Ok(Some(t)) => t,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
return Err(std::io::Error::other(
|
let detail = read_stderr_tail(stderr).await;
|
||||||
"pixelpass host exited before emitting a ticket",
|
return Err(std::io::Error::other(format!(
|
||||||
));
|
"pixelpass host exited before emitting a ticket{detail}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
@@ -204,15 +420,74 @@ pub async fn spawn_host(bin: &Path) -> std::io::Result<(Child, String)> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
drain_in_background(lines, "host");
|
if let Some(stderr) = stderr {
|
||||||
|
drain_stderr_in_background(stderr);
|
||||||
|
}
|
||||||
|
drain_in_background(lines, "host", notices);
|
||||||
Ok((child, ticket))
|
Ok((child, ticket))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read a killed pixelpass child's stderr to EOF and reduce it to a short,
|
||||||
|
/// user-facing diagnostic tail via [`pixelpass_failure_detail`]. Bounded: the
|
||||||
|
/// caller kills the child first, so the pipe EOFs promptly. Returns an empty
|
||||||
|
/// string when stderr was already taken or carried nothing useful.
|
||||||
|
async fn read_stderr_tail(stderr: Option<tokio::process::ChildStderr>) -> String {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let Some(mut stderr) = stderr else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let _ = stderr.read_to_end(&mut buf).await;
|
||||||
|
pixelpass_failure_detail(&String::from_utf8_lossy(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard a running pixelpass child's stderr in the background so its pipe
|
||||||
|
/// can't fill and stall the host (mirrors [`drain_in_background`] for stdout).
|
||||||
|
fn drain_stderr_in_background(mut stderr: tokio::process::ChildStderr) {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
while let Ok(n) = stderr.read(&mut buf).await {
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract a human-useful tail from a failed pixelpass child's stderr to append
|
||||||
|
/// to our error. pixelpass writes actionable startup errors there (a missing
|
||||||
|
/// GStreamer element / `pactl` plus an `Install hint: sudo apt install ...`
|
||||||
|
/// line), which is exactly what a freshly-installed host needs to see. The
|
||||||
|
/// decorative host banner (box-drawing) is dropped — it only prints on the
|
||||||
|
/// success path, but we filter it defensively. Pure: no I/O. Returns an empty
|
||||||
|
/// string when there's nothing worth surfacing (so callers can append blindly).
|
||||||
|
pub fn pixelpass_failure_detail(stderr: &str) -> String {
|
||||||
|
let useful: Vec<&str> = stderr
|
||||||
|
.lines()
|
||||||
|
.map(str::trim_end)
|
||||||
|
.filter(|l| !l.trim().is_empty())
|
||||||
|
.filter(|l| !l.trim_start().starts_with(['│', '┌', '└', '├']))
|
||||||
|
.collect();
|
||||||
|
if useful.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
// The anyhow error and its install hint are the *last* lines printed, so
|
||||||
|
// keep the tail rather than the head.
|
||||||
|
const MAX_LINES: usize = 12;
|
||||||
|
let start = useful.len().saturating_sub(MAX_LINES);
|
||||||
|
format!("\n\npixelpass reported:\n{}", useful[start..].join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
/// Spawn a pixelpass viewer for `ticket`, wait for it to connect, and open the
|
||||||
/// stream in a local player (mpv, falling back to vlc). Returns the live viewer
|
/// stream in a local player (mpv/VLC in the configured order, then fallback).
|
||||||
/// child so the caller can kill it on room-leave; it also self-exits when the
|
/// Returns the live viewer child so the caller can kill it on room-leave; it also
|
||||||
/// player window closes (its tunnel ends).
|
/// self-exits when the player window closes (its tunnel ends).
|
||||||
pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
pub async fn spawn_viewer(
|
||||||
|
bin: &Path,
|
||||||
|
ticket: &str,
|
||||||
|
settings: &ScreenShareSettings,
|
||||||
|
) -> std::io::Result<Child> {
|
||||||
let mut child = Command::new(bin)
|
let mut child = Command::new(bin)
|
||||||
.args(viewer_args(ticket))
|
.args(viewer_args(ticket))
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
@@ -246,12 +521,12 @@ pub async fn spawn_viewer(bin: &Path, ticket: &str) -> std::io::Result<Child> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = launch_player(&url) {
|
if let Err(e) = launch_player(&url, settings) {
|
||||||
let _ = child.kill().await;
|
let _ = child.kill().await;
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
drain_in_background(lines, "viewer");
|
drain_in_background(lines, "viewer", None);
|
||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,15 +561,24 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
/// Keep reading the child's stdout to EOF in the background so a full pipe can't
|
||||||
/// stall it; log notable events for diagnostics.
|
/// stall it; log notable events for diagnostics. When `notices` is `Some`, each
|
||||||
fn drain_in_background<R>(mut lines: tokio::io::Lines<BufReader<R>>, role: &'static str)
|
/// parsed event is also forwarded to the caller (the core, which translates the
|
||||||
where
|
/// `app_audio` ones into a UI warning); a send failure (receiver dropped) just
|
||||||
|
/// stops forwarding, draining continues. The task ends on EOF (child exited).
|
||||||
|
fn drain_in_background<R>(
|
||||||
|
mut lines: tokio::io::Lines<BufReader<R>>,
|
||||||
|
role: &'static str,
|
||||||
|
notices: Option<tokio::sync::mpsc::UnboundedSender<PixelpassEvent>>,
|
||||||
|
) where
|
||||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||||
{
|
{
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
if let Some(ev) = parse_pixelpass_event(&line) {
|
if let Some(ev) = parse_pixelpass_event(&line) {
|
||||||
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
|
||||||
|
if let Some(tx) = ¬ices {
|
||||||
|
let _ = tx.send(ev);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -313,27 +597,39 @@ fn event_for_log(ev: &PixelpassEvent) -> String {
|
|||||||
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
|
||||||
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
|
||||||
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
PixelpassEvent::CaptureStopped => "capture_stopped".to_string(),
|
||||||
|
PixelpassEvent::AppAudioRouted => "app_audio_routed".to_string(),
|
||||||
|
PixelpassEvent::AppAudioLost => "app_audio_lost".to_string(),
|
||||||
PixelpassEvent::Other => "other".to_string(),
|
PixelpassEvent::Other => "other".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the viewer stream URL in a media player. Mirrors pixelpass's own
|
/// Open the viewer stream URL in a media player, then fall back to vlc. The
|
||||||
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a
|
/// player is reaped in a background task so it doesn't linger as a zombie when
|
||||||
/// background task so it doesn't linger as a zombie when its window closes.
|
/// its window closes.
|
||||||
fn launch_player(url: &str) -> std::io::Result<()> {
|
///
|
||||||
const MPV_ARGS: &[&str] = &[
|
/// The flags keep latency low while preserving A/V sync. We deliberately do
|
||||||
"--profile=low-latency",
|
/// NOT pass mpv's `--untimed`: that displays each video frame the instant it
|
||||||
"--untimed",
|
/// decodes, ignoring audio timestamps, which makes a shared *video* drift
|
||||||
"--hwdec=auto",
|
/// progressively out of sync with its audio. Pacing to the audio clock costs a
|
||||||
"--audio-buffer=0.2",
|
/// little latency (negligible for pointing at a desktop) and keeps a shared
|
||||||
"--demuxer-max-bytes=2M",
|
/// video in sync. We also leave hwdec at the `low-latency` default (software
|
||||||
"--demuxer-readahead-secs=0.5",
|
/// decode): forcing `--hwdec=auto` froze some viewers on frame 1 while audio
|
||||||
];
|
/// kept playing.
|
||||||
const VLC_ARGS: &[&str] = &["--network-caching=200", "--live-caching=200"];
|
fn launch_player(url: &str, settings: &ScreenShareSettings) -> std::io::Result<()> {
|
||||||
|
let mpv_args = mpv_args(settings);
|
||||||
|
let vlc_args = vlc_args(settings);
|
||||||
|
let first = match settings.player {
|
||||||
|
SharePlayer::Mpv => ("mpv", &mpv_args),
|
||||||
|
SharePlayer::Vlc => ("vlc", &vlc_args),
|
||||||
|
};
|
||||||
|
let second = match settings.player {
|
||||||
|
SharePlayer::Mpv => ("vlc", &vlc_args),
|
||||||
|
SharePlayer::Vlc => ("mpv", &mpv_args),
|
||||||
|
};
|
||||||
|
|
||||||
let child = match spawn_player("mpv", MPV_ARGS, url) {
|
let child = match spawn_player(first.0, first.1, url) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => spawn_player("vlc", VLC_ARGS, url).map_err(|_| {
|
Err(_) => spawn_player(second.0, second.1, url).map_err(|_| {
|
||||||
std::io::Error::new(
|
std::io::Error::new(
|
||||||
std::io::ErrorKind::NotFound,
|
std::io::ErrorKind::NotFound,
|
||||||
"no media player found — install mpv or vlc to watch screen shares",
|
"no media player found — install mpv or vlc to watch screen shares",
|
||||||
@@ -347,7 +643,64 @@ fn launch_player(url: &str) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_player(bin: &str, args: &[&str], url: &str) -> std::io::Result<Child> {
|
pub fn mpv_args(settings: &ScreenShareSettings) -> Vec<String> {
|
||||||
|
let mut args = Vec::new();
|
||||||
|
match settings.buffering {
|
||||||
|
ShareBuffering::LowLatency => {
|
||||||
|
args.push("--profile=low-latency".to_string());
|
||||||
|
args.push("--audio-buffer=0.2".to_string());
|
||||||
|
args.push("--demuxer-readahead-secs=0.5".to_string());
|
||||||
|
}
|
||||||
|
ShareBuffering::Smooth => {
|
||||||
|
args.push("--cache=yes".to_string());
|
||||||
|
args.push("--demuxer-readahead-secs=2".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
args.push(format!("--demuxer-max-bytes={}M", settings.cache_mb));
|
||||||
|
if settings.hardware_decode {
|
||||||
|
args.push("--hwdec=auto".to_string());
|
||||||
|
}
|
||||||
|
args.extend(split_extra_args(&settings.extra_mpv_args));
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the argv for a VLC viewer. VLC honors the subset of viewer settings
|
||||||
|
/// that map cleanly onto its option set: the buffering posture (network/live
|
||||||
|
/// caching, in ms) and hardware decoding. The rest of the viewer knobs are
|
||||||
|
/// mpv-specific — `cache_mb` is an mpv demuxer *byte* cache (VLC's caching is
|
||||||
|
/// time-based, already covered by `buffering`) and `extra_mpv_args` is literally
|
||||||
|
/// mpv flags — so they are deliberately not mapped here; the Settings UI labels
|
||||||
|
/// them as mpv-only. Pure: no I/O.
|
||||||
|
///
|
||||||
|
/// The hardware-decode mapping is the load-bearing one: VLC hardware-decodes by
|
||||||
|
/// default, so without an explicit `--avcodec-hw=none` a VLC viewer would ignore
|
||||||
|
/// the (default-off) hardware-decode toggle and could hit the frame-1 freeze
|
||||||
|
/// that default exists to avoid — the same A-bug that made us drop mpv's forced
|
||||||
|
/// `--hwdec=auto`.
|
||||||
|
fn vlc_args(settings: &ScreenShareSettings) -> Vec<String> {
|
||||||
|
let caching_ms = match settings.buffering {
|
||||||
|
ShareBuffering::LowLatency => 200,
|
||||||
|
ShareBuffering::Smooth => 1500,
|
||||||
|
};
|
||||||
|
let hw = if settings.hardware_decode {
|
||||||
|
"--avcodec-hw=any"
|
||||||
|
} else {
|
||||||
|
"--avcodec-hw=none"
|
||||||
|
};
|
||||||
|
vec![
|
||||||
|
format!("--network-caching={caching_ms}"),
|
||||||
|
format!("--live-caching={caching_ms}"),
|
||||||
|
hw.to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_player(bin: &str, args: &[String], url: &str) -> std::io::Result<Child> {
|
||||||
|
// Log the player + its flags (mpv/vlc, incl. hardware-decode: --hwdec /
|
||||||
|
// --avcodec-hw) so a field log can confirm the viewer settings reached the
|
||||||
|
// player. The `url` is omitted deliberately — it is the local stream address
|
||||||
|
// and is not needed to verify the flags. Logged on each attempt, so a
|
||||||
|
// fallback from the preferred player to the other one is visible too.
|
||||||
|
crate::log_msg(&format!("player spawn: {bin} {}", args.join(" ")));
|
||||||
Command::new(bin)
|
Command::new(bin)
|
||||||
.args(args)
|
.args(args)
|
||||||
.arg(url)
|
.arg(url)
|
||||||
@@ -368,7 +721,10 @@ mod tests {
|
|||||||
// i.e. after the `--` end-of-options guard, never parsed as a flag.
|
// i.e. after the `--` end-of-options guard, never parsed as a flag.
|
||||||
let args = viewer_args("--malicious-flag");
|
let args = viewer_args("--malicious-flag");
|
||||||
assert_eq!(args.last().unwrap(), "--malicious-flag", "ticket is last");
|
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;
|
let ticket = args.len() - 1;
|
||||||
assert!(guard < ticket, "ticket must follow the `--` guard");
|
assert!(guard < ticket, "ticket must follow the `--` guard");
|
||||||
// The real flags are parsed before the guard.
|
// The real flags are parsed before the guard.
|
||||||
@@ -384,16 +740,290 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_without_app_shares_whole_desktop() {
|
||||||
|
// No app selected → no --app flag → pixelpass keeps its default
|
||||||
|
// (whole-desktop) audio capture.
|
||||||
|
let settings = ScreenShareSettings::default();
|
||||||
|
assert_eq!(
|
||||||
|
host_args(None, &settings, ShareQuality::Auto),
|
||||||
|
vec!["--host", "--output", "json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_with_app_appends_single_token_flag() {
|
||||||
|
// The chosen app rides in the `--app=<name>` single-token form so a
|
||||||
|
// name beginning with `-` can never be reparsed as a flag (A23), plus
|
||||||
|
// `--strict-audio` so pixelpass never falls back to whole-desktop audio.
|
||||||
|
let settings = ScreenShareSettings::default();
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("Firefox"), &settings, ShareQuality::Auto),
|
||||||
|
vec![
|
||||||
|
"--host",
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"--app=Firefox",
|
||||||
|
"--strict-audio"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// The hyphen-leading name is still bound to --app as a single token;
|
||||||
|
// --strict-audio is the trailing flag.
|
||||||
|
let args = host_args(Some("-rm -rf"), &settings, ShareQuality::Auto);
|
||||||
|
assert_eq!(args[3], "--app=-rm -rf");
|
||||||
|
assert_eq!(args[4], "--strict-audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_blank_or_control_app_is_dropped() {
|
||||||
|
// An empty / whitespace / control-laden selection is sanitized away,
|
||||||
|
// falling back to whole-desktop capture rather than a broken flag.
|
||||||
|
let settings = ScreenShareSettings::default();
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some(" "), &settings, ShareQuality::Auto),
|
||||||
|
vec!["--host", "--output", "json"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("bad\nname"), &settings, ShareQuality::Auto),
|
||||||
|
vec!["--host", "--output", "json"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_args_apply_screen_share_settings_and_extra_args_last() {
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
bitrate_mbps: Some(5),
|
||||||
|
framerate: Some(60),
|
||||||
|
max_height: Some(1080),
|
||||||
|
max_viewers: Some(4),
|
||||||
|
force_software_encode: true,
|
||||||
|
extra_host_args: "--relay https://relay.example --verbose".to_string(),
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
host_args(Some("Firefox"), &settings, ShareQuality::High),
|
||||||
|
vec![
|
||||||
|
"--host",
|
||||||
|
"--output",
|
||||||
|
"json",
|
||||||
|
"--app=Firefox",
|
||||||
|
"--strict-audio",
|
||||||
|
"--quality=high",
|
||||||
|
"--max-height=1080",
|
||||||
|
"--bitrate=5000",
|
||||||
|
"--framerate=60",
|
||||||
|
"--no-hwencode",
|
||||||
|
"--max-viewers=4",
|
||||||
|
"--relay",
|
||||||
|
"https://relay.example",
|
||||||
|
"--verbose",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mpv_args_default_matches_low_latency_software_decode() {
|
||||||
|
assert_eq!(
|
||||||
|
mpv_args(&ScreenShareSettings::default()),
|
||||||
|
vec![
|
||||||
|
"--profile=low-latency",
|
||||||
|
"--audio-buffer=0.2",
|
||||||
|
"--demuxer-readahead-secs=0.5",
|
||||||
|
"--demuxer-max-bytes=2M",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mpv_args_smooth_hwdecode_and_extra_args_last() {
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
hardware_decode: true,
|
||||||
|
buffering: ShareBuffering::Smooth,
|
||||||
|
cache_mb: 16,
|
||||||
|
extra_mpv_args: "--no-osc --vd-lavc-threads=2".to_string(),
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
mpv_args(&settings),
|
||||||
|
vec![
|
||||||
|
"--cache=yes",
|
||||||
|
"--demuxer-readahead-secs=2",
|
||||||
|
"--demuxer-max-bytes=16M",
|
||||||
|
"--hwdec=auto",
|
||||||
|
"--no-osc",
|
||||||
|
"--vd-lavc-threads=2",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vlc_args_default_disables_hardware_decode() {
|
||||||
|
// The A-bug fix default (hardware_decode = false) must reach VLC too:
|
||||||
|
// VLC hardware-decodes by default, so without an explicit
|
||||||
|
// `--avcodec-hw=none` a VLC viewer would ignore the toggle and could hit
|
||||||
|
// the frame-1 freeze. Low-latency buffering keeps the 200 ms caches.
|
||||||
|
assert_eq!(
|
||||||
|
vlc_args(&ScreenShareSettings::default()),
|
||||||
|
vec![
|
||||||
|
"--network-caching=200",
|
||||||
|
"--live-caching=200",
|
||||||
|
"--avcodec-hw=none",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vlc_args_smooth_buffering_and_hwdecode() {
|
||||||
|
// Enabling hardware decode flips VLC to `--avcodec-hw=any`; Smooth
|
||||||
|
// buffering raises the network/live caches. cache_mb / extra_mpv_args are
|
||||||
|
// mpv-only and must NOT leak into the VLC argv.
|
||||||
|
let settings = ScreenShareSettings {
|
||||||
|
hardware_decode: true,
|
||||||
|
buffering: ShareBuffering::Smooth,
|
||||||
|
cache_mb: 16,
|
||||||
|
extra_mpv_args: "--no-osc".to_string(),
|
||||||
|
..ScreenShareSettings::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
vlc_args(&settings),
|
||||||
|
vec![
|
||||||
|
"--network-caching=1500",
|
||||||
|
"--live-caching=1500",
|
||||||
|
"--avcodec-hw=any",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_app_name_trims_and_rejects_garbage() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_app_name(" Firefox \n"),
|
||||||
|
Some("Firefox".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_app_name(""), None);
|
||||||
|
assert_eq!(sanitize_app_name(" "), None);
|
||||||
|
assert_eq!(sanitize_app_name("a\tb"), None);
|
||||||
|
assert_eq!(sanitize_app_name(&"x".repeat(MAX_APP_NAME_LEN + 1)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_dedups_and_sorts_by_application_name() {
|
||||||
|
let stdout = br#"[
|
||||||
|
{"index":1,"properties":{"application.name":"Firefox"}},
|
||||||
|
{"index":2,"properties":{"application.name":"mpv"}},
|
||||||
|
{"index":3,"properties":{"application.name":"Firefox"}},
|
||||||
|
{"index":4,"properties":{"application.name":" Spotify "}},
|
||||||
|
{"index":5,"properties":{"application.name":""}},
|
||||||
|
{"index":6,"properties":{"other":"no name here"}}
|
||||||
|
]"#;
|
||||||
|
assert_eq!(
|
||||||
|
parse_audio_apps(stdout),
|
||||||
|
vec![
|
||||||
|
"Firefox".to_string(),
|
||||||
|
"Spotify".to_string(),
|
||||||
|
"mpv".to_string()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_empty_or_garbage_is_empty() {
|
||||||
|
assert_eq!(parse_audio_apps(b""), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_audio_apps(b"not json"), Vec::<String>::new());
|
||||||
|
assert_eq!(parse_audio_apps(b"[]"), Vec::<String>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_audio_apps_drops_names_host_args_would_reject() {
|
||||||
|
// Names that parse from pactl but fail `sanitize_app_name` (control chars,
|
||||||
|
// overlong) must NOT be offered in the picker — otherwise the user could
|
||||||
|
// pick one, `host_args` would silently drop `--app`, and the share would
|
||||||
|
// revert to whole-desktop audio (A23 echo) with no signal. The valid name
|
||||||
|
// survives; the control-char and overlong ones are filtered out.
|
||||||
|
let overlong = "x".repeat(MAX_APP_NAME_LEN + 1);
|
||||||
|
let stdout = format!(
|
||||||
|
r#"[
|
||||||
|
{{"index":1,"properties":{{"application.name":"mpv"}}}},
|
||||||
|
{{"index":2,"properties":{{"application.name":"bad\nname"}}}},
|
||||||
|
{{"index":3,"properties":{{"application.name":"{overlong}"}}}}
|
||||||
|
]"#
|
||||||
|
);
|
||||||
|
assert_eq!(parse_audio_apps(stdout.as_bytes()), vec!["mpv".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_surfaces_install_hint_and_drops_banner() {
|
||||||
|
// The real shape of a fresh-host failure: anyhow error + install hint on
|
||||||
|
// stderr. We must keep those (so the user knows what to apt install) and
|
||||||
|
// drop the decorative banner box-drawing lines.
|
||||||
|
let stderr = "\
|
||||||
|
┌─ PixelPass · host ─────────────────────────────────────────
|
||||||
|
│ display server : Wayland
|
||||||
|
└────────────────────────────────────────────────────────────
|
||||||
|
Error: GStreamer element `vah264enc` not available.
|
||||||
|
Install hint: sudo apt install gstreamer1.0-plugins-bad
|
||||||
|
";
|
||||||
|
let detail = pixelpass_failure_detail(stderr);
|
||||||
|
assert!(detail.starts_with("\n\npixelpass reported:\n"));
|
||||||
|
assert!(detail.contains("vah264enc` not available"));
|
||||||
|
assert!(detail.contains("sudo apt install gstreamer1.0-plugins-bad"));
|
||||||
|
assert!(!detail.contains('│'), "banner box-drawing must be dropped");
|
||||||
|
assert!(!detail.contains('┌'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_empty_when_nothing_useful() {
|
||||||
|
// Blank / banner-only stderr yields an empty string so the caller can
|
||||||
|
// append it to the base message unconditionally without trailing noise.
|
||||||
|
assert_eq!(pixelpass_failure_detail(""), "");
|
||||||
|
assert_eq!(pixelpass_failure_detail(" \n \n"), "");
|
||||||
|
assert_eq!(
|
||||||
|
pixelpass_failure_detail("│ display server : Wayland\n│ capture : x\n"),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_detail_keeps_only_the_tail() {
|
||||||
|
// A long stderr is truncated to its last lines (where the real error
|
||||||
|
// and hint live), not its head.
|
||||||
|
let body: String = (0..30).map(|i| format!("line {i}\n")).collect();
|
||||||
|
let detail = pixelpass_failure_detail(&body);
|
||||||
|
assert!(detail.contains("line 29"));
|
||||||
|
assert!(!detail.contains("line 0\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn help_probe_detects_strict_audio_flag() {
|
||||||
|
// A new pixelpass advertises the flag; an old one doesn't. The probe must
|
||||||
|
// match the token even when clap wraps the option onto its own line.
|
||||||
|
let new_help = b"Options:\n --app <APP>\n --strict-audio\n With --app, never fall back...";
|
||||||
|
assert!(help_mentions_strict_audio(new_help));
|
||||||
|
let old_help = b"Options:\n --app <APP>\n --output <OUTPUT>\n -h, --help";
|
||||||
|
assert!(!help_mentions_strict_audio(old_help));
|
||||||
|
// Garbage / empty output degrades to "unsupported" (safe path).
|
||||||
|
assert!(!help_mentions_strict_audio(b""));
|
||||||
|
assert!(!help_mentions_strict_audio(&[0xff, 0xfe, 0x00]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
|
||||||
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
|
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]
|
#[test]
|
||||||
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
|
||||||
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
|
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);
|
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +1047,9 @@ mod tests {
|
|||||||
fn parses_connected_url() {
|
fn parses_connected_url() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
|
parse_pixelpass_event(r#"{"event":"connected","url":"http://127.0.0.1:5500"}"#),
|
||||||
Some(PixelpassEvent::Connected("http://127.0.0.1:5500".to_string()))
|
Some(PixelpassEvent::Connected(
|
||||||
|
"http://127.0.0.1:5500".to_string()
|
||||||
|
))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,6 +1102,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_app_audio_states() {
|
||||||
|
// The wire contract from pixelpass's --strict-audio run (A23): routed =
|
||||||
|
// the chosen app's audio is live; lost = it stopped (viewers now silent).
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio","state":"routed"}"#),
|
||||||
|
Some(PixelpassEvent::AppAudioRouted)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio","state":"lost"}"#),
|
||||||
|
Some(PixelpassEvent::AppAudioLost)
|
||||||
|
);
|
||||||
|
// Unknown / missing state is recognized-but-unused, not a parse failure.
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio","state":"weird"}"#),
|
||||||
|
Some(PixelpassEvent::Other)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_pixelpass_event(r#"{"event":"app_audio"}"#),
|
||||||
|
Some(PixelpassEvent::Other)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recognized_but_unused_event_is_other() {
|
fn recognized_but_unused_event_is_other() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -529,7 +1184,10 @@ mod tests {
|
|||||||
let dir = Path::new("bin");
|
let dir = Path::new("bin");
|
||||||
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
|
||||||
#[cfg(windows)]
|
#[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))]
|
#[cfg(not(windows))]
|
||||||
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
assert_eq!(candidates, vec![dir.join("pixelpass")]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -400,7 +400,11 @@ mod tests {
|
|||||||
for theme in AppTheme::ALL {
|
for theme in AppTheme::ALL {
|
||||||
let p = theme.palette();
|
let p = theme.palette();
|
||||||
let sub = contrast_ratio(p.subtext, p.base);
|
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);
|
let accent = contrast_ratio(p.blue, p.base);
|
||||||
assert!(
|
assert!(
|
||||||
accent >= 3.0,
|
accent >= 3.0,
|
||||||
|
|||||||
@@ -0,0 +1,996 @@
|
|||||||
|
use iced::advanced::clipboard::{self, Clipboard};
|
||||||
|
use iced::advanced::layout;
|
||||||
|
use iced::advanced::mouse;
|
||||||
|
use iced::advanced::overlay;
|
||||||
|
use iced::advanced::renderer;
|
||||||
|
use iced::advanced::text;
|
||||||
|
use iced::advanced::widget::tree::{self, Tree};
|
||||||
|
use iced::advanced::widget::{self, Widget};
|
||||||
|
use iced::advanced::{Layout, Shell};
|
||||||
|
use iced::widget::text_input;
|
||||||
|
use iced::{
|
||||||
|
Background, Border, Color, Element, Event, Length, Padding, Pixels, Point, Rectangle, Shadow,
|
||||||
|
Size, Vector, alignment,
|
||||||
|
};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Edit {
|
||||||
|
pub value: String,
|
||||||
|
pub cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
|
||||||
|
let value = text_input::Value::new(value);
|
||||||
|
let (start, end) = normalized_range(&value, start, end);
|
||||||
|
|
||||||
|
(start != end).then(|| value.select(start, end).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cut_selection(value: &str, start: usize, end: usize) -> (Edit, Option<String>) {
|
||||||
|
let mut value = text_input::Value::new(value);
|
||||||
|
let (start, end) = normalized_range(&value, start, end);
|
||||||
|
|
||||||
|
if start == end {
|
||||||
|
return (
|
||||||
|
Edit {
|
||||||
|
value: value.to_string(),
|
||||||
|
cursor: start,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected = value.select(start, end).to_string();
|
||||||
|
value.remove_many(start, end);
|
||||||
|
|
||||||
|
(
|
||||||
|
Edit {
|
||||||
|
value: value.to_string(),
|
||||||
|
cursor: start,
|
||||||
|
},
|
||||||
|
Some(selected),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
|
||||||
|
let mut value = text_input::Value::new(value);
|
||||||
|
let (start, end) = normalized_range(&value, start, end);
|
||||||
|
let clip = text_input::Value::new(clip);
|
||||||
|
let cursor = start + clip.len();
|
||||||
|
|
||||||
|
if start != end {
|
||||||
|
value.remove_many(start, end);
|
||||||
|
}
|
||||||
|
value.insert_many(start, clip);
|
||||||
|
|
||||||
|
Edit {
|
||||||
|
value: value.to_string(),
|
||||||
|
cursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip control characters (e.g. a trailing newline on an X11 PRIMARY
|
||||||
|
/// selection) from clipboard text before it is pasted. Shared by the
|
||||||
|
/// right-click menu Paste and the middle-click PRIMARY paste.
|
||||||
|
pub fn sanitize_clip(raw: &str) -> String {
|
||||||
|
raw.chars().filter(|c| !c.is_control()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_all_range(value: &str) -> (usize, usize) {
|
||||||
|
let value = text_input::Value::new(value);
|
||||||
|
|
||||||
|
(0, value.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_range(value: &text_input::Value, start: usize, end: usize) -> (usize, usize) {
|
||||||
|
let len = value.len();
|
||||||
|
|
||||||
|
(start.min(end).min(len), start.max(end).min(len))
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputStyleFn<'a, Theme> = Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
|
||||||
|
|
||||||
|
pub fn context_input<'a, Message, Theme, Renderer>(
|
||||||
|
placeholder: &str,
|
||||||
|
value: &str,
|
||||||
|
) -> ContextInput<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: Clone + 'a,
|
||||||
|
Theme: text_input::Catalog + 'a,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
ContextInput::new(placeholder, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn locked_value<'a, Message, Theme, Renderer>(
|
||||||
|
value: &str,
|
||||||
|
noop: Message,
|
||||||
|
) -> ContextInput<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: Clone + 'a,
|
||||||
|
Theme: text_input::Catalog + 'a,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
ContextInput::new("", value)
|
||||||
|
.on_input(move |_| noop.clone())
|
||||||
|
.locked(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ContextInput<'a, Message, Theme = iced::Theme, Renderer = iced::Renderer>
|
||||||
|
where
|
||||||
|
Theme: text_input::Catalog,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
input: text_input::TextInput<'a, Message, Theme, Renderer>,
|
||||||
|
value: String,
|
||||||
|
is_secure: bool,
|
||||||
|
locked: bool,
|
||||||
|
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
|
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
|
style: Option<InputStyleFn<'a, Theme>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message, Theme, Renderer> ContextInput<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: Clone + 'a,
|
||||||
|
Theme: text_input::Catalog + 'a,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
pub fn new(placeholder: &str, value: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
input: text_input::TextInput::new(placeholder, value),
|
||||||
|
value: value.to_owned(),
|
||||||
|
is_secure: false,
|
||||||
|
locked: false,
|
||||||
|
on_input: None,
|
||||||
|
on_paste: None,
|
||||||
|
style: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
|
||||||
|
self.input = self.input.id(id);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn secure(mut self, is_secure: bool) -> Self {
|
||||||
|
self.is_secure = is_secure;
|
||||||
|
self.input = self.input.secure(is_secure);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn locked(mut self, yes: bool) -> Self {
|
||||||
|
self.locked = yes;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_input(mut self, on_input: impl Fn(String) -> Message + 'a) -> Self {
|
||||||
|
let on_input: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_input);
|
||||||
|
let input_callback = Rc::clone(&on_input);
|
||||||
|
|
||||||
|
self.input = self
|
||||||
|
.input
|
||||||
|
.on_input(move |value| input_callback.as_ref()(value));
|
||||||
|
self.on_input = Some(on_input);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_submit(mut self, message: Message) -> Self {
|
||||||
|
self.input = self.input.on_submit(message);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
|
||||||
|
self.input = self.input.on_submit_maybe(message);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_paste(mut self, on_paste: impl Fn(String) -> Message + 'a) -> Self {
|
||||||
|
let on_paste: Rc<dyn Fn(String) -> Message + 'a> = Rc::new(on_paste);
|
||||||
|
let paste_callback = Rc::clone(&on_paste);
|
||||||
|
|
||||||
|
self.input = self
|
||||||
|
.input
|
||||||
|
.on_paste(move |value| paste_callback.as_ref()(value));
|
||||||
|
self.on_paste = Some(on_paste);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn font(mut self, font: Renderer::Font) -> Self {
|
||||||
|
self.input = self.input.font(font);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
|
||||||
|
self.input = self.input.icon(icon);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
||||||
|
self.input = self.input.width(width);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
||||||
|
self.input = self.input.padding(padding);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
|
||||||
|
self.input = self.input.size(size);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn line_height(mut self, line_height: impl Into<text::LineHeight>) -> Self {
|
||||||
|
self.input = self.input.line_height(line_height);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn align_x(mut self, alignment: impl Into<alignment::Horizontal>) -> Self {
|
||||||
|
self.input = self.input.align_x(alignment);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn style(
|
||||||
|
mut self,
|
||||||
|
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
|
||||||
|
) -> Self
|
||||||
|
where
|
||||||
|
Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
|
||||||
|
{
|
||||||
|
let style: InputStyleFn<'a, Theme> = Rc::new(style);
|
||||||
|
let input_style = Rc::clone(&style);
|
||||||
|
|
||||||
|
self.input = self
|
||||||
|
.input
|
||||||
|
.style(move |theme, status| input_style.as_ref()(theme, status));
|
||||||
|
self.style = Some(style);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
|
||||||
|
self.input = self.input.class(class);
|
||||||
|
self.style = None;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ContextInputState {
|
||||||
|
menu: Option<MenuState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct MenuState {
|
||||||
|
anchor: Point,
|
||||||
|
selection: (usize, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
|
||||||
|
for ContextInput<'_, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: Clone,
|
||||||
|
Theme: text_input::Catalog,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
fn tag(&self) -> tree::Tag {
|
||||||
|
tree::Tag::of::<ContextInputState>()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(&self) -> tree::State {
|
||||||
|
tree::State::new(ContextInputState::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn children(&self) -> Vec<Tree> {
|
||||||
|
vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn diff(&self, tree: &mut Tree) {
|
||||||
|
if tree.children.is_empty() {
|
||||||
|
tree.children
|
||||||
|
.push(Tree::new(&self.input as &dyn Widget<_, _, _>));
|
||||||
|
} else {
|
||||||
|
tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
|
||||||
|
tree.children.truncate(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(&self) -> Size<Length> {
|
||||||
|
Widget::size(&self.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_hint(&self) -> Size<Length> {
|
||||||
|
Widget::size_hint(&self.input)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
renderer: &Renderer,
|
||||||
|
limits: &layout::Limits,
|
||||||
|
) -> layout::Node {
|
||||||
|
Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operate(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
renderer: &Renderer,
|
||||||
|
operation: &mut dyn widget::Operation,
|
||||||
|
) {
|
||||||
|
Widget::operate(
|
||||||
|
&mut self.input,
|
||||||
|
&mut tree.children[0],
|
||||||
|
layout,
|
||||||
|
renderer,
|
||||||
|
operation,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
tree: &mut Tree,
|
||||||
|
event: &Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
renderer: &Renderer,
|
||||||
|
clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
let right_click_on_input = matches!(
|
||||||
|
event,
|
||||||
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||||
|
) && cursor.is_over(layout.bounds());
|
||||||
|
|
||||||
|
if right_click_on_input {
|
||||||
|
let value = text_input::Value::new(&self.value);
|
||||||
|
let input_state = tree.children[0]
|
||||||
|
.state
|
||||||
|
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
|
||||||
|
let selection = match input_state.cursor().state(&value) {
|
||||||
|
text_input::cursor::State::Index(index) => {
|
||||||
|
let index = index.min(value.len());
|
||||||
|
(index, index)
|
||||||
|
}
|
||||||
|
text_input::cursor::State::Selection { start, end } => {
|
||||||
|
normalized_range(&value, start, end)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tree.state.downcast_mut::<ContextInputState>().menu = cursor
|
||||||
|
.position()
|
||||||
|
.map(|anchor| MenuState { anchor, selection });
|
||||||
|
|
||||||
|
shell.capture_event();
|
||||||
|
shell.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middle-click pastes the X11 PRIMARY selection at the cursor. iced's
|
||||||
|
// base text_input only wires Ctrl+V to the Standard (CLIPBOARD)
|
||||||
|
// selection, so without this the common "select text, middle-click to
|
||||||
|
// paste" workflow does nothing on X11.
|
||||||
|
let middle_click_on_input = matches!(
|
||||||
|
event,
|
||||||
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
|
||||||
|
) && cursor.is_over(layout.bounds());
|
||||||
|
|
||||||
|
if middle_click_on_input && !self.locked {
|
||||||
|
let clip = sanitize_clip(&clipboard.read(clipboard::Kind::Primary).unwrap_or_default());
|
||||||
|
|
||||||
|
if !clip.is_empty() {
|
||||||
|
let value = text_input::Value::new(&self.value);
|
||||||
|
let input_state = tree.children[0]
|
||||||
|
.state
|
||||||
|
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
|
||||||
|
let (start, end) = match input_state.cursor().state(&value) {
|
||||||
|
text_input::cursor::State::Index(index) => {
|
||||||
|
let index = index.min(value.len());
|
||||||
|
(index, index)
|
||||||
|
}
|
||||||
|
text_input::cursor::State::Selection { start, end } => {
|
||||||
|
normalized_range(&value, start, end)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let edit = paste(&self.value, start, end, &clip);
|
||||||
|
input_state.move_cursor_to(edit.cursor);
|
||||||
|
|
||||||
|
if let Some(on_paste) = &self.on_paste {
|
||||||
|
shell.publish(on_paste.as_ref()(edit.value));
|
||||||
|
} else if let Some(on_input) = &self.on_input {
|
||||||
|
shell.publish(on_input.as_ref()(edit.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.capture_event();
|
||||||
|
shell.request_redraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget::update(
|
||||||
|
&mut self.input,
|
||||||
|
&mut tree.children[0],
|
||||||
|
event,
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
renderer,
|
||||||
|
clipboard,
|
||||||
|
shell,
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
tree: &Tree,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
) {
|
||||||
|
Widget::draw(
|
||||||
|
&self.input,
|
||||||
|
&tree.children[0],
|
||||||
|
renderer,
|
||||||
|
theme,
|
||||||
|
style,
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
tree: &Tree,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
viewport: &Rectangle,
|
||||||
|
renderer: &Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
Widget::mouse_interaction(
|
||||||
|
&self.input,
|
||||||
|
&tree.children[0],
|
||||||
|
layout,
|
||||||
|
cursor,
|
||||||
|
viewport,
|
||||||
|
renderer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay<'a>(
|
||||||
|
&'a mut self,
|
||||||
|
tree: &'a mut Tree,
|
||||||
|
_layout: Layout<'a>,
|
||||||
|
_renderer: &Renderer,
|
||||||
|
_viewport: &Rectangle,
|
||||||
|
_translation: Vector,
|
||||||
|
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
|
||||||
|
let Tree {
|
||||||
|
state, children, ..
|
||||||
|
} = tree;
|
||||||
|
let menu = &mut state.downcast_mut::<ContextInputState>().menu;
|
||||||
|
|
||||||
|
if menu.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let input_state = children[0]
|
||||||
|
.state
|
||||||
|
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
|
||||||
|
|
||||||
|
Some(overlay::Element::new(Box::new(ContextMenuOverlay {
|
||||||
|
menu,
|
||||||
|
input_state,
|
||||||
|
value: &self.value,
|
||||||
|
is_secure: self.is_secure,
|
||||||
|
locked: self.locked,
|
||||||
|
on_input: self.on_input.clone(),
|
||||||
|
on_paste: self.on_paste.clone(),
|
||||||
|
style: self.style.clone(),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a, Message, Theme, Renderer> From<ContextInput<'a, Message, Theme, Renderer>>
|
||||||
|
for Element<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Message: Clone + 'a,
|
||||||
|
Theme: text_input::Catalog + 'a,
|
||||||
|
Renderer: text::Renderer + 'a,
|
||||||
|
{
|
||||||
|
fn from(
|
||||||
|
input: ContextInput<'a, Message, Theme, Renderer>,
|
||||||
|
) -> Element<'a, Message, Theme, Renderer> {
|
||||||
|
Element::new(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ContextMenuOverlay<'a, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Theme: text_input::Catalog,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
menu: &'a mut Option<MenuState>,
|
||||||
|
input_state: &'a mut text_input::State<Renderer::Paragraph>,
|
||||||
|
value: &'a str,
|
||||||
|
is_secure: bool,
|
||||||
|
locked: bool,
|
||||||
|
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
|
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
|
||||||
|
style: Option<InputStyleFn<'a, Theme>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum MenuAction {
|
||||||
|
Cut,
|
||||||
|
Copy,
|
||||||
|
Paste,
|
||||||
|
SelectAll,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuAction {
|
||||||
|
const ALL: [Self; 4] = [Self::Cut, Self::Copy, Self::Paste, Self::SelectAll];
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Cut => "Cut",
|
||||||
|
Self::Copy => "Copy",
|
||||||
|
Self::Paste => "Paste",
|
||||||
|
Self::SelectAll => "Select All",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MENU_WIDTH: f32 = 136.0;
|
||||||
|
const ITEM_HEIGHT: f32 = 28.0;
|
||||||
|
const TEXT_SIZE: f32 = 13.0;
|
||||||
|
const MENU_PADDING_X: f32 = 10.0;
|
||||||
|
|
||||||
|
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
|
||||||
|
for ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Theme: text_input::Catalog,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
|
||||||
|
let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
|
||||||
|
let Some(menu) = self.menu.as_ref() else {
|
||||||
|
return layout::Node::new(Size::ZERO);
|
||||||
|
};
|
||||||
|
let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
|
||||||
|
let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));
|
||||||
|
|
||||||
|
layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(
|
||||||
|
&self,
|
||||||
|
renderer: &mut Renderer,
|
||||||
|
theme: &Theme,
|
||||||
|
_style: &renderer::Style,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
) {
|
||||||
|
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
|
||||||
|
let hovered_style = input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
let viewport = Rectangle::INFINITE;
|
||||||
|
|
||||||
|
renderer.fill_quad(
|
||||||
|
renderer::Quad {
|
||||||
|
bounds,
|
||||||
|
border: Border {
|
||||||
|
radius: 5.0.into(),
|
||||||
|
width: 1.0,
|
||||||
|
color: active_style.border.color,
|
||||||
|
},
|
||||||
|
shadow: Shadow {
|
||||||
|
color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
|
||||||
|
offset: Vector::new(0.0, 4.0),
|
||||||
|
blur_radius: 10.0,
|
||||||
|
},
|
||||||
|
..renderer::Quad::default()
|
||||||
|
},
|
||||||
|
active_style.background,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
|
||||||
|
let item_bounds = item_bounds(bounds, index);
|
||||||
|
let enabled = self.enabled(action);
|
||||||
|
let hovered = enabled && cursor.is_over(item_bounds);
|
||||||
|
|
||||||
|
if hovered {
|
||||||
|
renderer.fill_quad(
|
||||||
|
renderer::Quad {
|
||||||
|
bounds: item_bounds,
|
||||||
|
border: Border {
|
||||||
|
radius: 3.0.into(),
|
||||||
|
..Border::default()
|
||||||
|
},
|
||||||
|
..renderer::Quad::default()
|
||||||
|
},
|
||||||
|
Background::Color(hovered_style.selection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.fill_text(
|
||||||
|
text::Text {
|
||||||
|
content: action.label().to_owned(),
|
||||||
|
bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
|
||||||
|
size: Pixels(TEXT_SIZE),
|
||||||
|
line_height: text::LineHeight::default(),
|
||||||
|
font: renderer.default_font(),
|
||||||
|
align_x: text::Alignment::Default,
|
||||||
|
align_y: alignment::Vertical::Center,
|
||||||
|
shaping: text::Shaping::Advanced,
|
||||||
|
wrapping: text::Wrapping::default(),
|
||||||
|
},
|
||||||
|
Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
|
||||||
|
if enabled {
|
||||||
|
active_style.value
|
||||||
|
} else {
|
||||||
|
disabled_color(active_style.value)
|
||||||
|
},
|
||||||
|
viewport,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(
|
||||||
|
&mut self,
|
||||||
|
event: &Event,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
_renderer: &Renderer,
|
||||||
|
clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
) {
|
||||||
|
match event {
|
||||||
|
Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||||
|
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
self.close(shell);
|
||||||
|
}
|
||||||
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||||
|
let Some(position) = cursor.position() else {
|
||||||
|
self.close(shell);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let bounds = layout.bounds();
|
||||||
|
|
||||||
|
if !bounds.contains(position) {
|
||||||
|
self.close(shell);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(action) = self.hit_action(bounds, position) {
|
||||||
|
if self.enabled(action) {
|
||||||
|
self.perform(action, clipboard, shell);
|
||||||
|
}
|
||||||
|
self.close(shell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
|
||||||
|
let should_close = cursor
|
||||||
|
.position()
|
||||||
|
.is_none_or(|position| !layout.bounds().contains(position));
|
||||||
|
|
||||||
|
if should_close {
|
||||||
|
self.close(shell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_interaction(
|
||||||
|
&self,
|
||||||
|
layout: Layout<'_>,
|
||||||
|
cursor: mouse::Cursor,
|
||||||
|
_renderer: &Renderer,
|
||||||
|
) -> mouse::Interaction {
|
||||||
|
let Some(position) = cursor.position() else {
|
||||||
|
return mouse::Interaction::default();
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.hit_action(layout.bounds(), position).is_some() {
|
||||||
|
mouse::Interaction::Pointer
|
||||||
|
} else {
|
||||||
|
mouse::Interaction::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
|
||||||
|
where
|
||||||
|
Theme: text_input::Catalog,
|
||||||
|
Renderer: text::Renderer,
|
||||||
|
{
|
||||||
|
fn enabled(&self, action: MenuAction) -> bool {
|
||||||
|
let Some(menu) = self.menu.as_ref() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let has_selection = menu.selection.0 != menu.selection.1;
|
||||||
|
let has_value = !text_input::Value::new(self.value).is_empty();
|
||||||
|
|
||||||
|
menu_action_enabled(
|
||||||
|
action,
|
||||||
|
has_selection,
|
||||||
|
has_value,
|
||||||
|
self.is_secure,
|
||||||
|
self.locked,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hit_action(&self, bounds: Rectangle, position: Point) -> Option<MenuAction> {
|
||||||
|
if !bounds.contains(position) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;
|
||||||
|
|
||||||
|
MenuAction::ALL.get(index).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn perform(
|
||||||
|
&mut self,
|
||||||
|
action: MenuAction,
|
||||||
|
clipboard: &mut dyn Clipboard,
|
||||||
|
shell: &mut Shell<'_, Message>,
|
||||||
|
) {
|
||||||
|
let Some(menu) = self.menu.as_ref().copied() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (start, end) = menu.selection;
|
||||||
|
|
||||||
|
match action {
|
||||||
|
MenuAction::Cut => {
|
||||||
|
let (edit, selected) = cut_selection(self.value, start, end);
|
||||||
|
|
||||||
|
if let Some(selected) = selected {
|
||||||
|
clipboard.write(clipboard::Kind::Standard, selected);
|
||||||
|
self.publish_edit(edit, shell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::Copy => {
|
||||||
|
if let Some(selected) = copy_selection(self.value, start, end) {
|
||||||
|
clipboard.write(clipboard::Kind::Standard, selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MenuAction::Paste => {
|
||||||
|
let clip = sanitize_clip(
|
||||||
|
&clipboard
|
||||||
|
.read(clipboard::Kind::Standard)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let edit = paste(self.value, start, end, &clip);
|
||||||
|
|
||||||
|
self.publish_paste(edit, shell);
|
||||||
|
}
|
||||||
|
MenuAction::SelectAll => {
|
||||||
|
let (start, end) = select_all_range(self.value);
|
||||||
|
|
||||||
|
self.input_state.select_range(start, end);
|
||||||
|
shell.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||||
|
if let Some(on_input) = &self.on_input {
|
||||||
|
self.input_state.move_cursor_to(edit.cursor);
|
||||||
|
shell.publish(on_input.as_ref()(edit.value));
|
||||||
|
shell.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
|
||||||
|
self.input_state.move_cursor_to(edit.cursor);
|
||||||
|
|
||||||
|
if let Some(on_paste) = &self.on_paste {
|
||||||
|
shell.publish(on_paste.as_ref()(edit.value));
|
||||||
|
} else if let Some(on_input) = &self.on_input {
|
||||||
|
shell.publish(on_input.as_ref()(edit.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
shell.request_redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self, shell: &mut Shell<'_, Message>) {
|
||||||
|
*self.menu = None;
|
||||||
|
shell.capture_event();
|
||||||
|
shell.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
|
||||||
|
Rectangle {
|
||||||
|
x: menu_bounds.x + 3.0,
|
||||||
|
y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
|
||||||
|
width: menu_bounds.width - 6.0,
|
||||||
|
height: ITEM_HEIGHT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn input_style<Theme: text_input::Catalog>(
|
||||||
|
theme: &Theme,
|
||||||
|
style: Option<&InputStyleFn<'_, Theme>>,
|
||||||
|
status: text_input::Status,
|
||||||
|
) -> text_input::Style {
|
||||||
|
if let Some(style) = style {
|
||||||
|
style.as_ref()(theme, status)
|
||||||
|
} else {
|
||||||
|
let class = <Theme as text_input::Catalog>::default();
|
||||||
|
|
||||||
|
theme.style(&class, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled_color(color: Color) -> Color {
|
||||||
|
Color {
|
||||||
|
a: color.a * 0.45,
|
||||||
|
..color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn menu_action_enabled(
|
||||||
|
action: MenuAction,
|
||||||
|
has_selection: bool,
|
||||||
|
has_value: bool,
|
||||||
|
is_secure: bool,
|
||||||
|
locked: bool,
|
||||||
|
) -> bool {
|
||||||
|
match action {
|
||||||
|
MenuAction::Cut => has_selection && !is_secure && !locked,
|
||||||
|
MenuAction::Copy => has_selection && !is_secure,
|
||||||
|
MenuAction::Paste => !locked,
|
||||||
|
MenuAction::SelectAll => has_value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn copy_selection_returns_middle_substring_and_empty_none() {
|
||||||
|
assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
|
||||||
|
assert_eq!(copy_selection("abcdef", 3, 3), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cut_selection_removes_range_and_copies_selection() {
|
||||||
|
let (edit, clip) = cut_selection("abcdef", 2, 5);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
edit,
|
||||||
|
Edit {
|
||||||
|
value: "abf".to_owned(),
|
||||||
|
cursor: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(clip, Some("cde".to_owned()));
|
||||||
|
|
||||||
|
let (edit, clip) = cut_selection("abcdef", 3, 3);
|
||||||
|
assert_eq!(
|
||||||
|
edit,
|
||||||
|
Edit {
|
||||||
|
value: "abcdef".to_owned(),
|
||||||
|
cursor: 3,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(clip, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_clip_strips_control_chars_keeps_text() {
|
||||||
|
// An X11 PRIMARY selection commonly carries a trailing newline.
|
||||||
|
assert_eq!(sanitize_clip("pixelpassF1:abc\n"), "pixelpassF1:abc");
|
||||||
|
assert_eq!(sanitize_clip("a\tb\r\nc"), "abc");
|
||||||
|
// Non-control unicode is preserved.
|
||||||
|
assert_eq!(sanitize_clip("héllo🦀"), "héllo🦀");
|
||||||
|
assert_eq!(sanitize_clip(""), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paste_replaces_selection_or_inserts_at_cursor() {
|
||||||
|
assert_eq!(
|
||||||
|
paste("abcdef", 2, 5, "XY"),
|
||||||
|
Edit {
|
||||||
|
value: "abXYf".to_owned(),
|
||||||
|
cursor: 4,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
paste("abcdef", 3, 3, "XY"),
|
||||||
|
Edit {
|
||||||
|
value: "abcXYdef".to_owned(),
|
||||||
|
cursor: 5,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_all_range_uses_grapheme_length() {
|
||||||
|
assert_eq!(select_all_range(""), (0, 0));
|
||||||
|
assert_eq!(select_all_range("abé🦀"), (0, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unicode_selection_boundaries_are_grapheme_correct() {
|
||||||
|
assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));
|
||||||
|
|
||||||
|
let (edit, clip) = cut_selection("aé🦀z", 2, 3);
|
||||||
|
assert_eq!(clip, Some("🦀".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
edit,
|
||||||
|
Edit {
|
||||||
|
value: "aéz".to_owned(),
|
||||||
|
cursor: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
paste("aéz", 2, 2, "🦀"),
|
||||||
|
Edit {
|
||||||
|
value: "aé🦀z".to_owned(),
|
||||||
|
cursor: 3,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_menu_allows_copy_and_select_all_only() {
|
||||||
|
assert!(!menu_action_enabled(
|
||||||
|
MenuAction::Cut,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(menu_action_enabled(
|
||||||
|
MenuAction::Copy,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!menu_action_enabled(
|
||||||
|
MenuAction::Paste,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(menu_action_enabled(
|
||||||
|
MenuAction::SelectAll,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(!menu_action_enabled(
|
||||||
|
MenuAction::Copy,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!menu_action_enabled(
|
||||||
|
MenuAction::SelectAll,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||