Compare commits

..
Author SHA1 Message Date
mollusk 01150ff249 Merge W21 Phase 1: selectable node ID + ticket display fields
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
2026-06-27 15:53:41 -04:00
molluskandClaude Opus 4.8 a6d9a8cbd4 W21 Phase 1: locked selectable display fields for node ID + ticket
Add a read-only-but-selectable "locked" mode to the A9 ContextInput so
share-critical values (full node ID, full room ticket) can be drag-selected
and copied with the mouse/keyboard, in addition to the existing one-click
Copy buttons (which are kept).

- context_input.rs: add `locked` flag + builder + `locked_value(value, noop)`
  constructor. A controlled text_input with a no-op on_input stays focusable
  and selection-capable while never mutating (iced treats on_input==None as
  Disabled, verified against iced_widget-0.14.2 source).
- Extract overlay gating into a pure `menu_action_enabled` seam: when locked,
  Cut/Paste are disabled, Copy is enabled with a (non-secure) selection, and
  Select All is enabled when there's a value. +1 unit test.
- app/mod.rs: add AppMessage::Noop; render the full node ID and full ticket in
  width-capped locked fields beside their existing Copy buttons.

Phase 2 (cross-message selectable chat transcript) intentionally deferred:
it requires a transcript-level custom widget that owns selection/layout/hit-
testing while preserving A13 links and attachment rows — out of scope for a
bounded edit. Design path recorded in the Codex task report.

Tests-green only (460 lib, clippy clean, release build green); wants a quick
field check of mouse drag-select + right-click Copy + Ctrl+A/C.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:53:33 -04:00
a4bb6ce0be A9: right-click context menu (Cut/Copy/Paste/Select All) for all text fields
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
iced 0.14 ships no native right-click menu on text_input. Add a custom
ContextInput widget (src/widget/context_input.rs) that wraps text_input,
intercepts right-click to read the inner text_input::State selection, and
renders a themed 4-action overlay menu operating on that selection.

- Pure, grapheme-indexed edit seam (copy/cut/paste/select_all over
  iced text_input::Value), unit-tested for ASCII and multi-byte/emoji.
- iced::advanced Widget + overlay::Overlay; clipboard via &mut dyn
  Clipboard, edits published through the existing on_input/on_paste.
- Cut/Copy disabled on empty selection (and on secure fields), Select
  All disabled on empty field, Paste always enabled; dismiss on
  click-out / Esc / item-click.
- Route all 10 text_input call sites in app/mod.rs through context_input.
- Cargo.toml: enable iced "advanced" feature (same crate, no new dep).

459 lib tests (+5), clippy --all-targets clean, release green.

Implemented by Codex (gpt-5.5), senior-audited against the 5-point brief
and re-verified (tests/clippy/release) here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
2026-06-27 05:00:47 -04:00
molluskandClaude Opus 4.8 ebfc39de46 core: route critical commands through a reliable unbounded channel (A15)
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
CoreController::send put every app->core command on a single bounded
depth-100 channel via try_send and discarded the result. iced slider
drags emit ~60-120 commands/sec, so a drag burst could transiently
saturate the queue exactly when the user hit mute / released PTT /
left a room, silently dropping that critical command and leaving the
mic hot -- a privacy/state mismatch.

Split the queue by drop-tolerance:
- A pure delivery_class(&CoreCommand) classifier in messages.rs maps the
  7 continuous audio sliders to BestEffort and every other (discrete,
  human-paced) command to Reliable. The match has no wildcard arm, so a
  new CoreCommand variant fails to compile until it is classified.
- CoreController now holds two senders: an unbounded reliable channel
  and the existing bounded(100) best-effort channel. send() routes by
  class; Reliable uses unbounded send (fails only if the core loop is
  dead), BestEffort keeps today's bounded try_send.
- run_core_loop takes both receivers and drains them with a biased
  select: reliable first, best-effort second, game-change third.

Unbounded is safe because the only machine-rate producer (slider drags)
stays on the bounded channel; Reliable commands are all human-paced.
command_sender() and the awaiting Shutdown path are unchanged.

Implemented by Codex (gpt-5.5), senior-reviewed and verified here:
454 lib tests pass, clippy --all-targets clean, release builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 04:19:40 -04:00
molluskandClaude Opus 4.8 e3ff778d5b A25: surface a clock-skew warning instead of failing silently
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A validly-signed gossip payload rejected only by the 120s replay
freshness window (GossipReject::OutOfWindow) now drives a room-level
"clocks out of sync" warning banner, instead of silently dropping the
peer so the room shows "1 in room" with no error.

