Commit Graph
91 Commits
Author SHA1 Message Date
6100abef33 fix(core,audio,app): Tier B bug-sweep fixes (F-05, F-06, F-10, F-11)
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>
2026-06-22 15:38:48 -04:00
molluskandClaude Opus 4.8 3878e716dd feat(game): Step 7 UI — opt-in toggle, roster Playing line, Settings
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>
2026-06-21 15:43:24 -04:00
molluskandClaude Opus 4.8 961705ffa9 feat(game): broadcast game presence (GOSSIP_PROTO 3) + per-game background
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>
2026-06-21 15:31:09 -04:00
molluskandClaude Opus 4.8 7e4f2f2127 fix(chat): use async save dialog for attachments
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
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>
2026-06-21 03:43:12 -04:00
mollusk bcb597a0ea Add inline chat audio player 2026-06-21 01:05:24 -04:00
molluskandClaude Opus 4.8 60c1951567 Chat file attachments, stages 3-4: core wiring + chat UI
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
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>
2026-06-20 23:15:06 -04:00
molluskandClaude Opus 4.8 cbba4b644e Add per-peer listener-side noise gate
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
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>
2026-06-20 22:18:50 -04:00
molluskandClaude Opus 4.8 c5375e200a Make per-peer in-call volume continuous and persistent
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
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>
2026-06-20 21:29:18 -04:00
mollusk 601ec92181 Show custom background in room view 2026-06-20 17:57:16 -04:00
mollusk eab9357f23 Add custom background settings controls 2026-06-20 17:54:27 -04:00
molluskandClaude Opus 4.8 70a0e6798f W16 custom backgrounds: core + render layer (Settings UI pending)
Pure src/background.rs (process_background downscale→PNG, scrim_color; 5 tests),
AppConfig.background/background_dim + background_path(), cached AppState.background_image,
PickBackgroundFile/BackgroundFilePicked/RemoveBackground/SetBackgroundDim handlers,
view_with_background stack(image Cover→scrim→ui) + transparent screen roots.
Lib builds clean. Remaining: Settings UI controls + full build/clippy/test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:29:09 -04:00
mollusk 2c93c1c24f Add post-grace peer recovery coordinator 2026-06-20 15:37:33 -04:00
molluskandClaude Opus 4.8 ddb3d2aabc Windows compat quick wins: echo-cancel UI gate, pixelpass .exe, cfg tighten (W5/W6/W9)
Three Windows-compatibility fixes from the Codex review. Implemented by Codex
(gpt-5.5); reviewed and committed by Claude.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:32:17 -04:00
mollusk 7724da73b8 Refine home empty-state layout 2026-06-17 17:06:45 -04:00
mollusk 92c9d585b8 Add settings category navigation 2026-06-17 16:44:58 -04:00
mollusk 33e3998e7c Add orderly shutdown on window close 2026-06-16 17:37:00 -04:00
mollusk 20643a24de Add audio controls and focused hotkeys 2026-06-16 17:23:38 -04:00
molluskandClaude Opus 4.8 22f0eed94d feat(w7 p6): opt-in n0 DNS discovery for Discoverable presence
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>
2026-06-16 16:24:46 -04:00
molluskandClaude Opus 4.8 3b02c2be0a refactor(w7): place Recent Rooms left of the Connect card
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>
2026-06-16 15:11:49 -04:00
molluskandClaude Opus 4.8 ffc2d6223d fix(w7): always show the Recent Rooms card, with an empty state
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>
2026-06-16 15:08:46 -04:00
molluskandClaude Opus 4.8 cb936cdcbd refactor(w7): move recents into its own card on the home screen
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>
2026-06-16 15:04:18 -04:00
molluskandClaude Opus 4.8 c12d15ed7d feat(w7): recently-joined rooms list with one-click rejoin (P5)
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>
2026-06-16 14:58:14 -04:00
molluskandClaude Opus 4.8 fb17fd1850 feat(w7): add-friend-from-room — friend a co-participant with one click (P5)
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>
2026-06-16 14:42:04 -04:00
molluskandClaude Opus 4.8 4227ecc61d feat(w7): cosmetic room labels carried in the ticket (P5)
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>
2026-06-16 14:32:11 -04:00
molluskandClaude Opus 4.8 be42941b97 feat(home): presence selector as a compact dropdown instead of 3 radios
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>
2026-06-16 14:12:29 -04:00
molluskandClaude Opus 4.8 ddc78f190e fix(home): responsive friends-panel layout (stack vertically when narrow)
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>
2026-06-16 14:07:10 -04:00
molluskandClaude Opus 4.8 173585f1a0 feat(w7): move Friends + Presence panel from Settings to the home screen
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>
2026-06-16 05:22:40 -04:00
molluskandClaude Opus 4.8 1ed64cbede feat(presence): live friends listener + ownership move (W7 B2)
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>
2026-06-15 16:51:45 -04:00
molluskandClaude Opus 4.8 c5df7a31f0 fix(ui): compact, left-aligned friends rows
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>
2026-06-15 05:08:25 -04:00
molluskandClaude Opus 4.8 70d7a90a4d fix(ui): keep friends Remove/Add buttons clear of the scrollbar edge
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>
2026-06-15 05:04:06 -04:00
molluskandClaude Opus 4.8 e3f9cd5be7 feat(identity): copy-to-clipboard button for the node ID (W7)
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>
2026-06-15 04:58:57 -04:00
molluskandClaude Opus 4.8 f161534a4b feat(friends): friends-list UI in Settings (W7 P5)
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>
2026-06-15 04:47:56 -04:00
molluskandClaude Opus 4.8 47e7762437 feat(presence): presence-mode selector in Settings (W7 P5)
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>
2026-06-15 04:43:34 -04:00
molluskandClaude Opus 4.8 a99c789716 feat(identity): regenerate control + degraded-identity warning (W7 P1)
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>
2026-06-15 04:05:55 -04:00
molluskandClaude Opus 4.8 7e75ae31af feat(notify): per-sound notification toggles (W6)
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>
2026-06-14 23:06:49 -04:00
molluskandClaude Opus 4.8 e917c5393f fix(avatars): cache image handles so avatars don't flicker on redraw
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>
2026-06-14 17:13:00 -04:00
molluskandClaude Opus 4.8 f029a30ea7 feat(avatars): custom image upload — W4 Phase 3
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>
2026-06-14 17:00:18 -04:00
molluskandClaude Opus 4.8 3007002b1b feat(avatars): 6 selectable preset avatars over presence — W4 Phase 2
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>
2026-06-14 16:43:24 -04:00
molluskandClaude Opus 4.8 19194f0cee feat(avatars): monogram avatars in roster, self card, and chat — W4 Phase 1
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>
2026-06-14 16:28:05 -04:00
molluskandClaude Opus 4.8 2621576ed9 feat(chat): clickable links in chat messages (A13)
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>
2026-06-14 15:46:58 -04:00
molluskandClaude Opus 4.8 7af8edd5ae fix(ui): explainer popup for screen share instead of a dead "Needs pixelpass" button (A11)
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>
2026-06-14 15:41:04 -04:00
molluskandClaude Opus 4.8 3d2124ed98 fix(ui): keep the Leave button reachable on a short window (A12)
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>
2026-06-14 15:35:49 -04:00
molluskandClaude Opus 4.8 eb99f89a91 fix(ui): stop the participant mute button shifting when a peer speaks (A10)
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>
2026-06-14 15:33:42 -04:00
molluskandClaude Opus 4.8 88e0adec5d feat(ui): in-call echo-cancellation toggle, synced with Settings
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>
2026-06-14 03:47:19 -04:00