X11 sessions now persist and restore the window position (window_x/y) in
addition to size. Gated to X11: Wayland's xdg-shell gives clients no way
to self-position, so we center there (and iced never emits Moved on
Wayland, so window_x/y stay None). No drift across save/restore — iced's
Moved event and Position::Specific both use the window's outer position.
Also confirmed peerspeak already runs on X11 out of the box (winit
compiles both backends and auto-selects via WAYLAND_DISPLAY/DISPLAY) and
documented X11/Wayland support in FEATURES.md.
- config: window_x/window_y: Option<i32> (serde-default None).
- app: is_wayland() + pure initial_window_position() helper; a Moved
handler records position; the close path persists it.
- tests: +3 initial_window_position (X11 restore / Wayland centers /
partial-or-missing centers), +2 config (round-trip incl. negative
coords; backward-compat load without the new fields). 130 -> 135 lib.
Verified live on X11/XWayland: saved an off-center position, the window
reopened there (not centered). clippy clean incl. --all-targets.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a desktop/taskbar identity and a system package for PeerSpeak.
- assets/icons/peerspeak.svg: master app icon — the in-app mic glyph
over a P2P mesh of peer nodes, Catppuccin Mocha palette. Rendered to
the hicolor raster sizes (16..512) committed alongside it.
- Window/taskbar icon: embed a 128x128 straight-RGBA blob and load it
via iced from_rgba (keeps us off iced's heavy `image` feature). Set
the Wayland/X11 app_id to "peerspeak" so compositors match the window
to the .desktop launcher and show the icon natively.
- packaging/peerspeak.desktop: launcher (StartupWMClass=peerspeak).
- packaging/PKGBUILD: peerspeak-git VCS package — cargo --frozen build,
--lib check, installs the binary, .desktop, and hicolor icons.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The window always opened at the hardcoded 900x760 because the size was never
persisted: run_gui hardcoded it, AppConfig had no size fields, and the Resized
handler only kept the size in memory (for divider clamping) while
exit_on_close_request:true quit before anything could save.
- AppConfig gains window_width/window_height (serde-default 900/760).
- run_gui restores them as the initial window size.
- The Resized handler mirrors the live size into config (guarded against bogus
tiny sizes); divider positions on load now clamp against the restored size
rather than a hardcoded default.
- exit_on_close_request:false + a CloseRequested handler writes the final size
once, then iced::exit() — no per-resize disk thrash.
Verified empirically that KWin/Wayland honors a client-requested initial size
(requested 1150x680 -> window reported 1150x680). Window *position* is not
restored: xdg-shell gives Wayland clients no way to set their own position.
+1 config test (default + round-trip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an 18-icon set drawn on iced canvas (no image/font dep, recolors with the
theme) — consistent with the existing LayoutThumb/GateMeter/Divider widgets.
Icons: mic, mic-off, headphones, deafen, speaker, speaker-off, monitor (share),
eye (watch), record, stop, chat, people, clock, settings, copy, leave, create,
live. Each authored in a 24x24 space, scaled to the widget, stroked with round
caps/joins.
Wired throughout the UI in place of emoji + added to the icon-less primary
buttons (mute/deafen/leave/copy/settings/create): self + peer cards (live badge,
watch, per-peer speaker mute), header (participants/timer/REC), controls
(mute/deafen/record/share/leave), launch (create/settings), settings (test mic),
drawer chat toggle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surface pixelpass screen-sharing from inside a peerspeak room. peerspeak owns
voice, pixelpass owns pixels — they're never Cargo deps of each other; the
contract is pixelpass's CLI flags + its `--output json` stdout stream.
Modelled on Discord: multiple simultaneous sharers, a 🔴 Live badge + 👁 Watch
on each sharing peer's card, and in-progress shares visible to late joiners.
- New `src/screenshare` module: pure `parse_pixelpass_event` seam + `pixelpass_path`
discovery (13 unit tests), async `spawn_host` (→ ticket) and `spawn_viewer`
(→ parse connected{url} → open mpv, vlc fallback). No new deps.
- Sharing rides presence: `PeerState.sharing: Option<ticket>` (serde-defaulted),
so the existing gossip re-announce delivers the offer to late joiners for free
and a PeerUpdated fires on start/stop — no separate gossip message needed.
- core: Start/Stop/ViewShare commands; host + viewer children tracked in the
session, killed on stop/leave (kill_on_drop backstop). Viewer limit left to
pixelpass's bandwidth-measured cap.
- UI: Share/Stop button (graceful "needs pixelpass" disabled state), Live badge
+ Watch on peer cards, Sharing badge on the self card. Verified by screenshot.
- config: optional `pixelpass_path` override (hand-editable).
Tests-green; the 2-machine gossip/remote path is not yet field-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add three in-call room layouts — 3-Column (Participants | Chat | Controls),
Bottom Dock (Participants+Controls over a full-width Chat strip), and Drawer
(Participants | Controls with a collapsible Chat panel) — chosen via one
persisted RoomLayout config setting and applied live.
Picker UX: a square layout button (drawn LayoutIcon glyph) in the top bar of the
launch and in-call screens opens a popup gallery (dimmed click-to-dismiss
backdrop + centered panel) of clickable schematic thumbnails; the Settings screen
shows the same thumbnails inline (no button). Thumbnails are drawn with the
canvas widget (new LayoutThumb program — colored panel boxes, blue border on the
selected one), so no image-decoding dependency is added.
Each layout's panel boundaries are draggable (DividerKind gains Controls +
ChatDrawer for the 3-column right divider and the drawer's left edge; new
clamp_controls_width / clamp_chat_drawer_width, persisted + re-clamped on resize).
Participants width is shared across layouts but capped per layout at render time
so a fixed panel can't starve the Fill panel (e.g. a wide Participants width set
in the dock layout won't collapse Chat in 3-column or Controls in the drawer).
The Drawer layout adds a header chat-toggle. +1 clamp test (now 127 tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remember last nickname: a new serde-default AppConfig.username field is
pre-filled into the launch-screen nickname field, and saved when a room is
joined or created (i.e. when the name is actually used), so it carries across
launches.
Sanitize chat: a pure sanitize_chat() drops control characters (ANSI escapes,
NUL, stray CR/LF/TAB), collapses whitespace runs to single spaces, trims, and
caps length (2000 chars). Applied to our outgoing text on submit AND to incoming
peer messages on receive — peer content is untrusted, so the sender's name and
text are both sanitized before display; empty-after-sanitize messages are
dropped. Unit tests for sanitize_chat (control/whitespace/unicode/empty + length
cap) and a config backward-compat assertion for username.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a reusable Divider canvas widget and place two in the room screen: a
vertical divider between the Participants and Controls panels (drag to resize
the Participants width) and a horizontal divider between the main row and the
Chat dock (drag to resize the dock height). The Participants panel and Chat dock
size from persisted config values; the Controls panel and main row fill the rest.
The widget reports drag motion as a pixel delta along its axis (mirroring the
GateMeter drag handling, so a drag continues past the thin strip). update()
applies the delta and clamps it: clamp_participants_width / clamp_chat_height
keep both sides of each divider above a minimum. Sizes are re-clamped on window
resize (window size tracked from window::Event::Resized) and clamped again on
load (a size saved under a different window could be out of range).
Persistence: participants_width / chat_height are new serde-default AppConfig
fields; the divider publishes PersistConfig on drag release so the final
position is written once (not per pixel). 3 clamp unit tests (incl. a tiny-window
degenerate case) + config backward-compat assertions for the new fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add room text chat riding the existing iroh-gossip topic (same layer as the
presence roster). New GossipMessage::Chat { name, text, ts }; the gossip loop
forwards it as RoomEvent::ChatMessage, core relays it to the UI as
UiEvent::ChatMessage, and RoomState::send_chat broadcasts an authored line
(display name from self-state, ms timestamp). CoreCommand::SendChat sends; our
own author is suppressed by the existing self-echo guard, so the UI echoes our
sent line locally instead.
UI: a full-width chat dock along the bottom of the room (the chosen layout) —
bottom-anchored scrollback with per-sender name colouring (green = you), an
input with Enter-to-send + a Send button, history capped at 300 lines. The room
window default grows to 900x760 so the dock doesn't squeeze the controls column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opt-in recording of the full call as you experienced it. New dep-free
src/audio/recorder.rs: a canonical mono S16LE WavWriter (header patched on
finalize) plus a Recorder that buffers your transmitted mic in a bounded FIFO
and sums it, sample-aligned, with each incoming-mix frame the playout mixer
produces. The two independently-clocked streams stay aligned via the FIFO
(capped at ~200ms so drift lag can't grow without bound); silent stretches
record the incoming mix alone. Dep-free UTC timestamp -> sortable filename.
Wiring: CoreCommand::SetRecording toggles an Arc<Mutex<Option<Recorder>>> gated
by an is_recording flag (so the capture/mixer hot paths only lock while actually
recording); capture pushes post-gate mic, the mixer writes the pre-deafen mix.
Recording finalizes on stop, room leave, and room switch. UI: a Record/Stop
button in the controls and a red "● REC m:ss" pill in the room header;
core-confirmed Recording{Started,Stopped} events drive the UI flag so a failed
start can't lie. Files land in ~/peerspeak-recordings/.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two boundary assertions to format_duration_renders_mss_and_hmmss:
59s -> "0:59" (last second of m:ss form) and 3599s -> "59:59" (final
second before the output switches to h:mm:ss at 3600).
Gemini-authored (junior), senior-reviewed against the real diff and
independently re-verified (cargo test --lib + clippy clean). First task
driven through the headless `agy --print --sandbox` loop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enriches the in-room screen:
- Per-peer VU meters: a live level bar per peer card (reuses the per-peer
audio_levels stream), green while speaking, dim when idle/locally-muted.
- Your own mic meter on the self-card (reuses the in-call MicLevel), green
when transmitting, grey when muted or PTT-inactive.
- Per-peer local mute (🔊/🔇): silences a peer for you only — decoded so
their VU still moves, but not mixed. New CoreCommand::SetPeerMuted + a
locally_muted set in the core/mixer, distinct from per-peer volume.
- Header call-info: participant count + a live m:ss / h:mm:ss call timer
(dependency-free — rides the in-call event stream rather than a tick sub).
format_duration unit-tested. Build + clippy clean, 70 lib tests.
Field-verified on a real desktop<->dopedart call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Discord-style app-internal gain controls under each device picker:
input volume scales the captured mic (applied before the meter/gate/encode,
so it also moves the mic meter), output volume scales the mixed playback
(on top of per-peer volumes). PeerSpeak-only — no system/other-app effect.
Both persist in config (input_volume/output_volume, serde default 1.0 for
backward compat) and read live by the audio loops via f32-bit atomics, so
they take effect mid-call. Sliders apply live on drag and save on release.
The standalone mic-test monitor applies the same input gain so the test
meter reflects it. Reuses the existing apply_volume helper (unity fast-path
+ i16 saturation).
Tests: config backward-compat + round-trip for the new fields (gain math
itself is covered by the existing apply_volume tests). 65 lib tests, clippy
clean. Field-verified: input slider moves the mic-test meter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extracts the peak-hold/throttle logic shared by the in-call capture thread
and run_mic_monitor into MicLevelMeter, and adds unit coverage:
- core: MicLevelMeter reports only after a full window, holds the window
peak, resets between windows, and reports zero for silence.
- app: GateMeter::x_to_threshold maps edges/midpoint correctly, clamps
out-of-bounds drags, and stays finite for a zero-width (pre-layout) bar.
9 new tests, all green; clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a mic input meter to Settings for gate calibration, replacing the
blind noise-gate slider with a unified Discord/OBS-style control: the bar
shows the live mic level and a draggable handle sets the gate threshold on
the same axis. Fill is green above the gate (transmitting), dim below it
(muted), with a live status word; the handle is bright red with a dark
edge so it stays legible when the green level sweeps past it.
Two level sources:
- In-call: the capture thread peak-holds the raw (pre-gate, pre-mute)
frame level and emits UiEvent::MicLevel ~10/sec.
- Off-call: a "Test mic" toggle runs CoreCommand::SetMicMonitor, spinning
up a standalone capture-only stream feeding run_mic_monitor. It shares
the backend's single capture stream, so Join tears it down first and
leaving Settings releases it; ignored while a session is active.
The gate handle drags live via NoiseGateDragging (no disk write per pixel)
and persists once on release via NoiseGateChanged. Meter axis is 0..0.3 so
a normal voice doesn't peg. Enables the iced "canvas" feature for the
custom GateMeter widget.
Build + clippy clean, tests pass. Field-verified on desktop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pull the PeerConnecting/PeerConnected chime logic out of update()'s match
arms into two pure functions — reconnect_attempt_chime and reconnected_chime
— that own the connecting/ever_connected set transitions and return the
Option<Sound> to play, leaving the notify::play side effect in update.
Behavior-preserving; the full suite still passes.
Adds 4 #[cfg(test)] tests pinning the edge-trigger contract: a first dial is
silent, a reconnect attempt chimes exactly once and stays silent across the
supervisor's repeated redials, "reconnected" only fires after a prior link,
and a two-outage cycle chimes attempt->reconnected each time (per-outage, not
once-ever). Closes the last catalogued Tier B test gap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an opt-in Echo Cancellation toggle (Settings) that routes the call
through PipeWire's module-echo-cancel (WebRTC AEC + noise suppression + AGC)
instead of running an in-process canceller. PipeWire already sees both the
mic and the speaker monitor, so it handles the echo-reference alignment for
free and we avoid a C++ DSP dependency.
src/audio/echo_cancel.rs (new):
- enable(real_source, real_sink) loads the module via pactl (aec_method=webrtc),
bound to the chosen devices with source_master/sink_master (defaults if unset),
waits for the virtual nodes to appear, and returns an RAII guard that unloads
the module on drop. Best-effort pre-clean of a stale instance from a crashed run.
- EC_SOURCE / EC_SINK are the virtual cleaned-mic source and reference sink.
core: when echo_cancellation is set on Join, load the module and point capture
at EC_SOURCE / playback at EC_SINK; stash the guard in ActiveSession so it
unloads on shutdown (after the audio streams release the nodes). Any failure
logs + warns the UI and falls back to the direct devices — never blocks the call.
config: new echo_cancellation_enabled (serde default false). app: Settings
checkbox under Mic Sensitivity, applied on next room join. An ignored live
smoke test covers the real load/unload path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-frame hard-cut noise gate with a stateful envelope gate
(src/audio/gate.rs):
- Hysteresis: opens at the slider threshold, closes only below 0.6x that,
so speech near the threshold doesn't flap the gate.
- Attack/release: per-sample gain ramp (5ms open, 80ms close) instead of a
click — the gate fades rather than dropping frames outright.
- Hangover: holds the gate open 200ms after the level drops, so word tails
and brief pauses aren't chopped.
A fully-closed frame still reports don't-transmit, preserving the original
bandwidth win of not sending pure silence (receiver jitter buffer conceals
the gap). The live slider value is read per frame so changes apply
immediately. Settings slider gains a one-line hint. 6 unit tests cover
attack shape, hysteresis, hangover-then-release, disabled pass-through, and
the closed-gate no-transmit path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Settings screen now follows the full window width/height instead of a
fixed 460x500 card. Device/privacy pickers and the noise-gate slider fill
the available width, and controls are laid out in 2 columns so a single
dropdown never stretches across the whole window:
- Input Device | Output Device
- Mic Sensitivity | Network Privacy (top-aligned)
- The 8 custom chime path fields remain a 2-column grid
Section spacing tightened so everything (incl. Back) fits on 1080p without
scrolling. The styled card now fills the page (max_width cap dropped).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audible chimes for reconnect/reconnected/reconnect-failed, self-leave,
peer join/leave, and mic/deafen toggle, completing the 4-phase
notification plan (phase 1 room/peer join+leave shipped in 673b72d).
- Per-event custom WAV overrides in Settings, with ~ expansion and live
file-found/not-found validation; persisted on settings exit. Each event
falls back to its embedded default chime when no custom path is set.
- Reconnect-attempt chime is edge-triggered: fires once per disconnect,
not once per redial attempt.
- Global "enable sound notifications" toggle.
Adds audible notifications for the basic membership events: a bright ascending
triad when you join a room, a soft two-note rise when another participant
joins, and a two-note fall when one leaves. Hooked into the app's UiEvent
handling (RoomJoined / PeerJoined / PeerLeft) so each is played locally by
whoever observes the event.
The chimes are short mono 16-bit WAVs generated by a committed stdlib-only
Python script (assets/sounds/generate_chimes.py) and embedded in the binary
with include_bytes!, so a deployed single binary is self-contained. The new
notify module materializes each to a temp file once, then plays it
fire-and-forget via pw-play (PipeWire-native; falls back to paplay/aplay) on a
detached thread that waits on the child -- never blocks the UI, never leaves a
zombie, and silently no-ops if no player is available.
Phase 1 of a larger plan; always-on for now. Later phases: reconnect-attempt /
reconnected sounds, a settings enable/disable toggle, per-event custom sounds,
and extra events (self-leave, mute/unmute, connection-lost).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audio links now survive a dropped QUIC connection instead of silently
dying until the peer leaves and rejoins the room.
Transport: each peer is owned by a single supervisor task running a
connect → run → reconnect loop. The deterministic-initiator rule (lower
id dials, higher accepts) holds on every reconnect, so one shared
connection re-forms each time; the dialer redials with capped backoff
and the acceptor awaits the inbound link, switching to a replacement
immediately if one arrives before its own close fires. Inbound
connections are routed to the supervisor via a per-peer channel.
Fixes a latent bug from the prior design: aborting a peer's tasks left
the detached send/read loops running, holding Connection clones so the
link never actually closed. The loops now live in abort-on-drop guards
scoped to the supervisor, so cancelling it releases the connection.
UX: a new ConnEvent stream surfaces per-peer link state to the UI, which
shows "Connecting…"/"Reconnecting…" with a yellow indicator and border
while a peer's audio link is down, returning to normal when it recovers.
Tests: the loopback test now runs through the supervisor path, plus a
new test drives a real drop (explicit close of a controlled peer
endpoint) and asserts the dialer re-dials the stable address and audio
resumes over the rebuilt connection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The endpoint previously used presets::N0, which both relays through n0's
servers and publishes a signed EndpointId->addresses record to n0's public
DNS every time you go online. Since PeerSpeak already exchanges full peer
addresses via the join ticket and gossip, that DNS presence beacon is
redundant here.
Adds a NetworkMode config option (persisted, switchable from Settings):
- RelayNoDiscovery (new default): presets::Minimal + RelayMode::Default +
the in-memory address lookup. Keeps n0 relay for NAT traversal but drops
the DNS publish/resolve, so n0 only ever sees relayed-call metadata, never
a standing online beacon.
- N0Full: previous behavior (relay + DNS) for maximum reliability.
- DirectOnly: RelayMode::Disabled, fully serverless.
The mode is applied when the endpoint is built on room join. Existing
config.json files load unchanged via #[serde(default)].
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove Gemini's committed update_*.py regex-surgery scripts
- Drop unused iroh-tickets dependency (hand-rolled ticket is used instead)
- Replace ToString antipattern with Display impl on PeerSpeakTicket
- Route debug log to XDG state/cache dir instead of hardcoded /home path
- Clear all compiler + clippy warnings (unused imports, collapsible ifs,
redundant pattern matching, missing Default)
Builds clean with zero warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>