Observe-only: verify_gossip's accept/reject decision and
GOSSIP_FRESHNESS_MS are unchanged; the payload is still dropped exactly
as before. The warning is gated strictly on OutOfWindow (which, because
the signature is verified first, implies a genuine authenticated peer
whose clock is skewed), never on BadSignature.

Policy lives in a pure, unit-tested ClockSkewMonitor seam with injected
now_ms: >=3 OutOfWindow drops from the same author within 60s warn once,
5-min per-author cooldown, bounded/pruned author map. The warning rides
the existing in-process RoomEvent -> UiEvent -> transient-banner path
(no wire/serialization or dependency change).

Implemented by Codex (gpt-5.5), senior-audited against the 5-point
checklist and independently verified (452 lib tests, clippy
--all-targets clean, release build green). Tests-green only; a 2-machine
deliberate-skew field test is still owed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:43:39 -04:00
9a059e1bb8 audio: extract apply_peer_volume seam + A24 regression tests
cargo-deny / cargo-deny (push) Has been cancelled
windows-build / windows-build (push) Has been cancelled
A24 (per-peer volume slider has no effect) does not reproduce on current
main from a code trace: the UI slider's EndpointId is the same key the
mixer uses for the incoming jitter frame, and the gain is applied before
EQ/pan/output. Extract the inline per-peer lookup into a pure
apply_peer_volume() seam and add two regression tests:
 - matching key scales the frame (0.5 halves it)
 - mismatched key defaults to unity (guards the key-identity failure mode)

No wire/gossip/identity/PeerState change. The field-reported A24 was most
likely a stale listener build (volume is listener-side); needs a 2-machine
audible re-verify to close.

