Closes the final phase of docs/chat-hardening-plan.md. Two problems: a
locally echoed message always looked sent even when the core had no active
session or the gossip broadcast failed; and a fast burst could broadcast
'successfully' yet be silently dropped by every receiver's per-author rate
bucket (8 burst, then 1/s) with no sender feedback.
Send status: CoreCommand::SendChat/SendChatFile carry a local-only id (never
on the wire); the core replies with UiEvent::ChatSendResult after the gossip
broadcast succeeds or fails, and a no-active-session is now an explicit
failure rather than a silent no-op. gossip send_chat, which previously
returned Ok on a missing sender/topic or an encode failure, now returns Err.
ChatEntry gains local_send: Option<LocalSend>; failed sends render a red
'Not sent — {reason} [Retry]' line, Broadcast/Pending render nothing
(there are no delivery receipts, so silence is the honest success state).
Sender-side pacing (new src/app/sendqueue.rs): sends past the burst queue
locally as 'queued…' and trickle out at the receivers' sustained rate, so
nothing is lost and typing is never blocked (user chose queue-and-trickle
over input throttling). The pacer reuses the gossip gate's own TokenBucket +
per-author constants (now pub(crate)) so the two sides of the policy can't
drift. A 250ms drain subscription runs only while the queue is non-empty.
Retry re-dispatches the retained payload; re-serving the same attachment id
replaces the ServeStore entry rather than double-counting bytes. The pacer
and monotonic send-id counter survive a room reset (receivers' buckets
persist; ids never alias a late result); queue and retry payloads are cleared.
582 lib tests (+11: 4 pacer/queue seam, 7 app-level transition/retry/reset);
all-targets green, clippy -D warnings clean, fmt clean, smoke launch OK. No
wire change (GOSSIP_PROTO stays 5). Tests-green-only — the two owed
two-machine field-test items are logged in the plan.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The playlist drawer duplicated the player bar's |prev/play/next| transport
row even though the drawer can only be open while the bar is visible
(drawer_open gates on show_player_bar), so the drawer copy is removed;
seek, music volume, Browse, and the tune-in checkbox remain drawer-only.
The track list (and the Public tab's broadcast list) was a 160px-fixed
scrollable nested inside a second full-height scrollable, showing only a
few entries. The outer scrollable is gone and both lists now fill the
drawer's remaining height, resizing with the window.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also re-triggers CI: run 165 on 1d038be died to rust-lld crashes from disk
exhaustion on the runner host (12G free vs ~12G cold-build transient), not a
code failure; 18G of local build artifacts have been swept (30G free now).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 4 of docs/chat-hardening-plan.md — URL and rendering resilience.
Closes the chat-body half of S14 (bidi override strip).
- sanitize: new is_safe_web_url shared link policy (url crate, promoted to a
direct dependency): http/https scheme + non-empty host + no userinfo;
candidates failing it stay plain text (their whole whitespace run, interior
not re-scanned). Scheme detection is now ASCII-case-insensitive.
- sanitize: linkify() -> link_ranges()/segments(): validated byte ranges
computed once, exact-roundtrip slicing, at most CHAT_MSG_MAX_LINKS (8)
clickable links per message; the rest stays selectable plain text.
- sanitize_chat: strips bidi overrides/isolates (U+202A-202E, U+2066-2069)
from message bodies while keeping ZWJ/ZWNJ/LRM/RLM (S14 chat-body half).
- app: ChatEntry caches its link ranges (filled in push_chat), so redraws
slice instead of rescanning/re-validating; only link spans allocate.
- app: chat history now also bounded by 512 KiB total sanitized text
(CHAT_HISTORY_MAX_TEXT_BYTES) alongside the 300-entry cap; the attachment
byte cache is deliberately untouched by history eviction (own budgets).
- app: AppMessage::OpenUrl re-checks the same parsed policy (defence in
depth) instead of prefix checks - non-web schemes can never reach the
opener even if the handler is invoked directly.
571 lib tests green (+3 net); clippy -D warnings + fmt clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 3 of docs/chat-hardening-plan.md — attachments can no longer turn
into unbounded memory, bandwidth, decoder, or task pressure (S15 closed;
S14's filename half closed).
Cache and image cost (3A): AttachmentCache now carries encoded- and
decoded-byte budgets (96 MiB / 64 MiB) on top of the count cap, with
per-entry weights, replacement accounting, and oldest-first eviction; an
individually over-budget fetch services any pending Save/Play from the
bytes in hand and is exposed as Evicted instead of retained.
validate_image_bytes prechecks header dimensions (per-side AND a new
14 MP total-pixel limit) before any decode; the renderer only ever
receives a ≤1600 px downscaled RGBA preview whose w*h*4 cost counts
against the decoded budget — originals stay encoded-only for Save.
sanitize_filename strips the bidi/zero-width spoofing set (RTL-override
extension spoof).
Download policy and state (3B): images auto-fetch only when roster-
authored AND declared ≤4 MiB, gated by a new deterministic
AutoFetchBudget (per-author and session request+byte token buckets,
check-then-take, bounded author map) alongside the existing dedup and
four-permit bound. Attachment state is now explicit — absence/Loading/
Ready/Failed/Evicted — driven by a new AttachmentFetchStarted event, so
skipped or evicted images render a "Load image" button instead of an
indefinite "loading…", and repeated clicks can never spawn duplicate
fetch tasks.
Exact transfers and serve store (3C): fetch_blob requires the received
length to equal the declared size (short = local error, overlong =
bounded-read reject, empty keeps meaning "sender no longer has it");
the file picker's unbounded read is replaced by a metadata-prechecked
cap+1 bounded reader; one Arc<Vec<u8>> now backs the UI cache, command
queue, and serve store; served_files is a count- and byte-budgeted FIFO
ServeStore (16 entries / 128 MiB).
37 new tests (568 lib total) including a real two-endpoint loopback
exercising exact/short/overlong/unknown-id transfers. Plan checkboxes
ticked and constant deviations decision-logged. Tests-green-only: the
plan's two-machine field-test section remains open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chat-hardening plan Phase 2 — only current authenticated room members can
create chat UI work, impersonation via the wire name is structurally closed,
and no member can monopolize the event channel:
- core: new ChatRoster (bounded id -> sanitized-name map, shared) replaces the
event task's bare HashSet; upserted on PeerJoined/PeerUpdated, removed on
graceful PeerLeft AND terminal grace-expiry eviction (both timer paths).
Non-roster chat is dropped before attachment handling; the rendered author
label is the roster-bound name — the sender-claimed wire name is never read.
- gossip: ChatIngressGate after verify_gossip, before any sanitize work or
event send: early known-author gate (live + mid-reconnect peers), exact-
replay suppression keyed on the deterministic Ed25519 signature (1024-entry
cap + freshness-window TTL, zero new deps vs the plan's BLAKE3 option), then
per-author (8 burst, 1/s) and room-wide (32 burst, 8/s) token buckets.
Replays are detected before tokens are consumed; a room-bucket reject
refunds the author token; rejection logging is squelched per author.
- The inner Chat.ts is now ignored entirely; RoomEvent carries the signed
envelope timestamp.
550 lib tests (+18), reconnect_eviction +1 (grace keeps chat authority,
terminal eviction revokes it), clippy --all-targets -D warnings clean.
Tests-green-only: the plan's two-machine field-test section remains open.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chat-hardening plan Phase 1. The chat body policy (2,000-char + 8 KiB
ceilings, single-pass control/whitespace normalization) moves from the UI
layer into src/sanitize.rs and is now enforced at every trust boundary:
cap_chat_input bounds the live input (oversized paste), the gossip sign
point re-sanitizes so non-UI callers can't bypass policy, and gossip
ingress rejects oversized raw text before sanitizing (admit_chat_text)
and drops messages with neither visible text nor an attachment. The
incoming chat author label now uses the strict name sanitizer until
Phase 2 roster-binds it. +8 tests (532 lib green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The jitter buffer's gap path fed the LOWEST buffered packet to
decode_fec regardless of position. Opus in-band FEC in packet N carries
a copy of frame N-1 and nothing else, so that reconstruction is only
correct when the smallest survivor is exactly next+1 (single loss).
On burst loss it spliced a later frame's audio into the wrong slot —
worse than concealment. Gate FEC on adjacency (new fec_covers_gap(),
wraparound-aware); everything else falls back to plain PLC.
Two new tests: the gate itself, and a burst-loss test proven to bite —
it compares bit-exact against a twin decoder and fails against the old
unconditional-FEC behavior (checked by mutation).
Fixes finding 3 of the 2026-07-16 full-codebase review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GPT-5.6's 5-phase plan for the chat identity/replay/rate-limit cluster
(2026-07-16 review findings 5-8): roster-bound display names, replay
dedup, quiet rate limiting, bidi-aware sanitization, attachment size
checks. Self-describes as temporary — delete when the work completes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The four live-rebuild sites (deferred rebuild on Join/Leave, idle
SetNetworkMode, idle RegenerateIdentity) all did
`net.shutdown().await` then `build_net_stack(...).await?` — a build
failure propagated out of run_core_loop, which its supervisor only
logs. Every subsequent command went nowhere: window alive, app dead,
user told nothing. (The initial startup build already reported.)
New replace_net_stack() helper: tear down the old stack, build for the
requested posture, and on failure fall back to the posture the old
stack was actually running (tracked in the new `net_mode` local; when
the postures are equal the fallback is a plain retry — e.g. identity
regeneration, where reverting the already-persisted key would be
wrong). If the fallback lands, the UI is told the change didn't stick
and `network_mode` reverts so state stays honest and the change stays
re-attemptable. If both builds fail the UI gets a fatal 'Networking
lost … restart' error before the loop exits — informed, not a zombie.
Retry policy isolated in rebuild_with_fallback(), generic over the
builder: 4 new unit tests cover first-try success, fall-back, plain
retry, and double failure without binding sockets.
Fixes finding 2 of the 2026-07-16 full-codebase review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cargo-deny.yml (runs-on: ubuntu-latest) and windows-build.yml (runs-on:
windows-latest) target runner labels no registered runner advertises, so
every push queued two runs Gitea auto-cancelled ~24h later — the Actions
page has shown 2 cancelled runs per push since the runner went live.
- cargo-deny.yml: deleted; redundant with ci.yml's deny step, which now
runs `cargo deny --locked check` to preserve the locked-tree stance.
- windows-build.yml: kept but workflow_dispatch-only until a Windows
runner exists; restore instructions in the header comment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RUSTSEC-2026-0194/0195 (quick-xml 0.39.4, published 2026-06-29) broke the
deny/audit CI gates on every push since June 29. quick-xml is reached only
via the wayland-scanner proc-macro parsing vendored protocol XML at compile
time — attacker input never touches it and it is absent from the shipped
binary. The fixed 0.41.0 is semver-incompatible with wayland-scanner's
`^0.39` req (no upstream bump yet); documented ignores until one exists.
RUSTSEC-2026-0192 (ttf-parser unmaintained, via iced/cosmic-text) joins the
existing unmaintained ignores (paste, audiopus_sys) — same class, same
lockfile-pinning protection.
New .cargo/audit.toml keeps cargo-audit in sync with deny.toml.
Known leftover warning (allowed, non-failing): spin 0.10.0 is yanked but
futures-buffered (via iroh) requires ^0.10 and no unyanked 0.10.x exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six diffs across four files: the 2026-07-08 stable toolchain update
(rustc 1.96.1 / rustfmt 1.9.0) re-flags code that was fmt-clean when
committed under the previous rustfmt. No semantic change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Answer "am I actually P2P right now?" per peer. A 1 Hz session task
snapshots the selected QUIC path of every live audio connection
(IrohTransport::connection_stats), core::connstats::derive turns
consecutive snapshots into RTT/loss/bitrate (path switches and counter
resets invalidate the rate window), and the peer card shows a
Direct/Relay badge with a hover tooltip for address, loss, and up/down
bitrate. No new dependencies, no wire change.
Loopback-integration-tested against real iroh endpoints; not yet
field-verified on a 2-machine call (FEATURES.md row marked 🧪).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bump to 0.6.3 and document the screen-share work merged on this branch:
the advanced Settings section + per-call quality picker (96e41de), the
hardware-decode-defaults-off frame-1 freeze fix (96e41de), the per-call
quality override fix (e378b2e), and the VLC-honors-viewer-settings fix
(faad8ce). All local-only — no wire-protocol change, old configs load
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The screen-share code only logged pixelpass's high-level JSON events, never
the argv it spawned children with, so a field log couldn't confirm which
encode/viewer settings actually reached the helpers — e.g. the per-call
quality's --bitrate (host) or the hardware-decode --avcodec-hw/--hwdec flag
(player). Log both verbatim at spawn: host args carry no secret, and the
player line omits the local stream URL. Logged per attempt so a player
fallback is visible too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The viewer playback settings (hardware decode + buffering) only shaped
mpv's argv; vlc_args() was fixed, so a VLC viewer silently ignored them.
The load-bearing case is hardware decode: mpv defaults to software decode
(the A-bug fix), but VLC hardware-decodes by default, so a VLC viewer with
the default hardware_decode=false still got GPU decode and could hit the
frame-1 freeze the default exists to avoid — the toggle did nothing.
vlc_args() now takes the settings and maps the knobs that translate
cleanly to VLC: hardware decode (--avcodec-hw=none/any) and buffering
posture (network/live caching ms). The genuinely mpv-specific knobs
(cache_mb byte-cache, extra_mpv_args) stay mpv-only; the Settings UI
hints are reworded to say which knobs are mpv-only vs universal. +2 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Share control's inline quality dropdown sets a session-only
`share_quality_selection`, but ToggleScreenShare (which opens the audio
picker on the only real path to a share) unconditionally reset it back to
the saved config default before ConfirmShareScreen read it. The picker has
no quality control of its own, so the user's per-call pick was silently
dropped 100% of the time and every share used the persisted default.
Drop the reset; add a regression test asserting the override survives
picker-open and reaches the confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a local-only "Screen sharing" section to Settings plus a per-call quality
picker on the Share control: in-app control over how a share is encoded
(quality/bitrate/framerate/max-height/max-viewers/software-x264, + extra
pixelpass args) and how it's played back (mpv/vlc, hardware decode, buffering,
cache, + extra mpv args). Settings live in AppConfig.screen_share (all
serde-defaulted, so old configs load unchanged) and become pixelpass host CLI
flags / mpv args at share/view launch.
Hardware decode defaults OFF, which also fixes the frozen-frame-with-audio bug:
forcing --hwdec=auto stalled some viewers' HW decoder on frame 1 while audio
kept playing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Joe (X11) reported that middle-click paste did nothing in the ticket and
node-ID fields. iced's base text_input only binds Ctrl+V to the Standard
(CLIPBOARD) selection and never reads PRIMARY or binds mouse button 2, so
the "select text, middle-click to paste" workflow was dead.
Add a Button::Middle branch to ContextInput::update that reads
clipboard::Kind::Primary, sanitizes it, and pastes at the cursor (reusing
the already-tested pure paste()). Factor the control-char stripping into a
shared, unit-tested sanitize_clip() helper also used by the menu Paste, so
a trailing newline on the PRIMARY selection is dropped. Respects `locked`
so read-only display fields still reject paste.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every click of Watch (`CoreCommand::ViewShare`) spawned a fresh pixelpass
viewer + mpv and pushed it onto an untracked Vec. A field test hit the
consequence: the first click gave a frozen player (the host's capture was
stalling), so the viewer clicked again to retry — and got a SECOND mpv,
doubling the shared audio.
Track viewers paired with their share ticket. On ViewShare, reap players
whose window already closed (try_wait), then if a live player for the same
ticket exists, kill it before spawning the replacement. Re-watching a
share now swaps its player instead of stacking a second one. Pure
`replace_viewer_index` seam + test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The viewer launched mpv with `--untimed`, which displays each video
frame the instant it decodes and ignores audio timestamps. Sharing a
desktop (no audio) that just minimizes latency, but sharing a *video*
made its audio drift progressively out of sync — confirmed in a field
test watching a video together. Remove the flag so mpv paces video to
the audio clock; the remaining low-latency flags keep lag negligible for
desktop pointing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bump to 0.6.2 (Cargo + Inno .iss), promote CHANGELOG [Unreleased] -> [0.6.2].
0.6.2 rolls up the licensing (MIT + THIRD_PARTY_LICENSES) and the friends-list
liveness fixes (active offline marking, 15s refresh, manual Rescan) on the
0.6.x wire format (gossip v5, compatible with 0.6.0/0.6.1).
Adds packaging/appimage: a thin AppImage recipe (linuxdeploy) that bundles the
pixelpass screen-share helper in usr/bin so peerspeak's $PATH lookup finds it
with no code change. Assets are include_bytes!-embedded; the graphics stack and
pixelpass's gstreamer/mpv tools are left to the host. Built on Ubuntu 24.04
(glibc 2.39) for reach across Debian 13+/Fedora 40+/rolling.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex-authored refresh of docs/WINDOWS.md and packaging/windows/{README,INSTALL}.md
from the 2026-07-01 Windows session; scan.rs qualifies super::running_executables()
to drop an unused glob import. PKGBUILD pkgver reflects the last Arch build
(auto-regenerated by makepkg's pkgver()).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the two P2 efficacy findings from the Codex audit of the W12
profiles feature.
FEC was enabled on the encoder but never used: the jitter buffer's
loss path did pure PLC, so the redundancy was wasted bitrate. Now the
gap path reconstructs the lost frame from the next buffered packet via
Opus in-band FEC (new `AudioDecoder::decode_fec`, libopus decode with
fec=true into a one-frame buffer), keeping that packet for its own
normal decode and falling back to PLC if FEC decode fails. This is the
documented libopus FEC pattern; receiver-side only, no wire change.
DTX was enabled on BadNetwork but provided no benefit — the capture
noise gate already suppresses silence transmission, and the broadcast
DTX silence packets only created seq gaps that grew the jitter cushion.
All profiles now set dtx=false (plumbing kept for a future revisit).
Adds a jitter-buffer test proving FEC reconstruction beats pure PLC
(RMS error < 0.75x) and that the FEC source packet stays buffered.
500 lib tests, clippy + fmt clean, release build clean.
Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a small, named codec-policy picker (Low latency / Balanced / Bad
network) instead of exposing raw Opus knobs. The profile->params mapping
is a pure function (`codec::opus_impl::opus_params`) for unit testing;
profiles tune bitrate, in-band FEC, expected packet-loss, and DTX.
- config: `AudioProfile` enum (serde + Display + ALL + u8 round-trip),
persisted `audio_profile` field (default Balanced).
- codec: `OpusParams` + pure `opus_params()` + `OpusEncoder::apply_params`
/ `apply_profile`.
- core: new `SetAudioProfile` command (Reliable, no coalesce); a shared
`AtomicU8` lets the capture thread re-tune the live encoder on a
mid-call switch and read it at each new call's encoder creation.
- app: Settings "Connection quality" picker in the Audio tab, startup
config-sync send, and a one-line hint per profile.
No wire-format change (GOSSIP/audio planes untouched). 499 lib tests
green (config + codec mapping/apply tests added), clippy + fmt clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cargo-audit flagged memmap2 0.9.10 as unsound (RUSTSEC-2026-0186, unchecked
pointer offset); 0.9.11 is the patched release. Warning-level only (audit/deny
don't fail on it), but cheap to clear. Audit now down to the two deliberately
-accepted unmaintained warnings (audiopus_sys, paste; ignored in deny.toml).
Lockfile-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A push that only changed Cargo.lock failed to create any Actions run while the
concurrency group was present; removing it restores reliable push triggering.
Single-dev CI doesn't need run-cancellation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's cargo-deny flagged RUSTSEC-2026-0190: unsoundness in anyhow's
Error::downcast_mut() (UB via borrow-rule violation after Error::context),
reached transitively (n0-error / iroh + the image/rav1e chain). 1.0.103 is the
patched release; lockfile-only, no API change. cargo deny check now fully clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's fmt --check caught that Codex's hand-written additions in these two files
weren't rustfmt-formatted (the senior gate ran clippy + tests but not
fmt --check). Pure line-wrapping, no logic change. Keeps the crate fmt-clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI runs on a self-hosted host-mode gitea-runner on the desktop (label `arch`),
so the cheap gitbutter VPS only queues jobs while all compile/test compute runs
locally. Pipeline on push-to-main / PR / manual dispatch: cargo fmt --check,
clippy --all-targets -D warnings, cargo test --all-targets + doc tests, cargo
deny check, cargo audit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Track B code body for the 0.6.2 patch release. No wire change (GOSSIP_PROTO stays
5, interoperable with 0.6.0/0.6.1). Two code commits + two investigation closeouts
(A6 root-caused -> deferred to W5; A3 palette audit -> accepted as-is).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A field build is now self-identifying. `run_gui` logs `PeerSpeak v<version>
starting` (from env!("CARGO_PKG_VERSION")) on launch, and the Settings panel
shows a muted `PeerSpeak v<version>` footer — pinned to the bottom of the
220px category sidebar (wide layout) and appended under the body in the narrow
(<820px) layout. Compile-time string, no new test, no deps, local-only.
Renders the current crate version, so it tracks the Cargo.toml bump at each
release cut (shows v0.6.1 until 0.6.2 is stamped in Track A).
Codex-implemented (gpt-5.5 xhigh), senior-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The multitrack recorder wrote every per-stem WAV frame (and the potentially
large late-joiner back-pad) inline on the caller thread while holding the
recorder mutex, so a slow/contended disk stalled the playout mixer (local
underruns) and the events loop. This is the multitrack counterpart to A17
(e0325d4), which moved the single-file recorder's writes off the mixer path.
Design: the front (MultitrackRecorder) now keeps only cheap in-memory state
(known-peer set, mic FIFO, a pending-cycle builder) and on each end_cycle
assembles ONE whole-cycle batch (new peers + mic frame + optional mix frame +
the map of peer frames written this cycle) and try_sends it over a bounded
sync_channel(256) to a dedicated writer thread. The writer thread owns every
WavWriter, is authoritative for its own cycle count, back-pads a brand-new
peer by cycles_written*frame_samples, fills absent peer/mix frames with
silence, latches the first write/create error then drains, and finalizes all
headers on channel close.
The unit of hand-off is a whole cycle, not a track: the writer appends exactly
frame_samples to every existing track per applied batch, and a full queue
DROPS the entire batch (counted + logged at 1 and every 256). So a dropped
cycle omits the same 20ms from every stem at once and all tracks stay
equal-length and sample-aligned by construction even under disk back-pressure.
On drop the batch's new-peer announcements are rolled back out of the known set
so they re-announce (and correctly re-back-pad) on the next applied cycle.
Public method signatures are unchanged -> zero core/mod.rs edits. The
WAV/file format is unchanged (no wire/on-disk change), no new deps
(std::sync::mpsc + std::thread, as A17). Writer logic is factored behind a
generic SampleWriter seam so the apply-batch alignment invariant is unit-tested
without spawning the thread; new tests cover the back-pad-on-apply invariant,
the dropped-cycle equal-length property, and async create-error surfacing at
finalize. The three existing end-to-end tests pass unchanged (now exercising
the threaded path). 496 lib tests, clippy --all-targets clean, release builds.
Codex-implemented (gpt-5.5 xhigh), senior-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The const comment claimed "v5 (0.7.0)" while GOSSIP_PROTO has been 5 since the
v0.6.0 tag (introduced by bca2ccd, "release 0.6.0"). Git confirms the value went
straight 3 -> 5 in that one release and a GOSSIP_PROTO == 4 build never existed.
Merge the two mislabeled v4/v5 bullets into one accurate v4-v5 (0.6.0) entry and
note the 3->5 jump + that this breaking gossip change correctly rode the
0.5.1 -> 0.6.0 MINOR bump per VERSIONING.md (0.6.1 is a wire-compatible PATCH,
still proto 5). Comment-only; no wire/behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0.6.1 refinements release: A19 atomic config, S5 temp-WAV hardening, A15b slider
coalescing, A2 window-position clamp, A17 single-file recording I/O off the mixer
path, and a crate-wide cargo fmt. All wire-compatible (no *_PROTO change) with
0.6.0 peers -- no resync required.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recorder::write_frame ran on the playout mixer path and did a blocking write_all
to disk per 20ms frame; slow/contended storage could stall the mixer and cause
local playback underruns. Now the mixer thread only does the cheap mic-sum
(extracted as the pure mix_with_mic helper) and try_sends the frame to a
dedicated writer thread over a bounded sync_channel(256). The writer thread owns
the WavWriter, writes queued frames, records the first write error then drains
without writing, and patches the WAV size fields on channel close. A full queue
DROPS the recording frame (counted + logged at 1 and every 256) rather than
blocking call audio; a disconnected writer surfaces BrokenPipe. finalize() closes
the channel, joins the thread, and returns the first write error or the finalize
result (thread panic handled).
Scope: single-file Recorder only; WavWriter unchanged so the multitrack recorder
is untouched (its writer-thread offload is deferred as A17b). Public method
signatures preserved -> no core/mod.rs changes. New end-to-end threaded WAV
readback test + mix_with_mic helper tests; existing FIFO/mic-sum intent kept.
No new deps, no wire change. Codex-implemented (gpt-5.5 xhigh), senior-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
initial_window_position fed saved window_x/window_y straight into
Position::Specific with no bounds check, so a saved position on a since-
disconnected monitor (or after a resolution shrink) could open the window fully
off-screen on a bare X11 WM that doesn't clamp. New pure clamp_window_position
seam: given display bounds it pulls a partly-offscreen window back inside,
centers one parked on a vanished monitor, and crucially PRESERVES legitimate
multi-monitor negative-origin coordinates (a naive clamp-to-0 would break that).
iced 0.14 has no dependency-free way to learn the virtual-desktop bounds before
the window exists, so screen_bounds() returns None for now and the clamp applies
a sanity envelope (reject |coord| > 32000 -> Centered) while preserving today's
restore behavior; the full clamp is unit-tested and ready for when bounds can be
supplied. Five clamp tests (inside, edge-clamp, disconnected, negative-origin,
None-sanity) + existing tests updated. No new deps, no wire change.
Codex-implemented (gpt-5.5 xhigh), senior-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fast slider drag could burst past the bounded(100) best-effort command queue
and try_send would drop commands -- possibly the FINAL value of the drag, leaving
a gain/pan/volume stuck mid-drag until the next interaction. Replace the
best-effort queue with a coalescing latest-value map keyed by control
(CoalesceKey) plus a bounded(1) wake channel: send() overwrites the latest value
per control (never drops, never blocks) and wakes the loop, which pops one
coalesced command at a time and self-re-arms while entries remain. The existing
single-command match handler is reused unchanged.
command_sender() now returns a typed CoreCommandSender that routes by
delivery_class, so the window-close Shutdown (Reliable) goes through the
unbounded reliable channel (drained biased-first) instead of the best-effort
path -- a small correctness improvement. Mute/PTT remain Reliable, untouched.
Pure seams coalesce_key/coalesce_insert/coalesce_pop with unit tests
(overwrite-same-key, distinct-peers, global control, empty pop, drain-each-once)
and a coalesce_key<->BestEffort invariant assertion. No new deps, no wire change.
Codex-implemented (gpt-5.5 xhigh), senior-reviewed; tests-green (487 lib).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cached_path materialized each embedded chime to a fixed, predictable path
(/tmp/peerspeak-<name>.wav) via fs::write, which follows symlinks -> a local
attacker on a shared host could pre-plant a symlink and redirect the write. New
write_private_wav seam writes to a randomized peerspeak-<stem>-<pid>-<counter>-
<nanos>.wav name with OpenOptions::create_new (O_EXCL, refuses to write through
an existing path) and 0600 mode at creation on Unix. Per-process cache and the
None-on-error fallback (chime simply doesn't play) are unchanged.
Unit tests: exact bytes, 0600 mode, unique paths, create_new-refuses-existing.
No new deps, no wire/schema change. Codex-implemented (gpt-5.5 xhigh), reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
AppConfig::save now writes to a same-dir temp file and atomically renames
over the target (mirrors identity.rs/friends.rs), and surfaces errors via
log_msg instead of swallowing them. AppConfig::load distinguishes a missing
config (silent default, first run) from a present-but-corrupt one: the damaged
file is moved aside to config.json.corrupt.<unix_secs> before falling back to
defaults, so a later save can no longer clobber the user's real prefs.
Path-injectable seams save_to/load_from + LoadOutcome with unit tests
(round-trip, missing, corrupt-preserves-bytes, no leftover temp). No new deps,
no schema/wire change. Codex-implemented (gpt-5.5 xhigh), senior-reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The .deb recipe already lives in Cargo.toml's [package.metadata.deb], but the
build *environment* (bookworm distrobox, glibc floor, the mandatory separate
CARGO_TARGET_DIR) was only captured in handoff notes. Add a packaging/debian
README so the deb path is as self-documenting as the Arch + AppImage paths.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The skip-back/forward buttons rendered orange in every theme because the
emoji glyphs ⏮/⏭ (U+23EE/U+23ED) are drawn by the system color-emoji font,
which ignores the button's text color. Replace them with |◀ / ▶| built from
the text-presentation triangles ◀/▶ (U+25C0/U+25B6) — the same family the
play button already uses — so they honor .color() and follow the active
theme like the play button does. Applies to both the now-playing player bar
and the full music drawer panel. Pure visual change; no behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The transport buttons were pushed to the far right because the
now-playing label had width(Fill). Regroup the bar into three sections
— left(Fill) identity+label, centered transport, right(Fill) position +
expand — so the controls sit in the middle. Pure widget regrouping; no
message, config, or behavior change.
Implemented by Codex (gpt-5.5), reviewed + gates re-run by Claude.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full music panel was rendered inline at all times (its own card in
the 3-column layout, stuffed into the Controls panel otherwise), which
crowded every layout. Move it behind two toggles:
- A room-only ♪ button in the top bar shows/hides a slim 56px
now-playing player bar (track + ⏮ ⏸/▶ ⏭ + position + expand). The
preference persists (AppConfig.show_player_bar).
- The bar's ⤢ button opens the full panel in a resizable right-edge
drawer (DividerKind::PlaylistDrawer, mirrors the Chat drawer). When
open, body_w shrinks so the layouts' fixed panels don't overflow.
Removes all inline playlist placement (3-col card + ThreeColPlaylist
divider, ctrl_music block) and the now-dead clamp/consts. Pure
now_playing_label seam + drawer-width clamp test. 474 lib tests, clippy
-D warnings clean, release build green.
Implemented by Codex (gpt-5.5), reviewed + gates re-run by Claude.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a relative-time indicator in the Friends panel header showing how
long ago the last manual Rescan completed: "just now", "2m ago",
"1h 2m ago", "2d 2h ago". It advances on its own via a 30s
RescanLabelTick subscription (only armed once a rescan has happened), so
the label stays current without user interaction.
Placed in the panel header rather than the status bar: the status bar is
a single ephemeral label overwritten by every other action, so it can't
host a persistent, live-updating timestamp without clobbering other
statuses. The completion event (FriendsRescanned) stamps the time;
formatting is a pure, unit-tested helper (format_relative_ago).
470 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The manual Rescan set a persistent "Rescanning friends…" status but
nothing ever cleared it: the probe pass emits per-friend presence events
with no "done" signal, so the banner stuck forever (an offline friend's
probe can take up to the 10s IO timeout, and there was no terminal event
after).
Core now emits a `FriendsRescanned` UiEvent after the manual pass finishes
(only the on-demand button, never the 15s auto-refresh, so the status bar
isn't churned each interval). The GUI replaces the transient banner with
"Friends rescanned." — guarded so it won't clobber a status the user has
since triggered. Invisible mode probes no one, so the button now explains
that instead of showing a banner that resolves with nothing changed.
469 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 60s cadence predated the self-heal fix. 15s keeps the friends list
tracking online/in-room/offline changes more closely; each pass is still
just one short connection per friend, so the cost is small at typical
friend-list sizes. The manual Rescan button covers anything faster.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The friends list only ever updated a friend's status on a *successful*
presence probe, so it could ratchet a status up (offline -> online -> in a
room) but never down. A friend who dropped, left a room, or went invisible
kept showing a stale "online"/"in a room" status until PeerSpeak was
relaunched (which cleared the in-memory presence map back to offline).
The 60s auto-refresh scheduler already existed; the bug was that
`probe_friends_once` emitted nothing on a failed probe. Now every pass
reports a *definitive* status for every friend: a failed probe (or a
friend with no known address) is mapped to a new `FriendPresence::Offline`
via the pure, tested `presence::presence_from_probe`, so the list
self-heals each cycle.
Also adds a manual "⟳ Rescan" button to the Friends panel (new
`CoreCommand::RefreshFriends` -> immediate probe pass) for instant
feedback instead of waiting up to 60s.
469 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Set license = "MIT" (Cargo.toml), add the MIT LICENSE file, and switch the
PKGBUILD to license=('MIT'). Generate a THIRD_PARTY_LICENSES file enumerating
the full dependency-graph manifest plus the canonical text of every referenced
license, with notices for the statically-bundled Opus codec and the embedded
fonts (Iced-Icons, Cantarell/OFL-1.1). Ship both files in the .deb and Arch
packages. Update README License section and stale private-build metadata.
Codex (gpt-5.5) audited the tree and confirmed no copyleft blocks MIT; the
remaining items were asset provenance + binary-distribution notice hygiene,
which THIRD_PARTY_LICENSES addresses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Matches the 0.6.0 release; the Inno Setup MyAppVersion drives the
output filename (peerspeak-0.6.0-setup.exe) and the installed
AppVersion/uninstall entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
makepkg warned that usr/bin/peerspeak referenced $srcdir: Rust bakes source
paths into panic/backtrace metadata that survives stripping. Add
--remap-path-prefix=$srcdir=/ in build() so neither our sources nor the
vendored deps under CARGO_HOME leave the build dir embedded in the package.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Personal playlist + per-person timeline-synced shared listening with
gapless prefetch and per-source volume; standalone playlist card in the
3-column layout. Wire bump to gossip v5 (breaking). Version 0.5.1 -> 0.6.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Personal playlist on a dedicated music player (browse/play/prev/next/
seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared
listening: broadcast your track over presence, peers tune in and stream
it point-to-point over the files plane. Playback is timeline-synced
(play/pause/skip/seek mirror with no drift) with gapless prefetch of the
next track and independent per-source volume per listener.
In the 3-column layout the playlist gets its own card stacked under the
chat, with a resizable divider and its own scrollbar; other layouts keep
it in the Controls panel.
Breaking wire change: gossip protocol v5 (presence gains music fields),
so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 ->
0.6.0; CHANGELOG updated.
Untrusted-input handling: broadcast track name sanitized and size
cap-checked at gossip ingest, fetched bytes confirmed audio before
decode, only the descriptor rides gossip (bytes go point-to-point, one
fetch in flight).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking an inline chat image now opens it enlarged in a full-window
overlay. The image renders contain-fit (scaled down, never cropped) over
an 80% dimmed backdrop with a ✕ button pinned top-right. The overlay
closes four ways: Esc, clicking the backdrop, clicking the image, or the
✕ button.
- AppMessage: OpenImageLightbox(AttachmentKey) / CloseImageLightbox
- AppState.image_lightbox: Option<AttachmentKey> (init None, cleared on
leave in reset_room_state)
- inline image wrapped in a mouse_area with a pointer cursor
- with_image_lightbox overlay modeled on with_regenerate_confirm; guarded
cache lookup so an evicted handle can't panic
- Esc handled at the top of the KeyPressed arm so it takes priority over
user-bound hotkeys while the overlay is open
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a per-clip volume slider plus a master volume control with a
"Universal volume" toggle in the chat header.
- ClipPlayer gains a SetVolume command; the worker remembers gain across
clips and reapplies it to each freshly connected player.
- New config.clip_volume (universal level) and config.clip_volume_universal
(mode toggle, default on), both persisted; old configs load at unity in
universal mode.
- Universal on: master and per-clip sliders drive one shared level applied
to every clip. Universal off: each clip keeps its own in-memory level and
the master slider is inert.
- play_ready_audio applies the resolved effective gain right after Play.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add `SelectableRichText`, a custom iced widget that mirrors `rich_text`
(linkified spans + A13 link clicks) and adds per-message drag selection,
Ctrl/Cmd+C copy, and Ctrl/Cmd+A select-all. Swap it in for the chat body
element; the chat row and attachment layout are unchanged.
Selection offsets are paragraph-global byte offsets (matching cosmic-text's
hit_test), which equals a single global range because sanitize_chat keeps
every message on one logical line. Pure seam `selected_substring` /
`select_all` is unit-tested incl. unicode/emoji byte boundaries. Highlight
quads are computed from public Paragraph primitives, falling back to a
whole-message span_bounds union if sub-range rects can't be derived.
Only one message holds a selection at a time: each widget clears its own
selection on a left-press that lands outside its bounds. Right-click menu
(Part B) intentionally deferred — native Ctrl/Cmd+C/A is the path.
Implemented by Codex on branch, reviewed/committed by Claude.
464 lib tests, clippy --all-targets clean, release green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The source fragment used git+ssh://git@gitbutter.xyz, which requires the
maintainer's SSH key — so no one else could build peerspeak-git. The repo
is public on gitbutter, so switch to the anonymous HTTPS clone URL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a read-only-but-selectable "locked" mode to the A9 ContextInput so
share-critical values (full node ID, full room ticket) can be drag-selected
and copied with the mouse/keyboard, in addition to the existing one-click
Copy buttons (which are kept).
- context_input.rs: add `locked` flag + builder + `locked_value(value, noop)`
constructor. A controlled text_input with a no-op on_input stays focusable
and selection-capable while never mutating (iced treats on_input==None as
Disabled, verified against iced_widget-0.14.2 source).
- Extract overlay gating into a pure `menu_action_enabled` seam: when locked,
Cut/Paste are disabled, Copy is enabled with a (non-secure) selection, and
Select All is enabled when there's a value. +1 unit test.
- app/mod.rs: add AppMessage::Noop; render the full node ID and full ticket in
width-capped locked fields beside their existing Copy buttons.
Phase 2 (cross-message selectable chat transcript) intentionally deferred:
it requires a transcript-level custom widget that owns selection/layout/hit-
testing while preserving A13 links and attachment rows — out of scope for a
bounded edit. Design path recorded in the Codex task report.
Tests-green only (460 lib, clippy clean, release build green); wants a quick
field check of mouse drag-select + right-click Copy + Ctrl+A/C.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iced 0.14 ships no native right-click menu on text_input. Add a custom
ContextInput widget (src/widget/context_input.rs) that wraps text_input,
intercepts right-click to read the inner text_input::State selection, and
renders a themed 4-action overlay menu operating on that selection.
- Pure, grapheme-indexed edit seam (copy/cut/paste/select_all over
iced text_input::Value), unit-tested for ASCII and multi-byte/emoji.
- iced::advanced Widget + overlay::Overlay; clipboard via &mut dyn
Clipboard, edits published through the existing on_input/on_paste.
- Cut/Copy disabled on empty selection (and on secure fields), Select
All disabled on empty field, Paste always enabled; dismiss on
click-out / Esc / item-click.
- Route all 10 text_input call sites in app/mod.rs through context_input.
- Cargo.toml: enable iced "advanced" feature (same crate, no new dep).
459 lib tests (+5), clippy --all-targets clean, release green.
Implemented by Codex (gpt-5.5), senior-audited against the 5-point brief
and re-verified (tests/clippy/release) here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
CoreController::send put every app->core command on a single bounded
depth-100 channel via try_send and discarded the result. iced slider
drags emit ~60-120 commands/sec, so a drag burst could transiently
saturate the queue exactly when the user hit mute / released PTT /
left a room, silently dropping that critical command and leaving the
mic hot -- a privacy/state mismatch.
Split the queue by drop-tolerance:
- A pure delivery_class(&CoreCommand) classifier in messages.rs maps the
7 continuous audio sliders to BestEffort and every other (discrete,
human-paced) command to Reliable. The match has no wildcard arm, so a
new CoreCommand variant fails to compile until it is classified.
- CoreController now holds two senders: an unbounded reliable channel
and the existing bounded(100) best-effort channel. send() routes by
class; Reliable uses unbounded send (fails only if the core loop is
dead), BestEffort keeps today's bounded try_send.
- run_core_loop takes both receivers and drains them with a biased
select: reliable first, best-effort second, game-change third.
Unbounded is safe because the only machine-rate producer (slider drags)
stays on the bounded channel; Reliable commands are all human-paced.
command_sender() and the awaiting Shutdown path are unchanged.
Implemented by Codex (gpt-5.5), senior-reviewed and verified here:
454 lib tests pass, clippy --all-targets clean, release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A validly-signed gossip payload rejected only by the 120s replay
freshness window (GossipReject::OutOfWindow) now drives a room-level
"clocks out of sync" warning banner, instead of silently dropping the
peer so the room shows "1 in room" with no error.
Observe-only: verify_gossip's accept/reject decision and
GOSSIP_FRESHNESS_MS are unchanged; the payload is still dropped exactly
as before. The warning is gated strictly on OutOfWindow (which, because
the signature is verified first, implies a genuine authenticated peer
whose clock is skewed), never on BadSignature.
Policy lives in a pure, unit-tested ClockSkewMonitor seam with injected
now_ms: >=3 OutOfWindow drops from the same author within 60s warn once,
5-min per-author cooldown, bounded/pruned author map. The warning rides
the existing in-process RoomEvent -> UiEvent -> transient-banner path
(no wire/serialization or dependency change).
Implemented by Codex (gpt-5.5), senior-audited against the 5-point
checklist and independently verified (452 lib tests, clippy
--all-targets clean, release build green). Tests-green only; a 2-machine
deliberate-skew field test is still owed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A24 (per-peer volume slider has no effect) does not reproduce on current
main from a code trace: the UI slider's EndpointId is the same key the
mixer uses for the incoming jitter frame, and the gain is applied before
EQ/pan/output. Extract the inline per-peer lookup into a pure
apply_peer_volume() seam and add two regression tests:
- matching key scales the frame (0.5 halves it)
- mismatched key defaults to unity (guards the key-identity failure mode)
No wire/gossip/identity/PeerState change. The field-reported A24 was most
likely a stale listener build (volume is listener-side); needs a 2-machine
audible re-verify to close.
Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
When a pixelpass host dies before emitting its ticket (e.g. a fresh Ubuntu
box missing the GStreamer capture stack), we discarded its stderr — which is
exactly where pixelpass prints the failing element plus an actionable
"Install hint: sudo apt install ..." line. The user only saw a dead-end
"pixelpass host exited before emitting a ticket".
Capture stderr instead of nulling it, and on the early-exit path fold a short
tail into our error via the pure `pixelpass_failure_detail` (drops the
decorative banner, keeps the error + hint, caps at the last 12 lines). On the
success path stderr is drained in the background so its pipe can't stall the
host. +3 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Triage of Codex's a23-p2-audit-2026-06-26 (the strict-audio fix itself verified
sound — no path reloads the desktop loopback). Four findings actioned:
- P2 (F1, silent-from-start): pair pixelpass's new initial `app_audio:lost`
with a reworded warning ("Shared app isn't sending audio — viewers hear
silence until it plays") so an app share that never produced audio is flagged,
not just one that stopped.
- P2 (version skew): probe `pixelpass --help` for `--strict-audio` before
offering per-app capture (pure `help_mentions_strict_audio` + timeout-bounded,
kill-on-drop `supports_strict_audio`). An older pixelpass would have clap
reject the flag and hard-fail the share; now the picker degrades to
whole-desktop only with an upgrade nudge. Never a silent best-effort `--app`
fallback (that reopens A23). AudioAppsListed now carries `app_audio_supported`.
- P3 (pactl child leak): list_audio_apps' enumeration child is now
kill_on_drop, so a wedged pactl is reaped on the 2s timeout.
- P3 (unscoped events): apply ShareAudioActive only while actually sharing a
specific app (`share_audio_app_active`), so a late event from a just-killed
host can't flip the warning after stop or on a whole-desktop share.
+4 tests (probe parse, post-stop/whole-desktop guard, old-pixelpass picker
degrade), updated reset + app_audio tracking tests. 440 lib tests, clippy
--all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror packaging/PKGBUILD as a [package.metadata.deb] block so a Debian/
Ubuntu .deb can be produced with `cargo deb` from inside a matching
distrobox (glibc is forward-compatible only, so the build base sets the
minimum target). Ships only the `peerspeak` binary (not test_net/specview),
the desktop entry, and the full hicolor icon set; runtime shared-lib deps
are resolved by dpkg-shlibdeps. Recommends pixelpass + mpv. Also add the
missing [package].description (cargo-deb requires a synopsis) and roll the
stale PKGBUILD pkgver fallback to the current 0.4.0 tag.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consumes the new pixelpass --strict-audio mode + app_audio events (pixelpass
85fdebe) to close Codex's A23 P2: the per-app pick alone was best-effort —
pixelpass would fall back to whole-desktop audio before the app routed and again
if it stopped, both reintroducing the call echo.
- host_args appends --strict-audio alongside --app=<name>, so pixelpass never
mirrors the desktop for our share: viewers hear only the chosen app or silence.
- Parse the app_audio JSON event (routed/lost) into PixelpassEvent; spawn_host
takes an optional notices channel and the stdout drain forwards events on it.
- Core spawns a forwarder (only when an app is selected) mapping routed/lost to
UiEvent::ShareAudioActive(bool); the channel/task self-terminate on host EOF.
- App tracks share_audio_dropped and shows a transient warning under the "Sharing
your screen" badge when the chosen app's audio stops ("viewers hear silence
until it plays again"). Reset on start/stop/room-leave.
In-process mpsc only — no wire/GOSSIP_PROTO change. 435 lib tests (+2: app_audio
parse + the dropped-flag state machine), clippy --all-targets + release clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Triage of Codex's read-only A23 audit (a23-audit-2026-06-26.md). The P2 (pixelpass
--app best-effort fallback) is cross-repo and deferred to a design decision; these
are the three actionable peerspeak-side P3s:
- P3-1: guard the share-startup window. New `share_starting` flag blocks reopening
the picker (and re-firing StartScreenShare) between ConfirmShareScreen and the
core's ScreenShareStarted; cleared on Started/Stopped/Error so a failed spawn
(surfaced as Error, not Stopped) can't wedge it. +2 state-machine tests.
- P3-2: parse_audio_apps now runs each name through sanitize_app_name, so the
picker never offers a name that host_args would later silently drop (which would
revert the share to whole-desktop audio = the A23 echo, with no signal). +1 test.
- P3-3: list_audio_apps wraps pactl in a 2s timeout so a wedged enumeration can't
stall the core command loop (mute/deafen/leave/stop) while the picker opens.
433 lib tests (+3), clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Screen sharing spawned pixelpass with a bare `--host`, so pixelpass
captured the whole default-sink monitor — which contains peerspeak's own
call playout. A viewer therefore heard their OWN voice echoed back out of
the sharer's machine (confirmed live, backlog A23).
pixelpass already supports `--app <NAME>` (capture only one app's audio,
per-app PipeWire routing); peerspeak just never passed it. This wires that
flag through, peerspeak-side only — no pixelpass change.
- screenshare: pure `host_args(audio_app)` builds the host argv, appending
`--app=<name>` (single-token form so a hyphen-leading name can't be
reparsed as a flag) when an app is chosen; `sanitize_app_name` guards the
locally-chosen value; `list_audio_apps`/`parse_audio_apps` enumerate
currently-playing apps via `pactl -f json list sink-inputs` (mirroring how
pixelpass builds its own picker, so the names match what `--app` matches).
- core: `StartScreenShare { audio_app }` + `ListAudioApps`/`AudioAppsListed`.
- GUI: Share Screen now opens a small audio picker (radio-style modal) listing
the playing apps + "All system audio" (warned, = legacy whole-desktop);
picking one starts the share with `--app=<name>`. Reset on room leave.
+6 unit tests (host_args with/without/blank app, sanitize_app_name,
parse_audio_apps dedup + garbage). 430 lib tests, clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex audit of 381e00b (f02-f12-audit-2026-06-26) found no P1/P2 regression
and verified the F-12 key round-trip invariant sound (iroh EndpointId
Display/FromStr are exact inverses). Acting on the one real P3 + nits:
- P3: save_attachment_task picked the dialog's DEFAULT FILENAME by bare
attachment id, so a peer reusing a victim's id could mislabel the save
with another sender's name/extension (bytes were already author-keyed and
correct — this was a metadata residual, not content aliasing). Extracted a
pure `attachment_default_name` that matches the full (author, id) key, like
find_attachment_source. +1 unit test (closes the audit's coverage gap).
- Doc nits: refreshed the stale `attachment_data` reference on ChatEntry,
the "keyed by attachment id" note on spawn_attachment_fetch, and a
duplicated doc block above find_attachment_source.
DEFERRED (user decision pending): the P3 judgement call — pending_plays /
invalid_audio / clip playing_id stay bare-id keyed, so duplicate-id audio
rows share play/seek/invalid state (cosmetic; bytes played are still
author-keyed and correct). Fully closing it means threading AttachmentKey
through the clip player.
424 lib tests, clippy --all-targets clean, release build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The chat-attachment result cache (`attachment_data` + `image_handle_cache`)
was keyed by attachment id alone and only cleared on room-leave, so an
authenticated insider could (F-02) stream distinct attachments to grow it
without bound, and (F-12) reuse a victim's attachment id to alias displayed/
saved bytes — the id is attacker-chosen, so a signature only proves keypair
ownership, not a distinct human.
F-12: thread the author (`from: EndpointId`) back through the
`AttachmentReady`/`AttachmentFailed` core→UI events (the fetch task already
holds it) and key all attachment result state on `(author, id)`:
- new `AttachmentKey = (EndpointId, AttachmentId)`;
- `attachment_data` + `image_handle_cache` fold into one `AttachmentCache`;
- `pending_saves` and the `SaveAttachment`/`PlayAudio` messages re-keyed, so
the save/fetch dispatch can't be redirected to the wrong sender's line;
- `find_attachment_source` now matches author AND id;
- the render path resolves each line's key from `ChatEntry.from`.
F-02: `AttachmentCache` is bounded (`ATTACHMENT_CACHE_CAP = 64`) with
insertion-order eviction. True LRU is impossible because iced's `view`
borrows `&self` and so can't reorder on a render read; the generous cap means
a normal session never evicts and the newest (on-screen) entries are always
retained — only an abusive stream hits the bound.
Deliberately id-keyed (cosmetic only, documented): the clip player's
`playing_id`, `pending_plays`, `invalid_audio` — they're coupled to the
id-keyed clip player, and the bytes actually played come from the
author-keyed cache, so content is always correct.
No gossip/wire/protocol change (UiEvent is in-process), no new deps. +6
unit tests (cache eviction, replace-keeps-position, same-id/distinct-author
non-aliasing, is_ready/handle/clear, cap-zero clamp). 423 lib tests,
clippy --all-targets clean, release build green. TESTS-GREEN-ONLY.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the remaining insider resource-exhaustion vector the Codex Tier C
audit flagged: the active-roster cap bounds the live peer map, but a member
could join (<=32), drop the link without a signed Leave, let the grace timer
expire, and repeat with a fresh identity. Each abandoned identity grew two
unbounded structures and kept doing periodic work forever:
- known_peers[topic] (the retained rejoin/recovery dial table) was only
pruned on a signed PeerLeft, so grace-evicted ghosts accumulated.
- the recovery coordinator's active set + entries map had no identity cap
and no terminal retry budget — backoff saturated at 60s and re-dialed a
never-returning peer indefinitely.
Two non-breaking, dependency-free bounds (no wire/protocol change):
- MAX_RETAINED_PEERS=64 per topic via pure admit_retained() — refreshing a
tracked peer always succeeds, a brand-new identity is rejected when full.
Set above MAX_ACTIVE_PEERS=32 so legitimate rooms never hit it.
- RECOVERY_TERMINAL_ATTEMPTS=12 (~7 min) via pure recovery_is_terminal():
the coordinator gives up, frees the active slot, and signals a new
terminal channel; a small drain task forgets the retained address (so the
table self-drains), scrubs seen-connected state, and emits
PeerConnectionFailed.
Giving up never blocks a legitimate reconnect: a peer returning after a long
outage still rejoins on its own via a gossip announce — terminal eviction only
stops us from dialing a peer that is not coming back, which was a latent leak
even absent an attacker.
+2 pure-seam unit tests (admit_retained, recovery_is_terminal); 418 lib tests
green, clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to 5c11947 after Codex's adversarial audit
(tier-c-audit-2026-06-23.md). Fixes a regression the roster cap introduced
and closes F-01's two cheap unbounded-growth vectors. No wire/protocol
change, no new deps.
- Regression (cap × reconnect): a peer reconnecting from a transient drop
sits in `disconnected_peers`, not the live roster, so the new cap could
reject it as "new" at a full 32-peer roster — and the eager
`disconnected_peers.remove()` (before the cap check) then orphaned its
recovery state so a later signed Leave skipped cleanup. Now reconnecting
(and existing) peers are exempt from the cap via the pure
`announce_subject_to_cap`, and the disconnect marker is cleared only after
admission. PeerJoined semantics for reconnects are preserved.
- F-01 replay map: `state_mutations_seen` was uncapped, so signed Leaves
from unlimited generated keys grew it for the room's lifetime. Prune
entries older than the freshness window once past a soft cap
(`prune_stale_mutations`) — stale entries can't gate an in-window message
(verify_gossip rejects the replay first), so replay protection is intact;
the map is now bounded to ~authors-seen-per-window.
- F-01 address lookup: a signed Leave now calls `remove_endpoint_info`, so
cycling identities through Announce→Leave can't grow the iroh lookup
without bound. Re-announce re-populates it.
- F-03 test: added a forced same-hash/different-bytes ByteLru test (via a
hash-injectable inner seam) so collision-safety is regression-tested, not
just code-reviewed.
Deferred follow-ups from the audit (logged): recovery/known_peers identity
cap (needs a design pass, touches reconnect-resilience), F-02 result-cache
LRU, and the (author,id)-vs-id attachment aliasing integrity bug.
416 lib tests (+3), clippy --all-targets clean, release build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A room ticket holder is an authenticated insider; signatures only prove
keypair ownership, not a distinct human. Previously such a member could
exhaust a victim's memory/tasks/dials without bound. Add caps + dedup at
the gossip/core/UI boundaries (no wire/protocol change, no new deps):
F-01 (gossip): cap the roster at MAX_ACTIVE_PEERS (32) — new authors are
rejected when full, existing peers' updates always pass; sanitize each
announced EndpointAddr (<=8 addrs, relay-URL <=256 bytes, drop Custom);
replace (set_endpoint_info) instead of unioning attacker address history.
F-02 (core): gate chat image auto-fetch — only roster authors qualify,
(author, attachment_id) is deduped, and a 4-permit pool bounds concurrent
detached fetch tasks (RAII AutoFetchGuard releases permit + dedup marker).
Chat text is still shown (already sanitized); the user-initiated "Save"
fetch is unchanged. Non-roster sock-puppet chat can no longer spawn tasks.
F-03 (app): replace the unbounded AVATAR_HANDLE_CACHE map with a bounded,
byte-equality-keyed LRU (avatar::ByteLru, cap 64) — fixes both unbounded
growth from an endless stream of distinct valid avatars and the prior
64-bit-hash-collision-shows-wrong-avatar bug.
Pure seams (sanitize_endpoint_addr, admit_into_roster, should_auto_fetch,
ByteLru) + 6 adversarial/unit tests. 413 lib tests, clippy --all-targets
clean, release build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 0.4.0 Windows binary was cross-built (x86_64-pc-windows-gnu,
build-std static) and verified in the libvirt Windows 11 VM: the raw
exe launches/renders the full v0.4.0 UI and runs stably, and the
compiled installer was test-installed end-to-end (Program Files exe
sha256 1a211eb6…, Start-menu shortcut, firewall rule, launch from
the installed location) before publishing to the v0.4.0 release.
- peerspeak.iss: MyAppVersion 0.3.0 -> 0.4.0 (installer output is
peerspeak-0.4.0-setup.exe)
- INSTALL.md / README.md: update the 0.3.0 filename/version references
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>