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>
Field-test regression from the F-06 fix (a17b930): joining a room via the
friends-list / Recents Join button sometimes landed in an empty roster even
though the peer was fully connected at the gossip layer.
Cause: `PeerJoined` (gossip event task) and `RoomJoined` (core command loop)
ride the same UI channel from different senders. The core emits `RoomJoined`
only after audio + echo-cancel setup, so `PeerJoined` for the new room
routinely arrives first. F-06 had added `reset_room_state()` to the
`RoomJoined` handler, which then cleared the peer that had already announced.
Echo cancellation widened the window and made it reliable; the roster
"self-healed" only on the peer's next periodic re-announce (`PeerUpdated`).
Fix: reset room-scoped UI state at join *initiation* (JoinPressed,
CreatePressed, JoinFriendRoom, JoinRecent) instead of on `RoomJoined`. From
Home that's a no-op (already cleared on leave), so nothing leaks, and an
early `PeerJoined` for the new room now survives. The in-call switch path
F-06 targeted is unreachable from the current UI (friends list + Recents
render only on the Home screen), so this fully covers the reachable case.
Field-verified on a 2-machine desktop<->dopedart call. 407 lib tests pass,
clippy --all-targets clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Four confirmed P2 findings from the 2026-06-22 adversarial bug sweep.
None change the wire format / PeerState / GOSSIP_PROTO — all local.
- F-05: re-key the A8 rejoin archive (known_peers) and RecoveryContext
by topic_id ([u8; 32]) instead of the raw ticket string. A W7-
restamped member ticket shares the room's topic but not its string,
so a rejoin-from-Recents previously missed the retained bootstrap
bucket and dropped to an empty bootstrap — the exact dead-end A8
fixed. Topic is derived once via PeerSpeakTicket::topic_of in Join;
a malformed ticket now fails early and clean.
- F-06: an in-call Join no longer leaks the old room's peers/chat into
the new room, nor strands stale presence on a failed switch. Core
captures was_in_room, clears current_room at teardown, and emits a
new local UiEvent::RoomReset on every post-teardown failure path so
a failed switch lands idle on Home. The UI's room-scoped clearing is
factored into AppState::reset_room_state(), called by RoomLeft,
RoomReset, and at the top of RoomJoined — so a successful switch
clears+repopulates seamlessly on the Room screen (no Home bounce, no
leave chime).
- F-10: echo-cancel virtual nodes now get per-PID-unique names
(peerspeak_echocancel_{source,sink}.<pid>); the guard carries them
and core targets them instead of the fixed constants. unload_stale
only unloads our modules whose owner PID is dead (/proc check, cfg-
gated; conservative elsewhere), so enabling AEC in one instance can
no longer tear down another live instance's call. Pure
pid_from_ec_args / ec_module_is_stale seams.
- F-11: a recording write failure now stops recording atomically
(best-effort finalize via stop_recording + one UI Error) instead of
looping the error at ~50 Hz with silent data loss. Both mixer
branches release the recorder mutex before calling stop_recording to
avoid a self-deadlock on the non-reentrant std::Mutex.
407 lib tests pass (+4), clippy --all-targets clean, release build
green. Tests-green only; the rejoin (F-05), in-call switch (F-06),
two-instance AEC (F-10), and disk-full (F-11) paths need a real run.
Implemented by Codex, reviewed + gates re-run by senior.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Five confirmed findings from the 2026-06-22 adversarial bug sweep:
- S-01: clamp PipeWire capture chunk size to the mapped slice before
indexing, so a bad reported size can't panic (= process abort) from
the RT capture callback. Extracted testable for_each_capture_sample.
- F-04: reserve ring occupancy before publishing a frame on the PipeWire
playback path (mirrors the cpal fix), preventing the RT consumer from
popping an uncounted sample and wrapping fill_gauge to usize::MAX,
which permanently wedged mixer pacing. Extracted publish_frame.
- F-09: GameDetector::spawn now returns io::Result and retains its
JoinHandle (joined on Drop); core fuses a closed watch receiver to
None via next_game_change so a dead detector can't busy-loop select!.
- F-08: collision-free recording paths — Recorder::create and the
multitrack session dir use create_new/create_dir with bounded suffix
retry, so two recordings in the same second no longer truncate the
first.
- S-02: bound the Windows SteamPath registry read (<=4 KiB, even length,
re-checked type/returned length) before allocating/decoding.
403 lib tests pass (+6), clippy --all-targets clean. Implemented by
Codex, reviewed + gates re-run by senior.
Co-Authored-By: Codex <codex@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the new PeerState.game field to the presence log formatter so
gossip Announces show whether a peer is broadcasting a game. The line
previously printed name/muted/addr_id/addrs/sharing only, making the
game-presence broadcast invisible in logs (verified solely via UI
during the 2026-06-22 2-machine field test). Log-only: no wire,
protocol, or GOSSIP_PROTO change. Adds the first unit test for the
formatter (Some and None cases).
Implemented by Codex (gpt-5.5) on branch codex-log-game-field; reviewed
and gates re-run by the senior (397 lib tests, clippy --all-targets,
release build all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field test found Steam games were never detected on Linux. Root cause:
Steam rewrites ~/.steam/registry.vdf only on SHUTDOWN, so its RunningAppID
is stale (often absent) while a game is actually running — polling it can
never see the live game.
Fix: on Linux, read the live appid from the running game's environment
(SteamAppId in /proc/<pid>/environ, the var Steam exports to every game
process — the same signal MangoHud uses; readable for our own processes).
registry.vdf stays as a best-effort fallback. Windows still reads the real
registry's RunningAppID, which IS updated live there. Other Unix keeps the
registry.vdf fallback.
Pure parse_steam_app_id_from_environ() is unit-tested (nonzero filter,
absent, substring-not-fooled, garbage). Also fixes a latent bug in the
first draft where a single non-UTF8 SteamAppId value would abort the whole
scan via ? instead of skipping.
396 lib tests, clippy --all-targets clean.
Final step of game detection. Functional, plain styling (to art-direct).
- Settings 'Games' category: opt-in 'show my game' toggle
(SetGamePresenceEnabled, persisted), manual override picker (Auto /
None / Pin current), per-game background picker+remove (reuses
process_background + hashed game_background_path), and a non-Steam
process->name mapping editor (add/remove, pushes SetGameProcessMap).
- Roster: each peer card shows 'Playing <game>' under their name when
they broadcast one; our own self card shows it too, marked
'(not shared)' when broadcasting is off.
- Startup: seeds SetGamePresenceEnabled + SetGameProcessMap from config.
- Updated the settings-category navigation test for the new category.
395 lib tests green, clippy --all-targets clean, binary builds, and an
8s smoke launch starts the core + detector thread with no panic (detector
logs nothing by design — privacy).
Feature complete on Linux end-to-end (pending a coordinated GOSSIP_PROTO
3 redeploy to field-test presence with peers). Windows FFI still needs
its cross-build pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Steps 5-6 of game detection. BREAKING wire change — bump everyone.
Wire (step 5):
- PeerState.game: Option<String> (display label only — never appid/source).
- SelfPresence.game + to_state carry it (single self-state builder).
- GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together;
Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into
different topics + signature domains, so a coordinated redeploy is
required (same as the W4 avatar bump).
- Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/
control strip, 64-char/256-byte cap); empty -> None.
- Bonus security fix (Codex find): reject inbound gossip frames over a
128KB cap BEFORE serde_json::from_slice — a legit Announce with a full
48KB avatar is ~49KB, so this bounds allocation abuse with headroom.
Core wiring:
- Spawns the detector at startup; consumes its watch channel in the main
select. Detection runs continuously (for the local background); the
broadcast is gated by game_presence_enabled (opt-in, default OFF).
New commands: SetGamePresenceEnabled (immediate publish/clear, D8),
SetGameOverride, SetGameProcessMap. New event: GameChanged.
- game_presence_label sanitizes the outgoing label too.
Background switch (step 6):
- GUI handles GameChanged: stores current_game, swaps background to the
per-game override (config.game_backgrounds[id]) or falls back to the
W16 default; reuses the existing cached-handle path (no redraw flicker).
397 lib tests (all green), clippy --all-targets clean, full binary builds.
Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual
override control, Settings game-backgrounds + process-map editors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 4 of game detection. One std-thread worker owns the SteamProbe cache
+ Debouncer across ticks, polls the OS adapters every 3s off the async
runtime, and publishes the stable detected game on a tokio watch channel
only when it changes. Manual override + process map are live-updatable via
shared handles; a cancellable sleep honors stop promptly; drop stops it.
The per-tick decision (match + resolve + debounce) is the pure poll_once,
unit-tested with synthetic Steam/process inputs (debounce, process-only
match, immediate manual override). +4 tests (397 lib).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 2-3 of game detection (game-presence-plan.md). The OS edges feeding
the pure seams from the previous commit.
- src/game/steam.rs: SteamProbe — reads the live RunningAppID and resolves
it to a name via appmanifest_<id>.acf (no binary appinfo.vdf). Pure parse
fns (parse_running_app_id / parse_library_paths / parse_app_name) over
file contents are unit-tested incl. current+legacy libraryfolders shapes,
escaped Windows paths, empty/missing names, and garbage. Roots discovered
across native/Flatpak/Snap (Linux) and the registry (Windows); libraries
and resolved names cached + mtime-invalidated so the 3s poll doesn't
rescan. File reads byte-capped.
- src/game/scan.rs: native running-process enumeration — /proc (exe symlink,
comm fallback) on Linux, Toolhelp on Windows — feeding the pure
match_processes. No sysinfo dep (D7).
- Cargo.toml: windows-sys as a direct Windows-only dep for the registry +
Toolhelp FFI. No NEW crate — it was already in the lockfile transitively
via cpal/rfd, so the audit surface is unchanged.
391 lib tests (+5). Linux: build + clippy --all-targets clean. Windows FFI
signatures verified against windows-sys 0.61 source (one *const vs *mut
lpReserved fixed) but NOT yet cross-compiled — defer to the post-UI Windows
build cycle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 1 of the game-detection feature (game-presence-plan.md): all the
pure, I/O-free logic, tested first.
- src/game/mod.rs: DetectedGame + stable namespaced ids (steam:730 /
exe:hl2_linux, never the mutable name); ManualOverride; the priority
resolve() matcher (override -> Steam -> mapped process -> none); the
Debouncer (2-on/3-off, immediate bypass for manual override) that
stops a flapping detector re-announcing the ~48KB-avatar PeerState;
match_processes() over explicit user mappings with a launcher denylist
(never guesses a game from an arbitrary process).
- src/game/vdf.rs: a real recursive-descent KeyValues/VDF parser (not a
name-regex) for appmanifest/.acf, libraryfolders.vdf, registry.vdf —
depth-capped, escape-aware, never panics on malformed/truncated input.
- src/sanitize.rs: sanitize_game_label (64-char/256-byte cap, wider than
the 48-char name cap) sharing the bidi/zero-width cleaning.
- src/config.rs: additive game_presence_enabled (opt-in, default OFF),
game_backgrounds + game_process_map (BTreeMap, deterministic);
background_path generalized to hashed per-game files; explicit
legacy-config migration test (load() wipes on any deserialize error).
- src/background.rs: game_background_filename (FNV-1a hashed, fs-safe).
No wire/protocol change yet; no OS reads yet. 386 lib tests (+28).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Core reconstructed PeerState in five command branches (join, mute
toggle, avatar change, screen-share start/stop), each repeating the full
field list. Factor a SelfPresence struct holding the sticky identity
fields (name + avatar) with a to_state(is_muted, addr, sharing) builder
that folds in the volatile per-announce fields, so the PeerState literal
lives in one place. This is the precondition for adding a broadcast
game-presence field without editing every call site.
No behavior change. +1 unit test (359 lib total path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Save-attachment handler called the blocking rfd::FileDialog::save_file()
directly inside iced's update() loop. That blocking dialog spins its own GTK
loop; invoked from within iced's already-running event loop (notably the Linux
xdg-desktop-portal/GTK backend, but also observed wedged on Windows) the dialog
becomes unresponsive — Save/Cancel clicks are never processed.
Convert to rfd::AsyncFileDialog returning a Task, mirroring the existing file
*picker* paths (PickAttachmentFile / PickAvatarFile / PickBackgroundFile) which
already use the async variant. The chosen path's bytes are written when the
future resolves; the status line is reported via a new AttachmentSaved message.
No blocking call remains in the update loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pre-stages the playback dependency for the inline chat audio player task so
Codex can build it in its network-off sandbox.
rodio 0.22.2 decodes wav/mp3/ogg(vorbis)/flac (via bundled symphonia) and
handles output + play/pause/seek + resampling. It brings its own cpal 0.17
(the project's PipeWire/cpal-0.15 call path is untouched; rodio's output is a
separate stream on the system default device) and alsa on Linux.
Supply chain: cargo audit reports NO new advisories from this subtree -- the
only 2 warnings (audiopus_sys, paste) are pre-existing, unmaintained-only, and
already on the allow-list. Builds clean (release).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat file fetches failed 100% of the time with "file fetch: read failed:
read error: connection lost" (both images and arbitrary files, both
directions). Root cause: the FileRouter serve handler called send.finish()
and immediately returned Ok(()), which dropped the Connection. In QUIC,
finish() only marks the stream's EOF -- it does not wait for the written
bytes to be delivered and acknowledged -- so the connection's
CONNECTION_CLOSE raced ahead of the still-in-flight stream data and the
fetcher's read_to_end aborted.
Fix: after finishing, wait on connection.closed() (bounded by
FILE_FETCH_TIMEOUT) so the link stays up until the fetcher has read
everything and closed the connection itself, which is the signal the
transfer landed.
Wire-compatible (no protocol change), so version stays 0.3.0; both peers
just need the rebuilt binary since either side can be the file server.
Adds tests/file_transfer_loopback.rs: a real two-endpoint serve->fetch
round-trip over FILES_ALPN with a 2 MiB multi-packet blob (deterministic
A/B: 0/20 pass without the fix, 20/20 with it) plus an unknown-id "gone"
case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- INSTALL.md: bump the setup filename to 0.3.0; add an end-user section
on text chat + sending photos/files (inline images, file chips,
Save/Download, 25 MB cap, session-only); note that both ends must run
the same version under "won't connect".
- README.md: add a Version compatibility section (installer version
tracks Cargo; a 0.x MINOR bump is a breaking wire change so everyone
must reinstall; 0.3.0 can't talk to 0.2.x).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the Cargo 0.3.0 release (chat file sharing + per-peer gate). The
installer payload is unchanged (single self-contained peerspeak.exe +
icon); only the version string / output filename move to 0.3.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the send/receive paths and the chat UI on top of the file plane.
(Committed together because the UI renders the state the core wiring
produces.)
Core:
- CoreCommand::SendChatFile {text, attachment, data}: serve the bytes on
the file plane (serve_attachment) then broadcast the descriptor via
send_chat. CoreCommand::FetchAttachment {from, attachment}: detached
fetch -> AttachmentReady/AttachmentFailed.
- On an inbound Chat with an Image attachment, auto-fetch + defensively
re-validate (decodable + within pixel limits) before delivering;
non-images wait for an explicit fetch (the Save/Download chip).
- UiEvent::ChatMessage carries the attachment; new AttachmentReady /
AttachmentFailed events keyed by attachment id.
App:
- 📎 attach button + native picker; reads the file, enforces the size
cap, classifies image vs file, mints a random id, optimistically
echoes the message + caches our own bytes (so we see our own image
inline), and sends SendChatFile.
- Renders inline image thumbnails (handle cached by id to avoid the
per-redraw re-upload flicker), file chips with Save/Download, a
loading placeholder for in-flight images, and an error line on
failure. Image messages with no caption still render.
- SaveAttachment: saves immediately if bytes are in hand, else fetches
then saves when ready (pending_saves) via a native save dialog;
filename defaulted from the sanitized descriptor.
- Session-only: attachment bytes/handles cleared on leave, never
persisted.
Binary + clippy clean, 349 lib tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the dedicated FILES_ALPN data plane that moves attachment bytes
off-gossip via direct QUIC streams.
- FileRouter (ProtocolHandler on the persistent router, mirroring
AudioRouter): bound to the active session's Shared on join, cleared on
leave. On an inbound stream it authenticates the peer via the ALPN
handshake, gates on live room membership (reuses audio_sender_admitted,
so a former member cannot pull files), reads exactly one 32-byte
attachment id (bounded request read), and streams back the matching
blob from the session serve store — or an empty body for an unknown id.
- Shared gains served_files (id -> bytes), populated by serve_attachment
and cleared on leave.
- IrohTransport::serve_attachment + fetch_attachment (inherent methods;
transport is used concretely). fetch dials the sender on FILES_ALPN
(preferring a known full address), writes the id, and reads bounded by
the descriptor's declared size, with a 30s connect/read timeout so a
stalled sender can't hang the fetch.
- Register FILES_ALPN in the router; bind/clear file_router in lock-step
with audio_router at every join/leave site.
Builds + clippy clean, 349 lib tests pass (plane is runtime I/O,
field-tested in stage 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of in-chat file/photo sharing (dedicated file plane, images
inline + file chips, session-only). This stage adds the wire types and
the pure, unit-tested logic; no transport or UI yet.
- protocol: new FILES_ALPN / FILES_PROTO (peerspeak/files/1) for the
dedicated file-transfer plane. Bump GOSSIP_PROTO 1->2 + sig domain v2
(Chat gained an attachment field, so cross-version peers fail fast
rather than half-work) and Cargo 0.2.0 -> 0.3.0 per VERSIONING.md.
BREAKING wire change: all peers must run >= 0.3.0.
- new src/files.rs: ChatAttachment descriptor (name/size/kind/id; bytes
travel off-gossip), AttachmentKind, plus pure seams — sanitize_filename
(path-traversal/control-char/length-safe), size_within_cap, image
magic-byte sniffing + defensive limited decode (decode-bomb guard),
32-byte request parsing, human_size. 13 unit tests.
- GossipMessage::Chat and RoomEvent::ChatMessage carry an optional
ChatAttachment; send_chat takes Option<ChatAttachment>. Untrusted
inbound descriptors are filename-sanitized + size-validated on ingest.
serde(default) keeps the field forward-compatible at the JSON layer;
+round-trip and pre-v2 back-compat tests.
The attachment id is a random 32-byte handle (rand, already a dep), not
a content hash — the fetch is authenticated + encrypted + member-gated,
so no crypto-hash dep is needed.
349 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Let a listener apply a noise gate to an individual peer's incoming
audio — "fix this person's noisy mic / background hum on my end" —
which is only possible because full-mesh P2P keeps every peer's stream
unmixed locally (server-mixed apps can't do per-listener per-peer DSP).
The DSP is the existing mic NoiseGate reused verbatim: it already
processes i16 frames at a fixed rate with hysteresis/attack/release/
hangover and takes the threshold per-frame. Wiring mirrors per-peer EQ:
- AppConfig.peer_gate map (threshold per peer id; absent/0 = off),
persisted, never sent over the wire
- CoreCommand::SetPeerGate + Arc<Mutex<HashMap>> shared into the mixer
- a live HashMap<EndpointId, NoiseGate> in the mixer task, created
lazily and dropped when disabled (no rebuild needed — threshold is
passed per frame)
- Gate row (threshold slider, "Off" at zero) in each participant card
next to Vol/Pan/EQ, persisting on release
The gate runs on the raw decoded frame: after the clean multitrack stem
tap (recordings stay ungated) but before volume/EQ, so the threshold
tracks the peer's true signal level regardless of our volume setting.
Same 0..METER_MAX scale as the mic gate.
+2 unit tests (config helper); +1 config back-compat assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-peer volume slider had no .step(), so iced's default step of
1.0 on a 0.0..=2.0 range meant it could only snap to 0%, 100%, or
200% — it felt like hard-left/hard-right only. Add .step(0.01) for
smooth 1%-increment control (matching the Pan slider below it, which
already set its own step).
Also persist per-peer volume across sessions, mirroring peer_pan/peer_eq:
- new AppConfig.peer_volume map (keyed by peer id string, serde default
for back-compat; never sent over the wire)
- replace the in-memory peer_volumes map with config-backed storage via
a new set_peer_volume_config helper (clamps to range, drops at-unity
entries so the config stays tidy)
- replay saved volumes to core on startup alongside pan/eq
- the slider writes to disk on release (AppMessage::PersistConfig)
+1 unit test for the config helper; +1 config back-compat assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>