Commit Graph
100 Commits
Author SHA1 Message Date
molluskandClaude Opus 4.8 b0fdd4e058 audio(win): filter choose_config to drivable formats (Codex B3/B5 re-review P3)
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
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>
2026-06-19 16:54:26 -04:00
molluskandClaude Opus 4.8 306bc295b1 audio(win): land the deferred cpal start-resilience items (B3 + B5)
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>
2026-06-19 16:48:07 -04:00
molluskandClaude Opus 4.8 8e0b4c16ec audio(win): tighten the cpal start-handshake (Codex re-review B1/B2/B4)
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
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>
2026-06-19 16:27:35 -04:00
molluskandClaude Opus 4.8 f52b5ea64e audio(win): fix RT-safety + start-handshake bugs in the cpal backend
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>
2026-06-19 16:15:49 -04:00
molluskandClaude Opus 4.8 4d07e03395 core: skip network-stack rebuild when SetNetworkMode is a no-op
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>
2026-06-19 15:57:35 -04:00
mollusk 20bfcffe6d Complete Windows audio remap path
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
2026-06-19 04:56:01 -04:00
molluskandClaude Opus 4.8 185d47aa8d W4 (WIP): dep-free resampler + capture/config wiring (playback pending)
- 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>
2026-06-19 04:42:51 -04:00
mollusk 2eae95ede0 Merge Windows chimes + docs + cpal diagnostics (W7/W2/W8)
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
2026-06-19 04:21:58 -04:00
molluskandClaude Opus 4.8 fdd532de53 Windows audio (cpal): surface device fallback + callback-size diagnostics (W7, W2)
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>
2026-06-19 04:21:53 -04:00
molluskandClaude Opus 4.8 46809153d8 Windows: notification chimes via SoundPlayer + docs/WINDOWS.md (W8)
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>
2026-06-19 04:21:40 -04:00
mollusk 6ccad0d37a Merge Windows-compat quick wins (W5/W6/W9) into windows port
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
2026-06-19 04:03:55 -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 bbbe2d8f17 Windows audio (cpal): startup handshake + RT-safe capture ring (W1, W3)
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>
2026-06-19 03:57:41 -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 47c58047ce Windows port Phase 1: real cpal/WASAPI 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
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>
2026-06-18 17:15:56 -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 e4767be210 ci: enforce cargo-deny supply-chain policy on push and PRs
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>
2026-06-18 16:00:36 -04:00
molluskandClaude Opus 4.8 3034c42f71 Add security review report for security-scan branch
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>
2026-06-18 15:54:35 -04:00
molluskandClaude Opus 4.8 465c7ba2b0 Add cargo-deny supply-chain policy (deny.toml)
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>
2026-06-18 15:46:52 -04:00
molluskandClaude Opus 4.8 3ec09de87e Adopt versioning standard + migrate to versioned protocol planes (0.2.0)
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>
2026-06-18 05:20:27 -04:00
mollusk 4b8fb92dc5 Merge codex-security-s8-audio-membership: gate inbound audio by live room membership (S8) 2026-06-18 04:41:41 -04:00
molluskandClaude Opus 4.8 1adf8a97bb S8 (Pass 2): wire grace-aware audio-membership admission
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>
2026-06-18 04:41:37 -04:00
molluskandClaude Opus 4.8 10707152a3 S8 (design-first): pure audio_sender_admitted membership seam (unwired)
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>
2026-06-18 04:24:01 -04:00
mollusk f2e72624f7 Merge codex-security-s11-presence-discovery: honest presence/discovery state on apply failure (S11) 2026-06-18 03:32:42 -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
mollusk d56c2c90b2 Merge codex-security-hardening: S10 log redaction + T1/T2/T5/T6/T7 trust-boundary fixes 2026-06-18 03:05:56 -04:00
molluskandClaude Opus 4.8 5086e86bd2 Security hardening: log redaction + 5 trust-boundary fixes (S10, T1/T2/T5/T6/T7)
Codex (gpt-5.5) implementer branch, senior-reviewed.

- S10 (High): redact capabilities/chat from logs; create log 0600 + chmod
  existing; rotate at 5 MiB. New short_id/short_bytes_hex/redact_for_log seams.
