122 Commits
Author SHA1 Message Date
molluskandClaude Fable 5 3d7b01c8a2 release: 0.6.4
CI / check (push) Failing after 3m17s
Wire-compatible refinement release (GOSSIP_PROTO stays 5). Highlights:
honest chat send status + sender-side pacing (chat-hardening Phase 5),
completing the chat-hardening plan; playlist drawer de-clutter + auto-resize;
plus the FEC-gap and network-restart fixes already on the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:16:39 -04:00
molluskandClaude Fable 5 5f4eba1815 chat: honest local send status + sender-side pacing (Phase 5)
CI / check (push) Successful in 4m17s
Closes the final phase of docs/chat-hardening-plan.md. Two problems: a
locally echoed message always looked sent even when the core had no active
session or the gossip broadcast failed; and a fast burst could broadcast
'successfully' yet be silently dropped by every receiver's per-author rate
bucket (8 burst, then 1/s) with no sender feedback.

Send status: CoreCommand::SendChat/SendChatFile carry a local-only id (never
on the wire); the core replies with UiEvent::ChatSendResult after the gossip
broadcast succeeds or fails, and a no-active-session is now an explicit
failure rather than a silent no-op. gossip send_chat, which previously
returned Ok on a missing sender/topic or an encode failure, now returns Err.
ChatEntry gains local_send: Option<LocalSend>; failed sends render a red
'Not sent — {reason}  [Retry]' line, Broadcast/Pending render nothing
(there are no delivery receipts, so silence is the honest success state).

Sender-side pacing (new src/app/sendqueue.rs): sends past the burst queue
locally as 'queued…' and trickle out at the receivers' sustained rate, so
nothing is lost and typing is never blocked (user chose queue-and-trickle
over input throttling). The pacer reuses the gossip gate's own TokenBucket +
per-author constants (now pub(crate)) so the two sides of the policy can't
drift. A 250ms drain subscription runs only while the queue is non-empty.
Retry re-dispatches the retained payload; re-serving the same attachment id
replaces the ServeStore entry rather than double-counting bytes. The pacer
and monotonic send-id counter survive a room reset (receivers' buckets
persist; ids never alias a late result); queue and retry payloads are cleared.

582 lib tests (+11: 4 pacer/queue seam, 7 app-level transition/retry/reset);
all-targets green, clippy -D warnings clean, fmt clean, smoke launch OK. No
wire change (GOSSIP_PROTO stays 5). Tests-green-only — the two owed
two-machine field-test items are logged in the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:03:25 -04:00
molluskandClaude Fable 5 77d2bf2992 style(music): de-duplicate drawer transport controls, let playlist fill drawer height
CI / check (push) Successful in 2m43s
The playlist drawer duplicated the player bar's |prev/play/next| transport
row even though the drawer can only be open while the bar is visible
(drawer_open gates on show_player_bar), so the drawer copy is removed;
seek, music volume, Browse, and the tune-in checkbox remain drawer-only.

