The repo never enforced rustfmt, so formatting had drifted broadly. This is a
single mechanical `cargo fmt` pass over the whole crate (no behavioral change;
lib suite green, 493 passed). Going forward fmt should be enforced (planned CI
fmt --check step). Part of the 0.6.1 hygiene pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Personal playlist on a dedicated music player (browse/play/prev/next/
seek/volume/reorder/remove, .pls/.m3u import), plus per-person shared
listening: broadcast your track over presence, peers tune in and stream
it point-to-point over the files plane. Playback is timeline-synced
(play/pause/skip/seek mirror with no drift) with gapless prefetch of the
next track and independent per-source volume per listener.
In the 3-column layout the playlist gets its own card stacked under the
chat, with a resizable divider and its own scrollbar; other layouts keep
it in the Controls panel.
Breaking wire change: gossip protocol v5 (presence gains music fields),
so 0.6.0 peers cannot share a swarm with 0.5.x. Version bumped 0.5.1 ->
0.6.0; CHANGELOG updated.
Untrusted-input handling: broadcast track name sanitized and size
cap-checked at gossip ingest, fetched bytes confirmed audio before
decode, only the descriptor rides gossip (bytes go point-to-point, one
fetch in flight).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A validly-signed gossip payload rejected only by the 120s replay
freshness window (GossipReject::OutOfWindow) now drives a room-level
"clocks out of sync" warning banner, instead of silently dropping the
peer so the room shows "1 in room" with no error.
Observe-only: verify_gossip's accept/reject decision and
GOSSIP_FRESHNESS_MS are unchanged; the payload is still dropped exactly
as before. The warning is gated strictly on OutOfWindow (which, because
the signature is verified first, implies a genuine authenticated peer
whose clock is skewed), never on BadSignature.
Policy lives in a pure, unit-tested ClockSkewMonitor seam with injected
now_ms: >=3 OutOfWindow drops from the same author within 60s warn once,
5-min per-author cooldown, bounded/pruned author map. The warning rides
the existing in-process RoomEvent -> UiEvent -> transient-banner path
(no wire/serialization or dependency change).
Implemented by Codex (gpt-5.5), senior-audited against the 5-point
checklist and independently verified (452 lib tests, clippy
--all-targets clean, release build green). Tests-green only; a 2-machine
deliberate-skew field test is still owed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to 5c11947 after Codex's adversarial audit
(tier-c-audit-2026-06-23.md). Fixes a regression the roster cap introduced
and closes F-01's two cheap unbounded-growth vectors. No wire/protocol
change, no new deps.
- Regression (cap × reconnect): a peer reconnecting from a transient drop
sits in `disconnected_peers`, not the live roster, so the new cap could
reject it as "new" at a full 32-peer roster — and the eager
`disconnected_peers.remove()` (before the cap check) then orphaned its
recovery state so a later signed Leave skipped cleanup. Now reconnecting
(and existing) peers are exempt from the cap via the pure
`announce_subject_to_cap`, and the disconnect marker is cleared only after
admission. PeerJoined semantics for reconnects are preserved.
- F-01 replay map: `state_mutations_seen` was uncapped, so signed Leaves
from unlimited generated keys grew it for the room's lifetime. Prune
entries older than the freshness window once past a soft cap
(`prune_stale_mutations`) — stale entries can't gate an in-window message
(verify_gossip rejects the replay first), so replay protection is intact;
the map is now bounded to ~authors-seen-per-window.
- F-01 address lookup: a signed Leave now calls `remove_endpoint_info`, so
cycling identities through Announce→Leave can't grow the iroh lookup
without bound. Re-announce re-populates it.
- F-03 test: added a forced same-hash/different-bytes ByteLru test (via a
hash-injectable inner seam) so collision-safety is regression-tested, not
just code-reviewed.
Deferred follow-ups from the audit (logged): recovery/known_peers identity
cap (needs a design pass, touches reconnect-resilience), F-02 result-cache
LRU, and the (author,id)-vs-id attachment aliasing integrity bug.
416 lib tests (+3), clippy --all-targets clean, release build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A room ticket holder is an authenticated insider; signatures only prove
keypair ownership, not a distinct human. Previously such a member could
exhaust a victim's memory/tasks/dials without bound. Add caps + dedup at
the gossip/core/UI boundaries (no wire/protocol change, no new deps):
F-01 (gossip): cap the roster at MAX_ACTIVE_PEERS (32) — new authors are
rejected when full, existing peers' updates always pass; sanitize each
announced EndpointAddr (<=8 addrs, relay-URL <=256 bytes, drop Custom);
replace (set_endpoint_info) instead of unioning attacker address history.
F-02 (core): gate chat image auto-fetch — only roster authors qualify,
(author, attachment_id) is deduped, and a 4-permit pool bounds concurrent
detached fetch tasks (RAII AutoFetchGuard releases permit + dedup marker).
Chat text is still shown (already sanitized); the user-initiated "Save"
fetch is unchanged. Non-roster sock-puppet chat can no longer spawn tasks.
F-03 (app): replace the unbounded AVATAR_HANDLE_CACHE map with a bounded,
byte-equality-keyed LRU (avatar::ByteLru, cap 64) — fixes both unbounded
growth from an endless stream of distinct valid avatars and the prior
64-bit-hash-collision-shows-wrong-avatar bug.
Pure seams (sanitize_endpoint_addr, admit_into_roster, should_auto_fetch,
ByteLru) + 6 adversarial/unit tests. 413 lib tests, clippy --all-targets
clean, release build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the new PeerState.game field to the presence log formatter so
gossip Announces show whether a peer is broadcasting a game. The line
previously printed name/muted/addr_id/addrs/sharing only, making the
game-presence broadcast invisible in logs (verified solely via UI
during the 2026-06-22 2-machine field test). Log-only: no wire,
protocol, or GOSSIP_PROTO change. Adds the first unit test for the
formatter (Some and None cases).
Implemented by Codex (gpt-5.5) on branch codex-log-game-field; reviewed
and gates re-run by the senior (397 lib tests, clippy --all-targets,
release build all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Steps 5-6 of game detection. BREAKING wire change — bump everyone.
Wire (step 5):
- PeerState.game: Option<String> (display label only — never appid/source).
- SelfPresence.game + to_state carry it (single self-state builder).
- GOSSIP_PROTO 2->3, GOSSIP_SIG_DOMAIN v3, version comment bumped together;
Cargo MINOR 0.3.0 -> 0.4.0 per VERSIONING.md. v2/v3 isolate into
different topics + signature domains, so a coordinated redeploy is
required (same as the W4 avatar bump).
- Gossip ingest sanitizes incoming game via sanitize_game_label (bidi/
control strip, 64-char/256-byte cap); empty -> None.
- Bonus security fix (Codex find): reject inbound gossip frames over a
128KB cap BEFORE serde_json::from_slice — a legit Announce with a full
48KB avatar is ~49KB, so this bounds allocation abuse with headroom.
Core wiring:
- Spawns the detector at startup; consumes its watch channel in the main
select. Detection runs continuously (for the local background); the
broadcast is gated by game_presence_enabled (opt-in, default OFF).
New commands: SetGamePresenceEnabled (immediate publish/clear, D8),
SetGameOverride, SetGameProcessMap. New event: GameChanged.
- game_presence_label sanitizes the outgoing label too.
Background switch (step 6):
- GUI handles GameChanged: stores current_game, swaps background to the
per-game override (config.game_backgrounds[id]) or falls back to the
W16 default; reuses the existing cached-handle path (no redraw flicker).
397 lib tests (all green), clippy --all-targets clean, full binary builds.
Remaining: step 7 UI (opt-in toggle, roster 'Playing' text, manual
override control, Settings game-backgrounds + process-map editors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice of in-chat file/photo sharing (dedicated file plane, images
inline + file chips, session-only). This stage adds the wire types and
the pure, unit-tested logic; no transport or UI yet.
- protocol: new FILES_ALPN / FILES_PROTO (peerspeak/files/1) for the
dedicated file-transfer plane. Bump GOSSIP_PROTO 1->2 + sig domain v2
(Chat gained an attachment field, so cross-version peers fail fast
rather than half-work) and Cargo 0.2.0 -> 0.3.0 per VERSIONING.md.
BREAKING wire change: all peers must run >= 0.3.0.
- new src/files.rs: ChatAttachment descriptor (name/size/kind/id; bytes
travel off-gossip), AttachmentKind, plus pure seams — sanitize_filename
(path-traversal/control-char/length-safe), size_within_cap, image
magic-byte sniffing + defensive limited decode (decode-bomb guard),
32-byte request parsing, human_size. 13 unit tests.
- GossipMessage::Chat and RoomEvent::ChatMessage carry an optional
ChatAttachment; send_chat takes Option<ChatAttachment>. Untrusted
inbound descriptors are filename-sanitized + size-validated on ingest.
serde(default) keeps the field forward-compatible at the JSON layer;
+round-trip and pre-v2 back-compat tests.
The attachment id is a random 32-byte handle (rand, already a dep), not
a content hash — the fetch is authenticated + encrypted + member-gated,
so no crypto-hash dep is needed.
349 lib tests pass, clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Completes W4: users can upload a custom avatar image.
- `Avatar::Custom(String)` carries a base64 PNG. `process_upload` decodes an
arbitrary png/jpeg, downscales so the longest side is 128px (aspect kept),
re-encodes PNG, base64s, and rejects anything over a hard cap.
- Settings "Avatar" gains an "Upload image…" button (native picker via rfd's
xdg-portal backend, off-thread through Task::perform) and shows the current
custom avatar as a selected tile.
- Untrusted peer avatars are validated at gossip ingest (`sanitize_incoming`):
a custom image must be within the byte cap and decode as a PNG within bounds
(image-crate decode limits guard against decompression bombs) or it's
downgraded to a monogram.
- Raised the gossip max message size to 64 KB so a capped custom avatar fits
inline on the presence plane (all peers already need a matching build).
- Deps: image (png/jpeg only), rfd (xdg-portal, no GTK); only `rfd`+`pollster`
are actually new in the lockfile (rest were already transitive). cargo audit
clean (0 vulns; the 2 unmaintained warnings are pre-existing S7).
- `Controller::send` now returns bool (was a Result carrying the now-larger
CoreCommand by value, which tripped result_large_err).
- +5 avatar unit tests (upload resize/round-trip, reject non-image, ingest
accept/reject). 227 lib tests green, clippy clean.
Manual check: Settings → Avatar → Upload; confirm the picker opens and the
image shows for you and (after redeploy) for a peer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds bundled preset avatars on top of the monogram foundation.
- New `Avatar` enum (Monogram | Preset(u8)) in the avatar module, serde-encoded;
6 preset PNGs embedded via include_bytes! (placeholder art in assets/avatars/
— swap for real designs later). +3 unit tests.
- `PeerState` gains `avatar` (rides the gossip presence plane like `sharing`);
`AppConfig` gains `avatar` (persisted). Avatar choice flows app → core (Join +
new SetAvatar command) → every self-state announce, so it reaches the room incl.
late joiners, and changing it mid-call re-announces live.
- Settings "Avatar" section: monogram + 6 preset tiles, applied live + persisted.
- Rendering: `avatar_view` draws the chosen preset image (iced `image` feature,
now enabled) else the monogram, in the self card, peer rows, and chat (chat
looks up the sender's avatar from presence by id).
⚠️ BREAKING gossip wire change: presence Announce is signed (S2) and the
signature is recomputed by re-serializing the parsed struct, so a new PeerState
field means old and new builds can't verify each other's presence — ALL peers
must run a build >= this one (same as the S2 change). Redeploy dopedart before
2-machine testing.
Build + clippy clean, 222 lib + integration tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
Surface pixelpass screen-sharing from inside a peerspeak room. peerspeak owns
voice, pixelpass owns pixels — they're never Cargo deps of each other; the
contract is pixelpass's CLI flags + its `--output json` stdout stream.
Modelled on Discord: multiple simultaneous sharers, a 🔴 Live badge + 👁 Watch
on each sharing peer's card, and in-progress shares visible to late joiners.
- New `src/screenshare` module: pure `parse_pixelpass_event` seam + `pixelpass_path`
discovery (13 unit tests), async `spawn_host` (→ ticket) and `spawn_viewer`
(→ parse connected{url} → open mpv, vlc fallback). No new deps.
- Sharing rides presence: `PeerState.sharing: Option<ticket>` (serde-defaulted),
so the existing gossip re-announce delivers the offer to late joiners for free
and a PeerUpdated fires on start/stop — no separate gossip message needed.
- core: Start/Stop/ViewShare commands; host + viewer children tracked in the
session, killed on stop/leave (kill_on_drop backstop). Viewer limit left to
pixelpass's bandwidth-measured cap.
- UI: Share/Stop button (graceful "needs pixelpass" disabled state), Live badge
+ Watch on peer cards, Sharing badge on the self card. Verified by screenshot.
- config: optional `pixelpass_path` override (hand-editable).
Tests-green; the 2-machine gossip/remote path is not yet field-verified.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add room text chat riding the existing iroh-gossip topic (same layer as the
presence roster). New GossipMessage::Chat { name, text, ts }; the gossip loop
forwards it as RoomEvent::ChatMessage, core relays it to the UI as
UiEvent::ChatMessage, and RoomState::send_chat broadcasts an authored line
(display name from self-state, ms timestamp). CoreCommand::SendChat sends; our
own author is suppressed by the existing self-echo guard, so the UI echoes our
sent line locally instead.
UI: a full-width chat dock along the bottom of the room (the chosen layout) —
bottom-anchored scrollback with per-sender name colouring (green = you), an
input with Enter-to-send + a Send button, history capped at 300 lines. The room
window default grows to 900x760 so the dock doesn't squeeze the controls column.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add #[cfg(test)] coverage for the join-link codec and gossip wire format:
PeerSpeakTicket Display/FromStr round-trip and — the real gap —
malformed-input rejection (empty / non-base64 / valid-base64-non-JSON all
return NetError::InvalidTicket, never panic, since users paste untrusted
text here); PeerState serde round-trip; and GossipMessage::Leave /
Announce + GossipPayload round-trips. Tests-only; no production change.
Implemented by Gemini (junior implementer), reviewed and verified by senior
(cargo build + clippy --all-targets + cargo test all green).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Field test of the long-outage path failed: ~31s into a Wi-Fi drop the peer
vanished with no "Reconnecting..." indicator and never came back. Logs showed
the chain `Gossip NeighborDown -> Removed peer -> Transport: stopped
supervising peer`.
Root cause: a transient gossip NeighborDown was routed to RoomEvent::PeerLeft,
same as a graceful leave, so core called disconnect_peer -> supervisor.abort().
That aborted the very reconnect supervisor that was meant to redial -- before
its own reconnect loop (which re-emits Connecting and re-dials the retained
09acefd address with backoff) ever ran. The supervisor + retained-address fix
were effectively dead code in the field, which is also why the loopback tests
(they drive the supervisor directly) never caught it.
Fix: decouple a transient drop from a graceful leave.
- gossip.rs: NeighborDown now emits the new RoomEvent::PeerConnectionLost
instead of PeerLeft. A graceful GossipMessage::Leave still emits PeerLeft.
- core: on PeerConnectionLost, do NOT disconnect the peer. Keep its supervisor
alive (it redials the retained address and drives the yellow indicator) and
arm a per-peer reconnect grace timer (RECONNECT_GRACE = 45s, comfortably past
the ~30s QUIC idle timeout). The peer is evicted only if the link hasn't
recovered when the timer fires. A gossip rejoin (PeerJoined/PeerUpdated) or a
transport reconnect (ConnEvent::Connected) cancels the timer first; session
shutdown aborts all pending timers so none fire a stray eviction.
Builds clean, clippy clean, 3 transport tests pass. NOT yet field-verified --
re-test the long-outage path on a real call.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Remove Gemini's committed update_*.py regex-surgery scripts
- Drop unused iroh-tickets dependency (hand-rolled ticket is used instead)
- Replace ToString antipattern with Display impl on PeerSpeakTicket
- Route debug log to XDG state/cache dir instead of hardcoded /home path
- Clear all compiler + clippy warnings (unused imports, collapsible ifs,
redundant pattern matching, missing Default)
Builds clean with zero warnings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Fix tokio runtime panic by spawning a dedicated Tokio runtime thread in CoreController.
- Add central log_msg utility in src/lib.rs for debugging.
- Add instrumentation/logs to join, leave, and gossip events in src/network/gossip.rs.
- Add test_net.rs bin for testing gossip loopback sync.
- Use std::sync::Mutex in IrohGossipState to resolve Tokio block-in-async panics.