Compare commits

..
Author SHA1 Message Date
molluskandClaude Opus 5 9f3d0ac2ea feat(probe): give audio_probe a Windows/cpal arm
The probe drove PipeWire directly and stubbed out everywhere else, so the
Windows port had no way to isolate the local output path. win_probe mirrors
unix_probe against CpalBackend: same phase-continuous sine, same fill-paced
production against PLAYBACK_TARGET_SAMPLES, same playout-health log tailer.

NOT COMPILE-VERIFIED. The new module is behind #[cfg(windows)], so a Linux
host never type-checks it; it needs a cargo check on a Windows target (or the
mingw cross target) before it is trusted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 23:22:50 -04:00
mollusk 2eae95ede0 Merge Windows chimes + docs + cpal diagnostics (W7/W2/W8)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
2026-06-19 04:21:58 -04:00
molluskandClaude Opus 4.8 fdd532de53 Windows audio (cpal): surface device fallback + callback-size diagnostics (W7, W2)
Two safe, host-independent hardening steps from the Codex Windows-compat review.

W7 — when a saved input/output device name no longer resolves (WASAPI friendly
names can change across driver/endpoint changes), resolve() now logs the
fallback to the system default instead of switching devices silently — so a
"my audio went to the wrong device" report has a log line explaining why.
(cpal 0.15 exposes only the device name, so a stable hardware id isn't available
to persist; this surfaces the limitation rather than hiding it.)

W2 — the output RT callback now records the largest interleaved buffer length it
is ever asked for (a wait-free fetch_max into an atomic, kept off the log/alloc
path). The once-per-second health-logger reports that size and, if a callback
ever exceeds the prefill target (PLAYBACK_TARGET_SAMPLES), warns explicitly —
that's the exact signature of the WASAPI-shared-mode underrun-every-cycle bug.
This is the diagnostic a real-host test needs before committing to the
structural fix (larger target / fixed buffer); no behavior change.

Windows-only file (cfg(windows)); compile-verified via the windows-gnu
cross-build, not yet exercised on a real WASAPI host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:21:53 -04:00
molluskandClaude Opus 4.8 46809153d8 Windows: notification chimes via SoundPlayer + docs/WINDOWS.md (W8)
Implemented by Codex (gpt-5.5); reviewed and committed by Claude.

W8 — chimes were played by shelling out to pw-play/paplay/aplay, which don't
exist on Windows, so every chime silently no-op'd there. spawn_player is now
cfg-split: Linux/unix keeps the existing player list; Windows plays the WAV via
PowerShell's System.Media.SoundPlayer (PlaySync on the existing detached thread).
Dependency-free, same fire-and-forget / silent-on-failure contract. Custom chime
paths are single-quote-escaped for the PowerShell command (helper + unit test).

