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
11 changed files with 150 additions and 1506 deletions
+1 -1
View File
@@ -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."
+17 -186
View File
@@ -11,14 +11,12 @@ use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode;
use crate::theme::{AppTheme, Palette};
use crate::widget::context_input::context_input;
use iced::widget::{
container, column, row, text, button, scrollable, slider, checkbox, pick_list,
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list,
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
rich_text, span, responsive,
};
use iced::widget::text_input;
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::{
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
@@ -264,8 +262,6 @@ const ABOVE_CHAT_MIN_H: f32 = 300.0;
const DIVIDER_THICKNESS: f32 = 8.0;
/// Upper bound for waiting on orderly core shutdown before letting the window exit.
const SHUTDOWN_TIMEOUT_SECS: u64 = 5;
/// How long a room-level clock-skew warning remains visible without dismissal.
const CLOCK_SKEW_WARNING_VISIBLE_SECS: u64 = 12;
/// Clamp the Participants panel width so neither it nor the Controls panel drops
/// below its minimum, given the current window width.
@@ -408,10 +404,6 @@ pub enum AppMessage {
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
OpenPixelpassHelp,
ClosePixelpassHelp,
/// Dismiss the room-level clock-skew warning banner.
DismissClockSkewWarning,
/// Auto-clear cadence while the clock-skew warning banner is visible.
ClockSkewWarningTick,
/// Choose a room layout (applied live + persisted, closes the popup).
SelectRoomLayout(RoomLayout),
/// Choose a UI theme (applied live + persisted).
@@ -507,13 +499,6 @@ fn core_subscription() -> impl iced::futures::Stream<Item = UiEvent> {
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ClockSkewBanner {
skew_secs: u64,
peer_ahead: bool,
expires_at: std::time::Instant,
}
pub struct AppState {
name: String,
ticket_input: String,
@@ -619,10 +604,6 @@ pub struct AppState {
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
/// until the core's `AudioAppsListed` reports otherwise.
share_app_audio_supported: bool,
/// Room-level warning for a validly signed peer whose gossip timestamp falls
/// outside the replay freshness window. The peer is not yet in the roster, so
/// this is not attached to a participant card.
clock_skew_warning: Option<ClockSkewBanner>,
/// Whether the Chat drawer is open (drawer layout only).
drawer_chat_open: bool,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
@@ -699,7 +680,6 @@ impl AppState {
self.share_audio_dropped = false;
self.share_audio_app_active = false;
self.share_app_audio_supported = true;
self.clock_skew_warning = None;
}
fn custom_sound_path(&self, sound: Sound) -> &str {
@@ -828,7 +808,6 @@ impl Default for AppState {
share_audio_dropped: false,
share_audio_app_active: false,
share_app_audio_supported: true,
clock_skew_warning: None,
drawer_chat_open: false,
mic_level: 0.0,
mic_test_active: false,
@@ -973,13 +952,7 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
} else {
Subscription::none()
};
let clock_skew_sub = if state.clock_skew_warning.is_some() {
iced::time::every(std::time::Duration::from_secs(1))
.map(|_| AppMessage::ClockSkewWarningTick)
} else {
Subscription::none()
};
Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub])
Subscription::batch(vec![core_sub, event_sub, audio_sub])
}
fn shutdown_timeout_task() -> Task<AppMessage> {
@@ -1456,14 +1429,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.share_audio_dropped = !active;
}
}
UiEvent::ClockSkewWarning { skew_secs, peer_ahead } => {
show_clock_skew_warning(
state,
skew_secs,
peer_ahead,
std::time::Instant::now(),
);
}
UiEvent::IdentityStatus { node_id, persisted, error } => {
state.self_node_id = Some(node_id);
state.identity_persisted = persisted;
@@ -1828,12 +1793,6 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ClosePixelpassHelp => {
state.pixelpass_help_open = false;
}
AppMessage::DismissClockSkewWarning => {
state.clock_skew_warning = None;
}
AppMessage::ClockSkewWarningTick => {
clear_expired_clock_skew_warning(state, std::time::Instant::now());
}
AppMessage::SelectRoomLayout(layout) => {
state.config.room_layout = layout;
state.config.save();
@@ -2404,37 +2363,6 @@ fn format_duration(total_secs: u64) -> String {
}
}
fn format_clock_skew_duration(skew_secs: u64) -> String {
let minutes = skew_secs.max(1).saturating_add(59) / 60;
if minutes == 1 {
"1 minute".to_string()
} else {
format!("{minutes} minutes")
}
}
fn show_clock_skew_warning(
state: &mut AppState,
skew_secs: u64,
peer_ahead: bool,
now: std::time::Instant,
) {
state.clock_skew_warning = Some(ClockSkewBanner {
skew_secs,
peer_ahead,
expires_at: now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
});
}
fn clear_expired_clock_skew_warning(state: &mut AppState, now: std::time::Instant) {
if state
.clock_skew_warning
.is_some_and(|warning| now >= warning.expires_at)
{
state.clock_skew_warning = None;
}
}
/// First 8 characters of an id string for compact display. Panic-free: takes
/// chars (not a byte slice), so a short or non-ASCII id can never panic the
/// render (security finding S1) — ids are long ASCII hex today, but this guards
@@ -2626,7 +2554,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
let nickname_input = column![
text("Nickname").size(14).color(color_subtext),
vertical_space(4.0),
context_input("Enter nickname...", &state.name)
text_input("Enter nickname...", &state.name)
.on_input(AppMessage::NicknameChanged)
.style(t_style)
.padding(10)
@@ -2635,7 +2563,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
// Optional cosmetic room label (W7) above the Create button: it rides in the
// minted ticket so everyone who joins inherits "in <name>". Enter also creates.
let create_group = column![
context_input("Room name (optional)", &state.room_name_input)
text_input("Room name (optional)", &state.room_name_input)
.on_input(AppMessage::RoomNameChanged)
.on_submit(AppMessage::CreatePressed)
.style(t_style)
@@ -2651,7 +2579,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
let join_group = column![
text("Join Existing Room").size(14).color(color_subtext),
vertical_space(4.0),
context_input("Paste room ticket here...", &state.ticket_input)
text_input("Paste room ticket here...", &state.ticket_input)
.on_input(AppMessage::TicketInputChanged)
.style(t_style)
.padding(10),
@@ -2877,7 +2805,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
};
friend_rows = friend_rows.push(
row![
context_input("name", &f.name)
text_input("name", &f.name)
.on_input(move |v| AppMessage::RenameFriend(fid, v))
.style(t_style)
.padding(6)
@@ -2911,13 +2839,13 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
};
let add_form = column![
context_input("Friend's node ID", &state.friend_add_id)
text_input("Friend's node ID", &state.friend_add_id)
.on_input(AppMessage::FriendAddIdChanged)
.style(t_style)
.padding(6),
vertical_space(6.0),
row![
context_input("Name (optional)", &state.friend_add_name)
text_input("Name (optional)", &state.friend_add_name)
.on_input(AppMessage::FriendAddNameChanged)
.style(t_style)
.padding(6)
@@ -3166,7 +3094,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
horizontal_space(),
validation_widget,
].spacing(6).align_y(iced::alignment::Vertical::Center),
context_input("Default (embedded)...", path)
text_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style)
.padding(8)
@@ -3836,10 +3764,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
.size(11).color(color_subtext),
row![
context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
.on_input(AppMessage::GameMapExeChanged)
.width(iced::Length::Fill),
context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
.on_input(AppMessage::GameMapNameChanged)
.width(iced::Length::Fill),
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
@@ -4838,7 +4766,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.on_press(AppMessage::PickAttachmentFile)
.style(b_style(color_surface, color_overlay, color_text, 6.0))
.padding(8),
context_input("Message the room…", &state.chat_input)
text_input("Message the room…", &state.chat_input)
.on_input(AppMessage::ChatInputChanged)
.on_submit(AppMessage::ChatSubmit)
.style(t_style)
@@ -4959,43 +4887,8 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
}
};
let clock_skew_banner: Element<'_, AppMessage> =
if let Some(warning) = state.clock_skew_warning {
let direction = if warning.peer_ahead { "ahead" } else { "behind" };
let skew = format_clock_skew_duration(warning.skew_secs);
let copy = format!(
"A peer couldn't be seen - clocks are out of sync by ~{skew} (peer clock looks {direction}). Check your system clock (turn on automatic time sync)."
);
column![
vertical_space(10.0),
container(
row![
icon(IconKind::Clock, 16.0, color_yellow),
text(copy).size(12).color(color_text).width(iced::Length::Fill),
button(text("Dismiss").size(12))
.on_press(AppMessage::DismissClockSkewWarning)
.style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(6),
]
.spacing(10)
.align_y(iced::alignment::Vertical::Center)
)
.padding(10)
.width(iced::Length::Fill)
.style(move |_theme: &Theme| container::Style {
text_color: Some(color_text),
background: Some(Background::Color(Color { a: 0.14, ..color_yellow })),
border: Border { color: color_yellow, width: 1.0, radius: 8.0.into() },
..Default::default()
})
]
.into()
} else {
iced::widget::Space::new().width(0.0).height(0.0).into()
};
let room = container(
column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body]
column![top_bar, header_container, vertical_space(12.0), body]
)
.padding(15)
.width(iced::Length::Fill)
@@ -6329,11 +6222,10 @@ impl Program<AppMessage> for Icon {
#[cfg(test)]
mod tests {
use super::{
attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration,
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig,
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner,
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
attachment_default_name, format_duration, initial_window_position, reconnect_attempt_chime,
reconnected_chime, set_peer_gate_config, set_peer_volume_config, update, AppConfig,
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX,
UiEvent,
};
use iroh::SecretKey;
@@ -6500,11 +6392,6 @@ mod tests {
state.share_audio_dropped = true;
state.share_audio_app_active = true;
state.share_app_audio_supported = false;
state.clock_skew_warning = Some(ClockSkewBanner {
skew_secs: 180,
peer_ahead: true,
expires_at: now,
});
state.clip_status.lock().unwrap().playing_id = Some(attachment_id);
state.reset_room_state();
@@ -6532,7 +6419,6 @@ mod tests {
assert!(!state.share_audio_dropped);
assert!(!state.share_audio_app_active);
assert!(state.share_app_audio_supported, "reset is optimistic by default");
assert!(state.clock_skew_warning.is_none());
for _ in 0..50 {
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
@@ -6543,61 +6429,6 @@ mod tests {
panic!("clip player did not stop during room reset");
}
#[test]
fn clock_skew_warning_shows_dismisses_and_expires() {
let mut state = AppState::default();
let now = std::time::Instant::now();
show_clock_skew_warning(&mut state, 181, true, now);
let warning = state.clock_skew_warning.expect("warning should be visible");
assert_eq!(warning.skew_secs, 181);
assert!(warning.peer_ahead);
assert_eq!(
warning.expires_at,
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS)
);
let _ = update(&mut state, AppMessage::DismissClockSkewWarning);
assert!(state.clock_skew_warning.is_none());
show_clock_skew_warning(&mut state, 240, false, now);
clear_expired_clock_skew_warning(
&mut state,
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS - 1),
);
assert!(state.clock_skew_warning.is_some());
clear_expired_clock_skew_warning(
&mut state,
now + std::time::Duration::from_secs(CLOCK_SKEW_WARNING_VISIBLE_SECS),
);
assert!(state.clock_skew_warning.is_none());
}
#[test]
fn clock_skew_ui_event_populates_banner() {
let mut state = AppState::default();
let _ = update(
&mut state,
AppMessage::UiEventReceived(UiEvent::ClockSkewWarning {
skew_secs: 121,
peer_ahead: false,
}),
);
let warning = state.clock_skew_warning.expect("event should show banner");
assert_eq!(warning.skew_secs, 121);
assert!(!warning.peer_ahead);
}
#[test]
fn clock_skew_duration_rounds_up_to_minutes() {
assert_eq!(format_clock_skew_duration(0), "1 minute");
assert_eq!(format_clock_skew_duration(1), "1 minute");
assert_eq!(format_clock_skew_duration(60), "1 minute");
assert_eq!(format_clock_skew_duration(61), "2 minutes");
assert_eq!(format_clock_skew_duration(181), "4 minutes");
}
#[test]
fn share_picker_startup_window_is_guarded() {
// P3-1: between confirming the picker and the core's ScreenShareStarted,
-135
View File
@@ -109,80 +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::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 },
@@ -237,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
@@ -273,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 -80
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 {
@@ -958,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();
@@ -1092,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 })
})
@@ -1162,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,
},
@@ -1730,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)
@@ -2102,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
@@ -2811,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,
};
@@ -3073,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];
-1
View File
@@ -21,7 +21,6 @@ pub mod discovery;
pub mod hotkeys;
pub mod files;
pub mod game;
pub mod widget;
use std::fs::File;
use std::path::{Path, PathBuf};
+3 -204
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
@@ -1064,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;
-4
View File
@@ -106,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.
+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());
-894
View File
@@ -1,894 +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 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,
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,
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 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,
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,
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();
match action {
MenuAction::Cut | MenuAction::Copy => {
has_selection && !self.is_secure
}
MenuAction::Paste => true,
MenuAction::SelectAll => has_value,
}
}
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
}
}
#[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,
}
);
}
}
-1
View File
@@ -1 +0,0 @@
pub mod context_input;