Compare commits

..
Author SHA1 Message Date
molluskandClaude Opus 4.8 82e1740d3c QUARANTINE: Codex off-task presence rate-limiter (NOT A24, unreviewed)
Codex was assigned A24 (per-peer volume) but instead built a friends-listener
presence rate-limiter touching the fenced security surface (presence.rs, the
friends listener in core, contacts-plan.md). Preserved here for later review as
a possible W7 hardening item; NOT merged to main, NOT the assigned task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 02:39:34 -04:00
23 changed files with 193 additions and 4084 deletions
-48
View File
@@ -1,48 +0,0 @@
# Changelog
All notable changes to PeerSpeak are documented here.
## [0.6.0] — 2026-06-28
### Added
- **Shared music listening (W22).** A new **Playlist** panel lets you build a personal queue of local audio files and play them on a dedicated music player — Browse to add tracks, play/pause, previous/next, seek, per-track reorder, remove, and a local volume slider, all persisted across sessions. `.pls` and `.m3u` playlists can be imported (remote and non-audio entries are skipped).
- **Tune in to a friend's music.** Flip **"Let others tune in"** and peers see your current track under the **Public** tab; one click on **Listen** streams it to them. Playback is **timeline-synced** — play, pause, skip, and seek mirror across everyone with no drift — and the next track is **prefetched for gapless** transitions. Each listener gets an independent **per-source volume**, so music sits under voice at whatever level they like; voice chat stays fully audible throughout.
- **Standalone Playlist card in the 3-Column layout.** The playlist now lives in its own card stacked under the chat, with a draggable divider to resize it and its own scrollbar when space is tight. The other layouts keep the playlist in the Controls panel.
### Security
- Shared-music metadata is treated as untrusted: the broadcast track name is sanitized and its size is cap-checked at gossip ingest, fetched bytes are confirmed to be audio before decoding, and only a small descriptor ever rides gossip — track bytes move point-to-point over the existing files plane, one fetch in flight at a time.
### Changed
- **Wire protocol bump (gossip v5).** Shared listening adds presence fields, so **0.6.0 peers cannot share a swarm with 0.5.x peers** — everyone in a room must update together.
[0.6.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.6.0
## [0.5.1] — 2026-06-27
### Added
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
## [0.5.0] — 2026-06-27
### Added
- **Right-click context menu** (Cut / Copy / Paste / Select All) on every text-entry field. (A9)
- **Selectable, copyable text** for values that used to be read-only: your full node ID and the room ticket can now be click-selected and copied, and **chat messages are drag-selectable** (highlight + Ctrl+C / Ctrl+A) while clickable links keep working. (W21 Phase 1 + 2)
- **Clock-skew warning**: when a peer can't be seen because the two systems' clocks differ by more than the replay-protection window, PeerSpeak now shows a "your clocks are out of sync" banner instead of failing silently. (A25)
- **Per-application audio capture for screen-share**, removing the call-audio loopback echo when sharing a window, plus surfacing of pixelpass startup errors. (A23)
### Changed / Fixed
- **Critical commands are now delivered reliably under load** — muting, releasing push-to-talk, and leaving a room can no longer be silently dropped while a slider is being dragged (prevents a hot-mic state mismatch). (A15)
- Screen-share now passes `--strict-audio` and surfaces app-audio drop warnings; the source picker state machine and pactl handling were hardened. (A23)
- Per-peer volume control path verified and covered by regression tests. (A24)
### Security
- Hardened against insider resource-exhaustion: bounded + author-keyed chat attachment cache, capped recovery-identity state, and other Tier-C caps (F-01 / F-02 / F-03 / F-12).
### Packaging
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
- Arch `PKGBUILD` clones over anonymous HTTPS.
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
Generated
+1 -1
View File
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "peerspeak"
version = "0.6.0"
version = "0.4.0"
dependencies = [
"anyhow",
"async-trait",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "peerspeak"
version = "0.6.0"
version = "0.4.0"
edition = "2024"
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
# Application crate, not a crates.io library — refuse `cargo publish` and let
@@ -57,7 +57,7 @@ async-trait = "0.1.89"
base64 = "0.22.1"
bytes = "1.11.1"
dirs = "6.0.0"
iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] }
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] }
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
# the codec surface small). The matching native file picker (`rfd`) is platform-
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
+14
View File
@@ -225,6 +225,20 @@ state change; rate-limit pings), tickets from friends (validate defensively, no
auto-join), the discovery publish (only when toggled, ideally auto-expiring).
`cargo audit` (JSON store → no new deps expected). Field test on dopedart.
**Local hardening DONE 2026-06-27:** inbound friend-presence replies are now
rate-limited per authenticated friend id (`PresenceRateLimiter`: burst 4, refill
1/15s) and wired into the live friends listener before it builds a `Pong`; denied
probes get the same silent no-data close as unauthorized probes. Existing
defensive reply handling still validates room tickets against the authenticated
friend id and never auto-joins. Verified with `cargo test presence`,
`cargo test --lib`, `cargo clippy --all-targets -- -D warnings`, and
`cargo audit --no-fetch --stale` (local DB; reports only the two already-allowed
unmaintained advisories in `deny.toml`). A fresh advisory fetch was blocked in
this sandbox by network restrictions.
**Remaining:** live 2-machine field test on dopedart, a fresh online
`cargo audit`, and any follow-up findings from that test.
## The connect flow (the user's scenario, end to end)
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
"HangOut."
+2 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
pkgname=peerspeak-git
_pkgname=peerspeak
pkgver=0.5.0.r0.g0000000
pkgver=0.4.0.r254.g913b0b6
pkgrel=1
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
arch=('x86_64')
@@ -14,7 +14,7 @@ optdepends=('pixelpass: screen sharing inside a room'
provides=('peerspeak')
conflicts=('peerspeak')
options=('!lto' '!debug')
source=("$_pkgname::git+https://gitbutter.xyz/mollusk/peerspeak.git")
source=("$_pkgname::git+ssh://git@gitbutter.xyz/mollusk/peerspeak.git")
sha256sums=('SKIP')
pkgver() {
+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.5.0"
#define MyAppVersion "0.4.0"
#define MyAppPublisher "mollusk"
#define MyAppExeName "peerspeak.exe"
+39 -1321
View File
File diff suppressed because it is too large Load Diff
+4 -29
View File
@@ -40,7 +40,6 @@ enum ClipCommand {
Resume,
Seek(Duration),
Stop,
SetVolume(f32),
}
/// Cheap, `Send` command handle for the dedicated playback thread.
@@ -52,16 +51,13 @@ pub struct ClipPlayer {
impl ClipPlayer {
/// Start the playback worker. The system output device is opened lazily on
/// first Play, so merely launching PeerSpeak never claims another stream.
///
/// `initial_volume` is the universal gain (`1.0` = unity) applied to every
/// clip, restored from config so the level persists across sessions.
pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) {
pub fn new() -> (Self, SharedClipStatus) {
let (command_tx, command_rx) = mpsc::channel();
let status = Arc::new(Mutex::new(ClipStatus::default()));
let worker_status = Arc::clone(&status);
std::thread::Builder::new()
.name("peerspeak-clip-player".to_string())
.spawn(move || playback_worker(command_rx, worker_status, initial_volume))
.spawn(move || playback_worker(command_rx, worker_status))
.expect("failed to spawn clip playback thread");
(
Self {
@@ -98,24 +94,11 @@ impl ClipPlayer {
pub fn stop(&self) {
let _ = self.command_tx.send(ClipCommand::Stop);
}
/// Set the universal playback gain (`1.0` = unity). Applies to the current
/// clip immediately and to every clip played afterwards.
pub fn set_volume(&self, volume: f32) {
let _ = self.command_tx.send(ClipCommand::SetVolume(volume));
}
}
fn playback_worker(
command_rx: mpsc::Receiver<ClipCommand>,
status: SharedClipStatus,
initial_volume: f32,
) {
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
let mut output: Option<MixerDeviceSink> = None;
let mut player: Option<Player> = None;
// Universal gain remembered across clips so a level set on one upload
// carries to the next; reapplied to each freshly connected player.
let mut volume = initial_volume.max(0.0);
loop {
match command_rx.recv_timeout(Duration::from_millis(100)) {
@@ -141,9 +124,7 @@ fn playback_worker(
if output.is_none() {
match DeviceSinkBuilder::open_default_sink() {
Ok(sink) => {
let new_player = Player::connect_new(sink.mixer());
new_player.set_volume(volume);
player = Some(new_player);
player = Some(Player::connect_new(sink.mixer()));
output = Some(sink);
}
Err(error) => {
@@ -196,12 +177,6 @@ fn playback_worker(
}
reset(&status);
}
Ok(ClipCommand::SetVolume(level)) => {
volume = level.max(0.0);
if let Some(player) = player.as_ref() {
player.set_volume(volume);
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
Err(mpsc::RecvTimeoutError::Timeout) => {}
}
-2
View File
@@ -65,7 +65,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
sharing: None,
avatar: Default::default(),
game: None,
music: None,
};
room_a.join(&ticket_str, state_a, vec![]).await?;
println!("Node A joined topic.");
@@ -86,7 +85,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
sharing: None,
avatar: Default::default(),
game: None,
music: None,
};
room_b.join(&ticket_str, state_b, vec![]).await?;
println!("Node B joined topic.");
-61
View File
@@ -126,10 +126,6 @@ fn default_chat_height() -> f32 {
180.0
}
fn default_threecol_playlist_height() -> f32 {
220.0
}
fn default_controls_width() -> f32 {
280.0
}
@@ -160,24 +156,6 @@ pub struct AppConfig {
/// App-internal playback gain applied to the mixed output (1.0 = unity).
#[serde(default = "default_volume")]
pub output_volume: f32,
/// Universal playback gain for inline chat audio clips (1.0 = unity). One
/// level shared by every uploaded clip so the slider sticks across plays.
#[serde(default = "default_volume")]
pub clip_volume: f32,
/// W22 music: the user's personal playlist as local file PATHS (not bytes).
/// Loaded into memory at startup; missing files are skipped/marked on play.
#[serde(default)]
pub music_playlist: Vec<String>,
/// W22 music: local playback gain for the dedicated music player (1.0 = unity).
#[serde(default = "default_volume")]
pub music_volume: f32,
/// W22 music: opt-in shared listening broadcast toggle. Local preference.
#[serde(default)]
pub music_broadcast: bool,
/// When true, `clip_volume` governs every clip. When false, each clip keeps
/// its own (in-memory) level and the universal slider is inactive.
#[serde(default = "default_true")]
pub clip_volume_universal: bool,
#[serde(default)]
pub network_mode: NetworkMode,
/// Presence posture for the friends idle listener (W7): invisible / normal /
@@ -196,11 +174,6 @@ pub struct AppConfig {
pub participants_width: f32,
#[serde(default = "default_chat_height")]
pub chat_height: f32,
/// Height (px) of the standalone Playlist card stacked under Chat in the
/// 3-column layout. Resized via its own horizontal divider; re-clamped to the
/// window on load/resize. Only used by `RoomLayout::ThreeColumn`.
#[serde(default = "default_threecol_playlist_height")]
pub threecol_playlist_height: f32,
/// Controls panel width for the 3-column layout (px).
#[serde(default = "default_controls_width")]
pub controls_width: f32,
@@ -306,11 +279,6 @@ pub struct AppConfig {
/// string. Local preference only; never sent to peers. Absent entry = unity.
#[serde(default)]
pub peer_volume: HashMap<String, f32>,
/// Per-source music listen volume/gain (`1.0` = unity), keyed by peer node id
/// string. Local preference only; never sent to peers. Absent entry falls
/// back to `music_volume`.
#[serde(default)]
pub music_source_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).
@@ -344,18 +312,12 @@ impl Default for AppConfig {
noise_gate_threshold: 0.01,
input_volume: 1.0,
output_volume: 1.0,
clip_volume: 1.0,
music_playlist: Vec::new(),
music_volume: 1.0,
music_broadcast: false,
clip_volume_universal: true,
network_mode: NetworkMode::default(),
presence_mode: crate::presence::PresenceMode::default(),
echo_cancellation_enabled: false,
notifications_enabled: true,
participants_width: default_participants_width(),
chat_height: default_chat_height(),
threecol_playlist_height: default_threecol_playlist_height(),
controls_width: default_controls_width(),
chat_drawer_width: default_chat_drawer_width(),
room_layout: RoomLayout::default(),
@@ -388,7 +350,6 @@ impl Default for AppConfig {
peer_eq: HashMap::new(),
peer_pan: HashMap::new(),
peer_volume: HashMap::new(),
music_source_volume: HashMap::new(),
peer_gate: HashMap::new(),
hotkeys: crate::hotkeys::HotkeyMap::default(),
window_width: default_window_width(),
@@ -545,7 +506,6 @@ mod tests {
assert!(deserialized.peer_eq.is_empty());
assert!(deserialized.peer_pan.is_empty());
assert!(deserialized.peer_volume.is_empty());
assert!(deserialized.music_source_volume.is_empty());
assert!(deserialized.peer_gate.is_empty());
assert_eq!(
crate::hotkeys::format_binding(
@@ -697,44 +657,23 @@ mod tests {
let def = AppConfig::default();
assert_eq!(def.input_volume, 1.0);
assert_eq!(def.output_volume, 1.0);
assert_eq!(def.clip_volume, 1.0);
assert!(def.music_playlist.is_empty());
assert_eq!(def.music_volume, 1.0);
assert!(!def.music_broadcast);
assert!(def.clip_volume_universal);
// Missing in JSON → unity (serde default).
let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#;
let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap();
assert_eq!(cfg_missing.input_volume, 1.0);
assert_eq!(cfg_missing.output_volume, 1.0);
assert_eq!(cfg_missing.clip_volume, 1.0);
assert!(cfg_missing.music_playlist.is_empty());
assert_eq!(cfg_missing.music_volume, 1.0);
assert!(!cfg_missing.music_broadcast);
// Configs predating the toggle default to universal mode.
assert!(cfg_missing.clip_volume_universal);
// Explicit non-unity values are preserved across a round-trip.
let cfg = AppConfig {
input_volume: 1.5,
output_volume: 0.25,
clip_volume: 0.7,
music_playlist: vec!["/tmp/song.ogg".to_string()],
music_volume: 0.6,
music_broadcast: true,
clip_volume_universal: false,
..AppConfig::default()
};
let round_tripped: AppConfig =
serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap();
assert_eq!(round_tripped.input_volume, 1.5);
assert_eq!(round_tripped.output_volume, 0.25);
assert_eq!(round_tripped.clip_volume, 0.7);
assert_eq!(round_tripped.music_playlist, vec!["/tmp/song.ogg".to_string()]);
assert_eq!(round_tripped.music_volume, 0.6);
assert!(round_tripped.music_broadcast);
assert!(!round_tripped.clip_volume_universal);
}
#[test]
-168
View File
@@ -61,17 +61,6 @@ pub enum CoreCommand {
/// (used for on-demand file/chip downloads; images are auto-fetched on
/// receipt). Replies with `AttachmentReady`/`AttachmentFailed`.
FetchAttachment { from: EndpointId, attachment: crate::files::ChatAttachment },
/// Register `data` as fetchable under `id` for room members (the current
/// broadcast track). Called once per track when broadcasting.
ServeMusicTrack { id: crate::files::AttachmentId, data: std::sync::Arc<Vec<u8>> },
/// Drop a music blob that is no longer current-or-next.
ForgetMusicTrack(crate::files::AttachmentId),
/// Set (or clear) our broadcast music timeline and re-announce presence.
SetMusicPresence(Option<crate::network::MusicPresence>),
/// Fetch a source peer's current track bytes after tuning into them.
FetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
/// Fetch a source peer's advertised next track bytes before it becomes current.
PrefetchMusic { from: EndpointId, id: crate::files::AttachmentId, size: u64 },
/// Set the pixelpass binary location (config override, empty = use `$PATH`).
/// Sent at startup so screen-share can resolve the binary.
SetPixelpassPath(Option<String>),
@@ -120,96 +109,6 @@ pub enum CoreCommand {
SetGameProcessMap(std::collections::BTreeMap<String, String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeliveryClass {
Reliable,
BestEffort,
}
/// Route a command by how bad it is to drop it. Discrete, human-paced user
/// actions are Reliable (must land). The only high-frequency commands are the
/// continuous audio sliders, where dropping intermediate values is harmless;
/// those are BestEffort.
pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
match cmd {
CoreCommand::SetPeerVolume(_, _)
| CoreCommand::SetPeerPan(_, _)
| CoreCommand::SetPeerGate(_, _)
| CoreCommand::SetPeerEq(_, _)
| CoreCommand::SetInputVolume(_)
| CoreCommand::SetOutputVolume(_)
| CoreCommand::SetNoiseGateThreshold(_) => DeliveryClass::BestEffort,
CoreCommand::Join {
name: _,
ticket: _,
room_name: _,
input_device: _,
output_device: _,
echo_cancellation: _,
avatar: _,
}
| CoreCommand::Leave
| CoreCommand::Shutdown
| CoreCommand::ToggleMute
| CoreCommand::SetAvatar(_)
| CoreCommand::ToggleDeafen
| CoreCommand::SetPttMode(_)
| CoreCommand::SetPttActive(_)
| CoreCommand::SetPeerMuted(_, _)
| CoreCommand::SetMicMonitor {
enabled: _,
input_device: _,
}
| CoreCommand::SetNetworkMode(_)
| CoreCommand::SetRecording(_)
| CoreCommand::SetRecordingMode(_)
| CoreCommand::SendChat(_)
| CoreCommand::SendChatFile {
text: _,
attachment: _,
data: _,
}
| CoreCommand::FetchAttachment {
from: _,
attachment: _,
}
| CoreCommand::ServeMusicTrack {
id: _,
data: _,
}
| CoreCommand::ForgetMusicTrack(_)
| CoreCommand::SetMusicPresence(_)
| CoreCommand::FetchMusic {
from: _,
id: _,
size: _,
}
| CoreCommand::PrefetchMusic {
from: _,
id: _,
size: _,
}
| CoreCommand::SetPixelpassPath(_)
| CoreCommand::ListAudioApps
| CoreCommand::StartScreenShare { audio_app: _ }
| CoreCommand::StopScreenShare
| CoreCommand::ViewShare(_)
| CoreCommand::RegenerateIdentity
| CoreCommand::AddFriend {
id: _,
name: _,
addr: _,
}
| CoreCommand::RemoveFriend(_)
| CoreCommand::RenameFriend(_, _)
| CoreCommand::SetPresenceMode(_)
| CoreCommand::SetGamePresenceEnabled(_)
| CoreCommand::SetGameOverride(_)
| CoreCommand::SetGameProcessMap(_) => DeliveryClass::Reliable,
}
}
#[derive(Debug, Clone)]
pub enum UiEvent {
RoomJoined { ticket: String, self_id: String },
@@ -248,12 +147,6 @@ pub enum UiEvent {
AttachmentReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
/// An attachment fetch failed (sender gone, too large, decode error, etc.).
AttachmentFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
/// A tuned-in source's track bytes arrived; play them in the music sink.
MusicReady { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
/// A tuned-in source's next-track bytes arrived; cache them for a gapless swap.
MusicPrefetched { from: EndpointId, id: crate::files::AttachmentId, data: Vec<u8> },
/// A music-track fetch failed (source gone, too large, etc.).
MusicFetchFailed { from: EndpointId, id: crate::files::AttachmentId, error: String },
/// The apps currently producing audio, for the screen-share audio picker
/// (A23). Sorted, deduplicated `application.name`s; empty when nothing is
/// playing or enumeration isn't available. `app_audio_supported` reports
@@ -270,10 +163,6 @@ pub enum UiEvent {
/// run viewers currently hear silence. The UI shows a transient warning while
/// `false`. Only meaningful while sharing a specific app (not whole-desktop).
ShareAudioActive(bool),
/// A validly signed peer cannot be admitted because its gossip timestamp is
/// outside the replay freshness window. `peer_ahead` describes the peer's
/// sender-stamped timestamp relative to this machine's clock.
ClockSkewWarning { skew_secs: u64, peer_ahead: bool },
/// Our node identity (W7): the current node id string, and whether it is
/// PERSISTED to disk. Sent once at startup and again after a regenerate.
/// `persisted = false` means the key file couldn't be read/written and we're
@@ -306,60 +195,3 @@ pub enum UiEvent {
ShutdownComplete,
Error(String),
}
#[cfg(test)]
mod tests {
use super::{delivery_class, CoreCommand, DeliveryClass};
use crate::audio::eq::EqSettings;
use crate::presence::PresenceMode;
use iroh::{EndpointId, SecretKey};
fn endpoint_id() -> EndpointId {
SecretKey::generate().public()
}
#[test]
fn continuous_audio_controls_are_best_effort() {
let peer = endpoint_id();
let commands = [
CoreCommand::SetPeerVolume(peer, 0.7),
CoreCommand::SetPeerPan(peer, -0.2),
CoreCommand::SetPeerGate(peer, 0.1),
CoreCommand::SetPeerEq(peer, EqSettings::default()),
CoreCommand::SetInputVolume(0.8),
CoreCommand::SetOutputVolume(0.9),
CoreCommand::SetNoiseGateThreshold(0.02),
];
for cmd in commands {
assert_eq!(delivery_class(&cmd), DeliveryClass::BestEffort);
}
}
#[test]
fn discrete_user_actions_are_reliable() {
let peer = endpoint_id();
let commands = [
CoreCommand::ToggleMute,
CoreCommand::SetPttActive(false),
CoreCommand::Leave,
CoreCommand::RegenerateIdentity,
CoreCommand::Join {
name: "Peer".to_string(),
ticket: "create".to_string(),
room_name: "Room".to_string(),
input_device: None,
output_device: None,
echo_cancellation: true,
avatar: crate::avatar::Avatar::default(),
},
CoreCommand::SetPeerMuted(peer, true),
CoreCommand::SetPresenceMode(PresenceMode::Normal),
CoreCommand::SendChat("hello".to_string()),
];
for cmd in commands {
assert_eq!(delivery_class(&cmd), DeliveryClass::Reliable);
}
}
}
+33 -175
View File
@@ -11,7 +11,7 @@ use crate::network::{
iroh_impl::{IrohTransport, AudioRouter, FileRouter},
gossip::IrohGossipState,
};
use crate::core::messages::{CoreCommand, DeliveryClass, UiEvent, delivery_class};
use crate::core::messages::{CoreCommand, UiEvent};
use crate::core::recovery::RecoveryCoordinator;
use crate::config::{NetworkMode, RecordingMode};
@@ -26,44 +26,38 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration;
pub struct CoreController {
reliable_tx: mpsc::UnboundedSender<CoreCommand>,
besteffort_tx: mpsc::Sender<CoreCommand>,
cmd_tx: mpsc::Sender<CoreCommand>,
}
impl CoreController {
pub fn new(ui_tx: mpsc::Sender<UiEvent>) -> Self {
let (reliable_tx, reliable_rx) = mpsc::unbounded_channel();
let (besteffort_tx, besteffort_rx) = mpsc::channel(100);
let (cmd_tx, cmd_rx) = mpsc::channel(100);
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async move {
crate::log_msg("Starting core network loop in dedicated Tokio runtime");
if let Err(e) = run_core_loop(reliable_rx, besteffort_rx, ui_tx).await {
if let Err(e) = run_core_loop(cmd_rx, ui_tx).await {
crate::log_msg(&format!("App core loop failed: {:?}", e));
}
});
});
Self { reliable_tx, besteffort_tx }
Self { cmd_tx }
}
/// Queue a command for the core loop. Reliable commands only fail when the
/// core loop is dead; best-effort slider commands keep today's bounded
/// try-send behavior. (We return a plain bool rather than the channel's
/// `Result` so the bulky `CoreCommand` isn't carried back by value in every
/// caller's error type.)
/// Queue a command for the core loop, best-effort. Returns `true` if it was
/// accepted, `false` if the channel is full or closed. (We return a plain
/// bool rather than the channel's `Result` so the bulky `CoreCommand` isn't
/// carried back by value in every caller's error type.)
pub fn send(&self, cmd: CoreCommand) -> bool {
match delivery_class(&cmd) {
DeliveryClass::Reliable => self.reliable_tx.send(cmd).is_ok(),
DeliveryClass::BestEffort => self.besteffort_tx.try_send(cmd).is_ok(),
}
self.cmd_tx.try_send(cmd).is_ok()
}
/// Clone the command sender for asynchronous one-shot sends that should wait
/// for channel capacity instead of failing immediately on a full queue.
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
self.besteffort_tx.clone()
self.cmd_tx.clone()
}
}
@@ -300,17 +294,6 @@ fn apply_volume(frame: &mut [i16], vol: f32) {
}
}
/// Apply the listener's per-peer volume for the audio sender id currently being
/// mixed. The map key must be the same `EndpointId` used for the jitter buffer.
fn apply_peer_volume(
frame: &mut [i16],
peer_id: EndpointId,
volumes: &HashMap<EndpointId, f32>,
) {
let vol = volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(frame, vol);
}
/// Normalized RMS level of a frame in `[0.0, 1.0]` (32768 = full scale), for the
/// UI level meter. An empty frame reads as 0.0.
fn frame_level(frame: &[i16]) -> f32 {
@@ -865,52 +848,6 @@ fn spawn_attachment_fetch(
});
}
fn spawn_music_fetch(
transport: Arc<IrohTransport>,
ui_tx: mpsc::Sender<UiEvent>,
from: EndpointId,
id: crate::files::AttachmentId,
size: u64,
) {
tokio::spawn(async move {
match transport.fetch_blob(from, id, size).await {
Ok(data) => {
let _ = ui_tx
.send(UiEvent::MusicReady { from, id, data })
.await;
}
Err(e) => {
let _ = ui_tx
.send(UiEvent::MusicFetchFailed { from, id, error: e.to_string() })
.await;
}
}
});
}
fn spawn_music_prefetch(
transport: Arc<IrohTransport>,
ui_tx: mpsc::Sender<UiEvent>,
from: EndpointId,
id: crate::files::AttachmentId,
size: u64,
) {
tokio::spawn(async move {
match transport.fetch_blob(from, id, size).await {
Ok(data) => {
let _ = ui_tx
.send(UiEvent::MusicPrefetched { from, id, data })
.await;
}
Err(e) => {
let _ = ui_tx
.send(UiEvent::MusicFetchFailed { from, 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).
@@ -1004,8 +941,7 @@ async fn probe_friends_once(
}
async fn run_core_loop(
mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
mut besteffort_rx: mpsc::Receiver<CoreCommand>,
mut cmd_rx: mpsc::Receiver<CoreCommand>,
ui_tx: mpsc::Sender<UiEvent>,
) -> Result<(), anyhow::Error> {
let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new();
@@ -1082,7 +1018,6 @@ async fn run_core_loop(
name: "Anonymous".to_string(),
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
};
// Game detection (W17/W18): a background worker polls Steam state + the process
// list and publishes the debounced running game on a watch channel. Detection
@@ -1139,22 +1074,34 @@ async fn run_core_loop(
// Join, cleared on Leave.
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
Arc::new(std::sync::Mutex::new(None));
let presence_rate_limiter =
Arc::new(std::sync::Mutex::new(crate::presence::PresenceRateLimiter::default()));
// Reply policy for the idle friends listener (B2): answer friends only, never
// while invisible (`should_answer`), and report our current gathering so a friend
// can one-click join. Reads the shared snapshots, so it stays correct as they
// change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
// across one). Built once and handed to every `build_net_stack`.
// can one-click join. Rate-limits allowed friends before building a reply, so a
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the
// shared snapshots, so it stays correct as they change and survives a network-stack
// rebuild. Pure-sync (no awaits, no lock held across one). Built once and handed
// to every `build_net_stack`.
let friends_handler: crate::presence_net::Handler = {
let friends = friends.clone();
let presence_mode = presence_mode.clone();
let current_room = current_room.clone();
let presence_rate_limiter = presence_rate_limiter.clone();
Arc::new(move |from| {
let mode = *presence_mode.lock().unwrap();
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
if !allowed {
return None;
}
if !presence_rate_limiter
.lock()
.unwrap()
.allow(from, std::time::Instant::now())
{
return None;
}
let room = current_room.lock().unwrap().clone();
Some(crate::presence::ControlMsg::Pong { room })
})
@@ -1209,12 +1156,7 @@ async fn run_core_loop(
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
let cmd = tokio::select! {
biased;
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
Some(cmd) => cmd,
None => break,
},
maybe_cmd = besteffort_rx.recv() => match maybe_cmd {
maybe_cmd = cmd_rx.recv() => match maybe_cmd {
Some(cmd) => cmd,
None => break,
},
@@ -1777,7 +1719,8 @@ async fn run_core_loop(
peer_noise_gates.remove(&peer_id);
}
apply_peer_volume(&mut frame, peer_id, &current_volumes);
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0);
apply_volume(&mut frame, vol);
let eq_settings = current_eq
.get(&peer_id)
@@ -2149,19 +2092,6 @@ async fn run_core_loop(
attachment,
}).await;
}
RoomEvent::ClockSkewSuspected { author, skew_ms } => {
crate::log_msg(&format!(
"Clock skew suspected for authenticated gossip author={} skew_ms={skew_ms}",
crate::short_id(&author.to_string())
));
let skew_secs = skew_ms.unsigned_abs().saturating_add(999) / 1000;
let _ = ui_tx_events
.send(UiEvent::ClockSkewWarning {
skew_secs,
peer_ahead: skew_ms > 0,
})
.await;
}
RoomEvent::PeerConnectionLost(peer_id) => {
// Transient drop: do NOT tear down the peer. Its audio
// supervisor stays alive and keeps redialing the
@@ -2712,54 +2642,6 @@ async fn run_core_loop(
}
}
CoreCommand::ServeMusicTrack { id, data } => {
if let Some(session) = &active_session {
session.transport.serve_attachment(id, data);
}
}
CoreCommand::ForgetMusicTrack(id) => {
if let Some(session) = &active_session {
session.transport.forget_attachment(id);
}
}
CoreCommand::SetMusicPresence(music) => {
presence.music = music;
if let Some(session) = &active_session {
let self_state = presence.to_state(
is_muted.load(Ordering::Relaxed),
net.endpoint.addr(),
current_sharing.clone(),
);
let _ = session.room_state.update_self_state(self_state).await;
}
}
CoreCommand::FetchMusic { from, id, size } => {
if let Some(session) = &active_session {
spawn_music_fetch(
session.transport.clone(),
ui_tx.clone(),
from,
id,
size,
);
}
}
CoreCommand::PrefetchMusic { from, id, size } => {
if let Some(session) = &active_session {
spawn_music_prefetch(
session.transport.clone(),
ui_tx.clone(),
from,
id,
size,
);
}
}
CoreCommand::SetPixelpassPath(path) => {
pixelpass_override = path.filter(|p| !p.trim().is_empty());
}
@@ -2906,9 +2788,9 @@ async fn run_core_loop(
#[cfg(test)]
mod tests {
use super::{
admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, frame_level,
mix_frames, mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono,
KnownPeers, MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
admit_retained, apply_volume, audio_datagram_len_ok, frame_level, mix_frames,
mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, KnownPeers,
MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
MIC_LEVEL_REPORT_SAMPLES,
};
@@ -3168,30 +3050,6 @@ mod tests {
assert_eq!(frame, vec![2000, -2000]);
}
#[test]
fn peer_volume_map_scales_the_matching_audio_peer_frame() {
let peer = iroh::SecretKey::generate().public();
let other_peer = iroh::SecretKey::generate().public();
let volumes = std::collections::HashMap::from([(peer, 0.5), (other_peer, 2.0)]);
let mut frame = vec![100, -200, 300, -400];
apply_peer_volume(&mut frame, peer, &volumes);
assert_eq!(frame, vec![50, -100, 150, -200]);
}
#[test]
fn peer_volume_map_defaults_to_unity_when_audio_peer_key_is_unmatched() {
let ui_peer = iroh::SecretKey::generate().public();
let audio_peer = iroh::SecretKey::generate().public();
let volumes = std::collections::HashMap::from([(ui_peer, 0.5)]);
let mut frame = vec![100, -200, 300, -400];
apply_peer_volume(&mut frame, audio_peer, &volumes);
assert_eq!(frame, vec![100, -200, 300, -400]);
}
#[test]
fn three_peers_sum_without_saturation() {
let a = vec![10, 20];
-2
View File
@@ -20,9 +20,7 @@ pub mod recents;
pub mod discovery;
pub mod hotkeys;
pub mod files;
pub mod playlist;
pub mod game;
pub mod widget;
use std::fs::File;
use std::path::{Path, PathBuf};
+3 -228
View File
@@ -152,100 +152,6 @@ fn prune_stale_mutations(
seen.retain(|_, last_ts| *last_ts >= floor);
}
/// Three signed, out-of-window payloads inside one minute is enough to distinguish
/// a persistently skewed clock from a single delayed gossip frame without making
/// the user wait long. Repeats are suppressed for five minutes per author.
const CLOCK_SKEW_OBSERVATION_WINDOW_MS: u64 = 60_000;
const CLOCK_SKEW_WARNING_THRESHOLD: usize = 3;
const CLOCK_SKEW_COOLDOWN_MS: u64 = 5 * 60_000;
const CLOCK_SKEW_AUTHORS_SOFT_CAP: usize = 256;
const CLOCK_SKEW_AUTHORS_HARD_CAP: usize = 512;
const CLOCK_SKEW_AUTHOR_TTL_MS: u64 = CLOCK_SKEW_COOLDOWN_MS;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClockSkewWarning {
author: EndpointId,
/// Positive means the peer's sender-stamped clock is ahead of ours.
skew_ms: i64,
}
#[derive(Debug, Default)]
struct ClockSkewMonitor {
authors: HashMap<EndpointId, ClockSkewAuthorState>,
}
#[derive(Debug, Default)]
struct ClockSkewAuthorState {
observed_at: Vec<u64>,
last_seen_ms: u64,
last_warned_ms: Option<u64>,
}
impl ClockSkewMonitor {
fn observe(
&mut self,
author: EndpointId,
skew_ms: i64,
now_ms: u64,
) -> Option<ClockSkewWarning> {
if self.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP {
self.prune_stale_authors(now_ms);
}
let warning = {
let state = self.authors.entry(author).or_default();
state.last_seen_ms = now_ms;
let floor = now_ms.saturating_sub(CLOCK_SKEW_OBSERVATION_WINDOW_MS);
state.observed_at.retain(|ts| *ts >= floor);
state.observed_at.push(now_ms);
if state.observed_at.len() > CLOCK_SKEW_WARNING_THRESHOLD {
let excess = state.observed_at.len() - CLOCK_SKEW_WARNING_THRESHOLD;
state.observed_at.drain(0..excess);
}
let threshold_met = state.observed_at.len() >= CLOCK_SKEW_WARNING_THRESHOLD;
let in_cooldown = state
.last_warned_ms
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
if threshold_met && !in_cooldown {
state.last_warned_ms = Some(now_ms);
Some(ClockSkewWarning { author, skew_ms })
} else {
None
}
};
if self.authors.len() > CLOCK_SKEW_AUTHORS_HARD_CAP {
self.drop_oldest_authors();
}
warning
}
fn prune_stale_authors(&mut self, now_ms: u64) {
let stale_before = now_ms.saturating_sub(CLOCK_SKEW_AUTHOR_TTL_MS);
self.authors.retain(|_, state| {
let last_warning_live = state
.last_warned_ms
.is_some_and(|last| now_ms.saturating_sub(last) < CLOCK_SKEW_COOLDOWN_MS);
last_warning_live || state.last_seen_ms >= stale_before
});
}
fn drop_oldest_authors(&mut self) {
let remove_count = self.authors.len().saturating_sub(CLOCK_SKEW_AUTHORS_SOFT_CAP);
let mut by_age: Vec<_> = self
.authors
.iter()
.map(|(author, state)| (*author, state.last_seen_ms))
.collect();
by_age.sort_by_key(|(_, last_seen_ms)| *last_seen_ms);
for (author, _) in by_age.into_iter().take(remove_count) {
self.authors.remove(&author);
}
}
}
/// Maximum number of distinct peers we hold in a room roster at once.
///
/// Everyone with the room ticket is an authenticated *insider*: a signature only
@@ -507,7 +413,6 @@ impl RoomState for IrohGossipState {
let handle = tokio::spawn(async move {
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
let mut state_mutations_seen = HashMap::new();
let mut clock_skew_monitor = ClockSkewMonitor::default();
// Broadcast initial state
let initial_payload = {
@@ -548,28 +453,9 @@ impl RoomState for IrohGossipState {
// action: a forged/stale payload is dropped here
// so it can't impersonate, evict, or poison
// presence/address-book (security S2).
let received_now_ms = now_millis();
if let Err(reason) = verify_gossip(
&payload,
&topic_bytes,
received_now_ms,
GOSSIP_FRESHNESS_MS,
) {
if reason == GossipReject::OutOfWindow {
let skew_ms = payload.ts as i64 - received_now_ms as i64;
if let Some(warning) = clock_skew_monitor.observe(
payload.author,
skew_ms,
received_now_ms,
) {
let _ = event_tx
.send(RoomEvent::ClockSkewSuspected {
author: warning.author,
skew_ms: warning.skew_ms,
})
.await;
}
}
if let Err(reason) =
verify_gossip(&payload, &topic_bytes, now_millis(), GOSSIP_FRESHNESS_MS)
{
crate::log_msg(&format!(
"Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}",
payload.author, reason
@@ -645,29 +531,6 @@ impl RoomState for IrohGossipState {
let cleaned = crate::sanitize::sanitize_game_label(&g);
(!cleaned.is_empty()).then_some(cleaned)
});
// Music presence is untrusted peer data:
// the track name is display text (sanitize
// + cap like the game label) and the size
// bounds a future fetch (reject anything
// outside the attachment cap).
state.music = state.music.and_then(|mut m| {
let name = crate::sanitize::sanitize_game_label(&m.name);
if name.is_empty() || !crate::files::size_within_cap(m.size) {
return None;
}
m.name = name;
if m.next_id.is_some() {
let ok = m
.next_size
.map(crate::files::size_within_cap)
.unwrap_or(false);
if !ok {
m.next_id = None;
m.next_size = None;
}
}
Some(m)
});
// Bound an insider's advertised address set
// before we retain it / hand it to the dialer
// (Tier C F-01).
@@ -982,7 +845,6 @@ mod tests {
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
}
}
@@ -1088,93 +950,6 @@ mod tests {
assert!(!seen.contains_key(&(a, StateMutationKind::Announce)));
}
#[test]
fn clock_skew_monitor_single_drop_does_not_warn() {
let author = fresh_id();
let mut monitor = ClockSkewMonitor::default();
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
}
#[test]
fn clock_skew_monitor_three_drops_in_window_warn_once() {
let author = fresh_id();
let mut monitor = ClockSkewMonitor::default();
assert_eq!(monitor.observe(author, -121_000, 10_000), None);
assert_eq!(monitor.observe(author, -122_000, 40_000), None);
assert_eq!(
monitor.observe(author, -123_000, 69_999),
Some(ClockSkewWarning { author, skew_ms: -123_000 })
);
assert_eq!(monitor.observe(author, -124_000, 70_000), None);
}
#[test]
fn clock_skew_monitor_cooldown_suppresses_repeats() {
let author = fresh_id();
let mut monitor = ClockSkewMonitor::default();
assert_eq!(monitor.observe(author, 121_000, 0), None);
assert_eq!(monitor.observe(author, 122_000, 10_000), None);
assert!(monitor.observe(author, 123_000, 20_000).is_some());
assert_eq!(monitor.observe(author, 124_000, 30_000), None);
assert_eq!(monitor.observe(author, 125_000, 310_000), None);
assert_eq!(monitor.observe(author, 126_000, 319_000), None);
assert_eq!(monitor.observe(author, 127_000, 319_999), None);
assert_eq!(
monitor.observe(author, 128_000, 320_000),
Some(ClockSkewWarning { author, skew_ms: 128_000 })
);
}
#[test]
fn clock_skew_monitor_tracks_distinct_authors_independently() {
let a = fresh_id();
let b = fresh_id();
let mut monitor = ClockSkewMonitor::default();
assert_eq!(monitor.observe(a, -121_000, 0), None);
assert_eq!(monitor.observe(a, -121_000, 1_000), None);
assert_eq!(monitor.observe(b, 121_000, 0), None);
assert_eq!(monitor.observe(b, 121_000, 1_000), None);
assert_eq!(
monitor.observe(b, 121_000, 2_000),
Some(ClockSkewWarning { author: b, skew_ms: 121_000 })
);
assert_eq!(
monitor.observe(a, -121_000, 2_000),
Some(ClockSkewWarning { author: a, skew_ms: -121_000 })
);
}
#[test]
fn clock_skew_monitor_prunes_stale_authors_when_over_cap() {
let mut monitor = ClockSkewMonitor::default();
for _ in 0..=CLOCK_SKEW_AUTHORS_SOFT_CAP {
assert_eq!(monitor.observe(fresh_id(), -121_000, 1), None);
}
assert!(monitor.authors.len() > CLOCK_SKEW_AUTHORS_SOFT_CAP);
let current = fresh_id();
assert_eq!(
monitor.observe(current, -121_000, CLOCK_SKEW_AUTHOR_TTL_MS + 2),
None
);
assert_eq!(monitor.authors.len(), 1);
assert!(monitor.authors.contains_key(&current));
}
#[test]
fn clock_skew_monitor_hard_cap_bounds_fresh_author_growth() {
let mut monitor = ClockSkewMonitor::default();
for now_ms in 0..(CLOCK_SKEW_AUTHORS_HARD_CAP as u64 + 10) {
let _ = monitor.observe(fresh_id(), -121_000, now_ms);
assert!(monitor.authors.len() <= CLOCK_SKEW_AUTHORS_HARD_CAP);
}
}
#[test]
fn sanitize_endpoint_addr_caps_address_count() {
use std::net::SocketAddr;
+10 -23
View File
@@ -594,21 +594,17 @@ impl IrohTransport {
self.shared.served_files.lock().unwrap().insert(id, bytes);
}
/// Drop a previously-served blob (e.g. a music track no longer current-or-next).
pub fn forget_attachment(&self, id: AttachmentId) {
self.shared.served_files.lock().unwrap().remove(&id);
}
/// Fetch `size` bytes stored under `id` from peer `from` over the files plane.
/// Shared core of `fetch_attachment` and music-track fetching: dials
/// `FILES_ALPN`, writes the 32-byte id, and reads bounded by `size`.
pub async fn fetch_blob(
/// 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,
id: AttachmentId,
size: u64,
att: &ChatAttachment,
) -> Result<Vec<u8>, NetError> {
if !crate::files::size_within_cap(size) {
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();
@@ -627,13 +623,13 @@ impl IrohTransport {
.open_bi()
.await
.map_err(|e| NetError::Other(format!("file fetch: open stream failed: {e}")))?;
send.write_all(&id)
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(size as usize);
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()))?
@@ -643,15 +639,6 @@ impl IrohTransport {
}
Ok(bytes)
}
/// Fetch a chat attachment's bytes from its sender over the file plane.
pub async fn fetch_attachment(
&self,
from: EndpointId,
att: &ChatAttachment,
) -> Result<Vec<u8>, NetError> {
self.fetch_blob(from, att.id, att.size).await
}
}
#[async_trait]
-62
View File
@@ -22,38 +22,6 @@ pub enum NetError {
Other(String),
}
/// A peer's currently-broadcast music track + playback timeline (W22). Rides
/// gossip presence so listeners can tune in, follow track changes, and keep in
/// sync. Untrusted like `name`/`game`: the `name` is sanitized and `size` is
/// cap-checked at gossip ingest. Bytes never ride gossip — they are fetched
/// point-to-point over the files plane by `id`, exactly like a chat attachment.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MusicPresence {
/// Files-plane handle to fetch this track's bytes (minted per track by the DJ).
pub id: crate::files::AttachmentId,
/// Sanitized display name (track filename). Untrusted; cleaned at ingest.
pub name: String,
/// Byte length, bounds the listener's fetch. Must be `<= MAX_ATTACHMENT_BYTES`.
pub size: u64,
/// True while the DJ has the track paused.
pub paused: bool,
/// Wall-clock ms (UNIX epoch) of the timeline anchor. While playing, the true
/// playhead is `position_ms + (now_ms - anchor_ms)`; while paused it is
/// frozen at `position_ms`. Re-stamped on every play/pause/seek.
pub anchor_ms: u64,
/// Playhead position (ms) at `anchor_ms`.
pub position_ms: u64,
/// Files-plane handle for the DJ's NEXT track, so listeners can prefetch it
/// for a gapless skip. `None` when there is no distinct next track (single
/// item playlist) or the DJ isn't ready. Equals a future `id` once that
/// track plays.
#[serde(default)]
pub next_id: Option<crate::files::AttachmentId>,
/// Byte length of the next track; bounds the prefetch. Cap-checked at ingest.
#[serde(default)]
pub next_size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PeerState {
pub name: String,
@@ -80,10 +48,6 @@ pub struct PeerState {
/// Defaulted so peers/configs predating the field still deserialize.
#[serde(default)]
pub game: Option<String>,
/// This peer's currently-broadcast music track and playback timeline, or
/// `None` when not broadcasting. Defaulted so pre-W22 peers deserialize.
#[serde(default)]
pub music: Option<MusicPresence>,
}
/// The locally-owned, "sticky" pieces of our own presence: the identity fields
@@ -106,8 +70,6 @@ pub struct SelfPresence {
/// (see `crate::sanitize::sanitize_game_label`) before being stored here, so
/// the outgoing announce carries a safe value.
pub game: Option<String>,
/// Our current broadcast timeline, or `None` when not broadcasting / not playing.
pub music: Option<MusicPresence>,
}
impl SelfPresence {
@@ -127,7 +89,6 @@ impl SelfPresence {
sharing,
avatar: self.avatar.clone(),
game: self.game.clone(),
music: self.music.clone(),
}
}
}
@@ -145,10 +106,6 @@ pub enum RoomEvent {
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
/// reconnect path the way it used to.
PeerConnectionLost(EndpointId),
/// A validly signed gossip payload was rejected only because its timestamp is
/// outside the replay-protection window. The peer is not in the roster yet,
/// so this surfaces as a room-level warning instead of a peer-card state.
ClockSkewSuspected { author: EndpointId, skew_ms: i64 },
/// 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.
@@ -346,7 +303,6 @@ mod tests {
sharing: None,
avatar: crate::avatar::Avatar::default(),
game: None,
music: None,
}
}
@@ -462,7 +418,6 @@ mod tests {
name: "Alice".to_string(),
avatar: crate::avatar::Avatar::default(),
game: Some("Half-Life 2".to_string()),
music: None,
};
// Volatile fields come from the call; sticky fields from the struct.
let muted = presence.to_state(true, addr.clone(), Some("ticket".to_string()));
@@ -486,21 +441,4 @@ mod tests {
let deserialized: PeerState = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn music_presence_serde_round_trip() {
let original = MusicPresence {
id: [3u8; 32],
name: "track.ogg".to_string(),
size: 1234,
paused: false,
anchor_ms: 1_700_000_000_000,
position_ms: 42_000,
next_id: None,
next_size: None,
};
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: MusicPresence = serde_json::from_str(&serialized).unwrap();
assert_eq!(original, deserialized);
}
}
-114
View File
@@ -1,114 +0,0 @@
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PlaylistKind {
M3u,
Pls,
}
/// Classify a path by extension into a playlist kind, or None if it is not a
/// recognized playlist file. Case-insensitive: m3u/m3u8 -> M3u, pls -> Pls.
pub fn playlist_kind(path: &Path) -> Option<PlaylistKind> {
let ext = path.extension()?.to_string_lossy();
match ext.to_ascii_lowercase().as_str() {
"m3u" | "m3u8" => Some(PlaylistKind::M3u),
"pls" => Some(PlaylistKind::Pls),
_ => None,
}
}
/// Parse an m3u/m3u8 or pls playlist into local audio file paths. Remote entries
/// (http/https/ftp URLs) and non-audio entries are skipped; relative paths are
/// resolved against `base_dir` (the playlist file's parent directory). Order is
/// preserved. Does not touch the filesystem.
pub fn parse_playlist(contents: &str, base_dir: &Path, kind: PlaylistKind) -> Vec<PathBuf> {
let entries: Vec<&str> = match kind {
PlaylistKind::M3u => contents
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect(),
PlaylistKind::Pls => contents
.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
key.trim()
.to_ascii_lowercase()
.starts_with("file")
.then_some(value.trim())
})
.filter(|line| !line.is_empty())
.collect(),
};
entries
.into_iter()
.filter_map(|entry| playlist_entry_path(entry, base_dir))
.collect()
}
fn playlist_entry_path(entry: &str, base_dir: &Path) -> Option<PathBuf> {
let lower = entry.to_ascii_lowercase();
if lower.starts_with("http://")
|| lower.starts_with("https://")
|| lower.starts_with("ftp://")
{
return None;
}
let path = Path::new(entry);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
base_dir.join(path)
};
let file_name = resolved.file_name()?.to_string_lossy();
crate::files::looks_like_audio_name(&file_name).then_some(resolved)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn m3u_skips_comments_and_remote_urls() {
let base = Path::new("/music/lists");
let contents = "\
#EXTM3U
#EXTINF:123,Artist - Song
tracks/song.ogg
https://example.com/stream.mp3
";
assert_eq!(
parse_playlist(contents, base, PlaylistKind::M3u),
vec![PathBuf::from("/music/lists/tracks/song.ogg")]
);
}
#[test]
fn pls_keeps_file_values_and_skips_non_audio() {
let base = Path::new("/music");
let contents = "\
[playlist]
File1=one.flac
Title1=One
File2=notes.txt
File3=/var/audio/two.MP3
";
assert_eq!(
parse_playlist(contents, base, PlaylistKind::Pls),
vec![
PathBuf::from("/music/one.flac"),
PathBuf::from("/var/audio/two.MP3"),
]
);
}
#[test]
fn playlist_kind_is_case_insensitive() {
assert_eq!(playlist_kind(Path::new("mix.M3U")), Some(PlaylistKind::M3u));
assert_eq!(playlist_kind(Path::new("mix.m3u8")), Some(PlaylistKind::M3u));
assert_eq!(playlist_kind(Path::new("mix.PLS")), Some(PlaylistKind::Pls));
assert_eq!(playlist_kind(Path::new("mix.txt")), None);
}
}
+82
View File
@@ -15,6 +15,16 @@
use crate::friends::FriendStore;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Duration, Instant};
/// Maximum immediate presence replies to one friend before throttling. Normal
/// presence polling is once per minute, so this only catches repeated/manual or
/// abusive probes while still allowing a short burst after app startup.
pub const PRESENCE_RATE_LIMIT_BURST: u32 = 4;
/// Refill one presence-reply token per friend at this cadence.
pub const PRESENCE_RATE_LIMIT_REFILL: Duration = Duration::from_secs(15);
/// The user's presence posture — how reachable they are to friends while idle.
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends
@@ -100,6 +110,48 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
mode.answers_pings() && friends.contains(from)
}
#[derive(Debug, Clone)]
struct RateBucket {
tokens: u32,
last_refill: Instant,
}
/// Per-friend limiter for inbound presence pings. It is intentionally keyed by
/// the authenticated connection id, not payload data. Callers should only invoke
/// it after [`should_answer`] passes, so strangers do not consume memory here.
#[derive(Debug, Default, Clone)]
pub struct PresenceRateLimiter {
buckets: HashMap<EndpointId, RateBucket>,
}
impl PresenceRateLimiter {
/// Return whether `from` may receive a presence reply at `now`.
///
/// This is a token bucket: each friend starts with a small burst and regains
/// one token every [`PRESENCE_RATE_LIMIT_REFILL`]. A denied probe should be
/// answered with no data, matching the listener's "reveal nothing" policy.
pub fn allow(&mut self, from: EndpointId, now: Instant) -> bool {
let bucket = self.buckets.entry(from).or_insert(RateBucket {
tokens: PRESENCE_RATE_LIMIT_BURST,
last_refill: now,
});
let elapsed = now.saturating_duration_since(bucket.last_refill);
let refill = elapsed.as_secs() / PRESENCE_RATE_LIMIT_REFILL.as_secs();
if refill > 0 {
let refill = refill.min(u32::MAX as u64) as u32;
bucket.tokens = PRESENCE_RATE_LIMIT_BURST.min(bucket.tokens.saturating_add(refill));
bucket.last_refill = now;
}
if bucket.tokens == 0 {
return false;
}
bucket.tokens -= 1;
true
}
}
/// What we learned about a friend from a successful ping reply.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence {
@@ -177,6 +229,36 @@ mod tests {
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible));
}
#[test]
fn presence_rate_limiter_allows_a_small_burst_then_refills() {
let mut limiter = PresenceRateLimiter::default();
let friend = id();
let now = Instant::now();
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
assert!(limiter.allow(friend, now));
}
assert!(!limiter.allow(friend, now));
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL - Duration::from_millis(1)));
assert!(limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
assert!(!limiter.allow(friend, now + PRESENCE_RATE_LIMIT_REFILL));
}
#[test]
fn presence_rate_limiter_is_per_peer() {
let mut limiter = PresenceRateLimiter::default();
let a = id();
let b = id();
let now = Instant::now();
for _ in 0..PRESENCE_RATE_LIMIT_BURST {
assert!(limiter.allow(a, now));
}
assert!(!limiter.allow(a, now));
assert!(limiter.allow(b, now));
}
#[test]
fn presence_mode_flags() {
assert!(PresenceMode::Discoverable.publishes_to_discovery());
+2 -9
View File
@@ -32,14 +32,7 @@ pub const FRIENDS_PROTO: u32 = 1;
/// strictly required for decoding — but per the versioning discipline a wire-shape
/// change is isolated into its own topic + signature domain so v2 and v3 peers
/// never share a swarm. Resync everyone, exactly like the W4 avatar bump.
///
/// v4 (0.6.0): `PeerState` gained an optional `music` presence field carrying a
/// current shared-listening track descriptor and playback timeline. Bytes still
/// ride the files plane by id; gossip carries only the descriptor/timeline.
///
/// v5 (0.7.0): `MusicPresence` gained optional prefetch hints for the next
/// track so tuned-in listeners can fetch it before the DJ advances.
pub const GOSSIP_PROTO: u32 = 5;
pub const GOSSIP_PROTO: u32 = 3;
/// File-transfer plane version (chat attachment request/stream shape). Bump on
/// any change. Mirrored in [`FILES_ALPN`].
pub const FILES_PROTO: u32 = 1;
@@ -54,7 +47,7 @@ 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-v5";
pub const GOSSIP_SIG_DOMAIN: &str = "peerspeak-gossip-v3";
/// Version-namespace a room topic so peers on different gossip protocol versions
/// derive **different subscription topics from the same ticket** and therefore
-943
View File
@@ -1,943 +0,0 @@
use iced::advanced::clipboard::{self, Clipboard};
use iced::advanced::layout;
use iced::advanced::mouse;
use iced::advanced::overlay;
use iced::advanced::renderer;
use iced::advanced::text;
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{Layout, Shell};
use iced::widget::text_input;
use iced::{
alignment, Background, Border, Color, Element, Event, Length, Padding,
Pixels, Point, Rectangle, Shadow, Size, Vector,
};
use std::rc::Rc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit {
pub value: String,
pub cursor: usize,
}
pub fn copy_selection(value: &str, start: usize, end: usize) -> Option<String> {
let value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end);
(start != end).then(|| value.select(start, end).to_string())
}
pub fn cut_selection(
value: &str,
start: usize,
end: usize,
) -> (Edit, Option<String>) {
let mut value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end);
if start == end {
return (
Edit {
value: value.to_string(),
cursor: start,
},
None,
);
}
let selected = value.select(start, end).to_string();
value.remove_many(start, end);
(
Edit {
value: value.to_string(),
cursor: start,
},
Some(selected),
)
}
pub fn paste(value: &str, start: usize, end: usize, clip: &str) -> Edit {
let mut value = text_input::Value::new(value);
let (start, end) = normalized_range(&value, start, end);
let clip = text_input::Value::new(clip);
let cursor = start + clip.len();
if start != end {
value.remove_many(start, end);
}
value.insert_many(start, clip);
Edit {
value: value.to_string(),
cursor,
}
}
pub fn select_all_range(value: &str) -> (usize, usize) {
let value = text_input::Value::new(value);
(0, value.len())
}
fn normalized_range(
value: &text_input::Value,
start: usize,
end: usize,
) -> (usize, usize) {
let len = value.len();
(start.min(end).min(len), start.max(end).min(len))
}
type InputStyleFn<'a, Theme> =
Rc<dyn Fn(&Theme, text_input::Status) -> text_input::Style + 'a>;
pub fn context_input<'a, Message, Theme, Renderer>(
placeholder: &str,
value: &str,
) -> ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer,
{
ContextInput::new(placeholder, value)
}
pub fn locked_value<'a, Message, Theme, Renderer>(
value: &str,
noop: Message,
) -> ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer,
{
ContextInput::new("", value)
.on_input(move |_| noop.clone())
.locked(true)
}
pub struct ContextInput<
'a,
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
input: text_input::TextInput<'a, Message, Theme, Renderer>,
value: String,
is_secure: bool,
locked: bool,
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
style: Option<InputStyleFn<'a, Theme>>,
}
impl<'a, Message, Theme, Renderer>
ContextInput<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer,
{
pub fn new(placeholder: &str, value: &str) -> Self {
Self {
input: text_input::TextInput::new(placeholder, value),
value: value.to_owned(),
is_secure: false,
locked: false,
on_input: None,
on_paste: None,
style: None,
}
}
pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
self.input = self.input.id(id);
self
}
pub fn secure(mut self, is_secure: bool) -> Self {
self.is_secure = is_secure;
self.input = self.input.secure(is_secure);
self
}
pub fn locked(mut self, yes: bool) -> Self {
self.locked = yes;
self
}
pub fn on_input(
mut self,
on_input: impl Fn(String) -> Message + 'a,
) -> Self {
let on_input: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_input);
let input_callback = Rc::clone(&on_input);
self.input =
self.input.on_input(move |value| input_callback.as_ref()(value));
self.on_input = Some(on_input);
self
}
pub fn on_submit(mut self, message: Message) -> Self {
self.input = self.input.on_submit(message);
self
}
pub fn on_submit_maybe(mut self, message: Option<Message>) -> Self {
self.input = self.input.on_submit_maybe(message);
self
}
pub fn on_paste(
mut self,
on_paste: impl Fn(String) -> Message + 'a,
) -> Self {
let on_paste: Rc<dyn Fn(String) -> Message + 'a> =
Rc::new(on_paste);
let paste_callback = Rc::clone(&on_paste);
self.input =
self.input.on_paste(move |value| paste_callback.as_ref()(value));
self.on_paste = Some(on_paste);
self
}
pub fn font(mut self, font: Renderer::Font) -> Self {
self.input = self.input.font(font);
self
}
pub fn icon(mut self, icon: text_input::Icon<Renderer::Font>) -> Self {
self.input = self.input.icon(icon);
self
}
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.input = self.input.width(width);
self
}
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
self.input = self.input.padding(padding);
self
}
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.input = self.input.size(size);
self
}
pub fn line_height(
mut self,
line_height: impl Into<text::LineHeight>,
) -> Self {
self.input = self.input.line_height(line_height);
self
}
pub fn align_x(
mut self,
alignment: impl Into<alignment::Horizontal>,
) -> Self {
self.input = self.input.align_x(alignment);
self
}
pub fn style(
mut self,
style: impl Fn(&Theme, text_input::Status) -> text_input::Style + 'a,
) -> Self
where
Theme::Class<'a>: From<text_input::StyleFn<'a, Theme>>,
{
let style: InputStyleFn<'a, Theme> = Rc::new(style);
let input_style = Rc::clone(&style);
self.input = self
.input
.style(move |theme, status| input_style.as_ref()(theme, status));
self.style = Some(style);
self
}
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.input = self.input.class(class);
self.style = None;
self
}
}
#[derive(Default)]
struct ContextInputState {
menu: Option<MenuState>,
}
#[derive(Debug, Clone, Copy)]
struct MenuState {
anchor: Point,
selection: (usize, usize),
}
impl<Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for ContextInput<'_, Message, Theme, Renderer>
where
Message: Clone,
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<ContextInputState>()
}
fn state(&self) -> tree::State {
tree::State::new(ContextInputState::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.input as &dyn Widget<_, _, _>)]
}
fn diff(&self, tree: &mut Tree) {
if tree.children.is_empty() {
tree.children
.push(Tree::new(&self.input as &dyn Widget<_, _, _>));
} else {
tree.children[0].diff(&self.input as &dyn Widget<_, _, _>);
tree.children.truncate(1);
}
}
fn size(&self) -> Size<Length> {
Widget::size(&self.input)
}
fn size_hint(&self) -> Size<Length> {
Widget::size_hint(&self.input)
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
Widget::layout(&mut self.input, &mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
Widget::operate(
&mut self.input,
&mut tree.children[0],
layout,
renderer,
operation,
);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let right_click_on_input = matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
) && cursor.is_over(layout.bounds());
if right_click_on_input {
let value = text_input::Value::new(&self.value);
let input_state = tree.children[0]
.state
.downcast_ref::<text_input::State<Renderer::Paragraph>>();
let selection = match input_state.cursor().state(&value) {
text_input::cursor::State::Index(index) => {
let index = index.min(value.len());
(index, index)
}
text_input::cursor::State::Selection { start, end } => {
normalized_range(&value, start, end)
}
};
tree.state.downcast_mut::<ContextInputState>().menu =
cursor.position().map(|anchor| MenuState {
anchor,
selection,
});
shell.capture_event();
shell.request_redraw();
return;
}
Widget::update(
&mut self.input,
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
Widget::draw(
&self.input,
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
Widget::mouse_interaction(
&self.input,
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
_layout: Layout<'a>,
_renderer: &Renderer,
_viewport: &Rectangle,
_translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
let Tree {
state, children, ..
} = tree;
let menu = &mut state.downcast_mut::<ContextInputState>().menu;
if menu.is_none() {
return None;
}
let input_state = children[0]
.state
.downcast_mut::<text_input::State<Renderer::Paragraph>>();
Some(overlay::Element::new(Box::new(ContextMenuOverlay {
menu,
input_state,
value: &self.value,
is_secure: self.is_secure,
locked: self.locked,
on_input: self.on_input.clone(),
on_paste: self.on_paste.clone(),
style: self.style.clone(),
})))
}
}
impl<'a, Message, Theme, Renderer>
From<ContextInput<'a, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: Clone + 'a,
Theme: text_input::Catalog + 'a,
Renderer: text::Renderer + 'a,
{
fn from(
input: ContextInput<'a, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(input)
}
}
struct ContextMenuOverlay<'a, Message, Theme, Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
menu: &'a mut Option<MenuState>,
input_state: &'a mut text_input::State<Renderer::Paragraph>,
value: &'a str,
is_secure: bool,
locked: bool,
on_input: Option<Rc<dyn Fn(String) -> Message + 'a>>,
on_paste: Option<Rc<dyn Fn(String) -> Message + 'a>>,
style: Option<InputStyleFn<'a, Theme>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MenuAction {
Cut,
Copy,
Paste,
SelectAll,
}
impl MenuAction {
const ALL: [Self; 4] = [
Self::Cut,
Self::Copy,
Self::Paste,
Self::SelectAll,
];
fn label(self) -> &'static str {
match self {
Self::Cut => "Cut",
Self::Copy => "Copy",
Self::Paste => "Paste",
Self::SelectAll => "Select All",
}
}
}
const MENU_WIDTH: f32 = 136.0;
const ITEM_HEIGHT: f32 = 28.0;
const TEXT_SIZE: f32 = 13.0;
const MENU_PADDING_X: f32 = 10.0;
impl<Message, Theme, Renderer> overlay::Overlay<Message, Theme, Renderer>
for ContextMenuOverlay<'_, Message, Theme, Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
fn layout(&mut self, _renderer: &Renderer, bounds: Size) -> layout::Node {
let size = Size::new(MENU_WIDTH, ITEM_HEIGHT * MenuAction::ALL.len() as f32);
let Some(menu) = self.menu.as_ref() else {
return layout::Node::new(Size::ZERO);
};
let x = menu.anchor.x.min((bounds.width - size.width).max(0.0));
let y = menu.anchor.y.min((bounds.height - size.height).max(0.0));
layout::Node::new(size).move_to(Point::new(x.max(0.0), y.max(0.0)))
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
_style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
let active_style = input_style(theme, self.style.as_ref(), text_input::Status::Active);
let hovered_style =
input_style(theme, self.style.as_ref(), text_input::Status::Hovered);
let bounds = layout.bounds();
let viewport = Rectangle::INFINITE;
renderer.fill_quad(
renderer::Quad {
bounds,
border: Border {
radius: 5.0.into(),
width: 1.0,
color: active_style.border.color,
},
shadow: Shadow {
color: Color::from_rgba(0.0, 0.0, 0.0, 0.22),
offset: Vector::new(0.0, 4.0),
blur_radius: 10.0,
},
..renderer::Quad::default()
},
active_style.background,
);
for (index, action) in MenuAction::ALL.iter().copied().enumerate() {
let item_bounds = item_bounds(bounds, index);
let enabled = self.enabled(action);
let hovered = enabled && cursor.is_over(item_bounds);
if hovered {
renderer.fill_quad(
renderer::Quad {
bounds: item_bounds,
border: Border {
radius: 3.0.into(),
..Border::default()
},
..renderer::Quad::default()
},
Background::Color(hovered_style.selection),
);
}
renderer.fill_text(
text::Text {
content: action.label().to_owned(),
bounds: Size::new(item_bounds.width - MENU_PADDING_X * 2.0, item_bounds.height),
size: Pixels(TEXT_SIZE),
line_height: text::LineHeight::default(),
font: renderer.default_font(),
align_x: text::Alignment::Default,
align_y: alignment::Vertical::Center,
shaping: text::Shaping::Advanced,
wrapping: text::Wrapping::default(),
},
Point::new(item_bounds.x + MENU_PADDING_X, item_bounds.center_y()),
if enabled {
active_style.value
} else {
disabled_color(active_style.value)
},
viewport,
);
}
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
match event {
Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
..
}) => {
self.close(shell);
}
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
let Some(position) = cursor.position() else {
self.close(shell);
return;
};
let bounds = layout.bounds();
if !bounds.contains(position) {
self.close(shell);
return;
}
if let Some(action) = self.hit_action(bounds, position) {
if self.enabled(action) {
self.perform(action, clipboard, shell);
}
self.close(shell);
}
}
Event::Mouse(mouse::Event::ButtonPressed(_)) => {
let should_close = cursor
.position()
.is_none_or(|position| !layout.bounds().contains(position));
if should_close {
self.close(shell);
}
}
_ => {}
}
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
) -> mouse::Interaction {
let Some(position) = cursor.position() else {
return mouse::Interaction::default();
};
if self.hit_action(layout.bounds(), position).is_some() {
mouse::Interaction::Pointer
} else {
mouse::Interaction::default()
}
}
}
impl<Message, Theme, Renderer> ContextMenuOverlay<'_, Message, Theme, Renderer>
where
Theme: text_input::Catalog,
Renderer: text::Renderer,
{
fn enabled(&self, action: MenuAction) -> bool {
let Some(menu) = self.menu.as_ref() else {
return false;
};
let has_selection = menu.selection.0 != menu.selection.1;
let has_value = !text_input::Value::new(self.value).is_empty();
menu_action_enabled(
action,
has_selection,
has_value,
self.is_secure,
self.locked,
)
}
fn hit_action(
&self,
bounds: Rectangle,
position: Point,
) -> Option<MenuAction> {
if !bounds.contains(position) {
return None;
}
let index = ((position.y - bounds.y) / ITEM_HEIGHT).floor() as usize;
MenuAction::ALL.get(index).copied()
}
fn perform(
&mut self,
action: MenuAction,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
let Some(menu) = self.menu.as_ref().copied() else {
return;
};
let (start, end) = menu.selection;
match action {
MenuAction::Cut => {
let (edit, selected) = cut_selection(self.value, start, end);
if let Some(selected) = selected {
clipboard.write(clipboard::Kind::Standard, selected);
self.publish_edit(edit, shell);
}
}
MenuAction::Copy => {
if let Some(selected) = copy_selection(self.value, start, end) {
clipboard.write(clipboard::Kind::Standard, selected);
}
}
MenuAction::Paste => {
let clip = clipboard
.read(clipboard::Kind::Standard)
.unwrap_or_default()
.chars()
.filter(|c| !c.is_control())
.collect::<String>();
let edit = paste(self.value, start, end, &clip);
self.publish_paste(edit, shell);
}
MenuAction::SelectAll => {
let (start, end) = select_all_range(self.value);
self.input_state.select_range(start, end);
shell.request_redraw();
}
}
}
fn publish_edit(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
if let Some(on_input) = &self.on_input {
self.input_state.move_cursor_to(edit.cursor);
shell.publish(on_input.as_ref()(edit.value));
shell.request_redraw();
}
}
fn publish_paste(&mut self, edit: Edit, shell: &mut Shell<'_, Message>) {
self.input_state.move_cursor_to(edit.cursor);
if let Some(on_paste) = &self.on_paste {
shell.publish(on_paste.as_ref()(edit.value));
} else if let Some(on_input) = &self.on_input {
shell.publish(on_input.as_ref()(edit.value));
}
shell.request_redraw();
}
fn close(&mut self, shell: &mut Shell<'_, Message>) {
*self.menu = None;
shell.capture_event();
shell.request_redraw();
}
}
fn item_bounds(menu_bounds: Rectangle, index: usize) -> Rectangle {
Rectangle {
x: menu_bounds.x + 3.0,
y: menu_bounds.y + 3.0 + ITEM_HEIGHT * index as f32,
width: menu_bounds.width - 6.0,
height: ITEM_HEIGHT,
}
}
fn input_style<Theme: text_input::Catalog>(
theme: &Theme,
style: Option<&InputStyleFn<'_, Theme>>,
status: text_input::Status,
) -> text_input::Style {
if let Some(style) = style {
style.as_ref()(theme, status)
} else {
let class = <Theme as text_input::Catalog>::default();
theme.style(&class, status)
}
}
fn disabled_color(color: Color) -> Color {
Color {
a: color.a * 0.45,
..color
}
}
fn menu_action_enabled(
action: MenuAction,
has_selection: bool,
has_value: bool,
is_secure: bool,
locked: bool,
) -> bool {
match action {
MenuAction::Cut => has_selection && !is_secure && !locked,
MenuAction::Copy => has_selection && !is_secure,
MenuAction::Paste => !locked,
MenuAction::SelectAll => has_value,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copy_selection_returns_middle_substring_and_empty_none() {
assert_eq!(copy_selection("abcdef", 2, 5), Some("cde".to_owned()));
assert_eq!(copy_selection("abcdef", 3, 3), None);
}
#[test]
fn cut_selection_removes_range_and_copies_selection() {
let (edit, clip) = cut_selection("abcdef", 2, 5);
assert_eq!(
edit,
Edit {
value: "abf".to_owned(),
cursor: 2,
}
);
assert_eq!(clip, Some("cde".to_owned()));
let (edit, clip) = cut_selection("abcdef", 3, 3);
assert_eq!(
edit,
Edit {
value: "abcdef".to_owned(),
cursor: 3,
}
);
assert_eq!(clip, None);
}
#[test]
fn paste_replaces_selection_or_inserts_at_cursor() {
assert_eq!(
paste("abcdef", 2, 5, "XY"),
Edit {
value: "abXYf".to_owned(),
cursor: 4,
}
);
assert_eq!(
paste("abcdef", 3, 3, "XY"),
Edit {
value: "abcXYdef".to_owned(),
cursor: 5,
}
);
}
#[test]
fn select_all_range_uses_grapheme_length() {
assert_eq!(select_all_range(""), (0, 0));
assert_eq!(select_all_range("abé🦀"), (0, 4));
}
#[test]
fn unicode_selection_boundaries_are_grapheme_correct() {
assert_eq!(copy_selection("aé🦀z", 1, 3), Some("é🦀".to_owned()));
let (edit, clip) = cut_selection("aé🦀z", 2, 3);
assert_eq!(clip, Some("🦀".to_owned()));
assert_eq!(
edit,
Edit {
value: "aéz".to_owned(),
cursor: 2,
}
);
assert_eq!(
paste("aéz", 2, 2, "🦀"),
Edit {
value: "aé🦀z".to_owned(),
cursor: 3,
}
);
}
#[test]
fn locked_menu_allows_copy_and_select_all_only() {
assert!(!menu_action_enabled(MenuAction::Cut, true, true, false, true));
assert!(menu_action_enabled(MenuAction::Copy, true, true, false, true));
assert!(!menu_action_enabled(MenuAction::Paste, true, true, false, true));
assert!(menu_action_enabled(MenuAction::SelectAll, true, true, false, true));
assert!(!menu_action_enabled(MenuAction::Copy, false, true, false, true));
assert!(!menu_action_enabled(MenuAction::SelectAll, false, false, false, true));
}
}
-2
View File
@@ -1,2 +0,0 @@
pub mod context_input;
pub mod selectable_text;
-890
View File
@@ -1,890 +0,0 @@
use iced::advanced::clipboard::{self, Clipboard};
use iced::advanced::layout;
use iced::advanced::mouse;
use iced::advanced::renderer;
use iced::advanced::text::{self as advanced_text, Paragraph, Span};
use iced::advanced::widget::tree::{self, Tree};
use iced::advanced::widget::Widget;
use iced::advanced::{Layout, Shell};
use iced::widget::text::{
self as widget_text, Alignment, Catalog, LineHeight, Shaping, Style, StyleFn,
Wrapping,
};
use iced::{
alignment, Background, Border, Color, Element, Event, Length, Pixels, Point,
Rectangle, Size, Vector, keyboard,
};
const DRAG_THRESHOLD: f32 = 3.0;
const HIT_SEARCH_STEPS: usize = 24;
// Offsets here are paragraph-global BYTE offsets. `Paragraph::hit_test` returns
// `Hit::CharOffset(cursor.index)`, and cosmic-text's `cursor.index` is a byte
// offset WITHIN its buffer line — it discards the line number. That equals the
// global byte offset only when the text is a single logical line. Chat bodies
// satisfy this because `app::sanitize_chat` turns every control char (incl. `\n`
// and `\r`) into a space and collapses whitespace, so a stored message can never
// contain a newline. If that sanitizer ever starts preserving newlines, this
// widget's per-line offsets would stop being global and selection/copy across
// lines would break — revisit then.
pub fn selected_substring(
text: &str,
anchor: usize,
cursor: usize,
) -> Option<String> {
let (start, end) = normalized_byte_range(text, anchor, cursor);
(start != end).then(|| text[start..end].to_owned())
}
pub fn select_all(text: &str) -> (usize, usize) {
(0, text.len())
}
fn normalized_byte_range(
text: &str,
anchor: usize,
cursor: usize,
) -> (usize, usize) {
let start = clamp_to_char_boundary(text, anchor.min(cursor));
let end = clamp_to_char_boundary(text, anchor.max(cursor));
(start.min(end), start.max(end))
}
fn clamp_to_char_boundary(text: &str, offset: usize) -> usize {
let mut offset = offset.min(text.len());
while offset > 0 && !text.is_char_boundary(offset) {
offset -= 1;
}
offset
}
pub fn selectable_rich_text<'a, Link, Message, Theme, Renderer>(
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
) -> SelectableRichText<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog + 'a,
Renderer: advanced_text::Renderer,
Renderer::Font: 'a,
{
SelectableRichText::with_spans(spans)
}
pub struct SelectableRichText<
'a,
Link,
Message,
Theme = iced::Theme,
Renderer = iced::Renderer,
> where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
{
spans: Box<dyn AsRef<[Span<'a, Link, Renderer::Font>]> + 'a>,
size: Option<Pixels>,
line_height: LineHeight,
width: Length,
height: Length,
font: Option<Renderer::Font>,
align_x: Alignment,
align_y: alignment::Vertical,
wrapping: Wrapping,
class: Theme::Class<'a>,
hovered_link: Option<usize>,
on_link_click: Option<Box<dyn Fn(Link) -> Message + 'a>>,
selection_color: Color,
}
impl<'a, Link, Message, Theme, Renderer>
SelectableRichText<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
Renderer::Font: 'a,
{
pub fn new() -> Self {
Self {
spans: Box::new([]),
size: None,
line_height: LineHeight::default(),
width: Length::Shrink,
height: Length::Shrink,
font: None,
align_x: Alignment::Default,
align_y: alignment::Vertical::Top,
wrapping: Wrapping::default(),
class: Theme::default(),
hovered_link: None,
on_link_click: None,
selection_color: Color::from_rgba(0.35, 0.55, 0.95, 0.35),
}
}
pub fn with_spans(
spans: impl AsRef<[Span<'a, Link, Renderer::Font>]> + 'a,
) -> Self {
Self {
spans: Box::new(spans),
..Self::new()
}
}
pub fn size(mut self, size: impl Into<Pixels>) -> Self {
self.size = Some(size.into());
self
}
pub fn line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
self.line_height = line_height.into();
self
}
pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
self.font = Some(font.into());
self
}
pub fn width(mut self, width: impl Into<Length>) -> Self {
self.width = width.into();
self
}
pub fn height(mut self, height: impl Into<Length>) -> Self {
self.height = height.into();
self
}
pub fn align_x(mut self, alignment: impl Into<Alignment>) -> Self {
self.align_x = alignment.into();
self
}
pub fn align_y(
mut self,
alignment: impl Into<alignment::Vertical>,
) -> Self {
self.align_y = alignment.into();
self
}
pub fn wrapping(mut self, wrapping: Wrapping) -> Self {
self.wrapping = wrapping;
self
}
pub fn on_link_click(
mut self,
on_link_click: impl Fn(Link) -> Message + 'a,
) -> Self {
self.on_link_click = Some(Box::new(on_link_click));
self
}
pub fn selection_color(mut self, color: Color) -> Self {
self.selection_color = Color {
a: color.a.min(0.35),
..color
};
self
}
pub fn style(mut self, style: impl Fn(&Theme) -> Style + 'a) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.class = (Box::new(style) as StyleFn<'a, Theme>).into();
self
}
pub fn color(self, color: impl Into<Color>) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
self.color_maybe(Some(color))
}
pub fn color_maybe(self, color: Option<impl Into<Color>>) -> Self
where
Theme::Class<'a>: From<StyleFn<'a, Theme>>,
{
let color = color.map(Into::into);
self.style(move |_theme| Style { color })
}
pub fn class(mut self, class: impl Into<Theme::Class<'a>>) -> Self {
self.class = class.into();
self
}
}
impl<'a, Link, Message, Theme, Renderer> Default
for SelectableRichText<'a, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
Renderer::Font: 'a,
{
fn default() -> Self {
Self::new()
}
}
struct SelectableTextState<Link, P: Paragraph> {
spans: Vec<Span<'static, Link, P::Font>>,
span_pressed: Option<usize>,
paragraph: P,
selection: Option<(usize, usize)>,
dragging: bool,
active: bool,
press_position: Option<Point>,
}
impl<Link, P: Paragraph> SelectableTextState<Link, P> {
fn selection_range(&self, text: &str) -> Option<(usize, usize)> {
let (anchor, cursor) = self.selection?;
let (start, end) = normalized_byte_range(text, anchor, cursor);
(start != end).then_some((start, end))
}
}
impl<Link, Message, Theme, Renderer> Widget<Message, Theme, Renderer>
for SelectableRichText<'_, Link, Message, Theme, Renderer>
where
Link: Clone + 'static,
Theme: Catalog,
Renderer: advanced_text::Renderer,
{
fn tag(&self) -> tree::Tag {
tree::Tag::of::<SelectableTextState<Link, Renderer::Paragraph>>()
}
fn state(&self) -> tree::State {
tree::State::new(SelectableTextState::<Link, _> {
spans: Vec::new(),
span_pressed: None,
paragraph: Renderer::Paragraph::default(),
selection: None,
dragging: false,
active: false,
press_position: None,
})
}
fn size(&self) -> Size<Length> {
Size {
width: self.width,
height: self.height,
}
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
layout_text(
tree.state
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>(),
renderer,
limits,
TextLayout {
width: self.width,
height: self.height,
spans: self.spans.as_ref().as_ref(),
line_height: self.line_height,
size: self.size,
font: self.font,
align_x: self.align_x,
align_y: self.align_y,
wrapping: self.wrapping,
},
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
defaults: &renderer::Style,
layout: Layout<'_>,
_cursor: mouse::Cursor,
viewport: &Rectangle,
) {
if !layout.bounds().intersects(viewport) {
return;
}
let state = tree
.state
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
let spans = self.spans.as_ref().as_ref();
let flat_text = flatten_spans(spans);
let style = theme.style(&self.class);
let translation = layout.position() - Point::ORIGIN;
if let Some((start, end)) = state.selection_range(&flat_text) {
let mut rects = selection_rects(&state.paragraph, spans.len(), start, end);
if rects.is_empty() {
rects = visual_lines(&state.paragraph, spans.len());
}
for bounds in rects {
renderer.fill_quad(
renderer::Quad {
bounds: bounds + translation,
border: Border {
radius: 2.0.into(),
..Border::default()
},
..renderer::Quad::default()
},
Background::Color(self.selection_color),
);
}
}
for (index, span) in spans.iter().enumerate() {
let is_hovered_link = self.on_link_click.is_some()
&& Some(index) == self.hovered_link;
if span.highlight.is_some()
|| span.underline
|| span.strikethrough
|| is_hovered_link
{
let regions = state.paragraph.span_bounds(index);
if let Some(highlight) = span.highlight {
for bounds in &regions {
let bounds = Rectangle::new(
bounds.position()
- Vector::new(
span.padding.left,
span.padding.top,
),
bounds.size()
+ Size::new(span.padding.x(), span.padding.y()),
);
renderer.fill_quad(
renderer::Quad {
bounds: bounds + translation,
border: highlight.border,
..Default::default()
},
highlight.background,
);
}
}
if span.underline || span.strikethrough || is_hovered_link {
let size = span
.size
.or(self.size)
.unwrap_or(renderer.default_size());
let line_height = span
.line_height
.unwrap_or(self.line_height)
.to_absolute(size);
let color = span
.color
.or(style.color)
.unwrap_or(defaults.text_color);
let baseline = translation
+ Vector::new(
0.0,
size.0 + (line_height.0 - size.0) / 2.0,
);
if span.underline || is_hovered_link {
for bounds in &regions {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
bounds.position() + baseline
- Vector::new(0.0, size.0 * 0.08),
Size::new(bounds.width, 1.0),
),
..Default::default()
},
color,
);
}
}
if span.strikethrough {
for bounds in &regions {
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle::new(
bounds.position() + baseline
- Vector::new(0.0, size.0 / 2.0),
Size::new(bounds.width, 1.0),
),
..Default::default()
},
color,
);
}
}
}
}
}
widget_text::draw(
renderer,
defaults,
layout.bounds(),
&state.paragraph,
style,
viewport,
);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
_renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
let local_position = cursor.position_in(bounds);
let state = tree
.state
.downcast_mut::<SelectableTextState<Link, Renderer::Paragraph>>();
let spans = self.spans.as_ref().as_ref();
let flat_text = flatten_spans(spans);
let was_hovered = self.hovered_link.is_some();
self.hovered_link = local_position.and_then(|position| {
state.paragraph.hit_span(position).and_then(|span| {
if spans.get(span)?.link.is_some() {
Some(span)
} else {
None
}
})
});
if was_hovered != self.hovered_link.is_some() {
shell.request_redraw();
}
match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
if let Some(position) = local_position {
state.active = true;
state.dragging = true;
state.press_position = Some(position);
state.span_pressed = self.hovered_link;
state.selection = state
.paragraph
.hit_test(position)
.map(|hit| {
let offset = hit.cursor().min(flat_text.len());
(offset, offset)
});
shell.capture_event();
shell.request_redraw();
} else if state.active || state.selection.is_some() {
state.active = false;
state.dragging = false;
state.press_position = None;
state.span_pressed = None;
state.selection = None;
shell.request_redraw();
}
}
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
if state.dragging
&& let Some(position) = clamped_position(cursor, bounds)
&& let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection
{
state.selection =
Some((anchor, hit.cursor().min(flat_text.len())));
shell.request_redraw();
}
}
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
if state.dragging {
let release_position = clamped_position(cursor, bounds)
.or(local_position)
.or(state.press_position);
let dragged = state
.press_position
.zip(release_position)
.is_some_and(|(start, end)| point_distance(start, end) > DRAG_THRESHOLD);
if let Some(position) = release_position
&& let Some(hit) = state.paragraph.hit_test(position)
&& let Some((anchor, _)) = state.selection
{
state.selection =
Some((anchor, hit.cursor().min(flat_text.len())));
}
if !dragged {
if let (Some(on_link_clicked), Some(span)) =
(&self.on_link_click, state.span_pressed)
&& Some(span) == self.hovered_link
&& let Some(link) =
spans.get(span).and_then(|span| span.link.clone())
{
shell.publish(on_link_clicked(link));
}
state.selection = None;
} else if state.selection_range(&flat_text).is_none() {
state.selection = None;
}
state.dragging = false;
state.span_pressed = None;
state.press_position = None;
shell.capture_event();
shell.request_redraw();
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key,
physical_key,
modifiers,
..
}) if state.active && modifiers.command() => {
match key.to_latin(*physical_key) {
Some('c') | Some('C') => {
if let Some((anchor, cursor)) = state.selection
&& let Some(selected) =
selected_substring(&flat_text, anchor, cursor)
{
clipboard.write(clipboard::Kind::Standard, selected);
shell.capture_event();
}
}
Some('a') | Some('A') => {
state.selection = Some(select_all(&flat_text));
shell.capture_event();
shell.request_redraw();
}
_ => {}
}
}
_ => {}
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
_viewport: &Rectangle,
_renderer: &Renderer,
) -> mouse::Interaction {
let state = tree
.state
.downcast_ref::<SelectableTextState<Link, Renderer::Paragraph>>();
if state.dragging {
mouse::Interaction::Text
} else if self.hovered_link.is_some() {
mouse::Interaction::Pointer
} else if cursor.is_over(layout.bounds()) {
mouse::Interaction::Text
} else {
mouse::Interaction::None
}
}
}
struct TextLayout<'a, 'span, Link, Font> {
width: Length,
height: Length,
spans: &'a [Span<'span, Link, Font>],
line_height: LineHeight,
size: Option<Pixels>,
font: Option<Font>,
align_x: Alignment,
align_y: alignment::Vertical,
wrapping: Wrapping,
}
fn layout_text<Link, Renderer>(
state: &mut SelectableTextState<Link, Renderer::Paragraph>,
renderer: &Renderer,
limits: &layout::Limits,
config: TextLayout<'_, '_, Link, Renderer::Font>,
) -> layout::Node
where
Link: Clone,
Renderer: advanced_text::Renderer,
{
layout::sized(limits, config.width, config.height, |limits| {
let bounds = limits.max();
let size = config.size.unwrap_or_else(|| renderer.default_size());
let font = config.font.unwrap_or_else(|| renderer.default_font());
let text_with_spans = || advanced_text::Text {
content: config.spans,
bounds,
size,
line_height: config.line_height,
font,
align_x: config.align_x,
align_y: config.align_y,
shaping: Shaping::Advanced,
wrapping: config.wrapping,
};
if state.spans != config.spans {
state.paragraph =
Renderer::Paragraph::with_spans(text_with_spans());
state.spans = config
.spans
.iter()
.cloned()
.map(Span::to_static)
.collect();
} else {
match state.paragraph.compare(advanced_text::Text {
content: (),
bounds,
size,
line_height: config.line_height,
font,
align_x: config.align_x,
align_y: config.align_y,
shaping: Shaping::Advanced,
wrapping: config.wrapping,
}) {
advanced_text::Difference::None => {}
advanced_text::Difference::Bounds => {
state.paragraph.resize(bounds);
}
advanced_text::Difference::Shape => {
state.paragraph =
Renderer::Paragraph::with_spans(text_with_spans());
}
}
}
state.paragraph.min_bounds()
})
}
fn flatten_spans<Link, Font>(spans: &[Span<'_, Link, Font>]) -> String {
spans
.iter()
.map(|span| span.text.as_ref())
.collect::<String>()
}
fn selection_rects<P: Paragraph>(
paragraph: &P,
span_count: usize,
start: usize,
end: usize,
) -> Vec<Rectangle> {
visual_lines(paragraph, span_count)
.into_iter()
.filter_map(|line| selection_rect_for_line(paragraph, line, start, end))
.collect()
}
fn selection_rect_for_line<P: Paragraph>(
paragraph: &P,
line: Rectangle,
start: usize,
end: usize,
) -> Option<Rectangle> {
let y = line.center_y();
let paragraph_width = paragraph.bounds().width.max(line.x + line.width + 1.0);
let left_probe = line.x.max(0.0);
let right_probe = (line.x + line.width + 1.0).min(paragraph_width.max(1.0));
let line_start = paragraph
.hit_test(Point::new(left_probe, y))
.map(advanced_text::Hit::cursor)?;
let line_end = paragraph
.hit_test(Point::new(right_probe, y))
.map(advanced_text::Hit::cursor)
.unwrap_or(line_start);
let (line_start, line_end) = if line_start <= line_end {
(line_start, line_end)
} else {
(line_end, line_start)
};
let overlap_start = start.max(line_start);
let overlap_end = end.min(line_end);
if overlap_start >= overlap_end {
return None;
}
let x_start = if overlap_start <= line_start {
line.x
} else {
x_for_offset(paragraph, y, overlap_start, line.x, line.x + line.width)
};
let x_end = if overlap_end >= line_end {
line.x + line.width
} else {
x_for_offset(paragraph, y, overlap_end, line.x, line.x + line.width)
};
let left = x_start.min(x_end);
let right = x_start.max(x_end);
(right > left).then(|| {
Rectangle::new(
Point::new(left, line.y),
Size::new(right - left, line.height),
)
})
}
fn x_for_offset<P: Paragraph>(
paragraph: &P,
y: f32,
offset: usize,
left: f32,
right: f32,
) -> f32 {
let mut low = left;
let mut high = right.max(left);
for _ in 0..HIT_SEARCH_STEPS {
let mid = (low + high) / 2.0;
let hit = paragraph
.hit_test(Point::new(mid, y))
.map(advanced_text::Hit::cursor);
match hit {
Some(hit) if hit < offset => low = mid,
Some(_) => high = mid,
None => break,
}
}
high
}
fn visual_lines<P: Paragraph>(
paragraph: &P,
span_count: usize,
) -> Vec<Rectangle> {
let mut lines: Vec<Rectangle> = Vec::new();
for span in 0..span_count {
for bounds in paragraph.span_bounds(span) {
if bounds.width <= 0.0 || bounds.height <= 0.0 {
continue;
}
if let Some(line) = lines
.iter_mut()
.find(|line| (line.center_y() - bounds.center_y()).abs() < 1.0)
{
*line = union(*line, bounds);
} else {
lines.push(bounds);
}
}
}
lines.sort_by(|a, b| a.y.total_cmp(&b.y));
lines
}
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
let left = a.x.min(b.x);
let top = a.y.min(b.y);
let right = (a.x + a.width).max(b.x + b.width);
let bottom = (a.y + a.height).max(b.y + b.height);
Rectangle::new(
Point::new(left, top),
Size::new(right - left, bottom - top),
)
}
fn clamped_position(cursor: mouse::Cursor, bounds: Rectangle) -> Option<Point> {
cursor.position_from(bounds.position()).map(|position| {
Point::new(
position.x.clamp(0.0, bounds.width.max(1.0) - 1.0),
position.y.clamp(0.0, bounds.height.max(1.0) - 1.0),
)
})
}
fn point_distance(a: Point, b: Point) -> f32 {
let dx = a.x - b.x;
let dy = a.y - b.y;
(dx * dx + dy * dy).sqrt()
}
impl<'a, Link, Message, Theme, Renderer>
From<SelectableRichText<'a, Link, Message, Theme, Renderer>>
for Element<'a, Message, Theme, Renderer>
where
Message: 'a,
Link: Clone + 'a,
Theme: Catalog + 'a,
Renderer: advanced_text::Renderer + 'a,
{
fn from(
text: SelectableRichText<'a, Link, Message, Theme, Renderer>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_substring_returns_middle_and_none_for_collapsed() {
assert_eq!(selected_substring("abcdef", 2, 5), Some("cde".to_owned()));
assert_eq!(selected_substring("abcdef", 3, 3), None);
}
#[test]
fn selected_substring_handles_reversed_range() {
assert_eq!(selected_substring("abcdef", 5, 2), Some("cde".to_owned()));
}
#[test]
fn select_all_uses_byte_length() {
assert_eq!(select_all(""), (0, 0));
assert_eq!(select_all("aé👍z"), (0, "aé👍z".len()));
}
#[test]
fn unicode_selection_uses_byte_offsets_without_panicking() {
let text = "aé👍z";
assert_eq!(selected_substring(text, 1, 7), Some("é👍".to_owned()));
assert_eq!(selected_substring(text, 3, 7), Some("👍".to_owned()));
assert_eq!(selected_substring(text, 2, 7), Some("é👍".to_owned()));
}
}
-1
View File
@@ -64,7 +64,6 @@ fn state(name: &str, addr: EndpointAddr) -> PeerState {
sharing: None,
avatar: Default::default(),
game: None,
music: None,
}
}