- T1 (P2): friends-ALPN authorizes (handler(from)) before reading any peer
  bytes; unauthorized conns closed pre-read (DoS relief).
- T2 (P2): per-(author,kind) replay gate on state-changing gossip (Announce/
  Leave) only; Chat bypasses it, preserving the S2 no-monotonic-ts decision.
- T5 (P2): cap inbound Opus datagrams at 4 + MAX_OPUS_PAYLOAD (4000).
- T6 (P2): bind friend-Pong room ticket host to the authenticated responder
  (interpret_pong/probe now thread the remote id) — blocks Join-button
  redirect/phishing. Non-regressive given the W7 P3 restamp design.
- T7 (P3): sanitize_ticket caps/validates PeerState.sharing at gossip ingest
  so invalid offers never render a Watch button.

302 lib tests pass (was 291), clippy --all-targets clean, release builds.
Tests-green only; DoS relief + 2-machine replay/redirect behavior want a
field test. W7 P7 (n0 DNS privacy) reviewed read-only — findings to triage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 03:05:56 -04:00
molluskandClaude Opus 4.8 54780fa73b Remove Codex task-report.md from repo root
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>
2026-06-18 01:51:26 -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
molluskandClaude Opus 4.8 f3f399a748 Merge codex-wishlist-audio-hotkeys: per-peer EQ (W2), spatial pan + stereo bus (W1), focused hotkeys (W5), + A21/A22/A14 fixes
W2: src/audio/eq.rs 3-band RBJ biquad EQ, per-peer, flat=bypass.
W1: src/audio/pan.rs constant-power pan; mixer/playback/recorder converted
    to a stereo bus, bit-for-bit dual-mono at pan=0.
W5: src/hotkeys.rs config-backed focused hotkey map + Settings editor + info popup.
A21: jitter resets on large seq discontinuities (sender restart / far jump).
A22: WAV writer guards RIFF/data size overflow.
A14: orderly window-close shutdown (finalize recordings, leave room, close net).
W3 (PipeWire routing) intentionally left as a design note.

No new deps; no wire/serialization changes. Tests-green only; audio + 2-machine
field verification pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:29:45 -04:00
molluskandClaude Opus 4.8 1afdccbefe Merge codex-security-s9: bind gossip Announce addr to authenticated author (S9)
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>
2026-06-18 01:29:34 -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 8982df364e Fix gossip announce address binding 2026-06-17 16:17:03 -04:00
mollusk 33e3998e7c Add orderly shutdown on window close 2026-06-16 17:37:00 -04:00
mollusk 44bad7b70b Fix jitter restart and WAV size overflow 2026-06-16 17:28:41 -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 83b1bbcc9d docs(contacts-plan): mark W7 add-friend-from-room done (P5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:42:20 -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 b97b8eb7ca docs(contacts-plan): mark W7 room labels + home-panel move done (P5)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 14:32:33 -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 e47ea9c52f docs(contacts-plan): mark W7 B1+B2 done + field-verified
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 16:59:26 -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 af482d9666 fix(core): per-ticket rejoin bootstrap so room detours don't strand a rejoiner (A8)
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>
2026-06-15 16:10:28 -04:00
molluskandClaude Opus 4.8 fdab4e03a2 refactor(net): persistent endpoint/gossip/router (W7 B1)
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>
2026-06-15 15:44:19 -04:00
molluskandClaude Opus 4.8 d50c05744f docs(architecture): correct stale playback buffer-quantum description
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>
2026-06-15 05:53:16 -04:00
molluskandClaude Opus 4.8 362ead7a45 docs: correct contacts-plan status header (was 'SCOPED, not started')
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>
2026-06-15 05:50:50 -04:00
molluskandClaude Opus 4.8 09673d2592 docs: mark W7 P4 transport done (loopback-verified); endpoint fork next
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 05:28:27 -04:00
molluskandClaude Opus 4.8 501f76ac6d feat(presence): presence control transport (W7 P4 wire layer)
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>
2026-06-15 05:28:04 -04:00
molluskandClaude Opus 4.8 9a1163484a docs: 2-machine field test passed (P1 identity, P3 tickets, regression)
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>
2026-06-15 05:22:39 -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
mollusk 87e15dd817 docs: mark W7 P4 pure core done (presence protocol + auth gate) 2026-06-15 04:28:54 -04:00
molluskandClaude Opus 4.8 a897f5c5d7 feat(presence): pure friends presence protocol + auth gate (W7 P4 core)
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>
2026-06-15 04:28:08 -04:00
molluskandClaude Opus 4.8 3899a1db44 docs: mark W7 P3 member-issued tickets done (core)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 04:21:06 -04:00
molluskandClaude Opus 4.8 c90dcf71ef feat(tickets): member-issued tickets so rooms outlive their creator (W7 P3)
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>
2026-06-15 04:20:50 -04:00
molluskandClaude Opus 4.8 a7a4da3cd4 feat(friends): pure persistent friends store (W7 P2)
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>
2026-06-15 04:12:42 -04:00
molluskandClaude Opus 4.8 22e4c68544 docs: mark W7 P1 complete + screenshot-verified
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 04:06:44 -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 4ca497360b docs: mark P1 identity foundation done; bank degraded-identity UI warning
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>
2026-06-15 03:49:55 -04:00
molluskandClaude Opus 4.8 d157d78707 feat(identity): persistent node identity (W7 P1 foundation)
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>
2026-06-15 03:43:26 -04:00
molluskandClaude Opus 4.8 c7d384c606 docs: lock W7 friends-first model (friends = anchor, rooms = cosmetic)
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>
2026-06-15 03:36:39 -04:00
molluskandClaude Opus 4.8 b6984dda59 docs: redesign W7 around member-issued/multi-bootstrap tickets
Pivot the contacts plan away from the heavyweight control-plane design
(always-on listener + persistent reachable identity + n0 DNS presence
beacon) toward a serverless, no-beacon spine: member-issued, multi-bootstrap
tickets. Any current member can mint a fresh ticket from their own live
address + the room's existing topic_id (both already in hand; join() already
takes extra_bootstrap: Vec), so rooms outlive their creator for ~no cost.

