Pre-stages the playback dependency for the inline chat audio player task so
Codex can build it in its network-off sandbox.
rodio 0.22.2 decodes wav/mp3/ogg(vorbis)/flac (via bundled symphonia) and
handles output + play/pause/seek + resampling. It brings its own cpal 0.17
(the project's PipeWire/cpal-0.15 call path is untouched; rodio's output is a
separate stream on the system default device) and alsa on Linux.
Supply chain: cargo audit reports NO new advisories from this subtree -- the
only 2 warnings (audiopus_sys, paste) are pre-existing, unmaintained-only, and
already on the allow-list. Builds clean (release).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chat file fetches failed 100% of the time with "file fetch: read failed:
read error: connection lost" (both images and arbitrary files, both
directions). Root cause: the FileRouter serve handler called send.finish()
and immediately returned Ok(()), which dropped the Connection. In QUIC,
finish() only marks the stream's EOF -- it does not wait for the written
bytes to be delivered and acknowledged -- so the connection's
CONNECTION_CLOSE raced ahead of the still-in-flight stream data and the
fetcher's read_to_end aborted.
Fix: after finishing, wait on connection.closed() (bounded by
FILE_FETCH_TIMEOUT) so the link stays up until the fetcher has read
everything and closed the connection itself, which is the signal the
transfer landed.
Wire-compatible (no protocol change), so version stays 0.3.0; both peers
just need the rebuilt binary since either side can be the file server.
Adds tests/file_transfer_loopback.rs: a real two-endpoint serve->fetch
round-trip over FILES_ALPN with a 2 MiB multi-packet blob (deterministic
A/B: 0/20 pass without the fix, 20/20 with it) plus an unknown-id "gone"
case.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- INSTALL.md: bump the setup filename to 0.3.0; add an end-user section
on text chat + sending photos/files (inline images, file chips,
Save/Download, 25 MB cap, session-only); note that both ends must run
the same version under "won't connect".
- README.md: add a Version compatibility section (installer version
tracks Cargo; a 0.x MINOR bump is a breaking wire change so everyone
must reinstall; 0.3.0 can't talk to 0.2.x).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the Cargo 0.3.0 release (chat file sharing + per-peer gate). The
installer payload is unchanged (single self-contained peerspeak.exe +
icon); only the version string / output filename move to 0.3.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the send/receive paths and the chat UI on top of the file plane.
(Committed together because the UI renders the state the core wiring
produces.)
Core:
- CoreCommand::SendChatFile {text, attachment, data}: serve the bytes on
the file plane (serve_attachment) then broadcast the descriptor via
send_chat. CoreCommand::FetchAttachment {from, attachment}: detached
fetch -> AttachmentReady/AttachmentFailed.
- On an inbound Chat with an Image attachment, auto-fetch + defensively
re-validate (decodable + within pixel limits) before delivering;
non-images wait for an explicit fetch (the Save/Download chip).
- UiEvent::ChatMessage carries the attachment; new AttachmentReady /
AttachmentFailed events keyed by attachment id.
App:
- 📎 attach button + native picker; reads the file, enforces the size
cap, classifies image vs file, mints a random id, optimistically
echoes the message + caches our own bytes (so we see our own image
inline), and sends SendChatFile.
- Renders inline image thumbnails (handle cached by id to avoid the
per-redraw re-upload flicker), file chips with Save/Download, a
loading placeholder for in-flight images, and an error line on
failure. Image messages with no caption still render.
- SaveAttachment: saves immediately if bytes are in hand, else fetches
then saves when ready (pending_saves) via a native save dialog;
filename defaulted from the sanitized descriptor.
- Session-only: attachment bytes/handles cleared on leave, never
persisted.
Binary + clippy clean, 349 lib tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the dedicated FILES_ALPN data plane that moves attachment bytes
off-gossip via direct QUIC streams.
- FileRouter (ProtocolHandler on the persistent router, mirroring
AudioRouter): bound to the active session's Shared on join, cleared on
leave. On an inbound stream it authenticates the peer via the ALPN
handshake, gates on live room membership (reuses audio_sender_admitted,
so a former member cannot pull files), reads exactly one 32-byte
attachment id (bounded request read), and streams back the matching
blob from the session serve store — or an empty body for an unknown id.
- Shared gains served_files (id -> bytes), populated by serve_attachment
and cleared on leave.
- IrohTransport::serve_attachment + fetch_attachment (inherent methods;
transport is used concretely). fetch dials the sender on FILES_ALPN
(preferring a known full address), writes the id, and reads bounded by
the descriptor's declared size, with a 30s connect/read timeout so a
stalled sender can't hang the fetch.
- Register FILES_ALPN in the router; bind/clear file_router in lock-step
with audio_router at every join/leave site.
Builds + clippy clean, 349 lib tests pass (plane is runtime I/O,
field-tested in stage 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of in-chat file/photo sharing (dedicated file plane, images
inline + file chips, session-only). This stage adds the wire types and
the pure, unit-tested logic; no transport or UI yet.
- protocol: new FILES_ALPN / FILES_PROTO (peerspeak/files/1) for the
dedicated file-transfer plane. Bump GOSSIP_PROTO 1->2 + sig domain v2
(Chat gained an attachment field, so cross-version peers fail fast
rather than half-work) and Cargo 0.2.0 -> 0.3.0 per VERSIONING.md.
BREAKING wire change: all peers must run >= 0.3.0.
- new src/files.rs: ChatAttachment descriptor (name/size/kind/id; bytes
travel off-gossip), AttachmentKind, plus pure seams — sanitize_filename
(path-traversal/control-char/length-safe), size_within_cap, image
magic-byte sniffing + defensive limited decode (decode-bomb guard),
32-byte request parsing, human_size. 13 unit tests.
- GossipMessage::Chat and RoomEvent::ChatMessage carry an optional
ChatAttachment; send_chat takes Option<ChatAttachment>. Untrusted
inbound descriptors are filename-sanitized + size-validated on ingest.
serde(default) keeps the field forward-compatible at the JSON layer;
+round-trip and pre-v2 back-compat tests.
The attachment id is a random 32-byte handle (rand, already a dep), not
a content hash — the fetch is authenticated + encrypted + member-gated,
so no crypto-hash dep is needed.
349 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Let a listener apply a noise gate to an individual peer's incoming
audio — "fix this person's noisy mic / background hum on my end" —
which is only possible because full-mesh P2P keeps every peer's stream
unmixed locally (server-mixed apps can't do per-listener per-peer DSP).
The DSP is the existing mic NoiseGate reused verbatim: it already
processes i16 frames at a fixed rate with hysteresis/attack/release/
hangover and takes the threshold per-frame. Wiring mirrors per-peer EQ:
- AppConfig.peer_gate map (threshold per peer id; absent/0 = off),
persisted, never sent over the wire
- CoreCommand::SetPeerGate + Arc<Mutex<HashMap>> shared into the mixer
- a live HashMap<EndpointId, NoiseGate> in the mixer task, created
lazily and dropped when disabled (no rebuild needed — threshold is
passed per frame)
- Gate row (threshold slider, "Off" at zero) in each participant card
next to Vol/Pan/EQ, persisting on release
The gate runs on the raw decoded frame: after the clean multitrack stem
tap (recordings stay ungated) but before volume/EQ, so the threshold
tracks the peer's true signal level regardless of our volume setting.
Same 0..METER_MAX scale as the mic gate.
+2 unit tests (config helper); +1 config back-compat assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-peer volume slider had no .step(), so iced's default step of
1.0 on a 0.0..=2.0 range meant it could only snap to 0%, 100%, or
200% — it felt like hard-left/hard-right only. Add .step(0.01) for
smooth 1%-increment control (matching the Pan slider below it, which
already set its own step).
Also persist per-peer volume across sessions, mirroring peer_pan/peer_eq:
- new AppConfig.peer_volume map (keyed by peer id string, serde default
for back-compat; never sent over the wire)
- replace the in-memory peer_volumes map with config-backed storage via
a new set_peer_volume_config helper (clamps to range, drops at-unity
entries so the config stays tidy)
- replay saved volumes to core on startup alongside pan/eq
- the slider writes to disk on release (AppMessage::PersistConfig)
+1 unit test for the config helper; +1 config back-compat assertion.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-technical walkthrough to send alongside the installer: covers the
SmartScreen "unknown publisher" warning, the firewall/desktop-shortcut
checkboxes, and joining/creating a call via room tickets. Uses the actual UI
labels (Join Room / Create New Room / Copy Ticket / Leave Room).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package PeerSpeak for Windows as a single self-contained binary. The GUI
icon, notification chimes, and avatar presets are already embedded via
include_bytes!, and the cross-compiled .exe is statically linked (no extra
DLLs), so the installer payload is just peerspeak.exe plus an .ico.
- src/main.rs: set windows_subsystem = "windows" for release builds so the
GUI launches without a stray console window (debug keeps the console for
stderr/panics).
- packaging/windows/: Inno Setup script (peerspeak.iss), multi-resolution
app icon (peerspeak.ico), and a build README. The installer drops a
Start-menu/desktop shortcut, optionally adds a Windows Firewall allow-rule
(iroh UDP hole-punching), and provides an uninstaller.
- win-cross-build.sh: promote the cross-build helper from a throwaway to the
documented installer build step; .gitignore the staged exe + compiled
setup.exe build artifacts.
Built with Inno Setup 6.7.1 under Wine; binary is unsigned (SmartScreen will
warn until code-signed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes the field-observed dead-end where a network outage longer than the
45s anti-flap grace evicted the peer with no path back (required manual
Leave + re-ticket). On grace expiry the peer is now torn down cleanly and a
bounded per-session recovery coordinator re-bootstraps the gossip overlay
via GossipSender::join_peers on retained authenticated addresses, with
immediate-then-1/2/4/8/15/30/60s capped backoff. Readmission still requires
a fresh authenticated signed Announce, preserving the S8/S11 membership
boundary; a transport link alone cannot readmit a grace-expired peer.
Field-verified 2026-06-20 on a 2-machine Linux-host <-> Windows-VM call
through a 93s link outage on the libvirt NAT path: both UIs auto-recovered
to "2 in room" with no manual Leave/Join, exactly one Reconnected chime,
host showed "reconnecting" during the outage, and the VM log captured the
full sequence (grace expiry -> rebootstrap 1->2->4 backoff -> NeighborUp ->
authenticated Announce -> readmit -> audio link up).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the native Windows audio backend to main after a live host<->VM smoke
test: cpal/WASAPI capture+playback, device remap/resampling (W4/B5), cpal
RT-audit closed (B1-B5 + P3), Windows notification chimes, and Wine startup fix.
Verified on real Win11 (libvirt VM) this session: 2-way audio (host<->VM both
directions), audible join/leave/reconnect chimes, GUI renders, echo-cancel
correctly gated off. Linux unchanged (all changes cfg(windows); cargo test --lib
326/0, clippy clean). Windows build is GNU cross-compiled (b0fdd4e tester zip).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One `makepkg -si` from packaging/test-pack/ builds and installs both
peerspeak and pixelpass from the public gitbutter repos over https, so a
tester can clone the repo and get a working voice+screenshare pair in one
command. pixelpass installs to /usr/bin so peerspeak's screen-share button
finds it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex's xhigh re-review of 306bc29 confirmed B3 sound and bounded_rate
correct (no P1/P2), and caught one real P3: choose_config ranked supported
config ranges by sample rate + channel count only, but the stream builders
accept just F32/I16/U16 — cpal can also expose U8/I8/I32/U32/I64/U64/F64.
An unsupported-format range (or a zero-channel range) could therefore out-
rank a usable one, win selection, and then hard-fail in setup()'s
`other => Err(unsupported sample format)` arm without trying another
candidate. This was latent in the exact-48 kHz path too, not only B5's
bounded case 3.
Fix: a pure `format_supported` predicate + `usable_range` (nonzero channels
AND a drivable format), applied as a filter in BOTH the exact-48 kHz `pick`
and the bounded `pick_bounded`, so an undrivable range is never ranked. A
zero-channel range can no longer be logged as "using bounded …" and then
rejected by resolve. +1 unit test enumerating every cpal SampleFormat.
Verified: windows-gnu cargo check --release --lib --tests --bins clean, no
warnings; Linux paths untouched (cfg(windows)).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the two Windows-only follow-ups Codex deferred in the RT-audit
re-review (review-2026-06-19-cpal-rt-audit.md). Both are cfg(windows),
so they carry zero risk to the shared Linux audio path.
B3 — orphan-thread tombstone on a wedged start. On the FINISH_START_TIMEOUT
path the owner thread is detached (not joined) so start_*/stop can't hang;
previously the slot was left empty, so a retry against a permanently wedged
device spawned ANOTHER orphan worker holding its own COM/device handle, and
so on without bound. The slot is now a SlotState { Idle | Live | Wedged }:
- Each worker carries an `exited: Arc<AtomicBool>` flipped true by an
ExitGuard at the top of the thread body — fires on normal return, panic
unwind, or whenever the wedged driver call finally releases the thread.
- A timed-out start detaches its thread and leaves a `Wedged { exited }`
tombstone instead of an empty slot.
- `ensure_idle` (pure, unit-tested) rejects new starts while the orphan is
still alive, but clears the tombstone once `exited` flips, so the slot
becomes reusable after the device recovers. `stop` restores a still-live
tombstone rather than silently clearing it.
B5 — choose_config picks a bounded supported rate before the device default.
A device whose default rate is outside the drivable 8k–384k window but which
also exposes a usable in-window config was previously rejected by resolve().
New case 3 scans the supported config ranges for one overlapping the window
and drives it at a `bounded_rate` (48 kHz when reachable, else the nearest
in-window bound), preferring the native layout; the device default is now a
last resort. `bounded_rate` is pure and unit-tested.
6 new unit tests (bounded_rate x4, ensure_idle x2) — they're in the
cfg(windows) module, so they compile/run under the windows-gnu target, not
the Linux lib suite.
Verified: Linux cargo test --lib 326/0 + clippy --lib --tests clean (shared
paths untouched); windows-gnu cargo check --release --lib --tests --bins
clean, no warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex's xhigh re-review of the prior cpal RT fixes confirmed W2/W3/W7/W4-diag
addressed (and validated the reserve-first ring-publish ordering), but found the
W1/W6 start-handshake fixes were partial. This closes the holes:
- B1 (P1): wait_for_stream_start checked the liveness flag before the error code,
so a callback that ran then failed in the same WASAPI cycle could still report
Ok on a dead stream. Readiness now (a) treats the error as terminal — checked
first each loop AND re-checked before returning Ok — and (b) requires
MIN_START_CALLBACKS (2) completed callbacks, not one, so a fire-once-then-die
stream is caught by the error/timeout path. The liveness signal is now a
callback counter (AtomicUsize) instead of a one-shot bool.
- B2 (P2): on the inner STREAM_START_TIMEOUT the owner sent Err and THEN dropped
the stream; since cpal Stream::drop joins its (wedged) WASAPI worker and
finish_start joins the owner on that Err, start_*/stop could still hang past the
backstop. The owner now drops the stream BEFORE reporting Err, so a wedged drop
withholds the Err and lets finish_start's timeout branch detach.
- B4 (P3): the two timeouts didn't compose — a slow-but-valid setup plus a slow
first callback could exceed the 6s backstop and be falsely failed. Raised
FINISH_START_TIMEOUT to 10s (setup budget + callback wait + cleanup slack) and
corrected the comment.
Deferred (logged in review-2026-06-19-cpal-rt-audit.md): B3 (orphan-thread
tombstone accounting on a permanent >10s driver wedge — rare, non-crashing, needs
a slot-state redesign) and B5 (choose_config picking a bounded supported rate for
an oddball sub-8k/over-384k default-rate device — rare; the safety validation
already prevents the panic/spin).
Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe links.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses Codex's xhigh RT-audio audit of the new Windows cpal path (review
2026-06-19; all Windows-only, no Linux-path change):
- W1 (P1): start_capture/start_playback reported Ok as soon as cpal's play()
returned, but cpal's WASAPI play() only QUEUES IAudioClient::Start(); a later
Start failure left the UI joined-but-silent. Readiness is now driven by the
stream actually proving itself: the first RT data callback sets a started
flag (or the error callback sets an error code), and the owner thread waits
(bounded by STREAM_START_TIMEOUT) before reporting Ok.
- W2: both RT error callbacks ran format!+log_msg on the time-critical stream
thread. They now store a category in an AtomicU8 only; the owner / health
logger translate + log off the RT path.
- W3: the playback ring was published one interleaved sample at a time, letting
the RT consumer read a half-written L/R pair and letting a raced fetch_sub
wrap ring_fill to usize::MAX (wedging mixer pacing). Now reserves occupancy
before publishing and writes the whole frame with a single push_slice.
- W6: finish_start did an unbounded recv() while holding the slot mutex, so a
wedged driver hung start_* and any concurrent stop. Now recv_timeout with a
FINISH_START_TIMEOUT backstop; on timeout it signals + detaches (never joins).
- W7: OS-reported device geometry is validated in resolve() (channels>0, rate in
8k-384k) so 0 channels can't panic chunks_exact(0) and a 0/absurd rate can't
make an infinite/huge resample ratio. resample.rs constructors also clamp
rates >=1 (release-safe; +2 tests) instead of a debug-only assert.
- W4 (diagnostic half): the playout-health logger compared raw device samples
against the internal-stereo prefill target. The callback now records demand in
internal 48 kHz-stereo units (internal_demand) so the comparison is correct
for remapped/non-48k devices. The dynamic-target restructure stays deferred.
Deferred (logged in review-2026-06-19-cpal-rt-audit.md): W5 (bounded mixer->
worker channel) touches the shared Linux audio path and wants its own design +
regression pass; the W2 dynamic-target sizing needs a real WASAPI callback.
Verified: Linux cargo test --lib 326/0, clippy --all-targets clean; windows-gnu
cargo check --lib --tests --bins clean; windows-gnu release peerspeak.exe builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GUI re-sends the saved network mode as part of its startup config-sync.
The SetNetworkMode handler unconditionally tore down + rebuilt the iroh
endpoint whenever idle, so every launch rebuilt the freshly-built stack for
an identical posture — a needless ~1s teardown+rebuild bounce visible in the
logs on both Linux and Windows/Wine (the 'start core loop -> shut down network
stack ~1s later' pattern from the Wine spike). Guard the rebuild on an actual
mode change; a real change still rebuilds exactly as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/audio/resample.rs: pure linear PushResampler (capture) +
StereoPullResampler (playback pull), 6 unit tests green on Linux.
- choose_config: prefer native 48kHz, else fall back to device default
config and convert at the boundary instead of hard-erroring.
- run_capture: resample device-rate mono -> 48kHz on the drain thread.
- i16<->f32 helpers. Playback build_output remap still TODO (Codex).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two safe, host-independent hardening steps from the Codex Windows-compat review.
W7 — when a saved input/output device name no longer resolves (WASAPI friendly
names can change across driver/endpoint changes), resolve() now logs the
fallback to the system default instead of switching devices silently — so a
"my audio went to the wrong device" report has a log line explaining why.
(cpal 0.15 exposes only the device name, so a stable hardware id isn't available
to persist; this surfaces the limitation rather than hiding it.)
W2 — the output RT callback now records the largest interleaved buffer length it
is ever asked for (a wait-free fetch_max into an atomic, kept off the log/alloc
path). The once-per-second health-logger reports that size and, if a callback
ever exceeds the prefill target (PLAYBACK_TARGET_SAMPLES), warns explicitly —
that's the exact signature of the WASAPI-shared-mode underrun-every-cycle bug.
This is the diagnostic a real-host test needs before committing to the
structural fix (larger target / fixed buffer); no behavior change.
Windows-only file (cfg(windows)); compile-verified via the windows-gnu
cross-build, not yet exercised on a real WASAPI host.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implemented by Codex (gpt-5.5); reviewed and committed by Claude.
W8 — chimes were played by shelling out to pw-play/paplay/aplay, which don't
exist on Windows, so every chime silently no-op'd there. spawn_player is now
cfg-split: Linux/unix keeps the existing player list; Windows plays the WAV via
PowerShell's System.Media.SoundPlayer (PlaySync on the existing detached thread).
Dependency-free, same fire-and-forget / silent-on-failure contract. Custom chime
paths are single-quote-escaped for the PowerShell command (helper + unit test).
Also adds docs/WINDOWS.md: a build/run/status guide (native MSVC + cross-compile
to -gnu, first-run firewall/UDP note, %APPDATA% paths, and the honest known-gaps
table — echo-cancel/screenshare/resampling/device-id/buffer-pacing).
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>
Two correctness fixes for the cpal/WASAPI backend from the Codex Windows-compat
review, plus device logging.
W1 — start_capture/start_playback no longer return Ok before the stream exists.
The owning thread did device resolution, config selection, build_stream, and
play() and only *logged* failures, so a missing 48 kHz config / unsupported
format / WASAPI error left the UI in a joined-but-silent room. The worker now
reports readiness over a channel and start_* blocks on it via finish_start(),
returning the real AudioError on failure (and joining the dead worker).
W3 — the RT capture callback no longer allocates or sends on a channel. It now
only downmixes and wait-free-pushes mono samples into a preallocated lock-free
HeapRb; the owning thread drains that ring, frames it (the Vec allocation lives
off the RT path), and sends completed frames. A full ring increments an overrun
counter instead of blocking. Restores the no-alloc/no-block-in-callback contract
the PipeWire backend already honors.
Also logs the selected device name / sample format / channels / rate on stream
start (a review nice-to-have) and logs capture overruns when they occur.
Windows-only file (cfg(windows)); Linux build unaffected. Compile-verified via
the windows-gnu cross-build; not yet run on a real WASAPI host.
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>
Replace the Phase 0 no-op CpalBackend stub with a working cpal backend
(WASAPI on Windows), preserving the exact PipeWire AudioBackend contract
so the mixer/encoder/jitter pipeline is unchanged.
- Capture: input stream -> downmix to mono -> 960-sample (20ms) i16 frames
-> tx, matching the encoder/jitter frame size.
- Playback: 200ms stereo ring prefilled to PLAYBACK_TARGET_SAMPLES; the
output callback drains it (silence on underrun) while the owning thread
feeds it from rx. ring_fill is the exact delta-maintained occupancy
counter (fetch_add on push, fetch_sub on pop), preserving the clock-paced
production design (not ringbuf's stale occupied_len).
- cpal::Stream is !Send, but AudioBackend is Send+Sync and shared via Arc,
so each stream lives on its own owning thread (built/played/dropped
there); the struct holds only the running flag + JoinHandle. stop()
flips the flag and joins.
- Generic over F32/I16/U16 sample formats; device selected by name else
default; requires a native 48kHz config (clear error otherwise, no
resampling yet). Mirrors the PipeWire drain_loop and playout-health line.
- Cargo.toml: add cpal 0.15 under cfg(windows).
Verified by temporarily compiling cpal_impl against real cpal on Linux/ALSA:
build + clippy clean, 6/6 cpal_impl unit tests pass. Reverted to windows-only
gating; shipped Linux state green (316/316). Runtime/WASAPI end-to-end is
unverified and pending a Windows host (plan M2).
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>
Adds a Gitea Actions workflow that runs `cargo deny --locked check` on
every push to main and every PR, so the deny.toml policy (advisories,
bans, licenses, sources) is enforced automatically rather than by hand.
Runs on a locked tree so the pinned versions in Cargo.lock are what get
audited; a poisoned dependency release can't reach CI until Cargo.lock is
deliberately updated. cargo-deny is pinned to 0.19.9 via a prebuilt binary.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Documents the focused security review of the protocol-versioning migration
and the cargo-deny policy addition. Result: no high-confidence vulnerabilities
— the versioned_topic XOR transform is entropy-preserving, signature binding
uses the raw topic_id consistently, and the ALPN/domain changes are
handshake-level compatibility only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Supersede bare cargo-audit with an enforceable four-part policy, validated
against the current tree with cargo-deny 0.19.9 (advisories/bans/licenses/
sources all pass):
- advisories: deny vulnerabilities + yanked; ignore the two *unmaintained*
warnings (paste RUSTSEC-2024-0436, audiopus_sys RUSTSEC-2026-0150) with
rationale. Both are transitive and pinned via Cargo.lock, so a future
malicious release can't reach us until a deliberate cargo update.
- sources: trust only crates.io; deny unknown registries and git sources
(core anti-hijack control).
- bans: deny wildcard version reqs; warn on duplicate versions.
- licenses: permissive allow-list covering the current graph.
Mark peerspeak publish = false (it's an application, not a published
library): blocks accidental cargo publish and lets [licenses.private]
skip the missing-license check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Establishes VERSIONING.md: SemVer 0.x (MINOR = breaking wire change) for the
release version, and per-plane protocol versions enforced on the wire so
incompatible peers fail fast and legibly instead of via silent decode/signature
errors.
⚠️ BREAKING WIRE CHANGE — all peers must run >= 0.2.0 to interoperate (ALPNs and
gossip subscription topics changed). A pre-0.2.0 peer (e.g. an un-resynced
dopedart) can no longer connect, by design, and now fails at the handshake.
- New src/protocol.rs: single source of truth for AUDIO/FRIENDS/GOSSIP_PROTO,
the derived ALPNs (peerspeak/audio/1, peerspeak/friends/1), GOSSIP_SIG_DOMAIN,
and versioned_topic(). Unit tests assert ALPN/domain strings match their
integer versions (no silent drift) + that topic namespacing is deterministic.
- Unified ALPNs: audio was b"peerspeak-audio" (unversioned, and duplicated in
iroh_impl.rs + core/mod.rs) -> peerspeak/audio/1 from protocol.rs; friends
re-exports protocol::FRIENDS_ALPN (was peerspeak/friends/0 -> /1).
- Gossip: subscribe to versioned_topic(ticket.topic_id) so different gossip
versions never share a swarm; the raw topic_id stays the room identity and
what signatures bind. GOSSIP_SIG_DOMAIN centralized into protocol.rs.
- Cargo.toml 0.1.0 -> 0.2.0.
316 lib tests / clippy --all-targets clean. VERSIONING.md documents the bump
rules, the "I changed X -> what do I bump" table, and a release checklist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes S8 (High): inbound audio was authenticated by identity (remote_id) but
NOT by room membership, so a former member who knew a current member's endpoint
could reconnect on the audio ALPN and inject into / eavesdrop on the mix while
invisible in the roster. Now audio is admitted only for live gossip-roster
members (the senior+user-resolved GRACE-AWARE policy).
Transport (src/network/iroh_impl.rs):
- New per-session admitted_audio: HashSet<EndpointId> on Shared (internal state,
no wire/serialization change). Cleared on disconnect_all.
- AudioRouter::accept consults audio_sender_admitted BEFORE ensure_supervisor —
a non-member never gets a supervisor, sender handle, datagram reader, or
outbound mix. Brief StdMutex check, released before the await (no RT lock).
- Pure apply_audio_admission_event(roster, peer, event) with AudioAdmissionEvent
{RosterPresent insert, TransientDropGrace no-op, Remove}. Grace deliberately
cannot ADD membership — it only preserves an already-admitted peer — so an
unknown peer can't sneak in via a grace event. +3 lifecycle tests (on top of
Pass-1's 4 predicate tests).
- admit/keep_for_reconnect_grace/remove/query methods for core to drive.
Core (src/core/mod.rs) — authority is core's VERIFIED gossip-roster events, not
transport connect/disconnect:
- PeerJoined / PeerUpdated: admit_audio_sender before connect_peer.
- PeerConnectionLost: keep_audio_sender_for_reconnect_grace (preserve through the
existing RECONNECT_GRACE window — no audio cut on transient blips).
- gossip PeerLeft, transport ConnEvent::Left, grace-timer expiry: remove_audio_sender
before disconnect_peer + jitter removal (removal-before-teardown bounds the
in-flight-datagram race).
- datagram receiver: audio_sender_admitted gate before any jitter buffer (defense
in depth against a datagram racing a removal). Mixer stays off the hot path.
Mid-join: a peer who dials audio before we've verified their signed Announce is
dropped (no "pending" admission, which would reintroduce the eavesdrop); their
reconnect loop recovers once the Announce admits them.
tests/transport_loopback.rs: admit both ends before connecting, mirroring the
production room-event order.
313 lib / clippy --all-targets / transport_loopback 4 / reconnect_eviction 6 /
release — all re-run green by the senior. Former-member-rejection + mid-join
recovery are verifiable only in a 2-machine call (senior's to run).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design-first checkpoint for S8 (authorize inbound audio against live room
membership). Codex's design note (in the handoff task-report.md) establishes
the authoritative roster = gossip IrohGossipState.peers, NOT the audio
transport connection list, and recommends mirroring it into an audio-admission
snapshot consulted at AudioRouter::accept + datagram ingest.
This commit lands ONLY the pure decision seam + tests; wiring is deliberately
paused for a senior decision on the reconnect-grace policy (gossip drops a peer
from the roster on transient NeighborDown, but core keeps the audio supervisor
alive for RECONNECT_GRACE — a strict roster-only gate would cut audio on blips).
- audio_sender_admitted(remote, roster) -> bool (pub(crate), #[allow(dead_code)]).
- 4 tests: member admitted, stranger rejected, former member rejected after
roster removal, mid-join peer rejected until authenticated Announce inserts it.
- No behavior change: accept/datagram/mixer paths untouched. S8 remains OPEN.
310 lib tests / clippy --all-targets / release all green (re-run by senior).
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>
Transient implementer handoff note; its content is preserved in the
handoff docs. Not repo content.
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>
Reject signed Announce(PeerState) whose embedded state.addr.id does not
match the authenticated payload.author, closing the residual S2 gap where
a valid signer could advertise another node's EndpointAddr.
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>
Found by manual 2-machine exploration: desktop creates room A, dopedart joins;
desktop leaves A, creates room B, leaves B, then rejoins A — and dopedart (still
in A) isn't reconnected until dopedart itself leaves and rejoins.
Root cause: `known_peers` (the A8 rejoin-bootstrap memory) was a single flat set
cleared on ANY join-ticket change. The detour through room B wiped the memory of
room A's peers, so rejoining A — whose ticket names the creator itself as host —
produced an EMPTY bootstrap (`compute_bootstrap` drops self), leaving the desktop
isolated in the gossip topic with no one to dial. dopedart never re-dials a peer
that's already a (now-departed) neighbor, so they never relink.
Fix: key `known_peers` by room ticket (`HashMap<ticket, HashMap<peer, addr>>`)
and stop clearing it. The event loop records peers under its room's ticket; a
join pulls bootstrap targets from that ticket's bucket. Revisiting a room after a
detour now still remembers its peers. Pre-existing bug (logic unchanged by the
B1 refactor); reproduces on pre-B1 main too.
256 lib tests green, clippy --all-targets clean. NOT yet 2-machine field-verified
— the exact A→B→A scenario is the gate (dopedart is up; verifying next).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The friends-only presence listener (W7) must answer pings while the app is
open, whether or not we're in a call — but a node id has exactly one live
endpoint instance (proven by the dual-endpoint spike: two endpoints sharing a
SecretKey collide, all inbound connections land on one and the other's ALPN
fails the QUIC handshake). So the listener can't get its own endpoint; the
whole app must share one persistent endpoint. Today the core rebuilds the
endpoint+gossip+router on every Join and tears them down on leave, so there's
nothing alive between calls.
B1 hoists those durable pieces to the app lifetime (no new behavior):
- New persistent `NetStack` (endpoint + gossip + Router) built once at startup
under the RelayNoDiscovery default (relay reachability, no DNS beacon);
`online()` is backgrounded so launch isn't blocked.
- New persistent `AudioRouter` (src/network/iroh_impl.rs) replaces the
per-session `AudioProtocol`: it's registered once on the single Router and
delegates each inbound audio connection to whatever session `Shared` is bound
(`bind` on join, `clear` on leave), dropping links when idle. `IrohTransport::
new` now returns just `Self`.
- Join reuses `net.endpoint`/`net.gossip` and only subscribes its gossip topic +
binds the audio router; Leave clears the router but keeps the endpoint up.
- `SetNetworkMode`/`RegenerateIdentity` rebuild the stack immediately when idle,
else defer to the next Leave/Join (preserves "applies on next join"), and the
existing session is always torn down before any rebuild closes the endpoint.
Tests/loopback updated for the new transport API. 256 lib + 6 reconnect + 4
loopback + 2 ignored real-endpoint tests green, clippy --all-targets clean,
release builds. NOT yet 2-machine field-verified — that regression is the gate
before this merges to main.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Section 3 claimed playout is pinned to exactly 1024 frames; the code now
follows the graph's Buffer::requested() quantum (pipewire_impl.rs:229-237),
with 1024 only as a fallback -- the doc described the pre-fix behavior that
caused crackle. Flagged by the 2026-06-15 Codex/GPT-5.5 review (backlog A20).
The broader ARCHITECTURE refresh (missing modules, persistent identity, signed
gossip, friends/W7, recording modes) remains under A20.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flagged by the 2026-06-15 Codex/GPT-5.5 review (backlog A20): the header
contradicted the per-phase statuses showing P1-P3 done + P4 partial. Now reads
'IN PROGRESS' with the current phase rollup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The I/O edge of the friends-only idle listener: bind/probe/serve a ping->pong
over a dedicated ALPN (peerspeak/friends/0), adapted from pixelpass's proven
control plane. Request/response, one exchange per connection: probe sends a
Ping and reads the Pong; serve accepts, authenticates the remote id, and asks
an injected handler (which wraps presence::should_answer + builds the pong)
what to reply -- None for a stranger/invisible, so the listener reveals
nothing to non-friends.
Verified by a loopback integration test over two real iroh endpoints (ignored
by default): the allowed prober gets a Pong with the room; a fresh stranger id
gets an empty, unusable reply. 256 lib tests + the loopback (run with
--ignored) green, clippy clean (incl --all-targets), release builds.
DEFERRED (next session, needs care + 2 machines): spawning serve on a
persistent endpoint OUTSIDE the per-join room session, and the ping scheduler.
Real fork noted in the module: a second always-on endpoint shares our node id
with the room endpoint (possible relay collision) vs refactoring to one
persistent endpoint -- intentionally not decided at 5am.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Desktop<->dopedart on c5df7a3: persistent identity stable across restarts,
two-way audio regression clean, member-issued ticket lets a room outlive its
creator (desktop rejoins via dopedart's ticket after leaving), friends-add
persists. dopedart resynced to c5df7a3. Clears the P3 field-test.
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>
The logic half of the friends-only idle listener, built as pure unit-tested
seams so the security-critical decisions are provable without live networking.
src/presence.rs:
- ControlMsg { Ping, Pong { room: Option<RoomPresence> } } — self-describing
tagged JSON; unknown tags rejected (forward-compat).
- should_answer(from, friends, mode): the authorization gate — answer pings
from FRIENDS ONLY and never while invisible. This whitelist is what keeps
the always-on-while-open endpoint from being a stranger-facing spam/DoS
surface; must be the authenticated remote_id, never payload data.
- PresenceMode { Invisible, Normal(default), Discoverable } + helpers; persisted
in AppConfig (backward-compat default = Normal = friends-only, no beacon).
- interpret_pong: defensive reply handling — sanitizes the peer-supplied room
name and only surfaces a joinable room if its ticket actually parses, else
downgrades to plain Online (no dead/hostile Join button). Never auto-joins.
Deferred to a 2-machine session (the I/O edges): binding the live control
endpoint, its accept loop, and the ping scheduler. +8 presence tests, 256 lib
tests green, clippy clean, release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The ticket a member sees/copies for sharing is now stamped with THEIR OWN
live address + the room's topic, not the (possibly someone else's) ticket they
joined with. So every member — not just the creator — hands out a working door
that bootstraps newcomers off themselves; a room stays reachable as long as
anyone inside can share a ticket, even after the creator leaves.
Pure seam PeerSpeakTicket::restamp(ticket_str, my_addr): re-parse, swap
host_addr to mine, keep topic_id; no-op for an unparseable string or when the
addr is already mine. The core re-stamps only the DISPLAY copy sent in
RoomJoined; the join/bootstrap ticket_str and the A8 retain logic are
untouched, so this is non-breaking (same wire format, different addr).
+3 unit tests (swaps addr/keeps topic, idempotent for same addr, passes
through unparseable). 248 lib tests green, clippy clean, release builds.
Tests-green; the end-to-end 'creator leaves, joiner's ticket still works'
behaviour wants a 2-machine field test. Multi-bootstrap (Vec) + ticket
encoding tightening deferred (both breaking wire changes — want daylight).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The durable anchor of the friends-first model: a local JSON address book at
~/.config/peerspeak/friends.json keyed by stable EndpointId, with a locally
editable display name and a last-known address per friend.
src/friends.rs — FriendStore over Vec<Friend{id,name,last_addr}>. Pure ops:
add (explicit + idempotent; meeting someone in a room never auto-friends
them), remove, rename (local), and note_seen — the auto-heal hook that
refreshes a friend's saved address on connect, friends-only, never clobbering
a local name, and only reporting a change so callers can skip needless writes.
I/O behind a path-injectable seam (load_at/save_at, atomic tempfile+rename,
malformed = error not silent loss), JSON via serde_json (no toml dep).
7 unit tests (idempotent add, remove/contains, rename-existing-only, auto-heal
friends-only + name-preserving, save/load round-trip, missing=empty,
malformed=error). 245 lib tests green, clippy clean, release builds. Store
only — the friends-list UI + core wiring (add-from-room, note_seen on connect)
come with P5.
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>
Record the persistent-identity foundation as landed (d157d78) and add the
user's requirement: surface a persistent UI warning (not just a log) when the
key can't be read/written, since running on an ephemeral fallback silently
breaks friend recognition next launch. To build with the regenerate UI slice.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the per-launch SecretKey::generate() in the core loop with a stable
key loaded from ~/.config/peerspeak/identity.key, so a peer's node id now
survives restarts. This is the foundation of the friends-first contacts model
(docs/contacts-plan.md): friends are keyed by node id and reachability rests
on a saved address per friend, both of which only mean anything if the id is
stable. iroh never forced rolling ids — the old generate() was an unrevisited
default.
New src/identity.rs: load_or_create / regenerate / save over a 0600 hex key
file (atomic tempfile+rename, perms set before rename), hand-written hex (no
new dep). A malformed file is a hard error, not a silent regenerate, so a bad
hand-edit can't orphan everyone who saved the old id. The fs logic is behind a
path-injectable seam (load_or_create_at/save_at) tested in a temp dir:
create+persist, malformed-errors, regenerate-changes-key, 0600 perms, plus hex
round-trips. Core falls back to an ephemeral key only if the file can't be
read/created, so a bad disk never blocks a call.
regenerate() exists for the Settings 'Regenerate identity' control (next
slice; needs live endpoint rebuild). 238 lib tests green (+8), clippy clean,
release builds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rewrite the plan around the model converged in the 2026-06-15 design
session. Core shift: the friends list (stable node IDs) is the durable
anchor; rooms become ephemeral cosmetic labels, not addressable places.
Locked: persistent identity default-on + Settings regenerate; friends-only
idle listener (answers pings only from friends via remote_id, from a saved
address, no presence beacon); presence axis invisible/normal/discoverable
with discovery opt-in default-off and asymmetric (only the friend who moves
networks publishes); silent gossip-driven address auto-heal; and a universal
floor of hand-shared member-issued tickets that always connect.
Supersedes the heavyweight control-plane cut and the room-centric durable-room
cut; both retained at the bottom with reasons. ~3-4 sessions, build order
P1 identity -> P7 security/field-test. iroh does not force rolling IDs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>