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>
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>
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>
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>
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>
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>
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 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>
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>
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 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>