Layered optional add-ons: one-click invite sharing (peerspeak:// deep links
+ QR + .peerspeak files, delegating delivery to Signal/email/OS), persistent
identity (save-and-return), name-derived rooms, favorite-room bookmarks, and
a privacy-bounded silent occupancy peek. The old heavyweight path is retained
at the bottom of the doc as 'decided against', with the reasoning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 01:00:11 -04:00
molluskandClaude Opus 4.8 0af2af2dd9 docs: scope contract for contacts + room-invite notifications (W7)
Bank the W7 investigation as a scope contract. Key finding: pixelpass's
friends/control/identity code ports near-verbatim (same iroh 1.0.0-rc.0),
but PeerSpeak's endpoint is room-scoped (built in Join, torn down on Leave)
with a fresh identity each launch — so the real work is a new always-on
control-plane endpoint + persistent identity, not the friends list. Phased
plan (0 identity / 1 control plane / 2 store+handshake / 3 drawer UI /
4 security+field-test), ~4 sessions, with 4 open decisions that block build
(discovery-vs-privacy being the big one).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 23:27:56 -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 7c8f0114f7 fix(net): room creator can rejoin a room they left (A8)
The gossip bootstrap list was derived solely from the ticket: a client
dialed the host, but "we are the host" produced an EMPTY list. So when the
room CREATOR rejoined their own room (their ticket names themselves as host)
they dialed nobody and never re-entered the swarm — the remaining peer stayed
stuck until it too left and rejoined. (First 2-human field test, 2026-06-14;
user-confirmed call-breaking, P1.)

Fix: retain the peers seen in the current room across leave (core
`known_peers`, updated by the event task; reset only when the joined ticket
changes) and pass them to `RoomState::join` as extra bootstrap targets. The
new pure `compute_bootstrap` seam unions the ticket host + retained peers,
drops self, and de-dups; address resolution rides the persistent lookup.

- `RoomState::join` gains `extra_bootstrap: Vec<EndpointAddr>` (test_net
  callers pass vec![]).
- +4 unit tests on `compute_bootstrap`, incl. the host-rejoin regression case.

