Compare commits
4
Commits
96e3e0ba10
...
0aaf6be529
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aaf6be529 | ||
|
|
d059386aee | ||
|
|
79a091b1b3 | ||
|
|
c1de7efbc5 |
@@ -4,6 +4,12 @@ All notable changes to PeerSpeak are documented here.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Friends list now reflects status changes without a restart.** A presence probe that fails now actively marks the friend **offline**, so a friend who goes offline, leaves a room, or turns invisible no longer lingers showing a stale "online" / "in a room" status until PeerSpeak is relaunched. Previously only successful probes updated the list, so it could ratchet a friend's status up but never down. The auto-refresh interval was also shortened from 60s to **15s** so the list tracks changes more closely.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Manual "⟳ Rescan" button** on the Friends panel that refreshes everyone's presence immediately, instead of waiting for the next auto-refresh.
|
||||||
|
|
||||||
### Licensing
|
### Licensing
|
||||||
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
|
- **PeerSpeak is now released under the MIT License** (previously an unlicensed private build). Added a `LICENSE` file and a `THIRD_PARTY_LICENSES` file enumerating the full dependency manifest plus the canonical text of every referenced license, with notices for the statically bundled Opus codec and the embedded fonts (Iced-Icons, Cantarell/OFL-1.1). Both files ship in the Arch and Debian packages.
|
||||||
|
|
||||||
|
|||||||
+131
-4
@@ -366,6 +366,9 @@ pub enum AppMessage {
|
|||||||
AddFriendFromRoom(EndpointId),
|
AddFriendFromRoom(EndpointId),
|
||||||
RemoveFriend(EndpointId),
|
RemoveFriend(EndpointId),
|
||||||
RenameFriend(EndpointId, String),
|
RenameFriend(EndpointId, String),
|
||||||
|
/// Manually rescan all friends' presence now (the ⟳ button), instead of waiting
|
||||||
|
/// for the 60s scheduler tick. Statuses update as the probe replies arrive.
|
||||||
|
RefreshFriends,
|
||||||
/// Join the gathering a friend is in (W7 B2), via the member ticket their
|
/// Join the gathering a friend is in (W7 B2), via the member ticket their
|
||||||
/// presence reply carried. Mirrors a manual ticket join.
|
/// presence reply carried. Mirrors a manual ticket join.
|
||||||
JoinFriendRoom(String),
|
JoinFriendRoom(String),
|
||||||
@@ -474,6 +477,8 @@ pub enum AppMessage {
|
|||||||
DismissClockSkewWarning,
|
DismissClockSkewWarning,
|
||||||
/// Auto-clear cadence while the clock-skew warning banner is visible.
|
/// Auto-clear cadence while the clock-skew warning banner is visible.
|
||||||
ClockSkewWarningTick,
|
ClockSkewWarningTick,
|
||||||
|
/// Slow re-render so the friends "scanned Nm ago" indicator advances over time.
|
||||||
|
RescanLabelTick,
|
||||||
/// 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).
|
||||||
@@ -604,6 +609,11 @@ pub struct AppState {
|
|||||||
/// Carried in the minted ticket so joiners inherit it; empty = unnamed room.
|
/// Carried in the minted ticket so joiners inherit it; empty = unnamed room.
|
||||||
room_name_input: String,
|
room_name_input: String,
|
||||||
status_message: String,
|
status_message: String,
|
||||||
|
/// When the last manual friends "Rescan" pass completed, for the live
|
||||||
|
/// "scanned Nm ago" indicator in the Friends panel. `None` until the first
|
||||||
|
/// rescan this session. Only the on-demand button updates this, not the 15s
|
||||||
|
/// auto-refresh.
|
||||||
|
last_rescan: Option<std::time::Instant>,
|
||||||
self_id: String,
|
self_id: String,
|
||||||
ticket: String,
|
ticket: String,
|
||||||
is_muted: bool,
|
is_muted: bool,
|
||||||
@@ -932,6 +942,7 @@ impl Default for AppState {
|
|||||||
ticket_input: "".to_string(),
|
ticket_input: "".to_string(),
|
||||||
room_name_input: "".to_string(),
|
room_name_input: "".to_string(),
|
||||||
status_message: "Ready to connect".to_string(),
|
status_message: "Ready to connect".to_string(),
|
||||||
|
last_rescan: None,
|
||||||
self_id: "".to_string(),
|
self_id: "".to_string(),
|
||||||
ticket: "".to_string(),
|
ticket: "".to_string(),
|
||||||
is_muted: false,
|
is_muted: false,
|
||||||
@@ -1147,7 +1158,15 @@ fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
|||||||
} else {
|
} else {
|
||||||
Subscription::none()
|
Subscription::none()
|
||||||
};
|
};
|
||||||
Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub])
|
// Advance the friends "scanned Nm ago" indicator. Minute granularity, so a 30s
|
||||||
|
// tick keeps it within ~30s of accurate; only runs once a rescan has happened.
|
||||||
|
let rescan_label_sub = if state.last_rescan.is_some() {
|
||||||
|
iced::time::every(std::time::Duration::from_secs(30))
|
||||||
|
.map(|_| AppMessage::RescanLabelTick)
|
||||||
|
} else {
|
||||||
|
Subscription::none()
|
||||||
|
};
|
||||||
|
Subscription::batch(vec![core_sub, event_sub, audio_sub, clock_skew_sub, rescan_label_sub])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shutdown_timeout_task() -> Task<AppMessage> {
|
fn shutdown_timeout_task() -> Task<AppMessage> {
|
||||||
@@ -1697,6 +1716,16 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
UiEvent::FriendPresence { id, presence } => {
|
UiEvent::FriendPresence { id, presence } => {
|
||||||
state.friend_presence.insert(id, presence);
|
state.friend_presence.insert(id, presence);
|
||||||
}
|
}
|
||||||
|
UiEvent::FriendsRescanned => {
|
||||||
|
// The manual pass finished. Stamp the time for the live "scanned
|
||||||
|
// Nm ago" indicator, and replace the transient "Rescanning…"
|
||||||
|
// banner — but only if it's still showing (don't clobber a status
|
||||||
|
// the user has since triggered, e.g. by joining a room).
|
||||||
|
state.last_rescan = Some(std::time::Instant::now());
|
||||||
|
if state.status_message == "Rescanning friends…" {
|
||||||
|
state.status_message = "Friends rescanned.".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
UiEvent::PresenceModeReverted { mode } => {
|
UiEvent::PresenceModeReverted { mode } => {
|
||||||
// Core corrected the committed presence mode. Mirror + persist so
|
// Core corrected the committed presence mode. Mirror + persist so
|
||||||
// the picker reflects the discovery state the endpoint actually has.
|
// the picker reflects the discovery state the endpoint actually has.
|
||||||
@@ -1894,6 +1923,19 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
let _ = state.controller.send(CoreCommand::RenameFriend(id, new_name));
|
let _ = state.controller.send(CoreCommand::RenameFriend(id, new_name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppMessage::RefreshFriends => {
|
||||||
|
// Kick an immediate presence pass; the list updates as replies land and
|
||||||
|
// the banner clears on the FriendsRescanned completion event. While
|
||||||
|
// Invisible we probe no one, so say why instead of a banner that resolves
|
||||||
|
// with nothing changed.
|
||||||
|
if state.config.presence_mode == PresenceMode::Invisible {
|
||||||
|
state.status_message =
|
||||||
|
"You're invisible — turn on presence to scan friends.".to_string();
|
||||||
|
} else {
|
||||||
|
let _ = state.controller.send(CoreCommand::RefreshFriends);
|
||||||
|
state.status_message = "Rescanning friends…".to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
AppMessage::JoinFriendRoom(ticket) => {
|
AppMessage::JoinFriendRoom(ticket) => {
|
||||||
// Join via the friend's member ticket (same path as a manual join).
|
// Join via the friend's member ticket (same path as a manual join).
|
||||||
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
let input_device = state.selected_input.as_ref().map(|d| d.name.clone());
|
||||||
@@ -2066,6 +2108,10 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
|||||||
AppMessage::ClockSkewWarningTick => {
|
AppMessage::ClockSkewWarningTick => {
|
||||||
clear_expired_clock_skew_warning(state, std::time::Instant::now());
|
clear_expired_clock_skew_warning(state, std::time::Instant::now());
|
||||||
}
|
}
|
||||||
|
AppMessage::RescanLabelTick => {
|
||||||
|
// No state change; the tick exists purely to re-render the relative
|
||||||
|
// "scanned Nm ago" label as time passes.
|
||||||
|
}
|
||||||
AppMessage::SelectRoomLayout(layout) => {
|
AppMessage::SelectRoomLayout(layout) => {
|
||||||
state.config.room_layout = layout;
|
state.config.room_layout = layout;
|
||||||
state.config.save();
|
state.config.save();
|
||||||
@@ -2854,6 +2900,37 @@ fn format_duration(total_secs: u64) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A compact relative-time label for the friends "last scanned" indicator, e.g.
|
||||||
|
/// `just now`, `2m ago`, `1h 2m ago`, `3d ago`. Minute granularity (the indicator
|
||||||
|
/// re-renders on a slow tick), so anything under a minute reads "just now". Pure
|
||||||
|
/// so the formatting is unit-testable without a clock.
|
||||||
|
fn format_relative_ago(elapsed: std::time::Duration) -> String {
|
||||||
|
let secs = elapsed.as_secs();
|
||||||
|
if secs < 60 {
|
||||||
|
return "just now".to_string();
|
||||||
|
}
|
||||||
|
let mins = secs / 60;
|
||||||
|
if mins < 60 {
|
||||||
|
return format!("{mins}m ago");
|
||||||
|
}
|
||||||
|
let hours = mins / 60;
|
||||||
|
if hours < 24 {
|
||||||
|
let rem_m = mins % 60;
|
||||||
|
return if rem_m == 0 {
|
||||||
|
format!("{hours}h ago")
|
||||||
|
} else {
|
||||||
|
format!("{hours}h {rem_m}m ago")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let days = hours / 24;
|
||||||
|
let rem_h = hours % 24;
|
||||||
|
if rem_h == 0 {
|
||||||
|
format!("{days}d ago")
|
||||||
|
} else {
|
||||||
|
format!("{days}d {rem_h}h ago")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn format_clock_skew_duration(skew_secs: u64) -> String {
|
fn format_clock_skew_duration(skew_secs: u64) -> String {
|
||||||
let minutes = skew_secs.max(1).saturating_add(59) / 60;
|
let minutes = skew_secs.max(1).saturating_add(59) / 60;
|
||||||
if minutes == 1 {
|
if minutes == 1 {
|
||||||
@@ -3684,7 +3761,11 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
Some(crate::presence::FriendPresence::Online) => {
|
Some(crate::presence::FriendPresence::Online) => {
|
||||||
text("● online").size(11).color(color_green).into()
|
text("● online").size(11).color(color_green).into()
|
||||||
}
|
}
|
||||||
None => text("○ offline").size(11).color(color_subtext).into(),
|
// An explicit Offline (probe failed / no address) and a missing
|
||||||
|
// entry (not yet probed) both render as offline.
|
||||||
|
Some(crate::presence::FriendPresence::Offline) | None => {
|
||||||
|
text("○ offline").size(11).color(color_subtext).into()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
friend_rows = friend_rows.push(
|
friend_rows = friend_rows.push(
|
||||||
row![
|
row![
|
||||||
@@ -3768,10 +3849,37 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
|||||||
} else {
|
} else {
|
||||||
column![].into()
|
column![].into()
|
||||||
};
|
};
|
||||||
|
// Header: title + a "scanned Nm ago" indicator + a manual "Rescan" button.
|
||||||
|
// Presence is also auto-refreshed every 15s, but the button forces an immediate
|
||||||
|
// pass for instant feedback. Only shown when there are friends to scan. The
|
||||||
|
// indicator advances live via the RescanLabelTick subscription.
|
||||||
|
let scanned_label: Element<'_, AppMessage> = match state.last_rescan {
|
||||||
|
Some(t) => text(format!("scanned {}", format_relative_ago(t.elapsed())))
|
||||||
|
.size(11)
|
||||||
|
.color(color_subtext)
|
||||||
|
.into(),
|
||||||
|
None => column![].into(),
|
||||||
|
};
|
||||||
|
let title_row: Element<'_, AppMessage> = if has_friends {
|
||||||
|
row![
|
||||||
|
text("FRIENDS").size(18).color(color_text),
|
||||||
|
horizontal_space(),
|
||||||
|
scanned_label,
|
||||||
|
button(text("⟳ Rescan").size(12))
|
||||||
|
.on_press(AppMessage::RefreshFriends)
|
||||||
|
.style(b_style(color_surface, color_blue, color_text, 6.0))
|
||||||
|
.padding(6),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::alignment::Vertical::Center)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text("FRIENDS").size(14).color(color_text).into()
|
||||||
|
};
|
||||||
|
|
||||||
container(
|
container(
|
||||||
column![
|
column![
|
||||||
text("FRIENDS").size(if has_friends { 18 } else { 14 }).color(color_text),
|
title_row,
|
||||||
intro,
|
intro,
|
||||||
vertical_space(if has_friends { 10.0 } else { 4.0 }),
|
vertical_space(if has_friends { 10.0 } else { 4.0 }),
|
||||||
readonly_warning,
|
readonly_warning,
|
||||||
@@ -7545,7 +7653,7 @@ impl Program<AppMessage> for Icon {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration,
|
attachment_default_name, clear_expired_clock_skew_warning, format_clock_skew_duration,
|
||||||
format_duration, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
format_duration, format_relative_ago, initial_window_position, reconnect_attempt_chime, reconnected_chime,
|
||||||
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig,
|
set_peer_gate_config, set_peer_volume_config, show_clock_skew_warning, update, AppConfig,
|
||||||
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner,
|
AppMessage, AppState, AttachmentCache, AttachmentState, ChatEntry, ClockSkewBanner,
|
||||||
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
|
GateMeter, METER_MAX, UiEvent, CLOCK_SKEW_WARNING_VISIBLE_SECS,
|
||||||
@@ -8095,6 +8203,25 @@ mod tests {
|
|||||||
assert_eq!(format_duration(3725), "1:02:05");
|
assert_eq!(format_duration(3725), "1:02:05");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_relative_ago_buckets_by_minute() {
|
||||||
|
use std::time::Duration;
|
||||||
|
// Under a minute reads "just now" (no seconds-level churn).
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(0)), "just now");
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(59)), "just now");
|
||||||
|
// Minutes.
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(60)), "1m ago");
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(125)), "2m ago");
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(59 * 60)), "59m ago");
|
||||||
|
// Hours, with minutes only when non-zero.
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(3600)), "1h ago");
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(3600 + 120)), "1h 2m ago");
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(23 * 3600 + 59 * 60)), "23h 59m ago");
|
||||||
|
// Days, with hours only when non-zero.
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(24 * 3600)), "1d ago");
|
||||||
|
assert_eq!(format_relative_ago(Duration::from_secs(50 * 3600)), "2d 2h ago");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn settings_categories_are_stable_and_grouped_for_navigation() {
|
fn settings_categories_are_stable_and_grouped_for_navigation() {
|
||||||
use super::SettingsCategory;
|
use super::SettingsCategory;
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ pub enum CoreCommand {
|
|||||||
RemoveFriend(EndpointId),
|
RemoveFriend(EndpointId),
|
||||||
/// Locally rename a friend (W7).
|
/// Locally rename a friend (W7).
|
||||||
RenameFriend(EndpointId, String),
|
RenameFriend(EndpointId, String),
|
||||||
|
/// Run an immediate presence-refresh pass over all friends (the manual
|
||||||
|
/// "Rescan" button). Same work the 60s scheduler does on each tick, on demand —
|
||||||
|
/// no waiting for the next interval. A no-op while Invisible.
|
||||||
|
RefreshFriends,
|
||||||
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
|
/// Set our presence posture (W7). Gates the idle listener (answer friends-only /
|
||||||
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
/// invisible) and the outbound ping scheduler (invisible = fully dark). Sent at
|
||||||
/// startup from config and whenever the user changes it.
|
/// startup from config and whenever the user changes it.
|
||||||
@@ -203,6 +207,7 @@ pub fn delivery_class(cmd: &CoreCommand) -> DeliveryClass {
|
|||||||
}
|
}
|
||||||
| CoreCommand::RemoveFriend(_)
|
| CoreCommand::RemoveFriend(_)
|
||||||
| CoreCommand::RenameFriend(_, _)
|
| CoreCommand::RenameFriend(_, _)
|
||||||
|
| CoreCommand::RefreshFriends
|
||||||
| CoreCommand::SetPresenceMode(_)
|
| CoreCommand::SetPresenceMode(_)
|
||||||
| CoreCommand::SetGamePresenceEnabled(_)
|
| CoreCommand::SetGamePresenceEnabled(_)
|
||||||
| CoreCommand::SetGameOverride(_)
|
| CoreCommand::SetGameOverride(_)
|
||||||
@@ -291,6 +296,11 @@ pub enum UiEvent {
|
|||||||
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
/// joinable gathering (with a one-click ticket). Emitted by the outbound ping
|
||||||
/// scheduler; absence of a recent event = treat as offline.
|
/// scheduler; absence of a recent event = treat as offline.
|
||||||
FriendPresence { id: EndpointId, presence: FriendPresence },
|
FriendPresence { id: EndpointId, presence: FriendPresence },
|
||||||
|
/// A manual "Rescan" pass finished (every friend has been probed and its
|
||||||
|
/// per-friend `FriendPresence` already emitted). Lets the GUI clear the
|
||||||
|
/// transient "Rescanning…" status. Sent only for the on-demand button, not the
|
||||||
|
/// periodic auto-refresh, so the status bar isn't churned every interval.
|
||||||
|
FriendsRescanned,
|
||||||
/// Core corrected the committed presence posture. Usually the Discoverable
|
/// Core corrected the committed presence posture. Usually the Discoverable
|
||||||
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
/// time-box elapsed and the core auto-reverted to `Normal`; on discovery apply
|
||||||
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
/// failure, this carries the previous truthful mode. The GUI must mirror +
|
||||||
|
|||||||
+46
-15
@@ -966,19 +966,24 @@ async fn persist_and_emit_friends(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How often the outbound presence scheduler refreshes friends' status. Slow on
|
/// How often the outbound presence scheduler refreshes friends' status. Each pass
|
||||||
/// purpose — presence is best-effort, not real-time, and each pass opens a short
|
/// opens one short connection per friend with a saved address, so the cost scales
|
||||||
/// connection per friend.
|
/// with friend-count, not a fixed per-tick cost. 15s keeps the list feeling live
|
||||||
const PING_INTERVAL: Duration = Duration::from_secs(60);
|
/// without aggressively probing peers for a best-effort signal; the manual Rescan
|
||||||
|
/// button covers the "update now" case below this interval.
|
||||||
|
const PING_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
/// Delay before the FIRST presence pass, so the endpoint's background `online()`
|
/// Delay before the FIRST presence pass, so the endpoint's background `online()`
|
||||||
/// has a moment to finish (otherwise the first probes fail and friends flash offline).
|
/// has a moment to finish (otherwise the first probes fail and friends flash offline).
|
||||||
const PING_STARTUP_DELAY: Duration = Duration::from_secs(3);
|
const PING_STARTUP_DELAY: Duration = Duration::from_secs(3);
|
||||||
|
|
||||||
/// One outbound presence-refresh pass (W7 B2): probe every friend that has a saved
|
/// One outbound presence-refresh pass (W7 B2): probe every friend and emit a
|
||||||
/// address and emit their interpreted status. Friends with no saved address are
|
/// *definitive* status for each, so the UI self-heals every pass instead of only
|
||||||
/// skipped (a bare id can't resolve without discovery) and stay offline in the UI
|
/// ratcheting a friend upward. A friend with a saved address is probed and mapped
|
||||||
/// until first contact populates their address via `note_seen`. Probes run in
|
/// via [`crate::presence::presence_from_probe`] (a failed probe -> `Offline`); a
|
||||||
/// parallel (friend counts are small); an unreachable friend just yields nothing.
|
/// friend with no saved address (a bare add-by-id we've never met in a room) is
|
||||||
|
/// reported `Offline` directly, since a bare id can't resolve without discovery.
|
||||||
|
/// Probes run in parallel (friend counts are small). This is the fix for stale
|
||||||
|
/// "online"/"in a room" statuses lingering after a friend drops or leaves a room.
|
||||||
async fn probe_friends_once(
|
async fn probe_friends_once(
|
||||||
endpoint: Endpoint,
|
endpoint: Endpoint,
|
||||||
friends: crate::friends::FriendStore,
|
friends: crate::friends::FriendStore,
|
||||||
@@ -986,18 +991,25 @@ async fn probe_friends_once(
|
|||||||
) {
|
) {
|
||||||
let mut set = tokio::task::JoinSet::new();
|
let mut set = tokio::task::JoinSet::new();
|
||||||
for f in friends.list() {
|
for f in friends.list() {
|
||||||
let Some(addr) = f.last_addr.clone() else { continue };
|
|
||||||
let id = f.id;
|
let id = f.id;
|
||||||
|
let Some(addr) = f.last_addr.clone() else {
|
||||||
|
// Nothing to dial yet — report Offline so a prior status can't stick.
|
||||||
|
let _ = ui_tx
|
||||||
|
.send(UiEvent::FriendPresence { id, presence: crate::presence::FriendPresence::Offline })
|
||||||
|
.await;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
let ep = endpoint.clone();
|
let ep = endpoint.clone();
|
||||||
set.spawn(async move {
|
set.spawn(async move {
|
||||||
match crate::presence_net::probe(&ep, addr).await {
|
let presence = match crate::presence_net::probe(&ep, addr).await {
|
||||||
Ok((from, reply)) => crate::presence::interpret_pong(&reply, from).map(|p| (id, p)),
|
Ok((from, reply)) => crate::presence::presence_from_probe(Some((&reply, from))),
|
||||||
Err(_) => None,
|
Err(_) => crate::presence::presence_from_probe(None),
|
||||||
}
|
};
|
||||||
|
(id, presence)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
while let Some(res) = set.join_next().await {
|
while let Some(res) = set.join_next().await {
|
||||||
if let Ok(Some((id, presence))) = res {
|
if let Ok((id, presence)) = res {
|
||||||
let _ = ui_tx.send(UiEvent::FriendPresence { id, presence }).await;
|
let _ = ui_tx.send(UiEvent::FriendPresence { id, presence }).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2494,6 +2506,25 @@ async fn run_core_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CoreCommand::RefreshFriends => {
|
||||||
|
// Manual "Rescan": run an immediate probe pass (same as a scheduler
|
||||||
|
// tick), detached so it can't block command handling. Honour
|
||||||
|
// Invisible — stay fully dark and touch no friend's machine. A
|
||||||
|
// `FriendsRescanned` event always follows so the UI's transient
|
||||||
|
// "Rescanning…" status clears even when probing was skipped.
|
||||||
|
let visible =
|
||||||
|
*presence_mode.lock().unwrap() != crate::presence::PresenceMode::Invisible;
|
||||||
|
let endpoint = net.endpoint.clone();
|
||||||
|
let snapshot = friends.lock().unwrap().clone();
|
||||||
|
let tx = ui_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if visible {
|
||||||
|
probe_friends_once(endpoint, snapshot, tx.clone()).await;
|
||||||
|
}
|
||||||
|
let _ = tx.send(UiEvent::FriendsRescanned).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
CoreCommand::SetPresenceMode(mode) => {
|
CoreCommand::SetPresenceMode(mode) => {
|
||||||
let previous_mode = *presence_mode.lock().unwrap();
|
let previous_mode = *presence_mode.lock().unwrap();
|
||||||
let now = tokio::time::Instant::now();
|
let now = tokio::time::Instant::now();
|
||||||
|
|||||||
+48
-1
@@ -100,7 +100,11 @@ pub fn should_answer(from: &EndpointId, friends: &FriendStore, mode: PresenceMod
|
|||||||
mode.answers_pings() && friends.contains(from)
|
mode.answers_pings() && friends.contains(from)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What we learned about a friend from a successful ping reply.
|
/// What we learned about a friend's reachability. `Online`/`InRoom` come from a
|
||||||
|
/// successful ping reply (see [`interpret_pong`]); `Offline` is produced by the
|
||||||
|
/// presence scheduler when a probe fails or the friend has no known address, so a
|
||||||
|
/// friend who drops or leaves is *actively* downgraded rather than left showing a
|
||||||
|
/// stale status. The UI also treats a missing entry as offline.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FriendPresence {
|
pub enum FriendPresence {
|
||||||
/// Online, but not in a gathering we can join.
|
/// Online, but not in a gathering we can join.
|
||||||
@@ -108,6 +112,8 @@ pub enum FriendPresence {
|
|||||||
/// Online and in a joinable gathering (name already sanitized, ticket already
|
/// Online and in a joinable gathering (name already sanitized, ticket already
|
||||||
/// validated as parseable).
|
/// validated as parseable).
|
||||||
InRoom { name: String, ticket: String },
|
InRoom { name: String, ticket: String },
|
||||||
|
/// Unreachable: the probe failed, or we have no address to probe yet.
|
||||||
|
Offline,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Interpret a peer's reply defensively. `from` must be the connection's
|
/// Interpret a peer's reply defensively. `from` must be the connection's
|
||||||
@@ -140,6 +146,20 @@ pub fn interpret_pong(msg: &ControlMsg, from: EndpointId) -> Option<FriendPresen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a single probe outcome to a definitive [`FriendPresence`], used by the
|
||||||
|
/// presence scheduler. `Some((reply, from))` is a received message from the
|
||||||
|
/// authenticated remote `from`; `None` means the probe failed (offline /
|
||||||
|
/// unreachable / refused). Anything that doesn't interpret as a real presence —
|
||||||
|
/// a probe error, or a non-`Pong` reply — becomes [`FriendPresence::Offline`], so
|
||||||
|
/// a friend who drops is actively downgraded instead of keeping a stale status.
|
||||||
|
/// Pure so the scheduler's downgrade behaviour is unit-testable without a network.
|
||||||
|
pub fn presence_from_probe(reply: Option<(&ControlMsg, EndpointId)>) -> FriendPresence {
|
||||||
|
match reply {
|
||||||
|
Some((msg, from)) => interpret_pong(msg, from).unwrap_or(FriendPresence::Offline),
|
||||||
|
None => FriendPresence::Offline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -252,6 +272,33 @@ mod tests {
|
|||||||
assert_eq!(got, Some(FriendPresence::Online));
|
assert_eq!(got, Some(FriendPresence::Online));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn presence_from_probe_maps_outcomes_to_definitive_status() {
|
||||||
|
let friend = id();
|
||||||
|
// A failed probe (no reply) is an explicit downgrade to Offline, so the UI
|
||||||
|
// clears a friend who has dropped instead of keeping a stale status.
|
||||||
|
assert_eq!(presence_from_probe(None), FriendPresence::Offline);
|
||||||
|
// A successful Pong with no room is Online.
|
||||||
|
assert_eq!(
|
||||||
|
presence_from_probe(Some((&ControlMsg::Pong { room: None }, friend))),
|
||||||
|
FriendPresence::Online
|
||||||
|
);
|
||||||
|
// A successful Pong advertising the friend's own room is InRoom.
|
||||||
|
let t = valid_ticket(friend);
|
||||||
|
assert_eq!(
|
||||||
|
presence_from_probe(Some((
|
||||||
|
&ControlMsg::Pong { room: Some(RoomPresence { name: "Den".into(), ticket: t.clone() }) },
|
||||||
|
friend,
|
||||||
|
))),
|
||||||
|
FriendPresence::InRoom { name: "Den".into(), ticket: t }
|
||||||
|
);
|
||||||
|
// A non-reply (a stray Ping) is not a presence -> Offline, never a false Online.
|
||||||
|
assert_eq!(
|
||||||
|
presence_from_probe(Some((&ControlMsg::Ping, friend))),
|
||||||
|
FriendPresence::Offline
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
fn interpret_pong_sanitizes_a_hostile_room_name() {
|
||||||
// Control/bidi characters in a peer-supplied name are stripped.
|
// Control/bidi characters in a peer-supplied name are stripped.
|
||||||
|
|||||||
Reference in New Issue
Block a user