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>
Three Windows-compatibility fixes from the Codex review. Implemented by Codex
(gpt-5.5); reviewed and committed by Claude.
W5 — echo cancellation is a Linux/PipeWire feature, but the toggle was shown and
live on Windows, so a Windows join tried `pactl` and errored before falling back.
Now `#[cfg(target_os = "linux")]` gates the core enable path (and the
ActiveSession guard field); on other targets the Settings + in-call controls
render as a disabled checkbox with a "not available on Windows yet" note.
W6 — pixelpass PATH lookup only tried `pixelpass`; on Windows it now also tries
`pixelpass.exe` via a cfg-selected candidate list (+ unit test).
W9 — the Linux audio stack (pipewire/pw_cli/echo_cancel/audio_probe + the
`PlatformAudioBackend` alias and device-enum re-export) was gated `cfg(unix)`;
tightened to `cfg(target_os = "linux")` so a hypothetical macOS build won't try
to compile PipeWire. cpal stays `cfg(windows)`. Genuinely-Unix file/key
permission code in lib.rs/identity.rs left as `cfg(unix)`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iced's `window::settings::PlatformSpecific::application_id` only exists on
Linux (X11/Wayland use it to match the .desktop launcher icon); on Windows
the struct exposes a different field set, so the unconditional assignment
failed to compile for `*-pc-windows-*`. This was the first real Windows
compile blocker surfaced now that the port actually cross-compiles.
Move the field behind a `platform_specific_settings()` helper gated on
`target_os = "linux"`, with a defaults-only variant elsewhere. Linux build
unchanged (verified `cargo check`); the windows-gnu target now builds a
runnable .exe (verified launching under Wine: GUI renders, iroh network
stack + ring identity init, config/identity land in %APPDATA%).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give the Windows device pickers a real device list (Phase 0/1 left pw_cli
returning nothing off-Linux) and generalize enumeration into a
platform-neutral interface.
- audio/mod.rs: move AudioDevice here (neutral home), gate pw_cli to
cfg(unix), and re-export enumerate_audio_devices per-platform (pw_cli on
unix, cpal_impl on windows). Also drop a now-stale "no-op stub" doc note.
- cpal_impl.rs: add enumerate_audio_devices() — iterate the cpal host's
input + output devices into AudioDevice (name == description == the cpal
friendly name, which is what resolve() matches target_node against, so a
saved selection round-trips), sorted by description.
- pw_cli.rs: use super::AudioDevice instead of a local copy; parsing +
tests unchanged.
- app/mod.rs: one-line import change; the device-picker logic is untouched.
Verified: shipped Linux state green (build --locked, clippy, 316/316,
pw_cli parse tests 6/6); the cpal enumerator compiles against real cpal via
the Linux/ALSA toggle. Runtime device listing on Windows is pending a real
host (M2/M3). WASAPI names are less stable than PipeWire node names, so a
saved device may not always round-trip (falls back to default).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the tree compile for Windows without touching core logic, by
confining all Linux/PipeWire assumptions behind cfg gates and a single
platform-selected backend alias. No new dependencies — the cpal/WASAPI
backend lands in Phase 1; this ships a no-op stub.
- Cargo.toml: move pipewire + rfd(xdg-portal) under cfg(unix); add a
cfg(windows) rfd using the Win32 dialog backend.
- audio: gate pipewire_impl to unix, add a cpal_impl stub for windows,
and select between them via the new PlatformAudioBackend alias.
- core: use PlatformAudioBackend instead of the concrete PipeWireBackend.
- lib: gate the unix-only 0o600 log-file mode code (+ its test); Windows
logs inherit the directory ACL.
- audio_probe: gate this PipeWire diagnostic to unix with a stub main.
- app: open URLs via rundll32 on windows, xdg-open on unix (shell-free).
- ci: add .gitea/workflows/windows-build.yml (M1) — build + lib tests for
x86_64-pc-windows-msvc, with CMAKE_POLICY_VERSION_MINIMUM=3.5 for the
vendored libopus build. Needs a windows act_runner to actually run.
Linux build/clippy/tests green (316/316). The Windows path is verified by
inspection only (no local Windows toolchain); CI is the real gate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SetPresenceMode and the Discoverable time-box auto-revert both committed
the new presence_mode to local state *before* apply_discovery and only
log_msg'd on failure, so a failed off-transition could leave the n0 DNS
PkarrPublisher running while the UI showed not-discoverable (privacy /
reality mismatch — security-open-handoff S11, from the W7 P7 review).
Fix (Codex, senior-reviewed):
- discovery.rs: pure resolve_presence_transition(prev, requested, apply_ok)
-> (mode, Option<error>) seam — on failure keep the previous (truthful)
mode and surface a message. +4 unit tests.
- apply_discovery now builds the replacement resolver/publisher services
BEFORE clearing the service set, so a builder failure leaves the old
posture fully intact (no partial state) — "keep previous mode" is then
provably truthful.
- Both SetPresenceMode and the time-box revert apply discovery first, route
through the seam, commit only the truthful mode, and surface failures via
the existing PresenceModeReverted (corrects the picker) + UiEvent::Error.
No new wire/event variant.
- A failed off-transition stays Discoverable and arms a 60s retry
(DISCOVERY_REVERT_RETRY) so the beacon never stands stuck.
- P3 notes documented: relay-resolve exposes n0 query metadata (by design);
no explicit iroh unpublish API exists, so the bounded ~30s pkarr TTL
linger is documented, not behavior-changed; DirectOnly stays no-n0.
306 lib tests / clippy --all-targets / release all green (re-run by senior).
Runtime publish-stop behavior still wants a 2-machine / packet-capture check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fresh/empty Home keeps Create/Join dominant via a tested home_layout_mode
seam (FocusedEmpty / ThreeColumn / Stacked); once Recents or Friends has
content the normal three-card layout returns. Quieter empty-state cards.
Conflict resolution:
- HomeLayoutMode enum/fn coexists with the SettingsCategory enum (separate
derives); both unit tests kept.
- Top bar: the wishlist Hotkeys-info button is always shown; the room-layout
button is hidden on Home (home-empty's intent) and shown in Room. The
auto-merge had wedged the info tooltip into the conditional as a stray
expression — split into separate info_button / layout_button bindings.
291 lib tests pass, clippy --all-targets clean, bin builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings is split into navigable categories (left sidebar ≥820px wide,
pick_list dropdown below) instead of one long scroll. Integrated with the
wishlist branch's hotkey editor by giving it its own "Hotkeys" category
(7 categories total: Audio, Hotkeys, Recording, Profile, Appearance,
Network, Notifications).
Conflict resolution: the wishlist branch had inserted a Hotkeys section
into the old long-scroll between Microphone and Recording; relocated it
into a dedicated SettingsCategory::Hotkeys arm and updated the category
stability test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the Discoverable presence posture to n0 DNS publish/lookup, the last
core piece of W7 (friends-first contacts). When a friend moves networks and
their saved address goes stale, they flip Discoverable to publish their
current address; everyone else resolves it by node id. Asymmetric: only the
mover publishes.
- src/discovery.rs (pure seam, +3 tests): lookup_plan(network_mode, want_publish)
-> LookupPlan { resolver, publisher }. Relay-capable modes always resolve and
publish only when Discoverable; DirectOnly (the explicit no-server posture)
gets neither, overriding the toggle. DISCOVERY_TIMEBOX = 30 min.
- apply_discovery (core edge): clears + reinstalls the bound endpoint's
address-lookup services at runtime (no endpoint rebuild). memory-lookup always;
n0 PkarrResolver + DnsAddressLookup when resolver; PkarrPublisher when publisher.
Toggling publish off drops the publisher (republish task ends; TTL-30s record
expires). build_net_stack now binds uniformly with Minimal + per-mode relay and
installs discovery via apply_discovery (drops the per-mode presets::N0 build).
- Toggle + time-box: SetPresenceMode re-applies discovery and arms/cancels a
discovery_deadline; a select! branch fires at the deadline -> revert to Normal,
stop publishing, and emit UiEvent::PresenceModeReverted so the GUI mirrors and
persists it. Re-selecting Discoverable restarts the clock.
Decisions (user, 2026-06-16): 30-min auto-revert (not sticky); resolver always
on in relay-capable modes so a stationary friend in Normal can look up a mover.
266 lib tests green, clippy clean (--all-targets). Runtime smoke-tested: the new
Minimal+apply_discovery path binds and runs with no error/panic for both Normal
and Discoverable startup postures. Cross-network publish->lookup and the live
30-min revert still want a 2-machine field test (P7).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lay the home screen out as three side-by-side cards — Recents | Connect |
Friends — instead of stacking Recents under Connect. Three 380-460px cards
need ~1280px to fit in a row, so the responsive threshold rises to 1280px;
below that they stack in a column (Connect first). Screenshot-verified at
1920px: Recents left, Connect center, Friends right, top-aligned.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The card was hidden entirely when there was no history, so on a fresh
build it appeared to be missing. Always render it (with a "No recent
rooms yet" hint when empty), mirroring the Friends card, so the feature
is discoverable before the first join. Drops the has_recents gating in
the home layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A growing recents history shouldn't reflow the Connect card's Create/Join
controls. Extract the rendering into a `recents_card` free fn (mirroring
`friends_panel`'s self-contained styling) and place it in the left column
beneath the Connect card — both are "get into a room" — with Friends on
the right. The card is omitted entirely (no stray gap) when empty, in both
the narrow (stacked) and wide (row) responsive layouts.
Screenshot-verified at the default width: Connect + Recent Rooms stacked
left, Friends right; the Connect card stays fixed-size as recents grow.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a purely-local, most-recent-first recents list so users can hop back
into a room they were just in — meaningful now that rooms carry cosmetic
labels.
- src/recents.rs (new): `Recent {name, ticket, joined_at}`, `push_recent`
(de-dupes by room `topic_id`, refresh-and-move-to-front, caps at
RECENTS_MAX=12), `remove_recent`, `relative_time` ("5m ago"). 6 tests.
- PeerSpeakTicket::topic_of — the stable room identity used as the de-dup
key (host addr + label change between members/sessions; topic doesn't).
- AppConfig.recents (`#[serde(default)]`, back-compat) — local UI state,
never sent over the wire.
- Recorded on RoomJoined (label via label_of); rendered as a "Recent
rooms" block in connect_card (each entry → JoinRecent, ✕ → RemoveRecent),
shown only when non-empty.
Rejoin is best-effort by design: the stored ticket only admits us while
the room is still live and reachable (reliability is P6 discovery + the
member-issued ticket floor, not this list).
263 lib tests green, clippy --all-targets clean. Recents UI
screenshot-verified (seeded config → ages + Untitled-room fallback render).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each participant card gets a star affordance: a clickable outline star (☆) that
adds that peer to your friends list, or a non-interactive gold filled star (★)
once they're already a friend (hidden while the friends store is read-only). The
add pulls the peer's live presence name + address from the room roster and passes
addr: Some(..) to CoreCommand::AddFriend, so the new friend is reachable
immediately — no waiting for a future call to seed last_addr the way a bare
add-by-id does. Name sanitized, short-id fallback; idempotent in core; no-op if
already a friend. New AppMessage::AddFriendFromRoom(EndpointId).
clippy --all-targets clean, 257 lib tests green. UI wiring screenshot-pending: the
star + click need a live 2-machine call (a peer in the room) to verify visually.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rooms can now be named. A "Room name (optional)" field on the home Create card
mints a ticket carrying the label; PeerSpeakTicket gains a #[serde(default)]
`name` field (backward/forward compatible — serde ignores unknown fields and
defaults missing ones, so old/new builds still interoperate, just without
labels). restamp preserves the label so member-issued doors keep it; new
label_of helper reads it. Every member (creator or joiner) sets current_room.name
from the ticket, so presence reports a consistent "in <name>" to friends, and the
room-screen header shows the label under the wordmark. Labels are sanitized via
sanitize_name on both mint and display (untrusted peer-supplied ticket).
CoreCommand::Join gains room_name (used only when creating). +2 ticket tests
(label round-trip through restamp/label_of, pre-label backward-compat). clippy
--all-targets clean, 257 lib tests green.
Pure seam unit-tested + home field screenshot-verified; the in-room header label
and friend-side "in HangOut" presence display need a live/2-machine confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the three tooltip'd presence radios in the home Friends card with a
pick_list dropdown + a one-line explainer for the current choice, mirroring the
Settings NetworkMode picker. Add PresenceMode::ALL + a Display impl (descriptive
labels) to back the picker. Tightens the Friends card vertically. clippy
--all-targets clean, 256 lib tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The home-screen Connect + Friends cards were a fixed-width row, so below
~860px the 380px Connect card squeezed the Friends card until its node-ID
field, status, and remove button clipped away. Extract the Connect card into a
free `connect_card(state)` fn (mirroring `friends_panel`) and wrap both in
`responsive`: side-by-side row at >=900px, stacked column below. Verified at
560/760/1000/1912px. clippy clean, 256 lib tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract a self-contained friends_panel() (live list with presence +
one-click Join, add-by-node-ID form, presence-posture radios) and place
it as a side-by-side card next to the connect panel on the home screen,
removing the equivalent sections from Settings. Friends are now visible
without opening Settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes the friends list live, building on the B1 persistent endpoint.
Friends ownership moves into core (was the GUI's):
- Core loads/owns friends.json behind a shared Mutex<FriendStore>; a malformed
load yields an empty store flagged READ-ONLY so we never overwrite the damaged
file (fixes backlog A16). New commands AddFriend/RemoveFriend/RenameFriend +
UiEvent::FriendsUpdated{friends,read_only}; the GUI is now a read-only mirror
that renders from the event and drives mutations via commands. The friends UI
shows a warning + blocks edits when read-only.
- Presence posture pushed to core via SetPresenceMode (persistence stays in
AppConfig); held in a shared Mutex for the listener/scheduler.
Live listener + outbound scheduler:
- New FriendsProtocol ProtocolHandler on the persistent Router for FRIENDS_ALPN
(the router owns accept(), so the listener can't be presence_net::serve — same
delegation pattern as B1's AudioRouter). Its reply policy reads the shared
friends/mode/current-room and uses presence::should_answer: answer friends only,
never while invisible, and report our current gathering's restamped member
ticket so a friend can one-click Join. handle()'s body is factored into a shared
exchange() used by both serve (tests) and FriendsProtocol.
- Outbound ping scheduler folded into the core loop via tokio::select! on a slow
interval (60s, first pass delayed 3s for endpoint online). FULLY DARK while
Invisible (no probing at all — user's choice). Each pass runs detached so it
never blocks command handling and picks up a rebuilt stack next tick; probes
friends with a saved addr in parallel and emits UiEvent::FriendPresence.
- note_seen auto-heal: a connected peer who is a friend has their last_addr
refreshed (+persisted) so the scheduler can reach them later.
- current_room shared state set on Join (restamped ticket) / cleared on Leave.
P5 UI: each friend shows online / offline / in-room with a one-click Join.
256 lib + 6 reconnect + 4 loopback + 2 ignored real-endpoint tests green, clippy
--all-targets clean, release builds. B2a (ownership/A16) is solo-verifiable; the
live listener + scheduler need the 2-machine field test before this merges.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per design feedback: put Remove directly to the right of the name box (was
separated by the id column), and shrink the oversized add-friend inputs to
fixed half-widths (node id 340px, name 170px) instead of stretching across
the panel. A trailing horizontal_space absorbs the rest so rows are
left-aligned and nothing reaches the scrollbar edge.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fill-portion inputs pushed the trailing Remove and Add buttons under the
scrollbar gutter, clipping them. Reserve 12px right clearance on both rows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Identity section showed only a truncated id and iced text isn't
selectable, so there was no way to share your full node id. Add a Copy
button (mirrors the room-ticket copy) that writes the FULL id to the
clipboard via a new generic CopyText(String) message. Also unblocks the
add-a-friend self-test (copy your id, paste into Add friend).
256 lib tests green, clippy clean, release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the P2 friends store into the UI. New 'Friends' section in Settings:
loads the store at startup, lists each saved friend as a live-rename text
field + short id + Remove button, and an add row (node-id + optional name +
Add) that validates the id parses as an EndpointId, rejects duplicates, and
falls back to a short-id name when none is given. Every change persists via
friends::save. Empty state prompts adding by node id.
Solo-verifiable (copy your own ID from the Identity section to add a row).
Friends currently live in Settings; they will likely move to a prominent
home-screen panel once P4 presence gives them live status. 256 lib tests
green, clippy clean, release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a 'Presence' section to Settings with three radio options wired to the
persisted PresenceMode (W7): Normal (friends only, no beacon; default),
Invisible (appear offline to everyone), Discoverable (also publish so friends
can find you after a network change). Each has a hover tooltip explainer,
mirroring the recording-mode radios.
Selecting one saves config.presence_mode. The live friends listener (P4, not
yet wired) will read this posture when it lands; no core command until then.
256 lib tests green, clippy clean, release builds. Solo screenshot-verifiable
(the live effect needs P4 + 2 machines).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Complete P1: surface the identity in Settings and let the user manage it.
- New UiEvent::IdentityStatus { node_id, persisted, error }, sent at startup
and after a regenerate, so the app always knows its own node id and whether
the key is persisted.
- CoreCommand::RegenerateIdentity: mints + persists a fresh key (identity::
regenerate), swaps the core's live key for the next join (same 'applies on
next join' semantics as SetNetworkMode), and replies with a fresh status.
- Settings 'Identity' section: shows your permanent ID, a left-aligned
Regenerate button behind a confirm modal (destructive — discards the old id,
warns friends will stop recognising you), and a standing red warning banner
when the key isn't persisted (disk/permission failure -> ephemeral fallback),
explaining the id won't survive the next launch.
238 lib tests green, clippy clean, release builds. Screenshot-verified: the
Identity section, the confirm modal, and the degraded warning (chmod 000 the
key file -> 'Permission denied (os error 13)' banner; Regenerate clears it).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a per-event enable checkbox next to each chime in Settings so a user
can silence individual sounds (e.g. keep 'message'/peer-join but drop
reconnect chimes) while the master 'Enable sound notifications' toggle
stays as the global kill-switch.
The gate lives in one place at the play() seam: a pure should_play(master,
sound) AND that's unit-tested, fed by a per-sound AtomicBool array in
notify keyed by a stable Sound::index/ALL. Flags persist as 8 sound_*_enabled
bools in AppConfig (default true, so upgrades are silent-change-free) with
sound_enabled/set_sound_enabled accessors centralizing the field mapping.
The per-sound checkbox greys out (drops on_toggle) while the master is off.
+3 notify unit tests (should_play truth table, index bijection, flag
set/query independence) + extended config backward-compat test. 230 lib
tests green, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
iced's `image::Handle::from_bytes` assigns a fresh *unique* id on every call
(unlike `from_path`, which hashes). `avatar_view` built the handle inline in
`view()`, so every repaint produced a "new" image and iced re-uploaded the
texture each frame. Any redraw triggered it — notably the redraws fired on
mouse movement — so visible avatars flickered constantly while the mouse moved.
Fix: cache handles by a content hash of the PNG bytes (thread-local, UI thread)
and reuse the same `Handle` across redraws, giving a stable texture id. Covers
both presets and custom uploads.
Build + clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes W4: users can upload a custom avatar image.
- `Avatar::Custom(String)` carries a base64 PNG. `process_upload` decodes an
arbitrary png/jpeg, downscales so the longest side is 128px (aspect kept),
re-encodes PNG, base64s, and rejects anything over a hard cap.
- Settings "Avatar" gains an "Upload image…" button (native picker via rfd's
xdg-portal backend, off-thread through Task::perform) and shows the current
custom avatar as a selected tile.
- Untrusted peer avatars are validated at gossip ingest (`sanitize_incoming`):
a custom image must be within the byte cap and decode as a PNG within bounds
(image-crate decode limits guard against decompression bombs) or it's
downgraded to a monogram.
- Raised the gossip max message size to 64 KB so a capped custom avatar fits
inline on the presence plane (all peers already need a matching build).
- Deps: image (png/jpeg only), rfd (xdg-portal, no GTK); only `rfd`+`pollster`
are actually new in the lockfile (rest were already transitive). cargo audit
clean (0 vulns; the 2 unmaintained warnings are pre-existing S7).
- `Controller::send` now returns bool (was a Result carrying the now-larger
CoreCommand by value, which tripped result_large_err).
- +5 avatar unit tests (upload resize/round-trip, reject non-image, ingest
accept/reject). 227 lib tests green, clippy clean.
Manual check: Settings → Avatar → Upload; confirm the picker opens and the
image shows for you and (after redeploy) for a peer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds bundled preset avatars on top of the monogram foundation.
- New `Avatar` enum (Monogram | Preset(u8)) in the avatar module, serde-encoded;
6 preset PNGs embedded via include_bytes! (placeholder art in assets/avatars/
— swap for real designs later). +3 unit tests.
- `PeerState` gains `avatar` (rides the gossip presence plane like `sharing`);
`AppConfig` gains `avatar` (persisted). Avatar choice flows app → core (Join +
new SetAvatar command) → every self-state announce, so it reaches the room incl.
late joiners, and changing it mid-call re-announces live.
- Settings "Avatar" section: monogram + 6 preset tiles, applied live + persisted.
- Rendering: `avatar_view` draws the chosen preset image (iced `image` feature,
now enabled) else the monogram, in the self card, peer rows, and chat (chat
looks up the sender's avatar from presence by id).
⚠️ BREAKING gossip wire change: presence Announce is signed (S2) and the
signature is recomputed by re-serializing the parsed struct, so a new PeerState
field means old and new builds can't verify each other's presence — ALL peers
must run a build >= this one (same as the S2 change). Redeploy dopedart before
2-machine testing.
Build + clippy clean, 222 lib + integration tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for user avatars (W4). Every participant now shows a circular
monogram avatar: their initial(s) on a colour deterministically derived from a
stable key (node id, or name where no id is available). This is also the
universal fallback for the later preset/upload phases.
- New pure `avatar` module: `initials`, `color_for_key` (FNV-1a → 12-colour
palette), `use_dark_text_on` (contrast). +5 unit tests. Dependency-free.
- `avatar_badge` view helper: a radius-capped coloured container + centred
initials (plain iced widgets, no canvas/image needed).
- Wired into the self card, each peer row (left of the name — pairs with the
A10 fixed-width row), and each chat line.
- Threaded the chat sender's node id end to end (RoomEvent → UiEvent::ChatMessage
gains `from`, ChatEntry gains `from`) so chat avatars are id-keyed and ready
for the Phase 2/3 per-peer avatar lookup.
Build + clippy clean, 219 lib tests green. Visual confirm: join/create a room.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat messages rendered URLs as plain text. Now http/https URLs render as
clickable links that open in the system browser (xdg-open).
- New pure `sanitize::linkify` splits an (already-sanitized) message into
text/URL segments: conservative — only http:// and https:// runs, ending at
whitespace, with trailing prose punctuation peeled back out; reassembling the
segments reproduces the input exactly. +6 unit tests.
- Chat render uses iced `rich_text` with link spans + `on_link_click`.
- `OpenUrl` handler re-validates the http(s) scheme (defence in depth) before
spawning xdg-open with the URL as a single argv entry (no shell, no injection).
Linkify only runs after `sanitize_chat`, so control/format chars are already
gone. 214 lib tests green, clippy clean. Manual check: send a message with a
URL, click it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Screen sharing relies on the optional pixelpass companion CLI. Previously, when
it wasn't installed the Share Screen button was disabled and relabelled "Needs
pixelpass", which read as peerspeak advertising a broken in-app feature.
Now the Share Screen (and a peer's Watch) buttons stay enabled; if pixelpass
isn't on PATH, clicking opens a short explainer popup describing pixelpass as an
optional P2P-video companion and how to enable it (install pixelpass + mpv).
When pixelpass is present, behaviour is unchanged (toggles the share / opens the
viewer). Popup reuses the existing centered-modal + backdrop pattern.
Build + clippy clean. Manual check: with pixelpass off PATH, create a room and
click Share Screen — the explainer appears; backdrop / ✕ / "Got it" dismiss it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The controls column (mute/deafen/PTT/echo/record/share/Leave) had no scroll,
so on a short window the bottom of it — including Leave — was clipped with no
way to reach it; the only workaround was enlarging the window. At a small
enough size you couldn't exit the call through the UI at all.
Fix: pin Leave at the bottom of the control panel and wrap the controls above
it in a scrollable (height Fill). The controls now scroll when the window is
too short, and Leave (the exit control) is always visible. Applies to all
three room layouts (control_panel is Fill-height in every arm).
Build + clippy clean. Manual check: create a room, shrink the window — Leave
stays put, controls scroll.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The participant row right-anchors [name | space | share | mute | indicator],
with the status indicator ([Idle]/[Speaking]/[Muted]/[Connecting…]) as the
rightmost element. Those labels differ in width, so when a peer started
speaking the indicator grew and pushed the whole right cluster — including the
mute button — leftward, making the mute icon visibly jump.
Fix: render the indicator in a fixed-width (124px), right-aligned slot sized
for the longest label, so its left edge (and the mute button beside it) stays
put across state changes.
Build + clippy clean. Layout-only; visual confirmation wants a 2-machine call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an "Echo cancellation" checkbox to the in-call control column (beside
Push-to-Talk). It binds to the same config.echo_cancellation_enabled flag and
ToggleEchoCancellation message as the Settings checkbox, so the two stay in
sync automatically (single source of truth; iced re-renders from it). A tooltip
is explicit that it applies on the NEXT room join — the current PipeWire-module
AEC is wired at join time and isn't hot-swappable mid-call. (A true live in-call
toggle falls out for free once the in-process EchoCanceller lands — AEC Stage E.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>