Tests-green (208 lib + 6 + 4 integration), clippy clean. NOT yet 2-machine
field-verified — needs a live host leave→rejoin call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:57:37 -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
molluskandClaude Opus 4.8 bd57bdd1bc polish(ui): tooltip on the layout button + even Audio Devices spacing
- The top-bar room-layout button was an icon-only canvas with no label or
  tooltip, while the Settings button beside it is labeled — a discoverability
  and consistency gap. Wrap it in the existing tooltip pattern ("Room layout",
  Position::Bottom) so its purpose is discoverable on hover.
- The Audio Devices input/output columns used spacing(4) while every other
  Settings section uses spacing(8); bump both to 8 for an even vertical rhythm.

Pure presentational changes. Build + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:35:18 -04:00
molluskandClaude Opus 4.8 472e2b7230 fix(ui): robustly center the Settings header title
The sticky Settings header centered its title with two horizontal_space()
flanks plus a hardcoded 90px right spacer guessing the Back button's width —
off-center if the button's rendered width drifted (documented follow-up).
Replace with equal-width Fill flanks: the Back button sits in a left Fill
segment (left-aligned), an empty Fill segment balances the right, so the
center title is geometrically centered regardless of button width. No magic
constant. Pure layout change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 03:32:53 -04:00
molluskandClaude Opus 4.8 9e5fd63291 feat(dsp): double-talk detection for the NLMS echo canceller (Stage C)
Add Geigel double-talk detection so the adaptive filter stops diverging when
the near-end talks over the far-end (the failure the Stage B demo exposed:
ERLE went negative as the filter mistook near-end voice for echo).

- src/dsp/aec.rs: DoubleTalkDetector — Geigel test (mic level vs a sliding-
  window peak of the far-end, via an O(1) monotonic-deque max) with a hangover
  latch. EchoCanceller — wraps Nlms + the detector, freezing adaptation while
  double-talk is declared; reports the double-talk rate; DTD can be toggled off
  for A/B. 5 aec tests (detector fires on near-end not echo; DTD protects a
  converged filter through double-talk, +15 dB over raw NLMS).
- specview aec: now uses EchoCanceller with tunable --dtd-threshold / --hangover
  / --near-onset / --no-dtd, and prints the double-talk %.

Tuning found with the harness (white far-end, -12 dB echo, near-end onset
mid-call): DTD threshold 0.5 is the sweet spot — late-call ERLE +29.6 dB with
DTD vs -4.0 dB without (a 33 dB swing); 0.35 over-triggers (never learns), 0.7
under-triggers (drifts). Default set to 0.5. Also documents the false-positive
/miss tradeoff and that correlated (pink/speech) far-ends converge slower.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:45:40 -04:00
molluskandClaude Opus 4.8 eeb725f996 feat(dsp): in-process NLMS echo canceller + headless AEC test loop
Stage A+B of the in-process AEC: a Normalized LMS adaptive-filter echo
canceller and a synthetic echo-path simulator, driven end-to-end by a new
`specview aec` command so cancellation can be measured fully headless on
conjured signals (no speakers/mic needed).

- src/dsp/echo_path.rs: EchoPath — synthetic acoustic echo (bulk delay +
  exponentially-decaying diffuse RIR, energy-normalized to a target
  attenuation) convolved over a far-end signal. Gives ground-truth echo.
- src/dsp/aec.rs: Nlms — sample-at-a-time NLMS adaptive FIR (ring-buffered
  reference history, energy-normalized update, freezable for double-talk).
  Cleaned output = mic minus the learned echo estimate.
- specview aec: far -> sim echo -> (+ optional near-end) -> cancel -> measure.
  ERLE via oracle residual (cleaned - near), broadband + early/late
  (convergence) + per voice band, optional before/after spectrograms.
- 6 new tests (path delay/attenuation, NLMS convergence >20 dB on a known
  path, frozen-filter no-op, near-end passthrough). 33 dsp tests total.

