Compare commits

...
Author SHA1 Message Date
molluskandClaude Opus 4.8 2d067a2e41 packaging(win): update INSTALL.md + README.md for 0.3.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
- INSTALL.md: bump the setup filename to 0.3.0; add an end-user section
  on text chat + sending photos/files (inline images, file chips,
  Save/Download, 25 MB cap, session-only); note that both ends must run
  the same version under "won't connect".
- README.md: add a Version compatibility section (installer version
  tracks Cargo; a 0.x MINOR bump is a breaking wire change so everyone
  must reinstall; 0.3.0 can't talk to 0.2.x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:41:22 -04:00
molluskandClaude Opus 4.8 8ea40f719c packaging(win): bump installer version to 0.3.0
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Match the Cargo 0.3.0 release (chat file sharing + per-peer gate). The
installer payload is unchanged (single self-contained peerspeak.exe +
icon); only the version string / output filename move to 0.3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:39:30 -04:00
molluskandClaude Opus 4.8 60c1951567 Chat file attachments, stages 3-4: core wiring + chat UI
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Wire the send/receive paths and the chat UI on top of the file plane.
(Committed together because the UI renders the state the core wiring
produces.)

Core:
- CoreCommand::SendChatFile {text, attachment, data}: serve the bytes on
  the file plane (serve_attachment) then broadcast the descriptor via
  send_chat. CoreCommand::FetchAttachment {from, attachment}: detached
  fetch -> AttachmentReady/AttachmentFailed.
- On an inbound Chat with an Image attachment, auto-fetch + defensively
  re-validate (decodable + within pixel limits) before delivering;
  non-images wait for an explicit fetch (the Save/Download chip).
- UiEvent::ChatMessage carries the attachment; new AttachmentReady /
  AttachmentFailed events keyed by attachment id.

App:
- 📎 attach button + native picker; reads the file, enforces the size
  cap, classifies image vs file, mints a random id, optimistically
  echoes the message + caches our own bytes (so we see our own image
  inline), and sends SendChatFile.
- Renders inline image thumbnails (handle cached by id to avoid the
  per-redraw re-upload flicker), file chips with Save/Download, a
  loading placeholder for in-flight images, and an error line on
  failure. Image messages with no caption still render.
- SaveAttachment: saves immediately if bytes are in hand, else fetches
  then saves when ready (pending_saves) via a native save dialog;
  filename defaulted from the sanitized descriptor.
- Session-only: attachment bytes/handles cleared on leave, never
  persisted.

Binary + clippy clean, 349 lib tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:15:06 -04:00
molluskandClaude Opus 4.8 f75760b14e Chat file attachments, stage 2: file-transfer network plane
Add the dedicated FILES_ALPN data plane that moves attachment bytes
off-gossip via direct QUIC streams.

- FileRouter (ProtocolHandler on the persistent router, mirroring
  AudioRouter): bound to the active session's Shared on join, cleared on
  leave. On an inbound stream it authenticates the peer via the ALPN
  handshake, gates on live room membership (reuses audio_sender_admitted,
  so a former member cannot pull files), reads exactly one 32-byte
  attachment id (bounded request read), and streams back the matching
  blob from the session serve store — or an empty body for an unknown id.
- Shared gains served_files (id -> bytes), populated by serve_attachment
  and cleared on leave.
- IrohTransport::serve_attachment + fetch_attachment (inherent methods;
  transport is used concretely). fetch dials the sender on FILES_ALPN
  (preferring a known full address), writes the id, and reads bounded by
  the descriptor's declared size, with a 30s connect/read timeout so a
  stalled sender can't hang the fetch.
- Register FILES_ALPN in the router; bind/clear file_router in lock-step
  with audio_router at every join/leave site.

Builds + clippy clean, 349 lib tests pass (plane is runtime I/O,
field-tested in stage 5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:06:12 -04:00
molluskandClaude Opus 4.8 02cb46550e Chat file attachments, stage 1: protocol + data model + pure seams
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>
2026-06-20 23:00:51 -04:00
molluskandClaude Opus 4.8 cbba4b644e Add per-peer listener-side noise gate
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
Let a listener apply a noise gate to an individual peer's incoming
audio — "fix this person's noisy mic / background hum on my end" —
which is only possible because full-mesh P2P keeps every peer's stream
unmixed locally (server-mixed apps can't do per-listener per-peer DSP).

The DSP is the existing mic NoiseGate reused verbatim: it already
processes i16 frames at a fixed rate with hysteresis/attack/release/
hangover and takes the threshold per-frame. Wiring mirrors per-peer EQ:
- AppConfig.peer_gate map (threshold per peer id; absent/0 = off),
  persisted, never sent over the wire
- CoreCommand::SetPeerGate + Arc<Mutex<HashMap>> shared into the mixer
- a live HashMap<EndpointId, NoiseGate> in the mixer task, created
  lazily and dropped when disabled (no rebuild needed — threshold is
  passed per frame)
- Gate row (threshold slider, "Off" at zero) in each participant card
  next to Vol/Pan/EQ, persisting on release

The gate runs on the raw decoded frame: after the clean multitrack stem
tap (recordings stay ungated) but before volume/EQ, so the threshold
tracks the peer's true signal level regardless of our volume setting.
Same 0..METER_MAX scale as the mic gate.

+2 unit tests (config helper); +1 config back-compat assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:18:50 -04:00
molluskandClaude Opus 4.8 c5375e200a Make per-peer in-call volume continuous and persistent
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
The per-peer volume slider had no .step(), so iced's default step of
1.0 on a 0.0..=2.0 range meant it could only snap to 0%, 100%, or
200% — it felt like hard-left/hard-right only. Add .step(0.01) for
smooth 1%-increment control (matching the Pan slider below it, which
already set its own step).

Also persist per-peer volume across sessions, mirroring peer_pan/peer_eq:
- new AppConfig.peer_volume map (keyed by peer id string, serde default
  for back-compat; never sent over the wire)
- replace the in-memory peer_volumes map with config-backed storage via
  a new set_peer_volume_config helper (clamps to range, drops at-unity
  entries so the config stays tidy)
- replay saved volumes to core on startup alongside pan/eq
- the slider writes to disk on release (AppMessage::PersistConfig)

+1 unit test for the config helper; +1 config back-compat assertion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:29:18 -04:00
mollusk 06e97b9f50 Merge W16 room background fix
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-20 17:57:20 -04:00
mollusk 601ec92181 Show custom background in room view 2026-06-20 17:57:16 -04:00
mollusk 713526b2a8 Merge W19 Ayu themes 2026-06-20 17:54:40 -04:00
mollusk 450121b591 Merge W16 custom backgrounds 2026-06-20 17:54:36 -04:00
mollusk eab9357f23 Add custom background settings controls 2026-06-20 17:54:27 -04:00
molluskandClaude Opus 4.8 70a0e6798f W16 custom backgrounds: core + render layer (Settings UI pending)
Pure src/background.rs (process_background downscale→PNG, scrim_color; 5 tests),
AppConfig.background/background_dim + background_path(), cached AppState.background_image,
PickBackgroundFile/BackgroundFilePicked/RemoveBackground/SetBackgroundDim handlers,
view_with_background stack(image Cover→scrim→ui) + transparent screen roots.
Lib builds clean. Remaining: Settings UI controls + full build/clippy/test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:29:09 -04:00
18 changed files with 1451 additions and 40 deletions
+21
View File
@@ -0,0 +1,21 @@
# PeerSpeak Codebase Layout and Architecture Rules
When working in the PeerSpeak repository, adhere to the following architectural boundaries and layout:
## Code Layout
- `src/main.rs`: The application entry point (initializes Tokio and the Iced GUI).
- `src/app/`: The UI layer (Iced). Handles themes, views (Home, Room, Settings), and visual state. Must communicate with the core via message passing (`UiEvent`/`CoreCommand`), not direct function calls.
- `src/core/`: The central orchestrator.
- `mod.rs`: Manages the session lifecycle, ties together network and UI, and manages the async mixer tasks.
- `jitter.rs`: Houses the adaptive playout delay JitterBuffer and Packet Loss Concealment (PLC) logic.
- `src/network/`: The "Dual-Plane" transport layer.
- `gossip.rs` (Control Plane): Built on `iroh-gossip`. Manages room rosters, verified membership, presence, and chat via cryptographically signed envelopes.
- `iroh_impl.rs` (Data Plane): Manages raw QUIC endpoints and peer connections. Forwards UDP voice datagrams directly to peers for minimum latency.
- `src/audio/`: Hardware audio backends.
- Interfaces heavily with `cpal_impl.rs` (Windows/WASAPI) and `pipewire_impl.rs` (Linux).
- **CRITICAL RULE**: The RT audio callbacks are strictly lock-free. They communicate with the async core exclusively via Single-Producer Single-Consumer (SPSC) ring buffers (`HeapRb`). Never allocate memory, log to stdout, or lock Mutexes on the RT threads.
- `src/codec/`: Audio compression abstractions, standardizing on Opus at 48kHz mono (`opus_impl.rs`).
## General Directives
- **Security**: Audio admission is strictly derived from the verified gossip roster (S8). Never trust raw UDP sender IDs without validating against gossip.
- **Latency**: Preserve the deterministic dialer vs acceptor logic in the QUIC layer to prevent connection loops.
Generated
+1 -1
View File
@@ -4742,7 +4742,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.2.0"
version = "0.3.0"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.2.0"
version = "0.3.0"
edition = "2024"
# Application crate, not a crates.io library — refuse `cargo publish` and let
# cargo-deny's [licenses.private] skip the missing-license check.
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.1.0
pkgver=0.2.0.r218.gcbba4b6
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
+28 -2
View File
@@ -8,7 +8,7 @@ it once, then you and I connect directly to each other.
## 1. Install it
1. Double-click **`peerspeak-0.2.0-setup.exe`** (the file I sent you).
1. Double-click **`peerspeak-0.3.0-setup.exe`** (the file I sent you).
2. **Windows will probably show a blue "Windows protected your PC" warning.**
This is normal — it shows up for any app that isn't from a big company with a
@@ -69,13 +69,39 @@ Either way works the same; it just depends on who makes the room.
---
## 4. Chatting and sharing photos/files
There's a **text chat** box at the bottom of the call window — type a message
and press **Enter** to send it to everyone in the room.
You can also **send a photo or a file**:
1. Click the **attach button** (the small paperclip-style button) next to the
message box.
2. Pick a photo or file from your computer.
3. It sends to everyone in the room. **Photos show up right in the chat**;
other files appear as a small download chip with the file's name.
To **save** a file someone sent you, click the **Save** (or **Download**)
button next to it in the chat and choose where to put it.
A couple of notes:
- There's a size limit of about **25 MB** per file — bigger files are turned
away with a message.
- Shared files only last for the **current call**. They aren't saved anywhere
automatically, so save anything you want to keep before you leave the room.
---
## Troubleshooting
- **"I don't hear anything."** Open Settings and pick the correct microphone and
output device. Headphones are best — they prevent echo.
- **"It won't connect."** Make sure you pasted the *entire* ticket (they're
long and easy to cut off). If it still won't connect, we may just need a fresh
ticket — they're meant to be used right away.
ticket — they're meant to be used right away. Also make sure we're both on the
**same version** — if I've sent you an updated installer, install it (an old
version and a new one can't connect to each other).
- **The blue warning again.** Same as install: **More info → Run anyway**. It's
the unsigned-app warning, not malware.
+12
View File
@@ -9,6 +9,18 @@ notification chimes, and avatar presets are all embedded in the binary
runtime, so there are no extra DLLs to bundle. The installer payload is just the
`.exe` plus an `.ico` for the Start-menu / desktop shortcuts.
## Version compatibility
The installer version tracks the crate version in `Cargo.toml` (currently
**0.3.0**) — keep `MyAppVersion` in `peerspeak.iss` in sync when it changes.
Per `VERSIONING.md`, a **MINOR** bump in `0.x` is a **breaking wire change**:
peers on different MINOR versions can't connect (they fail fast at the
handshake rather than misbehaving). So when you ship a new Windows build after
a MINOR bump, **everyone on the call must reinstall** — an old Windows build
and a newer Linux/Windows peer won't talk. (0.3.0 was the chat file-sharing +
per-peer noise-gate release; it cannot connect to a 0.2.x peer.)
## Files
| File | Tracked | Purpose |
+1 -1
View File
@@ -12,7 +12,7 @@
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
#define MyAppName "PeerSpeak"
#define MyAppVersion "0.2.0"
#define MyAppVersion "0.3.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+535 -11
View File
@@ -114,6 +114,19 @@ struct ChatEntry {
/// Sender's node id string, used to key their avatar colour (W4). `None` only
/// for any future system-generated lines.
from: Option<String>,
/// Optional file attachment descriptor. The bytes (if fetched) live in
/// `AppState.attachment_data` keyed by `attachment.id`; the entry only holds
/// the descriptor so history stays cheap.
attachment: Option<crate::files::ChatAttachment>,
}
/// Fetch state of a chat attachment's bytes (session-only).
#[derive(Debug, Clone)]
enum AttachmentState {
/// Bytes in hand (image decoded-valid, or a file ready to save).
Ready(Vec<u8>),
/// Fetch or decode failed; carries a short reason for the UI.
Failed(String),
}
/// Cap on retained chat history so a long call can't grow it without bound.
@@ -208,6 +221,7 @@ pub enum AppMessage {
ClearHotkey(HotkeyAction),
PeerVolumeChanged(EndpointId, f32),
PeerPanChanged(EndpointId, f32),
PeerGateChanged(EndpointId, f32),
PeerEqChanged(EndpointId, EqBand, f32),
/// Toggle local mute of a peer (silence them just for us).
TogglePeerMute(EndpointId),
@@ -263,6 +277,12 @@ pub enum AppMessage {
ToggleRecording,
/// Live edits to the chat input line.
ChatInputChanged(String),
/// Open the native picker to attach a file to the chat.
PickAttachmentFile,
/// Result of the attach picker: (filename, bytes), or None if cancelled.
AttachmentFilePicked(Option<(String, Vec<u8>)>),
/// Save (downloading first if needed) a received attachment to disk.
SaveAttachment(crate::files::AttachmentId),
/// Send the current chat input line (Enter or the Send button).
ChatSubmit,
/// Open a clicked chat link in the system browser (A13).
@@ -290,6 +310,15 @@ pub enum AppMessage {
/// Result of the avatar file picker: the chosen file's raw bytes, or `None`
/// if the user cancelled.
AvatarFilePicked(Option<Vec<u8>>),
/// Open the native file picker to choose a custom UI background image (W16).
PickBackgroundFile,
/// Result of the background file picker: the chosen file's raw bytes, or
/// `None` if the user cancelled.
BackgroundFilePicked(Option<Vec<u8>>),
/// Clear the custom background, reverting to the theme background (W16).
RemoveBackground,
/// Set the background legibility scrim strength (0.0..=1.0) (W16).
SetBackgroundDim(f32),
/// Toggle the Chat drawer open/closed (drawer layout).
ToggleDrawerChat,
/// Start/stop sharing our own screen (spawns/kills a pixelpass host).
@@ -335,8 +364,11 @@ pub struct AppState {
selected_input: Option<AudioDevice>,
selected_output: Option<AudioDevice>,
config: AppConfig,
/// Decoded bytes of the custom background image (W16), cached so `view()`
/// doesn't read the file from disk on every redraw. Loaded on startup and
/// refreshed when the background is changed/removed. `None` = no custom bg.
background_image: Option<bytes::Bytes>,
peers: HashMap<EndpointId, PeerState>,
peer_volumes: HashMap<EndpointId, f32>,
audio_levels: HashMap<EndpointId, f32>,
/// Peers we've locally muted (their audio isn't mixed into our output).
locally_muted: HashSet<EndpointId>,
@@ -349,6 +381,15 @@ pub struct AppState {
/// Room text-chat history (newest last) and the pending input line.
chat_messages: Vec<ChatEntry>,
chat_input: String,
/// Fetched/failed state for chat attachments, keyed by attachment id.
/// Session-only (cleared on leave); never persisted.
attachment_data: HashMap<crate::files::AttachmentId, AttachmentState>,
/// Cached iced image handles for ready image attachments, keyed by id, so we
/// don't re-upload to the GPU every redraw (the e917c53 avatar flicker fix).
image_handle_cache: HashMap<crate::files::AttachmentId, iced::widget::image::Handle>,
/// Attachment ids the user asked to save before the bytes arrived; when the
/// fetch completes a save dialog is opened for them.
pending_saves: std::collections::HashSet<crate::files::AttachmentId>,
/// Last known window size, tracked so divider clamps stay valid on resize.
/// (The divider positions themselves are persisted in `config`.)
window_size: Size,
@@ -461,6 +502,16 @@ impl Default for AppState {
let _ = controller.send(CoreCommand::SetPeerPan(id, *pan));
}
}
for (peer, volume) in &config.peer_volume {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerVolume(id, *volume));
}
}
for (peer, threshold) in &config.peer_gate {
if let Ok(id) = peer.parse::<EndpointId>() {
let _ = controller.send(CoreCommand::SetPeerGate(id, *threshold));
}
}
let pixelpass_available =
crate::screenshare::is_available(config.pixelpass_path.as_deref());
let all_devices = enumerate_audio_devices();
@@ -470,6 +521,7 @@ impl Default for AppState {
let selected_input = input_devices.iter().find(|d| d.name == config.input_device).cloned();
let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned();
let background_image = load_background_bytes(&config);
Self {
// Pre-fill the nickname with the last one used (or "Peer" by default).
@@ -489,14 +541,17 @@ impl Default for AppState {
selected_input,
selected_output,
config,
background_image,
peers: HashMap::new(),
peer_volumes: HashMap::new(),
audio_levels: HashMap::new(),
locally_muted: HashSet::new(),
call_started: None,
recording: false,
recording_started: None,
chat_messages: Vec::new(),
attachment_data: HashMap::new(),
image_handle_cache: HashMap::new(),
pending_saves: std::collections::HashSet::new(),
chat_input: String::new(),
window_size: Size::new(ww, wh),
layout_picker_open: false,
@@ -533,6 +588,15 @@ fn theme(state: &AppState) -> Theme {
state.config.theme.base_theme()
}
/// Read the custom background PNG (W16) from disk into memory, if one is set and
/// readable. Called once on startup and whenever the background changes, so the
/// per-frame `view()` never touches the filesystem. A missing/unreadable file
/// silently yields `None` (the UI falls back to the theme background).
fn load_background_bytes(config: &AppConfig) -> Option<bytes::Bytes> {
let path = config.background.as_deref()?;
std::fs::read(path).ok().map(bytes::Bytes::from)
}
pub fn run_gui() -> iced::Result {
// Restore the last window size (saved on close). Position is restored too,
// but only on X11 — Wayland's xdg-shell gives clients no way to set their own
@@ -540,7 +604,7 @@ pub fn run_gui() -> iced::Result {
let saved = AppConfig::load();
let init_size = iced::Size::new(saved.window_width, saved.window_height);
let init_position = initial_window_position(saved.window_x, saved.window_y, is_wayland());
iced::application(AppState::default, update, view)
iced::application(AppState::default, update, view_with_background)
.title("PeerSpeak P2P Voice Chat")
.theme(theme)
.subscription(subscription)
@@ -720,6 +784,34 @@ fn set_peer_pan_config(config: &mut AppConfig, id: EndpointId, pan: f32) -> f32
pan
}
/// Store the per-peer listener volume, clamped to the slider range. Unity gain
/// (`1.0`) is the implicit default, so an at-unity entry is removed rather than
/// stored to keep the config tidy. Returns the clamped value.
fn set_peer_volume_config(config: &mut AppConfig, id: EndpointId, volume: f32) -> f32 {
let volume = volume.clamp(0.0, 2.0);
let key = id.to_string();
if (volume - 1.0).abs() <= 0.001 {
config.peer_volume.remove(&key);
} else {
config.peer_volume.insert(key, volume);
}
volume
}
/// Store the per-peer listener noise-gate threshold, clamped to the slider
/// range. `0.0` means the gate is off, so an at-zero entry is removed rather
/// than stored. Returns the clamped value.
fn set_peer_gate_config(config: &mut AppConfig, id: EndpointId, threshold: f32) -> f32 {
let threshold = threshold.clamp(0.0, METER_MAX);
let key = id.to_string();
if threshold <= 0.0 {
config.peer_gate.remove(&key);
} else {
config.peer_gate.insert(key, threshold);
}
threshold
}
fn peer_eq_settings(config: &AppConfig, id: &EndpointId) -> EqSettings {
config
.peer_eq
@@ -935,19 +1027,45 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.recording_started = None;
state.status_message = format!("Saved recording → {path}");
}
UiEvent::ChatMessage { from, name, text } => {
UiEvent::ChatMessage { from, name, text, attachment } => {
// Incoming peer content is untrusted — sanitize name + text.
// (The attachment filename was already sanitized in core.)
let text = sanitize_chat(&text);
if !text.is_empty() {
// Keep the message if it has visible text OR an attachment (an
// image with no caption is still a real message).
if !text.is_empty() || attachment.is_some() {
let name = sanitize_chat(&name);
push_chat(&mut state.chat_messages, ChatEntry {
name,
text,
mine: false,
from: Some(from),
attachment,
});
}
}
UiEvent::AttachmentReady { id, data } => {
// Bytes arrived. For images we can cache the iced handle now
// (built once, not per redraw). If the user was waiting to save
// this file, the save dialog is opened from update() below by
// checking pending_saves — done lazily so this arm stays simple.
if crate::files::validate_image_bytes(&data).is_some() {
state.image_handle_cache.insert(
id,
iced::widget::image::Handle::from_bytes(data.clone()),
);
}
let needs_save = state.pending_saves.remove(&id);
state.attachment_data.insert(id, AttachmentState::Ready(data));
if needs_save {
save_attachment_to_disk(state, id);
}
}
UiEvent::AttachmentFailed { id, error } => {
state.pending_saves.remove(&id);
state.attachment_data.insert(id, AttachmentState::Failed(error.clone()));
state.status_message = format!("Attachment failed: {error}");
}
UiEvent::ScreenShareStarted => {
state.self_sharing = true;
state.status_message = "Sharing your screen".to_string();
@@ -1014,13 +1132,17 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.config.save();
}
AppMessage::PeerVolumeChanged(id, vol) => {
state.peer_volumes.insert(id, vol);
let vol = set_peer_volume_config(&mut state.config, id, vol);
let _ = state.controller.send(CoreCommand::SetPeerVolume(id, vol));
}
AppMessage::PeerPanChanged(id, pan) => {
let pan = set_peer_pan_config(&mut state.config, id, pan);
let _ = state.controller.send(CoreCommand::SetPeerPan(id, pan));
}
AppMessage::PeerGateChanged(id, threshold) => {
let threshold = set_peer_gate_config(&mut state.config, id, threshold);
let _ = state.controller.send(CoreCommand::SetPeerGate(id, threshold));
}
AppMessage::PeerEqChanged(id, band, gain_db) => {
let settings = set_peer_eq_config(&mut state.config, id, band, gain_db);
let _ = state.controller.send(CoreCommand::SetPeerEq(id, settings));
@@ -1347,6 +1469,73 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
}
}
}
AppMessage::PickBackgroundFile => {
// Native picker off the UI thread; result returns as BackgroundFilePicked.
return Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.add_filter("Images", &["png", "jpg", "jpeg", "webp", "bmp"])
.set_title("Choose a background image")
.pick_file()
.await;
match handle {
Some(h) => Some(h.read().await),
None => None,
}
},
AppMessage::BackgroundFilePicked,
);
}
AppMessage::BackgroundFilePicked(picked) => {
if let Some(bytes) = picked {
match crate::background::process_background(&bytes) {
Ok(png) => match AppConfig::background_path() {
Some(path) => {
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
match std::fs::write(&path, &png) {
Ok(()) => {
state.config.background =
Some(path.to_string_lossy().into_owned());
state.config.save();
// Refresh the in-memory cache from the bytes we
// just wrote (avoids re-reading from disk).
state.background_image = Some(bytes::Bytes::from(png));
state.status_message = "Background updated.".to_string();
}
Err(e) => {
state.status_message =
format!("Couldn't save background: {e}");
}
}
}
None => {
state.status_message =
"Couldn't find a config directory to save the background."
.to_string();
}
},
Err(e) => {
state.status_message = e;
}
}
}
}
AppMessage::RemoveBackground => {
// Best-effort delete of our stored copy; clear the config + cache.
if let Some(path) = AppConfig::background_path() {
let _ = std::fs::remove_file(path);
}
state.config.background = None;
state.config.save();
state.background_image = None;
state.status_message = "Background removed.".to_string();
}
AppMessage::SetBackgroundDim(dim) => {
state.config.background_dim = dim.clamp(0.0, 1.0);
state.config.save();
}
AppMessage::ToggleDrawerChat => {
state.drawer_chat_open = !state.drawer_chat_open;
}
@@ -1359,11 +1548,93 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
text: text.clone(),
mine: true,
from: Some(state.self_id.clone()),
attachment: None,
});
let _ = state.controller.send(CoreCommand::SendChat(text));
state.chat_input.clear();
}
}
AppMessage::PickAttachmentFile => {
// Native picker off the UI thread; returns (filename, bytes).
return Task::perform(
async {
let handle = rfd::AsyncFileDialog::new()
.set_title("Attach a file to the chat")
.pick_file()
.await;
match handle {
Some(h) => Some((h.file_name(), h.read().await)),
None => None,
}
},
AppMessage::AttachmentFilePicked,
);
}
AppMessage::AttachmentFilePicked(picked) => {
if let Some((name, bytes)) = picked {
let size = bytes.len() as u64;
if !crate::files::size_within_cap(size) {
state.status_message = format!(
"File too large — max {}.",
crate::files::human_size(crate::files::MAX_ATTACHMENT_BYTES)
);
return Task::none();
}
let kind = crate::files::classify(&bytes);
// Random 32-byte handle for this attachment.
let id: crate::files::AttachmentId = rand::random();
let att = crate::files::ChatAttachment {
name: crate::files::sanitize_filename(&name),
size,
kind,
id,
};
// Keep our own bytes locally so we see our own attachment inline
// immediately (others fetch it off the file plane).
if kind == crate::files::AttachmentKind::Image
&& crate::files::validate_image_bytes(&bytes).is_some()
{
state
.image_handle_cache
.insert(id, iced::widget::image::Handle::from_bytes(bytes.clone()));
}
state
.attachment_data
.insert(id, AttachmentState::Ready(bytes.clone()));
push_chat(
&mut state.chat_messages,
ChatEntry {
name: format!("{} (You)", state.name),
text: String::new(),
mine: true,
from: Some(state.self_id.clone()),
attachment: Some(att.clone()),
},
);
let _ = state.controller.send(CoreCommand::SendChatFile {
text: String::new(),
attachment: att,
data: bytes,
});
}
}
AppMessage::SaveAttachment(id) => {
// If we already have the bytes, save now; otherwise fetch from the
// sender and save when AttachmentReady arrives (pending_saves).
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
save_attachment_to_disk(state, id);
} else if let Some((from, att)) = find_attachment_source(state, id) {
if let Ok(eid) = from.parse::<EndpointId>() {
state.pending_saves.insert(id);
state.status_message = format!("Downloading {}", att.name);
let _ = state
.controller
.send(CoreCommand::FetchAttachment { from: eid, attachment: att });
} else {
state.status_message = "Can't download: unknown sender.".to_string();
}
}
}
AppMessage::OpenUrl(url) => {
// Defence in depth: only ever hand http(s) URLs to the opener. The
// link span's href came from `linkify`, which only emits http/https,
@@ -1597,6 +1868,54 @@ fn push_chat(messages: &mut Vec<ChatEntry>, entry: ChatEntry) {
}
}
/// Find the sender id + descriptor for a received attachment by its id, so a
/// fetch can be addressed. Returns `None` for our own attachments or an unknown
/// id.
fn find_attachment_source(
state: &AppState,
id: crate::files::AttachmentId,
) -> Option<(String, crate::files::ChatAttachment)> {
state.chat_messages.iter().find_map(|m| {
let att = m.attachment.as_ref()?;
if att.id == id && !m.mine {
Some((m.from.clone()?, att.clone()))
} else {
None
}
})
}
/// Write a ready attachment's bytes to a user-chosen location via a native save
/// dialog. The default filename comes from the (already-sanitized) descriptor.
/// No-op if the bytes aren't ready. Sync dialog: the brief block is acceptable
/// for a deliberate save action.
fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) {
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
return;
};
let data = data.clone();
let default_name = state
.chat_messages
.iter()
.find_map(|m| {
m.attachment
.as_ref()
.filter(|a| a.id == id)
.map(|a| a.name.clone())
})
.unwrap_or_else(|| "download".to_string());
if let Some(path) = rfd::FileDialog::new()
.set_file_name(default_name)
.set_title("Save attachment")
.save_file()
{
match std::fs::write(&path, &data) {
Ok(()) => state.status_message = format!("Saved {}", path.display()),
Err(e) => state.status_message = format!("Save failed: {e}"),
}
}
}
fn horizontal_space() -> iced::widget::Space {
iced::widget::Space::new().width(iced::Length::Fill)
}
@@ -2018,6 +2337,40 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
.into()
}
/// Wrap the main [`view`] with the custom background layer (W16). When a
/// background image is set, render `stack![ image(Cover), scrim, ui ]` so the
/// photo sits behind the whole UI with a legibility scrim (the theme base colour
/// at `background_dim` alpha) between them; otherwise return the UI untouched. The
/// three screen roots go transparent (`root_bg` in `view`) so the image shows
/// through the gaps between panels. This is the registered top-level view.
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
let content = view(state);
let Some(bytes) = state.background_image.clone() else {
return content;
};
let pal = state.config.theme.palette();
let dim = state.config.background_dim;
let image_layer = iced::widget::image(cached_image_handle(bytes))
.content_fit(iced::ContentFit::Cover)
.width(iced::Length::Fill)
.height(iced::Length::Fill);
let scrim = container(
iced::widget::Space::new()
.width(iced::Length::Fill)
.height(iced::Length::Fill),
)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))),
..Default::default()
});
iced::widget::stack![image_layer, scrim, content]
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.into()
}
fn view(state: &AppState) -> Element<'_, AppMessage> {
// Theme colours — sourced from the active palette (see `src/theme.rs`), so
// all styling below re-themes when the user picks a different theme.
@@ -2036,6 +2389,16 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
let color_green = pal.green;
let color_yellow = pal.yellow;
// The window backdrop fill for the three screen roots. When a custom
// background image is set (W16), the root goes transparent so the image +
// scrim layered behind by `view_with_background` shows through the gaps
// between panels; otherwise it's the usual opaque `crust`.
let root_bg = if state.background_image.is_some() {
Color::TRANSPARENT
} else {
color_crust
};
// Style Helpers
let c_style = move |bg: Color, b_color: Color, radius: f32| {
move |_theme: &Theme| container::Style {
@@ -2297,6 +2660,35 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(10)
.width(iced::Length::Fill);
let remove_background: Element<'_, AppMessage> = if state.config.background.is_some() {
button(text("Remove background").size(13))
.on_press(AppMessage::RemoveBackground)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8)
.into()
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
let background_section = column![
row![
button(text("Choose image…").size(13))
.on_press(AppMessage::PickBackgroundFile)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(8),
remove_background,
].spacing(8),
text(format!("Background dimming: {:.0}%", state.config.background_dim * 100.0))
.size(11)
.color(color_subtext),
slider(0.0..=1.0, state.config.background_dim, AppMessage::SetBackgroundDim)
.step(0.05),
text("Set a picture from your computer as the app background. Auto-resized; a dimming overlay keeps text readable. Applies live.")
.size(11)
.color(color_subtext),
]
.spacing(10)
.width(iced::Length::Fill);
// Inline avatar chooser (W4): the monogram fallback plus the bundled
// presets, each a clickable tile. Same SelectAvatar message, applied live
// + persisted (and re-announced to the room).
@@ -2645,6 +3037,9 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
vertical_space(section_gap),
section_header("Theme"),
theme_section,
vertical_space(section_gap),
section_header("Background"),
background_section,
]
.spacing(10)
.width(iced::Length::Fill)
@@ -2830,7 +3225,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill)
.padding(24)
.center_x(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
return with_regenerate_confirm(settings_screen.into(), state);
}
@@ -2889,7 +3284,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
with_hotkey_info(with_layout_picker(home.into(), state), state)
} else {
@@ -3181,11 +3576,18 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
].spacing(8);
// Peer volume slider
let current_vol = state.peer_volumes.get(peer_id).copied().unwrap_or(1.0);
let current_vol = state
.config
.peer_volume
.get(&peer_id.to_string())
.copied()
.unwrap_or(1.0);
card_content = card_content.push(
row![
text("Vol:").size(12).color(color_subtext),
slider(0.0..=2.0, current_vol, move |v| AppMessage::PeerVolumeChanged(peer_id_clone, v))
.step(0.01)
.on_release(AppMessage::PersistConfig)
].spacing(8).align_y(iced::alignment::Vertical::Center)
);
@@ -3204,6 +3606,28 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.align_y(iced::alignment::Vertical::Center),
);
// Peer noise gate: suppress this peer's background noise on our end.
// Threshold is normalized RMS on the same 0..METER_MAX scale as the
// mic gate; 0 = off.
let current_gate = state.config.peer_gate.get(&peer_key).copied().unwrap_or(0.0);
let gate_label = if current_gate <= 0.0 {
"Off".to_string()
} else {
format!("{:.0}%", (current_gate / METER_MAX * 100.0).clamp(0.0, 100.0))
};
card_content = card_content.push(
row![
text("Gate:").size(12).color(color_subtext),
container(text(gate_label).size(11).color(color_subtext))
.width(iced::Length::Fixed(58.0)),
slider(0.0..=METER_MAX, current_gate, move |v| AppMessage::PeerGateChanged(peer_id_clone, v))
.step(0.001)
.on_release(AppMessage::PersistConfig),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center),
);
let eq = peer_eq_settings(&state.config, peer_id);
let eq_row = |label: &'static str, band: EqBand, value: f32| -> Element<'_, AppMessage> {
row![
@@ -3456,6 +3880,59 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(8)
.align_y(iced::alignment::Vertical::Top),
);
// Attachment row (indented under the message), if any.
if let Some(att) = &m.attachment {
let data = state.attachment_data.get(&att.id);
let elem: Element<'_, AppMessage> =
if let Some(AttachmentState::Failed(e)) = data {
text(format!("{}{e}", att.name))
.size(12)
.color(color_red)
.into()
} else if att.kind == crate::files::AttachmentKind::Image {
match state.image_handle_cache.get(&att.id) {
Some(handle) => iced::widget::image(handle.clone())
.width(iced::Length::Fixed(260.0))
.into(),
None => text(format!("🖼 {} — loading…", att.name))
.size(12)
.color(color_subtext)
.into(),
}
} else {
let ready =
matches!(data, Some(AttachmentState::Ready(_)));
let btn_label = if ready { "Save" } else { "Download" };
row![
text(format!(
"📎 {} ({})",
att.name,
crate::files::human_size(att.size)
))
.size(12)
.color(color_text),
button(text(btn_label).size(12))
.on_press(AppMessage::SaveAttachment(att.id))
.style(b_style(
color_blue,
color_lavender,
color_crust,
6.0,
))
.padding(6),
]
.spacing(8)
.align_y(iced::alignment::Vertical::Center)
.into()
};
chat_col = chat_col.push(
row![
iced::widget::Space::new().width(iced::Length::Fixed(30.0)),
elem
]
.spacing(0),
);
}
}
}
let chat_scroll = scrollable(chat_col)
@@ -3463,6 +3940,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.height(iced::Length::Fill)
.anchor_bottom();
let chat_input_row = row![
button(text("📎").size(15))
.on_press(AppMessage::PickAttachmentFile)
.style(b_style(color_surface, color_overlay, color_text, 6.0))
.padding(8),
text_input("Message the room…", &state.chat_input)
.on_input(AppMessage::ChatInputChanged)
.on_submit(AppMessage::ChatSubmit)
@@ -3590,7 +4071,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.padding(15)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.style(c_style(color_crust, Color::TRANSPARENT, 0.0));
.style(c_style(root_bg, Color::TRANSPARENT, 0.0));
with_hotkey_info(
with_pixelpass_help(with_layout_picker(room.into(), state), state),
@@ -4733,8 +5214,48 @@ impl Program<AppMessage> for Icon {
mod tests {
use super::{
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
GateMeter, METER_MAX,
set_peer_gate_config, set_peer_volume_config, AppConfig, GateMeter, METER_MAX,
};
use iroh::SecretKey;
#[test]
fn peer_gate_persists_when_on_and_clears_when_off() {
let mut config = AppConfig::default();
let id = SecretKey::generate().public();
// A positive threshold is stored, clamped to the slider's METER_MAX ceiling.
let stored = set_peer_gate_config(&mut config, id, 0.05);
assert_eq!(stored, 0.05);
assert_eq!(config.peer_gate.get(&id.to_string()).copied(), Some(0.05));
assert_eq!(set_peer_gate_config(&mut config, id, 99.0), METER_MAX);
// Zero (or negative) means "gate off" — the entry is removed so the
// config doesn't carry a disabled gate.
let stored = set_peer_gate_config(&mut config, id, 0.0);
assert_eq!(stored, 0.0);
assert!(!config.peer_gate.contains_key(&id.to_string()));
}
#[test]
fn peer_volume_persists_non_unity_and_clears_at_unity() {
let mut config = AppConfig::default();
let id = SecretKey::generate().public();
// A non-unity value is clamped into range and stored.
let stored = set_peer_volume_config(&mut config, id, 1.5);
assert_eq!(stored, 1.5);
assert_eq!(config.peer_volume.get(&id.to_string()).copied(), Some(1.5));
// Out-of-range values clamp to the slider bounds.
assert_eq!(set_peer_volume_config(&mut config, id, 5.0), 2.0);
assert_eq!(set_peer_volume_config(&mut config, id, -1.0), 0.0);
// Returning to unity removes the entry (unity is the implicit default),
// so the config doesn't accumulate no-op entries.
let stored = set_peer_volume_config(&mut config, id, 1.0);
assert_eq!(stored, 1.0);
assert!(!config.peer_volume.contains_key(&id.to_string()));
}
#[test]
fn x11_restores_saved_window_position() {
@@ -5009,6 +5530,7 @@ mod tests {
text: "Hello".to_string(),
mine: true,
from: None,
attachment: None,
};
push_chat(&mut messages, entry);
assert_eq!(messages.len(), 1);
@@ -5029,6 +5551,7 @@ mod tests {
text: format!("Msg{}", i),
mine: i % 2 == 0,
from: None,
attachment: None,
},
);
}
@@ -5052,6 +5575,7 @@ mod tests {
text: format!("Msg{}", i),
mine: i % 2 == 0,
from: None,
attachment: None,
},
);
}
+107
View File
@@ -0,0 +1,107 @@
//! Custom UI background (W16): turn a user-picked image into a capped PNG to
//! render behind the whole UI, plus the legibility scrim drawn over it.
//!
//! This is the pure, unit-testable seam — `process_background` decodes/downscales
//! arbitrary input defensively (same caution as avatar uploads) and `scrim_color`
//! computes the overlay tint. The file I/O, the `rfd` picker, and the iced
//! `stack!` that layers image → scrim → UI all live at the app edge in
//! `src/app/mod.rs`. The background is **local-only** — never sent to peers — so
//! there's no gossip-frame budget here (hence a much larger size cap than avatars).
use iced::Color;
/// Longest side a custom background is downscaled to on ingest (aspect preserved,
/// never upscaled). Big enough to look crisp filling the window, small enough to
/// decode and cache cheaply. Local-only, so this is generous vs. the avatar cap.
pub const BACKGROUND_MAX_PX: u32 = 1920;
/// Default scrim strength. `0.0` = the image shows at full strength, `1.0` = it's
/// fully hidden behind the theme's base colour. Half keeps a photo clearly visible
/// while text and cards stay readable over it.
pub const DEFAULT_DIM: f32 = 0.5;
/// Decode an arbitrary user image (png/jpeg/…), downscale so its longest side is
/// at most [`BACKGROUND_MAX_PX`] (aspect preserved; smaller images are left as-is,
/// never upscaled), and re-encode as PNG bytes ready to write to disk. Decoding is
/// bounded by the `image` crate's defaults so a malformed/huge file is rejected
/// rather than exhausting memory. Errors come back as a message for the UI.
pub fn process_background(raw: &[u8]) -> Result<Vec<u8>, String> {
let img = image::load_from_memory(raw).map_err(|e| format!("Couldn't read image: {e}"))?;
// Only ever shrink. `resize` preserves aspect, fitting within the box; a
// higher-quality filter than `thumbnail` since a background fills the window.
let scaled = if img.width() > BACKGROUND_MAX_PX || img.height() > BACKGROUND_MAX_PX {
img.resize(
BACKGROUND_MAX_PX,
BACKGROUND_MAX_PX,
image::imageops::FilterType::Lanczos3,
)
} else {
img
};
let mut png = std::io::Cursor::new(Vec::new());
scaled
.write_to(&mut png, image::ImageFormat::Png)
.map_err(|e| format!("Couldn't encode image: {e}"))?;
Ok(png.into_inner())
}
/// The legibility scrim drawn between the background image and the UI: the active
/// theme's base colour at `dim` alpha (clamped to `0.0..=1.0`). A higher `dim`
/// recedes the image so body text and panel chrome stay readable, and it re-tints
/// per theme since `base` comes from the active palette.
pub fn scrim_color(base: Color, dim: f32) -> Color {
Color { a: dim.clamp(0.0, 1.0), ..base }
}
#[cfg(test)]
mod tests {
use super::*;
/// A valid PNG of the given size, as raw bytes (test helper).
fn make_png(w: u32, h: u32) -> Vec<u8> {
let img = image::DynamicImage::new_rgb8(w, h);
let mut buf = std::io::Cursor::new(Vec::new());
img.write_to(&mut buf, image::ImageFormat::Png).unwrap();
buf.into_inner()
}
#[test]
fn process_background_downscales_oversized() {
// A 4000x2000 image is shrunk so the longest side is BACKGROUND_MAX_PX,
// aspect preserved, and the result re-decodes as a PNG within bounds.
let raw = make_png(4000, 2000);
let png = process_background(&raw).expect("should process");
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!(decoded.width().max(decoded.height()), BACKGROUND_MAX_PX);
assert_eq!(decoded.width(), BACKGROUND_MAX_PX);
assert_eq!(decoded.height(), BACKGROUND_MAX_PX / 2); // 2:1 aspect kept
}
#[test]
fn process_background_leaves_small_images_unscaled() {
let raw = make_png(640, 480);
let png = process_background(&raw).expect("should process");
let decoded = image::load_from_memory(&png).unwrap();
assert_eq!((decoded.width(), decoded.height()), (640, 480));
}
#[test]
fn process_background_rejects_non_image() {
assert!(process_background(b"definitely not an image").is_err());
}
#[test]
fn scrim_color_sets_alpha_and_keeps_rgb() {
let base = Color::from_rgb(0.1, 0.2, 0.3);
let s = scrim_color(base, 0.5);
assert_eq!((s.r, s.g, s.b), (0.1, 0.2, 0.3));
assert!((s.a - 0.5).abs() < f32::EPSILON);
}
#[test]
fn scrim_color_clamps_dim() {
let base = Color::BLACK;
assert!((scrim_color(base, -1.0).a - 0.0).abs() < f32::EPSILON);
assert!((scrim_color(base, 2.0).a - 1.0).abs() < f32::EPSILON);
}
}
+40
View File
@@ -106,6 +106,10 @@ fn default_true() -> bool {
true
}
fn default_background_dim() -> f32 {
crate::background::DEFAULT_DIM
}
fn default_volume() -> f32 {
1.0
}
@@ -185,6 +189,16 @@ pub struct AppConfig {
/// Our chosen avatar (W4): monogram fallback or a bundled preset.
#[serde(default)]
pub avatar: crate::avatar::Avatar,
/// Custom UI background image (W16): path to the downscaled PNG we wrote into
/// the config dir (see `background_path`). `None` = use the theme background.
/// Local-only; never sent to peers.
#[serde(default)]
pub background: Option<String>,
/// Scrim strength drawn over the custom background for legibility (0.0 = image
/// at full strength, 1.0 = fully hidden behind the theme base). See
/// `crate::background::scrim_color`.
#[serde(default = "default_background_dim")]
pub background_dim: f32,
/// What a call recording captures (mixed / per-peer stems / both).
#[serde(default)]
pub recording_mode: RecordingMode,
@@ -241,6 +255,15 @@ pub struct AppConfig {
/// keyed by peer node id string. Local preference only.
#[serde(default)]
pub peer_pan: HashMap<String, f32>,
/// Per-peer listener-side volume/gain (`1.0` = unity), keyed by peer node id
/// string. Local preference only; never sent to peers. Absent entry = unity.
#[serde(default)]
pub peer_volume: HashMap<String, f32>,
/// Per-peer listener-side noise-gate threshold (normalized RMS, `0.0` = off),
/// keyed by peer node id string. Local preference only; never sent to peers.
/// Absent entry = gate disabled (pass-through).
#[serde(default)]
pub peer_gate: HashMap<String, f32>,
/// Focused app-local keyboard shortcuts.
#[serde(default)]
pub hotkeys: crate::hotkeys::HotkeyMap,
@@ -280,6 +303,8 @@ impl Default for AppConfig {
room_layout: RoomLayout::default(),
theme: AppTheme::default(),
avatar: crate::avatar::Avatar::default(),
background: None,
background_dim: default_background_dim(),
recording_mode: RecordingMode::default(),
custom_sound_self_join: None,
custom_sound_peer_join: None,
@@ -301,6 +326,8 @@ impl Default for AppConfig {
recents: Vec::new(),
peer_eq: HashMap::new(),
peer_pan: HashMap::new(),
peer_volume: HashMap::new(),
peer_gate: HashMap::new(),
hotkeys: crate::hotkeys::HotkeyMap::default(),
window_width: default_window_width(),
window_height: default_window_height(),
@@ -348,6 +375,17 @@ impl AppConfig {
})
}
/// Path the processed custom-background PNG (W16) is written to, alongside
/// `config.json` in the app config dir. We store our own downscaled copy here
/// (rather than base64 in the config) so the JSON stays small.
pub fn background_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut p| {
p.push("peerspeak");
p.push("background.png");
p
})
}
pub fn load() -> Self {
if let Some(path) = Self::config_path()
&& let Ok(contents) = fs::read_to_string(&path)
@@ -430,6 +468,8 @@ mod tests {
// shortcut settings.
assert!(deserialized.peer_eq.is_empty());
assert!(deserialized.peer_pan.is_empty());
assert!(deserialized.peer_volume.is_empty());
assert!(deserialized.peer_gate.is_empty());
assert_eq!(
crate::hotkeys::format_binding(
deserialized
+19 -1
View File
@@ -26,6 +26,10 @@ pub enum CoreCommand {
SetPeerEq(EndpointId, crate::audio::eq::EqSettings),
/// Listener-side per-peer pan. Local only; never leaves this app instance.
SetPeerPan(EndpointId, f32),
/// Listener-side per-peer noise gate threshold (normalized RMS, `0.0` = off).
/// Applies the same smooth gate as the mic path to a peer's incoming audio,
/// to suppress their background noise on our end. Local only.
SetPeerGate(EndpointId, f32),
/// Locally mute/unmute a peer: when muted, their audio is decoded (so levels
/// still show) but not mixed into our output.
SetPeerMuted(EndpointId, bool),
@@ -49,6 +53,14 @@ pub enum CoreCommand {
SetRecordingMode(RecordingMode),
/// Broadcast a room text-chat message. No-op when not in a call.
SendChat(String),
/// Send a chat message carrying a file attachment. The app has already read +
/// capped the file and built the descriptor; core makes the bytes available
/// on the file plane and broadcasts the descriptor.
SendChatFile { text: String, attachment: crate::files::ChatAttachment, data: Vec<u8> },
/// Fetch a received attachment's bytes from its sender over the file plane
/// (used for on-demand file/chip downloads; images are auto-fetched on
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
/// Sent at startup so screen-share can resolve the binary.
SetPixelpassPath(Option<String>),
@@ -105,7 +117,13 @@ pub enum UiEvent {
/// A room text-chat message arrived from a peer (never our own — local
/// messages are echoed by the UI on send). `from` is the sender's node id
/// string, used to key their avatar (W4).
ChatMessage { from: String, name: String, text: String },
ChatMessage { from: String, name: String, text: String, attachment: Option<crate::files::ChatAttachment> },
/// An attachment's bytes are now available (auto-fetched for images, or
/// fetched on demand for files). Keyed by attachment id so the UI can match
/// it to the chat entry.
AttachmentReady { id: crate::files::AttachmentId, data: Vec<u8> },
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
AttachmentFailed { id: crate::files::AttachmentId, error: String },
/// Our own screen share started; the UI flips the Share button to "Stop".
ScreenShareStarted,
/// Our own screen share stopped (or failed to start).
+137 -3
View File
@@ -8,7 +8,7 @@ use crate::codec::{AudioEncoder, opus_impl::OpusEncoder};
use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES};
use crate::network::{
NetworkTransport, RoomState, PeerState, RoomEvent, ConnEvent, PeerSpeakTicket,
iroh_impl::{IrohTransport, AudioRouter},
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
gossip::IrohGossipState,
};
use crate::core::messages::{CoreCommand, UiEvent};
@@ -578,6 +578,9 @@ struct NetStack {
/// The persistent inbound-audio handler on `router`; per-join we bind the
/// active session's transport into it, and clear it on leave.
audio_router: AudioRouter,
/// The persistent chat-file-transfer handler on `router`; bound/cleared in
/// lock-step with `audio_router` (same session lifecycle).
file_router: FileRouter,
/// In-memory address book (ticket + gossip fed), shared with every session.
memory_lookup: iroh::address_lookup::memory::MemoryLookup,
}
@@ -689,6 +692,7 @@ async fn build_net_stack(
.spawn(endpoint.clone());
let audio_router = AudioRouter::new();
let file_router = FileRouter::new();
// The friends presence listener (W7 B2) rides this same persistent router as a
// third ALPN — it MUST be a handler here, not a standalone accept loop, since
// the router owns endpoint.accept(). Policy (who we answer / what room we
@@ -696,6 +700,7 @@ async fn build_net_stack(
let router = Router::builder(endpoint.clone())
.accept(iroh_gossip::net::GOSSIP_ALPN, gossip.clone())
.accept(crate::protocol::AUDIO_ALPN, audio_router.clone())
.accept(crate::protocol::FILES_ALPN, file_router.clone())
.accept(
crate::presence_net::FRIENDS_ALPN,
crate::presence_net::FriendsProtocol::new(friends_handler),
@@ -707,10 +712,49 @@ async fn build_net_stack(
gossip,
router,
audio_router,
file_router,
memory_lookup,
})
}
/// Fetch a chat attachment's bytes from `from` over the file plane in a detached
/// task, then report the result to the UI via [`UiEvent::AttachmentReady`] /
/// [`UiEvent::AttachmentFailed`] keyed by the attachment id. For images
/// (`is_image`) the bytes are defensively re-validated (decodable + within pixel
/// limits) before being handed to the renderer; an "Image" that doesn't decode is
/// reported as a failure rather than rendered.
fn spawn_attachment_fetch(
transport: Arc<IrohTransport>,
ui_tx: mpsc::Sender<UiEvent>,
from: EndpointId,
att: crate::files::ChatAttachment,
is_image: bool,
) {
tokio::spawn(async move {
match transport.fetch_attachment(from, &att).await {
Ok(data) => {
if is_image && crate::files::validate_image_bytes(&data).is_none() {
let _ = ui_tx
.send(UiEvent::AttachmentFailed {
id: att.id,
error: "received image failed to decode".to_string(),
})
.await;
return;
}
let _ = ui_tx
.send(UiEvent::AttachmentReady { id: att.id, data })
.await;
}
Err(e) => {
let _ = ui_tx
.send(UiEvent::AttachmentFailed { id: att.id, error: e.to_string() })
.await;
}
}
});
}
/// Finalize and clear the active recording, if any, emitting `RecordingStopped`.
/// No-op when not recording. Called on stop, room leave, and room switch so a
/// recording is always closed cleanly (its WAV size fields patched).
@@ -869,6 +913,8 @@ async fn run_core_loop(
let peer_volumes = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
let peer_eq = Arc::new(Mutex::new(HashMap::<EndpointId, EqSettings>::new()));
let peer_pan = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Listener-side per-peer noise-gate thresholds (normalized RMS, 0.0 = off).
let peer_gate = Arc::new(Mutex::new(HashMap::<EndpointId, f32>::new()));
// Peers locally muted by us: decoded for level metering but not mixed.
let locally_muted = Arc::new(Mutex::new(HashSet::<EndpointId>::new()));
let mut current_name = "Anonymous".to_string();
@@ -1069,6 +1115,7 @@ async fn run_core_loop(
if let Some(session) = active_session.take() {
session.shutdown(audio_backend.clone()).await;
net.audio_router.clear();
net.file_router.clear();
}
*current_room.lock().unwrap() = None;
@@ -1091,6 +1138,7 @@ async fn run_core_loop(
crate::log_msg("Shutting down existing active session");
session.shutdown(audio_backend.clone()).await;
net.audio_router.clear();
net.file_router.clear();
}
// If a network-mode / identity change was deferred while a call was
@@ -1147,6 +1195,7 @@ async fn run_core_loop(
// NetStack; the session just subscribes its topic below.
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
net.audio_router.bind(&transport);
net.file_router.bind(&transport);
let room_state = Arc::new(IrohGossipState::new(
endpoint.clone(),
@@ -1191,6 +1240,7 @@ async fn run_core_loop(
crate::log_msg(&format!("Error room_state.join failed: {:?}", e));
let _ = ui_tx.send(UiEvent::Error(format!("Failed to join room: {}", e))).await;
net.audio_router.clear();
net.file_router.clear();
continue;
}
crate::log_msg("Joined room successfully via room_state");
@@ -1243,6 +1293,7 @@ async fn run_core_loop(
let _ = ui_tx.send(UiEvent::Error(format!("Failed to start capture: {}", e))).await;
let _ = room_state.leave().await;
net.audio_router.clear();
net.file_router.clear();
continue;
}
@@ -1255,6 +1306,7 @@ async fn run_core_loop(
let _ = audio_backend.stop();
let _ = room_state.leave().await;
net.audio_router.clear();
net.file_router.clear();
continue;
}
@@ -1397,6 +1449,7 @@ async fn run_core_loop(
let peer_volumes_mixer = peer_volumes.clone();
let peer_eq_mixer = peer_eq.clone();
let peer_pan_mixer = peer_pan.clone();
let peer_gate_mixer = peer_gate.clone();
let locally_muted_mixer = locally_muted.clone();
let output_gain_mixer = output_gain.clone();
let ui_tx_mixer = ui_tx.clone();
@@ -1413,6 +1466,11 @@ async fn run_core_loop(
// Per-peer EQ filter state. Settings are live-cloned each
// cycle; state is rebuilt only when a peer's EQ changes.
let mut peer_eqs: HashMap<EndpointId, Eq> = HashMap::new();
// Per-peer noise-gate envelope state. The threshold is passed
// per frame (live slider), so the gate is never rebuilt — only
// created once per peer and dropped when the peer leaves.
let mut peer_noise_gates: HashMap<EndpointId, crate::audio::gate::NoiseGate> =
HashMap::new();
// When the ring is at/above target we have nothing to do; nap
// briefly and re-check. Short enough (relative to the ~60ms
// target and ~21ms device quantum) that we always refill well
@@ -1438,6 +1496,7 @@ async fn run_core_loop(
let current_volumes = peer_volumes_mixer.lock().await.clone();
let current_eq = peer_eq_mixer.lock().await.clone();
let current_pans = peer_pan_mixer.lock().await.clone();
let current_gates = peer_gate_mixer.lock().await.clone();
let muted_peers = locally_muted_mixer.lock().await.clone();
let mut peer_frames: Vec<(Vec<i16>, f32)> = Vec::new();
let mut peers_seen = HashSet::new();
@@ -1463,6 +1522,26 @@ async fn run_core_loop(
stems.push((peer_id, frame.clone()));
}
// Listener-side per-peer noise gate, applied to the
// raw decoded frame (after the clean stem tap, before
// volume/EQ) so the threshold tracks the peer's true
// signal level regardless of our volume setting. The
// gate's "should transmit" return is irrelevant here —
// we only attenuate. Threshold 0 = off; the gate is
// created lazily and dropped when disabled.
let gate_threshold =
current_gates.get(&peer_id).copied().unwrap_or(0.0);
if gate_threshold > 0.0 {
peer_noise_gates
.entry(peer_id)
.or_insert_with(|| {
crate::audio::gate::NoiseGate::new(48_000)
})
.process(&mut frame, gate_threshold);
} else {
peer_noise_gates.remove(&peer_id);
}
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(&mut frame, vol);
@@ -1507,6 +1586,8 @@ async fn run_core_loop(
}
}
peer_eqs.retain(|id, _| peers_seen.contains(id) || current_eq.contains_key(id));
peer_noise_gates
.retain(|id, _| peers_seen.contains(id) || current_gates.contains_key(id));
// Lossless i32 sum, then the limiter applies the master
// output gain (in f32, so a boost past the ceiling is
@@ -1691,11 +1772,27 @@ async fn run_core_loop(
.insert(peer_id, state.addr.clone());
let _ = ui_tx_events.send(UiEvent::PeerUpdated { id: peer_id, state }).await;
}
RoomEvent::ChatMessage { from, name, text, .. } => {
RoomEvent::ChatMessage { from, name, text, ts: _, attachment } => {
// Auto-fetch image attachments so they render inline
// without a click; non-image files wait for an explicit
// FetchAttachment (the "Save" chip). The descriptor was
// already filename-sanitized + size-capped on ingest.
if let Some(att) = attachment.clone()
&& att.kind == crate::files::AttachmentKind::Image
{
spawn_attachment_fetch(
transport_events.clone(),
ui_tx_events.clone(),
from,
att,
true,
);
}
let _ = ui_tx_events.send(UiEvent::ChatMessage {
from: from.to_string(),
name,
text,
attachment,
}).await;
}
RoomEvent::PeerConnectionLost(peer_id) => {
@@ -1798,6 +1895,7 @@ async fn run_core_loop(
session.shutdown(audio_backend.clone()).await;
// Stop routing inbound audio links — the endpoint/router stay up.
net.audio_router.clear();
net.file_router.clear();
// No longer in a gathering — friends who ping see us as just online.
*current_room.lock().unwrap() = None;
let _ = ui_tx.send(UiEvent::RoomLeft).await;
@@ -1884,6 +1982,16 @@ async fn run_core_loop(
}
}
CoreCommand::SetPeerGate(peer_id, threshold) => {
let threshold = threshold.clamp(0.0, 1.0);
let mut guard = peer_gate.lock().await;
if threshold <= 0.0 {
guard.remove(&peer_id);
} else {
guard.insert(peer_id, threshold);
}
}
CoreCommand::SetPeerMuted(peer_id, muted) => {
let mut guard = locally_muted.lock().await;
if muted {
@@ -2167,12 +2275,38 @@ async fn run_core_loop(
CoreCommand::SendChat(text) => {
if let Some(session) = &active_session
&& let Err(e) = session.room_state.send_chat(text).await
&& let Err(e) = session.room_state.send_chat(text, None).await
{
crate::log_msg(&format!("Failed to send chat: {e}"));
}
}
CoreCommand::SendChatFile { text, attachment, data } => {
if let Some(session) = &active_session {
// Make the bytes fetchable by room members, then broadcast the
// descriptor alongside the (possibly empty) caption text.
session
.transport
.serve_attachment(attachment.id, Arc::new(data));
if let Err(e) = session.room_state.send_chat(text, Some(attachment)).await {
crate::log_msg(&format!("Failed to send chat file: {e}"));
}
}
}
CoreCommand::FetchAttachment { from, attachment } => {
if let Some(session) = &active_session {
let is_image = attachment.kind == crate::files::AttachmentKind::Image;
spawn_attachment_fetch(
session.transport.clone(),
ui_tx.clone(),
from,
attachment,
is_image,
);
}
}
CoreCommand::SetPixelpassPath(path) => {
pixelpass_override = path.filter(|p| !p.trim().is_empty());
}
+295
View File
@@ -0,0 +1,295 @@
//! Chat file attachments: the compact descriptor that rides a gossip chat
//! message, plus the pure validation/sanitization seams for the file-transfer
//! plane.
//!
//! Attachment **bytes do not travel over gossip** — gossip is a small-frame
//! broadcast plane (see `avatar` for why image bytes there are hard-capped to
//! tens of KB). Instead a chat message carries a [`ChatAttachment`] *descriptor*
//! (name, size, kind, id); the sender serves the actual bytes over the dedicated
//! file ALPN (`protocol::FILES_ALPN`) via direct QUIC streams, and recipients
//! fetch them point-to-point. Everything in this module is dependency-light and
//! pure so it can be unit-tested away from the network and the GUI.
use serde::{Deserialize, Serialize};
/// Hard ceiling on a single attachment's byte size. Bounds the memory a peer can
/// make us hold (when fetching) or serve, and the time a transfer can take.
/// 25 MiB comfortably covers phone photos and ordinary documents.
pub const MAX_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
/// Max decoded pixels per side for an inline image preview. Defends against a
/// decode-bomb (a small file that expands to an enormous bitmap), independent of
/// the byte cap. Applied via `image::Limits` when validating/decoding.
pub const MAX_IMAGE_PX: u32 = 4096;
/// Longest filename we keep and display. Keeps the gossip descriptor compact and
/// the UI tidy; the real bytes are unaffected.
pub const MAX_FILENAME_LEN: usize = 96;
/// A 32-byte opaque id identifying one attachment for the fetch request. Minted
/// randomly per attachment by the sender (see core); the transfer itself is
/// authenticated + encrypted + room-member gated, so the id only needs to be a
/// hard-to-guess handle into the sender's serve store, not a content hash.
pub type AttachmentId = [u8; 32];
/// How the receiver should present an attachment. A *hint* derived from the
/// sender's content sniff — never trusted for a safety decision. The receiver
/// re-validates image bytes itself before decoding, and falls back to a file
/// chip if an "Image" doesn't actually decode.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum AttachmentKind {
Image,
File,
}
/// The descriptor carried inside a `GossipMessage::Chat`. Compact by design: it
/// holds no file bytes, only what the UI needs to render a placeholder/chip and
/// what a fetch needs to pull the bytes.
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct ChatAttachment {
/// Sanitized display filename (already path-stripped — see
/// [`sanitize_filename`]). Never used as a filesystem path on receipt without
/// the user choosing a save location.
pub name: String,
/// Byte length of the file. Bounds the fetch read; must be
/// `<= MAX_ATTACHMENT_BYTES` (enforced by [`size_within_cap`]).
pub size: u64,
/// Presentation hint (image vs. generic file).
pub kind: AttachmentKind,
/// Opaque handle the receiver writes on the file plane to request the bytes.
pub id: AttachmentId,
}
/// Sanitize an arbitrary (possibly hostile) filename for display and as a
/// save-dialog default. Strips any directory component (both `/` and `\`),
/// removes control characters, collapses whitespace, trims, caps the length
/// while trying to preserve a short extension, and rejects the `.`/`..` traps.
/// Always returns a non-empty, path-component-free name (falls back to `file`).
pub fn sanitize_filename(raw: &str) -> String {
// Take only the final *non-empty* path component, defeating
// `../../etc/passwd`, `C:\foo\bar`, embedded separators, and trailing slashes
// (`a/b/c/` → `c`).
let base = raw
.rsplit(['/', '\\'])
.find(|s| !s.trim().is_empty())
.unwrap_or("")
.trim();
// Drop control chars; turn other whitespace into single spaces later.
let cleaned: String = base
.chars()
.filter(|c| !c.is_control())
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
let collapsed = collapsed.trim_matches('.').trim();
if collapsed.is_empty() {
return "file".to_string();
}
if collapsed.chars().count() <= MAX_FILENAME_LEN {
return collapsed.to_string();
}
// Too long: keep the extension (if short + sane) and truncate the stem.
if let Some((stem, ext)) = collapsed.rsplit_once('.')
&& !ext.is_empty()
&& ext.chars().count() <= 8
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
{
let keep = MAX_FILENAME_LEN.saturating_sub(ext.chars().count() + 1);
let truncated: String = stem.chars().take(keep).collect();
return format!("{truncated}.{ext}");
}
collapsed.chars().take(MAX_FILENAME_LEN).collect()
}
/// Whether a declared/observed size is within the transfer cap and non-zero.
/// Used both when sending (reject before serving) and when fetching (reject a
/// descriptor before opening a stream).
pub fn size_within_cap(size: u64) -> bool {
size > 0 && size <= MAX_ATTACHMENT_BYTES
}
/// Sniff the leading bytes for a known image container, to set the attachment
/// *kind* hint at send time. Recognizes PNG, JPEG, GIF, WebP, and BMP. This is a
/// presentation hint only — actual inline rendering still depends on the bytes
/// decoding (we only build image features for PNG/JPEG), with a file-chip
/// fallback otherwise.
pub fn is_probably_image(bytes: &[u8]) -> bool {
let b = bytes;
let png = b.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]);
let jpeg = b.starts_with(&[0xFF, 0xD8, 0xFF]);
let gif = b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a");
let bmp = b.starts_with(b"BM");
let webp = b.len() >= 12 && b.starts_with(b"RIFF") && &b[8..12] == b"WEBP";
png || jpeg || gif || bmp || webp
}
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
/// sniffs as an image container, else [`AttachmentKind::File`].
pub fn classify(bytes: &[u8]) -> AttachmentKind {
if is_probably_image(bytes) {
AttachmentKind::Image
} else {
AttachmentKind::File
}
}
/// Defensively decode image bytes under strict pixel limits to confirm they're a
/// real, sane image before we hand them to the renderer. Returns the decoded
/// dimensions on success. Guards against decode-bombs (small file → huge bitmap)
/// regardless of [`MAX_ATTACHMENT_BYTES`]. Only PNG/JPEG are buildable in our
/// `image` feature set; anything else returns `None` and the caller shows a chip.
pub fn validate_image_bytes(bytes: &[u8]) -> Option<(u32, u32)> {
let mut limits = image::Limits::default();
limits.max_image_width = Some(MAX_IMAGE_PX);
limits.max_image_height = Some(MAX_IMAGE_PX);
let reader = image::ImageReader::new(std::io::Cursor::new(bytes))
.with_guessed_format()
.ok()?;
let mut reader = reader;
reader.limits(limits);
let img = reader.decode().ok()?;
let (w, h) = (img.width(), img.height());
if w == 0 || h == 0 || w > MAX_IMAGE_PX || h > MAX_IMAGE_PX {
return None;
}
Some((w, h))
}
/// Parse a file-plane request: it must be exactly one [`AttachmentId`] (32
/// bytes). Anything else is rejected so a peer can't send a malformed/oversized
/// request frame. Pure half of the serve handler.
pub fn parse_request(bytes: &[u8]) -> Option<AttachmentId> {
if bytes.len() != 32 {
return None;
}
let mut id = [0u8; 32];
id.copy_from_slice(bytes);
Some(id)
}
/// A human-readable size like `2.3 MB` / `812 KB` / `40 B` for the file chip.
pub fn human_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
if bytes >= MB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.0} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} B")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_strips_directory_traversal() {
assert_eq!(sanitize_filename("../../etc/passwd"), "passwd");
assert_eq!(sanitize_filename("/abs/path/photo.png"), "photo.png");
assert_eq!(sanitize_filename(r"C:\Users\me\secret.doc"), "secret.doc");
assert_eq!(sanitize_filename("a/b/c/"), "c");
}
#[test]
fn sanitize_rejects_dot_traps_and_empty() {
assert_eq!(sanitize_filename(""), "file");
assert_eq!(sanitize_filename("."), "file");
assert_eq!(sanitize_filename(".."), "file");
assert_eq!(sanitize_filename(" "), "file");
assert_eq!(sanitize_filename("/"), "file");
}
#[test]
fn sanitize_removes_control_chars_and_collapses_ws() {
// Control chars (incl. tab/newline) are stripped entirely.
assert_eq!(sanitize_filename("my\tphoto\n.png"), "myphoto.png");
assert_eq!(sanitize_filename("a\u{0000}b.txt"), "ab.txt");
// Real spaces are collapsed but preserved.
assert_eq!(sanitize_filename("my photo .png"), "my photo .png");
}
#[test]
fn sanitize_caps_length_preserving_extension() {
let long_stem = "x".repeat(200);
let name = format!("{long_stem}.png");
let out = sanitize_filename(&name);
assert!(out.chars().count() <= MAX_FILENAME_LEN, "len was {}", out.chars().count());
assert!(out.ends_with(".png"), "extension preserved: {out}");
}
#[test]
fn size_cap_bounds() {
assert!(!size_within_cap(0));
assert!(size_within_cap(1));
assert!(size_within_cap(MAX_ATTACHMENT_BYTES));
assert!(!size_within_cap(MAX_ATTACHMENT_BYTES + 1));
}
#[test]
fn image_sniffing_recognizes_containers() {
assert!(is_probably_image(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]));
assert!(is_probably_image(&[0xFF, 0xD8, 0xFF, 0xE0]));
assert!(is_probably_image(b"GIF89a...."));
let mut webp = b"RIFF".to_vec();
webp.extend_from_slice(&[0, 0, 0, 0]);
webp.extend_from_slice(b"WEBP");
assert!(is_probably_image(&webp));
assert!(!is_probably_image(b"%PDF-1.7"));
assert!(!is_probably_image(b""));
}
#[test]
fn classify_maps_sniff_to_kind() {
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
assert_eq!(classify(b"plain text"), AttachmentKind::File);
}
#[test]
fn parse_request_requires_exact_32_bytes() {
assert_eq!(parse_request(&[7u8; 32]), Some([7u8; 32]));
assert_eq!(parse_request(&[7u8; 31]), None);
assert_eq!(parse_request(&[7u8; 33]), None);
assert_eq!(parse_request(&[]), None);
}
#[test]
fn validate_image_rejects_garbage() {
assert_eq!(validate_image_bytes(b"not an image"), None);
assert_eq!(validate_image_bytes(&[]), None);
}
#[test]
fn validate_image_accepts_a_real_png() {
// Encode a tiny PNG in-memory, then validate it.
let img = image::RgbImage::from_pixel(4, 3, image::Rgb([10, 20, 30]));
let mut buf = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgb8(img)
.write_to(&mut buf, image::ImageFormat::Png)
.unwrap();
assert_eq!(validate_image_bytes(&buf.into_inner()), Some((4, 3)));
}
#[test]
fn human_size_units() {
assert_eq!(human_size(40), "40 B");
assert_eq!(human_size(2048), "2 KB");
assert_eq!(human_size(3 * 1024 * 1024 + 300 * 1024), "3.3 MB");
}
#[test]
fn attachment_descriptor_round_trips_json() {
let a = ChatAttachment {
name: "photo.png".to_string(),
size: 12345,
kind: AttachmentKind::Image,
id: [9u8; 32],
};
let bytes = serde_json::to_vec(&a).unwrap();
let back: ChatAttachment = serde_json::from_slice(&bytes).unwrap();
assert_eq!(a, back);
}
}
+2
View File
@@ -15,9 +15,11 @@ pub mod notify;
pub mod screenshare;
pub mod sanitize;
pub mod avatar;
pub mod background;
pub mod recents;
pub mod discovery;
pub mod hotkeys;
pub mod files;
use std::fs::File;
use std::path::{Path, PathBuf};
+74 -13
View File
@@ -184,9 +184,16 @@ fn compute_bootstrap(
pub enum GossipMessage {
Announce(PeerState),
Leave,
/// A room text-chat message: the author's display name, the text, and a
/// sender-stamped millisecond timestamp.
Chat { name: String, text: String, ts: u64 },
/// A room text-chat message: the author's display name, the text, a
/// sender-stamped millisecond timestamp, and an optional file attachment
/// descriptor (the bytes are fetched off-gossip on the file plane).
Chat {
name: String,
text: String,
ts: u64,
#[serde(default)]
attachment: Option<crate::files::ChatAttachment>,
},
}
pub struct IrohGossipState {
@@ -438,13 +445,24 @@ impl RoomState for IrohGossipState {
let _ = event_tx.send(RoomEvent::PeerLeft(payload.author)).await;
}
}
GossipMessage::Chat { name, text, ts } => {
GossipMessage::Chat { name, text, ts, attachment } => {
crate::log_msg(&format!("Gossip chat from author={:?}", payload.author));
// Defensively normalize an untrusted attachment
// descriptor: sanitize the filename and drop it
// entirely if it declares an out-of-cap size.
let attachment = attachment.and_then(|mut a| {
if !crate::files::size_within_cap(a.size) {
return None;
}
a.name = crate::files::sanitize_filename(&a.name);
Some(a)
});
let _ = event_tx.send(RoomEvent::ChatMessage {
from: payload.author,
name,
text,
ts,
attachment,
}).await;
}
}
@@ -565,7 +583,11 @@ impl RoomState for IrohGossipState {
}
}
async fn send_chat(&self, text: String) -> Result<(), NetError> {
async fn send_chat(
&self,
text: String,
attachment: Option<crate::files::ChatAttachment>,
) -> Result<(), NetError> {
let name = {
let guard = self.self_state.lock().unwrap();
match guard.as_ref() {
@@ -582,7 +604,7 @@ impl RoomState for IrohGossipState {
&self.secret_key,
&topic,
ts,
GossipMessage::Chat { name, text, ts },
GossipMessage::Chat { name, text, ts, attachment },
);
if let Ok(bytes) = serde_json::to_vec(&payload) {
sender.broadcast(bytes.into()).await
@@ -744,13 +766,15 @@ mod tests {
name: "Alice".to_string(),
text: "Hello".to_string(),
ts: 123456789,
attachment: None,
};
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
if let GossipMessage::Chat { name, text, ts } = deserialized {
if let GossipMessage::Chat { name, text, ts, attachment } = deserialized {
assert_eq!(name, "Alice");
assert_eq!(text, "Hello");
assert_eq!(ts, 123456789);
assert_eq!(attachment, None);
} else {
panic!("Expected GossipMessage::Chat");
}
@@ -760,10 +784,11 @@ mod tests {
name: "".to_string(),
text: "".to_string(),
ts: u64::MAX,
attachment: None,
};
let serialized_empty = serde_json::to_string(&original_empty).unwrap();
let deserialized_empty: GossipMessage = serde_json::from_str(&serialized_empty).unwrap();
if let GossipMessage::Chat { name, text, ts } = deserialized_empty {
if let GossipMessage::Chat { name, text, ts, .. } = deserialized_empty {
assert_eq!(name, "");
assert_eq!(text, "");
assert_eq!(ts, u64::MAX);
@@ -772,6 +797,40 @@ mod tests {
}
}
#[test]
fn test_gossip_chat_attachment_round_trip_and_back_compat() {
let att = crate::files::ChatAttachment {
name: "photo.png".to_string(),
size: 4096,
kind: crate::files::AttachmentKind::Image,
id: [42u8; 32],
};
let original = GossipMessage::Chat {
name: "Alice".to_string(),
text: "look at this".to_string(),
ts: 1,
attachment: Some(att.clone()),
};
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
if let GossipMessage::Chat { attachment, .. } = deserialized {
assert_eq!(attachment, Some(att));
} else {
panic!("Expected GossipMessage::Chat");
}
// A pre-v2 chat payload (no `attachment` field) must still deserialize,
// defaulting the attachment to None (serde(default)).
let legacy = r#"{"Chat":{"name":"Old","text":"hi","ts":7}}"#;
let parsed: GossipMessage = serde_json::from_str(legacy).unwrap();
if let GossipMessage::Chat { name, attachment, .. } = parsed {
assert_eq!(name, "Old");
assert_eq!(attachment, None);
} else {
panic!("Expected GossipMessage::Chat");
}
}
#[test]
fn test_gossip_payload_chat_round_trip() {
let secret = SecretKey::generate();
@@ -784,6 +843,7 @@ mod tests {
name: "Bob".to_string(),
text: "Hi there".to_string(),
ts: 987654321,
attachment: None,
},
);
@@ -791,7 +851,7 @@ mod tests {
let deserialized: GossipPayload = serde_json::from_str(&serialized).unwrap();
assert_eq!(deserialized.author, secret.public());
if let GossipMessage::Chat { name, text, ts } = deserialized.msg {
if let GossipMessage::Chat { name, text, ts, .. } = deserialized.msg {
assert_eq!(name, "Bob");
assert_eq!(text, "Hi there");
assert_eq!(ts, 987654321);
@@ -806,10 +866,11 @@ mod tests {
name: "🎙 User".to_string(),
text: "héllo 🎙 世界".to_string(),
ts: 1717171717,
attachment: None,
};
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: GossipMessage = serde_json::from_str(&serialized).unwrap();
if let GossipMessage::Chat { name, text, ts } = deserialized {
if let GossipMessage::Chat { name, text, ts, .. } = deserialized {
assert_eq!(name, "🎙 User");
assert_eq!(text, "héllo 🎙 世界");
assert_eq!(ts, 1717171717);
@@ -849,7 +910,7 @@ mod tests {
let secret = SecretKey::generate();
let topic = [4u8; 32];
let mut p = sign_gossip(&secret, &topic, 5_000, GossipMessage::Leave);
p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000 };
p.msg = GossipMessage::Chat { name: "x".into(), text: "y".into(), ts: 5_000, attachment: None };
assert_eq!(
verify_gossip(&p, &topic, 5_000, GOSSIP_FRESHNESS_MS),
Err(GossipReject::BadSignature)
@@ -922,8 +983,8 @@ mod tests {
fn state_mutation_replay_gate_leaves_chat_ordering_untouched() {
let author = fresh_id();
let mut seen = HashMap::new();
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200 };
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100 };
let later_chat = GossipMessage::Chat { name: "A".into(), text: "later".into(), ts: 200, attachment: None };
let earlier_chat = GossipMessage::Chat { name: "A".into(), text: "earlier".into(), ts: 100, attachment: None };
assert!(admit_state_mutation(&mut seen, author, &later_chat, 200));
assert!(admit_state_mutation(&mut seen, author, &earlier_chat, 100));
+148 -1
View File
@@ -9,7 +9,8 @@ use std::collections::{HashMap, HashSet};
use std::time::Duration;
use async_trait::async_trait;
use crate::protocol::AUDIO_ALPN;
use crate::protocol::{AUDIO_ALPN, FILES_ALPN};
use crate::files::{AttachmentId, ChatAttachment};
/// Per-peer datagram send queue depth. Audio is real-time, so a backlog is
/// useless latency — keep it shallow and drop the oldest frame when full.
@@ -31,6 +32,10 @@ const MAX_BACKOFF: Duration = Duration::from_secs(5);
/// already means "the peer closed this on purpose."
const GOODBYE_CODE: u32 = 1;
/// Bound on each phase (connect, read) of a chat-attachment fetch, so a slow or
/// stalled sender can't hang the fetch indefinitely.
const FILE_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
/// State shared between the transport, its protocol handler, and every per-peer
/// supervisor task. One supervisor owns a peer's whole connection lifecycle.
struct Shared {
@@ -60,6 +65,11 @@ struct Shared {
/// verified gossip roster plus peers still inside reconnect grace; transport
/// connections alone never mutate this set.
admitted_audio: StdMutex<HashSet<EndpointId>>,
/// Chat file attachments we're serving to room members this session, keyed by
/// the random attachment id. Populated when we send a chat file; read by the
/// file protocol handler to answer a member's fetch. Cleared on leave. Each
/// blob is already byte-capped at send time.
served_files: StdMutex<HashMap<crate::files::AttachmentId, Arc<Vec<u8>>>>,
incoming_tx: mpsc::Sender<(EndpointId, Bytes)>,
/// Best-effort link-state notifications for the UI (connecting / connected).
conn_events_tx: mpsc::Sender<ConnEvent>,
@@ -402,6 +412,88 @@ impl iroh::protocol::ProtocolHandler for AudioRouter {
}
}
/// Protocol handler for the file-transfer plane (`FILES_ALPN`). Mirrors
/// [`AudioRouter`]: it's persistent on the router and bound to the active
/// session's [`Shared`] on join. On an inbound stream it authenticates the peer
/// (iroh ALPN handshake gives us `remote_id`), gates on **live room membership**
/// (same invariant as audio admission, so a former member can't pull files),
/// reads a single 32-byte attachment id, and streams back the matching blob from
/// the session serve store — or nothing if the id is unknown.
#[derive(Clone, Default)]
pub struct FileRouter {
current: Arc<StdMutex<Option<Arc<Shared>>>>,
}
impl std::fmt::Debug for FileRouter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FileRouter").finish_non_exhaustive()
}
}
impl FileRouter {
pub fn new() -> Self {
Self::default()
}
/// Route inbound file fetches to `transport`'s session (called on join).
pub fn bind(&self, transport: &IrohTransport) {
*self.current.lock().unwrap() = Some(transport.shared.clone());
}
/// Stop serving files until the next [`bind`](Self::bind) (called on leave).
pub fn clear(&self) {
*self.current.lock().unwrap() = None;
}
}
/// Max bytes we'll read for a fetch *request* frame. A request is exactly one
/// 32-byte id; this small ceiling rejects a peer trying to stream us a huge
/// "request" as a cheap DoS.
const FILE_REQUEST_MAX: usize = 64;
impl iroh::protocol::ProtocolHandler for FileRouter {
fn accept(
&self,
connection: Connection,
) -> impl std::future::Future<Output = Result<(), iroh::protocol::AcceptError>> + Send {
let peer_id = connection.remote_id();
let shared = self.current.lock().unwrap().clone();
async move {
// No active call → nothing to serve.
let Some(shared) = shared else {
return Ok(());
};
// Member gating: only current room members may fetch our files. Reuses
// the audio admission roster (the authoritative room membership set).
if !shared.audio_sender_admitted(peer_id) {
crate::log_msg(&format!(
"Transport: rejected file fetch from non-member {}",
crate::short_id(&peer_id.to_string())
));
return Ok(());
}
// Accept one bidirectional stream: read the id, write the bytes.
let Ok((mut send, mut recv)) = connection.accept_bi().await else {
return Ok(());
};
let Ok(req) = recv.read_to_end(FILE_REQUEST_MAX).await else {
return Ok(());
};
let Some(id) = crate::files::parse_request(&req) else {
return Ok(());
};
let blob = shared.served_files.lock().unwrap().get(&id).cloned();
if let Some(blob) = blob {
let _ = send.write_all(&blob).await;
}
// Finish either way: an unknown id closes with an empty body, which
// the fetcher reads as a zero-length result and treats as "gone".
let _ = send.finish();
Ok(())
}
}
}
pub struct IrohTransport {
shared: Arc<Shared>,
incoming_rx: tokio::sync::Mutex<Option<mpsc::Receiver<(EndpointId, Bytes)>>>,
@@ -426,6 +518,7 @@ impl IrohTransport {
peers: tokio::sync::Mutex::new(HashMap::new()),
live_conns: StdMutex::new(HashMap::new()),
admitted_audio: StdMutex::new(HashSet::new()),
served_files: StdMutex::new(HashMap::new()),
incoming_tx,
conn_events_tx,
});
@@ -455,6 +548,7 @@ impl IrohTransport {
self.shared.senders.lock().unwrap().clear();
self.shared.addrs.lock().unwrap().clear();
self.shared.admitted_audio.lock().unwrap().clear();
self.shared.served_files.lock().unwrap().clear();
// Give the CONNECTION_CLOSE frames a moment to flush before the caller
// shuts the endpoint/router down (the `conns` clones are still alive
// here, so the endpoint can still transmit them).
@@ -483,6 +577,59 @@ impl IrohTransport {
pub fn audio_sender_admitted(&self, peer_id: EndpointId) -> bool {
self.shared.audio_sender_admitted(peer_id)
}
/// Make `bytes` available to room members under `id` for the rest of this
/// session (served by the [`FileRouter`] handler). Called by core when we
/// send a chat file. The blob is cleared on leave.
pub fn serve_attachment(&self, id: AttachmentId, bytes: Arc<Vec<u8>>) {
self.shared.served_files.lock().unwrap().insert(id, bytes);
}
/// Fetch a chat attachment's bytes from its sender over the file plane. Dials
/// the sender on `FILES_ALPN` (preferring a known full address), writes the
/// 32-byte id, and reads the response bounded by the descriptor's declared
/// size (which the caller has already validated against the global cap). The
/// read limit means a malicious sender can't stream us more than advertised.
pub async fn fetch_attachment(
&self,
from: EndpointId,
att: &ChatAttachment,
) -> Result<Vec<u8>, NetError> {
if !crate::files::size_within_cap(att.size) {
return Err(NetError::Other("attachment size out of range".to_string()));
}
let addr = self.shared.addrs.lock().unwrap().get(&from).cloned();
let connect = async {
match addr {
Some(addr) => self.shared.endpoint.connect(addr, FILES_ALPN).await,
None => self.shared.endpoint.connect(from, FILES_ALPN).await,
}
};
let conn = tokio::time::timeout(FILE_FETCH_TIMEOUT, connect)
.await
.map_err(|_| NetError::Other("file fetch: connect timed out".to_string()))?
.map_err(|e| NetError::Other(format!("file fetch: connect failed: {e}")))?;
let (mut send, mut recv) = conn
.open_bi()
.await
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
send.write_all(&att.id)
.await
.map_err(|e| NetError::Other(format!("file fetch: request write failed: {e}")))?;
send.finish()
.map_err(|e| NetError::Other(format!("file fetch: request finish failed: {e}")))?;
let read = recv.read_to_end(att.size as usize);
let bytes = tokio::time::timeout(FILE_FETCH_TIMEOUT, read)
.await
.map_err(|_| NetError::Other("file fetch: read timed out".to_string()))?
.map_err(|e| NetError::Other(format!("file fetch: read failed: {e}")))?;
if bytes.is_empty() {
return Err(NetError::Other("file fetch: sender no longer has the file".to_string()));
}
Ok(bytes)
}
}
#[async_trait]
+16 -3
View File
@@ -57,7 +57,15 @@ pub enum RoomEvent {
/// A peer sent a room text-chat message. Carries the sender's id, their
/// display name (embedded so it shows even without a presence entry), the
/// text, and a sender-stamped millisecond timestamp.
ChatMessage { from: EndpointId, name: String, text: String, ts: u64 },
ChatMessage {
from: EndpointId,
name: String,
text: String,
ts: u64,
/// Optional file attachment descriptor; the bytes are fetched off-gossip
/// on the file plane. Already filename-sanitized + size-capped on ingest.
attachment: Option<crate::files::ChatAttachment>,
},
}
/// Transport-level link state for a peer, surfaced so the UI can show when a
@@ -206,8 +214,13 @@ pub trait RoomState: Send + Sync {
fn mark_peer_disconnected(&self, peer_id: EndpointId);
/// Broadcasts a room text-chat message authored by us (our display name is
/// taken from the current self-state).
async fn send_chat(&self, text: String) -> Result<(), NetError>;
/// taken from the current self-state), optionally carrying a file attachment
/// descriptor whose bytes are served separately on the file plane.
async fn send_chat(
&self,
text: String,
attachment: Option<crate::files::ChatAttachment>,
) -> Result<(), NetError>;
/// Leaves the room and announces departure.
async fn leave(&self) -> Result<(), NetError>;
+13 -2
View File
@@ -22,16 +22,26 @@ pub const FRIENDS_PROTO: u32 = 1;
/// Gossip plane version (`GossipPayload`/`GossipMessage`/`PeerState`, signing,
/// freshness). Bump on any change. Mirrored in [`GOSSIP_SIG_DOMAIN`] and folded
/// into [`versioned_topic`].
pub const GOSSIP_PROTO: u32 = 1;
///
/// v2 (0.3.0): `GossipMessage::Chat` gained an optional file attachment
/// (`ChatAttachment`), so a pre-v2 peer can't interpret/serve chat files — bumped
/// to fail fast rather than half-work.
pub const GOSSIP_PROTO: u32 = 2;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
pub const FILES_PROTO: u32 = 1;
/// ALPN for the audio datagram plane: `peerspeak/audio/<AUDIO_PROTO>`.
pub const AUDIO_ALPN: &[u8] = b"peerspeak/audio/1";
/// ALPN for the friends/presence plane: `peerspeak/friends/<FRIENDS_PROTO>`.
pub const FRIENDS_ALPN: &[u8] = b"peerspeak/friends/1";
/// ALPN for the file-transfer plane: `peerspeak/files/<FILES_PROTO>`. Carries
/// chat attachment bytes via direct QUIC streams (not gossip).
pub const FILES_ALPN: &[u8] = b"peerspeak/files/1";
/// ed25519 gossip signature domain: `peerspeak-gossip-v<GOSSIP_PROTO>`. Carries
/// the gossip protocol version into every signed payload — a version mismatch
/// fails verification (cryptographic separation between gossip versions).
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v1";
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v2";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
@@ -62,6 +72,7 @@ mod tests {
fn alpns_match_their_proto_versions() {
assert_eq!(AUDIO_ALPN, format!("peerspeak/audio/{AUDIO_PROTO}").as_bytes());
assert_eq!(FRIENDS_ALPN, format!("peerspeak/friends/{FRIENDS_PROTO}").as_bytes());
assert_eq!(FILES_ALPN, format!("peerspeak/files/{FILES_PROTO}").as_bytes());
assert_eq!(GOSSIP_SIG_DOMAIN, format!("peerspeak-gossip-v{GOSSIP_PROTO}"));
}