Co-Authored-By: Codex (gpt-5.5) <noreply@openai.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 03:16:06 -04:00
11 changed files with 1575 additions and 154 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ async-trait = "0.1.89"
base64 = "0.22.1" base64 = "0.22.1"
bytes = "1.11.1" bytes = "1.11.1"
dirs = "6.0.0" dirs = "6.0.0"
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] } iced = { version = "0.14.0", features = ["advanced", "canvas", "image", "tokio"] }
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep # 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- # 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). # gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
-14
View File
@@ -225,20 +225,6 @@ state change; rate-limit pings), tickets from friends (validate defensively, no
auto-join), the discovery publish (only when toggled, ideally auto-expiring). auto-join), the discovery publish (only when toggled, ideally auto-expiring).
`cargo audit` (JSON store → no new deps expected). Field test on dopedart. `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) ## The connect flow (the user's scenario, end to end)
1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled 1. Friend X, at a coffee shop, opens peerspeak and starts a gathering labeled
"HangOut." "HangOut."
+206 -21
View File
@@ -11,12 +11,14 @@ use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding}; use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
use crate::presence::PresenceMode; use crate::presence::PresenceMode;
use crate::theme::{AppTheme, Palette}; use crate::theme::{AppTheme, Palette};
use crate::widget::context_input::{context_input, locked_value};
use iced::widget::{ use iced::widget::{
container, column, row, text, button, text_input, scrollable, slider, checkbox, pick_list, container, column, row, text, button, scrollable, slider, checkbox, pick_list,
radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area, radio, tooltip, progress_bar, canvas, Canvas, Column, stack, mouse_area,
rich_text, span, responsive, rich_text, span, responsive,
}; };
use iced::widget::text_input;
use iced::widget::canvas::{Action, Frame, Geometry, Path, Program}; use iced::widget::canvas::{Action, Frame, Geometry, Path, Program};
use iced::{ use iced::{
Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse, Color, Background, Border, Element, Subscription, Task, Theme, Event, keyboard, mouse,
@@ -262,6 +264,8 @@ const ABOVE_CHAT_MIN_H: f32 = 300.0;
const DIVIDER_THICKNESS: f32 = 8.0; const DIVIDER_THICKNESS: f32 = 8.0;
/// Upper bound for waiting on orderly core shutdown before letting the window exit. /// Upper bound for waiting on orderly core shutdown before letting the window exit.
const SHUTDOWN_TIMEOUT_SECS: u64 = 5; 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 /// Clamp the Participants panel width so neither it nor the Controls panel drops
/// below its minimum, given the current window width. /// below its minimum, given the current window width.
@@ -309,6 +313,8 @@ pub enum AppMessage {
CopyToClipboard, CopyToClipboard,
/// Copy an arbitrary string to the clipboard (e.g. the full node ID). /// Copy an arbitrary string to the clipboard (e.g. the full node ID).
CopyText(String), CopyText(String),
/// No-op message for controlled read-only selectable fields.
Noop,
TogglePtt(bool), TogglePtt(bool),
StartHotkeyCapture(HotkeyAction), StartHotkeyCapture(HotkeyAction),
ClearHotkey(HotkeyAction), ClearHotkey(HotkeyAction),
@@ -404,6 +410,10 @@ pub enum AppMessage {
/// Open / close the "screen sharing needs pixelpass" explainer popup (A11). /// Open / close the "screen sharing needs pixelpass" explainer popup (A11).
OpenPixelpassHelp, OpenPixelpassHelp,
ClosePixelpassHelp, 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). /// Choose a room layout (applied live + persisted, closes the popup).
SelectRoomLayout(RoomLayout), SelectRoomLayout(RoomLayout),
/// Choose a UI theme (applied live + persisted). /// Choose a UI theme (applied live + persisted).
@@ -499,6 +509,13 @@ 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 { pub struct AppState {
name: String, name: String,
ticket_input: String, ticket_input: String,
@@ -604,6 +621,10 @@ pub struct AppState {
/// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true` /// would pass a flag an older pixelpass rejects (audit P2). Optimistic `true`
/// until the core's `AudioAppsListed` reports otherwise. /// until the core's `AudioAppsListed` reports otherwise.
share_app_audio_supported: bool, 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). /// Whether the Chat drawer is open (drawer layout only).
drawer_chat_open: bool, drawer_chat_open: bool,
/// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter. /// Raw mic level (normalized RMS, `0.0..=1.0`) for the settings meter.
@@ -680,6 +701,7 @@ impl AppState {
self.share_audio_dropped = false; self.share_audio_dropped = false;
self.share_audio_app_active = false; self.share_audio_app_active = false;
self.share_app_audio_supported = true; self.share_app_audio_supported = true;
self.clock_skew_warning = None;
} }
fn custom_sound_path(&self, sound: Sound) -> &str { fn custom_sound_path(&self, sound: Sound) -> &str {
@@ -808,6 +830,7 @@ impl Default for AppState {
share_audio_dropped: false, share_audio_dropped: false,
share_audio_app_active: false, share_audio_app_active: false,
share_app_audio_supported: true, share_app_audio_supported: true,
clock_skew_warning: None,
drawer_chat_open: false, drawer_chat_open: false,
mic_level: 0.0, mic_level: 0.0,
mic_test_active: false, mic_test_active: false,
@@ -952,7 +975,13 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
} else { } else {
Subscription::none() Subscription::none()
}; };
Subscription::batch(vec![core_sub, event_sub, audio_sub]) 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])
} }
fn shutdown_timeout_task() -> Task<AppMessage> { fn shutdown_timeout_task() -> Task<AppMessage> {
@@ -1429,6 +1458,14 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
state.share_audio_dropped = !active; 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 } => { UiEvent::IdentityStatus { node_id, persisted, error } => {
state.self_node_id = Some(node_id); state.self_node_id = Some(node_id);
state.identity_persisted = persisted; state.identity_persisted = persisted;
@@ -1487,6 +1524,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::CopyText(s) => { AppMessage::CopyText(s) => {
return iced::clipboard::write(s); return iced::clipboard::write(s);
} }
AppMessage::Noop => {}
AppMessage::TogglePtt(enabled) => { AppMessage::TogglePtt(enabled) => {
state.ptt_enabled = enabled; state.ptt_enabled = enabled;
let _ = state.controller.send(CoreCommand::SetPttMode(enabled)); let _ = state.controller.send(CoreCommand::SetPttMode(enabled));
@@ -1793,6 +1831,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
AppMessage::ClosePixelpassHelp => { AppMessage::ClosePixelpassHelp => {
state.pixelpass_help_open = false; 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) => { AppMessage::SelectRoomLayout(layout) => {
state.config.room_layout = layout; state.config.room_layout = layout;
state.config.save(); state.config.save();
@@ -2363,6 +2407,37 @@ 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 /// 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 /// 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 /// render (security finding S1) — ids are long ASCII hex today, but this guards
@@ -2554,7 +2629,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
let nickname_input = column![ let nickname_input = column![
text("Nickname").size(14).color(color_subtext), text("Nickname").size(14).color(color_subtext),
vertical_space(4.0), vertical_space(4.0),
text_input("Enter nickname...", &state.name) context_input("Enter nickname...", &state.name)
.on_input(AppMessage::NicknameChanged) .on_input(AppMessage::NicknameChanged)
.style(t_style) .style(t_style)
.padding(10) .padding(10)
@@ -2563,7 +2638,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
// Optional cosmetic room label (W7) above the Create button: it rides in the // 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. // minted ticket so everyone who joins inherits "in <name>". Enter also creates.
let create_group = column![ let create_group = column![
text_input("Room name (optional)", &state.room_name_input) context_input("Room name (optional)", &state.room_name_input)
.on_input(AppMessage::RoomNameChanged) .on_input(AppMessage::RoomNameChanged)
.on_submit(AppMessage::CreatePressed) .on_submit(AppMessage::CreatePressed)
.style(t_style) .style(t_style)
@@ -2579,7 +2654,7 @@ fn connect_card(state: &AppState) -> Element<'_, AppMessage> {
let join_group = column![ let join_group = column![
text("Join Existing Room").size(14).color(color_subtext), text("Join Existing Room").size(14).color(color_subtext),
vertical_space(4.0), vertical_space(4.0),
text_input("Paste room ticket here...", &state.ticket_input) context_input("Paste room ticket here...", &state.ticket_input)
.on_input(AppMessage::TicketInputChanged) .on_input(AppMessage::TicketInputChanged)
.style(t_style) .style(t_style)
.padding(10), .padding(10),
@@ -2805,7 +2880,7 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
}; };
friend_rows = friend_rows.push( friend_rows = friend_rows.push(
row![ row![
text_input("name", &f.name) context_input("name", &f.name)
.on_input(move |v| AppMessage::RenameFriend(fid, v)) .on_input(move |v| AppMessage::RenameFriend(fid, v))
.style(t_style) .style(t_style)
.padding(6) .padding(6)
@@ -2839,13 +2914,13 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
}; };
let add_form = column![ let add_form = column![
text_input("Friend's node ID", &state.friend_add_id) context_input("Friend's node ID", &state.friend_add_id)
.on_input(AppMessage::FriendAddIdChanged) .on_input(AppMessage::FriendAddIdChanged)
.style(t_style) .style(t_style)
.padding(6), .padding(6),
vertical_space(6.0), vertical_space(6.0),
row![ row![
text_input("Name (optional)", &state.friend_add_name) context_input("Name (optional)", &state.friend_add_name)
.on_input(AppMessage::FriendAddNameChanged) .on_input(AppMessage::FriendAddNameChanged)
.style(t_style) .style(t_style)
.padding(6) .padding(6)
@@ -3094,7 +3169,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
horizontal_space(), horizontal_space(),
validation_widget, validation_widget,
].spacing(6).align_y(iced::alignment::Vertical::Center), ].spacing(6).align_y(iced::alignment::Vertical::Center),
text_input("Default (embedded)...", path) context_input("Default (embedded)...", path)
.on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val)) .on_input(move |val| AppMessage::CustomSoundPathChanged(sound, val))
.style(t_style) .style(t_style)
.padding(8) .padding(8)
@@ -3465,11 +3540,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
}) })
.into() .into()
}; };
// The ID line shows a short form (iced text isn't selectable) plus a Copy // The ID line exposes the full value in a locked selectable field while
// button that puts the FULL node id on the clipboard, so it's shareable. // keeping the one-click Copy button for fast whole-ID copy.
let id_row: Element<AppMessage> = match state.self_node_id.clone() { let id_row: Element<AppMessage> = match state.self_node_id.clone() {
Some(full) => row![ Some(full) => row![
text(format!("ID: {id_display}")).size(13).color(color_text), text("ID:").size(13).color(color_text),
locked_value(&full, AppMessage::Noop)
.width(iced::Length::Fixed(260.0))
.size(13)
.padding(4),
button( button(
row![ row![
icon(IconKind::Copy, 13.0, color_text), icon(IconKind::Copy, 13.0, color_text),
@@ -3478,7 +3557,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.spacing(5) .spacing(5)
.align_y(iced::alignment::Vertical::Center) .align_y(iced::alignment::Vertical::Center)
) )
.on_press(AppMessage::CopyText(full)) .on_press(AppMessage::CopyText(full.clone()))
.style(b_style(color_surface, color_blue, color_text, 6.0)) .style(b_style(color_surface, color_blue, color_text, 6.0))
.padding(6), .padding(6),
] ]
@@ -3764,10 +3843,10 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text("Steam games are detected automatically. For other launchers, map an executable name to a display name.") text("Steam games are detected automatically. For other launchers, map an executable name to a display name.")
.size(11).color(color_subtext), .size(11).color(color_subtext),
row![ row![
text_input("executable (e.g. hl2_linux)", &state.game_map_exe_input) context_input("executable (e.g. hl2_linux)", &state.game_map_exe_input)
.on_input(AppMessage::GameMapExeChanged) .on_input(AppMessage::GameMapExeChanged)
.width(iced::Length::Fill), .width(iced::Length::Fill),
text_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input) context_input("shown name (e.g. Half-Life 2)", &state.game_map_name_input)
.on_input(AppMessage::GameMapNameChanged) .on_input(AppMessage::GameMapNameChanged)
.width(iced::Length::Fill), .width(iced::Length::Fill),
button(text("Add").size(13)).on_press(AppMessage::AddGameMapping), button(text("Add").size(13)).on_press(AppMessage::AddGameMapping),
@@ -4031,6 +4110,15 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
text(format!("My ID: {}", short_id(&state.self_id))) text(format!("My ID: {}", short_id(&state.self_id)))
.size(14) .size(14)
.color(color_subtext), .color(color_subtext),
row![
text("Ticket:").size(12).color(color_subtext),
locked_value(&state.ticket, AppMessage::Noop)
.width(iced::Length::Fixed(260.0))
.size(12)
.padding(4),
]
.spacing(6)
.align_y(iced::alignment::Vertical::Center),
button( button(
row![ row![
icon(IconKind::Copy, 14.0, color_text), icon(IconKind::Copy, 14.0, color_text),
@@ -4766,7 +4854,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
.on_press(AppMessage::PickAttachmentFile) .on_press(AppMessage::PickAttachmentFile)
.style(b_style(color_surface, color_overlay, color_text, 6.0)) .style(b_style(color_surface, color_overlay, color_text, 6.0))
.padding(8), .padding(8),
text_input("Message the room…", &state.chat_input) context_input("Message the room…", &state.chat_input)
.on_input(AppMessage::ChatInputChanged) .on_input(AppMessage::ChatInputChanged)
.on_submit(AppMessage::ChatSubmit) .on_submit(AppMessage::ChatSubmit)
.style(t_style) .style(t_style)
@@ -4887,8 +4975,43 @@ 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( let room = container(
column![top_bar, header_container, vertical_space(12.0), body] column![top_bar, header_container, clock_skew_banner, vertical_space(12.0), body]
) )
.padding(15) .padding(15)
.width(iced::Length::Fill) .width(iced::Length::Fill)
@@ -6222,10 +6345,11 @@ impl Program<AppMessage> for Icon {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
attachment_default_name, format_duration, initial_window_position, reconnect_attempt_chime, attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration,
reconnected_chime, set_peer_gate_config, set_peer_volume_config, update, AppConfig, format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, GateMeter, METER_MAX, set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig,
UiEvent, AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner,
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
}; };
use iroh::SecretKey; use iroh::SecretKey;
@@ -6392,6 +6516,11 @@ mod tests {
state.share_audio_dropped = true; state.share_audio_dropped = true;
state.share_audio_app_active = true; state.share_audio_app_active = true;
state.share_app_audio_supported = false; 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.clip_status.lock().unwrap().playing_id = Some(attachment_id);
state.reset_room_state(); state.reset_room_state();
@@ -6419,6 +6548,7 @@ mod tests {
assert!(!state.share_audio_dropped); assert!(!state.share_audio_dropped);
assert!(!state.share_audio_app_active); assert!(!state.share_audio_app_active);
assert!(state.share_app_audio_supported, "reset is optimistic by default"); assert!(state.share_app_audio_supported, "reset is optimistic by default");
assert!(state.clock_skew_warning.is_none());
for _ in 0..50 { for _ in 0..50 {
if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() { if crate::audio::clip_player::status_snapshot(&state.clip_status).playing_id.is_none() {
@@ -6429,6 +6559,61 @@ mod tests {
panic!("clip player did not stop during room reset"); 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] #[test]
fn share_picker_startup_window_is_guarded() { fn share_picker_startup_window_is_guarded() {
// P3-1: between confirming the picker and the core's ScreenShareStarted, // P3-1: between confirming the picker and the core's ScreenShareStarted,
+135
View File
@@ -109,6 +109,80 @@ pub enum CoreCommand {
SetGameProcessMap(std::collections::BTreeMap<String, String>), 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)] #[derive(Debug, Clone)]
pub enum UiEvent { pub enum UiEvent {
RoomJoined { ticket: String, self_id: String }, RoomJoined { ticket: String, self_id: String },
@@ -163,6 +237,10 @@ pub enum UiEvent {
/// run viewers currently hear silence. The UI shows a transient warning while /// run viewers currently hear silence. The UI shows a transient warning while
/// `false`. Only meaningful while sharing a specific app (not whole-desktop). /// `false`. Only meaningful while sharing a specific app (not whole-desktop).
ShareAudioActive(bool), 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 /// 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 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 /// `persisted = false` means the key file couldn't be read/written and we're
@@ -195,3 +273,60 @@ pub enum UiEvent {
ShutdownComplete, ShutdownComplete,
Error(String), 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);
}
}
}
+80 -33
View File
@@ -11,7 +11,7 @@ use crate::network::{
iroh_impl::{IrohTransport, AudioRouter, FileRouter}, iroh_impl::{IrohTransport, AudioRouter, FileRouter},
gossip::IrohGossipState, gossip::IrohGossipState,
}; };
use crate::core::messages::{CoreCommand, UiEvent}; use crate::core::messages::{CoreCommand, DeliveryClass, UiEvent, delivery_class};
use crate::core::recovery::RecoveryCoordinator; use crate::core::recovery::RecoveryCoordinator;
use crate::config::{NetworkMode, RecordingMode}; use crate::config::{NetworkMode, RecordingMode};
@@ -26,38 +26,44 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::time::Duration; use std::time::Duration;
pub struct CoreController { pub struct CoreController {
cmd_tx: mpsc::Sender<CoreCommand>, reliable_tx: mpsc::UnboundedSender<CoreCommand>,
besteffort_tx: mpsc::Sender<CoreCommand>,
} }
impl CoreController { impl CoreController {
pub fn new(ui_tx: mpsc::Sender<UiEvent>) -> Self { pub fn new(ui_tx: mpsc::Sender<UiEvent>) -> Self {
let (cmd_tx, cmd_rx) = mpsc::channel(100); let (reliable_tx, reliable_rx) = mpsc::unbounded_channel();
let (besteffort_tx, besteffort_rx) = mpsc::channel(100);
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime");
rt.block_on(async move { rt.block_on(async move {
crate::log_msg("Starting core network loop in dedicated Tokio runtime"); crate::log_msg("Starting core network loop in dedicated Tokio runtime");
if let Err(e) = run_core_loop(cmd_rx, ui_tx).await { if let Err(e) = run_core_loop(reliable_rx, besteffort_rx, ui_tx).await {
crate::log_msg(&format!("App core loop failed: {:?}", e)); crate::log_msg(&format!("App core loop failed: {:?}", e));
} }
}); });
}); });
Self { cmd_tx } Self { reliable_tx, besteffort_tx }
} }
/// Queue a command for the core loop, best-effort. Returns `true` if it was /// Queue a command for the core loop. Reliable commands only fail when the
/// accepted, `false` if the channel is full or closed. (We return a plain /// core loop is dead; best-effort slider commands keep today's bounded
/// bool rather than the channel's `Result` so the bulky `CoreCommand` isn't /// try-send behavior. (We return a plain bool rather than the channel's
/// carried back by value in every caller's error type.) /// `Result` so the bulky `CoreCommand` isn't carried back by value in every
/// caller's error type.)
pub fn send(&self, cmd: CoreCommand) -> bool { pub fn send(&self, cmd: CoreCommand) -> bool {
self.cmd_tx.try_send(cmd).is_ok() match delivery_class(&cmd) {
DeliveryClass::Reliable => self.reliable_tx.send(cmd).is_ok(),
DeliveryClass::BestEffort => self.besteffort_tx.try_send(cmd).is_ok(),
}
} }
/// Clone the command sender for asynchronous one-shot sends that should wait /// Clone the command sender for asynchronous one-shot sends that should wait
/// for channel capacity instead of failing immediately on a full queue. /// for channel capacity instead of failing immediately on a full queue.
pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> { pub fn command_sender(&self) -> mpsc::Sender<CoreCommand> {
self.cmd_tx.clone() self.besteffort_tx.clone()
} }
} }
@@ -294,6 +300,17 @@ 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 /// 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. /// UI level meter. An empty frame reads as 0.0.
fn frame_level(frame: &[i16]) -> f32 { fn frame_level(frame: &[i16]) -> f32 {
@@ -941,7 +958,8 @@ async fn probe_friends_once(
} }
async fn run_core_loop( async fn run_core_loop(
mut cmd_rx: mpsc::Receiver<CoreCommand>, mut reliable_rx: mpsc::UnboundedReceiver<CoreCommand>,
mut besteffort_rx: mpsc::Receiver<CoreCommand>,
ui_tx: mpsc::Sender<UiEvent>, ui_tx: mpsc::Sender<UiEvent>,
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new(); let memory_lookup = iroh::address_lookup::memory::MemoryLookup::new();
@@ -1074,34 +1092,22 @@ async fn run_core_loop(
// Join, cleared on Leave. // Join, cleared on Leave.
let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> = let current_room: Arc<std::sync::Mutex<Option<crate::presence::RoomPresence>>> =
Arc::new(std::sync::Mutex::new(None)); 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 // Reply policy for the idle friends listener (B2): answer friends only, never
// while invisible (`should_answer`), and report our current gathering so a friend // while invisible (`should_answer`), and report our current gathering so a friend
// can one-click join. Rate-limits allowed friends before building a reply, so a // can one-click join. Reads the shared snapshots, so it stays correct as they
// spammy saved peer gets the same silent close as an unauthorized peer. Reads the // change and survives a network-stack rebuild. Pure-sync (no awaits, no lock held
// shared snapshots, so it stays correct as they change and survives a network-stack // across one). Built once and handed to every `build_net_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_handler: crate::presence_net::Handler = {
let friends = friends.clone(); let friends = friends.clone();
let presence_mode = presence_mode.clone(); let presence_mode = presence_mode.clone();
let current_room = current_room.clone(); let current_room = current_room.clone();
let presence_rate_limiter = presence_rate_limiter.clone();
Arc::new(move |from| { Arc::new(move |from| {
let mode = *presence_mode.lock().unwrap(); let mode = *presence_mode.lock().unwrap();
let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode); let allowed = crate::presence::should_answer(&from, &friends.lock().unwrap(), mode);
if !allowed { if !allowed {
return None; return None;
} }
if !presence_rate_limiter
.lock()
.unwrap()
.allow(from, std::time::Instant::now())
{
return None;
}
let room = current_room.lock().unwrap().clone(); let room = current_room.lock().unwrap().clone();
Some(crate::presence::ControlMsg::Pong { room }) Some(crate::presence::ControlMsg::Pong { room })
}) })
@@ -1156,7 +1162,12 @@ async fn run_core_loop(
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop { loop {
let cmd = tokio::select! { let cmd = tokio::select! {
maybe_cmd = cmd_rx.recv() => match maybe_cmd { biased;
maybe_cmd = reliable_rx.recv() => match maybe_cmd {
Some(cmd) => cmd,
None => break,
},
maybe_cmd = besteffort_rx.recv() => match maybe_cmd {
Some(cmd) => cmd, Some(cmd) => cmd,
None => break, None => break,
}, },
@@ -1719,8 +1730,7 @@ async fn run_core_loop(
peer_noise_gates.remove(&peer_id); peer_noise_gates.remove(&peer_id);
} }
let vol = current_volumes.get(&peer_id).copied().unwrap_or(1.0); apply_peer_volume(&mut frame, peer_id, &current_volumes);
apply_volume(&mut frame, vol);
let eq_settings = current_eq let eq_settings = current_eq
.get(&peer_id) .get(&peer_id)
@@ -2092,6 +2102,19 @@ async fn run_core_loop(
attachment, attachment,
}).await; }).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) => { RoomEvent::PeerConnectionLost(peer_id) => {
// Transient drop: do NOT tear down the peer. Its audio // Transient drop: do NOT tear down the peer. Its audio
// supervisor stays alive and keeps redialing the // supervisor stays alive and keeps redialing the
@@ -2788,9 +2811,9 @@ async fn run_core_loop(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
admit_retained, apply_volume, audio_datagram_len_ok, frame_level, mix_frames, admit_retained, apply_peer_volume, apply_volume, audio_datagram_len_ok, frame_level,
mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono, KnownPeers, mix_frames, mix_stereo_frames, next_game_change, should_auto_fetch, stereo_to_mono,
MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS, KnownPeers, MicLevelMeter, PeerSpeakTicket, MAX_OPUS_PAYLOAD, MAX_RETAINED_PEERS,
MIC_LEVEL_REPORT_SAMPLES, MIC_LEVEL_REPORT_SAMPLES,
}; };
@@ -3050,6 +3073,30 @@ mod tests {
assert_eq!(frame, vec![2000, -2000]); 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] #[test]
fn three_peers_sum_without_saturation() { fn three_peers_sum_without_saturation() {
let a = vec![10, 20]; let a = vec![10, 20];
+1
View File
@@ -21,6 +21,7 @@ pub mod discovery;
pub mod hotkeys; pub mod hotkeys;
pub mod files; pub mod files;
pub mod game; pub mod game;
pub mod widget;
use std::fs::File; use std::fs::File;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
+204 -3
View File
@@ -152,6 +152,100 @@ fn prune_stale_mutations(
seen.retain(|_, last_ts| *last_ts >= floor); 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. /// 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 /// Everyone with the room ticket is an authenticated *insider*: a signature only
@@ -413,6 +507,7 @@ impl RoomState for IrohGossipState {
let handle = tokio::spawn(async move { let handle = tokio::spawn(async move {
crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id)); crate::log_msg(&format!("Spawned gossip topic loop for self_id={:?}", self_id));
let mut state_mutations_seen = HashMap::new(); let mut state_mutations_seen = HashMap::new();
let mut clock_skew_monitor = ClockSkewMonitor::default();
// Broadcast initial state // Broadcast initial state
let initial_payload = { let initial_payload = {
@@ -453,9 +548,28 @@ impl RoomState for IrohGossipState {
// action: a forged/stale payload is dropped here // action: a forged/stale payload is dropped here
// so it can't impersonate, evict, or poison // so it can't impersonate, evict, or poison
// presence/address-book (security S2). // presence/address-book (security S2).
if let Err(reason) = let received_now_ms = now_millis();
verify_gossip(&payload, &topic_bytes, now_millis(), GOSSIP_FRESHNESS_MS) 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;
}
}
crate::log_msg(&format!( crate::log_msg(&format!(
"Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}", "Gossip dropped unauthenticated/stale payload claiming author={:?}: {:?}",
payload.author, reason payload.author, reason
@@ -950,6 +1064,93 @@ mod tests {
assert!(!seen.contains_key(&(a, StateMutationKind::Announce))); 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] #[test]
fn sanitize_endpoint_addr_caps_address_count() { fn sanitize_endpoint_addr_caps_address_count() {
use std::net::SocketAddr; use std::net::SocketAddr;
+4
View File
@@ -106,6 +106,10 @@ pub enum RoomEvent {
/// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the /// from `PeerLeft` precisely so a momentary `NeighborDown` can't tear down the
/// reconnect path the way it used to. /// reconnect path the way it used to.
PeerConnectionLost(EndpointId), 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 /// 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 /// display name (embedded so it shows even without a presence entry), the
/// text, and a sender-stamped millisecond timestamp. /// text, and a sender-stamped millisecond timestamp.
-82
View File
@@ -15,16 +15,6 @@
use crate::friends::FriendStore; use crate::friends::FriendStore;
use iroh::EndpointId; use iroh::EndpointId;
use serde::{Deserialize, Serialize}; 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. /// The user's presence posture — how reachable they are to friends while idle.
/// Persisted in `AppConfig`; the default keeps you privately reachable to friends /// Persisted in `AppConfig`; the default keeps you privately reachable to friends
@@ -110,48 +100,6 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
mode.answers_pings() && friends.contains(from) 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. /// What we learned about a friend from a successful ping reply.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum FriendPresence { pub enum FriendPresence {
@@ -229,36 +177,6 @@ mod tests {
assert!(!should_answer(&stranger, &friends, PresenceMode::Invisible)); 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] #[test]
fn presence_mode_flags() { fn presence_mode_flags() {
assert!(PresenceMode::Discoverable.publishes_to_discovery()); assert!(PresenceMode::Discoverable.publishes_to_discovery());
+943
View File
@@ -0,0 +1,943 @@
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));
}
}
+1
View File
@@ -0,0 +1 @@
pub mod context_input;