Verified: single-talk pink-noise echo cancels +14.5 -> +32.0 dB ERLE as the
filter converges; double-talk (no DTD yet) drives ERLE negative as the filter
diverges onto the near-end tone — the motivating result for Stage C (DTD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:39:01 -04:00
molluskandClaude Opus 4.8 aa94e861b1 feat(dsp): spectrogram + AEC measurement toolkit (specview)
Add a dependency-free signal-analysis toolkit and a `specview` dev CLI to
evaluate audio (especially echo cancellation) with objective numbers and a
terminal spectrogram instead of ear alone.

- src/dsp/: hand-written radix-2 FFT, Hann window, STFT, seeded test-signal
  generators (sine/log-sweep/white/pink/impulse), metrics (RMS/dBFS/peak/ERLE/
  per-band energy), minimal WAV read+write, and a 24-bit-ANSI half-block
  spectrogram renderer (magma colormap, freq/time axes, dB legend, ASCII
  fallback). Pure layers have no I/O; only `wav` touches the filesystem.
- src/bin/specview.rs: `gen` (conjure a test signal -> WAV), `show` (spectrogram
  + per-band energy summary), `erle` (broadband + per-band echo-return-loss
  between a before/after pair).
- 27 unit tests (FFT correctness, ERLE landmarks, WAV round-trip, render shape).
  Verified end-to-end: log sweep renders as the expected exponential curve;
  a 20 dB-quieter copy reads +20.0 dB ERLE broadband and per band.

Measurement substrate for upcoming AEC refinement (no shipped-path changes).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:25:58 -04:00
molluskandClaude Opus 4.8 d3e831d5e7 feat(audio): multitrack stem recording — Stage 3 (Recording settings UI)
Adds the "Recording" category to the Settings page so the recording mode is
selectable from the UI (config + core wiring already existed from Stage 2).

- AppMessage::RecordingModeSelected → persists config + sends SetRecordingMode
  (takes effect on the next recording start). recording_mode_hint copy.
- iced 0.14 pick_list can't host per-option tooltips, so the three modes are
  RADIO BUTTONS each wrapped in a `tooltip` (hover explains that mode) — chosen
  over a dropdown so each option is self-documenting. Placed after Microphone,
  using the shared section_header; output-dir note below.
- +1 config test (recording_mode round-trip + is_multitrack classification).

User-verified live (Both mode writes the expected WAVs; radios + tooltips
approved). Senior-written — Gemini's unsanctioned Stage 3 attempt was discarded.
179 tests (169 lib +1 ign, 6 reconnect, 4 transport), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 02:04:07 -04:00
molluskandClaude Opus 4.8 729f5ed6a5 docs(multitrack): mark Stages 1+2 done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:05:32 -04:00
molluskandClaude Opus 4.8 f6520b79f7 feat(audio): multitrack stem recording — Stage 2 (wire into the mixer)
Wires MultitrackRecorder into the live audio path, behind a recording_mode.

- config: RecordingMode { Mixed, Multitrack, Both } + AppConfig.recording_mode
  (serde-default Mixed, back-compat); CoreCommand::SetRecordingMode, sent at
  app startup from config.
- multitrack.rs: mic now arrives async via push_mic into an internal FIFO,
  drained one frame per end_cycle (mirrors recorder.rs) so the mic track tracks
  the cycle clock; added dir() accessor. mic is a plain WavWriter now.
- core: parallel `multitrack` slot + `is_multitrack` fast-path gate (exactly one
  of the mixed/multitrack recorders is active). SetRecording start branches on
  mode: Mixed → single-file Recorder (unchanged); Multitrack/Both → a per-session
  dir, MultitrackRecorder, and registers everyone already in the room (named,
  silence-aligned from t=0). The mixer taps each peer's RAW frame (pre-volume/
  mute/limiter) into stems and writes peer stems + mix (Both) + end_cycle per
  cycle; the capture thread pushes mic to whichever recorder; PeerJoined adds a
  late joiner's stem track. stop_recording finalizes both.

No UI yet to pick the mode (Stage 3) — defaults to Mixed, so behaviour is
unchanged until then; set recording_mode in config.json to exercise stems.
168 lib tests, clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 00:04:49 -04:00
molluskandClaude Opus 4.8 fde6f8a680 feat(audio): multitrack stem recording — Stage 1 (pure core + plan)
Scopes the differentiating "record every peer to their own synced track"
feature and lands its pure, isolated core (no live-audio wiring yet).