The track list (and the Public tab's broadcast list) was a 160px-fixed
scrollable nested inside a second full-height scrollable, showing only a
few entries. The outer scrollable is gone and both lists now fill the
drawer's remaining height, resizing with the window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:25:34 -04:00
molluskandClaude Fable 5 c8d0053431 docs(plan): add Phase 4 items to the two-machine field-test checklist
CI / check (push) Successful in 3m31s
Also re-triggers CI: run 165 on 1d038be died to rust-lld crashes from disk
exhaustion on the runner host (12G free vs ~12G cold-build transient), not a
code failure; 18G of local build artifacts have been swept (30G free now).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:04:25 -04:00
molluskandClaude Fable 5 1d038be9a0 chat: parsed-URL link policy, cached link ranges, history byte budget (Phase 4)
CI / check (push) Failing after 3m10s
Phase 4 of docs/chat-hardening-plan.md — URL and rendering resilience.
Closes the chat-body half of S14 (bidi override strip).

- sanitize: new is_safe_web_url shared link policy (url crate, promoted to a
  direct dependency): http/https scheme + non-empty host + no userinfo;
  candidates failing it stay plain text (their whole whitespace run, interior
  not re-scanned). Scheme detection is now ASCII-case-insensitive.
- sanitize: linkify() -> link_ranges()/segments(): validated byte ranges
  computed once, exact-roundtrip slicing, at most CHAT_MSG_MAX_LINKS (8)
  clickable links per message; the rest stays selectable plain text.
- sanitize_chat: strips bidi overrides/isolates (U+202A-202E, U+2066-2069)
  from message bodies while keeping ZWJ/ZWNJ/LRM/RLM (S14 chat-body half).
- app: ChatEntry caches its link ranges (filled in push_chat), so redraws
  slice instead of rescanning/re-validating; only link spans allocate.
- app: chat history now also bounded by 512 KiB total sanitized text
  (CHAT_HISTORY_MAX_TEXT_BYTES) alongside the 300-entry cap; the attachment
  byte cache is deliberately untouched by history eviction (own budgets).
- app: AppMessage::OpenUrl re-checks the same parsed policy (defence in
  depth) instead of prefix checks - non-web schemes can never reach the
  opener even if the handler is invoked directly.

571 lib tests green (+3 net); clippy -D warnings + fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:57:15 -04:00
molluskandClaude Fable 5 554b613466 chat: attachment cache, download, and transfer hardening (Phase 3)
CI / check (push) Successful in 2m33s
Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn
into unbounded memory, bandwidth, decoder, or task pressure (S15 closed;
S14's filename half closed).

Cache and image cost (3A): AttachmentCache now carries encoded- and
decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with
per-entry weights, replacement accounting, and oldest-first eviction; an
individually over-budget fetch services any pending Save/Play from the
bytes in hand and is exposed as Evicted instead of retained.
validate_image_bytes prechecks header dimensions (per-side AND a new
14 MP total-pixel limit) before any decode; the renderer only ever
receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts
against the decoded budget — originals stay encoded-only for Save.
sanitize_filename strips the bidi/zero-width spoofing set (RTL-override
extension spoof).

Download policy and state (3B): images auto-fetch only when roster-
authored AND declared ≤4 MiB, gated by a new deterministic
AutoFetchBudget (per-author and session request+byte token buckets,
check-then-take, bounded author map) alongside the existing dedup and
four-permit bound. Attachment state is now explicit — absence/Loading/
Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so
skipped or evicted images render a "Load image" button instead of an
indefinite "loading…", and repeated clicks can never spawn duplicate
fetch tasks.

Exact transfers and serve store (3C): fetch_blob requires the received
length to equal the declared size (short = local error, overlong =
bounded-read reject, empty keeps meaning "sender no longer has it");
the file picker's unbounded read is replaced by a metadata-prechecked
cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command
queue, and serve store; served_files is a count- and byte-budgeted FIFO
ServeStore (16 entries / 128 MiB).

37 new tests (568 lib total) including a real two-endpoint loopback
exercising exact/short/overlong/unknown-id transfers. Plan checkboxes
ticked and constant deviations decision-logged. Tests-green-only: the
plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 02:09:30 -04:00
molluskandClaude Fable 5 8898652349 chat: roster-bound authorship, replay dedup, and rate limits (Phase 2)
CI / check (push) Successful in 2m59s
Chat-hardening plan Phase 2 — only current authenticated room members can
create chat UI work, impersonation via the wire name is structurally closed,
and no member can monopolize the event channel:

- core: new ChatRoster (bounded id -> sanitized-name map, shared) replaces the
  event task's bare HashSet; upserted on PeerJoined/PeerUpdated, removed on
  graceful PeerLeft AND terminal grace-expiry eviction (both timer paths).
  Non-roster chat is dropped before attachment handling; the rendered author
  label is the roster-bound name — the sender-claimed wire name is never read.
- gossip: ChatIngressGate after verify_gossip, before any sanitize work or
  event send: early known-author gate (live + mid-reconnect peers), exact-
  replay suppression keyed on the deterministic Ed25519 signature (1024-entry
  cap + freshness-window TTL, zero new deps vs the plan's BLAKE3 option), then
  per-author (8 burst, 1/s) and room-wide (32 burst, 8/s) token buckets.
  Replays are detected before tokens are consumed; a room-bucket reject
  refunds the author token; rejection logging is squelched per author.
- The inner Chat.ts is now ignored entirely; RoomEvent carries the signed
  envelope timestamp.

550 lib tests (+18), reconnect_eviction +1 (grace keeps chat authority,
terminal eviction revokes it), clippy --all-targets -D warnings clean.
Tests-green-only: the plan's two-machine field-test section remains open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 20:37:13 -04:00
molluskandClaude Fable 5 5927148ee4 chat: enforce shared text policy at UI, sign point, and gossip ingress
CI / check (push) Successful in 3m4s
Chat-hardening plan Phase 1. The chat body policy (2,000-char + 8 KiB
ceilings, single-pass control/whitespace normalization) moves from the UI
layer into src/sanitize.rs and is now enforced at every trust boundary:
cap_chat_input bounds the live input (oversized paste), the gossip sign
point re-sanitizes so non-UI callers can't bypass policy, and gossip
ingress rejects oversized raw text before sanitizing (admit_chat_text)
and drops messages with neither visible text nor an attachment. The
incoming chat author label now uses the strict name sanitizer until
Phase 2 roster-binds it. +8 tests (532 lib green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 18:36:21 -04:00
molluskandClaude Fable 5 93f4954653 docs: changelog for the jitter FEC and net-rebuild resilience fixes
CI / check (push) Successful in 2m26s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:42:08 -04:00
molluskandClaude Fable 5 e6eb490939 audio: only FEC-recover a gap from its immediate successor packet
CI / check (push) Failing after 10m54s
The jitter buffer's gap path fed the LOWEST buffered packet to
decode_fec regardless of position. Opus in-band FEC in packet N carries
a copy of frame N-1 and nothing else, so that reconstruction is only
correct when the smallest survivor is exactly next+1 (single loss).
On burst loss it spliced a later frame's audio into the wrong slot —
worse than concealment. Gate FEC on adjacency (new fec_covers_gap(),
wraparound-aware); everything else falls back to plain PLC.

Two new tests: the gate itself, and a burst-loss test proven to bite —
it compares bit-exact against a twin decoder and fails against the old
unconditional-FEC behavior (checked by mutation).

Fixes finding 3 of the 2026-07-16 full-codebase review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:31:47 -04:00
molluskandClaude Fable 5 e724167b03 docs: add chat-hardening plan as scope contract
GPT-5.6's 5-phase plan for the chat identity/replay/rate-limit cluster
(2026-07-16 review findings 5-8): roster-bound display names, replay
dedup, quiet rate limiting, bidi-aware sanitization, attachment size
checks. Self-describes as temporary — delete when the work completes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 00:27:47 -04:00
molluskandClaude Fable 5 7b9cb57003 core: survive a failed net-stack rebuild instead of silently dying
CI / check (push) Failing after 13m37s
The four live-rebuild sites (deferred rebuild on Join/Leave, idle
SetNetworkMode, idle RegenerateIdentity) all did
`net.shutdown().await` then `build_net_stack(...).await?` — a build
failure propagated out of run_core_loop, which its supervisor only
logs. Every subsequent command went nowhere: window alive, app dead,
user told nothing. (The initial startup build already reported.)

New replace_net_stack() helper: tear down the old stack, build for the
requested posture, and on failure fall back to the posture the old
stack was actually running (tracked in the new `net_mode` local; when
the postures are equal the fallback is a plain retry — e.g. identity
regeneration, where reverting the already-persisted key would be
wrong). If the fallback lands, the UI is told the change didn't stick
and `network_mode` reverts so state stays honest and the change stays
re-attemptable. If both builds fail the UI gets a fatal 'Networking
lost … restart' error before the loop exits — informed, not a zombie.

Retry policy isolated in rebuild_with_fallback(), generic over the
builder: 4 new unit tests cover first-try success, fall-back, plain
retry, and double failure without binding sockets.

Fixes finding 2 of the 2026-07-16 full-codebase review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:58:58 -04:00
molluskandClaude Fable 5 af7a42a049 ci: remove zombie workflows from the per-push pipeline
cargo-deny.yml (runs-on: ubuntu-latest) and windows-build.yml (runs-on:
windows-latest) target runner labels no registered runner advertises, so
every push queued two runs Gitea auto-cancelled ~24h later — the Actions
page has shown 2 cancelled runs per push since the runner went live.

- cargo-deny.yml: deleted; redundant with ci.yml's deny step, which now
  runs `cargo deny --locked check` to preserve the locked-tree stance.
- windows-build.yml: kept but workflow_dispatch-only until a Windows
  runner exists; restore instructions in the header comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:56 -04:00
molluskandClaude Fable 5 e78e7bc2a5 supply-chain: ignore quick-xml build-time DoS advisories + ttf-parser unmaintained
RUSTSEC-2026-0194/0195 (quick-xml 0.39.4, published 2026-06-29) broke the
deny/audit CI gates on every push since June 29. quick-xml is reached only
via the wayland-scanner proc-macro parsing vendored protocol XML at compile
time — attacker input never touches it and it is absent from the shipped
binary. The fixed 0.41.0 is semver-incompatible with wayland-scanner's
`^0.39` req (no upstream bump yet); documented ignores until one exists.

RUSTSEC-2026-0192 (ttf-parser unmaintained, via iced/cosmic-text) joins the
existing unmaintained ignores (paste, audiopus_sys) — same class, same
lockfile-pinning protection.

New .cargo/audit.toml keeps cargo-audit in sync with deny.toml.

Known leftover warning (allowed, non-failing): spin 0.10.0 is yanked but
futures-buffered (via iroh) requires ^0.10 and no unyanked 0.10.x exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:46:13 -04:00
molluskandClaude Fable 5 52ab374b74 style: cargo fmt under rustfmt 1.9.0 (toolchain update 2026-07-08)
Six diffs across four files: the 2026-07-08 stable toolchain update
(rustc 1.96.1 / rustfmt 1.9.0) re-flags code that was fmt-clean when
committed under the previous rustfmt. No semantic change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 23:45:18 -04:00
mollusk 8825707c17 chore: patch crossbeam-epoch RustSec advisory
CI / check (push) Failing after 7s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-07-15 06:31:09 -04:00
molluskandClaude Fable 5 76c62e5ac3 docs: mark connection badge field-verified (2-machine call 2026-07-08)
CI / check (push) Failing after 5s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:07:54 -04:00
molluskandClaude Fable 5 d2432740c1 network: per-peer connection badge (direct/relay, RTT, loss, bitrate)
Answer "am I actually P2P right now?" per peer. A 1 Hz session task
snapshots the selected QUIC path of every live audio connection
(IrohTransport::connection_stats), core::connstats::derive turns
consecutive snapshots into RTT/loss/bitrate (path switches and counter
resets invalidate the rate window), and the peer card shows a
Direct/Relay badge with a hover tooltip for address, loss, and up/down
bitrate. No new dependencies, no wire change.

Loopback-integration-tested against real iroh endpoints; not yet
field-verified on a 2-machine call (FEATURES.md row marked 🧪).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 15:15:22 -04:00
molluskandClaude Opus 4.8 99a4a336ad Release 0.6.3 — in-app screen-sharing controls + hwdec fixes
CI / check (push) Failing after 4s
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Bump to 0.6.3 and document the screen-share work merged on this branch:
the advanced Settings section + per-call quality picker (96e41de), the
hardware-decode-defaults-off frame-1 freeze fix (96e41de), the per-call
quality override fix (e378b2e), and the VLC-honors-viewer-settings fix
(faad8ce). All local-only — no wire-protocol change, old configs load
unchanged.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 02:15:57 -04:00
molluskandClaude Opus 4.8 d0a16cb8b9 style: apply cargo fmt across the crate (A20)
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:59:49 -04:00
molluskandClaude Opus 4.8 0aaf6be529 feat(friends): live "scanned Nm ago" indicator after a manual rescan
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Adds a relative-time indicator in the Friends panel header showing how
long ago the last manual Rescan completed: "just now", "2m ago",
"1h 2m ago", "2d 2h ago". It advances on its own via a 30s
RescanLabelTick subscription (only armed once a rescan has happened), so
the label stays current without user interaction.

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

470 lib tests pass, clippy clean.

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

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

469 lib tests pass, clippy clean.

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

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

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

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

469 lib tests pass, clippy clean.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:07:23 -04:00
106 changed files with 23536 additions and 2191 deletions
+11
View File
@@ -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",
]
-34
View File
@@ -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
+44
View File
@@ -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
+15 -11
View File
@@ -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:
+116
View File
@@ -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`.
Generated
+322 -11
View File
@@ -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"
+49 -4
View File
@@ -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",
] }
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 mollusk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+115
View File
@@ -0,0 +1,115 @@
<h1>
<img src="assets/icons/peerspeak.svg" width="48" align="left" alt="PeerSpeak icon">
PeerSpeak
</h1>
Decentralized, peer-to-peer voice chat — full-mesh, NAT-traversing, with **no central server**. Built in Rust on [iroh](https://github.com/n0-computer/iroh) (QUIC), PipeWire audio, the Opus codec, and an [iced](https://github.com/iced-rs/iced) GUI.
Create a room, share the join ticket, and talk. Everyone connects directly to everyone else; relays are only used to punch through NATs when a direct path isn't available.
---
## Screenshots
| Launch screen | In a room |
|---|---|
| ![Launch screen](docs/screenshots/launch.jpg) | ![In a room](docs/screenshots/in-room.jpg) |
| Settings |
|---|
| ![Settings](docs/screenshots/settings.jpg) |
---
## Features
**Rooms & sessions**
- Create a room → shareable join ticket; join by pasting a ticket.
- Full-mesh multi-peer rooms with live presence.
- Recent-rooms list to hop back into a room someone's still in.
- Remembered nickname and in-call duration timer.
**Audio**
- PipeWire capture/playback, selectable input and output devices, per-app gain.
- Opus codec (48 kHz mono, 20 ms frames) with an adaptive jitter buffer + packet-loss concealment.
- Noise gate with a draggable threshold on a live mic meter (test your mic off-call too).
- Mix-bus soft limiter and opt-in echo cancellation (PipeWire WebRTC AEC + noise suppression).
**Voice controls**
- Self-mute, deafen, and rebindable push-to-talk.
- Per-peer volume, local mute, and speaking indicators.
**Text chat**
- In-room text chat over the gossip plane, with clickable links and inline image/audio attachments.
- Drag-selectable, copyable messages; right-click context menu on all text fields.
**Shared music listening**
- Build a personal playlist of local audio files with a full transport (play/pause, seek, reorder).
- Let others tune in: peers stream your current track, timeline-synced and gapless, sitting under voice at their own volume.
**Screen share** (via [pixelpass](https://gitbutter.xyz/mollusk/pixelpass))
- Share your screen; peers click 👁 Watch to open the stream in mpv (vlc fallback).
- Live badges on sharing peers; per-app audio capture.
**Recording & notifications**
- Local call recording (mic + incoming mix → WAV in `~/peerspeak-recordings/`).
- Desktop notifications and event chimes with per-event custom sound overrides.
**UI & networking**
- Selectable room layouts (3-Column, Bottom Dock, Drawer) with draggable, persisted dividers.
- 10 built-in themes (Catppuccin, Dracula, Nord, Tokyo Night, Gruvbox, Solarized…), all WCAG-AA checked.
- Network mode picker (relay-no-discovery default, full n0, or direct-only); retained-address reconnect.
- Config, window size/position, and all preferences persisted to `~/.config/peerspeak/`.
See [`docs/FEATURES.md`](docs/FEATURES.md) for the full inventory and field-test status, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for internals.
## Roadmap
- **Contacts & invites** — friends list with invite-notification one-click join (design in [`docs/contacts-plan.md`](docs/contacts-plan.md)).
- **Spatial audio & per-peer EQ.**
- **Soundboard** — play short clips into the call mix.
- **Room persistence / invite links** beyond the raw ticket.
- **Windows support** — cross-compiles and launches under Wine today; needs a real WASAPI audio pass (see [`docs/WINDOWS.md`](docs/WINDOWS.md)).
## Building
PeerSpeak builds with a stable Rust toolchain (edition 2024). Install the system dependencies below, then:
```sh
cargo build --release
./target/release/peerspeak
```
### System dependencies
**Arch Linux**
```sh
sudo pacman -S --needed rust pipewire opus pkgconf git
```
**Debian / Ubuntu**
```sh
sudo apt install build-essential pkg-config clang libclang-dev \
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
```
Plus a Rust toolchain via [rustup](https://rustup.rs/). `clang`/`libclang` are needed for the PipeWire bindings (bindgen).
At runtime you need a running **PipeWire** server. Screen sharing additionally requires `pixelpass` on your `PATH`, and `mpv` (or `vlc`) to watch a peer's share.
### Packaging
- **Arch:** `cd packaging && makepkg -si` (uses [`packaging/PKGBUILD`](packaging/PKGBUILD)).
- **Debian/Ubuntu:** `.deb` is built with [`cargo-deb`](https://github.com/kornelski/cargo-deb) from the `[package.metadata.deb]` block in `Cargo.toml`. Build inside a Debian/Ubuntu environment so the binary links that distro's glibc.
- **Windows:** see [`docs/WINDOWS.md`](docs/WINDOWS.md).
## License
PeerSpeak is licensed under the [MIT License](LICENSE), © 2026 mollusk.
Third-party components bundled with PeerSpeak (the Rust dependency tree, the
statically bundled Opus codec on some builds, and embedded fonts) are all under
permissive licenses; their texts and a full dependency manifest are collected in
[`THIRD_PARTY_LICENSES`](THIRD_PARTY_LICENSES).
+1824
View File
File diff suppressed because it is too large Load Diff
-80
View File
@@ -1,80 +0,0 @@
# Example entry in an antigravity.toml configuration file
[agent]
model = "gemini-3.5-flash"
system_instruction = """
You are a senior-level, terminal-native Rust systems engineer and an expert programming assistant. Your goal is to help me design, build, and refactor a decentralized, peer-to-peer (P2P) voice communication application modeled after Mumble, utilizing the Iroh network stack for NAT holepunching and QUIC stream orchestration.
### 0. How You Work — Operating Principles (read first)
Capability is not the constraint here; judgment is. These govern HOW you approach every task in this repo, and the project-specific sections below make them concrete.
- **Understand before you act.** Read the actual code and the local docs (Sections 1, 8) before changing anything — never reason from memory about an API, type, or signature; open it and confirm. This is Rule 1 made operational. Orient in the codebase (Section 5) and honor its trait boundaries and idioms — you are editing a mature codebase, not starting fresh, so match its naming, error-handling, and comment density.
- **Measure before you theorize — the single most important habit.** When debugging, get EVIDENCE before asserting a cause: instrument it, log it, reproduce it, read the real output. A plausible-sounding mechanism is a hypothesis, not a diagnosis. If the data contradicts your theory, drop the theory — do not bend the evidence to fit it. (The playback-crackle bug was only solved once the actual per-cycle PipeWire quantum was measured; every "reasoned" guess before that missed.)
- **Root-cause, don't patch symptoms.** Trace a bug to the exact mechanism that produces it; a fix you cannot explain is a coincidence waiting to break. Make the smallest change that addresses the real cause — don't expand scope or refactor unasked. Flag adjacent problems; don't silently fold them in.
- **"Compiles" and "tests pass" are NOT "it works."** These are three separate claims — builds-clean, tests-green, and field-verified-by-running-it — and you must state which you have actually reached (this reinforces Section 7). For this app, "verified" means a real run/call was observed behaving correctly (clean audio heard by ear, a reconnect watched in the logs), not that the suite passed. Never announce a fix as working on tests alone; explicitly label untested or tests-only work as "unverified."
- **Surface the forks on real decisions.** When a task has genuine tradeoffs (architecture, a new dependency, an irreversible change), lay out the realistic options with their costs and let me choose BEFORE you build. For a choice with an obvious default and no downside, just pick it, say what you picked, and proceed — don't manufacture decisions.
- **Report honestly.** If it failed, say so and show the evidence. If you assumed or skipped something, say that. When something is genuinely done and verified, say so plainly without hedging. If new evidence contradicts something you stated confidently, correct yourself explicitly. "I verified X" and "I believe X" are different claims — use the right one. Never fabricate APIs, file paths, or results; if unsure, say "I'm not sure" and go confirm (Rule 1).
- **Treat dependencies as a liability.** Prefer the standard library, tools already on the system, or a few lines of your own over pulling in a crate — I vet dependencies for supply-chain risk. Justify any addition, and default to safe Rust (Rule 3).
- **Know when NOT to do what I ask.** Doing the task is the default, but stop and confirm or push back when: the action is hard to reverse or outward-facing — pushing, publishing, deploying the binary to the other machine, deleting/overwriting files you did not create (confirm first; for git commits specifically, see Rule 4); the request rests on a false premise or contradicts what you find in the code (surface that instead of plowing ahead); compliance would introduce real risk — data loss, a security/privacy regression (e.g. changing the `RelayNoDiscovery` default, see Section 6), an `unsafe` block, or a heavy dependency (name the risk and offer a safer path); or the scope is ambiguous (confirm rather than over-building — build X, not X plus extras). Don't merely comply and don't merely refuse — offer the better route.
- **Work in checkpoints; keep state durable.** Give a short plan and a rough scope/effort estimate up front so I can redirect or defer (I watch a daily usage budget). Phase large work so it can pause cleanly. The handoff log (Section 8) is the durable record across sessions — read it first, update it when you finish meaningful work.
- **Follow the collaboration protocol (read first, every task).** Before starting any task, read `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/operating-agreement.md`. It defines how we work as a team: a senior engineer designs and reviews the work, tasks are assigned to you in `next-task.md`, and you report back in `task-report.md`. It is in force this session and every session until that file says otherwise.
### 1. Context and Knowledge Base
You have immediate, local access to the definitive Rust documentation suite located at the absolute path: `/home/mollusk/Documents/rust_docs/`.
Before answering highly complex questions, writing macros, or optimizing code, you must reference these specific resources:
- Syntax, language invariants, and semantics: `/home/mollusk/Documents/rust_docs/rust-reference/`
- Idiomatic structural choices, patterns, and logic: `/home/mollusk/Documents/rust_docs/the-book/`
- Pointer manipulation, data layout, and undefined behavior: `/home/mollusk/Documents/rust_docs/rust-nomicon/`
- API design, trait implementations, and naming conventions: `/home/mollusk/Documents/rust_docs/rust-api-guidelines/`
### 2. Specialized Architectural Constraints
- **P2P Audio Boundary Isolation:** We are utilizing a decoupled architecture. The asynchronous network runtime (Tokio + Iroh) must be kept strictly separated from the real-time audio thread pool (PipeWire). Communication between the Iroh network consumers and the PipeWire audio streams must happen exclusively via bounded, lock-free SPSC (Single-Producer Single-Consumer) or MPSC ring buffers.
- **The "No-Alloc" Audio Rule:** Code generated for the audio processing callback or multi-stream mixer must be strictly safe and real-time safe. It must contain zero heap allocations, zero blocking synchronization primitives (no standard Mutex/RwLock), and zero blocking file/network I/O.
- **Iroh Topology:** We handle voice channels by treating every peer node as a full-mesh target. Leverage Iroh's unreliable QUIC Datagrams for raw, low-latency audio packet delivery and Iroh-Gossip (or bi-directional streams) for state synchronization (room mapping, mute states, and peer metadata).
### 3. Behavioral Boundaries and Accuracy
- **Rule 1 (Absolute Ground Truth):** Never guess or hallucinate syntax rules, compiler behavior, or API surfaces. If you are not 100% sure about a specific language feature, macro expansion, standard library behavior, or dependency change, stop and explicitly state: "I'm actually not sure about that."
- **Rule 2 (No "C in Rust"):** Do not write C-style logic wrapped in Rust syntax. Prioritize idiomatic Rust patterns (e.g., using algebraic data types, proper trait bounds, combinators like `.map()` or `.and_then()`, and precise error handling with `Result` and `Option`).
- **Rule 3 (Safe by Default):** Always default to safe, idiomatic Rust code. Do not introduce an `unsafe` block unless it is explicitly requested, or unless you can rigorously prove using *The Rustonomicon* constraints that safe Rust cannot achieve the required performance boundary.
- **Rule 4 (Git Commit Policy):** When a feature is completed, you must always ask the user for permission before committing files to git. Never commit files automatically.
### 4. Output Requirements
- **Contextual Clarity:** When providing a solution that relies on advanced language mechanics (like complex lifetimes, custom traits, or macro rules), briefly cite which local resource or module layout you used to verify the approach.
- **Code Generation:** Provide clean, production-ready code with minimal boilerplate. Use standard formatting rules (`rustfmt` styles). Include brief, high-value comments for complex borrowing logic or lifetime annotations.
- **Error Resolution:** If asked to fix a compiler or borrow-checker error, explain *why* the error occurred in terms of Rust's core memory model (ownership/borrowing/lifetimes) before providing the refactored code.
### 5. Project Map — Where Things Live
This is a mature codebase, not a greenfield project. Orient yourself in it before editing. The architecture is trait-based so implementations stay swappable; honor the boundaries.
- `src/network/mod.rs` — the `NetworkTransport` and `RoomState` traits + shared types (`PeerState`, `RoomEvent`, `ConnEvent`, `PeerSpeakTicket`). Start here to understand the seams.
- `src/network/iroh_impl.rs` — the audio transport. Per-peer **supervisor** tasks own each connection's whole lifecycle; QUIC datagrams carry audio. This is the most subtle file see Section 6.
- `src/network/gossip.rs` `iroh-gossip` room state: presence roster, mute/metadata sync, join/leave, address announcements feeding the `MemoryLookup`.
- `src/core/mod.rs` the coordinator: wires captureencodebroadcast and receivejitterdecodemixplayback, and bridges room/transport events to the UI. Runs on its own Tokio runtime thread.
- `src/core/jitter.rs` per-peer jitter buffer (reorder + fixed playout delay + Opus PLC on loss). Unit-tested.
- `src/audio/{pipewire_impl.rs,pw_cli.rs}` PipeWire capture/playback in the real-time path; device enumeration via `pw-cli`.
- `src/codec/opus_impl.rs` Opus encode/decode behind the `AudioCodec` trait.
- `src/app/mod.rs` the `iced` GUI (Catppuccin-styled). `src/config.rs` persisted settings (`~/.config/peerspeak/config.json`).
- `tests/transport_loopback.rs` end-to-end transport tests over real localhost iroh endpoints. `src/bin/test_net.rs` a manual two-node harness.
### 6. Audio-Networking Invariants (hard-won — each of these maps to a real bug that was fixed)
Treat these as load-bearing. They are non-obvious and were violated in earlier iterations.
- **One shared connection per peer pair, deterministic initiator.** The lower `EndpointId` (string comparison) **dials**; the higher **accepts**. Both sides call `connect_peer`; the rule dedups so exactly one bidirectional QUIC connection forms per pair. Never open a second per-direction connection, and never spawn a connection (or a task) per audio frame use the long-lived per-peer send path.
- **The per-peer supervisor owns connect run reconnect.** All of a peer's connection lifecycle lives in one `supervise` task (`iroh_impl.rs`). Don't scatter dialing/reconnect logic across call sites; reconnection must re-apply the same deterministic-initiator rule so the single shared connection re-forms.
- **Any detached task holding a `Connection` clone MUST be abort-on-drop.** A live `Connection` clone keeps the QUIC link open. If send/read loops aren't torn down on peer-removal/reconnect, the link never actually closes and the peer only notices at the ~30s idle timeout. Scope them in `AbortOnDrop` guards tied to the live-link block.
- **A silent handle-drop is NOT a close.** Dropping all `Connection` handles does not promptly notify the peer — they find out only at the QUIC idle timeout (~30s). Only `Connection::close()` sends an immediate `CONNECTION_CLOSE`. This matters for both teardown and for writing tests that need a prompt drop.
- **Retain each peer's full `EndpointAddr` and dial it directly; do not lean on `MemoryLookup` alone.** Dialing by bare `EndpointId` forces iroh to resolve via the gossip-fed `MemoryLookup`. A transient drop that fires a gossip `Leave`/`NeighborDown` purges that entry, and the dialer then redial-loops forever with "no address." The transport keeps each peer's full address (relay + direct addrs) for the supervisor's lifetime, refreshed on every re-announce, and dials it directly. (This was the 2026-05-31 fix.)
- **Presence layer transport layer.** The gossip roster (who's in the room) is independent of a peer's audio-link state. A peer can be present with its audio link down/reconnecting. Keep the two UI signals distinct (`RoomEvent` vs `ConnEvent`); don't infer one from the other.
- **Every audio datagram carries a 4-byte little-endian sequence header.** The receiver feeds `(seq, payload)` into the per-peer `JitterBuffer`, which reorders, holds a fixed playout delay, and invokes Opus PLC (`decode(None)`) on gaps. Never decode datagrams directly in arrival order, and size PLC to one 20ms frame.
- **`broadcast()` must never block the capture/encode thread.** It is called from the non-async audio path. Use `try_send` into shallow per-peer queues and **drop on full** — stale audio is worthless and a slow peer must never stall encoding. No `await`, no large/unbounded queues here.
- **Real-time audio path (reaffirming Section 2):** zero heap allocation, zero blocking locks (no `Mutex`/`RwLock`), zero I/O inside the PipeWire callback/mixer. Cross the async↔RT boundary only through bounded lock-free ring buffers.
- **Throttle high-rate UI events.** Don't forward per-20ms-tick events (e.g. `AudioLevels`, ~50/sec) straight to the GUI; coalesce with peak-hold to ~10/sec.
- **Privacy posture is intentional.** Default `NetworkMode` is `RelayNoDiscovery`: keep the n0 relay (NAT traversal + re-reachability anchor) but emit **no** DNS presence beacon. Do not change the default to anything that publishes presence. Decision on record: we are **not** self-hosting a relay.
### 7. Testing & Field-Verification Gotchas
- **The loopback/integration tests use stable, fixed addresses**, so they silently miss address-eviction and new-address bugs. When testing reconnect resilience, **starve every address source** (empty the `MemoryLookup` *and* disable the relay) to force the retained-address path. Merely calling `remove_endpoint_info` is a **false** test: iroh internally caches the path from a recent live connection, so the reconnect still succeeds even with the bug present.
- **Two instances on one host are INVALID for outage/disconnect tests.** Docker bridges (`172.x`) plus loopback keep them talking even with the main NIC down. Use two real machines, or two network namespaces joined by a single `veth` you can `ip link set ... down`.
- **To drive a prompt link drop in a test, explicitly `close()` the connection** a silent drop waits out the ~30s idle timeout (see Section 6).
- **Before declaring anything done:** `cargo clippy --all-targets` must be clean (zero warnings) and `cargo test` must pass. Distinguish "tests-green" from "field-verified" say which one you actually have.
### 8. Offline Docs for the Network/Audio Stack
In addition to the general Rust docs in Section 1, the **API docs for this project's dependencies** (iroh, iroh-gossip, tokio, opus, pipewire, iced, …) are generated locally at `/home/mollusk/Documents/peerspeak_docs/`. Grep/read these instead of probing the web — e.g. confirm `Endpoint::connect`'s signature or `MemoryLookup`'s methods there. The design blueprint is at `/home/mollusk/Documents/P2P_Voice_Chat_Blueprint.md`. A **living handoff log** is maintained at `/home/mollusk/Documents/handoff-docs/Gemini/peerspeak/handoff.md` — **read it first each session** for current state, recent commits, and known/open bugs, and append a dated entry when you finish meaningful work.
Acknowledge these operational parameters, then orient yourself in the existing codebase (Section 5) and the handoff log (Section 8) before proposing or making changes. Summarize the current project state back to me and ask what we're tackling this session.
"""
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 B

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

+56 -31
View File
@@ -1,41 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?> <?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

+22
View File
@@ -0,0 +1,22 @@
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
if let Ok(head) = std::fs::read_to_string(".git/HEAD")
&& let Some(reference) = head.strip_prefix("ref: ")
{
println!("cargo:rerun-if-changed=.git/{}", reference.trim());
}
let short = Command::new("git")
.args(["rev-parse", "--short=8", "HEAD"])
.output()
.ok()
.filter(|output| output.status.success())
.and_then(|output| String::from_utf8(output.stdout).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=PEERSPEAK_GIT_SHORT={short}");
}
+16 -3
View File
@@ -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
+1
View File
@@ -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. |
+65 -39
View File
@@ -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.
+584
View File
@@ -0,0 +1,584 @@
# Chat hardening — ephemeral implementation plan
**Status (2026-07-18):** Phases 15 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+202A202E, U+20662069) — 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

+13 -3
View File
@@ -1,12 +1,12 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com> # 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"
} }
+4
View File
@@ -0,0 +1,4 @@
.tools/
AppDir/
*.AppImage
squashfs-root/
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# AppRun for the PeerSpeak AppImage.
#
# PeerSpeak bundles the pixelpass screen-share helper in usr/bin. We prepend our
# own usr/bin to PATH so peerspeak's $PATH lookup for `pixelpass` finds the
# bundled copy, while the host's tools (gst-launch-1.0, pactl, mpv — which
# pixelpass in turn shells out to) remain reachable via the appended host PATH.
# That no-sandbox spawning is exactly why this app suits AppImage over Flatpak.
HERE="$(dirname "$(readlink -f "$0")")"
export PATH="$HERE/usr/bin:$PATH"
exec "$HERE/usr/bin/peerspeak" "$@"
+76
View File
@@ -0,0 +1,76 @@
# PeerSpeak AppImage
A "thin" AppImage: the `peerspeak` binary, the bundled `pixelpass` screen-share
helper, a launcher (`AppRun`), and the desktop entry + icon. Run
`./build-appimage.sh` to produce `peerspeak-<version>-x86_64.AppImage`.
## Why thin, and why pixelpass is bundled
PeerSpeak owns voice; **pixelpass** owns pixels. They are never Cargo
dependencies of each other — peerspeak shells out to the `pixelpass` binary over
its CLI. The AppImage co-locates `pixelpass` in `usr/bin`, and `AppRun` prepends
`usr/bin` to `PATH`, so peerspeak's normal `$PATH` lookup finds it with no code
change. Joe gets one file, and screen-share works out of the box.
Almost nothing is bundled: peerspeak's own assets (notification WAVs, avatar
presets, window icon, fonts) are `include_bytes!`-embedded, and the graphics
stack (`libGL`, `libvulkan`, `libwayland-*`, `libxkbcommon`, X11) is dlopen'd at
runtime and on the AppImage excludelist because it must match the host driver.
So the image carries just the two binaries plus a handful of small libs.
## Host requirements
The AppImage runs on any reasonably current glibc-based distro that has:
- **A Vulkan-capable GPU + driver** (peerspeak's iced/wgpu renderer). Mesa/RADV
on AMD/Intel or the NVIDIA driver all work.
- **PipeWire** (with the PulseAudio shim, for `pactl`).
- For **screen-share only** — pixelpass shells out to these on the host `PATH`;
it prints the exact package names for your distro if any are missing:
- **GStreamer + plugins** (`gst-launch-1.0`/`gst-inspect-1.0`, base,
good/bad/ugly, libav, and the PipeWire plugin),
- **mpv** (or vlc) for the viewer side,
- on X11, `xwininfo` for single-window capture.
On Arch/Artix that is one pacman line, e.g.:
```sh
sudo pacman -S gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad \
gst-plugins-ugly gst-libav gst-plugin-pipewire mpv xorg-xwininfo libpulse
```
(Add `gstreamer-vaapi` for hardware H.264 encode on AMD/Intel; the software
x264 path always works. On XLibre / X11 the capture path uses `ximagesrc` and
needs no XDG portal — no systemd required.)
## Building for broad compatibility (glibc baseline)
An AppImage requires a host glibc **at least as new** as the build host's. Built
on a rolling distro (glibc 2.43) it only runs on equally-new systems. Build
inside **Ubuntu 24.04** (glibc 2.39, PipeWire 1.0.5) for wide reach — pixelpass's
`pipewire` crate binds the system PipeWire headers and needs PipeWire >= 1.0, so
the older Debian 12 `peerspeak-bookworm` box (PW 0.3.65) cannot build it. 2.39
covers Debian 13+, Fedora 40+, and current rolling distros.
```sh
# One-time: an Ubuntu 24.04 distrobox that reuses the host rustup toolchain.
distrobox create --yes --image ubuntu:24.04 --name peerspeak-appimage
distrobox enter peerspeak-appimage -- sudo apt-get update
distrobox enter peerspeak-appimage -- sudo apt-get install -y \
build-essential cmake clang libclang-dev pkg-config \
libpipewire-0.3-dev libspa-0.2-dev libasound2-dev libxcb1-dev \
curl ca-certificates file patchelf git
# Build (the host's ~/.rustup toolchain is glibc-2.17-baseline, so it runs in the
# box; isolated CARGO_TARGET_DIRs keep it off the host target/):
distrobox enter peerspeak-appimage -- env \
PATH="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH" \
./packaging/appimage/build-appimage.sh
```
## Caveats
- **Hardware encode (VAAPI)** uses the host GPU driver and can't be bundled; the
software x264 path always works.
- The bundled `pixelpass` is built headless (no `gui` feature) — it is only ever
driven by peerspeak, never launched standalone from this image.
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Build a "thin" PeerSpeak AppImage that also bundles the pixelpass screen-share
# helper.
#
# PeerSpeak is an iced/wgpu GUI app; pixelpass is the separate screen-share
# orchestrator peerspeak shells out to (never a Cargo dependency). Both link
# almost nothing — the graphics stack (libGL, libvulkan, wayland, xkbcommon,
# X11) is dlopen'd at runtime and is on the AppImage excludelist because it must
# match the host driver, and pixelpass's capture/encode tools (gst-launch-1.0,
# pactl, mpv) are expected on the host PATH. So the AppImage carries just the two
# binaries plus their handful of non-excludelisted libs. The custom AppRun
# prepends usr/bin to PATH so peerspeak's own $PATH lookup finds the bundled
# pixelpass, while the host's tools stay reachable.
#
# All runtime assets (notification WAVs, avatar presets, window icon, fonts) are
# include_bytes!-embedded in the peerspeak binary, so nothing else is bundled.
#
# Usage: packaging/appimage/build-appimage.sh
# Output: packaging/appimage/peerspeak-<version>-x86_64.AppImage
#
# Build inside an Ubuntu 24.04 distrobox (glibc 2.39, PipeWire 1.0.5) for broad
# reach — pixelpass's `pipewire` crate needs PipeWire >= 1.0 headers, so the
# older peerspeak-bookworm box (PW 0.3.65) cannot build it. The 2.39 baseline
# covers Debian 13+, Fedora 40+, and all current rolling distros. See README.md.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/../.." && pwd)"
tools="$here/.tools"
appdir="$here/AppDir"
mkdir -p "$tools"
# linuxdeploy is itself an AppImage; run it without FUSE so this works in a
# container / on CI without libfuse2.
export APPIMAGE_EXTRACT_AND_RUN=1
VERSION="$(grep -m1 '^version' "$repo/Cargo.toml" | sed -E 's/.*"(.*)".*/\1/')"
export VERSION
# Isolated target dirs so an old-glibc box build never clobbers the host target/.
cache="${PEERSPEAK_APPIMAGE_CACHE:-$HOME/.cache/peerspeak-appimage}"
ps_target="$cache/peerspeak-target"
pp_target="$cache/pixelpass-target"
# The pixelpass screen-share helper we bundle. Sibling checkout by default.
pixelpass_repo="${PIXELPASS_REPO:-$repo/../pixelpass}"
if [ ! -d "$pixelpass_repo" ]; then
echo "!! pixelpass repo not found at $pixelpass_repo (set PIXELPASS_REPO)" >&2
exit 1
fi
echo ">> building peerspeak (release)"
( cd "$repo" && CARGO_TARGET_DIR="$ps_target" cargo build --release )
ps_bin="$ps_target/release/peerspeak"
# Headless pixelpass: peerspeak drives it via `--host`/viewer + `--output json`,
# never its GUI, so the default (no `gui` feature) keeps the GL toolkit out.
echo ">> building pixelpass (release, headless) from $pixelpass_repo"
( cd "$pixelpass_repo" && CARGO_TARGET_DIR="$pp_target" cargo build --release )
pp_bin="$pp_target/release/pixelpass"
echo ">> fetching linuxdeploy"
ld="$tools/linuxdeploy-x86_64.AppImage"
if [ ! -x "$ld" ]; then
curl -fL --retry 3 -o "$ld" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
chmod +x "$ld"
fi
echo ">> assembling AppDir"
rm -rf "$appdir"
mkdir -p "$appdir/usr/bin"
install -m755 "$ps_bin" "$appdir/usr/bin/peerspeak"
install -m755 "$pp_bin" "$appdir/usr/bin/pixelpass"
echo ">> running linuxdeploy (bundles libs, builds the AppImage)"
# -e (repeated): analyse both binaries for libraries to bundle; excludelisted
# graphics/glibc libs are skipped. -d/-i: desktop entry + icon.
# --custom-apprun: our launcher that puts the bundled pixelpass on PATH.
( cd "$here" && OUTPUT="peerspeak-${VERSION}-x86_64.AppImage" "$ld" \
--appdir "$appdir" \
-e "$appdir/usr/bin/peerspeak" \
-e "$appdir/usr/bin/pixelpass" \
-d "$repo/packaging/peerspeak.desktop" \
-i "$repo/assets/icons/peerspeak-256.png" \
--icon-filename peerspeak \
--custom-apprun "$here/AppRun" \
--output appimage )
echo ">> done: $here/peerspeak-${VERSION}-x86_64.AppImage"
+87
View File
@@ -0,0 +1,87 @@
# Debian / Ubuntu `.deb` build
This documents how the `peerspeak_*.deb` is produced, so the deb path is as
self-documenting as the Arch (`packaging/PKGBUILD`) and AppImage paths.
The deb **recipe itself** lives in-repo as the `[package.metadata.deb]` block in
the top-level `Cargo.toml` (cargo-deb's equivalent of a PKGBUILD). This file
documents only the **build environment**, which is otherwise undiscoverable from
a fresh clone.
## TL;DR
```sh
# one-time: create + provision the build box (see "Build environment" below)
distrobox enter peerspeak-bookworm -- bash -lc '
source ~/.cargo/env
cd ~/git/butter/peerspeak
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak # MANDATORY, see below
cargo deb
'
# output: $CARGO_TARGET_DIR/debian/peerspeak_<version>-1_amd64.deb
```
## Build environment
- **Base: a Debian 12 (bookworm) distrobox named `peerspeak-bookworm`.**
Created with `distrobox create --name peerspeak-bookworm --image debian:12`.
Bookworm ships **glibc 2.36**, which sets the widest practical compatibility
floor (see "glibc floor" below).
- **NEVER build the `.deb` on the Arch host.** Two independent reasons:
1. The Arch host's glibc is far newer, so the resulting `.deb` would demand a
glibc no normal Debian/Ubuntu user has, and ships an empty `Depends`.
2. distrobox shares `$HOME` (and therefore the repo's `target/`) with the host,
so a host build links Arch-compiled C objects into the "Debian" binary.
### One-time provisioning inside the box
```sh
distrobox enter peerspeak-bookworm
sudo apt update
sudo apt install -y build-essential pkg-config clang libclang-dev \
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
# clang/libclang -> pipewire-sys bindgen ; libxcb1-dev -> link
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
cargo install cargo-deb
```
### The mandatory separate `CARGO_TARGET_DIR`
Because distrobox shares `$HOME`, the repo's default `target/` is the **same
directory** the Arch host builds into. If you run `cargo deb` without overriding
the target dir, cargo will happily reuse Arch-built `.o`/rlib artifacts and link
them into the Debian binary, producing a `.deb` that crashes or demands the
host's glibc.
Always point the build at a box-local cache:
```sh
export CARGO_TARGET_DIR=~/.cache/cargo-deb-targets/peerspeak
```
(Run `cargo clean` first if you ever suspect a polluted target dir.)
## glibc floor
The `.deb` is built against the build box's glibc, which becomes the install
floor (`libc6 (>= 2.36)` lands in `Depends` via `$auto`):
| Build box | glibc | Runs on |
|----------------------|-------|--------------------------------------|
| `debian:12` (current)| 2.36 | Debian 12+, Ubuntu 24.04+ (glibc ≥ 2.36) |
| `ubuntu:26.04` (old) | 2.43 | Ubuntu 26.04+ only — too narrow, abandoned |
If a friend is on something even older than Debian 12, drop the floor further by
recreating the box from an older base image and rebuilding.
## Runtime `Depends` / `Recommends`
- `Depends = "$auto"` — cargo-deb runs `dpkg-shlibdeps`, which discovers the
linked shared libraries (PipeWire, Opus, ALSA, xcb, glibc, …) automatically.
- `Recommends = "pixelpass, mpv"``pixelpass` provides in-room screen sharing
and `mpv` is the screen-share viewer (these are companion programs invoked as
subprocesses, not linked libraries, so they are Recommends not Depends).
See `pixelpass`'s own `packaging/debian/README.md` for why **its** `Depends`
lists the whole GStreamer stack explicitly.
-112
View File
@@ -1,112 +0,0 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
#
# Test-pack split package: ONE `makepkg -si` builds + installs BOTH peerspeak
# (voice chat) and pixelpass (screen sharing) from the public gitbutter repos
# over https. pixelpass lands on /usr/bin so peerspeak's screen-share button
# finds it. Shared version string is derived from peerspeak's git.
#
# Clone this repo and build from here:
# git clone https://gitbutter.xyz/mollusk/peerspeak.git
# cd peerspeak/packaging/test-pack
# makepkg -si
pkgbase=peerspeak-git
pkgname=('peerspeak-git' 'pixelpass')
pkgver=0.1.0
pkgrel=1
arch=('x86_64')
url="https://gitbutter.xyz/mollusk/peerspeak"
license=('custom' 'MIT' 'Apache-2.0' 'OFL-1.1')
makedepends=('git' 'cargo' 'pkgconf')
options=('!lto' '!debug')
source=("peerspeak::git+https://gitbutter.xyz/mollusk/peerspeak.git"
"pixelpass::git+https://gitbutter.xyz/mollusk/pixelpass.git#branch=main")
sha256sums=('SKIP'
'SKIP')
pkgver() {
cd "$srcdir/peerspeak"
# Shared across both split packages. 0.1.0.r<commits>.g<short-sha>.
printf '%s.r%s.g%s' \
"$(awk -F'\"' '/^version =/{print $2; exit}' Cargo.toml)" \
"$(git rev-list --count HEAD)" \
"$(git rev-parse --short HEAD)"
}
prepare() {
# Vendor deps up front so build() can run --frozen (no surprise network).
export CARGO_HOME="$srcdir/cargo-home"
local host; host="$(rustc -vV | sed -n 's/host: //p')"
cd "$srcdir/peerspeak"; cargo fetch --locked --target "$host"
cd "$srcdir/pixelpass"; cargo fetch --locked --target "$host"
}
build() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
export CARGO_TARGET_DIR=target
cd "$srcdir/peerspeak"
cargo build --frozen --release --bin peerspeak
cd "$srcdir/pixelpass"
# --features gui so the .desktop launcher (pixelpass --gui) works.
cargo build --frozen --release --features gui
}
check() {
export CARGO_HOME="$srcdir/cargo-home"
export RUSTUP_TOOLCHAIN=stable
# peerspeak library unit tests only — its integration suites bind real
# iroh/QUIC endpoints and fail in a sandboxed/offline build environment.
cd "$srcdir/peerspeak"
cargo test --frozen --release --lib
}
package_peerspeak-git() {
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
depends=('pipewire' 'opus')
optdepends=('pixelpass: screen sharing inside a room'
'mpv: screen-share viewer (vlc is used as a fallback)')
provides=('peerspeak')
conflicts=('peerspeak')
license=('custom')
cd "$srcdir/peerspeak"
install -Dm755 "target/release/peerspeak" "$pkgdir/usr/bin/peerspeak"
install -Dm644 "packaging/peerspeak.desktop" \
"$pkgdir/usr/share/applications/peerspeak.desktop"
# Hicolor icon theme (scalable SVG + the rendered raster sizes).
install -Dm644 "assets/icons/peerspeak.svg" \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/peerspeak.svg"
local s
for s in 16 24 32 48 64 128 256 512; do
install -Dm644 "assets/icons/peerspeak-$s.png" \
"$pkgdir/usr/share/icons/hicolor/${s}x${s}/apps/peerspeak.png"
done
}
package_pixelpass() {
pkgdesc='P2P screen sharing over iroh — no port forwarding, no signup'
depends=('gstreamer' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad'
'gst-libav' 'gst-plugin-va' 'libpulse' 'hicolor-icon-theme'
'libglvnd' 'libxkbcommon' 'wayland')
optdepends=('mpv: recommended stream viewer (the GUI launches mpv)'
'vlc: alternative stream viewer'
'gst-plugins-ugly: software x264 encoding for `pixelpass --no-hwencode`'
'gst-plugin-pipewire: screen capture on Wayland sessions'
'xorg-xwininfo: share a single window on X11 (`pixelpass --window`)')
license=('MIT' 'Apache-2.0' 'OFL-1.1')
cd "$srcdir/pixelpass"
install -Dm0755 "target/release/pixelpass" "$pkgdir/usr/bin/pixelpass"
install -Dm0644 assets/pixelpass.desktop \
"$pkgdir/usr/share/applications/pixelpass.desktop"
install -Dm0644 assets/pixelpass.svg \
"$pkgdir/usr/share/icons/hicolor/scalable/apps/pixelpass.svg"
install -Dm0644 README.md "$pkgdir/usr/share/doc/pixelpass/README.md"
install -Dm0644 LICENSE-MIT "$pkgdir/usr/share/licenses/pixelpass/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$pkgdir/usr/share/licenses/pixelpass/LICENSE-APACHE"
install -Dm0644 assets/NotoSans-OFL.txt \
"$pkgdir/usr/share/licenses/pixelpass/NotoSans-OFL.txt"
}
-52
View File
@@ -1,52 +0,0 @@
# PeerSpeak + PixelPass — CachyOS/Arch test pack
A single **split PKGBUILD** that builds the latest code from the public gitbutter
repos and installs **both** programs at once:
- `peerspeak` — decentralized P2P voice chat
- `pixelpass` — P2P screen sharing (peerspeak launches it for the screen-share button)
## Build & install (one command)
```sh
git clone https://gitbutter.xyz/mollusk/peerspeak.git
cd peerspeak/packaging/test-pack
makepkg -si
```
`makepkg -si` auto-installs every dependency via pacman before building —
including the Rust toolchain itself (the `cargo` makedepend is provided by the
`rust` package), `git`, `pkgconf`, pipewire + opus for peerspeak, and the
gstreamer/VA-API stack for pixelpass. The only prerequisite is the `base-devel`
group (which provides `makepkg`). If you already use `rustup`, that satisfies the
`cargo` makedepend and the `rust` package won't be pulled in — no conflict.
When it finishes you'll have `peerspeak` and `pixelpass` on your PATH at
`/usr/bin`. To rebuild later with fresh upstream code, re-run `makepkg -si`; the
git sources re-pull `main` and the version bumps automatically.
> Skip the test step with `makepkg -si --nocheck` for a faster build.
## Running the cross-internet test
1. Launch `peerspeak` on both machines.
2. One person **creates** a room and shares the room code/ticket with the other.
3. The other **joins** with that code.
4. iroh does NAT hole-punching automatically; if a direct path can't be made it
falls back to a public n0 relay — **no port forwarding required**.
5. Allow the app through any local firewall if prompted (outbound UDP / QUIC;
nothing needs to be opened inbound for relay mode).
### What we're smoke-testing
- Two real humans, two networks, over the internet.
- Mic capture + remote playback both directions, no crackle/dropouts.
- Mute / deafen, push-to-talk.
- Text chat in-room.
- Avatars (presets + custom upload) show up on the other side.
- Screen share: click the screen-share control → it launches `pixelpass`; the
viewer opens in `mpv` on the receiving side.
- Notification chimes (join/leave/etc.).
- Leave / rejoin cleanly.
If anything misbehaves, grab the log path peerspeak prints on startup and the
exact repro steps.
+1 -1
View File
@@ -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
+3 -2
View File
@@ -11,8 +11,9 @@ runtime, so there are no extra DLLs to bundle. The installer payload is just the
## Version compatibility ## 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 364 KiB

+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed). ; (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"
+5906 -511
View File
File diff suppressed because it is too large Load Diff
+129
View File
@@ -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));
}
}
}
+331
View File
@@ -0,0 +1,331 @@
//! Independent playback engine for inline chat audio attachments.
//!
//! The rodio device sink stays on a dedicated OS thread and never enters iced
//! state or the call-audio pipeline. The GUI sends small commands and reads a
//! shared status snapshot at its redraw cadence.
use crate::files::AttachmentId;
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
use std::io::Cursor;
use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration;
/// State published by the playback thread for the GUI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClipStatus {
pub playing_id: Option<AttachmentId>,
pub position: Duration,
pub total: Option<Duration>,
pub paused: bool,
/// Set when output initialization or decoding rejects the requested clip.
/// The app consumes this as a signal to fall back to the normal file chip.
pub failure: Option<ClipFailure>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipFailure {
pub id: AttachmentId,
pub error: String,
/// Decoder rejection means the filename hint should fall back to a file
/// chip. Output-device failures remain retryable as audio.
pub invalid_audio: bool,
}
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
#[derive(Debug)]
enum ClipCommand {
Play(AttachmentId, Vec<u8>),
Pause,
Resume,
Seek(Duration),
Stop,
SetVolume(f32),
}
/// Cheap, `Send` command handle for the dedicated playback thread.
pub struct ClipPlayer {
command_tx: mpsc::Sender<ClipCommand>,
status: SharedClipStatus,
}
impl ClipPlayer {
/// Start the playback worker. The system output device is opened lazily on
/// first Play, so merely launching PeerSpeak never claims another stream.
///
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
/// clip, restored from config so the level persists across sessions.
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
let (command_tx, command_rx) = mpsc::channel();
let status = Arc::new(Mutex::new(ClipStatus::default()));
let worker_status = Arc::clone(&status);
std::thread::Builder::new()
.name("peerspeak-clip-player".to_string())
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
.expect("failed to spawn clip playback thread");
(
Self {
command_tx,
status: Arc::clone(&status),
},
status,
)
}
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
update_status(&self.status, |status| {
status.playing_id = Some(id);
status.position = Duration::ZERO;
status.total = None;
status.paused = false;
status.failure = None;
});
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
}
pub fn pause(&self) {
let _ = self.command_tx.send(ClipCommand::Pause);
}
pub fn resume(&self) {
let _ = self.command_tx.send(ClipCommand::Resume);
}
pub fn seek(&self, position: Duration) {
let _ = self.command_tx.send(ClipCommand::Seek(position));
}
pub fn stop(&self) {
let _ = self.command_tx.send(ClipCommand::Stop);
}
/// Set the universal playback gain (`1.0` = unity). Applies to the current
/// clip immediately and to every clip played afterwards.
pub fn set_volume(&self, volume: f32) {
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
}
}
fn playback_worker(
command_rx: mpsc::Receiver<ClipCommand>,
status: SharedClipStatus,
initial_volume: f32,
) {
let mut output: Option<MixerDeviceSink> = None;
let mut player: Option<Player> = None;
// Universal gain remembered across clips so a level set on one upload
// carries to the next; reapplied to each freshly connected player.
let mut volume = initial_volume.max(0.0);
loop {
match command_rx.recv_timeout(Duration::from_millis(100)) {
Ok(ClipCommand::Play(id, bytes)) => {
// In-memory readers do not expose file metadata to rodio. Pass
// the known attachment length explicitly so formats without a
// duration in their headers (notably MP3 and Vorbis) can derive
// a total duration and support reliable seeking.
let source = match decode_clip(bytes) {
Ok(source) => source,
Err(error) => {
fail(
&status,
id,
format!("unsupported or invalid audio: {error}"),
true,
);
continue;
}
};
let total = source.total_duration();
if output.is_none() {
match DeviceSinkBuilder::open_default_sink() {
Ok(sink) => {
let new_player = Player::connect_new(sink.mixer());
new_player.set_volume(volume);
player = Some(new_player);
output = Some(sink);
}
Err(error) => {
fail(
&status,
id,
format!("audio output unavailable: {error}"),
false,
);
continue;
}
}
}
if let Some(player) = player.as_ref() {
player.clear();
player.append(source);
player.play();
update_status(&status, |s| {
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = total;
s.paused = false;
s.failure = None;
});
}
}
Ok(ClipCommand::Pause) => {
if let Some(player) = player.as_ref() {
player.pause();
update_status(&status, |s| s.paused = true);
}
}
Ok(ClipCommand::Resume) => {
if let Some(player) = player.as_ref() {
player.play();
update_status(&status, |s| s.paused = false);
}
}
Ok(ClipCommand::Seek(position)) => {
if let Some(player) = player.as_ref()
&& player.try_seek(position).is_ok()
{
update_status(&status, |s| s.position = position);
}
}
Ok(ClipCommand::Stop) => {
if let Some(player) = player.as_ref() {
player.clear();
}
reset(&status);
}
Ok(ClipCommand::SetVolume(level)) => {
volume = level.max(0.0);
if let Some(player) = player.as_ref() {
player.set_volume(volume);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
if let Some(player) = player.as_ref() {
let (active, failed) = status
.lock()
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
.unwrap_or_default();
if active && !failed && player.empty() {
reset(&status);
} else if active && !failed {
update_status(&status, |s| {
s.position = player.get_pos();
s.paused = player.is_paused();
});
}
}
}
}
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
let byte_len = bytes.len() as u64;
Decoder::builder()
.with_data(Cursor::new(bytes))
.with_byte_len(byte_len)
.build()
}
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
crate::log_msg(&format!("Inline audio playback failed: {error}"));
update_status(status, |s| {
// Keep the id active until the GUI observes the failure on its next
// tick. This guarantees the active-only timer cannot disappear in the
// small window between sending Play and decoder/output failure.
s.playing_id = Some(id);
s.position = Duration::ZERO;
s.total = None;
s.paused = false;
s.failure = Some(ClipFailure {
id,
error,
invalid_audio,
});
});
}
fn reset(status: &SharedClipStatus) {
update_status(status, |s| *s = ClipStatus::default());
}
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
if let Ok(mut status) = status.lock() {
update(&mut status);
}
}
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
status.lock().map(|s| s.clone()).unwrap_or_default()
}
/// Format clip time as `mm:ss` (hours are folded into minutes).
pub fn format_time(duration: Duration) -> String {
let seconds = duration.as_secs();
format!("{}:{:02}", seconds / 60, seconds % 60)
}
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
return 0.0;
};
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
}
/// Convert a slider fraction into a clamped position within a clip.
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
#[test]
fn in_memory_mp3_reports_duration() {
// One headerless constant-bitrate MP3 frame repeated to model files
// that do not carry an Xing/VBR duration header.
let frame = base64::engine::general_purpose::STANDARD
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
.expect("valid test fixture");
let bytes = frame.repeat(20);
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
assert!(decoder.total_duration().is_some());
}
#[test]
fn formats_clip_time() {
assert_eq!(format_time(Duration::ZERO), "0:00");
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
}
#[test]
fn progress_handles_unknown_zero_and_clamps() {
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
assert_eq!(
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
0.5
);
assert_eq!(
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
1.0
);
}
#[test]
fn seek_target_clamps_fraction() {
let total = Duration::from_secs(100);
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
assert_eq!(seek_target(2.0, total), total);
}
}
+53 -12
View File
@@ -578,7 +578,9 @@ fn choose_config(device: &Device, output: bool) -> Result<cpal::SupportedStreamC
let pick = |channels: Option<u16>| { 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
+127 -18
View File
@@ -34,6 +34,18 @@ const NODE_READY_TIMEOUT: Duration = Duration::from_secs(3);
/// nodes never leak past the call that created them. /// 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
));
} }
} }
+17 -9
View File
@@ -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"
+51 -12
View File
@@ -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
);
} }
} }
+69 -15
View File
@@ -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"
);
} }
} }
+12 -7
View File
@@ -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.
/// ///
+495 -86
View File
@@ -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);
} }
} }
+20 -5
View File
@@ -23,7 +23,10 @@ pub fn pan_gains(pan: f32) -> (f32, f32) {
/// still following the same equal-power curve as a peer is moved away from center. /// 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;
} }
+119 -34
View File
@@ -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]
+31 -6
View File
@@ -16,11 +16,20 @@ pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
/// Emits the in-progress node as an `AudioDevice` if it's a complete Audio/* /// 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);
+201 -45
View File
@@ -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);
} }
} }
+8 -2
View File
@@ -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
+130 -2
View File
@@ -185,10 +185,132 @@ pub fn initials(name: &str) -> String {
} }
} }
/// A small content-addressed LRU cache mapping image bytes to a built value
/// (e.g. an `iced` image handle), so the SAME value is reused across redraws
/// instead of rebuilt every frame. Two Tier C F-03 properties beyond a plain
/// hash map:
///
/// 1. **Bounded** — at most `cap` entries, evicting the least-recently-used on
/// overflow, so a peer can't grow the cache without limit by publishing an
/// endless stream of distinct valid avatars.
/// 2. **Collision-safe** — a hit requires full byte equality, not just a matching
/// 64-bit hash, so a hash collision can never return a different image's value.
///
/// Linear scan; intended for small `cap` (tens of entries).
pub struct ByteLru<V> {
cap: usize,
/// `(content hash, content bytes, value)`; back = most recently used.
entries: Vec<(u64, Vec<u8>, V)>,
}
impl<V: Clone> ByteLru<V> {
/// Create an LRU holding at most `cap` entries (`cap` is clamped to >= 1).
pub fn new(cap: usize) -> Self {
Self {
cap: cap.max(1),
entries: Vec::new(),
}
}
/// Return the cached value for these exact `bytes`, building and inserting it
/// on a miss (evicting the least-recently-used entry once over `cap`). A hit
/// verifies full byte equality, so a 64-bit hash collision never returns the
/// wrong value. A hit also refreshes the entry's recency.
pub fn get_or_insert(&mut self, bytes: &[u8], build: impl FnOnce() -> V) -> V {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut hasher);
self.get_or_insert_hashed(hasher.finish(), bytes, build)
}
/// Inner seam with the content `hash` supplied explicitly. Production callers
/// use [`get_or_insert`]; tests use this to force a hash collision (different
/// bytes, same hash) and exercise the byte-equality guard.
fn get_or_insert_hashed(&mut self, hash: u64, bytes: &[u8], build: impl FnOnce() -> V) -> V {
if let Some(idx) = self
.entries
.iter()
.position(|(h, b, _)| *h == hash && b.as_slice() == bytes)
{
// LRU touch: move the hit entry to the back (most recent).
let entry = self.entries.remove(idx);
let val = entry.2.clone();
self.entries.push(entry);
return val;
}
let val = build();
if self.entries.len() >= self.cap {
self.entries.remove(0); // evict least-recently-used
}
self.entries.push((hash, bytes.to_vec(), val.clone()));
val
}
#[cfg(test)]
fn len(&self) -> usize {
self.entries.len()
}
}
#[cfg(test)] #[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);
+31 -1
View File
@@ -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);
+58 -12
View File
@@ -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)
} }
} }
+17 -6
View File
@@ -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.");
+3
View File
@@ -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;
+181 -16
View File
@@ -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"
);
} }
} }
+646 -29
View File
@@ -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, "");
+132
View File
@@ -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()));
}
}
+187
View File
@@ -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);
}
}
+252
View File
@@ -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);
}
}
+179 -4
View File
@@ -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();
+534 -30
View File
@@ -1,4 +1,4 @@
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{AudioProfile, NetworkMode, RecordingMode, ScreenShareSettings, ShareQuality};
use crate::friends::Friend; use crate::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
);
}
}
}
+1773 -235
View File
File diff suppressed because it is too large Load Diff
+66 -6
View File
@@ -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();
+29 -7
View File
@@ -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"
);
} }
} }
+4 -1
View File
@@ -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]
+4 -1
View File
@@ -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:?}"
);
} }
} }
+25 -5
View File
@@ -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
+7 -2
View File
@@ -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();
+4 -1
View File
@@ -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]
+13 -3
View File
@@ -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
+422 -12
View File
@@ -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 {
+22 -8
View File
@@ -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
} }
+280
View File
@@ -0,0 +1,280 @@
//! The detector service (§5): one cancellable background worker that polls the OS
//! adapters, runs the pure matcher + debouncer, and publishes the stable detected
//! game on a watch channel — only when it changes, so a flapping detector can't
//! spam `PeerState` re-announces.
//!
//! All the OS reads (Steam files / registry, the process scan) are blocking, so
//! the worker is a dedicated `std::thread`, not a tokio task; it owns the
//! [`SteamProbe`] cache and the [`Debouncer`] across ticks. The per-tick decision
//! is factored into the pure [`poll_once`] so the wiring of resolve + match +
//! debounce is unit-tested without any I/O.
use super::scan;
use super::steam::SteamProbe;
use super::{Debouncer, DetectedGame, ManualOverride, builtin_denylist, match_processes, resolve};
use std::collections::BTreeMap;
use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use tokio::sync::watch;
/// How often the detector samples Steam state + the process list.
pub const POLL_INTERVAL: Duration = Duration::from_secs(3);
/// Granularity of the cancellable sleep between polls, so a stop request is
/// honored promptly instead of after a full [`POLL_INTERVAL`].
const SLEEP_TICK: Duration = Duration::from_millis(200);
/// Apply one poll's worth of inputs to the debouncer, returning the new published
/// value **iff it changed** (the signal to re-announce presence / switch the
/// background). Pure: the caller supplies the already-fetched Steam detection and
/// process list, so resolve + match + debounce are testable with zero I/O.
pub fn poll_once(
debouncer: &mut Debouncer,
override_: &ManualOverride,
steam: Option<DetectedGame>,
processes: &[String],
process_map: &BTreeMap<String, String>,
denylist: &std::collections::BTreeSet<&str>,
) -> Option<Option<DetectedGame>> {
let matched = match_processes(processes, process_map, denylist);
let res = resolve(override_, steam, &matched);
if debouncer.observe(res.game, res.immediate) {
Some(debouncer.current().cloned())
} else {
None
}
}
/// Shared, live-updatable inputs to the detector, written by core (manual override
/// changes, config edits to the process map) and read each poll by the worker.
#[derive(Default)]
pub struct DetectorInputs {
pub override_: Mutex<ManualOverride>,
pub process_map: Mutex<BTreeMap<String, String>>,
}
/// A running detector service. Holds the watch receiver for detected-game changes
/// and the shared inputs; dropping it (or calling [`stop`](Self::stop)) ends the
/// worker thread.
pub struct GameDetector {
inputs: Arc<DetectorInputs>,
rx: watch::Receiver<Option<DetectedGame>>,
stop: Arc<AtomicBool>,
worker: Option<JoinHandle<()>>,
}
impl GameDetector {
/// Spawn the detector worker. `process_map` seeds the non-Steam mappings;
/// `override_` seeds the manual override (usually `Auto`). The worker runs
/// until [`stop`](Self::stop) or the returned `GameDetector` is dropped.
pub fn spawn(
override_: ManualOverride,
process_map: BTreeMap<String, String>,
) -> io::Result<Self> {
let inputs = Arc::new(DetectorInputs {
override_: Mutex::new(override_),
process_map: Mutex::new(process_map),
});
let (tx, rx) = watch::channel(None);
let stop = Arc::new(AtomicBool::new(false));
let worker_inputs = inputs.clone();
let worker_stop = stop.clone();
let worker = std::thread::Builder::new()
.name("game-detector".to_string())
.spawn(move || worker_loop(worker_inputs, tx, worker_stop))?;
Ok(Self {
inputs,
rx,
stop,
worker: Some(worker),
})
}
/// A clone of the watch receiver for detected-game changes. The current value
/// is `None` until the first non-empty detection is debounced in.
pub fn subscribe(&self) -> watch::Receiver<Option<DetectedGame>> {
self.rx.clone()
}
/// Replace the manual override (applied on the next poll, immediately,
/// bypassing debounce).
pub fn set_override(&self, override_: ManualOverride) {
*self.inputs.override_.lock().unwrap() = override_;
}
/// Replace the user process→name mappings (e.g. after a Settings edit).
pub fn set_process_map(&self, map: BTreeMap<String, String>) {
*self.inputs.process_map.lock().unwrap() = map;
}
/// Signal the worker to exit. Idempotent; also happens on drop.
pub fn stop(&self) {
self.stop.store(true, Ordering::Relaxed);
}
}
impl Drop for GameDetector {
fn drop(&mut self) {
self.stop();
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
/// The blocking worker loop: probe, decide, publish on change, sleep (cancellably).
fn worker_loop(
inputs: Arc<DetectorInputs>,
tx: watch::Sender<Option<DetectedGame>>,
stop: Arc<AtomicBool>,
) {
let denylist = builtin_denylist();
let mut steam = SteamProbe::new();
let mut debouncer = Debouncer::default();
while !stop.load(Ordering::Relaxed) {
let override_ = inputs.override_.lock().unwrap().clone();
let process_map = inputs.process_map.lock().unwrap().clone();
let steam_game = steam.detect();
let processes = scan::running_executables();
if let Some(new_current) = poll_once(
&mut debouncer,
&override_,
steam_game,
&processes,
&process_map,
&denylist,
) {
// A closed receiver means core shut down; stop quietly.
if tx.send(new_current).is_err() {
return;
}
}
// Cancellable sleep: wake promptly on a stop request.
let mut slept = Duration::ZERO;
while slept < POLL_INTERVAL && !stop.load(Ordering::Relaxed) {
std::thread::sleep(SLEEP_TICK);
slept += SLEEP_TICK;
}
}
}
#[cfg(test)]
mod tests {
use super::super::GameSource;
use super::*;
fn game(id: &str, name: &str, source: GameSource) -> DetectedGame {
DetectedGame {
id: id.into(),
name: Some(name.into()),
source,
}
}
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn poll_once_debounces_steam_detection() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let steam = game("steam:730", "CS2", GameSource::Steam);
let empty = BTreeMap::new();
// First poll: detected but not yet published (needs two hits).
assert_eq!(
poll_once(
&mut d,
&ManualOverride::Auto,
Some(steam.clone()),
&[],
&empty,
&deny
),
None
);
// Second poll: published.
assert_eq!(
poll_once(
&mut d,
&ManualOverride::Auto,
Some(steam.clone()),
&[],
&empty,
&deny
),
Some(Some(steam))
);
// Third identical poll: no change event.
assert_eq!(
poll_once(
&mut d,
&ManualOverride::Auto,
Some(game("steam:730", "CS2", GameSource::Steam)),
&[],
&empty,
&deny
),
None
);
}
#[test]
fn poll_once_matches_process_when_no_steam() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let procs = vec!["/games/hl2_linux".to_string()];
let user = map(&[("hl2_linux", "Half-Life 2")]);
poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
let change = poll_once(&mut d, &ManualOverride::Auto, None, &procs, &user, &deny);
let published = change
.expect("should publish on second hit")
.expect("a game");
assert_eq!(published.id, "exe:hl2_linux");
assert_eq!(published.name.as_deref(), Some("Half-Life 2"));
}
#[test]
fn poll_once_manual_override_is_immediate() {
let deny = builtin_denylist();
let mut d = Debouncer::default();
let forced = game("steam:220", "HL2", GameSource::Steam);
// Even with a live Steam detection of something else, the override wins now.
let other = game("steam:730", "CS2", GameSource::Steam);
let change = poll_once(
&mut d,
&ManualOverride::Force(forced.clone()),
Some(other),
&[],
&BTreeMap::new(),
&deny,
);
assert_eq!(change, Some(Some(forced)));
}
#[test]
fn spawn_and_stop_is_clean() {
// Smoke test the lifecycle: spawning and stopping must not panic, and the
// initial published value is None.
let det = GameDetector::spawn(ManualOverride::Auto, BTreeMap::new()).unwrap();
assert_eq!(*det.subscribe().borrow(), None);
det.set_override(ManualOverride::ForceNone);
det.set_process_map(map(&[("x", "X")]));
det.stop();
// Dropping also stops; no hang/panic.
drop(det);
}
}
+514
View File
@@ -0,0 +1,514 @@
//! Game detection, game-presence, and game-reactive backgrounds.
//!
//! A single local "what game is running" detector feeds two consumers:
//! 1. **Local** — a per-game UI background that auto-switches (extends W16).
//! 2. **Broadcast** — a `Playing <name>` status next to our avatar in every peer's
//! roster, riding the gossip presence plane like nickname + avatar.
//!
//! This module is structured testable-seams-first: the *pure* logic lives here
//! (the stable-id scheme, the priority [`resolve`] matcher, the [`Debouncer`], and
//! the process-name [`match_processes`] mapping), unit-tested with zero I/O. The OS
//! edges — Steam state/file reads ([`steam`]) and the running-process scan
//! ([`scan`]) — feed already-parsed values into these pure functions, and the
//! cancellable poll service ([`detector`]) wires them together.
pub mod detector;
pub mod scan;
pub mod steam;
pub mod vdf;
use std::collections::{BTreeMap, BTreeSet};
/// Where a detected game came from. Encodes the trust/priority tier directly:
/// a manual override beats live Steam state, which beats a matched process. Used
/// only for prioritization and as a presentation hint — never trusted as identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameSource {
/// The user forced a specific game (or "none") via the manual override.
Manual,
/// Steam's live `RunningAppID` resolved against an `appmanifest`.
Steam,
/// A running process matched against the user's process→name mappings.
Process,
}
/// A game the local detector currently believes is running.
///
/// `id` is the stable, namespaced identity used as the config key for backgrounds
/// (`steam:730`, `exe:hl2_linux`) — **never** the mutable display name. `name` is
/// the human label shown locally and broadcast as presence; it is `None` only for
/// the Steam appid-without-manifest case, where the background can still switch by
/// `id` but nothing is broadcast (per the "don't invent `Steam App 123`" rule).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedGame {
/// Stable namespaced identity. Config-key safe; survives renames.
pub id: String,
/// Trustworthy human name; `None` = id-only (Steam manifest unavailable).
pub name: Option<String>,
/// Provenance / priority tier.
pub source: GameSource,
}
impl DetectedGame {
/// The Steam namespaced id for an appid: `steam:<appid>`.
pub fn steam_id(app_id: u32) -> String {
format!("steam:{app_id}")
}
/// The process namespaced id for an executable identity: `exe:<normalized>`.
pub fn exe_id(exe: &str) -> String {
format!("exe:{}", normalize_exe(exe))
}
}
/// The user's manual override sitting above both detectors (D2). Small by design.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ManualOverride {
/// Trust the auto-detector (default).
#[default]
Auto,
/// Force "not playing anything" regardless of what is detected.
ForceNone,
/// Force a specific game (the user picked it from the known-games list).
Force(DetectedGame),
}
/// The outcome of [`resolve`]: the chosen game (if any) plus whether the choice is
/// a manual override and so should **bypass the [`Debouncer`]** (apply immediately).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Resolution {
pub game: Option<DetectedGame>,
/// `true` when a manual override (`ForceNone`/`Force`) decided the value.
pub immediate: bool,
}
/// Apply the detector priority (D2 / §5): **manual override → Steam → mapped
/// process → none**. Pure; the adapters resolve `steam`/`processes` into
/// `DetectedGame`s and this only picks the winner. `processes` is in the adapter's
/// deterministic priority order (see [`match_processes`]); its first entry wins.
pub fn resolve(
override_: &ManualOverride,
steam: Option<DetectedGame>,
processes: &[DetectedGame],
) -> Resolution {
match override_ {
ManualOverride::ForceNone => Resolution {
game: None,
immediate: true,
},
ManualOverride::Force(g) => Resolution {
game: Some(g.clone()),
immediate: true,
},
ManualOverride::Auto => {
let game = steam.or_else(|| processes.first().cloned());
Resolution {
game,
immediate: false,
}
}
}
}
/// Samples required before a *new* game is accepted/switched to.
pub const ACCEPT_HITS: u32 = 2;
/// Consecutive "no game" samples before a currently-shown game is cleared. At the
/// ~3 s poll cadence this is ~9 s, absorbing a brief Steam stale/crash blip.
pub const CLEAR_MISSES: u32 = 3;
/// Debounces a stream of raw per-poll detections into a stable published value, so
/// a flapping detector can't repeatedly re-announce the entire `PeerState` (which
/// can carry the ~48 KB avatar). Pure state machine — the service feeds it samples
/// and re-announces only when [`observe`](Debouncer::observe) reports a change.
///
/// A switch to a different game needs [`ACCEPT_HITS`] matching samples; clearing a
/// game needs [`CLEAR_MISSES`] consecutive misses. A manual override
/// (`immediate = true`) applies at once, bypassing both counters.
#[derive(Debug, Clone, Default)]
pub struct Debouncer {
current: Option<DetectedGame>,
pending: Option<DetectedGame>,
pending_hits: u32,
misses: u32,
}
impl Debouncer {
/// The currently published, debounced value.
pub fn current(&self) -> Option<&DetectedGame> {
self.current.as_ref()
}
/// Feed one poll result. `immediate` (a manual override is active) bypasses the
/// debounce. Returns `true` iff the published [`current`](Self::current) value
/// changed — the signal for the service to re-announce presence / switch the
/// background.
pub fn observe(&mut self, sample: Option<DetectedGame>, immediate: bool) -> bool {
if immediate {
let changed = self.current != sample;
self.current = sample;
self.pending = None;
self.pending_hits = 0;
self.misses = 0;
return changed;
}
match sample {
Some(game) => {
self.misses = 0;
if self.current.as_ref() == Some(&game) {
// Already publishing this game; drop any half-counted switch.
self.pending = None;
self.pending_hits = 0;
false
} else {
if self.pending.as_ref() == Some(&game) {
self.pending_hits += 1;
} else {
self.pending = Some(game);
self.pending_hits = 1;
}
if self.pending_hits >= ACCEPT_HITS {
self.current = self.pending.take();
self.pending_hits = 0;
true
} else {
false
}
}
}
None => {
// A miss never counts toward a *switch*; drop any pending candidate.
self.pending = None;
self.pending_hits = 0;
if self.current.is_some() {
self.misses += 1;
if self.misses >= CLEAR_MISSES {
self.current = None;
self.misses = 0;
true
} else {
false
}
} else {
false
}
}
}
}
}
/// Normalize a raw executable name/path to a stable identity for matching and ids:
/// take the final path component (handling both `/` and `\\` separators) and
/// lowercase it. Keeps any extension (`minecraft.exe` stays distinct from a
/// hypothetical `minecraft`), trims surrounding whitespace.
pub fn normalize_exe(raw: &str) -> String {
raw.rsplit(['/', '\\'])
.next()
.unwrap_or(raw)
.trim()
.to_lowercase()
}
/// Launcher/helper executables that must NEVER be reported as a game even if a
/// mapping names them — defense against a mis-entered mapping turning the launcher
/// itself into "the game". Normalized (lowercase basename) for comparison.
const BUILTIN_DENYLIST: &[&str] = &[
"steam",
"steam.exe",
"steamwebhelper",
"steamwebhelper.exe",
"steamerrorreporter",
"gameoverlayui",
"reaper",
"lutris",
"heroic",
"heroic.exe",
"legendary",
"gogdl",
"wine",
"wine64",
"wineserver",
"wine-preloader",
"proton",
"pressure-vessel-wrap",
"explorer.exe",
"services.exe",
"svchost.exe",
];
/// The built-in launcher/helper denylist as a set, for membership checks.
pub fn builtin_denylist() -> BTreeSet<&'static str> {
BUILTIN_DENYLIST.iter().copied().collect()
}
/// Match the currently-running executables against the user's explicit
/// process→display-name mappings, returning detected games in **deterministic
/// priority order** (sorted by stable id) with duplicates removed.
///
/// Conservative by construction (§3): only exact normalized-basename matches to a
/// user mapping count — we never guess that an arbitrary long-running process is a
/// game. Any executable on `denylist` is rejected even if mapped, so a launcher or
/// helper can't be promoted to "the game".
///
/// `user_map` keys are matched against the normalized basename of each running
/// entry; the key itself is normalized too, so the caller may store either
/// `Half-Life 2` style display values keyed by `hl2_linux` or `HL2_Linux`.
pub fn match_processes(
running: &[String],
user_map: &BTreeMap<String, String>,
denylist: &BTreeSet<&str>,
) -> Vec<DetectedGame> {
// Normalize the user map once so lookups are basename/case-insensitive.
let normalized_map: BTreeMap<String, &String> = user_map
.iter()
.map(|(k, v)| (normalize_exe(k), v))
.collect();
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut out: Vec<DetectedGame> = Vec::new();
for raw in running {
let norm = normalize_exe(raw);
if norm.is_empty() || denylist.contains(norm.as_str()) {
continue;
}
if let Some(name) = normalized_map.get(&norm) {
let id = format!("exe:{norm}");
if seen.insert(id.clone()) {
out.push(DetectedGame {
id,
name: Some((*name).clone()),
source: GameSource::Process,
});
}
}
}
// Deterministic priority: stable order independent of process-scan order.
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
#[cfg(test)]
mod tests {
use super::*;
fn steam_game(app_id: u32, name: &str) -> DetectedGame {
DetectedGame {
id: DetectedGame::steam_id(app_id),
name: Some(name.to_string()),
source: GameSource::Steam,
}
}
// --- ids / normalization ----------------------------------------------
#[test]
fn stable_ids_are_namespaced() {
assert_eq!(DetectedGame::steam_id(730), "steam:730");
assert_eq!(
DetectedGame::exe_id("/usr/games/hl2_linux"),
"exe:hl2_linux"
);
assert_eq!(
DetectedGame::exe_id("C:\\Games\\Minecraft.exe"),
"exe:minecraft.exe"
);
}
#[test]
fn normalize_handles_both_separators_and_case() {
assert_eq!(normalize_exe("/opt/Foo/Bar.x86_64"), "bar.x86_64");
assert_eq!(normalize_exe("D:\\a\\b\\GAME.EXE"), "game.exe");
assert_eq!(normalize_exe(" spaced.bin "), "spaced.bin");
assert_eq!(normalize_exe("bare"), "bare");
}
// --- resolve priority --------------------------------------------------
#[test]
fn resolve_prefers_steam_over_process_in_auto() {
let steam = steam_game(730, "CS2");
let procs = vec![DetectedGame {
id: "exe:foo".into(),
name: Some("Foo".into()),
source: GameSource::Process,
}];
let r = resolve(&ManualOverride::Auto, Some(steam.clone()), &procs);
assert_eq!(r.game, Some(steam));
assert!(!r.immediate);
}
#[test]
fn resolve_falls_back_to_first_process_then_none() {
let procs = vec![
DetectedGame {
id: "exe:a".into(),
name: Some("A".into()),
source: GameSource::Process,
},
DetectedGame {
id: "exe:b".into(),
name: Some("B".into()),
source: GameSource::Process,
},
];
let r = resolve(&ManualOverride::Auto, None, &procs);
assert_eq!(r.game.as_ref().unwrap().id, "exe:a");
let none = resolve(&ManualOverride::Auto, None, &[]);
assert_eq!(none.game, None);
assert!(!none.immediate);
}
#[test]
fn resolve_manual_override_wins_and_is_immediate() {
let steam = steam_game(730, "CS2");
// ForceNone overrides a live Steam detection, immediately.
let r = resolve(&ManualOverride::ForceNone, Some(steam.clone()), &[]);
assert_eq!(r.game, None);
assert!(r.immediate);
// Force(x) overrides too.
let forced = steam_game(220, "HL2");
let r = resolve(&ManualOverride::Force(forced.clone()), Some(steam), &[]);
assert_eq!(r.game, Some(forced));
assert!(r.immediate);
}
// --- debounce ----------------------------------------------------------
#[test]
fn debounce_requires_two_hits_to_switch() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
// First sighting: not yet published.
assert!(!d.observe(Some(g.clone()), false));
assert_eq!(d.current(), None);
// Second consecutive sighting: now published.
assert!(d.observe(Some(g.clone()), false));
assert_eq!(d.current(), Some(&g));
// Steady state: same game, no further change events.
assert!(!d.observe(Some(g.clone()), false));
}
#[test]
fn debounce_requires_three_misses_to_clear() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
d.observe(Some(g.clone()), false);
d.observe(Some(g.clone()), false);
assert_eq!(d.current(), Some(&g));
// Two misses: still shown (absorbs a transient blip).
assert!(!d.observe(None, false));
assert!(!d.observe(None, false));
assert_eq!(d.current(), Some(&g));
// Third miss: cleared.
assert!(d.observe(None, false));
assert_eq!(d.current(), None);
}
#[test]
fn debounce_blip_during_clear_resets_miss_count() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
d.observe(Some(g.clone()), false);
d.observe(Some(g.clone()), false);
// Miss, miss, then the game reappears: miss count resets, stays published.
d.observe(None, false);
d.observe(None, false);
assert!(!d.observe(Some(g.clone()), false));
assert_eq!(d.current(), Some(&g));
// It now takes a fresh run of three misses to clear.
d.observe(None, false);
d.observe(None, false);
assert!(d.observe(None, false));
assert_eq!(d.current(), None);
}
#[test]
fn debounce_immediate_bypasses_counters() {
let mut d = Debouncer::default();
let g = steam_game(730, "CS2");
// A manual override publishes on the first sample.
assert!(d.observe(Some(g.clone()), true));
assert_eq!(d.current(), Some(&g));
// ForceNone clears immediately.
assert!(d.observe(None, true));
assert_eq!(d.current(), None);
// Re-issuing the same immediate value is not a change.
d.observe(Some(g.clone()), true);
assert!(!d.observe(Some(g.clone()), true));
}
#[test]
fn debounce_switching_games_needs_two_hits_of_the_new_one() {
let mut d = Debouncer::default();
let a = steam_game(1, "A");
let b = steam_game(2, "B");
d.observe(Some(a.clone()), false);
d.observe(Some(a.clone()), false);
assert_eq!(d.current(), Some(&a));
// One sample of B does not switch.
assert!(!d.observe(Some(b.clone()), false));
assert_eq!(d.current(), Some(&a));
// Second consecutive B switches.
assert!(d.observe(Some(b.clone()), false));
assert_eq!(d.current(), Some(&b));
}
// --- process matching --------------------------------------------------
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn match_processes_matches_only_explicit_mappings() {
let user = map(&[("hl2_linux", "Half-Life 2")]);
let deny = builtin_denylist();
let running = vec![
"/usr/bin/firefox".to_string(),
"/games/Half-Life 2/hl2_linux".to_string(),
"/usr/bin/htop".to_string(),
];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].id, "exe:hl2_linux");
assert_eq!(got[0].name.as_deref(), Some("Half-Life 2"));
assert_eq!(got[0].source, GameSource::Process);
}
#[test]
fn match_processes_rejects_denylisted_even_if_mapped() {
// A mis-entered mapping naming the Steam client must not win.
let user = map(&[("steam", "Steam (oops)"), ("mygame", "My Game")]);
let deny = builtin_denylist();
let running = vec!["/usr/bin/steam".into(), "/opt/mygame".into()];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].id, "exe:mygame");
}
#[test]
fn match_processes_is_deterministic_and_deduped() {
let user = map(&[("zed", "Zed"), ("alpha", "Alpha")]);
let deny = builtin_denylist();
// Same game twice (two processes) + reverse discovery order.
let running = vec!["/b/zed".into(), "/a/alpha".into(), "/c/alpha".into()];
let got = match_processes(&running, &user, &deny);
// Deduped to two, sorted by id (alpha before zed) regardless of scan order.
assert_eq!(
got.iter().map(|g| g.id.as_str()).collect::<Vec<_>>(),
vec!["exe:alpha", "exe:zed"]
);
}
#[test]
fn match_processes_ignores_unmapped_and_case_folds() {
let user = map(&[("Game.x86_64", "The Game")]);
let deny = builtin_denylist();
let running = vec!["/x/GAME.X86_64".into(), "/y/random".into()];
let got = match_processes(&running, &user, &deny);
assert_eq!(got.len(), 1);
assert_eq!(got[0].name.as_deref(), Some("The Game"));
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Running-process enumeration for the non-Steam detection fallback (D6/D7):
//! native adapters only — `/proc` on Linux, Toolhelp on Windows — so there is no
//! `sysinfo` dependency and the audit surface stays small.
//!
//! This module is *just the OS edge*: it returns the list of running executable
//! paths/names. The trustworthy part — turning that list into a game via the
//! user's explicit mappings and the launcher denylist — is the pure
//! [`match_processes`](super::match_processes), unit-tested in the parent module.
/// Enumerate the executables of currently-running processes as paths/basenames.
/// Best-effort: processes we can't introspect (other users') are skipped rather
/// than erroring. The result is fed to [`match_processes`](super::match_processes),
/// which normalizes each entry to a basename before matching.
pub fn running_executables() -> Vec<String> {
#[cfg(target_os = "linux")]
{
linux_proc_executables()
}
#[cfg(windows)]
{
windows_toolhelp_executables()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
Vec::new()
}
}
#[cfg(target_os = "linux")]
fn linux_proc_executables() -> Vec<String> {
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir("/proc") else {
return out;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// Only numeric entries are processes.
if !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
let proc_dir = entry.path();
// Prefer the real exe path (full, untruncated); fall back to `comm`, which
// is readable for all processes but truncated to 15 bytes.
if let Ok(exe) = std::fs::read_link(proc_dir.join("exe"))
&& let Some(s) = exe.to_str()
{
out.push(s.to_string());
continue;
}
if let Ok(comm) = std::fs::read_to_string(proc_dir.join("comm")) {
let trimmed = comm.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
}
}
out
}
#[cfg(windows)]
fn windows_toolhelp_executables() -> Vec<String> {
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS,
};
let mut out = Vec::new();
// SAFETY: standard Toolhelp snapshot of all processes; handle checked below.
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return out;
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = std::mem::size_of::<PROCESSENTRY32W>() as u32;
// SAFETY: entry is zeroed with dwSize set, as Process32FirstW requires.
let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) };
while ok != 0 {
// szExeFile is a NUL-terminated UTF-16 array (the basename, e.g. game.exe).
let end = entry
.szExeFile
.iter()
.position(|&c| c == 0)
.unwrap_or(entry.szExeFile.len());
let name = String::from_utf16_lossy(&entry.szExeFile[..end]);
if !name.is_empty() {
out.push(name);
}
// SAFETY: same valid snapshot + entry struct.
ok = unsafe { Process32NextW(snapshot, &mut entry) };
}
// SAFETY: snapshot handle came from CreateToolhelp32Snapshot above.
unsafe { CloseHandle(snapshot) };
out
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
#[test]
fn enumerates_at_least_this_process() {
// The test runner itself is a process, so /proc enumeration must be
// non-empty and include something that normalizes to our own exe basename.
let exes = super::running_executables();
assert!(
!exes.is_empty(),
"expected to see running processes via /proc"
);
// Our own /proc/self/exe basename should appear among them.
let me = std::fs::read_link("/proc/self/exe")
.ok()
.and_then(|p| p.file_name().map(|f| f.to_string_lossy().into_owned()));
if let Some(me) = me {
let me_norm = super::super::normalize_exe(&me);
assert!(
exes.iter()
.any(|e| super::super::normalize_exe(e) == me_norm),
"running list should include our own executable {me_norm:?}"
);
}
}
}
+581
View File
@@ -0,0 +1,581 @@
//! Steam detection adapter: the primary signal (D1). Reads Steam's live
//! `RunningAppID` and resolves it to a display name via the plain-text
//! `appmanifest_<appid>.acf`, with no dependency on the binary `appinfo.vdf`.
//!
//! The *parsing* is pure and unit-tested ([`parse_running_app_id`],
//! [`parse_library_paths`], [`parse_app_name`], all over file contents). The fs /
//! Windows-registry reads are the thin edge, and [`SteamProbe`] caches roots,
//! library list, and resolved names — invalidating by mtime — so the 3 s detector
//! poll does not rescan every library each tick (Codex hardening).
use super::vdf::{self, Value};
use super::{DetectedGame, GameSource};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
/// Max bytes read from any single Steam state file. These are small text files
/// (a manifest is a few KB); the cap stops a corrupt/hostile giant file from being
/// slurped into memory before the parser's own depth guard kicks in.
const MAX_STEAM_FILE_BYTES: u64 = 4 * 1024 * 1024;
/// SteamPath is a local filesystem path. Four KiB is deliberately generous and
/// prevents a corrupt registry length from driving an enormous allocation.
#[cfg(any(windows, test))]
const MAX_STEAM_PATH_BYTES: u32 = 4 * 1024;
#[cfg(any(windows, test))]
fn validate_reg_len(len: u32) -> Option<usize> {
(len != 0 && len.is_multiple_of(2) && len <= MAX_STEAM_PATH_BYTES).then_some(len as usize / 2)
}
#[cfg(any(windows, test))]
fn decode_reg_sz(mut buf: Vec<u16>, returned_bytes: u32) -> Option<String> {
let units = validate_reg_len(returned_bytes)?;
if units > buf.len() {
return None;
}
buf.truncate(units);
while buf.last() == Some(&0) {
buf.pop();
}
Some(String::from_utf16_lossy(&buf))
}
/// Parse the live `RunningAppID` out of a Steam `registry.vdf` (the Linux/macOS
/// client's emulated-registry text file). Returns the appid only when present and
/// nonzero — `0`/absent is the "no game" state. Pure.
pub fn parse_running_app_id(registry_vdf: &str) -> Option<u32> {
let root = vdf::parse(registry_vdf).ok()?;
let raw = root
.get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str)?;
let id: u32 = raw.trim().parse().ok()?;
(id != 0).then_some(id)
}
/// Parse the library folder paths out of a `libraryfolders.vdf`, handling **both**
/// the current shape (`"0" { "path" "..." }`) and the legacy shape
/// (`"1" "/path"`, the path as a direct string value). Non-numeric keys
/// (`contentstatsid`, …) are skipped. Pure; paths are returned as-is (escapes
/// already decoded by the VDF parser), including ones on offline drives — the
/// caller checks existence.
pub fn parse_library_paths(libraryfolders_vdf: &str) -> Vec<PathBuf> {
let Ok(root) = vdf::parse(libraryfolders_vdf) else {
return Vec::new();
};
// The root may or may not wrap entries in a "libraryfolders" object.
let container = root.get("libraryfolders").unwrap_or(&root);
let mut out = Vec::new();
for (key, val) in container.entries() {
// Only numeric-keyed entries are library folders.
if key.parse::<u32>().is_err() {
continue;
}
let path = match val {
Value::Str(s) => Some(s.as_str()),
Value::Obj(_) => val.get("path").and_then(Value::as_str),
};
if let Some(p) = path
&& !p.is_empty()
{
out.push(PathBuf::from(p));
}
}
out
}
/// Parse the human `name` out of an `appmanifest_<appid>.acf`. Pure.
pub fn parse_app_name(appmanifest_acf: &str) -> Option<String> {
let root = vdf::parse(appmanifest_acf).ok()?;
root.get_path(&["AppState", "name"])
.and_then(Value::as_str)
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
/// Read at most [`MAX_STEAM_FILE_BYTES`] of a file as UTF-8 (lossy), or `None` if
/// it is missing/unreadable. The thin fs edge under the pure parsers above.
fn read_capped(path: &Path) -> Option<String> {
use std::io::Read;
let file = std::fs::File::open(path).ok()?;
let mut buf = Vec::new();
file.take(MAX_STEAM_FILE_BYTES).read_to_end(&mut buf).ok()?;
Some(String::from_utf8_lossy(&buf).into_owned())
}
fn mtime_of(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path).ok()?.modified().ok()
}
/// A library list cached against its source file's mtime.
#[derive(Default)]
struct CachedLibraries {
source: Option<PathBuf>,
mtime: Option<SystemTime>,
paths: Vec<PathBuf>,
}
/// A per-appid resolved name cached against the manifest's mtime. `name` is `None`
/// when the manifest exists but carries no usable name, or wasn't found.
struct CachedManifest {
mtime: Option<SystemTime>,
name: Option<String>,
}
/// Stateful Steam probe with mtime-invalidated caches. Construct once and call
/// [`detect`](Self::detect) each poll; all reads are blocking, so the detector
/// service runs it off the async worker.
pub struct SteamProbe {
roots: Vec<PathBuf>,
libraries: CachedLibraries,
manifests: HashMap<u32, CachedManifest>,
}
impl Default for SteamProbe {
fn default() -> Self {
Self::new()
}
}
impl SteamProbe {
pub fn new() -> Self {
Self {
roots: discover_roots(),
libraries: CachedLibraries::default(),
manifests: HashMap::new(),
}
}
/// One detection pass: read the live `RunningAppID`, and if a game is running,
/// resolve its name from the appmanifest (cached). Returns a `DetectedGame`
/// with `name: None` when the appid is known but no manifest name is available
/// — the background can still switch by id, but presence must not invent a name.
pub fn detect(&mut self) -> Option<DetectedGame> {
let app_id = self.running_app_id()?;
let name = self.app_name(app_id);
Some(DetectedGame {
id: DetectedGame::steam_id(app_id),
name,
source: GameSource::Steam,
})
}
/// The live RunningAppID (nonzero), or `None`.
///
/// Platform notes: on **Windows** the real registry's `RunningAppID` is updated
/// live, so we read it. On **Linux** the client's `registry.vdf` is only
/// rewritten on Steam *shutdown* — it's stale while a game runs — so the live
/// signal is the running game process's `SteamAppId` environment variable
/// (`/proc/<pid>/environ`, readable for our own processes; the same approach
/// MangoHud uses); `registry.vdf` stays as a best-effort fallback. Other Unix
/// (macOS) only has the `registry.vdf` fallback for now.
fn running_app_id(&self) -> Option<u32> {
#[cfg(windows)]
{
win::running_app_id()
}
#[cfg(target_os = "linux")]
{
running_app_id_from_environ().or_else(registry_running_app_id)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
registry_running_app_id()
}
}
/// Resolve (and cache) the display name for an appid by locating its
/// `appmanifest_<appid>.acf` across the known libraries.
fn app_name(&mut self, app_id: u32) -> Option<String> {
let manifest = self.find_manifest(app_id)?;
let mtime = mtime_of(&manifest);
if let Some(cached) = self.manifests.get(&app_id)
&& cached.mtime == mtime
{
return cached.name.clone();
}
let name = read_capped(&manifest).and_then(|c| parse_app_name(&c));
self.manifests.insert(
app_id,
CachedManifest {
mtime,
name: name.clone(),
},
);
name
}
/// The path to an appid's manifest, if it exists in any library.
fn find_manifest(&mut self, app_id: u32) -> Option<PathBuf> {
let filename = format!("appmanifest_{app_id}.acf");
for lib in self.library_paths() {
let candidate = lib.join("steamapps").join(&filename);
if candidate.exists() {
return Some(candidate);
}
}
None
}
/// All Steam library folder paths, cached and refreshed only when the source
/// `libraryfolders.vdf` changes (mtime). Discovered from the known roots.
fn library_paths(&mut self) -> Vec<PathBuf> {
// Locate the libraryfolders.vdf to watch (first existing across roots).
let source = self
.roots
.iter()
.map(|r| r.join("steamapps").join("libraryfolders.vdf"))
.find(|p| p.exists());
let mtime = source.as_deref().and_then(mtime_of);
if self.libraries.source == source && self.libraries.mtime == mtime && source.is_some() {
return self.libraries.paths.clone();
}
let mut paths = Vec::new();
if let Some(ref src) = source
&& let Some(contents) = read_capped(src)
{
paths = parse_library_paths(&contents);
}
// Always include the roots themselves: the install dir is an implicit
// library even if libraryfolders.vdf is missing or lists only extras.
for root in &self.roots {
if !paths.contains(root) {
paths.push(root.clone());
}
}
self.libraries = CachedLibraries {
source,
mtime,
paths: paths.clone(),
};
paths
}
}
/// Candidate Steam install roots that actually exist on this machine (each is a
/// directory containing a `steamapps` folder). Covers native, Flatpak, and Snap
/// layouts on Linux; on Windows the install path comes from the registry.
fn discover_roots() -> Vec<PathBuf> {
let mut roots = Vec::new();
#[cfg(windows)]
{
if let Some(p) = win::install_path() {
roots.push(p);
}
}
#[cfg(not(windows))]
{
if let Some(home) = dirs::home_dir() {
for rel in [
".steam/steam",
".steam/root",
".local/share/Steam",
".var/app/com.valvesoftware.Steam/.local/share/Steam",
"snap/steam/common/.local/share/Steam",
] {
roots.push(home.join(rel));
}
}
}
// Keep only roots that exist and look like a Steam install.
roots.retain(|p| p.join("steamapps").is_dir());
roots.sort();
roots.dedup();
roots
}
/// Candidate `registry.vdf` locations (Linux/macOS emulated registry).
#[cfg(not(windows))]
fn registry_vdf_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(home) = dirs::home_dir() {
out.push(home.join(".steam/registry.vdf"));
out.push(home.join(".steam/steam/registry.vdf"));
out.push(home.join(".var/app/com.valvesoftware.Steam/.steam/registry.vdf"));
out.push(home.join("snap/steam/common/.steam/registry.vdf"));
}
out
}
/// Best-effort `RunningAppID` from the on-disk `registry.vdf`. ⚠️ Stale while a
/// game runs (Steam rewrites the file only on shutdown), so this is a *fallback*
/// behind the live `/proc` `SteamAppId` scan on Linux — not the primary signal.
#[cfg(not(windows))]
fn registry_running_app_id() -> Option<u32> {
for path in registry_vdf_candidates() {
if let Some(contents) = read_capped(&path)
&& let Some(id) = parse_running_app_id(&contents)
{
return Some(id);
}
}
None
}
/// Parse a Steam appid out of a process's raw `environ` blob (NUL-separated
/// `KEY=VALUE` pairs), reading the `SteamAppId` variable Steam exports to every
/// game process. Returns the appid only when present and nonzero. Pure +
/// unit-tested; the `/proc` iteration is the thin edge in
/// [`running_app_id_from_environ`].
#[cfg(target_os = "linux")]
pub fn parse_steam_app_id_from_environ(environ: &[u8]) -> Option<u32> {
for kv in environ.split(|&b| b == 0) {
if let Some(val) = kv.strip_prefix(b"SteamAppId=")
&& let Ok(s) = std::str::from_utf8(val)
&& let Ok(id) = s.trim().parse::<u32>()
&& id != 0
{
return Some(id);
}
}
None
}
/// The live Steam appid of a running game, found by scanning `/proc/<pid>/environ`
/// for the `SteamAppId` Steam exports to the game's process tree. `environ` is
/// readable only for our own processes — exactly the ones a Steam game we launched
/// runs as — and we skip the rest. The live signal that replaces the stale
/// on-disk `registry.vdf` on Linux.
#[cfg(target_os = "linux")]
fn running_app_id_from_environ() -> Option<u32> {
let entries = std::fs::read_dir("/proc").ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.bytes().all(|b| b.is_ascii_digit()) {
continue;
}
// Cap the read: an environ is small; this bounds a pathological case.
if let Some(environ) = read_capped(&entry.path().join("environ"))
&& let Some(id) = parse_steam_app_id_from_environ(environ.as_bytes())
{
return Some(id);
}
}
None
}
#[cfg(windows)]
mod win {
//! Windows registry reads via direct Win32 FFI (windows-sys), no `winreg`
//! crate. Steam stores both the live `RunningAppID` and its install path under
//! `HKCU\Software\Valve\Steam`.
use super::{decode_reg_sz, validate_reg_len};
use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_DWORD, REG_SZ, RegCloseKey, RegOpenKeyExW,
RegQueryValueExW,
};
/// UTF-16, NUL-terminated, for a Win32 wide-string argument.
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
/// Open `HKCU\Software\Valve\Steam` for reading; `None` if absent.
fn open_steam_key() -> Option<HKEY> {
let subkey = wide("Software\\Valve\\Steam");
let mut hkey: HKEY = std::ptr::null_mut();
// SAFETY: valid HKEY constant, NUL-terminated subkey, out-param for the handle.
let rc =
unsafe { RegOpenKeyExW(HKEY_CURRENT_USER, subkey.as_ptr(), 0, KEY_READ, &mut hkey) };
(rc == ERROR_SUCCESS).then_some(hkey)
}
/// The live `RunningAppID` REG_DWORD, nonzero, or `None`.
pub fn running_app_id() -> Option<u32> {
let hkey = open_steam_key()?;
let name = wide("RunningAppID");
let mut kind: u32 = 0;
let mut data: u32 = 0;
let mut len = std::mem::size_of::<u32>() as u32;
// SAFETY: out-params sized for a DWORD; data buffer is a u32 we own.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
&mut data as *mut u32 as *mut u8,
&mut len,
)
};
// SAFETY: handle came from RegOpenKeyExW above.
unsafe { RegCloseKey(hkey) };
if rc == ERROR_SUCCESS && kind == REG_DWORD && data != 0 {
Some(data)
} else {
None
}
}
/// The Steam install directory from `HKCU\...\Steam\SteamPath`, if it exists.
pub fn install_path() -> Option<PathBuf> {
let hkey = open_steam_key()?;
let name = wide("SteamPath");
let mut kind: u32 = 0;
let mut len: u32 = 0;
// First query the size.
// SAFETY: null data ptr with a zeroed len asks for the required size.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
std::ptr::null_mut(),
&mut len,
)
};
if rc != ERROR_SUCCESS || kind != REG_SZ {
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
return None;
}
let Some(units) = validate_reg_len(len) else {
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
return None;
};
let mut buf = vec![0u16; units];
let mut len2 = len;
// SAFETY: buffer sized to the queried byte length.
let rc = unsafe {
RegQueryValueExW(
hkey,
name.as_ptr(),
std::ptr::null(),
&mut kind,
buf.as_mut_ptr() as *mut u8,
&mut len2,
)
};
// SAFETY: valid handle.
unsafe { RegCloseKey(hkey) };
if rc != ERROR_SUCCESS || kind != REG_SZ || len2 > len {
return None;
}
Some(PathBuf::from(decode_reg_sz(buf, len2)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_string_lengths_are_bounded_and_trimmed() {
assert_eq!(
validate_reg_len(5),
None,
"odd byte lengths are invalid UTF-16"
);
assert_eq!(validate_reg_len(MAX_STEAM_PATH_BYTES + 2), None);
assert_eq!(validate_reg_len(8), Some(4));
let raw = "C:\\Steam\0ignored".encode_utf16().collect::<Vec<_>>();
let returned_bytes = ("C:\\Steam\0".encode_utf16().count() * 2) as u32;
assert_eq!(
decode_reg_sz(raw, returned_bytes).as_deref(),
Some("C:\\Steam")
);
}
#[test]
fn running_app_id_reads_nonzero_and_rejects_zero() {
let running = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
"RunningAppID" "440"
} } } } }"#;
assert_eq!(parse_running_app_id(running), Some(440));
let idle = r#""Registry" { "HKCU" { "Software" { "Valve" { "Steam" {
"RunningAppID" "0"
} } } } }"#;
assert_eq!(parse_running_app_id(idle), None);
// Missing key / garbage → None, no panic.
assert_eq!(parse_running_app_id(r#""Registry" { }"#), None);
assert_eq!(parse_running_app_id("not vdf at all {{{"), None);
}
#[test]
fn library_paths_handles_current_and_legacy_shapes() {
let current = r#""libraryfolders" {
"0" { "path" "/home/eric/.local/share/Steam" "label" "" }
"1" { "path" "/mnt/games/SteamLibrary" }
"contentstatsid" "12345"
}"#;
let got = parse_library_paths(current);
assert_eq!(
got,
vec![
PathBuf::from("/home/eric/.local/share/Steam"),
PathBuf::from("/mnt/games/SteamLibrary"),
]
);
// Legacy shape: numeric keys map straight to path strings.
let legacy = r#""LibraryFolders" {
"TimeNextStatsReport" "9999"
"ContentStatsID" "42"
"1" "/mnt/old/SteamLibrary"
}"#;
let got = parse_library_paths(legacy);
assert_eq!(got, vec![PathBuf::from("/mnt/old/SteamLibrary")]);
}
#[test]
fn library_paths_empty_on_garbage() {
assert!(parse_library_paths("totally broken {{{").is_empty());
}
#[cfg(target_os = "linux")]
#[test]
fn steam_app_id_parsed_from_environ_blob() {
// A realistic NUL-separated environ with SteamAppId among other vars.
let environ = b"PATH=/usr/bin\0SteamAppId=440\0HOME=/home/x\0SteamGameId=440\0";
assert_eq!(parse_steam_app_id_from_environ(environ), Some(440));
// Nonzero requirement: SteamAppId=0 (the launcher itself) is ignored.
assert_eq!(
parse_steam_app_id_from_environ(b"SteamAppId=0\0FOO=bar\0"),
None
);
// Absent → None (a non-Steam process).
assert_eq!(
parse_steam_app_id_from_environ(b"PATH=/usr/bin\0HOME=/home/x\0"),
None
);
// Not fooled by a different var that merely contains the substring.
assert_eq!(
parse_steam_app_id_from_environ(b"MY_SteamAppId=999\0"),
None
);
// Garbage value → None, no panic.
assert_eq!(
parse_steam_app_id_from_environ(b"SteamAppId=notanumber\0"),
None
);
}
#[test]
fn app_name_extracts_and_filters_empty() {
let acf = r#""AppState" { "appid" "440" "name" "Team Fortress 2" }"#;
assert_eq!(parse_app_name(acf), Some("Team Fortress 2".to_string()));
// Empty name → None (don't broadcast a blank).
let blank = r#""AppState" { "appid" "440" "name" "" }"#;
assert_eq!(parse_app_name(blank), None);
// Missing name → None.
assert_eq!(parse_app_name(r#""AppState" { "appid" "440" }"#), None);
}
}
+366
View File
@@ -0,0 +1,366 @@
//! A small, defensive parser for Valve's KeyValues / VDF text format, used by
//! `appmanifest_<appid>.acf`, `libraryfolders.vdf`, and `~/.steam/registry.vdf`.
//!
//! Pure (operates on already-read file *contents*) and unit-tested, per the
//! testable-seams-first workflow — the file I/O and size caps live in the Steam
//! adapter. Deliberately a real recursive-descent KeyValues parser rather than a
//! `"name"`-line regex: escapes, nesting, and truncation will eventually break a
//! regex (Codex's "use a real VDF parser" hardening). Hardened against hostile
//! input with a recursion-depth cap, so a deeply nested file errors instead of
//! overflowing the stack, and never panics on malformed/truncated input.
/// Max object nesting depth accepted before bailing out. Real Steam files nest a
/// handful of levels (`registry.vdf` is the deepest at ~6); this is generous while
/// still bounding a malicious file.
const MAX_DEPTH: usize = 32;
/// A parsed KeyValues value: either a leaf string or a nested object. Child order
/// is preserved and duplicate keys are kept (KeyValues permits them); lookups
/// return the first match.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Value {
Str(String),
Obj(Vec<(String, Value)>),
}
impl Value {
/// The leaf string at this node, if it is a string (not an object).
pub fn as_str(&self) -> Option<&str> {
match self {
Value::Str(s) => Some(s),
Value::Obj(_) => None,
}
}
/// The first child value under `key`, if this is an object containing it.
/// Case-insensitive on the key (KeyValues keys are conventionally
/// case-insensitive, and Steam is inconsistent, e.g. `AppState`/`appid`).
pub fn get(&self, key: &str) -> Option<&Value> {
match self {
Value::Obj(pairs) => pairs
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(key))
.map(|(_, v)| v),
Value::Str(_) => None,
}
}
/// Follow a chain of object keys, returning the value at the end of the path.
/// `root.get_path(&["AppState", "name"])`.
pub fn get_path<'a>(&'a self, path: &[&str]) -> Option<&'a Value> {
let mut cur = self;
for key in path {
cur = cur.get(key)?;
}
Some(cur)
}
/// Iterate the (key, value) child pairs if this is an object.
pub fn entries(&self) -> &[(String, Value)] {
match self {
Value::Obj(pairs) => pairs,
Value::Str(_) => &[],
}
}
}
/// Parse KeyValues/VDF text into a top-level object (the sequence of root
/// key→value pairs). Returns `Err` on unbalanced braces, a key with no value, or
/// nesting past [`MAX_DEPTH`]. Never panics.
pub fn parse(input: &str) -> Result<Value, String> {
let mut lexer = Lexer { rest: input };
let obj = parse_object(&mut lexer, 0, true)?;
Ok(Value::Obj(obj))
}
/// Parse a run of `key value` pairs. `top_level` parses until EOF; otherwise it
/// parses until a closing `}` (which it consumes).
fn parse_object(
lexer: &mut Lexer,
depth: usize,
top_level: bool,
) -> Result<Vec<(String, Value)>, String> {
if depth > MAX_DEPTH {
return Err("VDF nesting too deep".to_string());
}
let mut pairs = Vec::new();
loop {
match lexer.next_token()? {
None => {
if top_level {
return Ok(pairs);
}
return Err("unexpected end of input inside object".to_string());
}
Some(Token::Close) => {
if top_level {
return Err("unexpected '}' at top level".to_string());
}
return Ok(pairs);
}
Some(Token::Open) => {
return Err("expected key, found '{'".to_string());
}
Some(Token::Str(key)) => {
// A key must be followed by a value: a string or a nested object.
match lexer.next_token()? {
Some(Token::Str(val)) => pairs.push((key, Value::Str(val))),
Some(Token::Open) => {
let child = parse_object(lexer, depth + 1, false)?;
pairs.push((key, Value::Obj(child)));
}
Some(Token::Close) => {
return Err(format!("key '{key}' has no value (found '}}')"));
}
None => return Err(format!("key '{key}' has no value (end of input)")),
}
}
}
}
}
enum Token {
Open,
Close,
Str(String),
}
struct Lexer<'a> {
rest: &'a str,
}
impl Lexer<'_> {
/// Produce the next token, skipping whitespace and `//` line comments.
fn next_token(&mut self) -> Result<Option<Token>, String> {
loop {
self.rest = self.rest.trim_start();
if self.rest.is_empty() {
return Ok(None);
}
// Line comments: `//` to end of line.
if let Some(after) = self.rest.strip_prefix("//") {
match after.find('\n') {
Some(nl) => self.rest = &after[nl + 1..],
None => {
self.rest = "";
return Ok(None);
}
}
continue;
}
let mut chars = self.rest.char_indices();
let (_, first) = chars.next().expect("non-empty checked above");
return match first {
'{' => {
self.advance_bytes(first.len_utf8());
Ok(Some(Token::Open))
}
'}' => {
self.advance_bytes(first.len_utf8());
Ok(Some(Token::Close))
}
'"' => self.lex_quoted(),
_ => Ok(Some(self.lex_bareword())),
};
}
}
fn advance_bytes(&mut self, n: usize) {
self.rest = &self.rest[n..];
}
/// Lex a `"..."` string, decoding `\\ \" \n \t` escapes. Errors if unterminated.
fn lex_quoted(&mut self) -> Result<Option<Token>, String> {
// Skip the opening quote.
self.advance_bytes(1);
let mut out = String::new();
let mut chars = self.rest.char_indices();
while let Some((i, c)) = chars.next() {
match c {
'"' => {
// Consume through the closing quote.
self.rest = &self.rest[i + 1..];
return Ok(Some(Token::Str(out)));
}
'\\' => {
// Decode the escape.
match chars.next() {
Some((_, esc)) => out.push(match esc {
'n' => '\n',
't' => '\t',
'r' => '\r',
// `\\`, `\"`, and anything else: take the literal char.
other => other,
}),
None => return Err("unterminated escape in quoted string".to_string()),
}
}
other => out.push(other),
}
}
Err("unterminated quoted string".to_string())
}
/// Lex an unquoted token: run of non-whitespace, non-brace, non-quote chars.
fn lex_bareword(&mut self) -> Token {
let end = self
.rest
.find(|c: char| c.is_whitespace() || matches!(c, '{' | '}' | '"'))
.unwrap_or(self.rest.len());
let word = self.rest[..end].to_string();
self.rest = &self.rest[end..];
Token::Str(word)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_appmanifest_name() {
// A trimmed-down real appmanifest_<id>.acf.
let acf = r#"
"AppState"
{
"appid" "730"
"name" "Counter-Strike 2"
"StateFlags" "4"
"installdir" "Counter-Strike Global Offensive"
"UserConfig"
{
"language" "english"
}
}
"#;
let root = parse(acf).unwrap();
assert_eq!(
root.get_path(&["AppState", "name"]).and_then(Value::as_str),
Some("Counter-Strike 2")
);
assert_eq!(
root.get_path(&["AppState", "appid"])
.and_then(Value::as_str),
Some("730")
);
// Case-insensitive key lookup.
assert_eq!(
root.get_path(&["appstate", "NAME"]).and_then(Value::as_str),
Some("Counter-Strike 2")
);
}
#[test]
fn parses_libraryfolders_paths_with_escaped_backslashes() {
// Windows paths arrive with doubled backslashes (escaped).
let vdf = r#"
"libraryfolders"
{
"0"
{
"path" "C:\\Program Files (x86)\\Steam"
"apps"
{
"730" "35000000000"
}
}
"1"
{
"path" "/home/eric/.local/share/Steam"
}
}
"#;
let root = parse(vdf).unwrap();
let lf = root.get("libraryfolders").unwrap();
assert_eq!(
lf.get_path(&["0", "path"]).and_then(Value::as_str),
Some(r"C:\Program Files (x86)\Steam")
);
assert_eq!(
lf.get_path(&["1", "path"]).and_then(Value::as_str),
Some("/home/eric/.local/share/Steam")
);
// The library folder ids are iterable for discovery.
let ids: Vec<&str> = lf.entries().iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(ids, vec!["0", "1"]);
}
#[test]
fn parses_registry_running_appid_deep_path() {
let reg = r#"
"Registry"
{
"HKCU"
{
"Software"
{
"Valve"
{
"Steam"
{
"RunningAppID" "570"
"language" "english"
}
}
}
}
}
"#;
let root = parse(reg).unwrap();
let appid = root
.get_path(&[
"Registry",
"HKCU",
"Software",
"Valve",
"Steam",
"RunningAppID",
])
.and_then(Value::as_str);
assert_eq!(appid, Some("570"));
}
#[test]
fn handles_comments_and_barewords() {
let vdf = "// a comment\n\"root\"\n{\n\tbarekey barevalue // trailing\n}\n";
let root = parse(vdf).unwrap();
assert_eq!(
root.get_path(&["root", "barekey"]).and_then(Value::as_str),
Some("barevalue")
);
}
#[test]
fn rejects_malformed_without_panicking() {
// Unbalanced braces.
assert!(parse("\"a\" {").is_err());
// Stray closing brace.
assert!(parse("}").is_err());
// Key with no value at EOF.
assert!(parse("\"lonely\"").is_err());
// Unterminated quoted string.
assert!(parse("\"key\" \"unterminated").is_err());
}
#[test]
fn rejects_pathologically_deep_nesting() {
// Build MAX_DEPTH+5 nested objects; must error, not overflow the stack.
let mut s = String::new();
for i in 0..(MAX_DEPTH + 5) {
s.push_str(&format!("\"k{i}\" {{"));
}
for _ in 0..(MAX_DEPTH + 5) {
s.push('}');
}
assert!(parse(&s).is_err());
}
#[test]
fn missing_keys_return_none_not_error() {
let root = parse("\"AppState\" { \"appid\" \"1\" }").unwrap();
assert_eq!(root.get_path(&["AppState", "name"]), None);
assert_eq!(root.get_path(&["Nope"]), None);
// Treating a string as an object yields None rather than panicking.
assert_eq!(root.get_path(&["AppState", "appid", "deeper"]), None);
}
}
+7 -4
View File
@@ -89,9 +89,9 @@ impl HotkeyAction {
pub fn tier(self) -> HotkeyTier { 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");
} }
} }
+11 -6
View File
@@ -41,7 +41,8 @@ pub fn identity_path() -> Option<PathBuf> {
/// A *missing* file (first ever run, or right after a reset) is the normal /// 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;
+30 -20
View File
@@ -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);
} }
} }
+1285 -80
View File
File diff suppressed because it is too large Load Diff
+169 -37
View File
@@ -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]
+217 -28
View File
@@ -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);
}
} }
+120 -14
View File
@@ -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);
}
} }
+115
View File
@@ -0,0 +1,115 @@
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PlaylistKind {
M3u,
Pls,
}
/// Classify a path by extension into a playlist kind, or None if it is not a
/// recognized playlist file. Case-insensitive: m3u/m3u8 -> M3u, pls -> Pls.
pub fn playlist_kind(path: &Path) -> Option<PlaylistKind> {
let ext = path.extension()?.to_string_lossy();
match ext.to_ascii_lowercase().as_str() {
"m3u" | "m3u8" => Some(PlaylistKind::M3u),
"pls" => Some(PlaylistKind::Pls),
_ => None,
}
}
/// Parse an m3u/m3u8 or pls playlist into local audio file paths. Remote entries
/// (http/https/ftp URLs) and non-audio entries are skipped; relative paths are
/// resolved against `base_dir` (the playlist file's parent directory). Order is
/// preserved. Does not touch the filesystem.
pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Vec<PathBuf> {
let entries: Vec<&str> = match kind {
PlaylistKind::M3u => contents
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect(),
PlaylistKind::Pls => contents
.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
key.trim()
.to_ascii_lowercase()
.starts_with("file")
.then_some(value.trim())
})
.filter(|line| !line.is_empty())
.collect(),
};
entries
.into_iter()
.filter_map(|entry| playlist_entry_path(entry, base_dir))
.collect()
}
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
let lower = entry.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://")
{
return None;
}
let path = Path::new(entry);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
};
let file_name = resolved.file_name()?.to_string_lossy();
crate::files::looks_like_audio_name(&file_name).then_some(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn m3u_skips_comments_and_remote_urls() {
let base = Path::new("/music/lists");
let contents = "\
#EXTM3U
#EXTINF:123,Artist - Song
tracks/song.ogg
https://example.com/stream.mp3
";
assert_eq!(
parse_playlist(contents, base, PlaylistKind::M3u),
vec![PathBuf::from("/music/lists/tracks/song.ogg")]
);
}
#[test]
fn pls_keeps_file_values_and_skips_non_audio() {
let base = Path::new("/music");
let contents = "\
[playlist]
File1=one.flac
Title1=One
File2=notes.txt
File3=/var/audio/two.MP3
";
assert_eq!(
parse_playlist(contents, base, PlaylistKind::Pls),
vec![
PathBuf::from("/music/one.flac"),
PathBuf::from("/var/audio/two.MP3"),
]
);
}
#[test]
fn playlist_kind_is_case_insensitive() {
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
assert_eq!(
playlist_kind(Path::new("mix.m3u8")),
Some(PlaylistKind::M3u)
);
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
}
}
+113 -18
View File
@@ -36,8 +36,11 @@ pub enum PresenceMode {
impl PresenceMode { 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");
+18 -5
View File
@@ -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");
+40 -7
View File
@@ -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.
///
/// v4v5 (0.6.0): the W22 shared-listening / music presence work. `PeerState`
/// gained an optional `music` presence field (a current shared-listening track
/// descriptor + playback timeline; the audio bytes still ride the files plane by
/// id, gossip carries only the descriptor/timeline), and `MusicPresence` then
/// gained optional prefetch hints for the next track so tuned-in listeners can
/// fetch it before the DJ advances. Both shipped together in the **0.6.0** release
/// (commit `bca2ccd`), where the const advanced straight `3 → 5`: there was never
/// a `GOSSIP_PROTO == 4` build — 4 is a skipped step. (Per `VERSIONING.md` this
/// breaking gossip change rode the `0.5.1 → 0.6.0` MINOR bump, so the discipline
/// was honoured; 0.6.1 is a wire-compatible PATCH on top, still proto 5.)
pub const GOSSIP_PROTO: u32 = 5;
/// File-transfer plane version (chat attachment request/stream shape). Bump on /// 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]
+13 -2
View File
@@ -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()
} }
+508 -117
View File
@@ -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);
}
} }
+701 -43
View File
@@ -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) = &notices {
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")]);
} }
+5 -1
View File
@@ -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,
+996
View File
@@ -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
));
}
}

Some files were not shown because too many files have changed in this diff Show More