Also adds docs/WINDOWS.md: a build/run/status guide (native MSVC + cross-compile
to -gnu, first-run firewall/UDP note, %APPDATA% paths, and the honest known-gaps
table — echo-cancel/screenshare/resampling/device-id/buffer-pacing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:21:40 -04:00
mollusk 6ccad0d37a Merge Windows-compat quick wins (W5/W6/W9) into windows port
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
2026-06-19 04:03:55 -04:00
molluskandClaude Opus 4.8 ddb3d2aabc Windows compat quick wins: echo-cancel UI gate, pixelpass .exe, cfg tighten (W5/W6/W9)
Three Windows-compatibility fixes from the Codex review. Implemented by Codex
(gpt-5.5); reviewed and committed by Claude.

W5 — echo cancellation is a Linux/PipeWire feature, but the toggle was shown and
live on Windows, so a Windows join tried `pactl` and errored before falling back.
Now `#[cfg(target_os = "linux")]` gates the core enable path (and the
ActiveSession guard field); on other targets the Settings + in-call controls
render as a disabled checkbox with a "not available on Windows yet" note.

W6 — pixelpass PATH lookup only tried `pixelpass`; on Windows it now also tries
`pixelpass.exe` via a cfg-selected candidate list (+ unit test).

W9 — the Linux audio stack (pipewire/pw_cli/echo_cancel/audio_probe + the
`PlatformAudioBackend` alias and device-enum re-export) was gated `cfg(unix)`;
tightened to `cfg(target_os = "linux")` so a hypothetical macOS build won't try
to compile PipeWire. cpal stays `cfg(windows)`. Genuinely-Unix file/key
permission code in lib.rs/identity.rs left as `cfg(unix)`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 04:03:49 -04:00
molluskandClaude Opus 4.8 bbbe2d8f17 Windows audio (cpal): startup handshake + RT-safe capture ring (W1, W3)
Two correctness fixes for the cpal/WASAPI backend from the Codex Windows-compat
review, plus device logging.

W1 — start_capture/start_playback no longer return Ok before the stream exists.
The owning thread did device resolution, config selection, build_stream, and
play() and only *logged* failures, so a missing 48 kHz config / unsupported
format / WASAPI error left the UI in a joined-but-silent room. The worker now
reports readiness over a channel and start_* blocks on it via finish_start(),
returning the real AudioError on failure (and joining the dead worker).

W3 — the RT capture callback no longer allocates or sends on a channel. It now
only downmixes and wait-free-pushes mono samples into a preallocated lock-free
HeapRb; the owning thread drains that ring, frames it (the Vec allocation lives
off the RT path), and sends completed frames. A full ring increments an overrun
counter instead of blocking. Restores the no-alloc/no-block-in-callback contract
the PipeWire backend already honors.

Also logs the selected device name / sample format / channels / rate on stream
start (a review nice-to-have) and logs capture overruns when they occur.

Windows-only file (cfg(windows)); Linux build unaffected. Compile-verified via
the windows-gnu cross-build; not yet run on a real WASAPI host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:57:41 -04:00
molluskandClaude Opus 4.8 63b45e03ab Windows port: cfg-gate iced window application_id (Linux-only field)
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
iced's `window::settings::PlatformSpecific::application_id` only exists on
Linux (X11/Wayland use it to match the .desktop launcher icon); on Windows
the struct exposes a different field set, so the unconditional assignment
failed to compile for `*-pc-windows-*`. This was the first real Windows
compile blocker surfaced now that the port actually cross-compiles.

Move the field behind a `platform_specific_settings()` helper gated on
`target_os = "linux"`, with a defaults-only variant elsewhere. Linux build
unchanged (verified `cargo check`); the windows-gnu target now builds a
runnable .exe (verified launching under Wine: GUI renders, iroh network
stack + ring identity init, config/identity land in %APPDATA%).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:03:59 -04:00
molluskandClaude Opus 4.8 2937e5191a Windows port Phase 2: cpal device enumeration
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Give the Windows device pickers a real device list (Phase 0/1 left pw_cli
returning nothing off-Linux) and generalize enumeration into a
platform-neutral interface.

- audio/mod.rs: move AudioDevice here (neutral home), gate pw_cli to
  cfg(unix), and re-export enumerate_audio_devices per-platform (pw_cli on
  unix, cpal_impl on windows). Also drop a now-stale "no-op stub" doc note.
- cpal_impl.rs: add enumerate_audio_devices() — iterate the cpal host's
  input + output devices into AudioDevice (name == description == the cpal
  friendly name, which is what resolve() matches target_node against, so a
  saved selection round-trips), sorted by description.
- pw_cli.rs: use super::AudioDevice instead of a local copy; parsing +
  tests unchanged.
- app/mod.rs: one-line import change; the device-picker logic is untouched.

Verified: shipped Linux state green (build --locked, clippy, 316/316,
pw_cli parse tests 6/6); the cpal enumerator compiles against real cpal via
the Linux/ALSA toggle. Runtime device listing on Windows is pending a real
host (M2/M3). WASAPI names are less stable than PipeWire node names, so a
saved device may not always round-trip (falls back to default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:28:12 -04:00
molluskandClaude Opus 4.8 47c58047ce Windows port Phase 1: real cpal/WASAPI audio backend
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Replace the Phase 0 no-op CpalBackend stub with a working cpal backend
(WASAPI on Windows), preserving the exact PipeWire AudioBackend contract
so the mixer/encoder/jitter pipeline is unchanged.

- Capture: input stream -> downmix to mono -> 960-sample (20ms) i16 frames
  -> tx, matching the encoder/jitter frame size.
- Playback: 200ms stereo ring prefilled to PLAYBACK_TARGET_SAMPLES; the
  output callback drains it (silence on underrun) while the owning thread
  feeds it from rx. ring_fill is the exact delta-maintained occupancy
  counter (fetch_add on push, fetch_sub on pop), preserving the clock-paced
  production design (not ringbuf's stale occupied_len).
- cpal::Stream is !Send, but AudioBackend is Send+Sync and shared via Arc,
  so each stream lives on its own owning thread (built/played/dropped
  there); the struct holds only the running flag + JoinHandle. stop()
  flips the flag and joins.
- Generic over F32/I16/U16 sample formats; device selected by name else
  default; requires a native 48kHz config (clear error otherwise, no
  resampling yet). Mirrors the PipeWire drain_loop and playout-health line.
- Cargo.toml: add cpal 0.15 under cfg(windows).

Verified by temporarily compiling cpal_impl against real cpal on Linux/ALSA:
build + clippy clean, 6/6 cpal_impl unit tests pass. Reverted to windows-only
gating; shipped Linux state green (316/316). Runtime/WASAPI end-to-end is
unverified and pending a Windows host (plan M2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:15:56 -04:00
molluskandClaude Opus 4.8 85b12a26c9 Windows port Phase 0: platform-select the audio backend
windows-build / windows-build (push) Has been cancelled
cargo-deny / cargo-deny (pull_request) Has been cancelled
windows-build / windows-build (pull_request) Has been cancelled
Make the tree compile for Windows without touching core logic, by
confining all Linux/PipeWire assumptions behind cfg gates and a single
platform-selected backend alias. No new dependencies — the cpal/WASAPI
backend lands in Phase 1; this ships a no-op stub.

- Cargo.toml: move pipewire + rfd(xdg-portal) under cfg(unix); add a
  cfg(windows) rfd using the Win32 dialog backend.
- audio: gate pipewire_impl to unix, add a cpal_impl stub for windows,
  and select between them via the new PlatformAudioBackend alias.
- core: use PlatformAudioBackend instead of the concrete PipeWireBackend.
- lib: gate the unix-only 0o600 log-file mode code (+ its test); Windows
  logs inherit the directory ACL.
- audio_probe: gate this PipeWire diagnostic to unix with a stub main.
- app: open URLs via rundll32 on windows, xdg-open on unix (shell-free).
- ci: add .gitea/workflows/windows-build.yml (M1) — build + lib tests for
  x86_64-pc-windows-msvc, with CMAKE_POLICY_VERSION_MINIMUM=3.5 for the
  vendored libopus build. Needs a windows act_runner to actually run.

Linux build/clippy/tests green (316/316). The Windows path is verified by
inspection only (no local Windows toolchain); CI is the real gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:47:13 -04:00
molluskandClaude Opus 4.8 e4767be210 ci: enforce cargo-deny supply-chain policy on push and PRs
Adds a Gitea Actions workflow that runs `cargo deny --locked check` on
every push to main and every PR, so the deny.toml policy (advisories,
bans, licenses, sources) is enforced automatically rather than by hand.

Runs on a locked tree so the pinned versions in Cargo.lock are what get
audited; a poisoned dependency release can't reach CI until Cargo.lock is
deliberately updated. cargo-deny is pinned to 0.19.9 via a prebuilt binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:00:36 -04:00
mollusk 10ee765ffd Merge pull request 'Security scan' (#1) from security-scan into main
Reviewed-on: #1
2026-06-18 19:57:56 +00:00
molluskandClaude Opus 4.8 3034c42f71 Add security review report for security-scan branch
Documents the focused security review of the protocol-versioning migration
and the cargo-deny policy addition. Result: no high-confidence vulnerabilities
— the versioned_topic XOR transform is entropy-preserving, signature binding
uses the raw topic_id consistently, and the ALPN/domain changes are
handshake-level compatibility only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:54:35 -04:00
molluskandClaude Opus 4.8 465c7ba2b0 Add cargo-deny supply-chain policy (deny.toml)
Supersede bare cargo-audit with an enforceable four-part policy, validated
against the current tree with cargo-deny 0.19.9 (advisories/bans/licenses/
sources all pass):

- advisories: deny vulnerabilities + yanked; ignore the two *unmaintained*
  warnings (paste RUSTSEC-2024-0436, audiopus_sys RUSTSEC-2026-0150) with
  rationale. Both are transitive and pinned via Cargo.lock, so a future
  malicious release can't reach us until a deliberate cargo update.
- sources: trust only crates.io; deny unknown registries and git sources
  (core anti-hijack control).
- bans: deny wildcard version reqs; warn on duplicate versions.
- licenses: permissive allow-list covering the current graph.

Mark peerspeak publish = false (it's an application, not a published
library): blocks accidental cargo publish and lets [licenses.private]
skip the missing-license check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 15:46:52 -04:00
molluskandClaude Opus 4.8 3ec09de87e Adopt versioning standard + migrate to versioned protocol planes (0.2.0)
Establishes VERSIONING.md: SemVer 0.x (MINOR = breaking wire change) for the
release version, and per-plane protocol versions enforced on the wire so
incompatible peers fail fast and legibly instead of via silent decode/signature
errors.

⚠️ BREAKING WIRE CHANGE — all peers must run >= 0.2.0 to interoperate (ALPNs and
gossip subscription topics changed). A pre-0.2.0 peer (e.g. an un-resynced
dopedart) can no longer connect, by design, and now fails at the handshake.

- New src/protocol.rs: single source of truth for AUDIO/FRIENDS/GOSSIP_PROTO,
  the derived ALPNs (peerspeak/audio/1, peerspeak/friends/1), GOSSIP_SIG_DOMAIN,
  and versioned_topic(). Unit tests assert ALPN/domain strings match their
  integer versions (no silent drift) + that topic namespacing is deterministic.
- Unified ALPNs: audio was b"peerspeak-audio" (unversioned, and duplicated in
  iroh_impl.rs + core/mod.rs) -> peerspeak/audio/1 from protocol.rs; friends
  re-exports protocol::FRIENDS_ALPN (was peerspeak/friends/0 -> /1).
- Gossip: subscribe to versioned_topic(ticket.topic_id) so different gossip
  versions never share a swarm; the raw topic_id stays the room identity and
  what signatures bind. GOSSIP_SIG_DOMAIN centralized into protocol.rs.
- Cargo.toml 0.1.0 -> 0.2.0.

316 lib tests / clippy --all-targets clean. VERSIONING.md documents the bump
rules, the "I changed X -> what do I bump" table, and a release checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 05:20:27 -04:00
mollusk 4b8fb92dc5 Merge codex-security-s8-audio-membership: gate inbound audio by live room membership (S8) 2026-06-18 04:41:41 -04:00
molluskandClaude Opus 4.8 1adf8a97bb S8 (Pass 2): wire grace-aware audio-membership admission
Closes S8 (High): inbound audio was authenticated by identity (remote_id) but
NOT by room membership, so a former member who knew a current member's endpoint
could reconnect on the audio ALPN and inject into / eavesdrop on the mix while
invisible in the roster. Now audio is admitted only for live gossip-roster
members (the senior+user-resolved GRACE-AWARE policy).

Transport (src/network/iroh_impl.rs):
- New per-session admitted_audio: HashSet<EndpointId> on Shared (internal state,
  no wire/serialization change). Cleared on disconnect_all.
- AudioRouter::accept consults audio_sender_admitted BEFORE ensure_supervisor —
  a non-member never gets a supervisor, sender handle, datagram reader, or
  outbound mix. Brief StdMutex check, released before the await (no RT lock).
- Pure apply_audio_admission_event(roster, peer, event) with AudioAdmissionEvent
  {RosterPresent insert, TransientDropGrace no-op, Remove}. Grace deliberately
  cannot ADD membership — it only preserves an already-admitted peer — so an
  unknown peer can't sneak in via a grace event. +3 lifecycle tests (on top of
  Pass-1's 4 predicate tests).
- admit/keep_for_reconnect_grace/remove/query methods for core to drive.

Core (src/core/mod.rs) — authority is core's VERIFIED gossip-roster events, not
transport connect/disconnect:
- PeerJoined / PeerUpdated: admit_audio_sender before connect_peer.
- PeerConnectionLost: keep_audio_sender_for_reconnect_grace (preserve through the
  existing RECONNECT_GRACE window — no audio cut on transient blips).
- gossip PeerLeft, transport ConnEvent::Left, grace-timer expiry: remove_audio_sender
  before disconnect_peer + jitter removal (removal-before-teardown bounds the
  in-flight-datagram race).
- datagram receiver: audio_sender_admitted gate before any jitter buffer (defense
  in depth against a datagram racing a removal). Mixer stays off the hot path.

Mid-join: a peer who dials audio before we've verified their signed Announce is
dropped (no "pending" admission, which would reintroduce the eavesdrop); their
reconnect loop recovers once the Announce admits them.

tests/transport_loopback.rs: admit both ends before connecting, mirroring the
production room-event order.

313 lib / clippy --all-targets / transport_loopback 4 / reconnect_eviction 6 /
release — all re-run green by the senior. Former-member-rejection + mid-join
recovery are verifiable only in a 2-machine call (senior's to run).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:41:37 -04:00
molluskandClaude Opus 4.8 10707152a3 S8 (design-first): pure audio_sender_admitted membership seam (unwired)
Design-first checkpoint for S8 (authorize inbound audio against live room
membership). Codex's design note (in the handoff task-report.md) establishes
the authoritative roster = gossip IrohGossipState.peers, NOT the audio
transport connection list, and recommends mirroring it into an audio-admission
snapshot consulted at AudioRouter::accept + datagram ingest.

This commit lands ONLY the pure decision seam + tests; wiring is deliberately
paused for a senior decision on the reconnect-grace policy (gossip drops a peer
from the roster on transient NeighborDown, but core keeps the audio supervisor
alive for RECONNECT_GRACE — a strict roster-only gate would cut audio on blips).

- audio_sender_admitted(remote, roster) -> bool (pub(crate), #[allow(dead_code)]).
- 4 tests: member admitted, stranger rejected, former member rejected after
  roster removal, mid-join peer rejected until authenticated Announce inserts it.
- No behavior change: accept/datagram/mixer paths untouched. S8 remains OPEN.

310 lib tests / clippy --all-targets / release all green (re-run by senior).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 04:24:01 -04:00
mollusk f2e72624f7 Merge codex-security-s11-presence-discovery: honest presence/discovery state on apply failure (S11) 2026-06-18 03:32:42 -04:00
molluskandClaude Opus 4.8 319d0c5e29 S11: make presence/discovery state honest on apply failure
SetPresenceMode and the Discoverable time-box auto-revert both committed
the new presence_mode to local state *before* apply_discovery and only
log_msg'd on failure, so a failed off-transition could leave the n0 DNS
PkarrPublisher running while the UI showed not-discoverable (privacy /
reality mismatch — security-open-handoff S11, from the W7 P7 review).

Fix (Codex, senior-reviewed):
- discovery.rs: pure resolve_presence_transition(prev, requested, apply_ok)
  -> (mode, Option<error>) seam — on failure keep the previous (truthful)
  mode and surface a message. +4 unit tests.
- apply_discovery now builds the replacement resolver/publisher services
  BEFORE clearing the service set, so a builder failure leaves the old
  posture fully intact (no partial state) — "keep previous mode" is then
  provably truthful.
- Both SetPresenceMode and the time-box revert apply discovery first, route
  through the seam, commit only the truthful mode, and surface failures via
  the existing PresenceModeReverted (corrects the picker) + UiEvent::Error.
  No new wire/event variant.
- A failed off-transition stays Discoverable and arms a 60s retry
  (DISCOVERY_REVERT_RETRY) so the beacon never stands stuck.
- P3 notes documented: relay-resolve exposes n0 query metadata (by design);
  no explicit iroh unpublish API exists, so the bounded ~30s pkarr TTL
  linger is documented, not behavior-changed; DirectOnly stays no-n0.

306 lib tests / clippy --all-targets / release all green (re-run by senior).
Runtime publish-stop behavior still wants a 2-machine / packet-capture check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 03:32:38 -04:00
mollusk d56c2c90b2 Merge codex-security-hardening: S10 log redaction + T1/T2/T5/T6/T7 trust-boundary fixes 2026-06-18 03:05:56 -04:00
molluskandClaude Opus 4.8 5086e86bd2 Security hardening: log redaction + 5 trust-boundary fixes (S10, T1/T2/T5/T6/T7)
Codex (gpt-5.5) implementer branch, senior-reviewed.

- S10 (High): redact capabilities/chat from logs; create log 0600 + chmod
  existing; rotate at 5 MiB. New short_id/short_bytes_hex/redact_for_log seams.
- T1 (P2): friends-ALPN authorizes (handler(from)) before reading any peer
  bytes; unauthorized conns closed pre-read (DoS relief).
- T2 (P2): per-(author,kind) replay gate on state-changing gossip (Announce/
  Leave) only; Chat bypasses it, preserving the S2 no-monotonic-ts decision.
- T5 (P2): cap inbound Opus datagrams at 4 + MAX_OPUS_PAYLOAD (4000).
- T6 (P2): bind friend-Pong room ticket host to the authenticated responder
  (interpret_pong/probe now thread the remote id) — blocks Join-button
  redirect/phishing. Non-regressive given the W7 P3 restamp design.
- T7 (P3): sanitize_ticket caps/validates PeerState.sharing at gossip ingest
  so invalid offers never render a Watch button.

302 lib tests pass (was 291), clippy --all-targets clean, release builds.
Tests-green only; DoS relief + 2-machine replay/redirect behavior want a
field test. W7 P7 (n0 DNS privacy) reviewed read-only — findings to triage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 03:05:56 -04:00
molluskandClaude Opus 4.8 54780fa73b Remove Codex task-report.md from repo root
Transient implementer handoff note; its content is preserved in the
handoff docs. Not repo content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:51:26 -04:00
molluskandClaude Opus 4.8 b1aa751a84 Merge codex-home-empty-state-ui: focused empty-state Home layout
Fresh/empty Home keeps Create/Join dominant via a tested home_layout_mode
seam (FocusedEmpty / ThreeColumn / Stacked); once Recents or Friends has
content the normal three-card layout returns. Quieter empty-state cards.

Conflict resolution:
- HomeLayoutMode enum/fn coexists with the SettingsCategory enum (separate
  derives); both unit tests kept.
- Top bar: the wishlist Hotkeys-info button is always shown; the room-layout
  button is hidden on Home (home-empty's intent) and shown in Room. The
  auto-merge had wedged the info tooltip into the conditional as a stray
  expression — split into separate info_button / layout_button bindings.

291 lib tests pass, clippy --all-targets clean, bin builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:36:19 -04:00
molluskandClaude Opus 4.8 9efab491c7 Merge codex-settings-category-nav: category-navigated Settings
Settings is split into navigable categories (left sidebar ≥820px wide,
pick_list dropdown below) instead of one long scroll. Integrated with the
wishlist branch's hotkey editor by giving it its own "Hotkeys" category
(7 categories total: Audio, Hotkeys, Recording, Profile, Appearance,
Network, Notifications).

Conflict resolution: the wishlist branch had inserted a Hotkeys section
into the old long-scroll between Microphone and Recording; relocated it
into a dedicated SettingsCategory::Hotkeys arm and updated the category
stability test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:32:17 -04:00
molluskandClaude Opus 4.8 f3f399a748 Merge codex-wishlist-audio-hotkeys: per-peer EQ (W2), spatial pan + stereo bus (W1), focused hotkeys (W5), + A21/A22/A14 fixes
W2: src/audio/eq.rs 3-band RBJ biquad EQ, per-peer, flat=bypass.
W1: src/audio/pan.rs constant-power pan; mixer/playback/recorder converted
    to a stereo bus, bit-for-bit dual-mono at pan=0.
W5: src/hotkeys.rs config-backed focused hotkey map + Settings editor + info popup.
A21: jitter resets on large seq discontinuities (sender restart / far jump).
A22: WAV writer guards RIFF/data size overflow.
A14: orderly window-close shutdown (finalize recordings, leave room, close net).
W3 (PipeWire routing) intentionally left as a design note.

No new deps; no wire/serialization changes. Tests-green only; audio + 2-machine
field verification pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:29:45 -04:00
molluskandClaude Opus 4.8 1afdccbefe Merge codex-security-s9: bind gossip Announce addr to authenticated author (S9)
Reject signed Announce(PeerState) whose embedded state.addr.id does not
match the authenticated payload.author, closing the residual S2 gap where
a valid signer could advertise another node's EndpointAddr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:29:34 -04:00
mollusk 7724da73b8 Refine home empty-state layout 2026-06-17 17:06:45 -04:00
mollusk 92c9d585b8 Add settings category navigation 2026-06-17 16:44:58 -04:00
mollusk 8982df364e Fix gossip announce address binding 2026-06-17 16:17:03 -04:00
26 changed files with 3373 additions and 545 deletions
+34
View File
@@ -0,0 +1,34 @@
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
+85
View File
@@ -0,0 +1,85 @@
name: windows-build
# Milestone M1 of the Windows port (see docs/handoff windows-migration-plan):
# prove the tree compiles for `x86_64-pc-windows-msvc` and the unit tests pass.
# The audio backend is the Phase 0 `CpalBackend` stub for now — this job guards
# the *compile* boundary (cfg gating, platform deps, the PlatformAudioBackend
# alias) so a Unix-only assumption can't sneak back in and break Windows.
#
# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the
# `windows-latest` label (the Linux `cargo-deny` job's container approach does
# NOT apply here — Windows jobs run on the host, not a Linux container). If your
# runner advertises a different label, change `runs-on` below. Until a Windows
# runner exists this workflow is simply skipped/queued, not a failure of the
# Linux CI.
#
# BUILD-HOST REQUIREMENTS (validated by the opus spike, see
# peerspeak-windows-opus-spike.md):
# - MSVC C toolchain (Visual Studio Build Tools) — to compile vendored libopus.
# - CMake on PATH — `audiopus_sys` builds libopus from source via cmake.
# - CMAKE_POLICY_VERSION_MINIMUM=3.5 (set below) — the vendored libopus declares
# an ancient `cmake_minimum_required` that CMake >= 4.0 refuses without it.
# GitHub-hosted `windows-latest` images ship MSVC + CMake; a self-hosted runner
# must provide both.
on:
push:
# `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:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# The vendored libopus (audiopus_sys -> cmake) uses cmake_minimum_required < 3.5,
# which CMake 4.x rejects unless this is set. See the opus spike report.
CMAKE_POLICY_VERSION_MINIMUM: "3.5"
jobs:
windows-build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust (MSVC, pinned to repo toolchain if present)
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
components: clippy
- name: Show toolchain + build prerequisites
shell: bash
run: |
set -euo pipefail
rustc --version
cargo --version
# libopus is built from source via cmake; fail early with a clear
# message if the runner lacks it rather than deep in the opus build.
if ! command -v cmake >/dev/null 2>&1; then
echo "::error::cmake not found on PATH. The opus crate builds libopus from source via cmake; install CMake on this runner."
exit 1
fi
cmake --version
# Build on a *locked* tree so the pinned, vetted Cargo.lock versions are what
# get compiled — same supply-chain stance as the cargo-deny job.
- name: Build (all targets, msvc)
run: cargo build --all-targets --locked --target x86_64-pc-windows-msvc
# Unit (lib) tests only: the `transport_loopback` integration tests stand up
# real iroh/QUIC endpoints and need working loopback networking, which isn't
# guaranteed on a CI runner. Add `--tests` here once a networked Windows
# runner is confirmed.
- name: Unit tests (lib, msvc)
run: cargo test --lib --locked --target x86_64-pc-windows-msvc
# Informational for now (not `-D warnings`): the Windows tree may surface
# platform-specific lints we haven't triaged. Tighten to deny-warnings once
# it's clean.
- name: Clippy (msvc)
run: cargo clippy --all-targets --locked --target x86_64-pc-windows-msvc
Generated
+269 -25
View File
@@ -105,6 +105,28 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "alsa"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43"
dependencies = [
"alsa-sys",
"bitflags 2.11.1",
"cfg-if",
"libc",
]
[[package]]
name = "alsa-sys"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527"
dependencies = [
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "android-activity" name = "android-activity"
version = "0.6.1" version = "0.6.1"
@@ -114,12 +136,12 @@ dependencies = [
"android-properties", "android-properties",
"bitflags 2.11.1", "bitflags 2.11.1",
"cc", "cc",
"jni", "jni 0.22.4",
"libc", "libc",
"log", "log",
"ndk", "ndk 0.9.0",
"ndk-context", "ndk-context",
"ndk-sys", "ndk-sys 0.6.0+11769913",
"num_enum", "num_enum",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -712,6 +734,12 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]] [[package]]
name = "cexpr" name = "cexpr"
version = "0.6.0" version = "0.6.0"
@@ -1002,6 +1030,26 @@ dependencies = [
"libm", "libm",
] ]
[[package]]
name = "coreaudio-rs"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace"
dependencies = [
"bitflags 1.3.2",
"core-foundation-sys",
"coreaudio-sys",
]
[[package]]
name = "coreaudio-sys"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953"
dependencies = [
"bindgen",
]
[[package]] [[package]]
name = "cosmic-text" name = "cosmic-text"
version = "0.15.0" version = "0.15.0"
@@ -1026,6 +1074,29 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "cpal"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
dependencies = [
"alsa",
"core-foundation-sys",
"coreaudio-rs",
"dasp_sample",
"jni 0.21.1",
"js-sys",
"libc",
"mach2",
"ndk 0.8.0",
"ndk-context",
"oboe",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows 0.54.0",
]
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@@ -1228,6 +1299,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.0" version = "2.11.0"
@@ -2227,7 +2304,7 @@ dependencies = [
"http", "http",
"idna", "idna",
"ipnet", "ipnet",
"jni", "jni 0.22.4",
"rand 0.10.1", "rand 0.10.1",
"rustls", "rustls",
"thiserror 2.0.18", "thiserror 2.0.18",
@@ -2247,7 +2324,7 @@ dependencies = [
"data-encoding", "data-encoding",
"idna", "idna",
"ipnet", "ipnet",
"jni", "jni 0.22.4",
"once_cell", "once_cell",
"prefix-trie", "prefix-trie",
"rand 0.10.1", "rand 0.10.1",
@@ -2270,7 +2347,7 @@ dependencies = [
"hickory-proto", "hickory-proto",
"ipconfig", "ipconfig",
"ipnet", "ipnet",
"jni", "jni 0.22.4",
"moka", "moka",
"ndk-context", "ndk-context",
"once_cell", "once_cell",
@@ -3102,6 +3179,22 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys 0.3.1",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -3463,6 +3556,15 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -3596,7 +3698,7 @@ dependencies = [
"dispatch", "dispatch",
"futures-channel", "futures-channel",
"futures-lite", "futures-lite",
"jni", "jni 0.22.4",
"ndk-context", "ndk-context",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
@@ -3695,6 +3797,20 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "ndk"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7"
dependencies = [
"bitflags 2.11.1",
"jni-sys 0.3.1",
"log",
"ndk-sys 0.5.0+25.2.9519653",
"num_enum",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -3704,7 +3820,7 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"jni-sys 0.3.1", "jni-sys 0.3.1",
"log", "log",
"ndk-sys", "ndk-sys 0.6.0+11769913",
"num_enum", "num_enum",
"raw-window-handle", "raw-window-handle",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -3716,6 +3832,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "ndk-sys"
version = "0.5.0+25.2.9519653"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
dependencies = [
"jni-sys 0.3.1",
]
[[package]] [[package]]
name = "ndk-sys" name = "ndk-sys"
version = "0.6.0+11769913" version = "0.6.0+11769913"
@@ -4466,6 +4591,29 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "oboe"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
"jni 0.21.1",
"ndk 0.8.0",
"ndk-context",
"num-derive",
"num-traits",
"oboe-sys",
]
[[package]]
name = "oboe-sys"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -4594,12 +4742,13 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "peerspeak" name = "peerspeak"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
"base64", "base64",
"bytes", "bytes",
"cpal",
"dirs", "dirs",
"iced", "iced",
"image", "image",
@@ -5436,7 +5585,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
dependencies = [ dependencies = [
"core-foundation 0.10.1", "core-foundation 0.10.1",
"core-foundation-sys", "core-foundation-sys",
"jni", "jni 0.22.4",
"log", "log",
"once_cell", "once_cell",
"rustls", "rustls",
@@ -5877,7 +6026,7 @@ dependencies = [
"fastrand", "fastrand",
"js-sys", "js-sys",
"memmap2", "memmap2",
"ndk", "ndk 0.9.0",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -7162,7 +7311,7 @@ dependencies = [
"log", "log",
"metal", "metal",
"naga", "naga",
"ndk-sys", "ndk-sys 0.6.0+11769913",
"objc", "objc",
"once_cell", "once_cell",
"ordered-float", "ordered-float",
@@ -7247,6 +7396,16 @@ dependencies = [
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.58.0" version = "0.58.0"
@@ -7254,7 +7413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [ dependencies = [
"windows-core 0.58.0", "windows-core 0.58.0",
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7278,6 +7437,16 @@ dependencies = [
"windows-core 0.62.2", "windows-core 0.62.2",
] ]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.58.0" version = "0.58.0"
@@ -7288,7 +7457,7 @@ dependencies = [
"windows-interface 0.58.0", "windows-interface 0.58.0",
"windows-result 0.2.0", "windows-result 0.2.0",
"windows-strings 0.1.0", "windows-strings 0.1.0",
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7386,13 +7555,22 @@ dependencies = [
"windows-strings 0.5.1", "windows-strings 0.5.1",
] ]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.2.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7411,7 +7589,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [ dependencies = [
"windows-result 0.2.0", "windows-result 0.2.0",
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7423,13 +7601,22 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets 0.42.2",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [ dependencies = [
"windows-targets", "windows-targets 0.52.6",
] ]
[[package]] [[package]]
@@ -7441,20 +7628,35 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm 0.42.2",
"windows_aarch64_msvc 0.42.2",
"windows_i686_gnu 0.42.2",
"windows_i686_msvc 0.42.2",
"windows_x86_64_gnu 0.42.2",
"windows_x86_64_gnullvm 0.42.2",
"windows_x86_64_msvc 0.42.2",
]
[[package]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [ dependencies = [
"windows_aarch64_gnullvm", "windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc", "windows_aarch64_msvc 0.52.6",
"windows_i686_gnu", "windows_i686_gnu 0.52.6",
"windows_i686_gnullvm", "windows_i686_gnullvm",
"windows_i686_msvc", "windows_i686_msvc 0.52.6",
"windows_x86_64_gnu", "windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm", "windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc", "windows_x86_64_msvc 0.52.6",
] ]
[[package]] [[package]]
@@ -7466,18 +7668,36 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7490,24 +7710,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7536,7 +7780,7 @@ dependencies = [
"js-sys", "js-sys",
"libc", "libc",
"memmap2", "memmap2",
"ndk", "ndk 0.9.0",
"objc2 0.5.2", "objc2 0.5.2",
"objc2-app-kit 0.2.2", "objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
+27 -8
View File
@@ -1,7 +1,10 @@
[package] [package]
name = "peerspeak" name = "peerspeak"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
# Application crate, not a crates.io library — refuse `cargo publish` and let
# cargo-deny's [licenses.private] skip the missing-license check.
publish = false
[lib] [lib]
name = "peerspeak" name = "peerspeak"
@@ -27,17 +30,12 @@ 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 = ["canvas", "image"] }
# 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) and a native file picker (xdg-portal backend, no GTK). # the codec surface small). The matching native file picker (`rfd`) is platform-
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
iroh = "1.0.0-rc.0" iroh = "1.0.0-rc.0"
iroh-gossip = "0.99.0" iroh-gossip = "0.99.0"
opus = "0.3.1" opus = "0.3.1"
# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by
# the playback RT callback to fill exactly what the device asks for instead of
# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024
# hardware). The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
rand = "0.10.1" rand = "0.10.1"
ringbuf = "0.5.0" ringbuf = "0.5.0"
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
@@ -45,3 +43,24 @@ 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"
# --- Platform-specific dependencies -----------------------------------------
# Audio and the native file-picker backends differ per OS. Everything else in the
# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see
# `src/audio/mod.rs`), so platform selection is confined to these few lines.
[target.'cfg(target_os = "linux")'.dependencies]
# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle
# quantum), used by the playback RT callback to fill exactly what the device asks
# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware).
# The field has existed in libpipewire since 0.3.49 (2022).
pipewire = { version = "0.9", features = ["v0_3_49"] }
# Native file picker via the XDG desktop portal (no GTK) on Linux.
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
[target.'cfg(windows)'.dependencies]
# Native file picker using the built-in Win32 dialog backend on Windows.
rfd = { version = "0.17", default-features = false }
# Windows audio backend: cpal drives WASAPI for capture/playback behind the
# AudioBackend trait (src/audio/cpal_impl.rs). The Linux counterpart is pipewire.
cpal = "0.15"
+51
View File
@@ -0,0 +1,51 @@
# Security Review: `security-scan` branch (PeerSpeak)
_Date: 2026-06-18_
**Scope:** Protocol-versioning migration (`src/protocol.rs`, `versioned_topic`,
ALPN/domain centralization, gossip topic namespacing) and the `deny.toml`
supply-chain policy addition.
## Result: No high-confidence security vulnerabilities found.
Each plausible attack surface introduced by this branch was investigated and
confirmed safe:
### 1. `versioned_topic` XOR transform — topic secrecy preserved
`src/protocol.rs:46`, used at `src/network/gossip.rs:255`
The room `topic_id` is a uniformly random 32-byte secret (`rand::random()`,
`src/core/mod.rs:1012`) acting as the room capability. XOR-ing it with the public
constant `GOSSIP_PROTO.to_le_bytes()` cyclically is **bijective and
entropy-preserving** — the result is still uniformly random; no byte becomes
predictable and no entropy is lost. The room secret is no more recoverable by an
observer than before the change (previously the raw `topic_id` was the on-wire
topic; now it's a trivial public XOR of it). Bijectivity also preserves room
distinctness, so isolation is not weakened. **Not a vulnerability.**
### 2. Signature topic-binding — no raw/versioned confusion
`src/network/gossip.rs`
`active_topic_bytes` stores the **raw** `ticket.topic_id` (line 293), and both
`sign_gossip` and `verify_gossip` bind against that raw value. Only the
*subscribed* swarm topic (line 255) uses the versioned value. There is one swarm
per join and every peer signs/verifies against the same raw topic, so no second
topic exists to enable a raw↔versioned replay/confusion attack. Code matches
VERSIONING.md's claim. **Not a vulnerability.**
### 3. `GOSSIP_SIG_DOMAIN` — moved verbatim
Value identical (`"peerspeak-gossip-v1"`, `src/protocol.rs:34`); cross-version
cryptographic domain separation preserved. **Not a vulnerability.**
### 4. ALPN changes — handshake compatibility only
Audio `peerspeak-audio``peerspeak/audio/1`, friends `/0``/1`. No security
check keys off the old ALPN strings (audio admission is gated by live room
membership per S8, not the ALPN literal); no residual references to old strings
in non-test code. **Not a vulnerability.**
### 5. `deny.toml`
Ignores only two *unmaintained* advisories (`RUSTSEC-2024-0436`,
`RUSTSEC-2026-0150`) on compile-time/FFI-only crates — documented, and dependency
advisories are out of scope. **Not a vulnerability.**
The versioning migration is a clean, security-preserving change.
+139
View File
@@ -0,0 +1,139 @@
# PeerSpeak Versioning Standard
PeerSpeak is a full-mesh P2P voice app. Its "API contract" is not a library
surface — it is the **wire protocol** two nodes use to talk. So versioning here
tracks one question above all others:
> **Can a node on build X talk to a node on build Y?**
There are two distinct version layers. Keep them straight.
---
## Layer 1 — Release version (`Cargo.toml`)
The human-facing label you put on a build ("install this one").
**Scheme: SemVer, pre-1.0 (`0.MINOR.PATCH`).**
While we are pre-1.0 (friends-only, no stability promise yet):
| Change | Bump | Example |
| --- | --- | --- |
| **Breaking wire/protocol change** — peers on the old build can no longer interoperate; *everyone must update* | **MINOR** | `0.4.2 → 0.5.0` |
| Compatible change — bug fix, internal refactor, or a feature that does **not** change the wire (UI, local-only behavior, additive logic that old peers ignore safely) | **PATCH** | `0.4.2 → 0.4.3` |
- **Reaching `1.0.0`:** when PeerSpeak is first shared beyond the trusted-friends
circle (a "public" release), and we are willing to commit to wire stability.
After 1.0, MAJOR = wire break, MINOR = compatible feature, PATCH = fix (normal
SemVer).
- Bump `version` in `Cargo.toml` as part of the change that warrants it, in the
same commit. The number in `Cargo.toml` is the source of truth; surface it in
the UI (e.g. an About/Settings line) so a user can read their build.
**Rule of thumb:** if you find yourself writing "all peers must rebuild" or
"breaking gossip wire change" in a commit message (as S2 and W4 did), that is a
**MINOR** bump, and it must also bump the relevant protocol version in Layer 2.
---
## Layer 2 — Protocol compatibility (the one that actually breaks calls)
Wire incompatibility must **fail fast and legibly** — never as a silent
signature/decode error that looks like a bug or an attack. We achieve this by
embedding a protocol version into each transport plane, so incompatible peers
are rejected at connect/subscribe time instead of mid-conversation.
PeerSpeak has **three independent planes**, each versioned **separately** — bump
only the plane whose wire format actually changed (audio rarely changes; gossip
changes often; they must not be forced to bump together).
### ALPN naming convention
All peerspeak ALPNs use the form **`peerspeak/<plane>/<N>`** where `<N>` is that
plane's protocol version (an integer, starts at `1`). iroh refuses a connection
whose ALPN does not match exactly, so two peers on different `<N>` for a plane
simply cannot open that connection → we map that to a clean "peer is running an
incompatible version" instead of garbage.
| Plane | ALPN / mechanism | Bump when… |
| --- | --- | --- |
| **Audio** | ALPN `peerspeak/audio/<N>` | the Opus/datagram framing, sequencing, or audio-handshake changes |
| **Friends/presence** | ALPN `peerspeak/friends/<N>` | the `ControlMsg` / presence ping-pong shape changes |
| **Gossip** | *(see below — cannot use a custom ALPN)* | `GossipPayload` / `GossipMessage` / `PeerState` shape, signing, or freshness rules change |
### Gossip is special
The gossip plane runs over **iroh-gossip's own `GOSSIP_ALPN`**, which we do not
control, so we cannot version it via the ALPN. Instead, the gossip protocol
version is bound in **two** places:
1. **Topic namespacing (primary, fail-fast):** the room's `topic_id` is a random
32 bytes carried in the ticket, but the topic we actually *subscribe* to is
`protocol::versioned_topic(topic_id)` — a deterministic, dependency-free
transform that folds `GOSSIP_PROTO` into the bytes. Peers on different gossip
versions therefore derive **different subscription topics from the same ticket**
and never share a swarm — the same isolation a versioned ALPN gives the other
planes. The ticket format and the room identity (`topic_id`) are unchanged; only
the subscribed topic is namespaced. (The transform is for *isolation*, not
security — cryptographic separation is the signature domain below.)
2. **Signature domain (cryptographic separation):** the signing domain string
(`peerspeak-gossip-v<N>`, bound into every signed payload) carries the version,
so two versions that somehow met on a topic would fail each other's verification
rather than misread it.
Bumping the gossip version = bump `protocol::GOSSIP_PROTO` (drives
`versioned_topic`) **and** `protocol::GOSSIP_SIG_DOMAIN` together (a unit test in
`protocol.rs` asserts the domain string matches `GOSSIP_PROTO`, so they can't drift).
### Single source of truth for protocol versions
All protocol versions, ALPNs, the gossip signature domain, and `versioned_topic`
live in **`src/protocol.rs`**. Every call site derives from there (e.g.
`crate::protocol::AUDIO_ALPN`); **never hand-write an ALPN literal inline.** A
unit test asserts each ALPN/domain string matches its integer version so a bump
can't half-apply.
---
## "I changed X — what do I bump?" (quick reference)
| You changed… | Layer 2 (plane version) | Layer 1 (`Cargo.toml`) |
| --- | --- | --- |
| Opus framing / audio datagram layout | `peerspeak/audio/N``N+1` | MINOR |
| `ControlMsg` / presence shape | `peerspeak/friends/N``N+1` | MINOR |
| `GossipPayload`/`PeerState`/signing | `GOSSIP_PROTO_VERSION` + sig domain → next | MINOR |
| UI, local config, recording, a fix that doesn't touch any wire | nothing | PATCH |
| An *additive* gossip field that old peers safely ignore | judgement call — if old peers misbehave without it, treat as breaking (MINOR + gossip bump); if truly ignorable, PATCH | PATCH or MINOR |
When in doubt about "is this additive-safe?", assume **breaking** and bump. A
false MINOR bump costs a coordinated rebuild; a false PATCH costs silent broken
calls in the field.
---
## Release checklist (per build handed to anyone)
1. Decide MINOR vs PATCH from the table above; bump `Cargo.toml`.
2. If MINOR for a wire reason, confirm the matching Layer-2 plane version(s) were
bumped in the same change.
3. Note the version + "breaking?" in the commit / handoff.
4. Tag the commit (`v0.x.y`) so a given binary maps to a known commit.
5. Rebuild **every** peer that must interoperate (e.g. dopedart, staged friend
releases) when the bump was a MINOR/wire break.
---
## Current baseline (standard adopted + migrated, 2026-06-18, `0.2.0`)
- `Cargo.toml`: **`0.2.0`** — the MINOR bump for the (deliberately breaking)
migration to this standard. **All peers must run ≥ `0.2.0` to interoperate**
(the ALPNs and gossip topics changed); the pre-standard `0.1.0`-era build
(e.g. an un-resynced dopedart) cannot talk to a `0.2.0` peer — by design, and it
now fails cleanly at the handshake instead of silently.
- Protocol versions (all at `1`): `peerspeak/audio/1`, `peerspeak/friends/1`,
gossip `peerspeak-gossip-v1` + `versioned_topic`. All sourced from
`src/protocol.rs`.
- **Remaining nicety (not blocking):** surface `env!("CARGO_PKG_VERSION")` in the
UI (an About/Settings line) and/or log it at startup, so a running build is
self-identifying in the field. Small follow-up.
+88
View File
@@ -0,0 +1,88 @@
# cargo-deny policy for peerspeak
#
# Supersedes a bare `cargo audit` run. Enforce with:
# cargo install cargo-deny --locked
# cargo deny check
#
# In CI, run `cargo deny check` on a locked tree so the pinned, vetted
# versions in Cargo.lock are what actually get audited.
# ---------------------------------------------------------------------------
# Advisories: RustSec database. Vulnerabilities and yanked crates are denied
# by default. The two `ignore` entries below are *unmaintained* warnings only
# (no known exploit); they are deep transitive deps we cannot remove. Pinning
# them via Cargo.lock is our real protection — a future malicious release does
# not reach us until we deliberately `cargo update`, so each update is a review
# checkpoint. Revisit these if either advisory is upgraded to a vulnerability.
# ---------------------------------------------------------------------------
[advisories]
ignore = [
# paste: unmaintained, compile-time proc-macro only (zero runtime surface),
# transitive via iroh/netdev/netlink and rav1e/image/iced. Maintained fork
# `pastey` is already in the tree; stragglers will follow upstream.
"RUSTSEC-2024-0436",
# audiopus_sys: unmaintained FFI bindings to the stable libopus C library,
# pulled in via our direct `opus 0.3.1` dep. No drop-in replacement.
"RUSTSEC-2026-0150",
]
# ---------------------------------------------------------------------------
# Bans: shape of the dependency graph.
# ---------------------------------------------------------------------------
[bans]
# Multiple versions of the same crate bloat the build; warn rather than fail
# since transitive graphs (iroh, iced) routinely carry duplicates we can't fix.
multiple-versions = "warn"
# Wildcard ("*") version requirements are a supply-chain footgun: they accept
# any future release, defeating the lockfile-as-review-checkpoint model.
wildcards = "deny"
# ...but our own intra-repo path deps may use "*"; don't penalize those.
allow-wildcard-paths = true
# Crates that may never appear in the graph. Add a maintained replacement's
# predecessor here once you've migrated off it, to prevent regressions.
deny = []
# ---------------------------------------------------------------------------
# Sources: where crates are allowed to come from. This is the core anti-hijack
# control — only the official crates.io registry is trusted; arbitrary git
# sources (a common vector for slipping in unaudited code) are rejected.
# ---------------------------------------------------------------------------
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
# allow-git = [] # add a specific, pinned git repo here only if ever needed
# ---------------------------------------------------------------------------
# Licenses: permissive set covering the current graph. If `cargo deny check`
# reports an unmatched license, vet it and add the SPDX id here (or add a
# per-crate entry under [licenses.exceptions]) rather than widening blindly.
# ---------------------------------------------------------------------------
[licenses]
allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"BSD-3-Clause",
"ISC",
"Zlib",
"MPL-2.0",
"Unicode-3.0",
"Unicode-DFS-2016",
"CC0-1.0",
"0BSD",
"Unlicense",
"BSL-1.0",
"NCSA", # University of Illinois/NCSA — BSD-like permissive
"CDLA-Permissive-2.0", # Community Data License Agreement, permissive
]
confidence-threshold = 0.8
exceptions = []
# peerspeak itself has no `license` field and is not published, so skip the
# "unlicensed" check for our own (private) crate. Add a license to Cargo.toml
# if/when this is ever published.
[licenses.private]
ignore = true
+77
View File
@@ -0,0 +1,77 @@
# PeerSpeak on Windows
Current status: the Windows port cross-compiles to `x86_64-pc-windows-gnu` and the `.exe`
launches under Wine. A real Windows/WASAPI host is still needed for the final audio-device
checks listed below.
## What works today
| Area | Status |
|---|---|
| GUI | Iced/wgpu builds and renders under Wine. |
| Networking | Iroh QUIC transport and gossip compile on Windows. |
| Audio backend | `cpal` drives WASAPI capture/playback behind `AudioBackend`. |
| Codec | Opus remains 48 kHz mono, 20 ms frames. |
| Identity | `ring` identity generation/load is platform-neutral. |
| Chimes | Windows uses PowerShell `System.Media.SoundPlayer` for WAV playback. |
Windows paths are resolved through `dirs`:
- Config: `%APPDATA%\peerspeak\config.json`
- Identity: `%APPDATA%\peerspeak\identity.key`
- Log: `%LOCALAPPDATA%\peerspeak\peerspeak.log`
## Building
### Native Windows
Install MSVC Build Tools and CMake, then build normally:
```powershell
cargo build --release
```
If CMake is 4.x or newer, the vendored `opus`/`libopus` build may need:
```powershell
$env:CMAKE_POLICY_VERSION_MINIMUM = "3.5"
cargo build --release
```
### Cross-compile from Linux
The current dev path cross-compiles from an Arch environment to the GNU Windows target:
```sh
rustup target add x86_64-pc-windows-gnu
sudo pacman -S mingw-w64-gcc cmake
CMAKE_POLICY_VERSION_MINIMUM=3.5 cargo build --release --target x86_64-pc-windows-gnu --bin peerspeak
```
Wine is useful for launch/render smoke tests, but it is not a substitute for a real
Windows audio-device pass. The deeper migration plan (phases, decisions, the opus build
spike) lives in the maintainer's handoff docs, outside the repo.
## First run and networking
Expect a Windows Firewall prompt the first time the app opens network sockets. Allow it:
PeerSpeak uses UDP for QUIC, plus relay traffic when direct NAT traversal is not available.
The default network mode keeps the n0 relay available for NAT traversal without publishing
presence to n0 DNS. Direct peer-to-peer paths may work when both networks allow them; relayed
connections are expected and valid.
## Known gaps
| Item | Status |
|---|---|
| Echo cancellation | Linux-only PipeWire feature. The Windows UI shows it disabled as unavailable. |
| Screen share | Requires a Windows `pixelpass.exe` on `PATH` or a configured override. |
| Chimes | Now routed through Windows `SoundPlayer`; needs a real Windows host to audibly verify. |
| Resampling/device format | Open. Devices must support 48 kHz, and output must support stereo; a 44.1 kHz-only/default device currently errors instead of playing. |
| Device persistence | Open. WASAPI friendly names may duplicate or change across driver/profile changes. |
| Playback pacing | Open. The fixed playback target under WASAPI shared mode still needs real-hardware verification. |
Before calling Windows support done, verify a real Windows machine can create/join a room,
capture mic audio, hear remote audio, select devices, restart with selections preserved, and
play notification chimes.
+470 -183
View File
@@ -2,7 +2,7 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
use crate::network::PeerState; use crate::network::PeerState;
use crate::notify::{self, Sound}; use crate::notify::{self, Sound};
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN}; use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
use crate::audio::pw_cli::{AudioDevice, enumerate_audio_devices}; use crate::audio::{AudioDevice, enumerate_audio_devices};
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout}; use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
@@ -32,6 +32,78 @@ pub enum Screen {
Settings, Settings,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsCategory {
Audio,
Hotkeys,
Recording,
Profile,
Appearance,
Network,
Notifications,
}
impl SettingsCategory {
const ALL: [SettingsCategory; 7] = [
SettingsCategory::Audio,
SettingsCategory::Hotkeys,
SettingsCategory::Recording,
SettingsCategory::Profile,
SettingsCategory::Appearance,
SettingsCategory::Network,
SettingsCategory::Notifications,
];
fn label(self) -> &'static str {
match self {
SettingsCategory::Audio => "Audio",
SettingsCategory::Hotkeys => "Hotkeys",
SettingsCategory::Recording => "Recording",
SettingsCategory::Profile => "Profile",
SettingsCategory::Appearance => "Appearance",
SettingsCategory::Network => "Network",
SettingsCategory::Notifications => "Notifications",
}
}
fn hint(self) -> &'static str {
match self {
SettingsCategory::Audio => "Devices, mic gate, echo",
SettingsCategory::Hotkeys => "Focused keyboard shortcuts",
SettingsCategory::Recording => "Mixed and stem capture",
SettingsCategory::Profile => "Avatar and identity",
SettingsCategory::Appearance => "Layout and theme",
SettingsCategory::Network => "Relay and privacy mode",
SettingsCategory::Notifications => "Chimes and sounds",
}
}
}
impl std::fmt::Display for SettingsCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HomeLayoutMode {
FocusedEmpty,
ThreeColumn,
Stacked,
}
fn home_layout_mode(width: f32, has_recents: bool, has_friends: bool) -> HomeLayoutMode {
if width < 900.0 {
HomeLayoutMode::Stacked
} else if !has_recents && !has_friends {
HomeLayoutMode::FocusedEmpty
} else if width >= 1280.0 {
HomeLayoutMode::ThreeColumn
} else {
HomeLayoutMode::Stacked
}
}
/// One rendered room-chat line. `mine` distinguishes our own (locally echoed) /// One rendered room-chat line. `mine` distinguishes our own (locally echoed)
/// messages from peers' for colouring. /// messages from peers' for colouring.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -175,6 +247,7 @@ pub enum AppMessage {
EventOccurred(Event), EventOccurred(Event),
NavigateToSettings, NavigateToSettings,
NavigateBack, NavigateBack,
SelectSettingsCategory(SettingsCategory),
ToggleNotifications(bool), ToggleNotifications(bool),
ToggleEchoCancellation(bool), ToggleEchoCancellation(bool),
CustomSoundPathChanged(Sound, String), CustomSoundPathChanged(Sound, String),
@@ -298,6 +371,7 @@ pub struct AppState {
ever_connected: HashSet<EndpointId>, ever_connected: HashSet<EndpointId>,
controller: Arc<CoreController>, controller: Arc<CoreController>,
current_screen: Screen, current_screen: Screen,
settings_category: SettingsCategory,
/// Whether we're currently sharing our own screen (confirmed by the core). /// Whether we're currently sharing our own screen (confirmed by the core).
self_sharing: bool, self_sharing: bool,
/// Whether the `pixelpass` binary is available, gating the Share controls. /// Whether the `pixelpass` binary is available, gating the Share controls.
@@ -435,6 +509,7 @@ impl Default for AppState {
ever_connected: HashSet::new(), ever_connected: HashSet::new(),
controller, controller,
current_screen: Screen::Home, current_screen: Screen::Home,
settings_category: SettingsCategory::Audio,
self_sharing: false, self_sharing: false,
pixelpass_available, pixelpass_available,
self_node_id: None, self_node_id: None,
@@ -478,11 +553,9 @@ pub fn run_gui() -> iced::Result {
// the icon from the .desktop file matched by app_id instead). // the icon from the .desktop file matched by app_id instead).
icon: window_icon(), icon: window_icon(),
// app_id must match the .desktop basename so Wayland compositors // app_id must match the .desktop basename so Wayland compositors
// (e.g. KWin) attach our launcher icon to the window. // (e.g. KWin) attach our launcher icon to the window. The field is
platform_specific: iced::window::settings::PlatformSpecific { // Linux-only in iced (X11/Wayland); see platform_specific_settings().
application_id: "peerspeak".to_string(), platform_specific: platform_specific_settings(),
..Default::default()
},
// We save the final size ourselves on CloseRequested, then exit. // We save the final size ourselves on CloseRequested, then exit.
exit_on_close_request: false, exit_on_close_request: false,
..Default::default() ..Default::default()
@@ -490,6 +563,22 @@ pub fn run_gui() -> iced::Result {
.run() .run()
} }
/// Window `PlatformSpecific` settings. `application_id` (used by X11/Wayland to
/// match our `.desktop` launcher icon) only exists in iced on Linux, so it is
/// set there and left at defaults on Windows.
#[cfg(target_os = "linux")]
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
iced::window::settings::PlatformSpecific {
application_id: "peerspeak".to_string(),
..Default::default()
}
}
#[cfg(not(target_os = "linux"))]
fn platform_specific_settings() -> iced::window::settings::PlatformSpecific {
iced::window::settings::PlatformSpecific::default()
}
/// Build the window icon from an embedded 128×128 straight-RGBA blob rendered /// Build the window icon from an embedded 128×128 straight-RGBA blob rendered
/// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps /// from `assets/icons/peerspeak.svg`. Using `from_rgba` (always available) keeps
/// us off iced's heavy `image` feature — the blob is raw pixels, no decoder. /// us off iced's heavy `image` feature — the blob is raw pixels, no decoder.
@@ -880,13 +969,15 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.friend_presence.insert(id, presence); state.friend_presence.insert(id, presence);
} }
UiEvent::PresenceModeReverted { mode } => { UiEvent::PresenceModeReverted { mode } => {
// The Discoverable time-box elapsed; core dropped us back to // Core corrected the committed presence mode. Mirror + persist so
// `mode` (Normal) and stopped publishing. Mirror + persist so the // the picker reflects the discovery state the endpoint actually has.
// presence picker reflects it, and tell the user why it changed.
state.config.presence_mode = mode; state.config.presence_mode = mode;
state.config.save(); state.config.save();
state.status_message = state.status_message = if mode == PresenceMode::Normal {
"Discoverable timed out — back to Normal".to_string(); "Discoverable timed out — back to Normal".to_string()
} else {
format!("Presence mode stayed {mode}")
};
} }
UiEvent::ShutdownComplete => { UiEvent::ShutdownComplete => {
if state.closing { if state.closing {
@@ -1097,6 +1188,9 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
crate::recents::remove_recent(&mut state.config.recents, &ticket); crate::recents::remove_recent(&mut state.config.recents, &ticket);
state.config.save(); state.config.save();
} }
AppMessage::SelectSettingsCategory(category) => {
state.settings_category = category;
}
AppMessage::ToggleNotifications(enabled) => { AppMessage::ToggleNotifications(enabled) => {
state.config.notifications_enabled = enabled; state.config.notifications_enabled = enabled;
state.config.save(); state.config.save();
@@ -1270,12 +1364,28 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
// Defence in depth: only ever hand http(s) URLs to the opener. The // Defence in depth: only ever hand http(s) URLs to the opener. The
// link span's href came from `linkify`, which only emits http/https, // link span's href came from `linkify`, which only emits http/https,
// but re-check here so this can't be widened into launching arbitrary // but re-check here so this can't be widened into launching arbitrary
// schemes/args. `xdg-open` receives the URL as a single argv entry // schemes/args. Each opener receives the URL as a single argv entry
// (no shell), so there's no injection surface. // (no shell), so there's no injection surface:
if (url.starts_with("http://") || url.starts_with("https://")) // - Unix: `xdg-open <url>`.
&& let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn() // - Windows: `rundll32 url.dll,FileProtocolHandler <url>` — opens the
{ // default browser without going through `cmd`/`start`, which would
crate::log_msg(&format!("Failed to open URL {url:?}: {e}")); // otherwise re-parse `&` in query strings.
if url.starts_with("http://") || url.starts_with("https://") {
let spawned = {
#[cfg(unix)]
{
std::process::Command::new("xdg-open").arg(&url).spawn()
}
#[cfg(windows)]
{
std::process::Command::new("rundll32")
.args(["url.dll,FileProtocolHandler", &url])
.spawn()
}
};
if let Err(e) = spawned {
crate::log_msg(&format!("Failed to open URL {url:?}: {e}"));
}
} }
} }
AppMessage::ToggleMicTest(enabled) => { AppMessage::ToggleMicTest(enabled) => {
@@ -1544,7 +1654,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
selection: color_blue, selection: color_blue,
}; };
let logo = text("PEERSPEAK").size(36).color(color_blue); let logo = text("PEERSPEAK").size(38).color(color_blue);
let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext); let subtitle = text("NAT-traversing full-mesh voice chat").size(16).color(color_subtext);
let nickname_input = column![ let nickname_input = column![
@@ -1608,8 +1718,8 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
.align_x(iced::alignment::Horizontal::Center), .align_x(iced::alignment::Horizontal::Center),
) )
.style(c_style(color_mantle, color_surface, 12.0)) .style(c_style(color_mantle, color_surface, 12.0))
.padding(30) .padding(32)
.width(380) .width(420)
.into() .into()
} }
@@ -1653,50 +1763,51 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
} }
}; };
let now = std::time::SystemTime::now() let empty = state.config.recents.is_empty();
.duration_since(std::time::UNIX_EPOCH) let content: Element<'_, AppMessage> = if empty {
.map(|d| d.as_secs()) column![
.unwrap_or(0); text("RECENT ROOMS").size(14).color(color_subtext),
let mut rows = column![].spacing(6).width(iced::Length::Fill); text("No recent rooms yet.").size(12).color(color_subtext),
if state.config.recents.is_empty() { ]
rows = rows.push( .spacing(4)
text("No recent rooms yet — they'll appear here after you join one.") .into()
.size(12) } else {
.color(color_subtext), let now = std::time::SystemTime::now()
); .duration_since(std::time::UNIX_EPOCH)
} .map(|d| d.as_secs())
for r in &state.config.recents { .unwrap_or(0);
let label = { let mut rows = column![].spacing(6).width(iced::Length::Fill);
let n = crate::sanitize::sanitize_name(&r.name); for r in &state.config.recents {
if n.is_empty() { "Untitled room".to_string() } else { n } let label = {
}; let n = crate::sanitize::sanitize_name(&r.name);
let when = crate::recents::relative_time(now, r.joined_at); if n.is_empty() { "Untitled room".to_string() } else { n }
let entry = button( };
row![ let when = crate::recents::relative_time(now, r.joined_at);
text(label).size(14).color(color_text), let entry = button(
horizontal_space(), row![
text(when).size(11).color(color_subtext), text(label).size(14).color(color_text),
] horizontal_space(),
.align_y(iced::alignment::Vertical::Center), text(when).size(11).color(color_subtext),
) ]
.on_press(AppMessage::JoinRecent(r.ticket.clone())) .align_y(iced::alignment::Vertical::Center),
.style(b_style(color_crust, color_surface, color_text, 6.0)) )
.padding(8) .on_press(AppMessage::JoinRecent(r.ticket.clone()))
.width(iced::Length::Fill); .style(b_style(color_crust, color_surface, color_text, 6.0))
rows = rows.push( .padding(8)
row![ .width(iced::Length::Fill);
entry, rows = rows.push(
button(text("").size(12)) row![
.on_press(AppMessage::RemoveRecent(r.ticket.clone())) entry,
.style(b_style(color_surface, color_maroon, color_text, 6.0)) button(text("").size(12))
.padding(8), .on_press(AppMessage::RemoveRecent(r.ticket.clone()))
] .style(b_style(color_surface, color_maroon, color_text, 6.0))
.spacing(6) .padding(8),
.align_y(iced::alignment::Vertical::Center), ]
); .spacing(6)
} .align_y(iced::alignment::Vertical::Center),
);
}
container(
column![ column![
text("RECENT ROOMS").size(18).color(color_text), text("RECENT ROOMS").size(18).color(color_text),
text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.") text("Rooms you've been in — click to hop back. Best-effort: only works while someone's still there.")
@@ -1705,11 +1816,14 @@ fn recents_card(state: &AppState) -> Element<'_, AppMessage> {
vertical_space(10.0), vertical_space(10.0),
rows, rows,
] ]
.spacing(6), .spacing(6)
) .into()
.style(c_style(color_mantle, color_surface, 12.0)) };
.padding(24)
.width(380) container(content)
.style(c_style(if empty { color_crust } else { color_mantle }, color_surface, 8.0))
.padding(if empty { 16 } else { 24 })
.width(if empty { 340 } else { 380 })
.into() .into()
} }
@@ -1725,6 +1839,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
let color_red = pal.red; let color_red = pal.red;
let color_maroon = pal.maroon; let color_maroon = pal.maroon;
let color_green = pal.green; let color_green = pal.green;
let has_friends = !state.friends.list().is_empty();
let c_style = move |bg: Color, b_color: Color, radius: f32| { let c_style = move |bg: Color, b_color: Color, radius: f32| {
move |_theme: &Theme| container::Style { move |_theme: &Theme| container::Style {
@@ -1763,9 +1878,9 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
// The live friends list: status dot, inline rename, short id, remove. // The live friends list: status dot, inline rename, short id, remove.
let mut friend_rows = column![].spacing(6).width(iced::Length::Fill); let mut friend_rows = column![].spacing(6).width(iced::Length::Fill);
if state.friends.list().is_empty() { if !has_friends {
friend_rows = friend_rows.push( friend_rows = friend_rows.push(
text("No friends yet — add one by their node ID below.") text("No friends yet.")
.size(12) .size(12)
.color(color_subtext), .color(color_subtext),
); );
@@ -1868,28 +1983,34 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
] ]
.spacing(4) .spacing(4)
.width(iced::Length::Fill); .width(iced::Length::Fill);
let intro: Element<'_, AppMessage> = if has_friends {
text("Who's online — click Join to hop into a friend's room.")
.size(11)
.color(color_subtext)
.into()
} else {
column![].into()
};
container( container(
column![ column![
text("FRIENDS").size(18).color(color_text), text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text),
text("Who's online — click Join to hop into a friend's room.") intro,
.size(11) vertical_space(if has_friends { 10.0 } else { 4.0 }),
.color(color_subtext),
vertical_space(10.0),
readonly_warning, readonly_warning,
friend_rows, friend_rows,
vertical_space(12.0), vertical_space(if has_friends { 12.0 } else { 8.0 }),
text("Add a friend").size(13).color(color_subtext), text("Add a friend").size(13).color(color_subtext),
add_form, add_form,
vertical_space(14.0), vertical_space(if has_friends { 14.0 } else { 10.0 }),
text("Your presence").size(13).color(color_subtext), text("Your presence").size(13).color(color_subtext),
presence_picker, presence_picker,
] ]
.spacing(6), .spacing(6),
) )
.style(c_style(color_mantle, color_surface, 12.0)) .style(c_style(color_mantle, color_surface, 12.0))
.padding(24) .padding(if has_friends { 24 } else { 18 })
.width(460) .width(if has_friends { 460 } else { 360 })
.into() .into()
} }
@@ -1959,19 +2080,23 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
} }
}; };
let top_bar = row![ // The Hotkeys info button is always available (hotkeys are app-wide). The
horizontal_space(), // room-layout button is hidden on the Home screen, leaving only it + Settings.
tooltip( let info_button = tooltip(
button(icon(IconKind::Info, 18.0, color_text)) button(icon(IconKind::Info, 18.0, color_text))
.on_press(AppMessage::OpenHotkeyInfo) .on_press(AppMessage::OpenHotkeyInfo)
.style(b_style(color_surface, color_blue, color_text, 6.0)) .style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8), .padding(8),
container(text("Hotkeys").size(11).color(color_text)) container(text("Hotkeys").size(11).color(color_text))
.padding(8) .padding(8)
.style(c_style(color_crust, color_surface, 6.0)), .style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
) )
.gap(8), .gap(8);
let layout_button: Element<'_, AppMessage> = if state.current_screen == Screen::Home {
iced::widget::Space::new().width(0.0).height(0.0).into()
} else {
tooltip( tooltip(
button( button(
Canvas::new(LayoutIcon { fg: color_text }) Canvas::new(LayoutIcon { fg: color_text })
@@ -1986,7 +2111,14 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.style(c_style(color_crust, color_surface, 6.0)), .style(c_style(color_crust, color_surface, 6.0)),
iced::widget::tooltip::Position::Bottom, iced::widget::tooltip::Position::Bottom,
) )
.gap(8), .gap(8)
.into()
};
let top_bar = row![
horizontal_space(),
info_button,
layout_button,
button( button(
row![ row![
icon(IconKind::Settings, 15.0, color_text), icon(IconKind::Settings, 15.0, color_text),
@@ -2404,9 +2536,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
// Presence + Friends moved to the home screen (see `friends_panel`). // Presence + Friends moved to the home screen (see `friends_panel`).
let settings_content = scrollable( let settings_body: Element<'_, AppMessage> = match state.settings_category {
column![ SettingsCategory::Audio => column![
// --- Audio Devices ---
section_header("Audio Devices"), section_header("Audio Devices"),
row![ row![
column![ column![
@@ -2435,26 +2566,46 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].spacing(8).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill),
].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill), ].spacing(20).align_y(iced::alignment::Vertical::Top).width(iced::Length::Fill),
vertical_space(section_gap), vertical_space(section_gap),
// --- Microphone ---
section_header("Microphone"), section_header("Microphone"),
column![ column![
mic_meter, mic_meter,
text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext), text("Drag the yellow handle to set the gate. While talking, place it just above your quiet-room level so silence is muted but your voice passes through.").size(11).color(color_subtext),
vertical_space(4.0), vertical_space(4.0),
checkbox(state.config.echo_cancellation_enabled) {
.label("Echo cancellation") let control: Element<'_, AppMessage> = {
.on_toggle(AppMessage::ToggleEchoCancellation), #[cfg(target_os = "linux")]
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext), {
column![
checkbox(state.config.echo_cancellation_enabled)
.label("Echo cancellation")
.on_toggle(AppMessage::ToggleEchoCancellation),
text("Cancels speaker echo + suppresses noise (PipeWire). Takes effect on your next room join.").size(11).color(color_subtext),
].spacing(8).into()
}
#[cfg(not(target_os = "linux"))]
{
column![
checkbox(false)
.label("Echo cancellation"),
text("Echo cancellation is not available on Windows yet.").size(11).color(color_subtext),
].spacing(8).into()
}
};
control
},
].spacing(8).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap), ]
.spacing(10)
// --- Hotkeys --- .width(iced::Length::Fill)
.into(),
SettingsCategory::Hotkeys => column![
section_header("Hotkeys"), section_header("Hotkeys"),
hotkey_section, hotkey_section,
vertical_space(section_gap), ]
.spacing(10)
// --- Recording --- .width(iced::Length::Fill)
.into(),
SettingsCategory::Recording => column![
section_header("Recording"), section_header("Recording"),
column![ column![
mode_radio(RecordingMode::Mixed, "Mixed (single file)"), mode_radio(RecordingMode::Mixed, "Mixed (single file)"),
@@ -2463,22 +2614,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
vertical_space(2.0), vertical_space(2.0),
text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext), text("Hover an option for what it does. Saved to ~/peerspeak-recordings/ — Multitrack/Both as a timestamped folder of tracks, Mixed as a single file. Applies to your next recording.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill),
]
.spacing(10)
.width(iced::Length::Fill)
.into(),
SettingsCategory::Profile => column![
section_header("Avatar"),
avatar_section,
vertical_space(section_gap), vertical_space(section_gap),
section_header("Identity"),
// --- Network & Privacy --- identity_section,
section_header("Network & Privacy"), ]
column![ .spacing(10)
pick_list( .width(iced::Length::Fill)
&NetworkMode::ALL[..], .into(),
Some(state.config.network_mode), SettingsCategory::Appearance => column![
AppMessage::NetworkModeSelected,
).width(iced::Length::Fill),
text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
text("Takes effect on your next room join.").size(11).color(color_subtext),
].spacing(4).width(iced::Length::Fill),
vertical_space(section_gap),
// --- Room Layout ---
section_header("Room Layout"), section_header("Room Layout"),
column![ column![
row![ row![
@@ -2489,25 +2639,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext), text("How the in-call room is arranged. Applies live.").size(11).color(color_subtext),
].spacing(8).width(iced::Length::Fill), ].spacing(8).width(iced::Length::Fill),
vertical_space(section_gap), vertical_space(section_gap),
// --- Theme ---
section_header("Theme"), section_header("Theme"),
theme_section, theme_section,
vertical_space(section_gap), ]
.spacing(10)
// --- Avatar --- .width(iced::Length::Fill)
section_header("Avatar"), .into(),
avatar_section, SettingsCategory::Network => column![
vertical_space(section_gap), section_header("Network & Privacy"),
column![
// --- Identity --- pick_list(
section_header("Identity"), &NetworkMode::ALL[..],
identity_section, Some(state.config.network_mode),
vertical_space(section_gap), AppMessage::NetworkModeSelected,
).width(iced::Length::Fill),
// (Presence + Friends now live on the home screen.) text(network_mode_hint(state.config.network_mode)).size(11).color(color_subtext),
text("Takes effect on your next room join.").size(11).color(color_subtext),
// --- Notifications & Sounds --- ].spacing(4).width(iced::Length::Fill),
]
.spacing(10)
.width(iced::Length::Fill)
.into(),
SettingsCategory::Notifications => column![
section_header("Notifications & Sounds"), section_header("Notifications & Sounds"),
column![ column![
checkbox(state.config.notifications_enabled) checkbox(state.config.notifications_enabled)
@@ -2535,9 +2688,92 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
] ]
.spacing(10) .spacing(10)
.width(iced::Length::Fill) .width(iced::Length::Fill)
) .into(),
.width(iced::Length::Fill) };
.height(iced::Length::Fill);
let category_button = |category: SettingsCategory| -> Element<'_, AppMessage> {
let selected = state.settings_category == category;
let label_color = if selected { color_blue } else { color_text };
let border_color = if selected { color_blue } else { Color::TRANSPARENT };
let bg = if selected { color_surface } else { Color::TRANSPARENT };
button(
container(
column![
text(category.label()).size(14).color(label_color),
text(category.hint()).size(11).color(color_subtext),
]
.spacing(2)
.width(iced::Length::Fill),
)
.width(iced::Length::Fill),
)
.on_press(AppMessage::SelectSettingsCategory(category))
.style(move |_theme: &Theme, status: button::Status| {
let active_bg = match status {
button::Status::Hovered if selected => color_surface,
button::Status::Hovered => color_crust,
_ => bg,
};
button::Style {
background: Some(Background::Color(active_bg)),
text_color: label_color,
border: Border {
color: border_color,
width: if selected { 1.0 } else { 0.0 },
radius: 8.0.into(),
},
..Default::default()
}
})
.padding(10)
.width(iced::Length::Fill)
.into()
};
let mut settings_nav = column![
text("SETTINGS").size(11).color(color_subtext),
]
.spacing(8)
.width(iced::Length::Fill);
for category in SettingsCategory::ALL {
settings_nav = settings_nav.push(category_button(category));
}
let settings_nav = container(settings_nav)
.padding(12)
.width(iced::Length::Fixed(220.0))
.height(iced::Length::Fill)
.style(c_style(color_crust, color_surface, 8.0));
let settings_content: Element<'_, AppMessage> = if state.window_size.width < 820.0 {
scrollable(
column![
text("Category").size(12).color(color_subtext),
pick_list(
&SettingsCategory::ALL[..],
Some(state.settings_category),
AppMessage::SelectSettingsCategory,
).width(iced::Length::Fill),
vertical_space(10.0),
settings_body,
]
.spacing(8)
.width(iced::Length::Fill),
)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.into()
} else {
row![
settings_nav,
scrollable(settings_body)
.width(iced::Length::Fill)
.height(iced::Length::Fill),
]
.spacing(16)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.into()
};
// Sticky header bar: stays fixed above the scrollable content so the Back // Sticky header bar: stays fixed above the scrollable content so the Back
// button is always reachable. The "Settings" title is centered by flanking // button is always reachable. The "Settings" title is centered by flanking
@@ -2597,29 +2833,44 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
if state.current_screen == Screen::Home { if state.current_screen == Screen::Home {
// --- HOME SCREEN --- // --- HOME SCREEN ---
// Two cards: Connect (left) + the live Friends list (right). They sit // Keep Create/Join dominant on a fresh install. Once Recents or Friends
// side-by-side when the window is wide enough, and stack vertically on a // has real content, the wider three-card layout returns.
// narrow window so the Friends card never gets crushed — below ~860px the let has_recents = !state.config.recents.is_empty();
// fixed-width Connect card would otherwise squeeze it until its node-ID let has_friends = !state.friends.list().is_empty();
// field and remove button clip away. `responsive` measures the available
// width each layout pass and picks the orientation accordingly.
// Three cards: Recents | Connect | Friends, side-by-side when there's room.
// Three 380460px cards need ~1280px to fit in a row, so below that the
// `responsive` measure stacks them in a column (Connect first — the primary
// action) rather than letting the row clip. Recents always shows (empty-
// state hint when no history) for parity with the Friends card.
let body = responsive(move |size| { let body = responsive(move |size| {
let cards: Element<AppMessage> = if size.width < 1280.0 { let cards: Element<AppMessage> =
column![connect_card(state), recents_card(state), friends_panel(state)] match home_layout_mode(size.width, has_recents, has_friends) {
.spacing(20) HomeLayoutMode::FocusedEmpty => row![
.align_x(iced::alignment::Horizontal::Center) connect_card(state),
.into() column![friends_panel(state), recents_card(state)]
} else { .spacing(16)
row![recents_card(state), connect_card(state), friends_panel(state)] .width(iced::Length::Fixed(360.0)),
]
.spacing(22)
.align_y(iced::alignment::Vertical::Top)
.into(),
HomeLayoutMode::ThreeColumn => row![
recents_card(state),
connect_card(state),
friends_panel(state),
]
.spacing(20) .spacing(20)
.align_y(iced::alignment::Vertical::Top) .align_y(iced::alignment::Vertical::Top)
.into() .into(),
}; HomeLayoutMode::Stacked => {
let mut stack = column![connect_card(state)]
.spacing(20)
.align_x(iced::alignment::Horizontal::Center);
if has_recents {
stack = stack.push(recents_card(state));
}
stack = stack.push(friends_panel(state));
if !has_recents {
stack = stack.push(recents_card(state));
}
stack.into()
}
};
scrollable(container(cards).center_x(iced::Length::Fill)) scrollable(container(cards).center_x(iced::Length::Fill))
.width(iced::Length::Fill) .width(iced::Length::Fill)
.into() .into()
@@ -3044,26 +3295,40 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
column![] column![]
}, },
vertical_space(20.0), vertical_space(20.0),
// Echo cancellation — same flag + message as the Settings checkbox, so {
// toggling here and there stay in sync automatically (single source of // Echo cancellation is wired at join time on Linux; other
// truth: config.echo_cancellation_enabled). Tooltip is explicit that it // targets show an inert status row instead of a dead toggle.
// applies on the NEXT join (the PipeWire-module AEC is wired at join let control: Element<'_, AppMessage> = {
// time, not hot-swappable mid-call). #[cfg(target_os = "linux")]
tooltip( {
checkbox(state.config.echo_cancellation_enabled) tooltip(
.label("Echo cancellation") checkbox(state.config.echo_cancellation_enabled)
.on_toggle(AppMessage::ToggleEchoCancellation), .label("Echo cancellation")
container( .on_toggle(AppMessage::ToggleEchoCancellation),
text("Cancels speaker echo + suppresses noise. Applies on your next room join.") container(
.size(11) text("Cancels speaker echo + suppresses noise. Applies on your next room join.")
.color(color_text), .size(11)
) .color(color_text),
.padding(8) )
.max_width(260.0) .padding(8)
.style(c_style(color_crust, color_surface, 6.0)), .max_width(260.0)
iced::widget::tooltip::Position::Top, .style(c_style(color_crust, color_surface, 6.0)),
) iced::widget::tooltip::Position::Top,
.gap(8), )
.gap(8)
.into()
}
#[cfg(not(target_os = "linux"))]
{
column![
checkbox(false)
.label("Echo cancellation"),
text("Not available on Windows yet.").size(11).color(color_subtext),
].spacing(4).into()
}
};
control
},
vertical_space(20.0), vertical_space(20.0),
{ {
let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording { let (rec_kind, rec_label, rec_bg, rec_hover, rec_fg) = if state.recording {
@@ -4517,6 +4782,28 @@ mod tests {
assert_eq!(format_duration(3661), "1:01:01"); assert_eq!(format_duration(3661), "1:01:01");
assert_eq!(format_duration(3725), "1:02:05"); assert_eq!(format_duration(3725), "1:02:05");
} }
#[test]
fn settings_categories_are_stable_and_grouped_for_navigation() {
use super::SettingsCategory;
let labels: Vec<_> = SettingsCategory::ALL.iter().map(|c| c.label()).collect();
assert_eq!(
labels,
vec!["Audio", "Hotkeys", "Recording", "Profile", "Appearance", "Network", "Notifications"]
);
assert_eq!(SettingsCategory::Audio.hint(), "Devices, mic gate, echo");
assert_eq!(SettingsCategory::Profile.hint(), "Avatar and identity");
}
#[test]
fn home_layout_prioritizes_connect_on_empty_home() {
use super::{home_layout_mode, HomeLayoutMode};
assert_eq!(home_layout_mode(1280.0, false, false), HomeLayoutMode::FocusedEmpty);
assert_eq!(home_layout_mode(760.0, false, false), HomeLayoutMode::Stacked);
assert_eq!(home_layout_mode(1280.0, true, false), HomeLayoutMode::ThreeColumn);
assert_eq!(home_layout_mode(1100.0, true, true), HomeLayoutMode::Stacked);
}
use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W}; use super::{clamp_chat_height, clamp_participants_width, CHAT_MIN_H, PARTICIPANTS_MIN_W};
#[test] #[test]
+804
View File
@@ -0,0 +1,804 @@
//! Windows audio backend — cpal / WASAPI (Phase 1).
//!
//! Implements [`AudioBackend`] on top of [`cpal`], which wraps WASAPI on Windows.
//! It is the Windows counterpart to `pipewire_impl.rs` and deliberately preserves
//! the exact same contract so the rest of the app (mixer, encoder, jitter buffer)
//! is unchanged:
//!
//! - **Capture**: mono, 48 kHz, S16 PCM, emitted as `Vec<i16>` frames of
//! [`CAPTURE_FRAME`] (960 = 20 ms) samples — matching the encoder/jitter frame.
//! The RT capture callback only downmixes and pushes samples into a lock-free
//! ring; the owning thread drains that ring, frames it, and sends — so the
//! callback never allocates, locks, or touches an mpsc channel.
//! - **Playback**: stereo interleaved ([`PLAYBACK_CHANNELS`]) S16 PCM at 48 kHz,
//! drained from a ring buffer that is paced to the device's hardware clock via
//! `ring_fill` exactly as the PipeWire backend does.
//!
//! ## Threading and the `!Send` stream
//!
//! `cpal::Stream` is `!Send` (some backends require it to be created and dropped
//! on the same thread), but [`AudioBackend`] is `Send + Sync` and the backend is
//! shared through an `Arc`. So the stream never lives in the struct: each of
//! `start_capture`/`start_playback` spawns one owning thread that builds the
//! stream, plays it, and keeps it alive until the per-worker `running` flag flips
//! (set by `stop`). The struct holds only `Send` handles (the flag + the join
//! handle). The stream's RT callback does the actual audio work; the owning
//! thread additionally feeds the playback ring (or drains the capture ring).
//!
//! `start_*` does not return until the owning thread reports back over a readiness
//! channel that the device resolved and the stream is built and playing — so a
//! device/format/WASAPI failure surfaces as a real `Err` to the caller instead of
//! leaving the UI in a joined-but-silent room.
//!
//! ## Sample rate
//!
//! The whole pipeline assumes 48 kHz (Opus + the 960-sample frame). Phase 1 only
//! selects a native-48 kHz device config; if the device can't do 48 kHz we return
//! a clear error rather than silently producing pitch-shifted audio. Arbitrary
//! sample-rate support (resampling) is a Phase 1.1 follow-up.
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::{Device, FromSample, Sample, SampleFormat, SampleRate, SizedSample, Stream, StreamConfig};
use ringbuf::{
traits::{Consumer, Producer, Split},
HeapRb,
};
use super::{AudioBackend, AudioDevice, AudioError, PLAYBACK_CHANNELS, PLAYBACK_TARGET_SAMPLES};
/// The one sample rate the pipeline supports (Opus + the 20 ms frame).
const SAMPLE_RATE: u32 = 48_000;
/// Mono capture frame: 960 samples = 20 ms @ 48 kHz. Matches the PipeWire backend
/// and `core::jitter::FRAME_SAMPLES`.
const CAPTURE_FRAME: usize = 960;
/// Lock-free capture ring capacity (mono samples) between the RT callback and the
/// owning drain thread: 8 frames = 160 ms of headroom, so a scheduling hiccup on
/// the drain thread doesn't immediately overrun the RT producer.
const CAPTURE_RING_CAPACITY: usize = CAPTURE_FRAME * 8;
/// How long the capture drain thread sleeps when the ring is momentarily empty,
/// before polling again. Small enough to stay well under the 20 ms frame cadence.
const CAPTURE_POLL: Duration = Duration::from_millis(5);
/// Playback ring capacity in interleaved samples: 200 ms of stereo @ 48 kHz.
/// Comfortably above [`PLAYBACK_TARGET_SAMPLES`] so the clock-paced producer has
/// headroom and never has to drop frames in steady state.
const RING_CAPACITY: usize = 9600 * PLAYBACK_CHANNELS;
/// How often a blocked playback worker re-checks its `running` flag, bounding how
/// long `stop()` can take to join it (mirrors the PipeWire backend's `WORKER_POLL`).
const WORKER_POLL: Duration = Duration::from_millis(100);
/// Windows audio backend. See module docs.
pub struct CpalBackend {
capture: Mutex<Option<StreamWorker>>,
playback: Mutex<Option<StreamWorker>>,
}
/// A spawned owning thread plus the flag that tells it to drop its stream and exit.
struct StreamWorker {
running: Arc<AtomicBool>,
thread: JoinHandle<()>,
}
impl CpalBackend {
pub fn new() -> Self {
Self {
capture: Mutex::new(None),
playback: Mutex::new(None),
}
}
}
impl Default for CpalBackend {
fn default() -> Self {
Self::new()
}
}
impl AudioBackend for CpalBackend {
fn start_capture(
&self,
tx: Sender<Vec<i16>>,
target_node: Option<String>,
) -> Result<(), AudioError> {
let guard = self.capture.lock().unwrap();
if guard.is_some() {
return Err(AudioError::Stream("Capture already started".to_string()));
}
let running = Arc::new(AtomicBool::new(true));
let running_thread = running.clone();
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
let thread = thread::Builder::new()
.name("peerspeak-cpal-capture".to_string())
.spawn(move || {
run_capture(tx, target_node, running_thread, ready_tx);
})
.map_err(|e| AudioError::Init(e.to_string()))?;
finish_start(guard, StreamWorker { running, thread }, ready_rx, "capture")
}
fn start_playback(
&self,
rx: Receiver<Vec<i16>>,
target_node: Option<String>,
ring_fill: Arc<AtomicUsize>,
) -> Result<(), AudioError> {
let guard = self.playback.lock().unwrap();
if guard.is_some() {
return Err(AudioError::Stream("Playback already started".to_string()));
}
let running = Arc::new(AtomicBool::new(true));
let running_thread = running.clone();
let (ready_tx, ready_rx) = channel::<Result<(), AudioError>>();
let thread = thread::Builder::new()
.name("peerspeak-cpal-playback".to_string())
.spawn(move || {
run_playback(rx, target_node, ring_fill, running_thread, ready_tx);
})
.map_err(|e| AudioError::Init(e.to_string()))?;
finish_start(guard, StreamWorker { running, thread }, ready_rx, "playback")
}
fn stop(&self) -> Result<(), AudioError> {
for slot in [&self.capture, &self.playback] {
if let Some(worker) = slot.lock().unwrap().take() {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
}
}
Ok(())
}
}
/// Block until the just-spawned worker reports (over `ready_rx`) that its stream
/// is built and playing, then either install it (`Ok`) or join it and surface the
/// real error. This is what makes `start_capture`/`start_playback` fail loudly
/// instead of returning `Ok` into a joined-but-silent room (Codex review W1).
fn finish_start(
mut guard: std::sync::MutexGuard<'_, Option<StreamWorker>>,
worker: StreamWorker,
ready_rx: Receiver<Result<(), AudioError>>,
what: &str,
) -> Result<(), AudioError> {
match ready_rx.recv() {
Ok(Ok(())) => {
*guard = Some(worker);
Ok(())
}
// Setup failed (Err) or the worker exited before reporting (recv Err):
// either way it has stopped, so reap it and surface the error.
Ok(Err(e)) => {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
Err(e)
}
Err(_) => {
worker.running.store(false, Ordering::Relaxed);
let _ = worker.thread.join();
Err(AudioError::Init(format!(
"cpal {what} worker exited before reporting readiness"
)))
}
}
}
// ---------------------------------------------------------------------------
// Device enumeration (for the settings device pickers)
// ---------------------------------------------------------------------------
/// Enumerate WASAPI input/output devices via cpal, sorted by description to match
/// the PipeWire backend's stable UI ordering.
///
/// cpal exposes a single friendly name per device, which is also what [`resolve`]
/// matches `target_node` against — so `name` and `description` are the same string
/// and a saved selection round-trips. Note: WASAPI device names are less stable
/// across driver/endpoint changes than PipeWire node names, so a saved device may
/// not always be found again; selection then falls back to the system default.
pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let host = cpal::default_host();
let mut devices = Vec::new();
if let Ok(inputs) = host.input_devices() {
for device in inputs {
if let Ok(name) = device.name() {
devices.push(AudioDevice {
description: name.clone(),
name,
is_input: true,
});
}
}
}
if let Ok(outputs) = host.output_devices() {
for device in outputs {
if let Ok(name) = device.name() {
devices.push(AudioDevice {
description: name.clone(),
name,
is_input: false,
});
}
}
}
devices.sort_by(|a, b| a.description.cmp(&b.description));
devices
}
// ---------------------------------------------------------------------------
// Device / config selection
// ---------------------------------------------------------------------------
/// Resolve a device (by `target` name, else the system default) and a stream
/// config running natively at [`SAMPLE_RATE`].
///
/// For output we require [`PLAYBACK_CHANNELS`] (stereo) so the interleaved ring
/// maps 1:1 to the device buffer; for input we prefer mono but accept any channel
/// count and downmix. A device with no 48 kHz config is a hard error (no
/// resampling yet — see module docs).
fn resolve(
output: bool,
target: Option<String>,
) -> Result<(Device, StreamConfig, SampleFormat), AudioError> {
let host = cpal::default_host();
let default = || {
if output {
host.default_output_device()
} else {
host.default_input_device()
}
};
let device = match target {
// A saved device name that no longer resolves falls back to the system
// default — but log it, because WASAPI friendly names can change across
// driver/endpoint changes, so a silent fallback otherwise looks like
// "audio went to the wrong device for no reason" (review W7).
Some(ref name) => match find_device_by_name(&host, output, name) {
Some(dev) => Some(dev),
None => {
crate::log_msg(&format!(
"cpal: saved {} device '{name}' not found; using system default",
if output { "output" } else { "input" },
));
default()
}
},
None => default(),
}
.ok_or_else(|| AudioError::Device("no audio device available".to_string()))?;
let supported = choose_config(&device, output)?;
let sample_format = supported.sample_format();
let config = supported.config();
Ok((device, config, sample_format))
}
fn find_device_by_name(host: &cpal::Host, output: bool, name: &str) -> Option<Device> {
let devices = if output {
host.output_devices().ok()?
} else {
host.input_devices().ok()?
};
devices.into_iter().find(|d| d.name().is_ok_and(|n| n == name))
}
/// Pick a supported config at exactly [`SAMPLE_RATE`]. Output must be stereo;
/// input prefers mono, then any channel count (downmixed later).
fn choose_config(
device: &Device,
output: bool,
) -> Result<cpal::SupportedStreamConfig, AudioError> {
let ranges: Vec<cpal::SupportedStreamConfigRange> = if output {
device
.supported_output_configs()
.map_err(|e| AudioError::Device(e.to_string()))?
.collect()
} else {
device
.supported_input_configs()
.map_err(|e| AudioError::Device(e.to_string()))?
.collect()
};
// A range covers a sample-rate span and a fixed channel count.
let supports_48k = |r: &cpal::SupportedStreamConfigRange| {
r.min_sample_rate().0 <= SAMPLE_RATE && SAMPLE_RATE <= r.max_sample_rate().0
};
let pick = |channels: Option<u16>| {
ranges
.iter()
.find(|r| supports_48k(r) && channels.is_none_or(|c| r.channels() == c))
.cloned()
};
let chosen = if output {
pick(Some(PLAYBACK_CHANNELS as u16))
} else {
pick(Some(1)).or_else(|| pick(None))
};
chosen
.map(|r| r.with_sample_rate(SampleRate(SAMPLE_RATE)))
.ok_or_else(|| {
AudioError::Device(format!(
"device '{}' has no {SAMPLE_RATE} Hz {} config; resampling not yet implemented (Phase 1.1)",
device.name().unwrap_or_else(|_| "<unknown>".to_string()),
if output { "stereo output" } else { "input" },
))
})
}
// ---------------------------------------------------------------------------
// Capture
// ---------------------------------------------------------------------------
fn run_capture(
tx: Sender<Vec<i16>>,
target: Option<String>,
running: Arc<AtomicBool>,
ready: Sender<Result<(), AudioError>>,
) {
// The RT callback pushes mono samples into this lock-free ring; we drain it on
// this (non-RT) thread, so the callback never allocates or sends on a channel.
let rb = HeapRb::<i16>::new(CAPTURE_RING_CAPACITY);
let (producer, mut consumer) = rb.split();
let overrun = Arc::new(AtomicU64::new(0));
// Fallible device/stream setup. We report the real error to `start_capture`
// before doing any work, so a join never lands in a silent room.
let setup = || -> Result<(Stream, String, SampleFormat, usize), AudioError> {
let (device, config, sample_format) = resolve(false, target)?;
let channels = config.channels as usize;
let stream = match sample_format {
SampleFormat::F32 => {
build_input::<f32, _>(&device, &config, producer, channels, overrun.clone())
}
SampleFormat::I16 => {
build_input::<i16, _>(&device, &config, producer, channels, overrun.clone())
}
SampleFormat::U16 => {
build_input::<u16, _>(&device, &config, producer, channels, overrun.clone())
}
other => Err(AudioError::Stream(format!(
"unsupported capture sample format: {other:?}"
))),
}?;
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
Ok((stream, name, sample_format, channels))
};
let (stream, dev_name, sample_format, channels) = match setup() {
Ok(v) => {
let _ = ready.send(Ok(()));
v
}
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
crate::log_msg(&format!(
"cpal capture started: device='{dev_name}' format={sample_format:?} channels={channels} rate={SAMPLE_RATE} Hz"
));
// Drain the RT ring on this thread: pop mono samples, frame them (the `Vec`
// allocation lives here, off the RT path), and send completed frames. Keep
// `stream` alive until `stop()` flips the flag.
let mut acc = FrameAccumulator::new(CAPTURE_FRAME);
let mut last_overrun = 0u64;
while running.load(Ordering::Relaxed) {
let mut drained = false;
while let Some(sample) = consumer.try_pop() {
drained = true;
if let Some(frame) = acc.push(sample) {
// Consumer gone (call ended) → stop feeding; the stream is
// dropped below on the way out.
if tx.send(frame).is_err() {
drop(stream);
return;
}
}
}
let o = overrun.load(Ordering::Relaxed);
if o != last_overrun {
crate::log_msg(&format!(
"cpal capture overrun: dropped {} samples (drain thread fell behind)",
o - last_overrun
));
last_overrun = o;
}
if !drained {
thread::sleep(CAPTURE_POLL);
}
}
drop(stream);
}
fn build_input<T, P>(
device: &Device,
config: &StreamConfig,
mut producer: P,
channels: usize,
overrun: Arc<AtomicU64>,
) -> Result<Stream, AudioError>
where
T: SizedSample + Send + 'static,
i16: FromSample<T>,
P: Producer<Item = i16> + Send + 'static,
{
let err_fn = |e| crate::log_msg(&format!("cpal capture stream error: {e}"));
device
.build_input_stream::<T, _, _>(
config,
move |data: &[T], _| {
// RT-safe: downmix + wait-free push only. A full ring means the
// drain thread stalled; count the drop and keep going.
for frame in data.chunks_exact(channels) {
let mono = downmix_to_mono(frame);
if producer.try_push(mono).is_err() {
overrun.fetch_add(1, Ordering::Relaxed);
}
}
},
err_fn,
None,
)
.map_err(|e| AudioError::Stream(e.to_string()))
}
/// Average a device frame's channels down to a single mono i16. For a 1-channel
/// device this is just the converted sample.
fn downmix_to_mono<T>(frame: &[T]) -> i16
where
T: Copy,
i16: FromSample<T>,
{
if frame.is_empty() {
return 0;
}
let sum: i32 = frame.iter().map(|&s| i16::from_sample(s) as i32).sum();
(sum / frame.len() as i32) as i16
}
/// Accumulates mono samples into fixed-size [`CAPTURE_FRAME`] frames. Pulled out
/// of the RT callback so the framing is unit-testable.
struct FrameAccumulator {
buf: Vec<i16>,
frame_len: usize,
}
impl FrameAccumulator {
fn new(frame_len: usize) -> Self {
Self {
buf: Vec::with_capacity(frame_len),
frame_len,
}
}
/// Push one sample; returns a completed frame when the buffer fills.
fn push(&mut self, sample: i16) -> Option<Vec<i16>> {
self.buf.push(sample);
if self.buf.len() == self.frame_len {
Some(std::mem::replace(
&mut self.buf,
Vec::with_capacity(self.frame_len),
))
} else {
None
}
}
}
// ---------------------------------------------------------------------------
// Playback
// ---------------------------------------------------------------------------
fn run_playback(
rx: Receiver<Vec<i16>>,
target: Option<String>,
ring_fill: Arc<AtomicUsize>,
running: Arc<AtomicBool>,
ready: Sender<Result<(), AudioError>>,
) {
let rb = HeapRb::<i16>::new(RING_CAPACITY);
let (mut producer, consumer) = rb.split();
// Prefill to the steady-state depth so playout starts at target. `ring_fill`
// is an EXACT occupancy counter maintained by deltas (worker fetch_add on
// push, RT callback fetch_sub on pop) — not ringbuf's cached `occupied_len`,
// which is stale across the split halves and would lie high and starve the
// ring. See pipewire_impl.rs for the full rationale.
for _ in 0..PLAYBACK_TARGET_SAMPLES {
let _ = producer.try_push(0);
}
ring_fill.store(PLAYBACK_TARGET_SAMPLES, Ordering::Relaxed);
// Diagnostics (mirrors the PipeWire backend's playout-health line).
let underrun = Arc::new(AtomicU64::new(0));
let dropped = Arc::new(AtomicU64::new(0));
// Largest single output-callback length seen (interleaved samples). WASAPI
// shared-mode picks its own period, so this can exceed the prefill target —
// which would force an underrun every cycle (review W2). The callback only
// does a wait-free fetch_max; the health logger reports/warns off the RT path.
let max_cb = Arc::new(AtomicUsize::new(0));
// Fallible device/stream setup; report the real error to `start_playback`
// before any work so a failure surfaces instead of a silent room. `consumer`
// is moved into the output callback here.
let setup = || -> Result<(Stream, String, SampleFormat), AudioError> {
let (device, config, sample_format) = resolve(true, target)?;
let stream = match sample_format {
SampleFormat::F32 => {
build_output::<f32, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
SampleFormat::I16 => {
build_output::<i16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
SampleFormat::U16 => {
build_output::<u16, _>(&device, &config, consumer, ring_fill.clone(), underrun.clone(), max_cb.clone())
}
other => Err(AudioError::Stream(format!(
"unsupported playback sample format: {other:?}"
))),
}?;
stream.play().map_err(|e| AudioError::Stream(e.to_string()))?;
let name = device.name().unwrap_or_else(|_| "<unknown>".to_string());
Ok((stream, name, sample_format))
};
let (stream, dev_name, sample_format) = match setup() {
Ok(v) => {
let _ = ready.send(Ok(()));
v
}
Err(e) => {
let _ = ready.send(Err(e));
return;
}
};
crate::log_msg(&format!(
"cpal playback started: device='{dev_name}' format={sample_format:?} channels={PLAYBACK_CHANNELS} rate={SAMPLE_RATE} Hz"
));
let logger = spawn_health_logger(
running.clone(),
ring_fill.clone(),
underrun.clone(),
dropped.clone(),
max_cb.clone(),
);
// Feed the ring from the network mixer until `stop()` flips `running` or the
// sender disconnects (call ended). Clock-paced production keeps the ring near
// target, so the drop path below should never fire in steady state.
drain_loop(&rx, &running, |frame| {
if ring_fill.load(Ordering::Relaxed) + frame.len() > RING_CAPACITY {
dropped.fetch_add(1, Ordering::Relaxed);
return;
}
for &sample in &frame {
let _ = producer.try_push(sample);
}
ring_fill.fetch_add(frame.len(), Ordering::Relaxed);
});
// We're shutting down (either stop() or disconnect). Ensure the logger sees it
// even on the disconnect path, then drop the stream.
running.store(false, Ordering::Relaxed);
let _ = logger.join();
drop(stream);
}
fn build_output<T, C>(
device: &Device,
config: &StreamConfig,
mut consumer: C,
ring_fill: Arc<AtomicUsize>,
underrun: Arc<AtomicU64>,
max_cb: Arc<AtomicUsize>,
) -> Result<Stream, AudioError>
where
T: SizedSample + FromSample<i16> + Send + 'static,
C: Consumer<Item = i16> + Send + 'static,
{
let err_fn = |e| crate::log_msg(&format!("cpal playback stream error: {e}"));
device
.build_output_stream::<T, _, _>(
config,
move |data: &mut [T], _| {
// Wait-free; the logger thread reads this off the RT path.
max_cb.fetch_max(data.len(), Ordering::Relaxed);
let (popped, starved) = fill_output(&mut consumer, data);
if starved > 0 {
underrun.fetch_add(starved, Ordering::Relaxed);
}
if popped > 0 {
// Decrement the exact occupancy by what we actually pulled
// (underruns removed nothing) so the mixer paces against the
// true ring depth.
ring_fill.fetch_sub(popped, Ordering::Relaxed);
}
},
err_fn,
None,
)
.map_err(|e| AudioError::Stream(e.to_string()))
}
/// Drain the ring into the device buffer, substituting silence on underrun.
/// Returns `(samples_popped, samples_starved)`. RT-safe (wait-free `try_pop`).
fn fill_output<T, C>(consumer: &mut C, out: &mut [T]) -> (usize, u64)
where
T: Sample + FromSample<i16>,
C: Consumer<Item = i16>,
{
let mut popped = 0usize;
let mut starved = 0u64;
for slot in out.iter_mut() {
match consumer.try_pop() {
Some(v) => {
*slot = T::from_sample(v);
popped += 1;
}
None => {
*slot = T::from_sample(0i16);
starved += 1;
}
}
}
(popped, starved)
}
/// Once-per-second playout-health line (mirrors the PipeWire backend). Quiet
/// unless a second actually glitched, or `PEERSPEAK_AUDIO_VERBOSE` is set.
fn spawn_health_logger(
running: Arc<AtomicBool>,
ring_fill: Arc<AtomicUsize>,
underrun: Arc<AtomicU64>,
dropped: Arc<AtomicU64>,
max_cb: Arc<AtomicUsize>,
) -> JoinHandle<()> {
let verbose = std::env::var_os("PEERSPEAK_AUDIO_VERBOSE").is_some();
thread::spawn(move || {
let (mut last_u, mut last_d) = (0u64, 0u64);
let mut reported_cb = 0usize;
while running.load(Ordering::Relaxed) {
thread::sleep(Duration::from_secs(1));
let u = underrun.load(Ordering::Relaxed);
let d = dropped.load(Ordering::Relaxed);
let fill = ring_fill.load(Ordering::Relaxed);
let (du, dd) = (u - last_u, d - last_d);
last_u = u;
last_d = d;
if verbose || du > 0 || dd > 0 {
crate::log_msg(&format!(
"playout-health: fill={fill} samples (~{}ms) | underrun +{du} samples/s (total {u}) | dropped +{dd} frames/s (total {d})",
fill / (48 * PLAYBACK_CHANNELS),
));
}
// Report the device's callback size the first time it's seen (and on
// any new high). If a callback asks for more than the prefill target,
// the ring can't satisfy it and underruns every cycle — the W2 bug
// signature; warn so a real-host log shows whether it's biting.
let cb = max_cb.load(Ordering::Relaxed);
if cb > reported_cb {
reported_cb = cb;
let ms = cb / (48 * PLAYBACK_CHANNELS);
if cb > PLAYBACK_TARGET_SAMPLES {
crate::log_msg(&format!(
"cpal output callback up to {cb} samples/cycle (~{ms}ms) EXCEEDS prefill target {PLAYBACK_TARGET_SAMPLES} — expect periodic underruns; needs a larger target or a fixed buffer size (review W2)",
));
} else if verbose {
crate::log_msg(&format!(
"cpal output callback up to {cb} samples/cycle (~{ms}ms), target {PLAYBACK_TARGET_SAMPLES}",
));
}
}
}
})
}
/// Pump frames from `rx` to `on_frame` until `running` goes false or the sender
/// disconnects. The timed receive re-checks `running` at least every
/// [`WORKER_POLL`], so `stop()` can join the worker promptly instead of hanging
/// on a parked blocking `recv()` (same A7 fix as the PipeWire backend). Pure
/// w.r.t. its inputs, so it's unit-testable.
fn drain_loop(rx: &Receiver<Vec<i16>>, running: &AtomicBool, mut on_frame: impl FnMut(Vec<i16>)) {
while running.load(Ordering::Relaxed) {
match rx.recv_timeout(WORKER_POLL) {
Ok(frame) => on_frame(frame),
Err(RecvTimeoutError::Timeout) => continue,
Err(RecvTimeoutError::Disconnected) => return,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
#[test]
fn downmix_averages_channels() {
assert_eq!(downmix_to_mono::<i16>(&[100, 100]), 100);
assert_eq!(downmix_to_mono::<i16>(&[100, -100]), 0);
assert_eq!(downmix_to_mono::<i16>(&[50]), 50);
assert_eq!(downmix_to_mono::<i16>(&[]), 0);
// 4-channel average rounds toward zero (integer division).
assert_eq!(downmix_to_mono::<i16>(&[10, 20, 30, 41]), 25);
}
#[test]
fn frame_accumulator_emits_full_frames() {
let mut acc = FrameAccumulator::new(3);
assert_eq!(acc.push(1), None);
assert_eq!(acc.push(2), None);
assert_eq!(acc.push(3), Some(vec![1, 2, 3]));
// Resets for the next frame.
assert_eq!(acc.push(4), None);
assert_eq!(acc.push(5), None);
assert_eq!(acc.push(6), Some(vec![4, 5, 6]));
}
#[test]
fn fill_output_pops_then_substitutes_silence() {
let rb = HeapRb::<i16>::new(8);
let (mut prod, mut cons) = rb.split();
for v in [1, 2, 3] {
prod.try_push(v).unwrap();
}
let mut out = [0i16; 5];
let (popped, starved) = fill_output(&mut cons, &mut out);
assert_eq!(popped, 3);
assert_eq!(starved, 2);
assert_eq!(out, [1, 2, 3, 0, 0]);
}
#[test]
fn drain_loop_exits_when_running_flips_even_with_sender_alive() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
let r2 = running.clone();
let h = thread::spawn(move || drain_loop(&rx, &r2, |_| {}));
thread::sleep(Duration::from_millis(50));
running.store(false, Ordering::Relaxed);
thread::sleep(WORKER_POLL + Duration::from_millis(150));
assert!(
h.is_finished(),
"drain_loop must exit after running=false even while the sender is alive"
);
drop(tx);
h.join().unwrap();
}
#[test]
fn drain_loop_returns_on_disconnect() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
drop(tx);
drain_loop(&rx, &running, |_| panic!("no frame should arrive"));
}
#[test]
fn drain_loop_delivers_frames() {
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let running = Arc::new(AtomicBool::new(true));
let r2 = running.clone();
let got = Arc::new(Mutex::new(Vec::new()));
let g2 = got.clone();
let h = thread::spawn(move || drain_loop(&rx, &r2, |f| g2.lock().unwrap().push(f)));
tx.send(vec![1, 2, 3]).unwrap();
tx.send(vec![4, 5]).unwrap();
thread::sleep(Duration::from_millis(50));
running.store(false, Ordering::Relaxed);
drop(tx);
h.join().unwrap();
assert_eq!(*got.lock().unwrap(), vec![vec![1, 2, 3], vec![4, 5]]);
}
}
+45 -1
View File
@@ -56,12 +56,56 @@ pub trait AudioBackend: Send + Sync {
fn stop(&self) -> Result<(), AudioError>; fn stop(&self) -> Result<(), AudioError>;
} }
pub mod echo_cancel;
pub mod eq; pub mod eq;
pub mod gate; pub mod gate;
pub mod limiter; pub mod limiter;
pub mod multitrack; pub mod multitrack;
pub mod pan; pub mod pan;
#[cfg(target_os = "linux")]
pub mod echo_cancel;
#[cfg(target_os = "linux")]
pub mod pipewire_impl; pub mod pipewire_impl;
#[cfg(windows)]
pub mod cpal_impl;
#[cfg(target_os = "linux")]
pub mod pw_cli; pub mod pw_cli;
pub mod recorder; pub mod recorder;
/// A selectable audio device for the input/output pickers. `name` is the stable
/// identifier the backend uses to request the device (`target_node`);
/// `description` is the human-facing label shown in the UI. The two may be equal
/// (cpal/WASAPI) or differ (PipeWire node name vs. description).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioDevice {
pub name: String,
pub description: String,
pub is_input: bool,
}
impl std::fmt::Display for AudioDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
// Enumerate audio input/output devices for the pickers (sorted by description),
// returning the same `AudioDevice` shape regardless of platform: PipeWire
// (`pw-cli`) on Linux, cpal/WASAPI on Windows.
#[cfg(target_os = "linux")]
pub use pw_cli::enumerate_audio_devices;
#[cfg(windows)]
pub use cpal_impl::enumerate_audio_devices;
/// The audio backend implementation for the current platform.
///
/// The whole app constructs and threads this alias (via
/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so
/// platform selection lives entirely here. Both implementations satisfy the
/// [`AudioBackend`] trait, which is the only interface the core talks to.
///
/// - Linux → PipeWire ([`pipewire_impl::PipeWireBackend`]).
/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]).
#[cfg(target_os = "linux")]
pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend;
#[cfg(windows)]
pub type PlatformAudioBackend = cpal_impl::CpalBackend;
+1 -13
View File
@@ -1,18 +1,6 @@
use super::AudioDevice;
use std::process::Command; use std::process::Command;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AudioDevice {
pub name: String,
pub description: String,
pub is_input: bool,
}
impl std::fmt::Display for AudioDevice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.description)
}
}
pub fn enumerate_audio_devices() -> Vec<AudioDevice> { pub fn enumerate_audio_devices() -> Vec<AudioDevice> {
let output = Command::new("pw-cli") let output = Command::new("pw-cli")
.arg("list-objects") .arg("list-objects")
+225 -99
View File
@@ -1,11 +1,11 @@
//! Audio playout diagnostic probe. //! Audio playout diagnostic probe.
//! //!
//! Drives a phase-continuous sine tone through the *real* PipeWire playback path //! Drives a phase-continuous sine tone through the *real* playback path
//! (`PipeWireBackend::start_playback`), using the *same* fill-paced production //! (PipeWire on Linux, cpal/WASAPI on Windows), using the *same* fill-paced
//! the production mixer uses (`core/mod.rs`): generate a frame only while the //! production the production mixer uses (`core/mod.rs`): generate a frame only while the
//! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the //! playback ring is below `PLAYBACK_TARGET_SAMPLES`, so production tracks the
//! PipeWire hardware clock. No network, no microphone — this isolates the local //! hardware clock. No network, no microphone — this isolates the local output
//! output path so we can confirm the clock-paced playout is glitch-free. //! path so we can confirm the clock-paced playout is glitch-free.
//! //!
//! Use your ears on the tone (any click/pop is a glitch) together with the //! Use your ears on the tone (any click/pop is a glitch) together with the
//! `playout-health:` lines tailed to stdout: //! `playout-health:` lines tailed to stdout:
@@ -17,105 +17,231 @@
//! //!
//! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node] //! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node]
//! e.g. cargo run --release --bin audio_probe -- 440 30 //! e.g. cargo run --release --bin audio_probe -- 440 30
//!
//! This probe exercises the platform playback backend directly: PipeWire on Linux
//! and cpal/WASAPI on Windows. Other targets use a stub that explains the limitation.
use std::io::{BufRead, BufReader, Seek, SeekFrom}; #[cfg(target_os = "linux")]
use std::sync::Arc; fn main() {
use std::sync::atomic::AtomicUsize; unix_probe::run();
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
} }
/// Open the app log, seek to the end, and echo new lines (the `playout-health:` #[cfg(windows)]
/// reports) to stdout once they appear. fn main() {
fn spawn_log_tailer() { win_probe::run();
let path = peerspeak::log_file_path(); }
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it). #[cfg(not(any(target_os = "linux", windows)))]
let file = loop { fn main() {
if let Ok(f) = std::fs::File::open(&path) { eprintln!("audio_probe is only supported on Linux and Windows builds (it drives the platform playback backend directly).");
break f; }
#[cfg(target_os = "linux")]
mod unix_probe {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::pipewire_impl::PipeWireBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = PipeWireBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the PipeWire hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
} }
std::thread::sleep(Duration::from_millis(100)); let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
}; for _ in 0..FRAME_SAMPLES {
let mut reader = BufReader::new(file); let t = n as f32 / SAMPLE_RATE;
let _ = reader.seek(SeekFrom::End(0)); // 0.25 amplitude: clearly audible but not harsh.
loop { let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
let mut line = String::new(); // Stereo playback bus: duplicate the probe tone to L/R.
match reader.read_line(&mut line) { frame.push(sample);
Ok(0) => std::thread::sleep(Duration::from_millis(150)), frame.push(sample);
Ok(_) => { n += 1;
if line.contains("playout-health:") { }
print!("{line}"); if tx.send(frame).is_err() {
} eprintln!("playback channel closed early");
break;
}
}
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
} }
Err(_) => std::thread::sleep(Duration::from_millis(150)), std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
});
}
}
#[cfg(windows)]
mod win_probe {
use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc;
use std::time::Duration;
use peerspeak::audio::AudioBackend;
use peerspeak::audio::cpal_impl::CpalBackend;
use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz
const SAMPLE_RATE: f32 = 48_000.0;
#[tokio::main]
pub async fn run() {
let mut args = std::env::args().skip(1);
let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0);
let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30);
let target_node: Option<String> = args.next();
// The playout-health logger is quiet in normal operation (it only logs
// glitches); ask it for the full once-per-second heartbeat so the probe can
// show the steady-state numbers.
// SAFETY: set before any playback thread starts, so no concurrent env read.
unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") };
println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path.");
println!("Listen for clicks/pops; watch the playout-health lines below.\n");
// Tail the app log (where playout-health lines land) to stdout in the
// background so it's all in one terminal.
spawn_log_tailer();
let backend = CpalBackend::new();
let (tx, rx) = mpsc::channel::<Vec<i16>>();
let ring_fill = Arc::new(AtomicUsize::new(0));
if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) {
eprintln!("failed to start playback: {e}");
return;
}
// Phase-continuous sine, generated one 20ms frame at a time, fill-paced
// exactly like the production mixer: only produce while the ring is below
// target, so production tracks the cpal/WASAPI hardware clock.
use std::sync::atomic::Ordering;
let deadline = tokio::time::Instant::now() + Duration::from_secs(secs);
let mut n: u64 = 0; // running sample index keeps phase continuous across frames
while tokio::time::Instant::now() < deadline {
if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES {
tokio::time::sleep(Duration::from_millis(2)).await;
continue;
}
let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS);
for _ in 0..FRAME_SAMPLES {
let t = n as f32 / SAMPLE_RATE;
// 0.25 amplitude: clearly audible but not harsh.
let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16;
// Stereo playback bus: duplicate the probe tone to L/R.
frame.push(sample);
frame.push(sample);
n += 1;
}
if tx.send(frame).is_err() {
eprintln!("playback channel closed early");
break;
} }
} }
});
// Let the ring drain, then stop.
tokio::time::sleep(Duration::from_millis(300)).await;
let _ = backend.stop();
println!("\naudio_probe: done.");
}
/// Open the app log, seek to the end, and echo new lines (the `playout-health:`
/// reports) to stdout once they appear.
fn spawn_log_tailer() {
let path = peerspeak::log_file_path();
std::thread::spawn(move || {
// Wait for the file to exist (first log_msg creates it).
let file = loop {
if let Ok(f) = std::fs::File::open(&path) {
break f;
}
std::thread::sleep(Duration::from_millis(100));
};
let mut reader = BufReader::new(file);
let _ = reader.seek(SeekFrom::End(0));
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => std::thread::sleep(Duration::from_millis(150)),
Ok(_) => {
if line.contains("playout-health:") {
print!("{line}");
}
}
Err(_) => std::thread::sleep(Duration::from_millis(150)),
}
}
});
}
} }
+4 -5
View File
@@ -124,11 +124,10 @@ pub enum UiEvent {
/// 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 },
/// The Discoverable time-box elapsed (W7 P6): the core auto-reverted our presence /// Core corrected the committed presence posture. Usually the Discoverable
/// posture to the carried `mode` (always `Normal`) and stopped publishing. The /// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
/// GUI must mirror + persist this so its presence picker stops showing /// failure, this carries the previous truthful mode. The GUI must mirror +
/// Discoverable. Distinct from a user-driven change so the GUI knows to update /// persist this so its presence picker matches the endpoint's discovery state.
/// without having issued the command itself.
PresenceModeReverted { mode: PresenceMode }, PresenceModeReverted { mode: PresenceMode },
/// Core finished orderly app shutdown and the GUI can exit. /// Core finished orderly app shutdown and the GUI can exit.
ShutdownComplete, ShutdownComplete,
+220 -50
View File
@@ -1,7 +1,7 @@
pub mod messages; pub mod messages;
pub mod jitter; pub mod jitter;
use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend}; use crate::audio::{AudioBackend, PlatformAudioBackend};
use crate::audio::eq::{Eq, EqSettings}; use crate::audio::eq::{Eq, EqSettings};
use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
@@ -13,6 +13,7 @@ use crate::network::{
use crate::core::messages::{CoreCommand, UiEvent}; use crate::core::messages::{CoreCommand, UiEvent};
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{NetworkMode, RecordingMode};
use crate::presence::PresenceMode;
use crate::audio::multitrack::MultitrackRecorder; use crate::audio::multitrack::MultitrackRecorder;
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router}; use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey, endpoint::presets, protocol::Router};
use iroh_gossip::net::Gossip; use iroh_gossip::net::Gossip;
@@ -64,6 +65,32 @@ impl CoreController {
/// clears from the room promptly. /// clears from the room promptly.
const RECONNECT_GRACE: Duration = Duration::from_secs(45); const RECONNECT_GRACE: Duration = Duration::from_secs(45);
/// Opus frames sent by our encoder are one 20 ms mono frame, normally far below
/// this. 4000 bytes still leaves room for large valid Opus packets (well above a
/// 48 kHz / 60 ms frame) while bounding malicious datagram copy/decode churn.
const MAX_OPUS_PAYLOAD: usize = 4000;
/// If the Discoverable time-box tries to revert but discovery service reconfiguration
/// fails, retry soon while keeping the UI in the still-possible publishing state.
const DISCOVERY_REVERT_RETRY: Duration = Duration::from_secs(60);
fn audio_datagram_len_ok(len: usize) -> bool {
(4..=4 + MAX_OPUS_PAYLOAD).contains(&len)
}
fn arm_discovery_retry(
discovery_deadline: &mut Option<tokio::time::Instant>,
now: tokio::time::Instant,
) {
let retry_deadline = now + DISCOVERY_REVERT_RETRY;
if discovery_deadline
.map(|current| current > retry_deadline)
.unwrap_or(true)
{
*discovery_deadline = Some(retry_deadline);
}
}
/// Per-peer reconnect grace timers (see [`RECONNECT_GRACE`]). Shared between the /// Per-peer reconnect grace timers (see [`RECONNECT_GRACE`]). Shared between the
/// room-event task (which arms one on a transient drop and cancels it on a /// room-event task (which arms one on a transient drop and cancels it on a
/// gossip rejoin) and the conn-event task (which cancels it when the audio link /// gossip rejoin) and the conn-event task (which cancels it when the audio link
@@ -110,6 +137,7 @@ fn arm_grace_timer(
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
tokio::time::sleep(grace).await; tokio::time::sleep(grace).await;
crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id)); crate::log_msg(&format!("Reconnect grace expired; evicting peer {:?}", peer_id));
transport_evict.remove_audio_sender(peer_id);
transport_evict.disconnect_peer(peer_id).await; transport_evict.disconnect_peer(peer_id).await;
jitter_evict.lock().await.remove(&peer_id); jitter_evict.lock().await.remove(&peer_id);
// Scrub our internal state *before* announcing the eviction, so anything // Scrub our internal state *before* announcing the eviction, so anything
@@ -209,7 +237,7 @@ fn run_mic_monitor(
/// Stops a standalone mic monitor if one is running. MUST NOT be called while a /// Stops a standalone mic monitor if one is running. MUST NOT be called while a
/// room session is active — `backend.stop()` would also tear down the call's /// room session is active — `backend.stop()` would also tear down the call's
/// capture/playback. Monitor and session are mutually exclusive by construction. /// capture/playback. Monitor and session are mutually exclusive by construction.
fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) { fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option<MicMonitor>) {
if let Some(m) = monitor { if let Some(m) = monitor {
let _ = backend.stop(); let _ = backend.stop();
let _ = m.thread.join(); let _ = m.thread.join();
@@ -343,6 +371,7 @@ impl ConnEventHandler {
// until the grace timer or the slow gossip Leave. // until the grace timer or the slow gossip Leave.
cancel_grace_timer(&self.grace_timers, &id); cancel_grace_timer(&self.grace_timers, &id);
self.seen_connected.lock().unwrap().remove(&id); self.seen_connected.lock().unwrap().remove(&id);
self.transport.remove_audio_sender(id);
self.transport.disconnect_peer(id).await; self.transport.disconnect_peer(id).await;
self.jitter.lock().await.remove(&id); self.jitter.lock().await.remove(&id);
let _ = self.ui_tx.send(UiEvent::PeerLeft { id }).await; let _ = self.ui_tx.send(UiEvent::PeerLeft { id }).await;
@@ -361,6 +390,7 @@ struct ActiveSession {
grace_timers: GraceTimers, grace_timers: GraceTimers,
transport: Arc<IrohTransport>, transport: Arc<IrohTransport>,
/// Loaded PipeWire echo-cancel module (if enabled); unloads on drop. /// Loaded PipeWire echo-cancel module (if enabled); unloads on drop.
#[cfg(target_os = "linux")]
echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>, echo_cancel: Option<crate::audio::echo_cancel::EchoCancelGuard>,
/// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it /// Our pixelpass screen-share host child while sharing (`kill_on_drop`, so it
/// also dies if the session is dropped without an explicit stop). /// also dies if the session is dropped without an explicit stop).
@@ -371,7 +401,7 @@ struct ActiveSession {
} }
impl ActiveSession { impl ActiveSession {
async fn shutdown(mut self, audio_backend: Arc<PipeWireBackend>) { async fn shutdown(mut self, audio_backend: Arc<PlatformAudioBackend>) {
crate::log_msg("ActiveSession::shutdown started"); crate::log_msg("ActiveSession::shutdown started");
// Tear down any screen-share children first so the host stops streaming // Tear down any screen-share children first so the host stops streaming
// promptly (kill_on_drop is the backstop, but kill explicitly so viewers // promptly (kill_on_drop is the backstop, but kill explicitly so viewers
@@ -402,6 +432,7 @@ impl ActiveSession {
// Unload the echo-cancel module now that the audio streams releasing its // Unload the echo-cancel module now that the audio streams releasing its
// virtual nodes have stopped. (Dropping the guard runs `pactl unload`.) // virtual nodes have stopped. (Dropping the guard runs `pactl unload`.)
#[cfg(target_os = "linux")]
drop(self.echo_cancel); drop(self.echo_cancel);
crate::log_msg("Leaving room..."); crate::log_msg("Leaving room...");
@@ -457,12 +488,11 @@ impl NetStack {
/// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the /// `DnsAddressLookup`, mirroring the `N0` preset) is added when `plan.resolver`; the
/// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`. /// n0 DNS *publisher* (`PkarrPublisher`) when `plan.publisher`.
/// ///
/// Idempotent and reversible: it clears the whole service set and reinstalls exactly /// Idempotent and reversible: it builds the replacement services first, then clears
/// what the plan wants, so flipping `publisher` off simply drops the publisher (its /// the service set and reinstalls exactly what the plan wants. Flipping `publisher`
/// republish task ends when the last clone is dropped, and the already-published /// off drops the publisher (its republish task ends when the last clone is dropped,
/// record TTL-expires within ~30s) without an endpoint rebuild and without disturbing /// and the already-published record TTL-expires within ~30s) without an endpoint
/// resolution. The brief clear→re-add window is a few synchronous calls; presence /// rebuild and without disturbing resolution.
/// toggles are rare, so a concurrent dial racing it is not a practical concern.
fn apply_discovery( fn apply_discovery(
endpoint: &Endpoint, endpoint: &Endpoint,
memory_lookup: &iroh::address_lookup::memory::MemoryLookup, memory_lookup: &iroh::address_lookup::memory::MemoryLookup,
@@ -473,16 +503,34 @@ fn apply_discovery(
pkarr::{PkarrPublisher, PkarrResolver}, pkarr::{PkarrPublisher, PkarrResolver},
}; };
let services = endpoint.address_lookup()?; let services = endpoint.address_lookup()?;
let pkarr_resolver = if plan.resolver {
Some(PkarrResolver::n0_dns().into_address_lookup(endpoint)?)
} else {
None
};
let dns_resolver = if plan.resolver {
Some(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?)
} else {
None
};
let publisher = if plan.publisher {
Some(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?)
} else {
None
};
services.clear(); services.clear();
// Always keep the local, server-free lookup (this is what ticket/gossip dialing // Always keep the local, server-free lookup (this is what ticket/gossip dialing
// depends on — it must survive every posture, including DirectOnly). // depends on — it must survive every posture, including DirectOnly).
services.add(memory_lookup.clone()); services.add(memory_lookup.clone());
if plan.resolver { if let Some(pkarr_resolver) = pkarr_resolver {
services.add(PkarrResolver::n0_dns().into_address_lookup(endpoint)?); services.add(pkarr_resolver);
services.add(DnsAddressLookup::n0_dns().into_address_lookup(endpoint)?);
} }
if plan.publisher { if let Some(dns_resolver) = dns_resolver {
services.add(PkarrPublisher::n0_dns().into_address_lookup(endpoint)?); services.add(dns_resolver);
}
if let Some(publisher) = publisher {
services.add(publisher);
} }
Ok(()) Ok(())
} }
@@ -538,7 +586,7 @@ async fn build_net_stack(
// report) is injected via `friends_handler`. // report) is injected via `friends_handler`.
let router = Router::builder(endpoint.clone()) let router = Router::builder(endpoint.clone())
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone()) .accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
.accept(b"peerspeak-audio", audio_router.clone()) .accept(crate::protocol::AUDIO_ALPN, audio_router.clone())
.accept( .accept(
crate::presence_net::FRIENDS_ALPN, crate::presence_net::FRIENDS_ALPN,
crate::presence_net::FriendsProtocol::new(friends_handler), crate::presence_net::FriendsProtocol::new(friends_handler),
@@ -634,7 +682,7 @@ async fn probe_friends_once(
let ep = endpoint.clone(); let ep = endpoint.clone();
set.spawn(async move { set.spawn(async move {
match crate::presence_net::probe(&ep, addr).await { match crate::presence_net::probe(&ep, addr).await {
Ok(reply) => crate::presence::interpret_pong(&reply).map(|p| (id, p)), Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)),
Err(_) => None, Err(_) => None,
} }
}); });
@@ -685,7 +733,7 @@ async fn run_core_loop(
let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> = let known_peers: Arc<std::sync::Mutex<HashMap<String, HashMap<EndpointId, EndpointAddr>>>> =
Arc::new(std::sync::Mutex::new(HashMap::new())); Arc::new(std::sync::Mutex::new(HashMap::new()));
let audio_backend = Arc::new(PipeWireBackend::new()); let audio_backend = Arc::new(PlatformAudioBackend::new());
let is_muted = Arc::new(AtomicBool::new(false)); let is_muted = Arc::new(AtomicBool::new(false));
let is_deafened = Arc::new(AtomicBool::new(false)); let is_deafened = Arc::new(AtomicBool::new(false));
@@ -842,22 +890,64 @@ async fn run_core_loop(
// W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX // W7 P6 time-box: Discoverable auto-reverts to Normal after DISCOVERY_TIMEBOX
// so a publish beacon never stands indefinitely. The branch is disabled // so a publish beacon never stands indefinitely. The branch is disabled
// (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable // (`if` guard) unless a deadline is armed; `unwrap_or_else` is unreachable
// belt-and-braces. On fire: stop publishing, drop to Normal, tell the GUI. // belt-and-braces. On fire: stop publishing first, then commit Normal only
// if the endpoint's discovery services accepted the non-publishing plan.
_ = tokio::time::sleep_until( _ = tokio::time::sleep_until(
discovery_deadline.unwrap_or_else(tokio::time::Instant::now), discovery_deadline.unwrap_or_else(tokio::time::Instant::now),
), if discovery_deadline.is_some() => { ), if discovery_deadline.is_some() => {
discovery_deadline = None; let previous_mode = *presence_mode.lock().unwrap();
*presence_mode.lock().unwrap() = crate::presence::PresenceMode::Normal; if previous_mode != PresenceMode::Discoverable {
let plan = crate::discovery::lookup_plan(network_mode, false); discovery_deadline = None;
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) { continue;
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}")); }
let requested_mode = PresenceMode::Normal;
let now = tokio::time::Instant::now();
let plan = crate::discovery::lookup_plan(
network_mode,
requested_mode.publishes_to_discovery(),
);
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
let (committed_mode, transition_error) =
crate::discovery::resolve_presence_transition(
previous_mode,
requested_mode,
apply_result.is_ok(),
);
*presence_mode.lock().unwrap() = committed_mode;
discovery_deadline = if committed_mode == PresenceMode::Discoverable {
Some(now + DISCOVERY_REVERT_RETRY)
} else {
None
};
match apply_result {
Ok(()) => {
crate::log_msg(
"discovery: Discoverable time-box elapsed → reverting to Normal",
);
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: PresenceMode::Normal,
})
.await;
}
Err(e) => {
crate::log_msg(&format!("discovery: time-box revert failed: {e:#}"));
if committed_mode != requested_mode {
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: committed_mode,
})
.await;
}
if let Some(message) = transition_error {
let _ = ui_tx
.send(UiEvent::Error(format!("{message} ({e:#})")))
.await;
}
}
} }
crate::log_msg("discovery: Discoverable time-box elapsed → reverting to Normal");
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: crate::presence::PresenceMode::Normal,
})
.await;
continue; continue;
} }
}; };
@@ -923,7 +1013,12 @@ async fn run_core_loop(
let ticket_str = if ticket.trim().is_empty() || ticket == "create" { let ticket_str = if ticket.trim().is_empty() || ticket == "create" {
let topic_id: [u8; 32] = rand::random(); let topic_id: [u8; 32] = rand::random();
let host_addr = endpoint.addr(); let host_addr = endpoint.addr();
crate::log_msg(&format!("Creating room. host_addr={:?}, topic_id={:?}", host_addr, topic_id)); crate::log_msg(&format!(
"Creating room. host_id={}, host_addrs={}, topic={}",
crate::short_id(&host_addr.id.to_string()),
host_addr.addrs.len(),
crate::short_bytes_hex(&topic_id)
));
// The creator's chosen cosmetic label rides in the ticket so // The creator's chosen cosmetic label rides in the ticket so
// every joiner inherits it; sanitize it before it leaves here. // every joiner inherits it; sanitize it before it leaves here.
let label = crate::sanitize::sanitize_name(&room_name); let label = crate::sanitize::sanitize_name(&room_name);
@@ -931,7 +1026,10 @@ async fn run_core_loop(
ticket.to_string() ticket.to_string()
} else { } else {
let ticket_str = ticket.trim().to_string(); let ticket_str = ticket.trim().to_string();
crate::log_msg(&format!("Joining room with existing ticket={}", ticket_str)); crate::log_msg(&format!(
"Joining room with existing ticket={}",
crate::redact_for_log(&ticket_str)
));
ticket_str ticket_str
}; };
@@ -970,7 +1068,17 @@ async fn run_core_loop(
.map(|peers| peers.values().cloned().collect()) .map(|peers| peers.values().cloned().collect())
.unwrap_or_default(); .unwrap_or_default();
crate::log_msg(&format!("Attempting room_state.join with self_state={:?}, extra_bootstrap={:?}", self_state, extra_bootstrap.iter().map(|a| a.id).collect::<Vec<_>>())); let extra_bootstrap_ids = extra_bootstrap
.iter()
.map(|a| crate::short_id(&a.id.to_string()))
.collect::<Vec<_>>();
crate::log_msg(&format!(
"Attempting room_state.join self_id={}, self_name={:?}, sharing={}, extra_bootstrap={:?}",
crate::short_id(&self_state.addr.id.to_string()),
self_state.name,
self_state.sharing.is_some(),
extra_bootstrap_ids
));
if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await { if let Err(e) = room_state.join(&ticket_str, self_state.clone(), extra_bootstrap).await {
crate::log_msg(&format!("Error room_state.join failed: {:?}", e)); crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await; let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
@@ -989,7 +1097,9 @@ async fn run_core_loop(
// The guard unloads the module on drop — including the early-return // The guard unloads the module on drop — including the early-return
// paths below, since it's a local until moved into the session. On // paths below, since it's a local until moved into the session. On
// any failure, warn and fall back to the direct devices. // any failure, warn and fall back to the direct devices.
#[cfg(target_os = "linux")]
let mut echo_cancel_guard = None; let mut echo_cancel_guard = None;
#[cfg(target_os = "linux")]
let (capture_target, playback_target) = if echo_cancellation { let (capture_target, playback_target) = if echo_cancellation {
match crate::audio::echo_cancel::enable( match crate::audio::echo_cancel::enable(
input_device.as_deref(), input_device.as_deref(),
@@ -1016,6 +1126,10 @@ async fn run_core_loop(
} else { } else {
(input_device.clone(), output_device.clone()) (input_device.clone(), output_device.clone())
}; };
#[cfg(not(target_os = "linux"))]
let _ = echo_cancellation;
#[cfg(not(target_os = "linux"))]
let (capture_target, playback_target) = (input_device.clone(), output_device.clone());
if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) { if let Err(e) = audio_backend.start_capture(capture_tx, capture_target) {
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await; let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
@@ -1135,8 +1249,12 @@ async fn run_core_loop(
}; };
while let Some((from_peer, bytes)) = datagram_rx.recv().await { while let Some((from_peer, bytes)) = datagram_rx.recv().await {
if bytes.len() < 4 { if !transport_recv.audio_sender_admitted(from_peer) {
continue; // malformed: missing sequence header continue;
}
if !audio_datagram_len_ok(bytes.len()) {
// Malformed (< sequence header) or oversized Opus payload.
continue;
} }
let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); let seq = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let payload = bytes[4..].to_vec(); let payload = bytes[4..].to_vec();
@@ -1368,6 +1486,7 @@ async fn run_core_loop(
// A (re)join means the peer is back — cancel any // A (re)join means the peer is back — cancel any
// pending reconnect grace timer before re-adding it. // pending reconnect grace timer before re-adding it.
cancel_grace_timer(&grace_timers_events, &peer_id); cancel_grace_timer(&grace_timers_events, &peer_id);
transport_events.admit_audio_sender(peer_id);
// Establish the audio connection as soon as the peer // Establish the audio connection as soon as the peer
// is known (the transport dedupes the full-mesh race). // is known (the transport dedupes the full-mesh race).
// Hand over the full address so reconnects can dial // Hand over the full address so reconnects can dial
@@ -1419,6 +1538,7 @@ async fn run_core_loop(
{ {
peers.remove(&peer_id); peers.remove(&peer_id);
} }
transport_events.remove_audio_sender(peer_id);
transport_events.disconnect_peer(peer_id).await; transport_events.disconnect_peer(peer_id).await;
jitter_events.lock().await.remove(&peer_id); jitter_events.lock().await.remove(&peer_id);
let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await; let _ = ui_tx_events.send(UiEvent::PeerLeft { id: peer_id }).await;
@@ -1431,6 +1551,7 @@ async fn run_core_loop(
// it. Idempotent: an ordinary mute/unmute update just // it. Idempotent: an ordinary mute/unmute update just
// re-records the same address. // re-records the same address.
cancel_grace_timer(&grace_timers_events, &peer_id); cancel_grace_timer(&grace_timers_events, &peer_id);
transport_events.admit_audio_sender(peer_id);
transport_events.connect_peer(state.addr.clone()).await; transport_events.connect_peer(state.addr.clone()).await;
// Auto-heal a friend's saved address (W7) on the // Auto-heal a friend's saved address (W7) on the
// re-announce too — this is the path that catches a // re-announce too — this is the path that catches a
@@ -1472,6 +1593,7 @@ async fn run_core_loop(
// hasn't recovered within RECONNECT_GRACE. A gossip // hasn't recovered within RECONNECT_GRACE. A gossip
// rejoin (PeerJoined/PeerUpdated) or a transport // rejoin (PeerJoined/PeerUpdated) or a transport
// reconnect (ConnEvent::Connected) cancels it first. // reconnect (ConnEvent::Connected) cancels it first.
transport_events.keep_audio_sender_for_reconnect_grace(peer_id);
let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await; let _ = ui_tx_events.send(UiEvent::PeerConnecting { id: peer_id }).await;
arm_grace_timer( arm_grace_timer(
&grace_timers_events, &grace_timers_events,
@@ -1518,6 +1640,7 @@ async fn run_core_loop(
conn_event_task, conn_event_task,
grace_timers, grace_timers,
transport: transport.clone(), transport: transport.clone(),
#[cfg(target_os = "linux")]
echo_cancel: echo_cancel_guard, echo_cancel: echo_cancel_guard,
screenshare_host: None, screenshare_host: None,
screenshare_viewers: Vec::new(), screenshare_viewers: Vec::new(),
@@ -1770,22 +1893,60 @@ async fn run_core_loop(
} }
CoreCommand::SetPresenceMode(mode) => { CoreCommand::SetPresenceMode(mode) => {
*presence_mode.lock().unwrap() = mode; let previous_mode = *presence_mode.lock().unwrap();
// W7 P6: re-apply n0 DNS discovery for the new posture (publish on iff let now = tokio::time::Instant::now();
// Discoverable). Runtime — no endpoint rebuild; clears + reinstalls the
// address-lookup services. The resolver stays on regardless so we can if previous_mode == mode {
// still look up moved friends. // Same-mode requests are no-ops for discovery wiring, but keep the
let plan = crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery()); // existing UX: re-selecting Discoverable restarts the clock.
if let Err(e) = apply_discovery(&net.endpoint, &net.memory_lookup, plan) { discovery_deadline = if mode == PresenceMode::Discoverable {
crate::log_msg(&format!("discovery: apply failed: {e:#}")); Some(now + crate::discovery::DISCOVERY_TIMEBOX)
} else {
None
};
continue;
} }
// Arm (Discoverable) or cancel (any other posture) the auto-revert
// time-box. Re-selecting Discoverable restarts the clock. // W7 P6/S11: re-apply n0 DNS discovery for the requested posture
discovery_deadline = if mode == crate::presence::PresenceMode::Discoverable { // first, then commit the presence mode only if the endpoint accepted
Some(tokio::time::Instant::now() + crate::discovery::DISCOVERY_TIMEBOX) // that discovery plan. This keeps the UI truthful when dropping the
// publisher fails.
let plan =
crate::discovery::lookup_plan(network_mode, mode.publishes_to_discovery());
let apply_result = apply_discovery(&net.endpoint, &net.memory_lookup, plan);
let (committed_mode, transition_error) =
crate::discovery::resolve_presence_transition(
previous_mode,
mode,
apply_result.is_ok(),
);
*presence_mode.lock().unwrap() = committed_mode;
if committed_mode == PresenceMode::Discoverable {
if apply_result.is_ok() && mode == PresenceMode::Discoverable {
discovery_deadline = Some(now + crate::discovery::DISCOVERY_TIMEBOX);
} else {
arm_discovery_retry(&mut discovery_deadline, now);
}
} else { } else {
None discovery_deadline = None;
}; }
if let Err(e) = apply_result {
crate::log_msg(&format!("discovery: apply failed: {e:#}"));
if committed_mode != mode {
let _ = ui_tx
.send(UiEvent::PresenceModeReverted {
mode: committed_mode,
})
.await;
}
if let Some(message) = transition_error {
let _ = ui_tx
.send(UiEvent::Error(format!("{message} ({e:#})")))
.await;
}
}
} }
CoreCommand::SetRecordingMode(mode) => { CoreCommand::SetRecordingMode(mode) => {
@@ -1987,8 +2148,8 @@ async fn run_core_loop(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
apply_volume, frame_level, mix_frames, mix_stereo_frames, stereo_to_mono, MicLevelMeter, apply_volume, audio_datagram_len_ok, frame_level, mix_frames, mix_stereo_frames,
MIC_LEVEL_REPORT_SAMPLES, stereo_to_mono, MicLevelMeter, MAX_OPUS_PAYLOAD, MIC_LEVEL_REPORT_SAMPLES,
}; };
/// A frame of constant amplitude with the given sample count. /// A frame of constant amplitude with the given sample count.
@@ -2005,6 +2166,15 @@ mod tests {
assert!(m.push(&frame(1000, MIC_LEVEL_REPORT_SAMPLES)).is_some()); assert!(m.push(&frame(1000, MIC_LEVEL_REPORT_SAMPLES)).is_some());
} }
#[test]
fn audio_datagram_length_gate_preserves_header_and_caps_payload() {
assert!(!audio_datagram_len_ok(0));
assert!(!audio_datagram_len_ok(3));
assert!(audio_datagram_len_ok(4));
assert!(audio_datagram_len_ok(4 + MAX_OPUS_PAYLOAD));
assert!(!audio_datagram_len_ok(5 + MAX_OPUS_PAYLOAD));
}
#[test] #[test]
fn mic_meter_holds_the_peak_across_the_window() { fn mic_meter_holds_the_peak_across_the_window() {
let mut m = MicLevelMeter::new(); let mut m = MicLevelMeter::new();
+96 -10
View File
@@ -8,14 +8,19 @@
//! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16): //! The model (from `docs/contacts-plan.md` P6, decided 2026-06-16):
//! - **Resolving is always allowed on relay-capable modes** — a stationary friend //! - **Resolving is always allowed on relay-capable modes** — a stationary friend
//! (typically in `Normal`) must be able to look up a friend who moved networks. A //! (typically in `Normal`) must be able to look up a friend who moved networks. A
//! resolve is a DNS query to n0 that publishes nothing; it only fires when a saved //! resolve is a DNS query to n0 that publishes nothing, but still exposes query
//! address is stale and the dial falls through to discovery. //! timing/source metadata to n0; it only fires when a saved address is stale and
//! the dial falls through to discovery.
//! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover //! - **Publishing is gated on `Discoverable`** and asymmetric: only the mover
//! publishes their address to n0 DNS; everyone else just looks it up. //! publishes their address to n0 DNS; everyone else just looks it up.
//! - **Stopping publishing removes the local publisher service**; iroh does not
//! expose an explicit unpublish call here, so already-published pkarr records can
//! linger until their default ~30s TTL expires.
//! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish //! - **`DirectOnly` is the explicit no-server posture** — neither resolve nor publish
//! ever touches n0 there, regardless of the Discoverable toggle. //! ever touches n0 there, regardless of the Discoverable toggle.
use crate::config::NetworkMode; use crate::config::NetworkMode;
use crate::presence::PresenceMode;
use std::time::Duration; use std::time::Duration;
/// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is /// How long `Discoverable` stays on before auto-reverting to `Normal`. Discovery is
@@ -46,12 +51,43 @@ pub fn lookup_plan(network_mode: NetworkMode, want_publish: bool) -> LookupPlan
match network_mode { match network_mode {
// The explicit serverless posture: no n0 contact at all, even to resolve. // The explicit serverless posture: no n0 contact at all, even to resolve.
// A Discoverable toggle here is intentionally inert. // A Discoverable toggle here is intentionally inert.
NetworkMode::DirectOnly => LookupPlan { resolver: false, publisher: false }, NetworkMode::DirectOnly => LookupPlan {
resolver: false,
publisher: false,
},
// Relay-capable: always resolve (so a stationary friend can find a mover); // Relay-capable: always resolve (so a stationary friend can find a mover);
// publish only when the user opted into Discoverable. // publish only when the user opted into Discoverable.
NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => { NetworkMode::RelayNoDiscovery | NetworkMode::N0Full => LookupPlan {
LookupPlan { resolver: true, publisher: want_publish } resolver: true,
} publisher: want_publish,
},
}
}
/// Decide which presence mode may be committed after attempting to apply discovery
/// services for `requested`.
///
/// On failure, keep the previous mode: it is the only locally truthful state because
/// the endpoint's discovery services may still reflect the old posture. Same-mode
/// requests are no-ops from a presence-truth perspective and do not surface an error.
pub fn resolve_presence_transition(
previous: PresenceMode,
requested: PresenceMode,
apply_ok: bool,
) -> (PresenceMode, Option<String>) {
if previous == requested {
return (previous, None);
}
if apply_ok {
(requested, None)
} else {
(
previous,
Some(format!(
"Couldn't update discovery mode; keeping {previous}."
)),
)
} }
} }
@@ -64,12 +100,18 @@ mod tests {
for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] { for mode in [NetworkMode::RelayNoDiscovery, NetworkMode::N0Full] {
assert_eq!( assert_eq!(
lookup_plan(mode, false), lookup_plan(mode, false),
LookupPlan { resolver: true, publisher: false }, LookupPlan {
resolver: true,
publisher: false
},
"{mode:?}: resolve always on, no publish when not Discoverable" "{mode:?}: resolve always on, no publish when not Discoverable"
); );
assert_eq!( assert_eq!(
lookup_plan(mode, true), lookup_plan(mode, true),
LookupPlan { resolver: true, publisher: true }, LookupPlan {
resolver: true,
publisher: true
},
"{mode:?}: Discoverable adds publish on top of resolve" "{mode:?}: Discoverable adds publish on top of resolve"
); );
} }
@@ -79,12 +121,18 @@ mod tests {
fn direct_only_never_touches_n0_even_when_discoverable() { fn direct_only_never_touches_n0_even_when_discoverable() {
assert_eq!( assert_eq!(
lookup_plan(NetworkMode::DirectOnly, false), lookup_plan(NetworkMode::DirectOnly, false),
LookupPlan { resolver: false, publisher: false } LookupPlan {
resolver: false,
publisher: false
}
); );
// The serverless posture overrides the Discoverable request entirely. // The serverless posture overrides the Discoverable request entirely.
assert_eq!( assert_eq!(
lookup_plan(NetworkMode::DirectOnly, true), lookup_plan(NetworkMode::DirectOnly, true),
LookupPlan { resolver: false, publisher: false } LookupPlan {
resolver: false,
publisher: false
}
); );
} }
@@ -92,4 +140,42 @@ mod tests {
fn timebox_is_thirty_minutes() { fn timebox_is_thirty_minutes() {
assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800)); assert_eq!(DISCOVERY_TIMEBOX, Duration::from_secs(1800));
} }
#[test]
fn presence_transition_commits_requested_mode_after_successful_apply() {
assert_eq!(
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, true),
(PresenceMode::Discoverable, None)
);
}
#[test]
fn presence_transition_keeps_previous_mode_when_apply_fails() {
let (mode, err) =
resolve_presence_transition(PresenceMode::Normal, PresenceMode::Discoverable, false);
assert_eq!(mode, PresenceMode::Normal);
assert!(err.unwrap().contains("keeping Normal"));
}
#[test]
fn presence_transition_keeps_discoverable_when_off_transition_fails() {
let (mode, err) =
resolve_presence_transition(PresenceMode::Discoverable, PresenceMode::Normal, false);
assert_eq!(mode, PresenceMode::Discoverable);
assert!(err.unwrap().contains("keeping Discoverable"));
}
#[test]
fn presence_transition_same_mode_is_noop_without_error() {
assert_eq!(
resolve_presence_transition(
PresenceMode::Discoverable,
PresenceMode::Discoverable,
false
),
(PresenceMode::Discoverable, None)
);
}
} }
+135 -6
View File
@@ -2,6 +2,7 @@ pub mod audio;
pub mod codec; pub mod codec;
pub mod dsp; pub mod dsp;
pub mod network; pub mod network;
pub mod protocol;
pub mod core; pub mod core;
pub mod app; pub mod app;
pub mod config; pub mod config;
@@ -18,9 +19,16 @@ pub mod recents;
pub mod discovery; pub mod discovery;
pub mod hotkeys; pub mod hotkeys;
use std::path::PathBuf; use std::fs::File;
use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024;
// Owner-only log permissions are a Unix concept (mode bits); on Windows the log
// inherits the directory's default ACL. Only referenced under `cfg(unix)`.
#[cfg(unix)]
const LOG_MODE: u32 = 0o600;
/// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log` /// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log`
/// (via `dirs::state_dir`), falling back to the system temp dir. Computed lazily /// (via `dirs::state_dir`), falling back to the system temp dir. Computed lazily
/// so we never hardcode a per-user path. /// so we never hardcode a per-user path.
@@ -43,6 +51,74 @@ pub fn log_file_path() -> PathBuf {
log_path().clone() log_path().clone()
} }
/// Short, human-matchable id prefix for diagnostics. Never use this where the
/// full value is needed for protocol behavior.
pub fn short_id(id: &str) -> String {
id.chars().take(8).collect()
}
/// Redact a capability-bearing value for logs while keeping a tiny prefix for
/// support correlation. Tickets and endpoint addresses are bearer capabilities:
/// logging the full string is equivalent to leaking the room/share.
pub fn redact_for_log(value: &str) -> String {
let value = value.trim();
if value.is_empty() {
"<redacted:empty>".to_string()
} else {
format!("<redacted:{}...>", short_id(value))
}
}
pub fn short_bytes_hex(bytes: &[u8]) -> String {
bytes.iter()
.take(6)
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join("")
}
fn rotated_log_path(path: &Path) -> PathBuf {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("peerspeak.log");
path.with_file_name(format!("{file_name}.1"))
}
fn prepare_log_file(path: &Path) -> std::io::Result<File> {
prepare_log_file_with_limit(path, LOG_MAX_BYTES)
}
fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result<File> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::metadata(path).is_ok_and(|m| m.len() > max_bytes) {
let rotated = rotated_log_path(path);
let _ = std::fs::remove_file(&rotated);
if std::fs::rename(path, &rotated).is_err() {
let _ = std::fs::OpenOptions::new().write(true).truncate(true).open(path);
}
}
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
// The log can carry capability-bearing values (redacted, but still): keep it
// owner-only on Unix via the open mode. Windows has no mode bits; it inherits
// the directory ACL, so this hardening is Unix-only.
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(LOG_MODE);
}
let file = opts.open(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Re-assert the mode in case the file pre-existed with looser perms.
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(LOG_MODE));
}
Ok(file)
}
pub fn log_msg(msg: &str) { pub fn log_msg(msg: &str) {
// Format the whole line into one buffer first, then emit it with a single // Format the whole line into one buffer first, then emit it with a single
// `write_all`. The file is opened with `O_APPEND`, so a lone `write()` is // `write_all`. The file is opened with `O_APPEND`, so a lone `write()` is
@@ -52,12 +128,65 @@ pub fn log_msg(msg: &str) {
Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg), Ok(time) => format!("[{}.{:03}] {}\n", time.as_secs(), time.subsec_millis(), msg),
Err(_) => format!("{}\n", msg), Err(_) => format!("{}\n", msg),
}; };
if let Ok(mut file) = std::fs::OpenOptions::new() if let Ok(mut file) = prepare_log_file(log_path()) {
.create(true)
.append(true)
.open(log_path())
{
use std::io::Write; use std::io::Write;
let _ = file.write_all(line.as_bytes()); let _ = file.write_all(line.as_bytes());
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn temp_log_dir() -> PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("peerspeak-log-test-{}-{stamp}", std::process::id()))
}
#[test]
fn redaction_keeps_only_a_short_prefix() {
let secret = "abcdefghijklmnopqrstuvwxyz";
let redacted = redact_for_log(secret);
assert!(redacted.contains("abcdefgh"));
assert!(!redacted.contains("ijklmnopqrstuvwxyz"));
assert_eq!(redact_for_log(" "), "<redacted:empty>");
}
// Owner-only log perms are a Unix concept; on Windows the file inherits the
// directory ACL and there's no mode to assert.
#[cfg(unix)]
#[test]
fn log_file_is_created_private() {
let dir = temp_log_dir();
let path = dir.join("peerspeak.log");
let _file = prepare_log_file(&path).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, LOG_MODE);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn oversized_log_is_rotated_on_open() {
let dir = temp_log_dir();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("peerspeak.log");
{
let mut file = std::fs::File::create(&path).unwrap();
file.write_all(b"oversized").unwrap();
}
let _file = prepare_log_file_with_limit(&path, 4).unwrap();
let rotated = rotated_log_path(&path);
assert_eq!(std::fs::read_to_string(rotated).unwrap(), "oversized");
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
let _ = std::fs::remove_dir_all(dir);
}
}
+174 -15
View File
@@ -12,7 +12,7 @@ use serde::{Serialize, Deserialize};
/// Domain-separation tag mixed into every signed gossip payload so a signature /// Domain-separation tag mixed into every signed gossip payload so a signature
/// can never be lifted out of this protocol/version into another context. /// can never be lifted out of this protocol/version into another context.
const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1"; use crate::protocol::GOSSIP_SIG_DOMAIN;
/// How far a payload's sender-stamped timestamp may differ from local time /// How far a payload's sender-stamped timestamp may differ from local time
/// before it's rejected as stale (replayed) or implausibly future. Bounds the /// before it's rejected as stale (replayed) or implausibly future. Bounds the
@@ -43,7 +43,7 @@ impl std::fmt::Debug for GossipPayload {
f.debug_struct("GossipPayload") f.debug_struct("GossipPayload")
.field("author", &self.author) .field("author", &self.author)
.field("ts", &self.ts) .field("ts", &self.ts)
.field("msg", &self.msg) .field("msg_kind", &gossip_message_kind(&self.msg))
.finish_non_exhaustive() .finish_non_exhaustive()
} }
} }
@@ -79,6 +79,58 @@ enum GossipReject {
BadSignature, BadSignature,
/// Timestamp outside the freshness window — stale (replay) or implausibly future. /// Timestamp outside the freshness window — stale (replay) or implausibly future.
OutOfWindow, OutOfWindow,
/// A signed Announce advertised an address for a different node id.
AnnounceAddressMismatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum StateMutationKind {
Announce,
Leave,
}
fn gossip_message_kind(msg: &GossipMessage) -> &'static str {
match msg {
GossipMessage::Announce(_) => "Announce",
GossipMessage::Leave => "Leave",
GossipMessage::Chat { .. } => "Chat",
}
}
fn state_mutation_kind(msg: &GossipMessage) -> Option<StateMutationKind> {
match msg {
GossipMessage::Announce(_) => Some(StateMutationKind::Announce),
GossipMessage::Leave => Some(StateMutationKind::Leave),
GossipMessage::Chat { .. } => None,
}
}
fn admit_state_mutation(
seen: &mut HashMap<(EndpointId, StateMutationKind), u64>,
author: EndpointId,
msg: &GossipMessage,
ts: u64,
) -> bool {
let Some(kind) = state_mutation_kind(msg) else {
return true;
};
let key = (author, kind);
if seen.get(&key).is_some_and(|last_ts| ts <= *last_ts) {
return false;
}
seen.insert(key, ts);
true
}
fn peer_state_for_log(state: &PeerState) -> String {
format!(
"name={:?}, muted={}, addr_id={}, addrs={}, sharing={}",
state.name,
state.is_muted,
crate::short_id(&state.addr.id.to_string()),
state.addr.addrs.len(),
state.sharing.is_some()
)
} }
/// Authenticate a received payload against the room topic and local clock. The /// Authenticate a received payload against the room topic and local clock. The
@@ -99,6 +151,10 @@ fn verify_gossip(
if now_ms.abs_diff(payload.ts) > window_ms { if now_ms.abs_diff(payload.ts) > window_ms {
return Err(GossipReject::OutOfWindow); return Err(GossipReject::OutOfWindow);
} }
if let GossipMessage::Announce(state) = &payload.msg
&& state.addr.id != payload.author {
return Err(GossipReject::AnnounceAddressMismatch);
}
Ok(()) Ok(())
} }
@@ -185,11 +241,25 @@ impl RoomState for IrohGossipState {
self_state: PeerState, self_state: PeerState,
extra_bootstrap: Vec<EndpointAddr>, extra_bootstrap: Vec<EndpointAddr>,
) -> Result<(), NetError> { ) -> Result<(), NetError> {
crate::log_msg(&format!("RoomState::join: self_id={:?}, self_name={:?}, ticket={}", self_state.addr.id, self_state.name, ticket_str)); crate::log_msg(&format!(
"RoomState::join: self_id={}, self_name={:?}, ticket={}",
crate::short_id(&self_state.addr.id.to_string()),
self_state.name,
crate::redact_for_log(ticket_str)
));
let ticket = ticket_str.parse::<PeerSpeakTicket>()?; let ticket = ticket_str.parse::<PeerSpeakTicket>()?;
let topic_id = TopicId::from_bytes(ticket.topic_id); // Version-namespace the subscribed topic (VERSIONING.md): peers on a
// different gossip protocol version derive a different topic from the same
// ticket and never share a swarm. The raw ticket.topic_id stays the room
// identity (and what signatures bind, below).
let topic_id = TopicId::from_bytes(crate::protocol::versioned_topic(ticket.topic_id));
crate::log_msg(&format!("Parsed ticket. host_id={:?}, host_addrs={:?}, topic={:?}", ticket.host_addr.id, ticket.host_addr.addrs, topic_id)); crate::log_msg(&format!(
"Parsed ticket. host_id={}, host_addrs={}, topic={}",
crate::short_id(&ticket.host_addr.id.to_string()),
ticket.host_addr.addrs.len(),
crate::short_bytes_hex(&ticket.topic_id)
));
// Stop any currently running topic // Stop any currently running topic
let _ = self.leave().await; let _ = self.leave().await;
@@ -236,6 +306,7 @@ impl RoomState for IrohGossipState {
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id)); crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
let mut state_mutations_seen = HashMap::new();
// Broadcast initial state // Broadcast initial state
let initial_payload = { let initial_payload = {
@@ -285,7 +356,26 @@ impl RoomState for IrohGossipState {
continue; continue;
} }
crate::log_msg(&format!("Gossip Event::Received from author={:?}, payload={:?}", payload.author, payload.msg)); if !admit_state_mutation(
&mut state_mutations_seen,
payload.author,
&payload.msg,
payload.ts,
) {
crate::log_msg(&format!(
"Gossip dropped replayed state mutation author={}, kind={}, ts={}",
crate::short_id(&payload.author.to_string()),
gossip_message_kind(&payload.msg),
payload.ts
));
continue;
}
crate::log_msg(&format!(
"Gossip Event::Received author={}, kind={}",
crate::short_id(&payload.author.to_string()),
gossip_message_kind(&payload.msg)
));
match payload.msg { match payload.msg {
GossipMessage::Announce(mut state) => { GossipMessage::Announce(mut state) => {
@@ -299,6 +389,10 @@ impl RoomState for IrohGossipState {
// monogram, so a malformed/oversized/bomb // monogram, so a malformed/oversized/bomb
// image can't crash or exhaust us (W4). // image can't crash or exhaust us (W4).
state.avatar = state.avatar.sanitize_incoming(); state.avatar = state.avatar.sanitize_incoming();
// Screen-share tickets are capabilities and
// peer-supplied: cap/validate once at ingest
// so invalid offers never render a Watch button.
state.sharing = state.sharing.and_then(crate::screenshare::sanitize_ticket);
let (is_new, state_changed) = { let (is_new, state_changed) = {
let mut peer_map = peers.lock().unwrap(); let mut peer_map = peers.lock().unwrap();
let is_new = !peer_map.contains_key(&payload.author); let is_new = !peer_map.contains_key(&payload.author);
@@ -310,11 +404,19 @@ impl RoomState for IrohGossipState {
}; };
if is_new { if is_new {
crate::log_msg(&format!("Gossip new peer joined: {:?}, state: {:?}", payload.author, state)); crate::log_msg(&format!(
"Gossip new peer joined: {}, state: {}",
crate::short_id(&payload.author.to_string()),
peer_state_for_log(&state)
));
address_lookup.add_endpoint_info(state.addr.clone()); address_lookup.add_endpoint_info(state.addr.clone());
let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await; let _ = event_tx.send(RoomEvent::PeerJoined(payload.author, state)).await;
} else if state_changed { } else if state_changed {
crate::log_msg(&format!("Gossip peer state updated: {:?}, state: {:?}", payload.author, state)); crate::log_msg(&format!(
"Gossip peer state updated: {}, state: {}",
crate::short_id(&payload.author.to_string()),
peer_state_for_log(&state)
));
let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await; let _ = event_tx.send(RoomEvent::PeerUpdated(payload.author, state)).await;
} }
} }
@@ -390,7 +492,10 @@ impl RoomState for IrohGossipState {
} }
async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> { async fn update_self_state(&self, self_state: PeerState) -> Result<(), NetError> {
crate::log_msg(&format!("RoomState::update_self_state: state={:?}", self_state)); crate::log_msg(&format!(
"RoomState::update_self_state: state: {}",
peer_state_for_log(&self_state)
));
*self.self_state.lock().unwrap() = Some(self_state.clone()); *self.self_state.lock().unwrap() = Some(self_state.clone());
let sender_opt = self.active_sender.lock().unwrap().clone(); let sender_opt = self.active_sender.lock().unwrap().clone();
@@ -489,11 +594,10 @@ mod tests {
use super::*; use super::*;
use crate::network::PeerState; use crate::network::PeerState;
use iroh::SecretKey; use iroh::SecretKey;
use std::collections::HashMap;
fn sample_peer_state() -> PeerState { fn sample_peer_state_for(id: EndpointId) -> PeerState {
let secret = SecretKey::generate(); let addr = iroh::EndpointAddr::from(id);
let public = secret.public();
let addr = iroh::EndpointAddr::from(public);
PeerState { PeerState {
name: "TestPeerGossip".to_string(), name: "TestPeerGossip".to_string(),
is_muted: true, is_muted: true,
@@ -563,7 +667,7 @@ mod tests {
fn test_gossip_payload_announce_round_trip() { fn test_gossip_payload_announce_round_trip() {
let secret = SecretKey::generate(); let secret = SecretKey::generate();
let topic = [9u8; 32]; let topic = [9u8; 32];
let peer_state = sample_peer_state(); let peer_state = sample_peer_state_for(secret.public());
let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone())); let payload = sign_gossip(&secret, &topic, 1000, GossipMessage::Announce(peer_state.clone()));
let serialized = serde_json::to_string(&payload).unwrap(); let serialized = serde_json::to_string(&payload).unwrap();
@@ -732,5 +836,60 @@ mod tests {
// Within the window (clock skew tolerance) → accepted. // Within the window (clock skew tolerance) → accepted.
assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok()); assert!(verify_gossip(&p, &topic, 1_000_000 + GOSSIP_FRESHNESS_MS - 1, GOSSIP_FRESHNESS_MS).is_ok());
} }
}
#[test]
fn verify_rejects_announce_with_address_for_another_identity() {
let signer = SecretKey::generate();
let advertised = SecretKey::generate();
let topic = [6u8; 32];
let state = sample_peer_state_for(advertised.public());
let p = sign_gossip(&signer, &topic, 5_000, GossipMessage::Announce(state));
assert_eq!(
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
Err(GossipReject::AnnounceAddressMismatch)
);
}
#[test]
fn state_mutation_replay_gate_drops_replayed_leave_and_announce() {
let author = fresh_id();
let mut seen = HashMap::new();
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 10));
assert!(!admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 9));
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 11));
let announce = GossipMessage::Announce(sample_peer_state_for(author));
assert!(admit_state_mutation(&mut seen, author, &announce, 10));
assert!(!admit_state_mutation(&mut seen, author, &announce, 10));
assert!(!admit_state_mutation(&mut seen, author, &announce, 9));
assert!(admit_state_mutation(&mut seen, author, &announce, 12));
}
#[test]
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
let author = fresh_id();
let mut seen = HashMap::new();
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200 };
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100 };
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
assert!(seen.is_empty(), "chat must not populate the state-mutation replay map");
}
#[test]
fn state_mutation_replay_gate_is_per_author_and_kind() {
let author = fresh_id();
let other = fresh_id();
let mut seen = HashMap::new();
let announce = GossipMessage::Announce(sample_peer_state_for(author));
assert!(admit_state_mutation(&mut seen, author, &GossipMessage::Leave, 5));
assert!(admit_state_mutation(&mut seen, author, &announce, 5));
assert!(admit_state_mutation(&mut seen, other, &GossipMessage::Leave, 5));
}
}
+168 -5
View File
@@ -5,11 +5,11 @@ use bytes::Bytes;
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::sync::{Arc, Mutex as StdMutex};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
const AUDIO_ALPN: &[u8] = b"peerspeak-audio"; use crate::protocol::AUDIO_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.
@@ -56,6 +56,10 @@ struct Shared {
/// supervisor inserts its connection when the link comes up and removes it /// supervisor inserts its connection when the link comes up and removes it
/// when the link dies. /// when the link dies.
live_conns: StdMutex<HashMap<EndpointId, Connection>>, live_conns: StdMutex<HashMap<EndpointId, Connection>>,
/// Core-owned audio admission snapshot for this room session. It mirrors the
/// verified gossip roster plus peers still inside reconnect grace; transport
/// connections alone never mutate this set.
admitted_audio: StdMutex<HashSet<EndpointId>>,
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>,
@@ -112,6 +116,16 @@ impl Shared {
crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id)); crate::log_msg(&format!("Transport: stopped supervising peer {:?}", peer_id));
} }
} }
fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
let roster = self.admitted_audio.lock().unwrap();
audio_sender_admitted(peer_id, &roster)
}
fn apply_audio_admission(&self, peer_id: EndpointId, event: AudioAdmissionEvent) {
let mut roster = self.admitted_audio.lock().unwrap();
apply_audio_admission_event(&mut roster, peer_id, event);
}
} }
/// Why a peer's live-link wait woke up. /// Why a peer's live-link wait woke up.
@@ -135,6 +149,41 @@ fn is_graceful_leave(err: &ConnectionError) -> bool {
matches!(err, ConnectionError::ApplicationClosed(frame) if frame.error_code == VarInt::from_u32(GOODBYE_CODE)) matches!(err, ConnectionError::ApplicationClosed(frame) if frame.error_code == VarInt::from_u32(GOODBYE_CODE))
} }
/// Pure S8 membership decision: iroh already authenticated `remote` as the
/// connection's endpoint id, so audio admission is exactly live roster membership.
pub(crate) fn audio_sender_admitted(remote: EndpointId, roster: &HashSet<EndpointId>) -> bool {
roster.contains(&remote)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AudioAdmissionEvent {
/// A signed gossip Announce/Update says the peer is in the live room roster.
RosterPresent,
/// Gossip reported a transient drop; keep admission during reconnect grace.
TransientDropGrace,
/// Graceful leave, transport Left eviction, or reconnect-grace expiry.
Remove,
}
pub(crate) fn apply_audio_admission_event(
roster: &mut HashSet<EndpointId>,
peer_id: EndpointId,
event: AudioAdmissionEvent,
) {
match event {
AudioAdmissionEvent::RosterPresent => {
roster.insert(peer_id);
}
AudioAdmissionEvent::TransientDropGrace => {
// Grace is not an authority to add membership; it only preserves an
// already-admitted peer until either rejoin or grace expiry.
}
AudioAdmissionEvent::Remove => {
roster.remove(&peer_id);
}
}
}
/// Owns a single peer's connection lifecycle for as long as the peer is in the /// Owns a single peer's connection lifecycle for as long as the peer is in the
/// room: obtain a link, run the send/read loops, and on loss obtain a new one — /// room: obtain a link, run the send/read loops, and on loss obtain a new one —
/// with capped backoff on the dialing side. The deterministic-initiator rule /// with capped backoff on the dialing side. The deterministic-initiator rule
@@ -333,10 +382,17 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
if shared.self_id.to_string() < peer_id.to_string() { if shared.self_id.to_string() < peer_id.to_string() {
return Ok(()); return Ok(());
} }
if !shared.audio_sender_admitted(peer_id) {
crate::log_msg(&format!(
"Transport: rejected inbound audio from non-member {}",
crate::short_id(&peer_id.to_string())
));
return Ok(());
}
// Route the connection to this peer's supervisor (creating it if the // Route the connection to this peer's supervisor (creating it if the
// inbound link beat the gossip join event). try_send keeps the // inbound link arrives after the signed gossip Announce admitted it).
// protocol handler from ever blocking; a full queue only happens if // try_send keeps the protocol handler from ever blocking; a full queue
// links are churning, and the supervisor will get 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));
@@ -369,6 +425,7 @@ impl IrohTransport {
addrs: StdMutex::new(HashMap::new()), addrs: StdMutex::new(HashMap::new()),
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()),
incoming_tx, incoming_tx,
conn_events_tx, conn_events_tx,
}); });
@@ -397,11 +454,35 @@ impl IrohTransport {
} }
self.shared.senders.lock().unwrap().clear(); self.shared.senders.lock().unwrap().clear();
self.shared.addrs.lock().unwrap().clear(); self.shared.addrs.lock().unwrap().clear();
self.shared.admitted_audio.lock().unwrap().clear();
// Give the CONNECTION_CLOSE frames a moment to flush before the caller // Give the CONNECTION_CLOSE frames a moment to flush before the caller
// shuts the endpoint/router down (the `conns` clones are still alive // shuts the endpoint/router down (the `conns` clones are still alive
// here, so the endpoint can still transmit them). // here, so the endpoint can still transmit them).
tokio::time::sleep(Duration::from_millis(150)).await; tokio::time::sleep(Duration::from_millis(150)).await;
} }
/// Admit a peer to this session's audio plane. Core calls this from verified
/// gossip roster events; the transport never derives membership on its own.
pub fn admit_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::RosterPresent);
}
/// Preserve an already-admitted peer through the reconnect grace window.
pub fn keep_audio_sender_for_reconnect_grace(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::TransientDropGrace);
}
/// Remove a peer from audio admission before tearing down transport/jitter state.
pub fn remove_audio_sender(&self, peer_id: EndpointId) {
self.shared
.apply_audio_admission(peer_id, AudioAdmissionEvent::Remove);
}
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
self.shared.audio_sender_admitted(peer_id)
}
} }
#[async_trait] #[async_trait]
@@ -443,3 +524,85 @@ impl NetworkTransport for IrohTransport {
.ok_or_else(|| NetError::Other("Connection events already subscribed".to_string())) .ok_or_else(|| NetError::Other("Connection events already subscribed".to_string()))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use iroh::SecretKey;
fn endpoint_id() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn audio_sender_admission_accepts_roster_member() {
let member = endpoint_id();
let roster = HashSet::from([member]);
assert!(audio_sender_admitted(member, &roster));
}
#[test]
fn audio_sender_admission_rejects_unknown_sender() {
let member = endpoint_id();
let stranger = endpoint_id();
let roster = HashSet::from([member]);
assert!(!audio_sender_admitted(stranger, &roster));
}
#[test]
fn audio_sender_admission_rejects_former_member_after_roster_removal() {
let former = endpoint_id();
let mut roster = HashSet::from([former]);
assert!(audio_sender_admitted(former, &roster));
roster.remove(&former);
assert!(!audio_sender_admitted(former, &roster));
}
#[test]
fn audio_sender_admission_waits_for_mid_join_announce() {
let joining_peer = endpoint_id();
let mut roster = HashSet::new();
assert!(!audio_sender_admitted(joining_peer, &roster));
roster.insert(joining_peer);
assert!(audio_sender_admitted(joining_peer, &roster));
}
#[test]
fn audio_admission_lifecycle_keeps_peer_through_transient_grace() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::RosterPresent);
assert!(audio_sender_admitted(peer, &roster));
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_does_not_add_unknown_peer_on_grace_event() {
let peer = endpoint_id();
let mut roster = HashSet::new();
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::TransientDropGrace);
assert!(!audio_sender_admitted(peer, &roster));
}
#[test]
fn audio_admission_lifecycle_removes_peer_on_leave_or_grace_expiry() {
let peer = endpoint_id();
let mut roster = HashSet::from([peer]);
apply_audio_admission_event(&mut roster, peer, AudioAdmissionEvent::Remove);
assert!(!audio_sender_admitted(peer, &roster));
}
}
+41 -5
View File
@@ -3,11 +3,12 @@
//! //!
//! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single //! The WAVs are embedded in the binary (`include_bytes!`) so a deployed single
//! binary is self-contained — no asset directory to ship alongside it. On first //! binary is self-contained — no asset directory to ship alongside it. On first
//! use each sound is written once to a temp file, then played fire-and-forget //! use each sound is written once to a temp file, then played fire-and-forget.
//! via `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`). Playback runs //! Linux uses `pw-play` (PipeWire-native; falls back to `paplay`/`aplay`);
//! on a detached thread that waits on the child, so it never blocks the UI and //! Windows uses PowerShell's `System.Media.SoundPlayer`. Playback runs on a
//! never leaves a zombie. Any failure (no player, no audio) is silent by design — //! detached thread that waits on the child, so it never blocks the UI and never
//! a missing chime should never disrupt a call. //! leaves a zombie. Any failure (no player, no audio) is silent by design — a
//! missing chime should never disrupt a call.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -202,8 +203,14 @@ fn cached_path(sound: Sound) -> Option<PathBuf> {
Some(path) Some(path)
} }
#[cfg(any(windows, test))]
fn escape_powershell_single_quoted(s: &str) -> String {
s.replace('\'', "''")
}
/// Try each available player in turn, waiting on the first that starts (which /// Try each available player in turn, waiting on the first that starts (which
/// reaps the child). Runs on a detached thread, so the wait is harmless. /// reaps the child). Runs on a detached thread, so the wait is harmless.
#[cfg(not(windows))]
fn spawn_player(path: &Path) { fn spawn_player(path: &Path) {
for player in ["pw-play", "paplay", "aplay"] { for player in ["pw-play", "paplay", "aplay"] {
let started = Command::new(player) let started = Command::new(player)
@@ -221,6 +228,23 @@ fn spawn_player(path: &Path) {
} }
} }
/// Play through Windows' built-in WAV player. Runs on a detached thread, so
/// `PlaySync()` blocking for the sound duration is fine.
#[cfg(windows)]
fn spawn_player(path: &Path) {
let path = escape_powershell_single_quoted(&path.display().to_string());
let command = format!("(New-Object System.Media.SoundPlayer '{path}').PlaySync()");
let _ = Command::new("powershell")
.arg("-NoProfile")
.arg("-NonInteractive")
.arg("-Command")
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -234,6 +258,18 @@ mod tests {
assert!(!should_play(false, false)); assert!(!should_play(false, false));
} }
#[test]
fn test_powershell_single_quote_escape() {
assert_eq!(
escape_powershell_single_quoted(r"C:\Users\O'Brien\chime.wav"),
r"C:\Users\O''Brien\chime.wav"
);
assert_eq!(
escape_powershell_single_quoted("a'b'c"),
"a''b''c"
);
}
#[test] #[test]
fn test_sound_indices_unique_and_match_all() { fn test_sound_indices_unique_and_match_all() {
// `index()` must be a 0..COUNT bijection in `ALL` order, or the flag // `index()` must be a 0..COUNT bijection in `ALL` order, or the flag
+40 -21
View File
@@ -110,26 +110,32 @@ pub enum FriendPresence {
InRoom { name: String, ticket: String }, InRoom { name: String, ticket: String },
} }
/// Interpret a peer's reply defensively. Only a `Pong` is a reply (a `Ping` is /// Interpret a peer's reply defensively. `from` must be the connection's
/// not, so it yields `None`). When the peer reports a room, we **sanitize the /// authenticated remote id, not any value carried in the payload. Only a `Pong`
/// peer-supplied name** and **only surface it as joinable if the ticket actually /// is a reply (a `Ping` is not, so it yields `None`). When the peer reports a
/// parses** as a [`crate::network::PeerSpeakTicket`] — a garbage or hostile /// room, we **sanitize the peer-supplied name** and **only surface it as joinable
/// ticket downgrades the friend to plain `Online` rather than offering a dead / /// if the ticket actually parses** as a [`crate::network::PeerSpeakTicket`] and
/// dangerous Join button. (We still never auto-join; the user clicks.) /// points back at the replying friend. A garbage/redirect ticket downgrades the
pub fn interpret_pong(msg: &ControlMsg) -> Option<FriendPresence> { /// friend to plain `Online` rather than offering a dead or attacker-controlled
/// Join button. (We still never auto-join; the user clicks.)
pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresence> {
match msg { match msg {
ControlMsg::Ping => None, ControlMsg::Ping => None,
ControlMsg::Pong { room: None } => Some(FriendPresence::Online), ControlMsg::Pong { room: None } => Some(FriendPresence::Online),
ControlMsg::Pong { room: Some(r) } => { ControlMsg::Pong { room: Some(r) } => {
if r.ticket.parse::<crate::network::PeerSpeakTicket>().is_ok() { let Ok(ticket) = r.ticket.parse::<crate::network::PeerSpeakTicket>() else {
Some(FriendPresence::InRoom {
name: crate::sanitize::sanitize_name(&r.name),
ticket: r.ticket.clone(),
})
} else {
// Online, but the advertised room is unusable — don't offer Join. // Online, but the advertised room is unusable — don't offer Join.
Some(FriendPresence::Online) return Some(FriendPresence::Online);
};
if ticket.host_addr.id != from {
// Online, but the advertised room redirects away from the friend
// who authenticated this Pong — don't offer a phishing Join.
return Some(FriendPresence::Online);
} }
Some(FriendPresence::InRoom {
name: crate::sanitize::sanitize_name(&r.name),
ticket: r.ticket.clone(),
})
} }
} }
} }
@@ -206,21 +212,22 @@ mod tests {
#[test] #[test]
fn interpret_ping_is_not_a_reply() { fn interpret_ping_is_not_a_reply() {
assert_eq!(interpret_pong(&ControlMsg::Ping), None); assert_eq!(interpret_pong(&ControlMsg::Ping, id()), None);
} }
#[test] #[test]
fn interpret_pong_online_and_inroom() { fn interpret_pong_online_and_inroom() {
let friend = id();
// No room -> Online. // No room -> Online.
assert_eq!( assert_eq!(
interpret_pong(&ControlMsg::Pong { room: None }), interpret_pong(&ControlMsg::Pong { room: None }, friend),
Some(FriendPresence::Online) Some(FriendPresence::Online)
); );
// Valid ticket -> InRoom with a sanitized name. // Valid ticket -> InRoom with a sanitized name.
let t = valid_ticket(id()); let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }), room: Some(RoomPresence { name: "HangOut".into(), ticket: t.clone() }),
}); }, friend);
assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t })); assert_eq!(got, Some(FriendPresence::InRoom { name: "HangOut".into(), ticket: t }));
} }
@@ -230,17 +237,29 @@ mod tests {
// 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(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }), room: Some(RoomPresence { name: "Trap".into(), ticket: "not-a-ticket".into() }),
}); }, id());
assert_eq!(got, Some(FriendPresence::Online));
}
#[test]
fn interpret_pong_rejects_ticket_for_a_different_host() {
let friend = id();
let attacker = id();
let t = valid_ticket(attacker);
let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Redirect".into(), ticket: t }),
}, friend);
assert_eq!(got, Some(FriendPresence::Online)); assert_eq!(got, Some(FriendPresence::Online));
} }
#[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 t = valid_ticket(id()); let friend = id();
let t = valid_ticket(friend);
let got = interpret_pong(&ControlMsg::Pong { let got = interpret_pong(&ControlMsg::Pong {
room: Some(RoomPresence { name: "Hang\u{202e}Out\u{0007}".into(), ticket: t.clone() }), 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 -16
View File
@@ -31,7 +31,7 @@ use std::time::Duration;
/// ALPN for the friends presence/control plane. Separate from the audio/gossip /// ALPN for the friends presence/control plane. Separate from the audio/gossip
/// ALPNs so a control dial never lands on a bare room endpoint and vice versa. /// ALPNs so a control dial never lands on a bare room endpoint and vice versa.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/0"; pub const FRIENDS_ALPN: &[u8] = crate::protocol::FRIENDS_ALPN;
/// Upper bound on a single control message — generous for a Pong carrying a /// Upper bound on a single control message — generous for a Pong carrying a
/// member ticket (~300 chars), but rejects a peer trying to make us buffer a /// member ticket (~300 chars), but rejects a peer trying to make us buffer a
@@ -49,16 +49,17 @@ fn decode(bytes: &[u8]) -> Result<ControlMsg> {
serde_json::from_slice(bytes).context("failed to decode control message") serde_json::from_slice(bytes).context("failed to decode control message")
} }
/// Probe `peer` for presence: send a `Ping`, return their `Pong`. An error means /// Probe `peer` for presence: send a `Ping`, return their authenticated id and
/// no usable reply (offline / unreachable / refused / malformed) — the caller /// `Pong`. An error means no usable reply (offline / unreachable / refused /
/// treats that as "appears offline". `peer` is usually a bare [`EndpointId`] /// malformed) — the caller treats that as "appears offline". `peer` is usually a
/// (friends store the stable id); a full [`EndpointAddr`] is also accepted (and /// bare [`EndpointId`] (friends store the stable id); a full [`EndpointAddr`] is
/// used by hermetic tests). /// also accepted (and used by hermetic tests).
pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result<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")?
.context("failed to connect to peer")?; .context("failed to connect to peer")?;
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")?;
@@ -77,7 +78,7 @@ pub async fn probe(endpoint: &Endpoint, peer: impl Into<EndpointAddr>) -> Result
.await .await
.context("timed out awaiting pong")?; .context("timed out awaiting pong")?;
conn.close(VarInt::from_u32(0), b"done"); conn.close(VarInt::from_u32(0), b"done");
result result.map(|msg| (from, msg))
} }
/// A reply policy: given the *authenticated* remote id, decide whether and how to /// A reply policy: given the *authenticated* remote id, decide whether and how to
@@ -110,6 +111,10 @@ async fn handle(incoming: Incoming, handler: Handler) -> Result<()> {
async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> { async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Result<()> {
// The authenticated remote id — NOT anything the peer puts in the payload. // The authenticated remote id — NOT anything the peer puts in the payload.
let from = conn.remote_id(); let from = conn.remote_id();
let Some(reply) = handler(from) else {
conn.close(VarInt::from_u32(0), b"not authorized");
return Ok(());
};
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")?;
@@ -118,13 +123,9 @@ async fn exchange(conn: &iroh::endpoint::Connection, handler: &Handler) -> Resul
ControlMsg::Ping => {} ControlMsg::Ping => {}
other => bail!("expected a ping, got {other:?}"), other => bail!("expected a ping, got {other:?}"),
} }
// Ask the policy what to send. None -> answer nothing (stranger / invisible): send.write_all(&encode(&reply)?)
// finish the stream with no bytes so the prober sees an empty (unusable) reply. .await
if let Some(reply) = handler(from) { .context("failed to write pong")?;
send.write_all(&encode(&reply)?)
.await
.context("failed to write pong")?;
}
send.finish().context("failed to finish reply stream")?; send.finish().context("failed to finish reply stream")?;
Ok::<_, anyhow::Error>(()) Ok::<_, anyhow::Error>(())
}; };
@@ -220,10 +221,11 @@ 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 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");
assert_eq!(from, server_addr.id);
match pong { match pong {
ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"), ControlMsg::Pong { room: Some(r) } => assert_eq!(r.name, "HangOut"),
other => panic!("expected Pong with a room, got {other:?}"), other => panic!("expected Pong with a room, got {other:?}"),
+82
View File
@@ -0,0 +1,82 @@
//! Single source of truth for PeerSpeak's on-wire protocol versions and the
//! per-plane ALPNs / gossip constants derived from them.
//!
//! See `VERSIONING.md`. The rule: each transport plane is versioned independently
//! (audio rarely changes, gossip changes often), and incompatible peers must fail
//! fast — never as a silent decode/signature error. iroh refuses a mismatched
//! ALPN at the QUIC handshake, so the audio/friends planes are self-isolating;
//! gossip can't use a custom ALPN (it rides iroh-gossip's `GOSSIP_ALPN`), so its
//! version is bound into the subscribed topic ([`versioned_topic`]) and the
//! signature domain ([`GOSSIP_SIG_DOMAIN`]).
//!
//! **Never hand-write an ALPN literal elsewhere — derive it here.** Bumping a
//! plane's protocol version is a breaking wire change → also bump `Cargo.toml`
//! MINOR (see `VERSIONING.md`).
/// Audio datagram plane version (Opus framing / sequencing). Bump on any audio
/// wire change. Mirrored in [`AUDIO_ALPN`].
pub const AUDIO_PROTO: u32 = 1;
/// Friends/presence plane version (`ControlMsg` ping-pong shape). Bump on any
/// change. Mirrored in [`FRIENDS_ALPN`].
pub const FRIENDS_PROTO: u32 = 1;
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
/// into [`versioned_topic`].
pub const GOSSIP_PROTO: u32 = 1;
/// ALPN for the audio datagram plane: `peerspeak/audio/<AUDIO_PROTO>`.
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions).
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
/// never share a swarm — the gossip analog of a versioned ALPN. The room's raw
/// `topic_id` (random 32 bytes, carried in the ticket) is the room identity and
/// is unchanged; only the *subscribed* topic is namespaced.
///
/// Deterministic and dependency-free; bijective for a fixed version, so distinct
/// rooms stay distinct after namespacing. This transform is for *isolation*, not
/// security — cryptographic separation between versions comes from
/// [`GOSSIP_SIG_DOMAIN`].
pub fn versioned_topic(topic_id: [u8; 32]) -> [u8; 32] {
let v = GOSSIP_PROTO.to_le_bytes();
let mut out = topic_id;
for (i, b) in out.iter_mut().enumerate() {
*b ^= v[i % v.len()];
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// The ALPN/domain strings must stay in lock-step with the integer versions
/// so a version bump can't silently forget to update the wire string.
#[test]
fn alpns_match_their_proto_versions() {
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
}
#[test]
fn versioned_topic_is_deterministic_and_room_distinct() {
let a = [9u8; 32];
let mut b = a;
b[5] = 10;
assert_eq!(versioned_topic(a), versioned_topic(a), "deterministic");
assert_ne!(versioned_topic(a), versioned_topic(b), "distinct rooms stay distinct");
}
#[test]
fn versioned_topic_actually_namespaces_for_current_version() {
// Guards against a no-op transform: GOSSIP_PROTO=1 must change the topic.
assert_ne!(versioned_topic([0u8; 32]), [0u8; 32]);
}
}
+77 -2
View File
@@ -25,6 +25,20 @@ use tokio::process::{Child, Command};
/// points elsewhere. /// points elsewhere.
const PIXELPASS_BIN: &str = "pixelpass"; const PIXELPASS_BIN: &str = "pixelpass";
#[cfg(windows)]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 2] {
[dir.join(PIXELPASS_BIN), dir.join("pixelpass.exe")]
}
#[cfg(not(windows))]
fn pixelpass_path_candidates(dir: &Path) -> [PathBuf; 1] {
[dir.join(PIXELPASS_BIN)]
}
/// Pixelpass endpoint tickets are normally ~140 chars. Leave headroom for format
/// growth, but reject unbounded gossip payloads before the UI offers "Watch".
const MAX_TICKET_LEN: usize = 512;
/// 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.
@@ -108,6 +122,19 @@ pub fn viewer_args(ticket: &str) -> Vec<String> {
] ]
} }
/// Sanitize a peer-advertised pixelpass ticket at the gossip boundary. Peerspeak
/// intentionally does not depend on pixelpass/iroh-tickets, so this validates the
/// stable CLI ticket envelope we consume: bounded ASCII endpoint tickets beginning
/// with `endpoint`. Invalid input becomes `None`, which removes the Watch button.
pub fn sanitize_ticket(ticket: String) -> Option<String> {
let ticket = ticket.trim();
let valid_len = !ticket.is_empty() && ticket.len() <= MAX_TICKET_LEN;
let valid_shape = ticket.starts_with("endpoint")
&& ticket.len() > "endpoint".len()
&& ticket.bytes().all(|b| b.is_ascii_alphanumeric());
(valid_len && valid_shape).then(|| ticket.to_string())
}
/// Resolve the pixelpass binary: an explicit config override (used only if it /// Resolve the pixelpass binary: an explicit config override (used only if it
/// points at an existing file), otherwise the first `pixelpass` found on /// points at an existing file), otherwise the first `pixelpass` found on
/// `$PATH`. `None` means it isn't installed — a normal, handled state. An /// `$PATH`. `None` means it isn't installed — a normal, handled state. An
@@ -126,7 +153,7 @@ pub fn pixelpass_path(config_override: Option<&str>) -> Option<PathBuf> {
} }
let path_var = std::env::var_os("PATH")?; let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var) std::env::split_paths(&path_var)
.map(|dir| dir.join(PIXELPASS_BIN)) .flat_map(|dir| pixelpass_path_candidates(&dir))
.find(|c| c.is_file()) .find(|c| c.is_file())
} }
@@ -267,12 +294,29 @@ where
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}: {ev:?}")); crate::log_msg(&format!("pixelpass {role}: {}", event_for_log(&ev)));
} }
} }
}); });
} }
fn event_for_log(ev: &PixelpassEvent) -> String {
match ev {
PixelpassEvent::Ticket(ticket) => format!("ticket {}", crate::redact_for_log(ticket)),
PixelpassEvent::Connected(_) => "connected".to_string(),
PixelpassEvent::ViewerJoined { active, max } => {
format!("viewer_joined active={active} max={max}")
}
PixelpassEvent::ViewerLeft { active, max } => {
format!("viewer_left active={active} max={max}")
}
PixelpassEvent::Refused(reason) => format!("viewer_refused reason={reason:?}"),
PixelpassEvent::CaptureStarted => "capture_started".to_string(),
PixelpassEvent::CaptureStopped => "capture_stopped".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. Mirrors pixelpass's own
/// low-latency mpv invocation; falls back to vlc. The player is reaped in a /// low-latency mpv invocation; falls back to vlc. The player is reaped in a
/// background task so it doesn't linger as a zombie when its window closes. /// background task so it doesn't linger as a zombie when its window closes.
@@ -340,6 +384,27 @@ mod tests {
); );
} }
#[test]
fn sanitize_ticket_accepts_pixelpass_endpoint_ticket_shape() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm";
assert_eq!(sanitize_ticket(format!(" {ticket}\n")), Some(ticket.to_string()));
}
#[test]
fn sanitize_ticket_rejects_oversized_or_garbage_ticket() {
assert_eq!(sanitize_ticket("not-a-ticket".into()), None);
assert_eq!(sanitize_ticket(format!("endpoint{}", "a".repeat(MAX_TICKET_LEN))), None);
assert_eq!(sanitize_ticket("endpointabc-def".into()), None);
}
#[test]
fn event_log_redacts_ticket_values() {
let ticket = "endpointaabwxjexzensznfvuudiapn5tyzws3angd2merarm".to_string();
let log = event_for_log(&PixelpassEvent::Ticket(ticket.clone()));
assert!(log.contains("endpoint"));
assert!(!log.contains(&ticket["endpoint".len() + 8..]));
}
#[test] #[test]
fn parses_ticket() { fn parses_ticket() {
assert_eq!( assert_eq!(
@@ -458,4 +523,14 @@ mod tests {
// only assert it doesn't return the empty path as a match. // only assert it doesn't return the empty path as a match.
assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new(""))); assert_ne!(pixelpass_path(Some(" ")).as_deref(), Some(Path::new("")));
} }
#[test]
fn pixelpass_path_candidates_are_platform_specific() {
let dir = Path::new("bin");
let candidates: Vec<PathBuf> = pixelpass_path_candidates(dir).into_iter().collect();
#[cfg(windows)]
assert_eq!(candidates, vec![dir.join("pixelpass"), dir.join("pixelpass.exe")]);
#[cfg(not(windows))]
assert_eq!(candidates, vec![dir.join("pixelpass")]);
}
} }
-81
View File
@@ -1,81 +0,0 @@
# Codex task report - 2026-06-16
## W2 - Per-peer EQ
- Added `src/audio/eq.rs`: a 3-band listener-side RBJ biquad EQ (low shelf, mid peaking, high shelf) with per-peer state and flat bypass.
- Added local config persistence in `AppConfig.peer_eq`, keyed by peer node id string.
- Added local `CoreCommand::SetPeerEq` and mixer-side per-peer `Eq` state. EQ is applied after local volume and before pan/mix; raw multitrack stems remain pre-volume/pre-EQ.
- Added participant-card controls for Low/Mid/High gain sliders (-12 dB to +12 dB). Changes apply live and persist on slider release.
- Tests added for flat identity, low/high boost energy, coefficient finiteness, clamping, and hot-signal processing.
Unverified: subjective voice quality and zipper/noise behavior on real devices.
## W1 - Per-listener pan / stereo playback
- Added `src/audio/pan.rs`: constant-power `pan_gains()` with tests, plus playback gains that preserve the legacy default dual-mono center.
- Converted playback mix to interleaved stereo in `src/core/mod.rs`.
- Switched PipeWire playback output to 2-channel S16LE and adjusted ring target/capacity/stride accounting in `src/audio/pipewire_impl.rs`.
- Kept capture, Opus encode/decode, jitter buffers, and network audio mono.
- Limiter now receives the interleaved stereo bus; shared limiter gain ducks both channels consistently.
- Mixed WAV and multitrack convenience mix fold the listener stereo mix back to mono before writing. Per-peer stems remain raw mono.
- Updated `audio_probe` to send dual-mono stereo frames.
- Added tests for exact center dual-mono behavior, hard-left pan contribution, and stereo fold-down.
Decision for senior sanity-check: pure pan law is constant-power, but playback scales it by sqrt(2) so pan=0 is exactly the old mono signal in both ears. This satisfies the "default behavior unchanged" guardrail at the cost of louder hard-panned extremes, which the existing limiter catches.
Unverified: real PipeWire stereo playback, underrun behavior on actual hardware, and recorded WAV listening checks.
## W5 - Focused hotkeys + info popup
- Added `src/hotkeys.rs`: serializable `KeyBinding`, `HotkeyAction`, `HotkeyMap`, parse/format/lookup, tier checks, and duplicate conflict detection.
- Added `AppConfig.hotkeys` with defaults: F9 mute, F10 deafen, F2 Settings, Space push-to-talk, Leave unset.
- Replaced the hard-coded PTT key capture with config-backed binding capture.
- Added Settings hotkey editor with Set/Clear per action and live conflict warnings.
- Added top-right hotkey info popup that lists every action and current binding, showing `unset` for unbound actions.
- Routed focused iced key events through the map. App-wide actions can fire from any screen while focused; room-only actions require an active call. PTT press/release still uses `SetPttActive`.
- Tests added for unset formatting, duplicate detection, room-tier lookup, defaults, and character parse/format.
Unverified: manual keyboard interaction in the GUI. No OS-global hooks were added.
## W3 - PipeWire pro-routing plan (not implemented)
I stopped at design for W3. The current backend already supports simple target-node routing through PipeWire stream property `node.target`, but true "pro routing" (explicit ports / manual graph links / no-autoconnect patching) would require backend changes that are not safely verifiable offline.
Proposed future scope:
- Expose two advanced route targets: capture source node and playback sink node, with optional future per-port routing.
- Enumerate available nodes with the existing `pw-cli list-objects Node` parser. For port-level routing, add a separate parser for `pw-cli list-objects Port` collecting `object.id`, `node.id`, `port.name`, direction, and channel position.
- For node-level routing, continue using PipeWire stream property `node.target` on stream creation. This is the low-risk path and matches current backend behavior.
- For explicit port routing, do not use `AUTOCONNECT`; instead capture the created PeerSpeak stream node/port ids from the PipeWire registry, then link with PipeWire-native APIs or `pw-link <source-port-id> <sink-port-id>`. Degrade by falling back to `node.target` autoconnect if any selected node/port is missing.
- Offline tests should cover pure routing-plan decisions: selected node exists/missing, selected port exists/missing, capture/playback direction mismatch, and fallback choice. Real-device tests still need a PipeWire graph.
Reason for not implementing: the current `run_playback` / `run_capture` code does not retain stream node or port ids, and changing `AUTOCONNECT` behavior plus adding manual `pw-link` calls could destabilize the working audio path. That matches the assignment's "bail if risky" instruction.
## Backlog A21/A22 - correctness fixes
- Fixed A21 in `src/core/jitter.rs`: implausibly large sequence discontinuities now reset the per-peer jitter stream instead of being treated as ordinary late packets or packet loss.
- The reset threshold is `500` frames, about 10 seconds at 20 ms/frame. That covers both same-identity sender restart back to sequence 0 and a faulty/malicious jump far ahead that would otherwise force a long PLC run.
- Added jitter regression tests for both far-behind restart and far-ahead jump cases.
- Fixed A22 in `src/audio/recorder.rs`: `WavWriter` now tracks data bytes as `u64`, checks additions before writing, and rejects data that cannot fit both the RIFF size field and the `data` chunk size field.
- Added a WAV overflow regression test that exercises the limit without creating a huge file.
Unverified: the same-identity peer restart has not been exercised in a live 2-machine call; the WAV fix is counter/size-field tested, not a real >12h recording.
## Backlog A14 - orderly window-close shutdown
- Added `CoreCommand::Shutdown` and `UiEvent::ShutdownComplete`.
- Window close now saves config, marks the GUI as closing, asynchronously queues `Shutdown`, and exits only after the core acknowledges completion or after a 5-second fallback timeout.
- Core shutdown finalizes active mixed/multitrack recordings before session teardown, stops the standalone mic monitor, runs `ActiveSession::shutdown()` for active calls, clears room presence/routing, closes the persistent network stack, sends `ShutdownComplete`, and ends the core loop.
- The shutdown command is queued with an awaited `mpsc::Sender::send` task instead of the best-effort `try_send`, so a full command queue does not immediately drop the close command.
Unverified: actual GUI window-close behavior during a live call/recording still needs a manual run; tests/builds only prove the path compiles and existing unit coverage still passes.
## Verification
- `cargo check` passed.
- `cargo test --lib` passed: 288 passed, 0 failed, 2 ignored.
- `cargo clippy --all-targets` passed.
- `cargo build --release` passed.
- Formatted the touched Rust files with `rustfmt --edition 2024`; I did not run repo-wide `cargo fmt` to avoid unrelated formatting churn.
No new dependencies were added. Runtime/manual/field verification is still pending for audio-device and 2-machine behavior.
+3
View File
@@ -169,6 +169,9 @@ async fn loopback_sequenced_audio_reaches_peer_and_decodes() {
b.lookup.add_endpoint_info(a.endpoint.addr()); b.lookup.add_endpoint_info(a.endpoint.addr());
let a_id = a.endpoint.id(); let a_id = a.endpoint.id();
let b_id = b.endpoint.id();
a.transport.admit_audio_sender(b_id);
b.transport.admit_audio_sender(a_id);
// Subscribe to incoming datagrams on B before any are sent. // Subscribe to incoming datagrams on B before any are sent.
let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B"); let mut b_rx = b.transport.receive_datagrams().await.expect("subscribe B");