- docs/multitrack-recording-plan.md: scope contract + locked decisions
  (raw stems pre-volume/mute, stems + a mixed track, silence-pad late joiners).
- src/audio/multitrack.rs: MultitrackRecorder over the existing WavWriter.
  One master clock = the mixer cycle; every end_cycle() appends exactly
  FRAME_SAMPLES to every track (silence where idle) so all stems stay
  sample-aligned. add_peer back-pads a late joiner to cycle 0; track_filename
  gives fs-safe `<slug>-<shortid>.wav` (reuses sanitize_name). Optional mix
  track for "Both" mode.
- +5 unit tests: equal length across tracks, late-joiner leading silence,
  stems-only omits mix, fit() pad/truncate, filename slugging/disambiguation.

Stage 2 (wire into the mixer) is next, behind a checkpoint. 168 lib tests,
clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 23:48:07 -04:00
molluskandClaude Opus 4.8 3edac3f8d1 feat(ui): category headers on the Settings page
Reorganize Settings into six clearly-headed categories with a reusable
section_header (color_blue size-16 title + thin full-width divider): Audio
Devices, Microphone, Network & Privacy, Room Layout, Theme, Notifications &
Sounds. Splits the old shared Mic|Network row, drops the inconsistent inline
size-14 sublabels, and removes the hardcoded "Theme" title from theme_section
so it's rendered by the same header helper as every other section. Left-aligns
the content (was centered) so headers, dividers, and hints line up.

Presentational only; no message/logic changes. User-verified live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 22:33:06 -04:00
molluskandClaude Opus 4.8 2b52a48efe fix(security): authenticate gossip payloads (S2 — sign + verify, replay-bound)
The gossip `author` field was self-asserted, so an in-room member could forge
it to impersonate (chat), force-evict (Leave), or poison presence + the address
book (Announce). Now every GossipPayload is signed with the node's ed25519
secret key and verified on receipt; a forged author can't validate because the
attacker lacks the victim's key.

Done as the complete fix (forgery + replay):
- GossipPayload gains `ts` (sender-stamped) + `sig` (iroh::Signature, serde-64B);
  Debug is hand-written since Signature has none.
- Signature covers domain tag + room topic_id + author + ts + msg
  (`signable_bytes`): topic binding blocks cross-room replay, ts + a 2-min
  freshness window block temporal replay (within-window replays are byte-
  identical and de-duped by the swarm), author binding makes spoofing fail.
- New pure seams `sign_gossip` / `verify_gossip` (+ `GossipReject`); all four
  outgoing broadcasts sign, the receive loop verifies-then-trusts (drops
  unauthenticated/stale before any peer-map / event / address-book action).
- IrohGossipState now holds the node SecretKey + active topic bytes; callers
  (core, test_net) updated.

NOTE: breaking gossip wire change — all peers must run this build (the staged
friend release + dopedart need rebuild). Node identity is ephemeral, so no
migration concern beyond rebuild.

+5 unit tests (genuine accept; forged author, tampered msg, cross-room, stale/
future all rejected). 163 lib tests (was 158), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:42:02 -04:00
molluskandClaude Opus 4.8 fe627166d5 fix(security): close S3 (arg-injection), S4 (presence-name), S1 (panic-slice)
Three findings from the first security pass:

- S3 (Medium): the peer-supplied screen-share ticket was passed to pixelpass
  as the first positional CLI arg with no end-of-options guard, so a ticket
  starting with `-`/`--` could be reinterpreted as a flag (argument injection).
  New pure `viewer_args()` puts flags first, then a `--` guard, then the ticket
  positionally; spawn_viewer uses it. +2 tests.

- S4 (Medium): peer presence display-names (gossip `Announce`, untrusted and
  spoofable) were rendered unsanitized/unbounded, unlike the chat path. New
  `sanitize::sanitize_name` strips bidi/zero-width format chars + control chars,
  collapses whitespace, and caps at 48 chars; applied at the gossip ingest point
  so every consumer gets a safe value. +4 tests.

- S1 (Low): `&id[..8]` byte-slices could panic on a short/non-ASCII id. New
  panic-free `short_id()` (char-based take) replaces both slices. +1 test.

158 lib tests (was 151), clippy --all-targets clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 21:16:24 -04:00