refactor: split the files that had grown past reading size

Five files ran between 1.2k and 2.4k lines, and one function past the
length the quality gate allows. None of it was doing anything wrong; they
had simply accumulated until you had to scroll to hold one in your head.

The move throughout is into *child* modules rather than siblings, because
a child still sees its parent's private items. So `ClientContext` keeps
its private fields and `ServerActor` keeps its private state, while the
methods that work on them move out — no encapsulation is widened for the
sake of the split. Three `ServerActor` methods become `pub(super)`, which
is only acknowledging the calls the parent already made.

The client's operation loop and the actor's standing-message match are
split by subject: the arms move out whole, so the bodies read exactly as
they did inside. Where an arm said `continue` it now says `return` — the
match was the last thing the loop did, and none of the arms that moved
holds a loop of its own, so skipping the rest of an arm and ending its
handler are the same move.

The test modules split by topic the same way, which is most of the size:
main_tui's alone was 2.1k of the 2.4k lines.

No behaviour changes. Verified against a live server: the full e2e suite
passes, including the 98 CLI and 19 daemon-protocol cases that drive the
dispatch loop this touches.
This commit is contained in:
Michel de Graaf
2026-09-10 11:48:17 +02:00
parent 26b551d997
commit 62252588a4
28 changed files with 6041 additions and 5842 deletions
+1 -491
View File
@@ -59,6 +59,7 @@ use std::time::{Duration, Instant};
use crate::{SoulseekRs, debug, error, trace, warn};
mod handlers;
mod types;
pub use types::{Context, PeerAddress, UserMessage};
@@ -471,497 +472,6 @@ impl ServerActor {
));
}
fn handle_message(&mut self, msg: ServerMessage) {
if !matches!(self.connection_state, ConnectionState::Connected)
&& !matches!(
&msg,
ServerMessage::ProcessRead | ServerMessage::Login { .. }
)
{
self.queued_messages.push(msg);
return;
}
match msg {
ServerMessage::ConnectToPeer(peer) => {
self.handle_connect_to_peer(peer);
}
ServerMessage::LoginStatus(message) => {
self.handle_login_status(message);
}
ServerMessage::Relogged => self.handle_relogged(),
ServerMessage::PierceFirewall(token) => {
self.send_message(
MessageFactory::build_pierce_firewall_message(token),
);
}
ServerMessage::SendMessage(message) => {
self.send_message(message);
}
ServerMessage::GetPeerAddress(username) => {
self.send_message(MessageFactory::build_get_peer_address(
&username,
));
}
ServerMessage::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
} => {
self.handle_get_peer_address_response(
username,
host,
port,
obfuscation_type,
obfuscated_port,
);
}
ServerMessage::PrivateMessageReceived(user_message) => {
self.handle_private_message_received(user_message);
}
ServerMessage::ProcessRead => {
self.process_read();
}
ServerMessage::Login {
username,
password,
version,
response,
} => {
self.handle_login(username, password, version, response);
}
ServerMessage::FileSearch { token, query } => {
self.file_search(token, &query);
}
ServerMessage::FileSearchRequest {
username,
token,
query,
} => {
self.handle_file_search_request(username, token, query);
}
other => self.handle_social_message(other),
}
}
/// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events
/// and user status or stats. Split out, like the standing traffic below,
/// only to keep `handle_message`'s match readable.
fn handle_social_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::RoomListReceived(rooms) => {
self.forward_room_event(RoomEvent::List(rooms));
}
ServerMessage::UserStatusReceived {
username,
status,
privileged,
} => {
self.forward_to_client(ClientOperation::UserStatusReceived {
username,
status,
privileged,
});
}
ServerMessage::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::RoomJoined { room, users } => {
self.forward_room_event(RoomEvent::Joined { room, users });
}
ServerMessage::RoomMemberStats { room, stats } => {
self.forward_to_client(ClientOperation::RoomMemberStats {
room,
stats,
});
}
ServerMessage::RoomLeft { room } => {
self.forward_room_event(RoomEvent::Left { room });
}
ServerMessage::RoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::Message {
room,
username,
message,
});
}
ServerMessage::RoomUserJoined { room, username } => {
self.forward_room_event(RoomEvent::UserJoined {
room,
username,
});
}
ServerMessage::RoomUserLeft { room, username } => {
self.forward_room_event(RoomEvent::UserLeft { room, username });
}
ServerMessage::RoomTickers { room, tickers } => {
self.forward_room_event(RoomEvent::Tickers { room, tickers });
}
ServerMessage::RoomTickerAdded {
room,
username,
ticker,
} => {
self.forward_room_event(RoomEvent::TickerAdded {
room,
username,
ticker,
});
}
ServerMessage::RoomTickerRemoved { room, username } => {
self.forward_room_event(RoomEvent::TickerRemoved {
room,
username,
});
}
ServerMessage::GlobalRoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::GlobalMessage {
room,
username,
message,
});
}
other => self.handle_standing_message(other),
}
}
/// The wishlist and privilege traffic: standing searches (codes 103/104) and
/// who is privileged (codes 69/92).
///
/// Split out only because it keeps `handle_message`'s match to a readable
/// length; there is no behaviour here beyond dispatch.
fn handle_standing_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::PossibleParents(candidates) => {
self.forward_to_client(ClientOperation::PossibleParents(
candidates,
));
}
ServerMessage::ResetDistributed => {
self.forward_to_client(ClientOperation::ResetDistributed);
}
ServerMessage::WishlistSearch { token, query } => {
self.queue_message(MessageFactory::build_wishlist_search(
token, &query,
));
}
ServerMessage::WishlistInterval(seconds) => {
self.forward_to_client(ClientOperation::WishlistInterval(
seconds,
));
}
ServerMessage::PrivilegedUsers(users) => {
self.forward_to_client(ClientOperation::PrivilegedUsers(users));
}
ServerMessage::OwnPrivileges(seconds) => {
self.forward_to_client(ClientOperation::OwnPrivileges(seconds));
}
ServerMessage::CheckPrivileges => {
self.queue_message(MessageFactory::build_check_privileges());
}
ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: false,
recommended,
unrecommended,
});
}
ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: true,
recommended,
unrecommended,
});
}
ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
} => {
self.forward_to_client(ClientOperation::ItemRecommendations {
item,
recommendations,
});
}
ServerMessage::SimilarUsersReceived { users } => {
self.forward_to_client(ClientOperation::SimilarUsers(users));
}
ServerMessage::ItemSimilarUsersReceived { item, usernames } => {
self.forward_to_client(ClientOperation::ItemSimilarUsers {
item,
usernames,
});
}
ServerMessage::UserInterestsReceived(interests) => {
self.forward_to_client(ClientOperation::UserInterests(
interests,
));
}
ServerMessage::CantConnectToPeer { token } => {
self.forward_to_client(ClientOperation::CantConnectToPeer {
token,
});
}
ServerMessage::PrivateRoomMembers { room, users } => {
self.forward_room_event(RoomEvent::PrivateMembers {
room,
users,
});
}
ServerMessage::PrivateRoomOperators { room, users } => {
self.forward_room_event(RoomEvent::PrivateOperators {
room,
users,
});
}
ServerMessage::PrivateRoomRosterChanged {
room,
username,
members,
added,
} => {
self.forward_room_event(RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
});
}
ServerMessage::OwnRoomStandingChanged {
room,
members,
granted,
} => {
self.forward_room_event(RoomEvent::OwnStandingChanged {
room,
members,
granted,
});
}
ServerMessage::CantCreateRoom { room } => {
self.forward_room_event(RoomEvent::CantCreate { room });
}
ServerMessage::ParentMinSpeed(speed) => {
self.forward_to_client(ClientOperation::ParentMinSpeed(speed));
}
ServerMessage::ParentSpeedRatio(ratio) => {
self.forward_to_client(ClientOperation::ParentSpeedRatio(
ratio,
));
}
ServerMessage::ExcludedSearchPhrases(phrases) => {
self.forward_to_client(ClientOperation::ExcludedSearchPhrases(
phrases,
));
}
other => {
error!("[server] unroutable message: {:?}", other);
}
}
}
fn handle_connect_to_peer(&self, peer: Peer) {
if let Some(op) = match peer.connection_type {
ConnectionType::P | ConnectionType::F => {
Some(ClientOperation::ConnectToPeer(peer))
}
ConnectionType::D => None,
} && let Err(e) = self.client_channel.send(op)
{
error!("[server] failed to send ConnectToPeer: {}", e);
}
}
fn handle_login_status(&mut self, message: bool) {
// Send the post-login handshake exactly once, only on success,
// on the live path (the old ServerActor::login did this but was
// never called). Advertises real shared counts and, when
// listening, the port peers must connect to.
if message {
for msg in post_login_messages(
self.enable_listen,
self.listen_port,
self.shared_folder_count,
self.shared_file_count,
) {
self.send_message(msg);
}
self.session.clear();
// The distributed stance is the leaf's to announce, and a new
// session starts without a parent.
self.forward_to_client(ClientOperation::ResetDistributed);
// The server holds interests only for the life of a session.
self.forward_to_client(ClientOperation::SessionEstablished);
}
match self.context.write_safe() {
Ok(mut ctx) => ctx.logged_in = Some(message),
Err(e) => {
error!("[server] LoginStatus write: {}", e);
}
}
}
fn handle_get_peer_address_response(
&self,
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
) {
debug!(
"[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})",
username, host, port, obfuscation_type, obfuscated_port
);
if let Err(e) =
self.client_channel
.send(ClientOperation::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
})
{
error!(
"[server] Error forwarding GetPeerAddress response to client: {}",
e
);
}
}
/// Hand an operation to the client loop, logging a dead channel rather
/// than unwinding the actor.
fn forward_to_client(&self, operation: ClientOperation) {
if let Err(e) = self.client_channel.send(operation) {
error!("[server] Error forwarding to client: {}", e);
}
}
fn handle_private_message_received(&self, user_message: UserMessage) {
debug!("[server] Private message from {}", user_message.username());
if let Err(e) = self
.client_channel
.send(ClientOperation::PrivateMessageReceived(user_message))
{
error!(
"[server] Error forwarding private message to client: {}",
e
);
}
}
fn handle_login(
&mut self,
username: String,
password: String,
version: ClientVersion,
response: std::sync::mpsc::Sender<Result<bool, SoulseekRs>>,
) {
if self.stream.is_none() && !self.initiate_connection() {
let _ = response.send(Err(SoulseekRs::NotConnected));
return;
}
if let Ok(mut ctx) = self.context.write_safe() {
ctx.logged_in = None;
}
self.queue_message(MessageFactory::build_login_message(
&username, &password, version,
));
let start = std::time::Instant::now();
let context = self.context.clone();
std::thread::spawn(move || {
loop {
if start.elapsed() >= LOGIN_VERDICT_TIMEOUT {
let _ = response.send(Err(SoulseekRs::Timeout));
break;
}
let logged_in = match context.read_safe() {
Ok(ctx) => ctx.logged_in,
Err(e) => {
let _ = response.send(Err(e));
break;
}
};
if let Some(logged_in) = logged_in {
let result = if logged_in {
Ok(true)
} else {
Err(SoulseekRs::AuthenticationFailed)
};
let _ = response.send(result);
break;
}
std::thread::sleep(Duration::from_millis(100));
}
});
}
fn handle_file_search_request(
&self,
username: String,
token: u32,
query: String,
) {
if let Err(e) =
self.client_channel.send(ClientOperation::IncomingSearch {
from_parent: false,
username,
token,
query,
})
{
error!("[server] forward IncomingSearch: {}", e);
}
}
fn process_read(&mut self) {
if self.reader.buffer_len() > 0 {
self.extract_and_process_messages();
@@ -0,0 +1,528 @@
//! What the actor does with a message once it has one: the dispatch and the
//! per-message handlers behind it.
//!
//! A child module, so these keep reaching the actor's private state without
//! any of it being widened for the move.
use super::{
ClientOperation, ClientVersion, ConnectionState, ConnectionType, Duration,
LOGIN_VERDICT_TIMEOUT, MessageFactory, Peer, RoomEvent, RwLockExt,
ServerActor, ServerMessage, SoulseekRs, UserMessage, debug, error,
post_login_messages,
};
impl ServerActor {
pub(super) fn handle_message(&mut self, msg: ServerMessage) {
if !matches!(self.connection_state, ConnectionState::Connected)
&& !matches!(
&msg,
ServerMessage::ProcessRead | ServerMessage::Login { .. }
)
{
self.queued_messages.push(msg);
return;
}
match msg {
ServerMessage::ConnectToPeer(peer) => {
self.handle_connect_to_peer(peer);
}
ServerMessage::LoginStatus(message) => {
self.handle_login_status(message);
}
ServerMessage::Relogged => self.handle_relogged(),
ServerMessage::PierceFirewall(token) => {
self.send_message(
MessageFactory::build_pierce_firewall_message(token),
);
}
ServerMessage::SendMessage(message) => {
self.send_message(message);
}
ServerMessage::GetPeerAddress(username) => {
self.send_message(MessageFactory::build_get_peer_address(
&username,
));
}
ServerMessage::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
} => {
self.handle_get_peer_address_response(
username,
host,
port,
obfuscation_type,
obfuscated_port,
);
}
ServerMessage::PrivateMessageReceived(user_message) => {
self.handle_private_message_received(user_message);
}
ServerMessage::ProcessRead => {
self.process_read();
}
ServerMessage::Login {
username,
password,
version,
response,
} => {
self.handle_login(username, password, version, response);
}
ServerMessage::FileSearch { token, query } => {
self.file_search(token, &query);
}
ServerMessage::FileSearchRequest {
username,
token,
query,
} => {
self.handle_file_search_request(username, token, query);
}
other => self.handle_social_message(other),
}
}
/// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events
/// and user status or stats. Split out, like the standing traffic below,
/// only to keep `handle_message`'s match readable.
fn handle_social_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::RoomListReceived(rooms) => {
self.forward_room_event(RoomEvent::List(rooms));
}
ServerMessage::UserStatusReceived {
username,
status,
privileged,
} => {
self.forward_to_client(ClientOperation::UserStatusReceived {
username,
status,
privileged,
});
}
ServerMessage::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::WatchedUserReceived {
username,
exists,
status,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
} => {
self.forward_to_client(ClientOperation::UserStatsReceived {
username,
average_speed,
shared_files,
shared_folders,
});
}
ServerMessage::RoomJoined { room, users } => {
self.forward_room_event(RoomEvent::Joined { room, users });
}
ServerMessage::RoomMemberStats { room, stats } => {
self.forward_to_client(ClientOperation::RoomMemberStats {
room,
stats,
});
}
ServerMessage::RoomLeft { room } => {
self.forward_room_event(RoomEvent::Left { room });
}
ServerMessage::RoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::Message {
room,
username,
message,
});
}
ServerMessage::RoomUserJoined { room, username } => {
self.forward_room_event(RoomEvent::UserJoined {
room,
username,
});
}
ServerMessage::RoomUserLeft { room, username } => {
self.forward_room_event(RoomEvent::UserLeft { room, username });
}
ServerMessage::RoomTickers { room, tickers } => {
self.forward_room_event(RoomEvent::Tickers { room, tickers });
}
ServerMessage::RoomTickerAdded {
room,
username,
ticker,
} => {
self.forward_room_event(RoomEvent::TickerAdded {
room,
username,
ticker,
});
}
ServerMessage::RoomTickerRemoved { room, username } => {
self.forward_room_event(RoomEvent::TickerRemoved {
room,
username,
});
}
ServerMessage::GlobalRoomMessageReceived {
room,
username,
message,
} => {
self.forward_room_event(RoomEvent::GlobalMessage {
room,
username,
message,
});
}
other => self.handle_standing_message(other),
}
}
/// The wishlist and privilege traffic: standing searches (codes 103/104) and
/// who is privileged (codes 69/92).
///
/// Split out only because it keeps `handle_message`'s match to a readable
/// length; there is no behaviour here beyond dispatch.
fn handle_standing_message(&mut self, message: ServerMessage) {
match message {
ServerMessage::PossibleParents(candidates) => {
self.forward_to_client(ClientOperation::PossibleParents(
candidates,
));
}
ServerMessage::ResetDistributed => {
self.forward_to_client(ClientOperation::ResetDistributed);
}
ServerMessage::WishlistSearch { token, query } => {
self.queue_message(MessageFactory::build_wishlist_search(
token, &query,
));
}
ServerMessage::WishlistInterval(seconds) => {
self.forward_to_client(ClientOperation::WishlistInterval(
seconds,
));
}
ServerMessage::PrivilegedUsers(users) => {
self.forward_to_client(ClientOperation::PrivilegedUsers(users));
}
ServerMessage::OwnPrivileges(seconds) => {
self.forward_to_client(ClientOperation::OwnPrivileges(seconds));
}
ServerMessage::CheckPrivileges => {
self.queue_message(MessageFactory::build_check_privileges());
}
message @ (ServerMessage::RecommendationsReceived { .. }
| ServerMessage::GlobalRecommendationsReceived {
..
}
| ServerMessage::ItemRecommendationsReceived {
..
}
| ServerMessage::SimilarUsersReceived { .. }
| ServerMessage::ItemSimilarUsersReceived { .. }
| ServerMessage::UserInterestsReceived(..)) => {
self.handle_taste_message(message);
}
ServerMessage::CantConnectToPeer { token } => {
self.forward_to_client(ClientOperation::CantConnectToPeer {
token,
});
}
ServerMessage::PrivateRoomMembers { room, users } => {
self.forward_room_event(RoomEvent::PrivateMembers {
room,
users,
});
}
ServerMessage::PrivateRoomOperators { room, users } => {
self.forward_room_event(RoomEvent::PrivateOperators {
room,
users,
});
}
ServerMessage::PrivateRoomRosterChanged {
room,
username,
members,
added,
} => {
self.forward_room_event(RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
});
}
ServerMessage::OwnRoomStandingChanged {
room,
members,
granted,
} => {
self.forward_room_event(RoomEvent::OwnStandingChanged {
room,
members,
granted,
});
}
ServerMessage::CantCreateRoom { room } => {
self.forward_room_event(RoomEvent::CantCreate { room });
}
ServerMessage::ParentMinSpeed(speed) => {
self.forward_to_client(ClientOperation::ParentMinSpeed(speed));
}
ServerMessage::ParentSpeedRatio(ratio) => {
self.forward_to_client(ClientOperation::ParentSpeedRatio(
ratio,
));
}
ServerMessage::ExcludedSearchPhrases(phrases) => {
self.forward_to_client(ClientOperation::ExcludedSearchPhrases(
phrases,
));
}
other => {
error!("[server] unroutable message: {:?}", other);
}
}
}
/// What the server says about taste: the recommendation and similar-user
/// answers, and a user's interests. Split off `handle_standing_message`
/// only to keep that dispatch readable — these arms share nothing with
/// the rest of it.
fn handle_taste_message(&self, message: ServerMessage) {
match message {
ServerMessage::RecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: false,
recommended,
unrecommended,
});
}
ServerMessage::GlobalRecommendationsReceived {
recommended,
unrecommended,
} => {
self.forward_to_client(ClientOperation::Recommendations {
global: true,
recommended,
unrecommended,
});
}
ServerMessage::ItemRecommendationsReceived {
item,
recommendations,
} => {
self.forward_to_client(ClientOperation::ItemRecommendations {
item,
recommendations,
});
}
ServerMessage::SimilarUsersReceived { users } => {
self.forward_to_client(ClientOperation::SimilarUsers(users));
}
ServerMessage::ItemSimilarUsersReceived { item, usernames } => {
self.forward_to_client(ClientOperation::ItemSimilarUsers {
item,
usernames,
});
}
ServerMessage::UserInterestsReceived(interests) => {
self.forward_to_client(ClientOperation::UserInterests(
interests,
));
}
// `handle_standing_message` routes only the variants above here.
_ => {}
}
}
fn handle_connect_to_peer(&self, peer: Peer) {
if let Some(op) = match peer.connection_type {
ConnectionType::P | ConnectionType::F => {
Some(ClientOperation::ConnectToPeer(peer))
}
ConnectionType::D => None,
} && let Err(e) = self.client_channel.send(op)
{
error!("[server] failed to send ConnectToPeer: {}", e);
}
}
pub(super) fn handle_login_status(&mut self, message: bool) {
// Send the post-login handshake exactly once, only on success,
// on the live path (the old ServerActor::login did this but was
// never called). Advertises real shared counts and, when
// listening, the port peers must connect to.
if message {
for msg in post_login_messages(
self.enable_listen,
self.listen_port,
self.shared_folder_count,
self.shared_file_count,
) {
self.send_message(msg);
}
self.session.clear();
// The distributed stance is the leaf's to announce, and a new
// session starts without a parent.
self.forward_to_client(ClientOperation::ResetDistributed);
// The server holds interests only for the life of a session.
self.forward_to_client(ClientOperation::SessionEstablished);
}
match self.context.write_safe() {
Ok(mut ctx) => ctx.logged_in = Some(message),
Err(e) => {
error!("[server] LoginStatus write: {}", e);
}
}
}
fn handle_get_peer_address_response(
&self,
username: String,
host: String,
port: u32,
obfuscation_type: u32,
obfuscated_port: u16,
) {
debug!(
"[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})",
username, host, port, obfuscation_type, obfuscated_port
);
if let Err(e) =
self.client_channel
.send(ClientOperation::GetPeerAddressResponse {
username,
host,
port,
obfuscation_type,
obfuscated_port,
})
{
error!(
"[server] Error forwarding GetPeerAddress response to client: {}",
e
);
}
}
/// Hand an operation to the client loop, logging a dead channel rather
/// than unwinding the actor.
fn forward_to_client(&self, operation: ClientOperation) {
if let Err(e) = self.client_channel.send(operation) {
error!("[server] Error forwarding to client: {}", e);
}
}
fn handle_private_message_received(&self, user_message: UserMessage) {
debug!("[server] Private message from {}", user_message.username());
if let Err(e) = self
.client_channel
.send(ClientOperation::PrivateMessageReceived(user_message))
{
error!(
"[server] Error forwarding private message to client: {}",
e
);
}
}
pub(super) fn handle_login(
&mut self,
username: String,
password: String,
version: ClientVersion,
response: std::sync::mpsc::Sender<Result<bool, SoulseekRs>>,
) {
if self.stream.is_none() && !self.initiate_connection() {
let _ = response.send(Err(SoulseekRs::NotConnected));
return;
}
if let Ok(mut ctx) = self.context.write_safe() {
ctx.logged_in = None;
}
self.queue_message(MessageFactory::build_login_message(
&username, &password, version,
));
let start = std::time::Instant::now();
let context = self.context.clone();
std::thread::spawn(move || {
loop {
if start.elapsed() >= LOGIN_VERDICT_TIMEOUT {
let _ = response.send(Err(SoulseekRs::Timeout));
break;
}
let logged_in = match context.read_safe() {
Ok(ctx) => ctx.logged_in,
Err(e) => {
let _ = response.send(Err(e));
break;
}
};
if let Some(logged_in) = logged_in {
let result = if logged_in {
Ok(true)
} else {
Err(SoulseekRs::AuthenticationFailed)
};
let _ = response.send(result);
break;
}
std::thread::sleep(Duration::from_millis(100));
}
});
}
fn handle_file_search_request(
&self,
username: String,
token: u32,
query: String,
) {
if let Err(e) =
self.client_channel.send(ClientOperation::IncomingSearch {
from_parent: false,
username,
token,
query,
})
{
error!("[server] forward IncomingSearch: {}", e);
}
}
}
+761
View File
@@ -0,0 +1,761 @@
//! What the client's shared context does, as opposed to what it holds.
//!
//! A child module rather than a sibling: the state itself stays declared in
//! `client`, so these methods keep reaching its private fields without any
//! of them having to be widened for the move.
use super::{
ActorSystem, Arc, AtomicUsize, BROKER_CONNECT_TIMEOUT,
BROWSE_PROTECT_WINDOW, ClientContext, DEFAULT_MAX_PEERS,
DEFAULT_UPLOAD_SLOTS, DownloadStatus, DownloadStore, HashMap, HashSet,
Instant, PENDING_PEER_TTL, Recommendation, RoomEvent, RoomInfo, RoomTicker,
RoomUserStats, SharedDirectory, Shares, SimilarUser, UserInfo,
UserInterests, UserMessage, UserPresence, UserStats, UserStatus, children,
distributed, sorted_unique,
};
impl ClientContext {
/// A context for the client that logs in as `username`.
#[must_use]
pub fn for_user(username: &str) -> Self {
Self {
leaf: distributed::Leaf::new(username),
..Self::new()
}
}
#[must_use]
pub fn new() -> Self {
let actor_system = Arc::new(ActorSystem::new());
Self {
peer_registry: None,
server_sender: None,
operations: None,
leaf: distributed::Leaf::new(""),
children: children::Children::default(),
announced_accept_children: None,
own_average_speed: None,
parent_min_speed: 0,
parent_speed_ratio: 0,
searches: HashMap::new(),
private_messages: Vec::new(),
pending_connect_tokens: HashMap::new(),
max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)),
shares: Arc::new(Shares::empty()),
shared_directories: Vec::new(),
peer_addresses: HashMap::new(),
pending_peer_messages: HashMap::new(),
uploads: HashMap::new(),
active_uploads: HashMap::new(),
pending_serves: HashMap::new(),
browse_results: HashMap::new(),
folder_contents: HashMap::new(),
peer_infos: HashMap::new(),
pending_browses: HashMap::new(),
room_list: Vec::new(),
room_events: Vec::new(),
room_members: HashMap::new(),
room_member_stats: HashMap::new(),
room_tickers: HashMap::new(),
private_room_members: HashMap::new(),
private_room_operators: HashMap::new(),
recommendations: None,
global_recommendations: None,
item_recommendations: HashMap::new(),
similar_users: Vec::new(),
item_similar_users: HashMap::new(),
user_interests: HashMap::new(),
own_interests: UserInterests::default(),
user_info: HashMap::new(),
watched_users: HashSet::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
excluded_search_phrases: Vec::new(),
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
last_upload_speed: 0,
upload_events: Vec::new(),
downloads: DownloadStore::new(),
actor_system,
}
}
/// Apply a chat-room event: keep the room-list snapshot and the per-room
/// rosters current, then queue the event for the client/UI to drain.
pub fn apply_room_event(&mut self, event: RoomEvent) {
match &event {
RoomEvent::List(rooms) => self.room_list.clone_from(rooms),
RoomEvent::Joined { room, users } => {
let mut members = users.clone();
members.sort();
members.dedup();
self.room_members.insert(room.clone(), members);
}
RoomEvent::Left { room } => {
self.room_members.remove(room);
// A board for a room we are not in is stale; the server sends
// the whole board again on the next join.
self.room_tickers.remove(room);
}
RoomEvent::UserJoined { room, username } => {
let members =
self.room_members.entry(room.clone()).or_default();
if let Err(at) = members.binary_search(username) {
members.insert(at, username.clone());
}
}
RoomEvent::UserLeft { room, username } => {
if let Some(members) = self.room_members.get_mut(room)
&& let Ok(at) = members.binary_search(username)
{
members.remove(at);
}
}
RoomEvent::Tickers { room, tickers } => {
self.room_tickers.insert(room.clone(), tickers.clone());
}
RoomEvent::TickerAdded {
room,
username,
ticker,
} => {
// A user has at most one ticker: an added one replaces theirs
// rather than stacking, which is how the server treats it.
let board = self.room_tickers.entry(room.clone()).or_default();
board.retain(|t| &t.username != username);
board.push(RoomTicker {
username: username.clone(),
ticker: ticker.clone(),
});
}
RoomEvent::TickerRemoved { room, username } => {
if let Some(board) = self.room_tickers.get_mut(room) {
board.retain(|t| &t.username != username);
}
}
RoomEvent::PrivateMembers { room, users } => {
self.private_room_members
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateOperators { room, users } => {
self.private_room_operators
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
} => {
let roster = if *members {
self.private_room_members.entry(room.clone()).or_default()
} else {
self.private_room_operators.entry(room.clone()).or_default()
};
match (added, roster.binary_search(username)) {
(true, Err(at)) => roster.insert(at, username.clone()),
(false, Ok(at)) => {
roster.remove(at);
}
_ => {}
}
}
RoomEvent::OwnStandingChanged {
room,
members,
granted,
} => {
// Losing membership takes both rosters with it: a room we
// cannot enter has none worth showing. Losing operatorship
// leaves us a member who still sees them, and the server
// narrates that roster change separately (code 144).
if !granted && *members {
self.private_room_members.remove(room);
self.private_room_operators.remove(room);
}
}
RoomEvent::Message { .. }
| RoomEvent::GlobalMessage { .. }
| RoomEvent::CantCreate { .. } => {}
}
self.room_events.push(event);
}
/// Remember an interest of our own so it can be sent again next login.
/// Interests are matched by the server case-insensitively, and Nicotine+
/// lowercases them before sending; do the same so two spellings of one
/// interest cannot both be held.
/// Returns the stored spelling, or `None` for an empty item — which is
/// no interest, and must not be sent to the server either.
pub fn add_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
if !list.contains(&item) {
list.push(item.clone());
}
Some(item)
}
/// Forget one of our own interests. `None` for an empty item, as above.
pub fn remove_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
list.retain(|held| held != &item);
Some(item)
}
/// What we like and hate, as last set.
#[must_use]
pub fn own_interests(&self) -> UserInterests {
self.own_interests.clone()
}
/// Take the `AcceptChildren` answer to send, or `None` when the server
/// already has this one.
pub fn accept_children_change(&mut self) -> Option<bool> {
let has_room = self.children.has_room();
(self.announced_accept_children != Some(has_room)).then(|| {
self.announced_accept_children = Some(has_room);
has_room
})
}
/// The upload speed the server records for us, if it has said.
#[must_use]
pub const fn own_average_speed(&self) -> Option<u32> {
self.own_average_speed
}
/// Record what the server says our own upload speed is, and re-derive the
/// child limit from it.
pub fn set_own_average_speed(&mut self, speed: u32) {
self.own_average_speed = Some(speed);
self.apply_child_limits();
}
/// Record the server's parent figures (codes 83 and 84).
pub fn set_parent_min_speed(&mut self, speed: u32) {
self.parent_min_speed = speed;
self.apply_child_limits();
}
pub fn set_parent_speed_ratio(&mut self, ratio: u32) {
self.parent_speed_ratio = ratio;
self.apply_child_limits();
}
fn apply_child_limits(&mut self) {
self.children.set_limits(
self.own_average_speed,
self.parent_min_speed,
self.parent_speed_ratio,
);
}
/// Whether something is feeding us the distributed search stream: a
/// parent of our own, or the server relaying searches to us. Children may
/// only hang from a client that has one.
pub fn set_fed_by(&mut self, fed: bool) {
self.children.set_fed(fed);
if !fed {
self.children.drop_all();
}
}
/// Record what `username` says about itself.
pub fn store_peer_info(
&mut self,
username: String,
info: crate::message::peer::PeerInfo,
) {
self.peer_infos.insert(username, info);
}
/// What `username` last said about itself, if anything.
#[must_use]
pub fn peer_info(
&self,
username: &str,
) -> Option<crate::message::peer::PeerInfo> {
self.peer_infos.get(username).cloned()
}
/// Drop what we hold about `username`, so a poll after a fresh request
/// cannot report the previous answer.
pub fn invalidate_peer_info(&mut self, username: &str) {
self.peer_infos.remove(username);
}
/// Record one folder's listing from `username`.
pub fn store_folder_contents(
&mut self,
username: String,
folder: String,
directories: Vec<SharedDirectory>,
) {
self.folder_contents.insert((username, folder), directories);
}
/// Remove and return the listing of `folder` from `username`, if it has
/// arrived.
pub fn take_folder_contents(
&mut self,
username: &str,
folder: &str,
) -> Option<Vec<SharedDirectory>> {
self.folder_contents
.remove(&(username.to_string(), folder.to_string()))
}
/// Who may enter the private room `room`, as last reported.
#[must_use]
pub fn private_room_members(&self, room: &str) -> Vec<String> {
self.private_room_members
.get(room)
.cloned()
.unwrap_or_default()
}
/// Who runs the private room `room`, as last reported.
#[must_use]
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
self.private_room_operators
.get(room)
.cloned()
.unwrap_or_default()
}
/// The private rooms we belong to, in a stable order.
#[must_use]
pub fn private_rooms(&self) -> Vec<String> {
let mut rooms: Vec<String> =
self.private_room_members.keys().cloned().collect();
rooms.sort();
rooms
}
/// The ticker board of `room` as last reported by the server.
#[must_use]
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
self.room_tickers.get(room).cloned().unwrap_or_default()
}
/// Record a recommendations reply; `global` selects the server-wide set.
pub fn apply_recommendations(
&mut self,
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
) {
let slot = if global {
&mut self.global_recommendations
} else {
&mut self.recommendations
};
*slot = Some((recommended, unrecommended));
}
/// The last recommendations reply, or `None` until one arrives.
#[must_use]
pub fn recommendations(
&self,
global: bool,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
if global {
self.global_recommendations.clone()
} else {
self.recommendations.clone()
}
}
pub fn apply_item_recommendations(
&mut self,
item: String,
recommendations: Vec<Recommendation>,
) {
self.item_recommendations.insert(item, recommendations);
}
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
self.item_recommendations
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_similar_users(&mut self, users: Vec<SimilarUser>) {
self.similar_users = users;
}
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
self.similar_users.clone()
}
pub fn apply_item_similar_users(
&mut self,
item: String,
usernames: Vec<String>,
) {
self.item_similar_users.insert(item, usernames);
}
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
self.item_similar_users
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_user_interests(&mut self, interests: UserInterests) {
self.user_interests
.insert(interests.username.clone(), interests);
}
#[must_use]
pub fn user_interests(&self, username: &str) -> Option<UserInterests> {
self.user_interests.get(username).cloned()
}
/// Record the phrases the server excludes (code 160), lowercased here so
/// the per-file check is a plain `contains` on a lowercased path.
pub fn set_excluded_search_phrases(&mut self, phrases: Vec<String>) {
self.excluded_search_phrases =
phrases.iter().map(|p| p.to_lowercase()).collect();
}
/// The phrases the server refuses to search for.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.excluded_search_phrases.clone()
}
/// Forget any interests cached for `username`, so a poll after a fresh
/// request cannot mistake the previous answer for this one.
pub fn invalidate_user_interests(&mut self, username: &str) {
self.user_interests.remove(username);
}
/// Record a `GetUserStatus` reply, merging it with any statistics already
/// received for that user.
pub fn apply_user_status(
&mut self,
username: String,
status: u32,
privileged: bool,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
/// Forget what we know about `username`, so the next poll reports the
/// answer to the request being made now rather than the previous one.
pub fn invalidate_user_info(&mut self, username: &str) {
self.user_info.remove(username);
}
/// Record a `GetUserStats` reply, merging it with any status already
/// received for that user.
pub fn apply_user_stats(
&mut self,
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.stats = Some(UserStats {
average_speed,
shared_files,
shared_folders,
});
}
/// Record that we are now watching `username`.
pub fn add_watched_user(&mut self, username: &str) {
self.watched_users.insert(username.to_string());
}
/// Forget a watch, dropping any snapshot we held for that user so a later
/// re-watch reports a fresh answer rather than a stale one.
pub fn remove_watched_user(&mut self, username: &str) {
self.watched_users.remove(username);
self.user_info.remove(username);
}
/// Everyone we are currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
let mut users: Vec<String> =
self.watched_users.iter().cloned().collect();
users.sort();
users
}
/// Record the initial snapshot from a `WatchUser` reply. A username the
/// server does not know carries no stats and is dropped from the watch
/// list, since the server will never push anything for it.
pub fn apply_watched_user(
&mut self,
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
) {
if !exists {
self.watched_users.remove(&username);
return;
}
if let Some(status) = status {
let entry = self
.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username.clone()));
// The watch reply carries no privileged flag, so keep whatever a
// previous GetUserStatus told us rather than asserting `false`.
let privileged =
entry.presence.as_ref().is_some_and(|p| p.privileged);
entry.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
if let (Some(average_speed), Some(shared_files), Some(shared_folders)) =
(average_speed, shared_files, shared_folders)
{
self.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
);
}
}
/// What the server has said about `username` so far.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
self.user_info.get(username).cloned()
}
/// Who is currently in `room`, sorted, or empty when we are not in it.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
self.room_members.get(room).cloned().unwrap_or_default()
}
/// Record the per-member statistics of a room we just joined, replacing
/// any previous snapshot for it.
pub fn apply_room_member_stats(
&mut self,
room: String,
stats: Vec<RoomUserStats>,
) {
self.room_member_stats.insert(room, stats);
}
/// What the server reported about the members of `room`, sorted by name,
/// or empty for a room we have not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
let mut stats = self
.room_member_stats
.get(room)
.cloned()
.unwrap_or_default();
stats.sort_by(|a, b| a.username.cmp(&b.username));
stats
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
self.room_list.clone()
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&mut self) -> Vec<RoomEvent> {
std::mem::take(&mut self.room_events)
}
/// Cache a peer's listen address learned from a GetPeerAddress response.
pub fn cache_peer_address(
&mut self,
username: &str,
host: String,
port: u32,
) {
self.peer_addresses
.insert(username.to_string(), (host, port));
}
/// The cached listen address for `username`, if known.
#[must_use]
pub fn peer_address(&self, username: &str) -> Option<(String, u32)> {
self.peer_addresses.get(username).cloned()
}
/// Queue a peer message to send once a control connection to `username` is up.
pub fn queue_peer_message(
&mut self,
username: &str,
message: crate::message::Message,
) {
let entry = self
.pending_peer_messages
.entry(username.to_string())
.or_insert_with(|| (Instant::now(), Vec::new()));
entry.0 = Instant::now();
entry.1.push(message);
}
/// Remove and return the messages queued for `username`.
pub fn take_peer_messages(
&mut self,
username: &str,
) -> Vec<crate::message::Message> {
self.pending_peer_messages
.remove(username)
.map(|(_, messages)| messages)
.unwrap_or_default()
}
/// Drop peer messages nobody could deliver within
/// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed.
pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) {
self.pending_peer_messages.retain(|_, (since, _)| {
now.duration_since(*since) < PENDING_PEER_TTL
});
}
/// Store a shared-file listing received from browsing `username`.
pub fn store_browse_result(
&mut self,
username: String,
directories: Vec<SharedDirectory>,
) {
self.pending_browses.remove(&username);
self.browse_results.insert(username, directories);
}
pub(crate) fn mark_browse_pending(&mut self, username: &str) {
let now = Instant::now();
self.pending_browses.retain(|_, deadline| *deadline > now);
self.pending_browses
.insert(username.to_string(), now + BROWSE_PROTECT_WINDOW);
}
/// Remove and return the shared-file listing browsed from `username`.
pub fn take_browse_result(
&mut self,
username: &str,
) -> Option<Vec<SharedDirectory>> {
self.browse_results.remove(username)
}
/// Remember that a server-brokered connection to `username` is pending under
/// `token`; the peer will quote it back in a PierceFirewall.
pub fn add_pending_connect(&mut self, token: u32, username: String) {
self.pending_connect_tokens
.insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT));
}
/// Resolve and consume the peer expected for a brokered connection `token`.
pub fn take_pending_connect(&mut self, token: u32) -> Option<String> {
self.pending_connect_tokens
.remove(&token)
.map(|(username, _)| username)
}
pub fn take_expired_connects(&mut self, now: Instant) -> Vec<String> {
let mut expired = Vec::new();
self.pending_connect_tokens
.retain(|_, (username, deadline)| {
if now >= *deadline {
expired.push(std::mem::take(username));
return false;
}
true
});
expired
}
pub(crate) fn protected_peers(&self) -> HashSet<String> {
let mut protected: HashSet<String> = self
.downloads
.list()
.iter()
.filter(|d| {
matches!(
d.status,
DownloadStatus::Queued
| DownloadStatus::InProgress { .. }
| DownloadStatus::Paused { .. }
)
})
.map(|d| d.username.clone())
.collect();
protected
.extend(self.active_uploads.values().map(|u| u.username.clone()));
protected.extend(self.uploads.values().map(|j| j.downloader.clone()));
protected
.extend(self.upload_queue.iter().map(|q| q.downloader.clone()));
protected.extend(self.pending_serves.keys().cloned());
protected.extend(self.pending_peer_messages.keys().cloned());
let now = Instant::now();
protected.extend(
self.pending_browses
.iter()
.filter(|(_, deadline)| **deadline > now)
.map(|(username, _)| username.clone()),
);
protected
}
/// Record a private message received from another user.
pub fn push_private_message(&mut self, message: UserMessage) {
self.private_messages.push(message);
}
/// Remove and return all buffered private messages.
pub fn take_private_messages(&mut self) -> Vec<UserMessage> {
std::mem::take(&mut self.private_messages)
}
}
+1 -745
View File
@@ -629,751 +629,6 @@ impl ClientContext {
}
}
impl ClientContext {
/// A context for the client that logs in as `username`.
#[must_use]
pub fn for_user(username: &str) -> Self {
Self {
leaf: distributed::Leaf::new(username),
..Self::new()
}
}
#[must_use]
pub fn new() -> Self {
let actor_system = Arc::new(ActorSystem::new());
Self {
peer_registry: None,
server_sender: None,
operations: None,
leaf: distributed::Leaf::new(""),
children: children::Children::default(),
announced_accept_children: None,
own_average_speed: None,
parent_min_speed: 0,
parent_speed_ratio: 0,
searches: HashMap::new(),
private_messages: Vec::new(),
pending_connect_tokens: HashMap::new(),
max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)),
shares: Arc::new(Shares::empty()),
shared_directories: Vec::new(),
peer_addresses: HashMap::new(),
pending_peer_messages: HashMap::new(),
uploads: HashMap::new(),
active_uploads: HashMap::new(),
pending_serves: HashMap::new(),
browse_results: HashMap::new(),
folder_contents: HashMap::new(),
peer_infos: HashMap::new(),
pending_browses: HashMap::new(),
room_list: Vec::new(),
room_events: Vec::new(),
room_members: HashMap::new(),
room_member_stats: HashMap::new(),
room_tickers: HashMap::new(),
private_room_members: HashMap::new(),
private_room_operators: HashMap::new(),
recommendations: None,
global_recommendations: None,
item_recommendations: HashMap::new(),
similar_users: Vec::new(),
item_similar_users: HashMap::new(),
user_interests: HashMap::new(),
own_interests: UserInterests::default(),
user_info: HashMap::new(),
watched_users: HashSet::new(),
wishlist_interval: None,
privileged_users: HashSet::new(),
own_privileges: None,
excluded_search_phrases: Vec::new(),
upload_queue: Vec::new(),
upload_seq: 0,
upload_slots: DEFAULT_UPLOAD_SLOTS,
last_upload_speed: 0,
upload_events: Vec::new(),
downloads: DownloadStore::new(),
actor_system,
}
}
/// Apply a chat-room event: keep the room-list snapshot and the per-room
/// rosters current, then queue the event for the client/UI to drain.
pub fn apply_room_event(&mut self, event: RoomEvent) {
match &event {
RoomEvent::List(rooms) => self.room_list.clone_from(rooms),
RoomEvent::Joined { room, users } => {
let mut members = users.clone();
members.sort();
members.dedup();
self.room_members.insert(room.clone(), members);
}
RoomEvent::Left { room } => {
self.room_members.remove(room);
// A board for a room we are not in is stale; the server sends
// the whole board again on the next join.
self.room_tickers.remove(room);
}
RoomEvent::UserJoined { room, username } => {
let members =
self.room_members.entry(room.clone()).or_default();
if let Err(at) = members.binary_search(username) {
members.insert(at, username.clone());
}
}
RoomEvent::UserLeft { room, username } => {
if let Some(members) = self.room_members.get_mut(room)
&& let Ok(at) = members.binary_search(username)
{
members.remove(at);
}
}
RoomEvent::Tickers { room, tickers } => {
self.room_tickers.insert(room.clone(), tickers.clone());
}
RoomEvent::TickerAdded {
room,
username,
ticker,
} => {
// A user has at most one ticker: an added one replaces theirs
// rather than stacking, which is how the server treats it.
let board = self.room_tickers.entry(room.clone()).or_default();
board.retain(|t| &t.username != username);
board.push(RoomTicker {
username: username.clone(),
ticker: ticker.clone(),
});
}
RoomEvent::TickerRemoved { room, username } => {
if let Some(board) = self.room_tickers.get_mut(room) {
board.retain(|t| &t.username != username);
}
}
RoomEvent::PrivateMembers { room, users } => {
self.private_room_members
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateOperators { room, users } => {
self.private_room_operators
.insert(room.clone(), sorted_unique(users));
}
RoomEvent::PrivateRosterChanged {
room,
username,
members,
added,
} => {
let roster = if *members {
self.private_room_members.entry(room.clone()).or_default()
} else {
self.private_room_operators.entry(room.clone()).or_default()
};
match (added, roster.binary_search(username)) {
(true, Err(at)) => roster.insert(at, username.clone()),
(false, Ok(at)) => {
roster.remove(at);
}
_ => {}
}
}
RoomEvent::OwnStandingChanged {
room,
members,
granted,
} => {
// Losing membership takes both rosters with it: a room we
// cannot enter has none worth showing. Losing operatorship
// leaves us a member who still sees them, and the server
// narrates that roster change separately (code 144).
if !granted && *members {
self.private_room_members.remove(room);
self.private_room_operators.remove(room);
}
}
RoomEvent::Message { .. }
| RoomEvent::GlobalMessage { .. }
| RoomEvent::CantCreate { .. } => {}
}
self.room_events.push(event);
}
/// Remember an interest of our own so it can be sent again next login.
/// Interests are matched by the server case-insensitively, and Nicotine+
/// lowercases them before sending; do the same so two spellings of one
/// interest cannot both be held.
/// Returns the stored spelling, or `None` for an empty item — which is
/// no interest, and must not be sent to the server either.
pub fn add_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
if !list.contains(&item) {
list.push(item.clone());
}
Some(item)
}
/// Forget one of our own interests. `None` for an empty item, as above.
pub fn remove_own_interest(
&mut self,
item: &str,
liked: bool,
) -> Option<String> {
let item = item.trim().to_lowercase();
if item.is_empty() {
return None;
}
let list = if liked {
&mut self.own_interests.likes
} else {
&mut self.own_interests.hates
};
list.retain(|held| held != &item);
Some(item)
}
/// What we like and hate, as last set.
#[must_use]
pub fn own_interests(&self) -> UserInterests {
self.own_interests.clone()
}
/// Take the `AcceptChildren` answer to send, or `None` when the server
/// already has this one.
pub fn accept_children_change(&mut self) -> Option<bool> {
let has_room = self.children.has_room();
(self.announced_accept_children != Some(has_room)).then(|| {
self.announced_accept_children = Some(has_room);
has_room
})
}
/// The upload speed the server records for us, if it has said.
#[must_use]
pub const fn own_average_speed(&self) -> Option<u32> {
self.own_average_speed
}
/// Record what the server says our own upload speed is, and re-derive the
/// child limit from it.
pub fn set_own_average_speed(&mut self, speed: u32) {
self.own_average_speed = Some(speed);
self.apply_child_limits();
}
/// Record the server's parent figures (codes 83 and 84).
pub fn set_parent_min_speed(&mut self, speed: u32) {
self.parent_min_speed = speed;
self.apply_child_limits();
}
pub fn set_parent_speed_ratio(&mut self, ratio: u32) {
self.parent_speed_ratio = ratio;
self.apply_child_limits();
}
fn apply_child_limits(&mut self) {
self.children.set_limits(
self.own_average_speed,
self.parent_min_speed,
self.parent_speed_ratio,
);
}
/// Whether something is feeding us the distributed search stream: a
/// parent of our own, or the server relaying searches to us. Children may
/// only hang from a client that has one.
pub fn set_fed_by(&mut self, fed: bool) {
self.children.set_fed(fed);
if !fed {
self.children.drop_all();
}
}
/// Record what `username` says about itself.
pub fn store_peer_info(
&mut self,
username: String,
info: crate::message::peer::PeerInfo,
) {
self.peer_infos.insert(username, info);
}
/// What `username` last said about itself, if anything.
#[must_use]
pub fn peer_info(
&self,
username: &str,
) -> Option<crate::message::peer::PeerInfo> {
self.peer_infos.get(username).cloned()
}
/// Drop what we hold about `username`, so a poll after a fresh request
/// cannot report the previous answer.
pub fn invalidate_peer_info(&mut self, username: &str) {
self.peer_infos.remove(username);
}
/// Record one folder's listing from `username`.
pub fn store_folder_contents(
&mut self,
username: String,
folder: String,
directories: Vec<SharedDirectory>,
) {
self.folder_contents.insert((username, folder), directories);
}
/// Remove and return the listing of `folder` from `username`, if it has
/// arrived.
pub fn take_folder_contents(
&mut self,
username: &str,
folder: &str,
) -> Option<Vec<SharedDirectory>> {
self.folder_contents
.remove(&(username.to_string(), folder.to_string()))
}
/// Who may enter the private room `room`, as last reported.
#[must_use]
pub fn private_room_members(&self, room: &str) -> Vec<String> {
self.private_room_members
.get(room)
.cloned()
.unwrap_or_default()
}
/// Who runs the private room `room`, as last reported.
#[must_use]
pub fn private_room_operators(&self, room: &str) -> Vec<String> {
self.private_room_operators
.get(room)
.cloned()
.unwrap_or_default()
}
/// The private rooms we belong to, in a stable order.
#[must_use]
pub fn private_rooms(&self) -> Vec<String> {
let mut rooms: Vec<String> =
self.private_room_members.keys().cloned().collect();
rooms.sort();
rooms
}
/// The ticker board of `room` as last reported by the server.
#[must_use]
pub fn room_tickers(&self, room: &str) -> Vec<RoomTicker> {
self.room_tickers.get(room).cloned().unwrap_or_default()
}
/// Record a recommendations reply; `global` selects the server-wide set.
pub fn apply_recommendations(
&mut self,
global: bool,
recommended: Vec<Recommendation>,
unrecommended: Vec<Recommendation>,
) {
let slot = if global {
&mut self.global_recommendations
} else {
&mut self.recommendations
};
*slot = Some((recommended, unrecommended));
}
/// The last recommendations reply, or `None` until one arrives.
#[must_use]
pub fn recommendations(
&self,
global: bool,
) -> Option<(Vec<Recommendation>, Vec<Recommendation>)> {
if global {
self.global_recommendations.clone()
} else {
self.recommendations.clone()
}
}
pub fn apply_item_recommendations(
&mut self,
item: String,
recommendations: Vec<Recommendation>,
) {
self.item_recommendations.insert(item, recommendations);
}
#[must_use]
pub fn item_recommendations(&self, item: &str) -> Vec<Recommendation> {
self.item_recommendations
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_similar_users(&mut self, users: Vec<SimilarUser>) {
self.similar_users = users;
}
#[must_use]
pub fn similar_users(&self) -> Vec<SimilarUser> {
self.similar_users.clone()
}
pub fn apply_item_similar_users(
&mut self,
item: String,
usernames: Vec<String>,
) {
self.item_similar_users.insert(item, usernames);
}
#[must_use]
pub fn item_similar_users(&self, item: &str) -> Vec<String> {
self.item_similar_users
.get(item)
.cloned()
.unwrap_or_default()
}
pub fn apply_user_interests(&mut self, interests: UserInterests) {
self.user_interests
.insert(interests.username.clone(), interests);
}
#[must_use]
pub fn user_interests(&self, username: &str) -> Option<UserInterests> {
self.user_interests.get(username).cloned()
}
/// Record the phrases the server excludes (code 160), lowercased here so
/// the per-file check is a plain `contains` on a lowercased path.
pub fn set_excluded_search_phrases(&mut self, phrases: Vec<String>) {
self.excluded_search_phrases =
phrases.iter().map(|p| p.to_lowercase()).collect();
}
/// The phrases the server refuses to search for.
#[must_use]
pub fn excluded_search_phrases(&self) -> Vec<String> {
self.excluded_search_phrases.clone()
}
/// Forget any interests cached for `username`, so a poll after a fresh
/// request cannot mistake the previous answer for this one.
pub fn invalidate_user_interests(&mut self, username: &str) {
self.user_interests.remove(username);
}
/// Record a `GetUserStatus` reply, merging it with any statistics already
/// received for that user.
pub fn apply_user_status(
&mut self,
username: String,
status: u32,
privileged: bool,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
/// Forget what we know about `username`, so the next poll reports the
/// answer to the request being made now rather than the previous one.
pub fn invalidate_user_info(&mut self, username: &str) {
self.user_info.remove(username);
}
/// Record a `GetUserStats` reply, merging it with any status already
/// received for that user.
pub fn apply_user_stats(
&mut self,
username: String,
average_speed: u32,
shared_files: u32,
shared_folders: u32,
) {
self.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username))
.stats = Some(UserStats {
average_speed,
shared_files,
shared_folders,
});
}
/// Record that we are now watching `username`.
pub fn add_watched_user(&mut self, username: &str) {
self.watched_users.insert(username.to_string());
}
/// Forget a watch, dropping any snapshot we held for that user so a later
/// re-watch reports a fresh answer rather than a stale one.
pub fn remove_watched_user(&mut self, username: &str) {
self.watched_users.remove(username);
self.user_info.remove(username);
}
/// Everyone we are currently watching, sorted by name.
#[must_use]
pub fn watched_users(&self) -> Vec<String> {
let mut users: Vec<String> =
self.watched_users.iter().cloned().collect();
users.sort();
users
}
/// Record the initial snapshot from a `WatchUser` reply. A username the
/// server does not know carries no stats and is dropped from the watch
/// list, since the server will never push anything for it.
pub fn apply_watched_user(
&mut self,
username: String,
exists: bool,
status: Option<u32>,
average_speed: Option<u32>,
shared_files: Option<u32>,
shared_folders: Option<u32>,
) {
if !exists {
self.watched_users.remove(&username);
return;
}
if let Some(status) = status {
let entry = self
.user_info
.entry(username.clone())
.or_insert_with(|| UserInfo::pending(username.clone()));
// The watch reply carries no privileged flag, so keep whatever a
// previous GetUserStatus told us rather than asserting `false`.
let privileged =
entry.presence.as_ref().is_some_and(|p| p.privileged);
entry.presence = Some(UserPresence {
status: UserStatus::from_code(status),
privileged,
});
}
if let (Some(average_speed), Some(shared_files), Some(shared_folders)) =
(average_speed, shared_files, shared_folders)
{
self.apply_user_stats(
username,
average_speed,
shared_files,
shared_folders,
);
}
}
/// What the server has said about `username` so far.
#[must_use]
pub fn user_info(&self, username: &str) -> Option<UserInfo> {
self.user_info.get(username).cloned()
}
/// Who is currently in `room`, sorted, or empty when we are not in it.
#[must_use]
pub fn room_members(&self, room: &str) -> Vec<String> {
self.room_members.get(room).cloned().unwrap_or_default()
}
/// Record the per-member statistics of a room we just joined, replacing
/// any previous snapshot for it.
pub fn apply_room_member_stats(
&mut self,
room: String,
stats: Vec<RoomUserStats>,
) {
self.room_member_stats.insert(room, stats);
}
/// What the server reported about the members of `room`, sorted by name,
/// or empty for a room we have not joined.
#[must_use]
pub fn room_member_stats(&self, room: &str) -> Vec<RoomUserStats> {
let mut stats = self
.room_member_stats
.get(room)
.cloned()
.unwrap_or_default();
stats.sort_by(|a, b| a.username.cmp(&b.username));
stats
}
/// The latest snapshot of the public chat-room list.
#[must_use]
pub fn room_list(&self) -> Vec<RoomInfo> {
self.room_list.clone()
}
/// Remove and return all chat-room events received since the last call.
#[must_use]
pub fn take_room_events(&mut self) -> Vec<RoomEvent> {
std::mem::take(&mut self.room_events)
}
/// Cache a peer's listen address learned from a GetPeerAddress response.
pub fn cache_peer_address(
&mut self,
username: &str,
host: String,
port: u32,
) {
self.peer_addresses
.insert(username.to_string(), (host, port));
}
/// The cached listen address for `username`, if known.
#[must_use]
pub fn peer_address(&self, username: &str) -> Option<(String, u32)> {
self.peer_addresses.get(username).cloned()
}
/// Queue a peer message to send once a control connection to `username` is up.
pub fn queue_peer_message(
&mut self,
username: &str,
message: crate::message::Message,
) {
let entry = self
.pending_peer_messages
.entry(username.to_string())
.or_insert_with(|| (Instant::now(), Vec::new()));
entry.0 = Instant::now();
entry.1.push(message);
}
/// Remove and return the messages queued for `username`.
pub fn take_peer_messages(
&mut self,
username: &str,
) -> Vec<crate::message::Message> {
self.pending_peer_messages
.remove(username)
.map(|(_, messages)| messages)
.unwrap_or_default()
}
/// Drop peer messages nobody could deliver within
/// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed.
pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) {
self.pending_peer_messages.retain(|_, (since, _)| {
now.duration_since(*since) < PENDING_PEER_TTL
});
}
/// Store a shared-file listing received from browsing `username`.
pub fn store_browse_result(
&mut self,
username: String,
directories: Vec<SharedDirectory>,
) {
self.pending_browses.remove(&username);
self.browse_results.insert(username, directories);
}
pub(crate) fn mark_browse_pending(&mut self, username: &str) {
let now = Instant::now();
self.pending_browses.retain(|_, deadline| *deadline > now);
self.pending_browses
.insert(username.to_string(), now + BROWSE_PROTECT_WINDOW);
}
/// Remove and return the shared-file listing browsed from `username`.
pub fn take_browse_result(
&mut self,
username: &str,
) -> Option<Vec<SharedDirectory>> {
self.browse_results.remove(username)
}
/// Remember that a server-brokered connection to `username` is pending under
/// `token`; the peer will quote it back in a PierceFirewall.
pub fn add_pending_connect(&mut self, token: u32, username: String) {
self.pending_connect_tokens
.insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT));
}
/// Resolve and consume the peer expected for a brokered connection `token`.
pub fn take_pending_connect(&mut self, token: u32) -> Option<String> {
self.pending_connect_tokens
.remove(&token)
.map(|(username, _)| username)
}
pub fn take_expired_connects(&mut self, now: Instant) -> Vec<String> {
let mut expired = Vec::new();
self.pending_connect_tokens
.retain(|_, (username, deadline)| {
if now >= *deadline {
expired.push(std::mem::take(username));
return false;
}
true
});
expired
}
pub(crate) fn protected_peers(&self) -> HashSet<String> {
let mut protected: HashSet<String> = self
.downloads
.list()
.iter()
.filter(|d| {
matches!(
d.status,
DownloadStatus::Queued
| DownloadStatus::InProgress { .. }
| DownloadStatus::Paused { .. }
)
})
.map(|d| d.username.clone())
.collect();
protected
.extend(self.active_uploads.values().map(|u| u.username.clone()));
protected.extend(self.uploads.values().map(|j| j.downloader.clone()));
protected
.extend(self.upload_queue.iter().map(|q| q.downloader.clone()));
protected.extend(self.pending_serves.keys().cloned());
protected.extend(self.pending_peer_messages.keys().cloned());
let now = Instant::now();
protected.extend(
self.pending_browses
.iter()
.filter(|(_, deadline)| **deadline > now)
.map(|(username, _)| username.clone()),
);
protected
}
/// Record a private message received from another user.
pub fn push_private_message(&mut self, message: UserMessage) {
self.private_messages.push(message);
}
/// Remove and return all buffered private messages.
pub fn take_private_messages(&mut self) -> Vec<UserMessage> {
std::mem::take(&mut self.private_messages)
}
}
pub struct Client {
enable_listen: bool,
listen_port: u16,
@@ -1592,6 +847,7 @@ impl Client {
mod children;
mod connection;
mod context;
mod distributed;
mod downloads;
mod operations;
@@ -8,6 +8,11 @@ use super::{
use crate::message::server::MessageFactory;
use crate::peer::DownloadError;
mod peers;
mod search;
mod transfers;
mod tree;
const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1);
/// How often to ask the peers holding our queued downloads where they sit
@@ -98,118 +103,24 @@ impl Client {
}
}
}
ClientOperation::PeerDisconnected(id, username, error) => {
// Scope the read guard: process_failed_uploads
// below acquires a write lock on the same
// RwLock, which would self-deadlock the entire
// client ops loop if this read guard were still
// held on this thread. Evict only if this exact
// actor still occupies the slot, so a replaced
// actor's shutdown can't remove its successor.
{
let context = match client_context.read_safe() {
Ok(c) => c,
Err(e) => {
error!(
"[client] PeerDisconnected read: {}",
e
);
continue;
}
};
if let Some(ref registry) = context.peer_registry
&& let Some(handle) =
registry.remove_peer_if(&username, id)
{
let _ = handle.stop();
}
}
// Only an error is evidence the peer is gone. A clean
// close — our idle reaper, or a remote client tidying
// an idle socket while it waits in our queue — must
// not throw away everything that peer has queued: the
// connection comes back, the queue cannot. A departed
// peer wedging uploads shut is still covered: its
// erroring transfer fails within the socket deadlines
// and the error branch here frees its slots.
if let Some(error) = error {
warn!(
"[client] Peer {} disconnected with error: {:?}",
username, error
);
Self::process_failed_uploads(
client_context.clone(),
&username,
None,
);
Self::release_upload_slots(
&client_context,
&username,
);
}
operation @ (ClientOperation::PeerDisconnected(..)
| ClientOperation::PeerConnectFailed(..)
| ClientOperation::ChildConnected { .. }) => {
Self::on_peers(
operation,
&client_context,
&own_username,
);
}
ClientOperation::DownloadFromPeer(token, peer, allowed) => {
let maybe_download = match client_context.write_safe() {
Ok(mut ctx) => ctx
.downloads
.claim_for_peer(token, &peer.username),
Err(e) => {
error!(
"[client] DownloadFromPeer write: {}",
e
);
continue;
}
};
trace!(
"[client] DownloadFromPeer token: {} peer: {:?}",
token, peer
operation @ (ClientOperation::DownloadFromPeer(..)
| ClientOperation::UpdateDownloadTokens(..)
| ClientOperation::StartUpload { .. }
| ClientOperation::QueueUpload { .. }) => {
Self::on_transfers(
operation,
&client_context,
&own_username,
);
let Some(download) = maybe_download else {
debug!(
"[client] transfer token {} is missing, belongs \
to another peer, or is already claimed; ignoring",
token,
);
continue;
};
let own_username = own_username.clone();
let client_context = client_context.clone();
thread::spawn(move || {
let download_peer = DownloadPeer::new(
download.username.clone(),
peer.host.clone(),
peer.port,
token,
allowed,
own_username,
);
match download_peer.download_file(
client_context,
Some(download.clone()),
None,
) {
Ok((download, filename)) => {
info!(
"Successfully downloaded {} bytes to {}",
download.size, filename
);
}
Err(DownloadError::Cancelled) => {}
Err(e) => {
error!(
"Failed to download file '{}' from {}:{} (token: {}) - Error: {}",
download.filename,
peer.host,
peer.port,
download.token,
e
);
}
}
});
}
ClientOperation::GetPeerAddressResponse {
username,
@@ -310,78 +221,6 @@ impl Client {
);
}
}
ClientOperation::UpdateDownloadTokens(
transfer,
username,
) => {
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!(
"[client] UpdateDownloadTokens write: {}",
e
);
continue;
}
};
let download_to_update =
context.get_downloads().iter().find_map(|d| {
if d.username == username
&& d.filename == transfer.filename
{
Some((d.token, d.clone()))
} else {
None
}
});
let cancelled = download_to_update
.as_ref()
.is_some_and(|(_, d)| {
matches!(d.status, DownloadStatus::Cancelled)
});
if !cancelled
&& let Some((old_token, download)) =
download_to_update
{
trace!(
"[client] UpdateDownloadTokens found {old_token}, transfer: {:?}",
transfer
);
context.add_download(Download {
token: transfer.token,
size: transfer.size,
..download
});
context.remove_download(old_token);
}
// Only now invite the file connection: it is
// matched by this token, which is recorded as
// of the line above. Answering any earlier
// races the peer's connection against our own
// bookkeeping.
let registry = context.peer_registry.clone();
drop(context);
if let Some(registry) = registry {
let response = if cancelled {
MessageFactory::build_transfer_denial_message(
transfer.token,
"Cancelled",
)
} else {
MessageFactory::build_transfer_response_message(
transfer,
)
};
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(response),
);
}
}
ClientOperation::UploadFailed(username, filename) => {
Self::process_failed_uploads(
client_context.clone(),
@@ -583,138 +422,12 @@ impl Client {
}
}
}
ClientOperation::IncomingSearch {
username,
token,
query,
from_parent,
} => {
// Every search we receive comes from the tree: from
// our parent, or from the server acting as one. That
// is what qualifies us to carry children at all —
// Nicotine+ refuses them until something feeds it —
// and passing the search down is the whole duty.
let (capacity_changed, answer) = match client_context
.write_safe()
{
Ok(mut ctx) => {
let was_fed = ctx.children.is_fed();
let before = ctx.children.len();
ctx.set_fed_by(true);
if !from_parent {
// A parent's search was passed down where
// it arrived; this is the server's.
ctx.children.broadcast_search(
&username, token, &query,
);
}
// Answering costs a scan of everything we
// share, so the budget applies to searches
// from the tree. The server's own are few.
let answer = !from_parent
|| ctx.leaf.admit_search(Instant::now());
// A child whose socket has gone is dropped by
// that write, which frees a slot the server
// should hear about.
(
!was_fed || ctx.children.len() != before,
answer,
)
}
Err(_) => (false, false),
};
if capacity_changed {
Self::announce_child_capacity(&client_context);
}
if !answer {
continue;
}
// Don't answer our own distributed search.
if username == own_username {
continue;
}
let response = match client_context.read_safe() {
Ok(ctx) => build_search_response(
&ctx.shares,
&own_username,
token,
&query,
ctx.has_free_upload_slot(),
ctx.last_upload_speed,
ctx.upload_queue.len() as u32,
&ctx.excluded_search_phrases,
),
Err(e) => {
error!("[client] IncomingSearch read: {}", e);
continue;
}
};
let Some(message) = response else {
continue; // no matching shares
};
// Deliver to the searcher: send now if we have a
// control connection, else open one and queue.
let (connected, registry, server_sender) =
match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username)),
ctx.peer_registry.clone(),
ctx.server_sender.clone(),
),
Err(_) => continue,
};
if connected {
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(message),
);
}
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.queue_peer_message(&username, message);
}
if let Some(sender) = server_sender {
let _ = sender.send(
ServerMessage::GetPeerAddress(username),
);
}
}
}
ClientOperation::QueueUpload {
requester_key,
filename,
} => {
// The peer served next may not be this one.
match client_context.write_safe() {
Ok(mut ctx) => {
let Some(file) = ctx.shares.get(&filename)
else {
debug!(
"[client] QueueUpload for unknown file {}",
filename
);
continue;
};
let size = file.size;
let real_path = file.real_path.clone();
ctx.enqueue_upload(
&requester_key,
&filename,
real_path,
size,
);
}
Err(e) => {
error!("[client] QueueUpload write: {}", e);
continue;
}
}
Self::pump_upload_queue(&client_context);
operation @ ClientOperation::IncomingSearch { .. } => {
Self::on_search(
operation,
&client_context,
&own_username,
);
}
ClientOperation::PlaceInQueueRequested {
requester_key,
@@ -760,99 +473,16 @@ impl Client {
);
}
}
ClientOperation::ParentBranchLevel {
parent,
link,
level,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if let Some(branch) =
ctx.leaf.branch_level(&parent, link, level)
{
super::distributed::announce_move(
&mut ctx, &branch, true,
);
}
operation @ (ClientOperation::ParentSearch { .. }
| ClientOperation::ParentBranchLevel {
..
}
ClientOperation::ParentBranchRoot {
parent,
link,
root,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if let Some(branch) =
ctx.leaf.branch_root(&parent, link, &root)
{
super::distributed::announce_move(
&mut ctx, &branch, true,
);
}
| ClientOperation::ParentBranchRoot {
..
}
ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if let Some(branch) =
ctx.leaf.search_from(&parent, link)
{
super::distributed::announce_move(
&mut ctx, &branch, true,
);
}
if ctx.leaf.is_parent(&parent, link) {
// The tree's stream is not ours to throttle: every
// search goes down to our children. The budget
// below bounds only the share scan we do for our
// own reply.
ctx.children
.broadcast_search(&username, token, &query);
if let Some(ops) = &ctx.operations {
let _ =
ops.send(ClientOperation::IncomingSearch {
username,
token,
query,
from_parent: true,
});
}
}
}
ClientOperation::ParentClosed { parent, link } => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if ctx.leaf.closed(&parent, link) {
let branch = ctx.leaf.branch();
super::distributed::announce_move(
&mut ctx, &branch, false,
);
}
}
// Also what a fresh login means: whatever tree we hung
// from belongs to the old session.
ClientOperation::ResetDistributed => {
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
ctx.leaf.reset();
// A fresh session feeds us nothing until the tree (or
// the server) starts again, so the children we were
// carrying are let go rather than left starving.
ctx.set_fed_by(false);
let branch = ctx.leaf.branch();
super::distributed::announce_move(
&mut ctx, &branch, false,
);
| ClientOperation::ParentClosed { .. }
| ClientOperation::ResetDistributed) => {
Self::on_tree(operation, &client_context);
}
ClientOperation::PrivilegedUsers(users) => {
if let Ok(mut ctx) = client_context.write_safe() {
@@ -911,51 +541,6 @@ impl Client {
ctx.apply_user_interests(interests);
}
}
ClientOperation::ChildConnected { username, stream } => {
// A peer wants to hang from us. Take it on if this
// client serves children and has room, then tell it
// where our branch sits so it can report its own
// place; otherwise the socket is dropped here and the
// peer looks for another parent.
let Ok(mut ctx) = client_context.write_safe() else {
continue;
};
if !ctx.children.has_room() {
continue;
}
// A child that stops reading must not wedge us: the
// relay writes under the context lock, so the write
// is bounded and a child that hits it loses its slot.
if stream.set_nodelay(true).is_err()
|| stream
.set_write_timeout(Some(CHILD_WRITE_TIMEOUT))
.is_err()
{
continue;
}
if username == own_username {
continue; // we cannot hang from ourselves
}
if ctx.children.accept(&username, stream) {
let branch = ctx.leaf.branch();
ctx.children.send_stance_to(
&username,
&branch.root,
branch.level,
);
debug!(
"[distributed] carrying {} children",
ctx.children.len()
);
// The server stops offering us to peers once we
// are full, and starts again when one leaves.
let full = !ctx.children.has_room();
drop(ctx);
if full {
Self::announce_child_capacity(&client_context);
}
}
}
ClientOperation::PeerInfoReceived { username, info } => {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.store_peer_info(username, info);
@@ -1058,49 +643,6 @@ impl Client {
);
}
}
ClientOperation::StartUpload { token } => {
// The peer accepted our offer: resolve their
// address (from the code-9 GetPeerAddress) and
// stream the file, or queue until it resolves.
let (job_addr, downloader) = match client_context
.write_safe()
{
Ok(mut ctx) => {
ctx.mark_offer_answered(token);
let Some(job) = ctx.uploads.get(&token) else {
continue;
};
(
ctx.peer_address(&job.downloader),
job.downloader.clone(),
)
}
Err(_) => continue,
};
if let Some((host, port)) = job_addr {
Self::spawn_serve(
&client_context,
&own_username,
token,
host,
port,
);
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.pending_serves
.entry(downloader.clone())
.or_default()
.push(token);
}
if let Ok(ctx) = client_context.read_safe()
&& let Some(sender) = ctx.server_sender.clone()
{
let _ = sender.send(
ServerMessage::GetPeerAddress(downloader),
);
}
}
}
ClientOperation::ShareListRequested { requester_key } => {
Self::reply_to_peer(
&client_context,
@@ -1156,103 +698,6 @@ impl Client {
ctx.store_browse_result(username, directories);
}
}
ClientOperation::PeerConnectFailed(
id,
username,
brokered_token,
) => {
// Direct connect failed. A dial the server asked
// us to make is answered with CantConnectToPeer
// quoting the peer's own token: that peer is
// already waiting on the server, and asking it to
// broker the same connection back only trades one
// unreachable direction for the other. Every other
// dial falls back to the broker below.
if let Some(peer_token) = brokered_token {
let server_sender = match client_context
.write_safe()
{
Ok(ctx) => {
if let Some(handle) =
ctx.peer_registry.as_ref().and_then(
|r| r.remove_peer_if(&username, id),
)
{
let _ = handle.stop();
}
ctx.server_sender.clone()
}
Err(e) => {
error!(
"[client] PeerConnectFailed write: {}",
e
);
continue;
}
};
if let Some(sender) = server_sender {
let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer(
peer_token,
&username,
);
let _ = sender
.send(ServerMessage::SendMessage(msg));
}
// Nothing else is coming from this peer, so
// anything queued for it fails now rather than
// waiting out its timeout.
Self::fail_queued_downloads(
&client_context,
&username,
);
continue;
}
// Direct connect failed: ask the server to
// broker it. Register a correlation token, then
// send ConnectToPeer so the (firewalled) peer
// connects back to our listener quoting it.
let token = next_connect_token();
let server_sender = match client_context.write_safe() {
Ok(mut ctx) => {
// Reap the dead outbound actor so it
// stops pinning a pool worker and no
// longer shadows the brokered reconnect
// (a stale registry entry would make
// later downloads queue into a dead,
// streamless actor and hang). Identity-
// aware so a newer namesake is untouched.
if let Some(handle) =
ctx.peer_registry.as_ref().and_then(|r| {
r.remove_peer_if(&username, id)
})
{
let _ = handle.stop();
}
ctx.add_pending_connect(
token,
username.clone(),
);
ctx.server_sender.clone()
}
Err(e) => {
error!(
"[client] PeerConnectFailed write: {}",
e
);
continue;
}
};
let Some(sender) = server_sender else {
continue;
};
let msg = crate::message::server::MessageFactory::build_connect_to_peer(
token,
&username,
ConnectionType::P,
);
let _ = sender.send(ServerMessage::SendMessage(msg));
}
}
}
});
@@ -0,0 +1,196 @@
//! Dispatch arms about a peer connection appearing or going away.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, CHILD_WRITE_TIMEOUT, Client, ClientContext, ClientOperation,
ConnectionType, RwLock, RwLockExt, ServerMessage, debug, error,
next_connect_token, warn,
};
impl Client {
pub(super) fn on_peers(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
match operation {
ClientOperation::PeerDisconnected(id, username, error) => {
// Scope the read guard: process_failed_uploads
// below acquires a write lock on the same
// RwLock, which would self-deadlock the entire
// client ops loop if this read guard were still
// held on this thread. Evict only if this exact
// actor still occupies the slot, so a replaced
// actor's shutdown can't remove its successor.
{
let context = match client_context.read_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] PeerDisconnected read: {}", e);
return;
}
};
if let Some(ref registry) = context.peer_registry
&& let Some(handle) =
registry.remove_peer_if(&username, id)
{
let _ = handle.stop();
}
}
// Only an error is evidence the peer is gone. A clean
// close — our idle reaper, or a remote client tidying
// an idle socket while it waits in our queue — must
// not throw away everything that peer has queued: the
// connection comes back, the queue cannot. A departed
// peer wedging uploads shut is still covered: its
// erroring transfer fails within the socket deadlines
// and the error branch here frees its slots.
if let Some(error) = error {
warn!(
"[client] Peer {} disconnected with error: {:?}",
username, error
);
Self::process_failed_uploads(
client_context.clone(),
&username,
None,
);
Self::release_upload_slots(client_context, &username);
}
}
ClientOperation::PeerConnectFailed(
id,
username,
brokered_token,
) => {
// Direct connect failed. A dial the server asked
// us to make is answered with CantConnectToPeer
// quoting the peer's own token: that peer is
// already waiting on the server, and asking it to
// broker the same connection back only trades one
// unreachable direction for the other. Every other
// dial falls back to the broker below.
if let Some(peer_token) = brokered_token {
let server_sender = match client_context.write_safe() {
Ok(ctx) => {
if let Some(handle) = ctx
.peer_registry
.as_ref()
.and_then(|r| r.remove_peer_if(&username, id))
{
let _ = handle.stop();
}
ctx.server_sender.clone()
}
Err(e) => {
error!("[client] PeerConnectFailed write: {}", e);
return;
}
};
if let Some(sender) = server_sender {
let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer(
peer_token,
&username,
);
let _ = sender.send(ServerMessage::SendMessage(msg));
}
// Nothing else is coming from this peer, so
// anything queued for it fails now rather than
// waiting out its timeout.
Self::fail_queued_downloads(client_context, &username);
return;
}
// Direct connect failed: ask the server to
// broker it. Register a correlation token, then
// send ConnectToPeer so the (firewalled) peer
// connects back to our listener quoting it.
let token = next_connect_token();
let server_sender = match client_context.write_safe() {
Ok(mut ctx) => {
// Reap the dead outbound actor so it
// stops pinning a pool worker and no
// longer shadows the brokered reconnect
// (a stale registry entry would make
// later downloads queue into a dead,
// streamless actor and hang). Identity-
// aware so a newer namesake is untouched.
if let Some(handle) = ctx
.peer_registry
.as_ref()
.and_then(|r| r.remove_peer_if(&username, id))
{
let _ = handle.stop();
}
ctx.add_pending_connect(token, username.clone());
ctx.server_sender.clone()
}
Err(e) => {
error!("[client] PeerConnectFailed write: {}", e);
return;
}
};
let Some(sender) = server_sender else {
return;
};
let msg = crate::message::server::MessageFactory::build_connect_to_peer(
token,
&username,
ConnectionType::P,
);
let _ = sender.send(ServerMessage::SendMessage(msg));
}
ClientOperation::ChildConnected { username, stream } => {
// A peer wants to hang from us. Take it on if this
// client serves children and has room, then tell it
// where our branch sits so it can report its own
// place; otherwise the socket is dropped here and the
// peer looks for another parent.
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if !ctx.children.has_room() {
return;
}
// A child that stops reading must not wedge us: the
// relay writes under the context lock, so the write
// is bounded and a child that hits it loses its slot.
if stream.set_nodelay(true).is_err()
|| stream
.set_write_timeout(Some(CHILD_WRITE_TIMEOUT))
.is_err()
{
return;
}
if username == own_username {
return; // we cannot hang from ourselves
}
if ctx.children.accept(&username, stream) {
let branch = ctx.leaf.branch();
ctx.children.send_stance_to(
&username,
&branch.root,
branch.level,
);
debug!(
"[distributed] carrying {} children",
ctx.children.len()
);
// The server stops offering us to peers once we
// are full, and starts again when one leaves.
let full = !ctx.children.has_room();
drop(ctx);
if full {
Self::announce_child_capacity(client_context);
}
}
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
@@ -0,0 +1,118 @@
//! The dispatch arm that answers a search reaching us from the network.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, Client, ClientContext, ClientOperation, Instant, PeerMessage, RwLock,
RwLockExt, ServerMessage, build_search_response, error,
};
impl Client {
pub(super) fn on_search(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
let ClientOperation::IncomingSearch {
username,
token,
query,
from_parent,
} = operation
else {
return;
};
{
// Every search we receive comes from the tree: from
// our parent, or from the server acting as one. That
// is what qualifies us to carry children at all —
// Nicotine+ refuses them until something feeds it —
// and passing the search down is the whole duty.
let (capacity_changed, answer) = match client_context.write_safe() {
Ok(mut ctx) => {
let was_fed = ctx.children.is_fed();
let before = ctx.children.len();
ctx.set_fed_by(true);
if !from_parent {
// A parent's search was passed down where
// it arrived; this is the server's.
ctx.children.broadcast_search(&username, token, &query);
}
// Answering costs a scan of everything we
// share, so the budget applies to searches
// from the tree. The server's own are few.
let answer =
!from_parent || ctx.leaf.admit_search(Instant::now());
// A child whose socket has gone is dropped by
// that write, which frees a slot the server
// should hear about.
(!was_fed || ctx.children.len() != before, answer)
}
Err(_) => (false, false),
};
if capacity_changed {
Self::announce_child_capacity(client_context);
}
if !answer {
return;
}
// Don't answer our own distributed search.
if username == own_username {
return;
}
let response = match client_context.read_safe() {
Ok(ctx) => build_search_response(
&ctx.shares,
&own_username,
token,
&query,
ctx.has_free_upload_slot(),
ctx.last_upload_speed,
ctx.upload_queue.len() as u32,
&ctx.excluded_search_phrases,
),
Err(e) => {
error!("[client] IncomingSearch read: {}", e);
return;
}
};
let Some(message) = response else {
return; // no matching shares
};
// Deliver to the searcher: send now if we have a
// control connection, else open one and queue.
let (connected, registry, server_sender) =
match client_context.read_safe() {
Ok(ctx) => (
ctx.peer_registry
.as_ref()
.is_some_and(|r| r.contains(&username)),
ctx.peer_registry.clone(),
ctx.server_sender.clone(),
),
Err(_) => return,
};
if connected {
if let Some(registry) = registry {
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(message),
);
}
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.queue_peer_message(&username, message);
}
if let Some(sender) = server_sender {
let _ =
sender.send(ServerMessage::GetPeerAddress(username));
}
}
}
}
}
@@ -0,0 +1,219 @@
//! Dispatch arms that move bytes: what a peer allows, what it asks of us,
//! and the tokens tying the two together.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it — including `own_username`, which is re-owned on the way in
//! rather than have every line that clones it rewritten.
use super::{
Arc, Client, ClientContext, ClientOperation, Download, DownloadError,
DownloadPeer, DownloadStatus, MessageFactory, PeerMessage, RwLock,
RwLockExt, ServerMessage, debug, error, info, thread, trace,
};
impl Client {
pub(super) fn on_transfers(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
own_username: &str,
) {
let own_username = own_username.to_string();
match operation {
ClientOperation::DownloadFromPeer(token, peer, allowed) => {
let maybe_download = match client_context.write_safe() {
Ok(mut ctx) => {
ctx.downloads.claim_for_peer(token, &peer.username)
}
Err(e) => {
error!("[client] DownloadFromPeer write: {}", e);
return;
}
};
trace!(
"[client] DownloadFromPeer token: {} peer: {:?}",
token, peer
);
let Some(download) = maybe_download else {
debug!(
"[client] transfer token {} is missing, belongs \
to another peer, or is already claimed; ignoring",
token,
);
return;
};
let client_context = client_context.clone();
thread::spawn(move || {
let download_peer = DownloadPeer::new(
download.username.clone(),
peer.host.clone(),
peer.port,
token,
allowed,
own_username,
);
match download_peer.download_file(
client_context,
Some(download.clone()),
None,
) {
Ok((download, filename)) => {
info!(
"Successfully downloaded {} bytes to {}",
download.size, filename
);
}
Err(DownloadError::Cancelled) => {}
Err(e) => {
error!(
"Failed to download file '{}' from {}:{} (token: {}) - Error: {}",
download.filename,
peer.host,
peer.port,
download.token,
e
);
}
}
});
}
ClientOperation::UpdateDownloadTokens(transfer, username) => {
let mut context = match client_context.write_safe() {
Ok(c) => c,
Err(e) => {
error!("[client] UpdateDownloadTokens write: {}", e);
return;
}
};
let download_to_update =
context.get_downloads().iter().find_map(|d| {
if d.username == username
&& d.filename == transfer.filename
{
Some((d.token, d.clone()))
} else {
None
}
});
let cancelled =
download_to_update.as_ref().is_some_and(|(_, d)| {
matches!(d.status, DownloadStatus::Cancelled)
});
if !cancelled
&& let Some((old_token, download)) = download_to_update
{
trace!(
"[client] UpdateDownloadTokens found {old_token}, transfer: {:?}",
transfer
);
context.add_download(Download {
token: transfer.token,
size: transfer.size,
..download
});
context.remove_download(old_token);
}
// Only now invite the file connection: it is
// matched by this token, which is recorded as
// of the line above. Answering any earlier
// races the peer's connection against our own
// bookkeeping.
let registry = context.peer_registry.clone();
drop(context);
if let Some(registry) = registry {
let response = if cancelled {
MessageFactory::build_transfer_denial_message(
transfer.token,
"Cancelled",
)
} else {
MessageFactory::build_transfer_response_message(
transfer,
)
};
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(response),
);
}
}
ClientOperation::StartUpload { token } => {
// The peer accepted our offer: resolve their
// address (from the code-9 GetPeerAddress) and
// stream the file, or queue until it resolves.
let (job_addr, downloader) = match client_context.write_safe() {
Ok(mut ctx) => {
ctx.mark_offer_answered(token);
let Some(job) = ctx.uploads.get(&token) else {
return;
};
(
ctx.peer_address(&job.downloader),
job.downloader.clone(),
)
}
Err(_) => return,
};
if let Some((host, port)) = job_addr {
Self::spawn_serve(
client_context,
&own_username,
token,
host,
port,
);
} else {
if let Ok(mut ctx) = client_context.write_safe() {
ctx.pending_serves
.entry(downloader.clone())
.or_default()
.push(token);
}
if let Ok(ctx) = client_context.read_safe()
&& let Some(sender) = ctx.server_sender.clone()
{
let _ = sender
.send(ServerMessage::GetPeerAddress(downloader));
}
}
}
ClientOperation::QueueUpload {
requester_key,
filename,
} => {
// The peer served next may not be this one.
match client_context.write_safe() {
Ok(mut ctx) => {
let Some(file) = ctx.shares.get(&filename) else {
debug!(
"[client] QueueUpload for unknown file {}",
filename
);
return;
};
let size = file.size;
let real_path = file.real_path.clone();
ctx.enqueue_upload(
&requester_key,
&filename,
real_path,
size,
);
}
Err(e) => {
error!("[client] QueueUpload write: {}", e);
return;
}
}
Self::pump_upload_queue(client_context);
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
@@ -0,0 +1,103 @@
//! Dispatch arms for our place in the distributed search tree.
//!
//! Lifted out of the dispatch loop whole, so the bodies read exactly as they
//! did inside it.
use super::{Arc, Client, ClientContext, ClientOperation, RwLock, RwLockExt};
impl Client {
pub(super) fn on_tree(
operation: ClientOperation,
client_context: &Arc<RwLock<ClientContext>>,
) {
match operation {
ClientOperation::ParentSearch {
parent,
link,
username,
token,
query,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) = ctx.leaf.search_from(&parent, link) {
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
if ctx.leaf.is_parent(&parent, link) {
// The tree's stream is not ours to throttle: every
// search goes down to our children. The budget
// below bounds only the share scan we do for our
// own reply.
ctx.children.broadcast_search(&username, token, &query);
if let Some(ops) = &ctx.operations {
let _ = ops.send(ClientOperation::IncomingSearch {
username,
token,
query,
from_parent: true,
});
}
}
}
ClientOperation::ParentBranchLevel {
parent,
link,
level,
} => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) =
ctx.leaf.branch_level(&parent, link, level)
{
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
ClientOperation::ParentBranchRoot { parent, link, root } => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root)
{
crate::client::distributed::announce_move(
&mut ctx, &branch, true,
);
}
}
ClientOperation::ParentClosed { parent, link } => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
if ctx.leaf.closed(&parent, link) {
let branch = ctx.leaf.branch();
crate::client::distributed::announce_move(
&mut ctx, &branch, false,
);
}
}
// Also what a fresh login means: whatever tree we hung
// from belongs to the old session.
ClientOperation::ResetDistributed => {
let Ok(mut ctx) = client_context.write_safe() else {
return;
};
ctx.leaf.reset();
// A fresh session feeds us nothing until the tree (or
// the server) starts again, so the children we were
// carrying are let go rather than left starving.
ctx.set_fed_by(false);
let branch = ctx.leaf.branch();
crate::client::distributed::announce_move(
&mut ctx, &branch, false,
);
}
// The dispatch loop routes only the variants above to here.
_ => {}
}
}
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
//! The client's context, exercised directly.
//!
//! This file is the harness the topic modules below share.
mod peers;
mod rooms;
mod search;
mod transfers;
mod users;
use super::*;
fn download(
username: &str,
filename: &str,
token: u32,
status: DownloadStatus,
sender: Sender<DownloadStatus>,
) -> Download {
Download {
username: username.to_string(),
filename: filename.to_string(),
token,
size: 100,
download_directory: "test".to_string(),
status,
sender,
queue_position: None,
metadata: DownloadMetadata::default(),
}
}
fn peer_files(count: usize) -> SearchResult {
SearchResult {
token: 1,
files: (0..count)
.map(|i| crate::types::File {
username: "bob".to_string(),
name: format!("song-{i}.mp3"),
size: 1,
attribs: std::collections::HashMap::new(),
})
.collect(),
slots: 1,
speed: 0,
username: "bob".to_string(),
}
}
use crate::types::{RoomEvent, UploadStatus};
use std::time::{Duration, Instant};
+173
View File
@@ -0,0 +1,173 @@
//! Uploads, broker connects, and the peers a disconnect may not drop.
use super::*;
#[test]
fn upload_speed_is_reported_only_while_running() {
let two_seconds_ago = Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("clock supports a 2s offset");
// 1 KiB over ~2s is ~512 B/s.
let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago);
assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}");
// A finished upload reports no rate, exactly as a finished download
// does, so the Speed column renders "-" rather than a stale figure.
for status in [
UploadStatus::Completed,
UploadStatus::Cancelled,
UploadStatus::Failed("nope".to_string()),
] {
let rate = upload_speed(&status, 1024, two_seconds_ago);
assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}");
}
// A just-started upload must not divide by a zero elapsed time.
let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now());
assert!(
rate.is_finite() && rate.abs() < f64::EPSILON,
"unexpected rate {rate}"
);
}
// A clean disconnect — the idle reaper, or a remote client tidying an idle
// socket while it waits in our queue — must keep the peer's queued uploads;
// only an error disconnect is evidence the peer is gone.
#[test]
fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() {
let client = Client::new("test-user", "test-password");
let (ops, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"me".to_string(),
);
client.context.write().unwrap().enqueue_upload(
"amy",
"@@share\\f.mp3",
std::path::PathBuf::from("/tmp/f.mp3"),
4096,
);
ops.send(ClientOperation::PeerDisconnected(
1,
"amy".to_string(),
None,
))
.unwrap();
// The loop is serial, so once this fence op is visible the disconnect
// before it has been handled.
ops.send(ClientOperation::OwnPrivileges(7)).unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
while client.own_privilege_seconds() != Some(7) {
assert!(Instant::now() < deadline, "ops loop never caught up");
thread::sleep(Duration::from_millis(10));
}
assert_eq!(
client
.context
.read()
.unwrap()
.place_in_queue("amy", "@@share\\f.mp3"),
Some(1),
"a clean disconnect must keep the queue"
);
ops.send(ClientOperation::PeerDisconnected(
1,
"amy".to_string(),
Some(crate::error::SoulseekRs::NotConnected),
))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let place = client
.context
.read()
.unwrap()
.place_in_queue("amy", "@@share\\f.mp3");
if place.is_none() {
break;
}
assert!(
Instant::now() < deadline,
"an error disconnect must drop the queue"
);
thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn a_pending_connect_expires_after_the_broker_timeout() {
let mut context = ClientContext::new();
context.add_pending_connect(7, "ghost".to_string());
assert!(context.take_expired_connects(Instant::now()).is_empty());
let after_deadline =
Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1);
assert_eq!(
context.take_expired_connects(after_deadline),
vec!["ghost".to_string()]
);
assert!(context.take_pending_connect(7).is_none());
}
#[test]
fn protected_peers_covers_downloads_uploads_and_pending_serves() {
let mut context = ClientContext::new();
context.add_download(download(
"downloader",
"song.mp3",
1,
DownloadStatus::Queued,
mpsc::channel().0,
));
context.add_download(download(
"done",
"old.mp3",
2,
DownloadStatus::Completed,
mpsc::channel().0,
));
context
.pending_serves
.insert("waiting".to_string(), vec![9]);
context.mark_browse_pending("browsed");
let protected = context.protected_peers();
assert!(protected.contains("downloader"));
assert!(protected.contains("waiting"));
assert!(protected.contains("browsed"));
assert!(!protected.contains("done"));
context.store_browse_result("browsed".to_string(), Vec::new());
assert!(!context.protected_peers().contains("browsed"));
}
#[test]
fn a_peer_message_nobody_could_deliver_expires() {
let mut ctx = ClientContext::for_user("me");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now());
assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh");
ctx.queue_peer_message("ghost", crate::message::Message::new());
ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL);
assert!(ctx.take_peer_messages("ghost").is_empty(), "expired");
}
#[test]
fn the_server_is_told_our_child_capacity_only_when_it_changes() {
// AcceptChildren is a standing state on the server, not a heartbeat.
let mut ctx = ClientContext::new();
assert_eq!(
ctx.accept_children_change(),
Some(false),
"the first answer is always worth sending"
);
assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent");
}
+297
View File
@@ -0,0 +1,297 @@
//! Room rosters, ticker boards, and the private rooms the server lets us
//! see.
use super::*;
#[test]
fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() {
let mut context = ClientContext::new();
// Joining hands us the current membership in one event.
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["bob".to_string(), "alice".to_string()],
});
assert_eq!(context.room_members("lobby"), ["alice", "bob"]);
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "carol".to_string(),
});
context.apply_room_event(RoomEvent::UserLeft {
room: "lobby".to_string(),
username: "bob".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// Events for other rooms do not leak into this one.
context.apply_room_event(RoomEvent::UserJoined {
room: "elsewhere".to_string(),
username: "dave".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice", "carol"]);
// A room we never joined has no roster, and leaving forgets it.
assert!(context.room_members("unknown").is_empty());
context.apply_room_event(RoomEvent::Left {
room: "lobby".to_string(),
});
assert!(context.room_members("lobby").is_empty());
}
#[test]
fn a_user_joining_twice_is_listed_once() {
let mut context = ClientContext::new();
context.apply_room_event(RoomEvent::Joined {
room: "lobby".to_string(),
users: vec!["alice".to_string()],
});
context.apply_room_event(RoomEvent::UserJoined {
room: "lobby".to_string(),
username: "alice".to_string(),
});
assert_eq!(context.room_members("lobby"), ["alice"]);
}
#[test]
fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: true,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats(
"jazz".to_string(),
vec![stat("carol"), stat("alice")],
);
let names: Vec<String> = context
.room_member_stats("jazz")
.into_iter()
.map(|s| s.username)
.collect();
assert_eq!(names, vec!["alice", "carol"]);
assert!(context.room_member_stats("unjoined").is_empty());
}
#[test]
fn rejoining_replaces_the_previous_member_stats() {
use crate::types::{RoomUserStats, UserStatus};
let stat = |username: &str| RoomUserStats {
username: username.to_string(),
status: UserStatus::Online,
average_speed: 1,
shared_files: 2,
shared_folders: 3,
slots_full: false,
country: None,
};
let mut context = ClientContext::new();
context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]);
context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]);
let stats = context.room_member_stats("jazz");
assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot");
assert_eq!(stats[0].username, "bob");
}
#[test]
fn a_new_ticker_replaces_that_users_previous_one() {
// The server treats a user's ticker as singular: a second one from the
// same user supersedes the first rather than stacking beside it.
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![
RoomTicker {
username: "alice".into(),
ticker: "first".into(),
},
RoomTicker {
username: "bob".into(),
ticker: "bobs".into(),
},
],
});
ctx.apply_room_event(RoomEvent::TickerAdded {
room: "jazz".into(),
username: "alice".into(),
ticker: "second".into(),
});
let board = ctx.room_tickers("jazz");
assert_eq!(board.len(), 2);
assert_eq!(
board.iter().find(|t| t.username == "alice").unwrap().ticker,
"second"
);
}
#[test]
fn a_removed_ticker_leaves_the_rest_of_the_board() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![
RoomTicker {
username: "alice".into(),
ticker: "a".into(),
},
RoomTicker {
username: "bob".into(),
ticker: "b".into(),
},
],
});
ctx.apply_room_event(RoomEvent::TickerRemoved {
room: "jazz".into(),
username: "alice".into(),
});
let board = ctx.room_tickers("jazz");
assert_eq!(board.len(), 1);
assert_eq!(board[0].username, "bob");
}
#[test]
fn a_global_message_is_queued_without_touching_room_membership() {
// The global feed carries messages from rooms we have not joined; they
// must not invent a roster for those rooms.
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::GlobalMessage {
room: "elsewhere".into(),
username: "alice".into(),
message: "hi".into(),
});
assert!(ctx.room_members("elsewhere").is_empty());
assert_eq!(ctx.take_room_events().len(), 1);
}
#[test]
fn a_private_room_roster_tracks_who_is_added_and_removed() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["bob".into(), "alice".into(), "bob".into()],
});
assert_eq!(
ctx.private_room_members("club"),
vec!["alice".to_string(), "bob".to_string()],
"a roster is sorted and free of repeats"
);
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "carol".into(),
members: true,
added: true,
});
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "alice".into(),
members: true,
added: false,
});
assert_eq!(
ctx.private_room_members("club"),
vec!["bob".to_string(), "carol".to_string()]
);
// Operators are a separate roster in the same room.
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "bob".into(),
members: false,
added: true,
});
assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]);
assert_eq!(ctx.private_rooms(), vec!["club".to_string()]);
}
#[test]
fn revoked_membership_drops_the_room_we_can_no_longer_see() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::PrivateOperators {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
room: "club".into(),
members: true,
granted: false,
});
assert!(ctx.private_rooms().is_empty());
assert!(ctx.private_room_operators("club").is_empty());
}
#[test]
fn losing_operatorship_keeps_the_roster_we_can_still_see() {
// Demotion does not blind us: we are still a member, and the room's other
// operators are still ours to show. The server narrates our own removal
// from that roster separately (code 144).
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::PrivateMembers {
room: "club".into(),
users: vec!["alice".into()],
});
ctx.apply_room_event(RoomEvent::PrivateOperators {
room: "club".into(),
users: vec!["alice".into(), "bob".into()],
});
ctx.apply_room_event(RoomEvent::OwnStandingChanged {
room: "club".into(),
members: false,
granted: false,
});
assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]);
assert_eq!(
ctx.private_room_operators("club"),
vec!["alice".to_string(), "bob".to_string()],
"the other operators are still there to show"
);
ctx.apply_room_event(RoomEvent::PrivateRosterChanged {
room: "club".into(),
username: "alice".into(),
members: false,
added: false,
});
assert_eq!(
ctx.private_room_operators("club"),
vec!["bob".to_string()],
"only the demotion the server narrates removes us"
);
}
#[test]
fn leaving_a_room_drops_its_ticker_board() {
let mut ctx = ClientContext::new();
ctx.apply_room_event(RoomEvent::Tickers {
room: "jazz".into(),
tickers: vec![RoomTicker {
username: "alice".into(),
ticker: "hi".into(),
}],
});
ctx.apply_room_event(RoomEvent::Left {
room: "jazz".into(),
});
assert!(
ctx.room_tickers("jazz").is_empty(),
"a board for a room we left is stale"
);
}
+167
View File
@@ -0,0 +1,167 @@
//! Answering a search against our shares, and what the client keeps of
//! the answers it gets back.
use super::*;
#[test]
fn build_search_response_matches_shares_and_echoes_token() {
let dir = std::env::temp_dir()
.join(format!("soulseek-searchresp-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap();
let shares = Shares::scan(&dir).unwrap();
let response =
build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[])
.expect("a matching share yields a response");
let mut decoded =
crate::message::Message::new_with_data(response.get_buffer());
decoded.set_pointer(8);
let result = SearchResult::new_from_message(&mut decoded).unwrap();
assert_eq!(result.username, "me");
assert_eq!(result.token, 99);
assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy")));
assert!(
build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[])
.is_none()
);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn search_file_counts_cover_every_search_without_the_results() {
// The daemon polls these counts many times a second; they must come from
// a walk of the cache, never a copy of it.
let client = Client::new("u", "p");
{
let mut context = client.context.write().unwrap();
context.searches.insert(
"aphex twin".to_string(),
Search {
token: 1,
results: vec![peer_files(2), peer_files(1)],
},
);
context.searches.insert(
"nothing yet".to_string(),
Search {
token: 2,
results: Vec::new(),
},
);
}
let mut counts = client.search_file_counts();
counts.sort();
assert_eq!(
counts,
[
("aphex twin".to_string(), 3),
("nothing yet".to_string(), 0)
]
);
}
#[test]
fn a_search_stops_collecting_once_it_has_enough_responses() {
// A popular query on the live network draws answers for minutes —
// nearly a million files and gigabytes of memory for one search.
// Surplus responders are dropped, not archived.
let mut search = Search {
token: 1,
results: Vec::new(),
};
for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) {
search.accept(peer_files(1));
}
assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES);
}
#[test]
fn a_flood_of_files_fills_a_search_before_the_response_cap() {
// A handful of whales with huge matching collections must not add up
// to an unbounded set just because the responses are few.
let mut search = Search {
token: 1,
results: Vec::new(),
};
for _ in 0..10 {
search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2));
}
assert_eq!(
search.results.len(),
2,
"two half-cap responses fill the search; the rest are dropped"
);
}
#[test]
fn a_file_the_server_excludes_is_left_out_of_a_reply() {
// The server's excluded phrases (code 160) police what travels the search
// network: a matching file whose path carries one must not be offered,
// and a reply with nothing left is not sent at all.
let dir = std::env::temp_dir()
.join(format!("soulseek-excluded-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap();
let shares = Shares::scan(&dir).unwrap();
assert!(
build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[])
.is_some(),
"with no exclusions the file is offered"
);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&["spam".to_string()],
)
.is_none(),
"an excluded phrase in the path, matched case-insensitively, \
withholds the file — and with no files there is no reply"
);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&["unrelated".to_string()],
)
.is_some(),
"an exclusion the path does not carry changes nothing"
);
// The phrases are lowercased where they arrive, so a server that sends
// one capitalised still matches a path.
let mut ctx = ClientContext::new();
ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]);
assert!(
build_search_response(
&shares,
"me",
1,
"xyzzy",
true,
0,
0,
&ctx.excluded_search_phrases(),
)
.is_none(),
"a capitalised phrase from the server still withholds the file"
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -0,0 +1,329 @@
//! Downloads through the context: what is queued, paused, cancelled,
//! and what a peer's answer is allowed to start.
use super::*;
#[test]
fn test_client_context_downloads() {
let mut context = ClientContext::new();
let token = 123;
let new_token = 1234;
context.add_download(download(
"test",
"test.txt",
token,
DownloadStatus::Queued,
mpsc::channel().0,
));
assert!(context.get_download_by_token(123).is_some());
assert_eq!(context.get_download_tokens(), vec![123]);
assert_eq!(context.get_downloads().len(), 1);
if let Some(download) = context.get_download_by_token_mut(token) {
assert_eq!(download.token, token);
download.token = new_token;
}
assert!(context.get_download_by_token(new_token).is_some());
assert_eq!(context.get_download_tokens(), vec![new_token]);
context.remove_download(new_token);
assert_eq!(context.get_downloads().len(), 0);
assert!(context.get_download_by_token(1234).is_none());
}
#[test]
fn test_client_pause_and_resume_download() {
let client = Client::new("test-user", "test-password");
let (download_sender, download_receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"song.mp3",
123,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
download_sender,
));
assert!(client.pause_download("peer", "song.mp3"));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(123)
.unwrap()
.status,
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(matches!(
download_receiver.try_recv().unwrap(),
DownloadStatus::Paused {
bytes_downloaded: 25,
total_bytes: 100
}
));
assert!(client.resume_download("peer", "song.mp3"));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(123)
.unwrap()
.status,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 0.0
}
));
}
#[test]
fn download_without_a_connection_resolves_failed() {
// A client that never connected has no server handle and no peer registry,
// so it cannot open a connection to the peer: the download must resolve to
// Failed rather than hang Queued forever.
let client = Client::new("test-user", "test-password");
let (_download, receiver) = client
.download(
"song.mp3".to_string(),
"peer".to_string(),
100,
"test".to_string(),
)
.expect("download() should return a handle");
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)),
Ok(DownloadStatus::Failed(_))
));
}
#[test]
fn fail_queued_downloads_notifies_receiver_and_store() {
// When a brokered connect times out, every Queued download for the peer
// must resolve to Failed both on its channel and in the store.
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
7,
DownloadStatus::Queued,
sender,
));
Client::fail_queued_downloads(&client.context, "peer");
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_))));
assert!(matches!(
client
.context
.read()
.unwrap()
.get_download_by_token(7)
.unwrap()
.status,
DownloadStatus::Failed(_)
));
}
#[test]
fn test_client_removes_only_queued_downloads() {
let client = Client::new("test-user", "test-password");
{
let mut context = client.context.write().unwrap();
context.add_download(download(
"peer",
"queued.mp3",
123,
DownloadStatus::Queued,
mpsc::channel().0,
));
context.add_download(download(
"peer",
"active.mp3",
456,
DownloadStatus::InProgress {
bytes_downloaded: 25,
total_bytes: 100,
speed_bytes_per_sec: 10.0,
},
mpsc::channel().0,
));
}
assert!(client.remove_queued_download("peer", "queued.mp3"));
assert!(!client.remove_queued_download("peer", "active.mp3"));
let context = client.context.read().unwrap();
assert!(context.get_download_by_token(123).is_none());
assert!(context.get_download_by_token(456).is_some());
}
#[test]
fn an_expired_broker_connect_fails_the_queued_downloads() {
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
{
let mut ctx = client.context.write().unwrap();
ctx.add_download(download(
"ghost",
"f.mp3",
7,
DownloadStatus::Queued,
sender,
));
ctx.pending_connect_tokens
.insert(7, ("ghost".to_string(), Instant::now()));
}
let (_ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let status = receiver.recv_timeout(Duration::from_secs(5));
assert!(
matches!(status, Ok(DownloadStatus::Failed(_))),
"the sweep must fail the queued download, got {status:?}"
);
}
#[test]
fn a_replayed_transfer_response_does_not_start_a_second_transfer() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = u32::from(listener.local_addr().unwrap().port());
let download_dir = std::env::temp_dir()
.join(format!("soulseek-replayed-transfer-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&download_dir);
std::fs::create_dir_all(&download_dir).unwrap();
let client = Client::new("u", "p");
let (sender, _receiver) = mpsc::channel();
let mut queued =
download("peer", "f.mp3", 9, DownloadStatus::Queued, sender);
queued.download_directory = download_dir.display().to_string();
client.context.write().unwrap().add_download(queued);
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let peer = Peer::new(
"peer".to_string(),
ConnectionType::F,
"127.0.0.1".to_string(),
port,
None,
0,
0,
0,
);
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer.clone(), true))
.unwrap();
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer, true))
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut connections = 0;
while Instant::now() < deadline {
match listener.accept() {
Ok(_) => {
connections += 1;
if connections == 1 {
thread::sleep(Duration::from_millis(500));
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if connections >= 1 {
break;
}
thread::sleep(Duration::from_millis(20));
}
Err(_) => break,
}
}
assert_eq!(
connections, 1,
"a replayed TransferResponse must not dial the peer again"
);
let _ = std::fs::remove_dir_all(download_dir);
}
#[test]
fn a_cancelled_download_is_not_started_when_the_peer_allows_it() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let port = u32::from(listener.local_addr().unwrap().port());
let client = Client::new("u", "p");
let (sender, _receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
9,
DownloadStatus::Cancelled,
sender,
));
let (ops_tx, ops_rx) = mpsc::channel();
Client::listen_to_client_operations(
ops_rx,
client.context.clone(),
"u".to_string(),
);
let peer = Peer::new(
"peer".to_string(),
ConnectionType::F,
"127.0.0.1".to_string(),
port,
None,
0,
0,
0,
);
ops_tx
.send(ClientOperation::DownloadFromPeer(9, peer, true))
.unwrap();
thread::sleep(Duration::from_millis(500));
assert!(
matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock),
"a cancelled download must not dial the peer"
);
}
#[test]
fn cancel_download_marks_the_store_and_answers_whether_it_matched() {
let client = Client::new("u", "p");
let (sender, receiver) = mpsc::channel();
client.context.write().unwrap().add_download(download(
"peer",
"f.mp3",
9,
DownloadStatus::Queued,
sender,
));
assert!(client.cancel_download("peer", "f.mp3"));
assert!(!client.cancel_download("peer", "f.mp3"));
assert!(!client.cancel_download("peer", "other.mp3"));
assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled)));
assert!(matches!(
client.get_all_downloads()[0].status,
DownloadStatus::Cancelled
));
}
+200
View File
@@ -0,0 +1,200 @@
//! What the server tells us about people: watches, interests, and the
//! two halves of a user snapshot.
use super::*;
#[test]
fn a_fresh_request_discards_the_previous_answer() {
// Without this, a poll after a second request returns the old
// snapshot immediately and the caller cannot tell stale from fresh.
let mut context = ClientContext::new();
context.apply_user_status("alice".to_string(), 2, false);
context.apply_user_stats("alice".to_string(), 10, 20, 30);
assert!(context.user_info("alice").is_some_and(|i| i.is_complete()));
context.invalidate_user_info("alice");
assert!(
context.user_info("alice").is_none(),
"a new request must not be answerable from the old reply"
);
}
#[test]
fn each_reply_fills_only_its_own_half() {
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
let info = context.user_info("bob").expect("a snapshot");
assert!(!info.is_complete(), "stats have not arrived");
assert_eq!(
info.presence.map(|p| p.status),
Some(crate::types::UserStatus::Away)
);
assert!(info.stats.is_none(), "must not invent statistics");
context.apply_user_stats("bob".to_string(), 5, 6, 7);
let info = context.user_info("bob").expect("a snapshot");
assert!(info.is_complete());
assert_eq!(info.stats.map(|s| s.shared_files), Some(6));
assert_eq!(
info.presence.map(|p| p.privileged),
Some(true),
"the earlier half must survive the merge"
);
}
#[test]
fn a_watch_reply_fills_both_halves_of_the_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_watched_user(
"alice".to_string(),
true,
Some(2),
Some(1024),
Some(20),
Some(3),
);
let info = context.user_info("alice").expect("a snapshot");
assert!(info.is_complete(), "a watch reply carries status and stats");
assert_eq!(info.stats.map(|s| s.average_speed), Some(1024));
assert_eq!(context.watched_users(), vec!["alice".to_string()]);
}
#[test]
fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() {
// WatchUser has no privileged field, so it must not overwrite what
// GetUserStatus already told us with a fabricated `false`.
let mut context = ClientContext::new();
context.apply_user_status("bob".to_string(), 1, true);
context.apply_watched_user(
"bob".to_string(),
true,
Some(2),
Some(1),
Some(2),
Some(3),
);
let info = context.user_info("bob").expect("a snapshot");
assert_eq!(info.presence.map(|p| p.privileged), Some(true));
}
#[test]
fn watching_an_unknown_user_drops_them_from_the_list() {
// The server will never push status for a name it does not know, so
// keeping it in the watch list would show a permanently blank row.
let mut context = ClientContext::new();
context.add_watched_user("ghost");
context.apply_watched_user(
"ghost".to_string(),
false,
None,
None,
None,
None,
);
assert!(context.watched_users().is_empty());
assert!(context.user_info("ghost").is_none());
}
#[test]
fn unwatching_forgets_the_users_snapshot() {
let mut context = ClientContext::new();
context.add_watched_user("alice");
context.apply_user_status("alice".to_string(), 2, false);
context.remove_watched_user("alice");
assert!(context.watched_users().is_empty());
assert!(
context.user_info("alice").is_none(),
"a later re-watch must report a fresh answer"
);
}
#[test]
fn watched_users_are_listed_in_a_stable_order() {
let mut context = ClientContext::new();
context.add_watched_user("carol");
context.add_watched_user("alice");
context.add_watched_user("bob");
assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]);
}
// A reply queued for a searcher the server cannot place waits a minute,
// not forever: every such search would otherwise pin a map entry and a
// protected slot in the peer registry.
#[test]
fn own_and_global_recommendations_are_kept_apart() {
let mut ctx = ClientContext::new();
ctx.apply_recommendations(
false,
vec![Recommendation {
item: "jazz".into(),
rating: 2,
}],
Vec::new(),
);
ctx.apply_recommendations(
true,
vec![Recommendation {
item: "pop".into(),
rating: 9,
}],
Vec::new(),
);
assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz");
assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop");
}
#[test]
fn asking_again_about_a_users_interests_drops_the_previous_answer() {
let mut ctx = ClientContext::new();
ctx.apply_user_interests(UserInterests {
username: "alice".into(),
likes: vec!["jazz".into()],
hates: Vec::new(),
});
assert!(ctx.user_interests("alice").is_some());
ctx.invalidate_user_interests("alice");
assert!(
ctx.user_interests("alice").is_none(),
"a stale answer must not be mistaken for the next one"
);
}
#[test]
fn our_own_interests_are_kept_lowercased_and_deduplicated() {
// The server matches interests case-insensitively and forgets them when
// the session ends, so they are held here in one spelling to be sent
// again next login.
let mut ctx = ClientContext::new();
assert_eq!(
ctx.add_own_interest(" Krautrock ", true).as_deref(),
Some("krautrock")
);
assert_eq!(
ctx.add_own_interest(" ", true),
None,
"an empty interest is not stored, and is not sent either"
);
ctx.add_own_interest("KRAUTROCK", true);
ctx.add_own_interest("Muzak", false);
let interests = ctx.own_interests();
assert_eq!(interests.likes, ["krautrock"]);
assert_eq!(interests.hates, ["muzak"]);
ctx.remove_own_interest("KrautRock", true);
assert!(ctx.own_interests().likes.is_empty());
assert_eq!(
ctx.own_interests().hates,
["muzak"],
"the other list stands"
);
}
File diff suppressed because it is too large Load Diff
+509
View File
@@ -0,0 +1,509 @@
mod shortcuts;
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView};
use crate::ui::panes::{
InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane,
render_download_info_pane, render_downloads_pane, render_results_pane,
render_rooms_pane, render_searches_pane, selected_transfer,
};
use crate::ui::{
GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask,
pack_shortcuts, pane_block, plain_title, primary_style,
render_download_stats, warning_style,
};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Position, Rect},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
};
impl MainTui {
/// Draw the whole window.
pub fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width);
let mut constraints = vec![
Constraint::Length(3), // Status bar
Constraint::Fill(1), // Main content
];
if self.state.command_bar_active {
constraints.push(Constraint::Length(3)); // Command bar
}
constraints.push(Constraint::Length(shortcuts.len() as u16 + 2));
let main_chunks = Layout::vertical(constraints).split(frame.area());
let daemon = self.client.daemon_endpoint();
render_download_stats(
frame,
main_chunks[0],
&self.state.downloads,
self.state.active_downloads_count,
daemon.as_deref(),
);
self.render_content(frame, main_chunks[1]);
// Render command bar if active (vim-style, above shortcuts)
if self.state.command_bar_active {
self.render_command_bar(frame, main_chunks[2]);
self.render_shortcuts(frame, main_chunks[3], shortcuts);
} else {
self.render_shortcuts(frame, main_chunks[2], shortcuts);
}
self.render_overlays(frame);
}
/// Results across the top, where a long file name has the whole width;
/// Searches, Downloads and Info in a row underneath. A hidden pane drops
/// out of its row and the rest widen; a zoomed one has the area alone.
fn render_content(&mut self, frame: &mut Frame, area: Rect) {
// Only what is drawn can be clicked.
self.state.searches_pane_area = None;
self.state.results_pane_area = None;
self.state.downloads_pane_area = None;
self.state.info_pane_area = None;
self.state.hsplit_divider = None;
self.state.vsplit_dividers.clear();
self.state.row_area = None;
if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane);
return;
}
self.state.content_area = Some(area);
// Transfers carry the most columns, so they get the most width.
let row_panes: Vec<(FocusedPane, u16)> =
[(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)]
.into_iter()
.filter(|(pane, _)| self.state.layout.is_visible(*pane))
.collect();
let row_area = if self.state.layout.is_visible(FocusedPane::Results) {
// With only Info left in the row, the results deserve more of
// the height.
let row_weight = if row_panes.is_empty() { 1 } else { 2 };
let top = match self.state.layout.top_height {
Some(rows) => Constraint::Length(shrink(rows, area.height)),
None => Constraint::Fill(3),
};
let [top, bottom] =
Layout::vertical([top, Constraint::Fill(row_weight)])
.areas(area);
// The two touching border rows between the halves; either can
// be dragged.
self.state.hsplit_divider = Some(Rect::new(
area.x,
top.y.saturating_add(top.height).saturating_sub(1),
area.width,
2,
));
self.render_pane(frame, top, FocusedPane::Results);
bottom
} else {
area
};
self.state.row_area = Some(row_area);
let mut constraints: Vec<Constraint> = row_panes
.iter()
.map(|(pane, weight)| {
let dragged = match *pane {
FocusedPane::Searches => self.state.layout.searches_width,
FocusedPane::Downloads => self.state.layout.downloads_width,
FocusedPane::Results => None,
};
match dragged {
Some(cols) => {
Constraint::Length(shrink(cols, row_area.width))
}
None => Constraint::Fill(*weight),
}
})
.collect();
constraints.push(Constraint::Fill(6)); // Info
let chunks = Layout::horizontal(constraints).split(row_area);
for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) {
// The seam at this pane's right edge: its border column and the
// next pane's, both draggable.
self.state.vsplit_dividers.push((
*pane,
Rect::new(
chunk.x.saturating_add(chunk.width).saturating_sub(1),
row_area.y,
2,
row_area.height,
),
));
self.render_pane(frame, *chunk, *pane);
}
self.state.info_pane_area = Some(chunks[row_panes.len()]);
self.render_info_pane(frame, chunks[row_panes.len()]);
}
fn render_pane(
&mut self,
frame: &mut Frame,
area: Rect,
pane: FocusedPane,
) {
let focused = self.state.focused_pane == pane;
match pane {
FocusedPane::Searches => {
self.state.searches_pane_area = Some(area);
render_searches_pane(
frame,
area,
&self.state.searches,
&mut self.state.searches_table_state,
focused,
self.state.searches_query_offset,
);
}
FocusedPane::Results => {
self.state.results_pane_area = Some(area);
self.render_results_pane(frame, area, focused);
}
FocusedPane::Downloads => {
self.state.downloads_pane_area = Some(area);
render_downloads_pane(
frame,
area,
&self.state.downloads,
&self.state.uploads,
&mut self.state.downloads_table_state,
focused,
self.state.downloads_name_offset,
);
}
}
}
fn render_results_pane(
&mut self,
frame: &mut Frame,
area: Rect,
focused: bool,
) {
// When a filter is active the rendered rows are a subset, so the pane
// also needs the mapping back to unfiltered indices to render the
// selection checkboxes correctly.
let (results_items, results_original_indices) =
if self.state.results_filter_query.is_empty() {
(&self.state.results_items, None)
} else {
(
&self.state.results_filtered_items,
Some(self.state.results_filtered_indices.as_slice()),
)
};
let active_search_query = self
.state
.selected_search_index
.and_then(|idx| self.state.searches.get(idx))
.map(|search| search.query.as_str());
render_results_pane(
frame,
area,
ResultsPaneParams {
items: results_items,
table_state: &mut self.state.results_table_state,
selected_indices: &self.state.results_selected_indices,
original_indices: results_original_indices,
filter_query: &self.state.results_filter_query,
is_filtering: self.state.results_is_filtering,
focused,
active_search_query,
name_offset: self.state.results_name_offset,
},
);
}
/// The Info pane follows the focus: the highlighted result, or the
/// highlighted transfer.
fn render_info_pane(&self, frame: &mut Frame, area: Rect) {
let selected = if self.state.focused_pane == FocusedPane::Results {
self.highlighted_result().map(InfoSubject::Result)
} else {
selected_transfer(
self.state.downloads_table_state.selected(),
&self.state.downloads,
&self.state.uploads,
)
};
render_download_info_pane(
frame,
area,
selected,
matches!(
self.state.focused_pane,
FocusedPane::Results | FocusedPane::Downloads
),
);
}
fn render_overlays(&mut self, frame: &mut Frame) {
// Messages inbox overlays everything when open.
if self.state.show_messages {
self.render_messages_popup(frame);
}
// Browse tree overlays everything when open.
if self.state.show_browse && !self.state.browse.is_empty() {
let area = centered_rect(80, 80, frame.area());
self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area);
render_browse_pane(
frame,
area,
&self.state.browse,
&mut self.state.browse_table_state,
self.spinner_state,
&self.state.downloads,
);
}
// Settings popup overlays everything when open.
if self.state.settings.is_some() {
self.render_settings_popup(frame);
}
// Chat rooms overlay everything when open.
if self.state.show_rooms {
let area = centered_rect(85, 80, frame.area());
self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area);
render_rooms_pane(
frame,
area,
&mut self.state.rooms,
&mut self.state.rooms_list_table_state,
);
}
// The keys list only opens from the main view, so it is on top of
// nothing; drawn last so that stays true if that ever changes.
if self.state.show_help {
self.render_help_popup(frame);
}
}
fn render_settings_popup(&self, frame: &mut Frame) {
use crate::models::{SettingsMode, SettingsRow};
let Some(settings) = self.state.settings.as_ref() else {
return;
};
let area = centered_rect(70, 60, frame.area());
frame.render_widget(ratatui::widgets::Clear, area);
let selected = settings.selected_row();
let mut lines: Vec<Line> = Vec::new();
let entry = |on: bool, label: &str, value: String| {
Line::from(vec![
Span::styled(if on { "> " } else { " " }, accent_style()),
Span::styled(label.to_string(), dimmed_style()),
Span::styled(value, primary_style()),
])
};
let typing = |label: &str, value: String| {
Line::from(vec![
Span::styled("> ", accent_style()),
Span::styled(label.to_string(), dimmed_style()),
Span::styled(value, primary_style()),
Span::styled(GLYPH_CURSOR, accent_style()),
])
};
let section = |name: &str| Line::styled(name.to_string(), info_style());
// One column of labels, padded here rather than by hand in each
// string.
let field = |label: &str, value: String| {
entry(false, &format!(" {label:<14}"), value)
};
let account = &settings.account;
lines.push(section("Account"));
lines.push(field("Signed in as", account.username.clone()));
lines.push(field("Sharing", account.shares.clone()));
lines.push(field(
"Reachable on",
account.listen_port.map_or_else(
|| "not accepting connections".to_string(),
|port| format!("port {port}"),
),
));
if let Some(daemon) = &account.daemon {
lines.push(field("Daemon", daemon.clone()));
}
lines.push(Line::from(""));
lines.push(match settings.mode {
SettingsMode::NewPassword => {
typing("New password: ", mask(&settings.input))
}
SettingsMode::RepeatPassword(_) => {
typing("Repeat it: ", mask(&settings.input))
}
_ => entry(
selected == SettingsRow::ChangePassword,
"Change password…",
String::new(),
),
});
if settings.mode == SettingsMode::ConfirmingLogout {
lines.push(entry(
true,
&format!("Log out of {}? ", account.username),
"y / n".to_string(),
));
} else if settings.can_log_out() {
lines.push(entry(
selected == SettingsRow::Logout,
"Log out…",
String::new(),
));
}
lines.push(Line::from(""));
lines.push(section("Folders"));
lines.push(if settings.mode == SettingsMode::EditingDownloadDir {
typing("Download folder: ", settings.input.clone())
} else {
entry(
selected == SettingsRow::DownloadDir,
"Download folder: ",
settings.download_dir.clone(),
)
});
lines.push(Line::styled(
format!("Shared folders ({}):", settings.share_dirs.len()),
dimmed_style(),
));
for (i, dir) in settings.share_dirs.iter().enumerate() {
lines.push(entry(
selected == SettingsRow::Share(i),
"",
dir.clone(),
));
}
if settings.share_dirs.is_empty() {
lines.push(Line::styled(
" (nothing shared — press 'a' to add a folder)",
dimmed_style(),
));
}
if settings.mode == SettingsMode::AddingShare {
lines.push(typing(" Add share: ", settings.input.clone()));
}
if let Some(status) = &settings.status {
lines.push(Line::from(""));
lines.push(Line::styled(status.clone(), warning_style()));
}
let block = pane_block(true).title(plain_title("Settings", true));
frame.render_widget(
ratatui::widgets::Paragraph::new(lines)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: false }),
area,
);
}
fn render_messages_popup(&mut self, frame: &mut Frame) {
// The per-conversation chat box supersedes the old flat message list;
// it renders the messages and the compose line itself.
let area = centered_rect(70, 60, frame.area());
frame.render_widget(ratatui::widgets::Clear, area);
self.state.popup_area = Some(area);
let own = self.client.username();
render_chat_pane(frame, area, &mut self.state, &own);
}
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
let prefix = match self.state.command_bar_mode {
CommandBarMode::Search => "search: ",
CommandBarMode::Message => "message (to: recipient text): ",
CommandBarMode::Browse => "browse user: ",
};
let block = pane_block(true);
// Derive from the block's inner rect so borders and padding stay in
// one place; the cursor follows whatever the pane reserves.
let inner = block.inner(area);
let prefix_width = prefix.chars().count() as u16;
let input_width = inner.width.saturating_sub(prefix_width);
let (visible_input, cursor_column) = visible_input_at_cursor(
&self.state.command_bar_input,
self.state.command_bar_cursor_position,
input_width,
);
let command_line = Line::from(vec![
Span::styled(prefix, accent_style()),
Span::styled(visible_input, primary_style()),
]);
frame.render_widget(Paragraph::new(command_line).block(block), area);
if inner.width > 0 && inner.height > 0 {
let cursor_x = inner
.x
.saturating_add(prefix_width)
.saturating_add(cursor_column)
.min(inner.x.saturating_add(inner.width.saturating_sub(1)));
frame.set_cursor_position(Position::new(cursor_x, inner.y));
}
}
}
/// A dragged pane size kept inside the space the terminal now offers: never
/// below [`MIN_PANE`], and never so big that what shares the split with it
/// drops below `MIN_PANE` too.
pub(super) fn shrink(dragged: u16, total: u16) -> u16 {
let max = total.saturating_sub(MIN_PANE).max(MIN_PANE);
dragged.max(MIN_PANE).min(max)
}
/// The smallest pane a drag can leave anyone: a border and a row or column
/// of content each side.
pub(super) const MIN_PANE: u16 = 3;
/// A `Rect` centered within `area`, sized to the given percentages.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn visible_input_at_cursor(
input: &str,
cursor_position: usize,
width: u16,
) -> (String, u16) {
if width == 0 {
return (String::new(), 0);
}
let cursor_position = input.floor_char_boundary(cursor_position);
let cursor_character_index = input[..cursor_position].chars().count();
let max_cursor_column = usize::from(width.saturating_sub(1));
let start_character_index =
cursor_character_index.saturating_sub(max_cursor_column);
let visible_input = input
.chars()
.skip(start_character_index)
.take(usize::from(width))
.collect();
let cursor_column = cursor_character_index
.saturating_sub(start_character_index)
.min(max_cursor_column);
(visible_input, cursor_column as u16)
}
@@ -1,21 +1,15 @@
use super::MainTui;
use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView};
use crate::ui::panes::{
InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane,
render_download_info_pane, render_downloads_pane, render_results_pane,
render_rooms_pane, render_searches_pane, selected_transfer,
};
use crate::ui::{
GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask,
pack_shortcuts, pane_block, plain_title, primary_style,
render_download_stats, warning_style,
};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Position, Rect},
style::Modifier,
text::{Line, Span},
widgets::Paragraph,
//! The keys the window advertises: the shortcut bar along the foot, the `?`
//! overlay behind it, and the unread badge that rides in the bar.
//!
//! A child module of `render`, so it still reaches the window's state and
//! `render`'s own helpers directly; only what `render` calls back into is
//! widened to `pub(super)`.
use super::{
Alignment, CommandBarMode, Constraint, FocusedPane, Frame, Layout, Line,
MainTui, Modifier, PaneLayout, Paragraph, Rect, RoomsView, Span,
accent_style, body_style, dimmed_style, info_style, pack_shortcuts,
pane_block, plain_title, warning_style,
};
/// The keys overlay, grouped by where a key applies. Two columns, because a
@@ -182,236 +176,10 @@ fn settings_shortcuts(
}
impl MainTui {
/// Draw the whole window.
pub fn render(&mut self, frame: &mut Frame) {
let shortcuts = self.shortcut_rows(frame.area().width);
let mut constraints = vec![
Constraint::Length(3), // Status bar
Constraint::Fill(1), // Main content
];
if self.state.command_bar_active {
constraints.push(Constraint::Length(3)); // Command bar
}
constraints.push(Constraint::Length(shortcuts.len() as u16 + 2));
let main_chunks = Layout::vertical(constraints).split(frame.area());
let daemon = self.client.daemon_endpoint();
render_download_stats(
frame,
main_chunks[0],
&self.state.downloads,
self.state.active_downloads_count,
daemon.as_deref(),
);
self.render_content(frame, main_chunks[1]);
// Render command bar if active (vim-style, above shortcuts)
if self.state.command_bar_active {
self.render_command_bar(frame, main_chunks[2]);
self.render_shortcuts(frame, main_chunks[3], shortcuts);
} else {
self.render_shortcuts(frame, main_chunks[2], shortcuts);
}
self.render_overlays(frame);
}
/// Results across the top, where a long file name has the whole width;
/// Searches, Downloads and Info in a row underneath. A hidden pane drops
/// out of its row and the rest widen; a zoomed one has the area alone.
fn render_content(&mut self, frame: &mut Frame, area: Rect) {
// Only what is drawn can be clicked.
self.state.searches_pane_area = None;
self.state.results_pane_area = None;
self.state.downloads_pane_area = None;
self.state.info_pane_area = None;
self.state.hsplit_divider = None;
self.state.vsplit_dividers.clear();
self.state.row_area = None;
if self.state.layout.zoomed {
self.render_pane(frame, area, self.state.focused_pane);
return;
}
self.state.content_area = Some(area);
// Transfers carry the most columns, so they get the most width.
let row_panes: Vec<(FocusedPane, u16)> =
[(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)]
.into_iter()
.filter(|(pane, _)| self.state.layout.is_visible(*pane))
.collect();
let row_area = if self.state.layout.is_visible(FocusedPane::Results) {
// With only Info left in the row, the results deserve more of
// the height.
let row_weight = if row_panes.is_empty() { 1 } else { 2 };
let top = match self.state.layout.top_height {
Some(rows) => Constraint::Length(shrink(rows, area.height)),
None => Constraint::Fill(3),
};
let [top, bottom] =
Layout::vertical([top, Constraint::Fill(row_weight)])
.areas(area);
// The two touching border rows between the halves; either can
// be dragged.
self.state.hsplit_divider = Some(Rect::new(
area.x,
top.y.saturating_add(top.height).saturating_sub(1),
area.width,
2,
));
self.render_pane(frame, top, FocusedPane::Results);
bottom
} else {
area
};
self.state.row_area = Some(row_area);
let mut constraints: Vec<Constraint> = row_panes
.iter()
.map(|(pane, weight)| {
let dragged = match *pane {
FocusedPane::Searches => self.state.layout.searches_width,
FocusedPane::Downloads => self.state.layout.downloads_width,
FocusedPane::Results => None,
};
match dragged {
Some(cols) => {
Constraint::Length(shrink(cols, row_area.width))
}
None => Constraint::Fill(*weight),
}
})
.collect();
constraints.push(Constraint::Fill(6)); // Info
let chunks = Layout::horizontal(constraints).split(row_area);
for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) {
// The seam at this pane's right edge: its border column and the
// next pane's, both draggable.
self.state.vsplit_dividers.push((
*pane,
Rect::new(
chunk.x.saturating_add(chunk.width).saturating_sub(1),
row_area.y,
2,
row_area.height,
),
));
self.render_pane(frame, *chunk, *pane);
}
self.state.info_pane_area = Some(chunks[row_panes.len()]);
self.render_info_pane(frame, chunks[row_panes.len()]);
}
fn render_pane(
&mut self,
frame: &mut Frame,
area: Rect,
pane: FocusedPane,
) {
let focused = self.state.focused_pane == pane;
match pane {
FocusedPane::Searches => {
self.state.searches_pane_area = Some(area);
render_searches_pane(
frame,
area,
&self.state.searches,
&mut self.state.searches_table_state,
focused,
self.state.searches_query_offset,
);
}
FocusedPane::Results => {
self.state.results_pane_area = Some(area);
self.render_results_pane(frame, area, focused);
}
FocusedPane::Downloads => {
self.state.downloads_pane_area = Some(area);
render_downloads_pane(
frame,
area,
&self.state.downloads,
&self.state.uploads,
&mut self.state.downloads_table_state,
focused,
self.state.downloads_name_offset,
);
}
}
}
fn render_results_pane(
&mut self,
frame: &mut Frame,
area: Rect,
focused: bool,
) {
// When a filter is active the rendered rows are a subset, so the pane
// also needs the mapping back to unfiltered indices to render the
// selection checkboxes correctly.
let (results_items, results_original_indices) =
if self.state.results_filter_query.is_empty() {
(&self.state.results_items, None)
} else {
(
&self.state.results_filtered_items,
Some(self.state.results_filtered_indices.as_slice()),
)
};
let active_search_query = self
.state
.selected_search_index
.and_then(|idx| self.state.searches.get(idx))
.map(|search| search.query.as_str());
render_results_pane(
frame,
area,
ResultsPaneParams {
items: results_items,
table_state: &mut self.state.results_table_state,
selected_indices: &self.state.results_selected_indices,
original_indices: results_original_indices,
filter_query: &self.state.results_filter_query,
is_filtering: self.state.results_is_filtering,
focused,
active_search_query,
name_offset: self.state.results_name_offset,
},
);
}
/// The Info pane follows the focus: the highlighted result, or the
/// highlighted transfer.
fn render_info_pane(&self, frame: &mut Frame, area: Rect) {
let selected = if self.state.focused_pane == FocusedPane::Results {
self.highlighted_result().map(InfoSubject::Result)
} else {
selected_transfer(
self.state.downloads_table_state.selected(),
&self.state.downloads,
&self.state.uploads,
)
};
render_download_info_pane(
frame,
area,
selected,
matches!(
self.state.focused_pane,
FocusedPane::Results | FocusedPane::Downloads
),
);
}
/// The keys list, two columns side by side where the terminal is wide
/// enough and one on top of the other where it is not, scrolling when
/// even that is taller than the window.
fn render_help_popup(&mut self, frame: &mut Frame) {
pub(super) fn render_help_popup(&mut self, frame: &mut Frame) {
let screen = frame.area();
// Borders and padding on either side of the text.
let frame_width = 4;
@@ -455,182 +223,6 @@ impl MainTui {
}
}
fn render_overlays(&mut self, frame: &mut Frame) {
// Messages inbox overlays everything when open.
if self.state.show_messages {
self.render_messages_popup(frame);
}
// Browse tree overlays everything when open.
if self.state.show_browse && !self.state.browse.is_empty() {
let area = centered_rect(80, 80, frame.area());
self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area);
render_browse_pane(
frame,
area,
&self.state.browse,
&mut self.state.browse_table_state,
self.spinner_state,
&self.state.downloads,
);
}
// Settings popup overlays everything when open.
if self.state.settings.is_some() {
self.render_settings_popup(frame);
}
// Chat rooms overlay everything when open.
if self.state.show_rooms {
let area = centered_rect(85, 80, frame.area());
self.state.popup_area = Some(area);
frame.render_widget(ratatui::widgets::Clear, area);
render_rooms_pane(
frame,
area,
&mut self.state.rooms,
&mut self.state.rooms_list_table_state,
);
}
// The keys list only opens from the main view, so it is on top of
// nothing; drawn last so that stays true if that ever changes.
if self.state.show_help {
self.render_help_popup(frame);
}
}
fn render_settings_popup(&self, frame: &mut Frame) {
use crate::models::{SettingsMode, SettingsRow};
let Some(settings) = self.state.settings.as_ref() else {
return;
};
let area = centered_rect(70, 60, frame.area());
frame.render_widget(ratatui::widgets::Clear, area);
let selected = settings.selected_row();
let mut lines: Vec<Line> = Vec::new();
let entry = |on: bool, label: &str, value: String| {
Line::from(vec![
Span::styled(if on { "> " } else { " " }, accent_style()),
Span::styled(label.to_string(), dimmed_style()),
Span::styled(value, primary_style()),
])
};
let typing = |label: &str, value: String| {
Line::from(vec![
Span::styled("> ", accent_style()),
Span::styled(label.to_string(), dimmed_style()),
Span::styled(value, primary_style()),
Span::styled(GLYPH_CURSOR, accent_style()),
])
};
let section = |name: &str| Line::styled(name.to_string(), info_style());
// One column of labels, padded here rather than by hand in each
// string.
let field = |label: &str, value: String| {
entry(false, &format!(" {label:<14}"), value)
};
let account = &settings.account;
lines.push(section("Account"));
lines.push(field("Signed in as", account.username.clone()));
lines.push(field("Sharing", account.shares.clone()));
lines.push(field(
"Reachable on",
account.listen_port.map_or_else(
|| "not accepting connections".to_string(),
|port| format!("port {port}"),
),
));
if let Some(daemon) = &account.daemon {
lines.push(field("Daemon", daemon.clone()));
}
lines.push(Line::from(""));
lines.push(match settings.mode {
SettingsMode::NewPassword => {
typing("New password: ", mask(&settings.input))
}
SettingsMode::RepeatPassword(_) => {
typing("Repeat it: ", mask(&settings.input))
}
_ => entry(
selected == SettingsRow::ChangePassword,
"Change password…",
String::new(),
),
});
if settings.mode == SettingsMode::ConfirmingLogout {
lines.push(entry(
true,
&format!("Log out of {}? ", account.username),
"y / n".to_string(),
));
} else if settings.can_log_out() {
lines.push(entry(
selected == SettingsRow::Logout,
"Log out…",
String::new(),
));
}
lines.push(Line::from(""));
lines.push(section("Folders"));
lines.push(if settings.mode == SettingsMode::EditingDownloadDir {
typing("Download folder: ", settings.input.clone())
} else {
entry(
selected == SettingsRow::DownloadDir,
"Download folder: ",
settings.download_dir.clone(),
)
});
lines.push(Line::styled(
format!("Shared folders ({}):", settings.share_dirs.len()),
dimmed_style(),
));
for (i, dir) in settings.share_dirs.iter().enumerate() {
lines.push(entry(
selected == SettingsRow::Share(i),
"",
dir.clone(),
));
}
if settings.share_dirs.is_empty() {
lines.push(Line::styled(
" (nothing shared — press 'a' to add a folder)",
dimmed_style(),
));
}
if settings.mode == SettingsMode::AddingShare {
lines.push(typing(" Add share: ", settings.input.clone()));
}
if let Some(status) = &settings.status {
lines.push(Line::from(""));
lines.push(Line::styled(status.clone(), warning_style()));
}
let block = pane_block(true).title(plain_title("Settings", true));
frame.render_widget(
ratatui::widgets::Paragraph::new(lines)
.block(block)
.wrap(ratatui::widgets::Wrap { trim: false }),
area,
);
}
fn render_messages_popup(&mut self, frame: &mut Frame) {
// The per-conversation chat box supersedes the old flat message list;
// it renders the messages and the compose line itself.
let area = centered_rect(70, 60, frame.area());
frame.render_widget(ratatui::widgets::Clear, area);
self.state.popup_area = Some(area);
let own = self.client.username();
render_chat_pane(frame, area, &mut self.state, &own);
}
/// Context shortcuts for the chat-rooms popup.
fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> {
if self.state.rooms.composing {
@@ -829,7 +421,7 @@ impl MainTui {
/// The legend packed into rows for a window `width` wide, so a narrow
/// terminal grows the bar instead of cutting keys off its right edge.
fn shortcut_rows(&self, width: u16) -> Vec<Line<'static>> {
pub(super) fn shortcut_rows(&self, width: u16) -> Vec<Line<'static>> {
// Borders and padding, and the unread badge's column when it shows.
let badge = self.unread_badge().width() as u16;
let reserved = 4 + if badge > 0 { badge + 1 } else { 0 };
@@ -848,7 +440,7 @@ impl MainTui {
)
}
fn render_shortcuts(
pub(super) fn render_shortcuts(
&self,
frame: &mut Frame,
area: Rect,
@@ -888,57 +480,11 @@ impl MainTui {
);
}
}
fn render_command_bar(&self, frame: &mut Frame, area: Rect) {
let prefix = match self.state.command_bar_mode {
CommandBarMode::Search => "search: ",
CommandBarMode::Message => "message (to: recipient text): ",
CommandBarMode::Browse => "browse user: ",
};
let block = pane_block(true);
// Derive from the block's inner rect so borders and padding stay in
// one place; the cursor follows whatever the pane reserves.
let inner = block.inner(area);
let prefix_width = prefix.chars().count() as u16;
let input_width = inner.width.saturating_sub(prefix_width);
let (visible_input, cursor_column) = visible_input_at_cursor(
&self.state.command_bar_input,
self.state.command_bar_cursor_position,
input_width,
);
let command_line = Line::from(vec![
Span::styled(prefix, accent_style()),
Span::styled(visible_input, primary_style()),
]);
frame.render_widget(Paragraph::new(command_line).block(block), area);
if inner.width > 0 && inner.height > 0 {
let cursor_x = inner
.x
.saturating_add(prefix_width)
.saturating_add(cursor_column)
.min(inner.x.saturating_add(inner.width.saturating_sub(1)));
frame.set_cursor_position(Position::new(cursor_x, inner.y));
}
}
}
/// Columns between the two halves of the keys list.
const HELP_GAP: u16 = 2;
/// A dragged pane size kept inside the space the terminal now offers: never
/// below [`MIN_PANE`], and never so big that what shares the split with it
/// drops below `MIN_PANE` too.
pub(super) fn shrink(dragged: u16, total: u16) -> u16 {
let max = total.saturating_sub(MIN_PANE).max(MIN_PANE);
dragged.max(MIN_PANE).min(max)
}
/// The smallest pane a drag can leave anyone: a border and a row or column
/// of content each side.
pub(super) const MIN_PANE: u16 = 3;
/// The widest key in a column, so the descriptions line up.
fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize {
sections
@@ -990,22 +536,6 @@ fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec<Line<'static>> {
lines
}
/// A `Rect` centered within `area`, sized to the given percentages.
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
/// Build the shortcuts-bar unread badge: `✉ N` for unread private messages and
/// `💬 N` for unread chat-room messages. Returns an empty line when nothing is
/// unread so the caller can skip it.
@@ -1038,33 +568,6 @@ fn unread_indicator(
Line::from(Span::styled(format!("⟨ {} ⟩", counts.join(" ")), style))
}
fn visible_input_at_cursor(
input: &str,
cursor_position: usize,
width: u16,
) -> (String, u16) {
if width == 0 {
return (String::new(), 0);
}
let cursor_position = input.floor_char_boundary(cursor_position);
let cursor_character_index = input[..cursor_position].chars().count();
let max_cursor_column = usize::from(width.saturating_sub(1));
let start_character_index =
cursor_character_index.saturating_sub(max_cursor_column);
let visible_input = input
.chars()
.skip(start_character_index)
.take(usize::from(width))
.collect();
let cursor_column = cursor_character_index
.saturating_sub(start_character_index)
.min(max_cursor_column);
(visible_input, cursor_column as u16)
}
#[cfg(test)]
mod tests {
use super::unread_indicator;
+172
View File
@@ -0,0 +1,172 @@
//! The browse popup, over a peer's shares and over our own.
use super::*;
#[test]
fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() {
let session = Arc::new(TalkativeSession::default());
*session.listing.lock().expect("not poisoned") =
vec![soulseek_rs::SharedDirectory {
name: "Music\\Album".to_string(),
files: vec![soulseek_rs::SharedFileEntry {
name: "track.mp3".to_string(),
size: 4096,
attributes: Vec::new(),
}],
}];
let mut tui = attach(session.clone());
press(&mut tui, KeyCode::Char('B'));
assert!(tui.state.show_browse, "the popup opens");
let tab = tui.state.browse.active_tab().expect("tab");
assert!(tab.own, "on this session's own index");
assert_eq!(tab.username, "tester");
assert_eq!(tab.file_count, 1);
let screen = screen_of(&mut tui);
assert!(screen.contains("My shares"), "{screen}");
press(&mut tui, KeyCode::Char('L'));
let screen = screen_of(&mut tui);
assert!(
screen.contains("Album") && screen.contains("track.mp3"),
"the virtual path a peer would see, folder by folder: {screen}"
);
// Nothing here is downloadable from ourselves.
press(&mut tui, KeyCode::Char('d'));
assert!(tui.state.downloads.is_empty());
// `r` re-scans the disk rather than re-asking a peer, and the tab
// picks up what the fresh scan found.
session.listing.lock().expect("not poisoned").clear();
press(&mut tui, KeyCode::Char('r'));
assert!(
session.shares_set.lock().expect("not poisoned").is_some(),
"the session was asked to re-index"
);
assert_eq!(
tui.state.browse.active_tab().expect("tab").file_count,
0,
"the view follows the new index"
);
// A second B focuses the tab it already opened.
press(&mut tui, KeyCode::Esc);
press(&mut tui, KeyCode::Char('B'));
assert_eq!(tui.state.browse.tabs.len(), 1);
}
#[test]
fn o_leaves_the_share_view_for_the_folders_that_feed_it() {
let mut tui = attach(Arc::new(TalkativeSession::default()));
press(&mut tui, KeyCode::Char('B'));
press(&mut tui, KeyCode::Char('o'));
assert!(!tui.state.show_browse, "the popup steps aside");
assert!(tui.state.settings.is_some(), "for the settings pane");
}
#[test]
fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() {
let session = Arc::new(TalkativeSession::default());
let mut tui = attach(session);
browsed(&mut tui, 40);
let rows = tui.state.browse.active_tab().expect("tab").rows().len();
assert_eq!(rows, 41, "the folder and its files");
press(&mut tui, KeyCode::End);
let selected = |tui: &MainTui| {
tui.state.browse.active_tab().expect("tab").selected_row
};
assert_eq!(selected(&tui), 40);
press(&mut tui, KeyCode::Home);
assert_eq!(selected(&tui), 0);
press(&mut tui, KeyCode::PageDown);
let page = selected(&tui);
assert!(page > 1 && page < 40, "a page of rows: {page}");
ctrl(&mut tui, 'd');
assert!(selected(&tui) > page, "ctrl-d pages");
assert!(
tui.state.downloads.is_empty()
&& tui.state.downloads_receiver_channel.is_none(),
"and does not download the folder"
);
press(&mut tui, KeyCode::Char('G'));
assert_eq!(selected(&tui), 40);
assert!(tui.state.show_browse, "still open");
}
#[test]
fn folder_keys_and_the_filter_move_through_a_browsed_tree() {
let mut tui = attach(Arc::new(TalkativeSession::default()));
tui.state.browse.open("bob");
let listing = vec![
soulseek_rs::SharedDirectory {
name: "music\\alpha".to_string(),
files: vec![
soulseek_rs::SharedFileEntry {
name: "a1.mp3".to_string(),
size: 1,
attributes: Vec::new(),
},
soulseek_rs::SharedFileEntry {
name: "a2.flac".to_string(),
size: 2,
attributes: Vec::new(),
},
],
},
soulseek_rs::SharedDirectory {
name: "music\\beta".to_string(),
files: vec![soulseek_rs::SharedFileEntry {
name: "b1.mp3".to_string(),
size: 3,
attributes: Vec::new(),
}],
},
];
tui.state
.browse
.active_tab_mut()
.expect("tab")
.load(&listing);
tui.state.show_browse = true;
let _ = screen_of(&mut tui);
let selected = |tui: &MainTui| {
tui.state.browse.active_tab().expect("tab").selected_row
};
let rows = |tui: &MainTui| {
tui.state.browse.active_tab().expect("tab").rows().len()
};
press(&mut tui, KeyCode::Char('L'));
assert_eq!(rows(&tui), 6, "every folder open");
press(&mut tui, KeyCode::Char('J'));
press(&mut tui, KeyCode::Char('J'));
assert_eq!(selected(&tui), 4, "beta, skipping alpha's files");
press(&mut tui, KeyCode::Char('K'));
assert_eq!(selected(&tui), 1, "back to alpha");
press(&mut tui, KeyCode::Char('H'));
assert_eq!(rows(&tui), 1, "just music");
press(&mut tui, KeyCode::Char('/'));
for c in "flac".chars() {
press(&mut tui, KeyCode::Char(c));
}
assert_eq!(rows(&tui), 3, "music, alpha, a2.flac");
let screen = screen_of(&mut tui);
assert!(screen.contains("filter: flac_"), "{screen}");
ctrl(&mut tui, 'd');
assert_eq!(
tui.state.browse.active_tab().expect("tab").filter(),
"flac",
"a control chord pages, it does not type"
);
press(&mut tui, KeyCode::Enter);
press(&mut tui, KeyCode::Char('j'));
press(&mut tui, KeyCode::Char('j'));
assert_eq!(selected(&tui), 2, "j moves again once the filter is kept");
press(&mut tui, KeyCode::Esc);
assert_eq!(rows(&tui), 1, "Esc clears the filter first");
assert!(tui.state.show_browse);
press(&mut tui, KeyCode::Esc);
assert!(!tui.state.show_browse, "then hides the popup");
}
+386
View File
@@ -0,0 +1,386 @@
//! The window itself: which panes are up, how big, and what the chrome
//! says while they move.
use super::*;
#[test]
fn the_results_pane_has_the_whole_width_to_itself() {
let mut tui = furnished_tui();
let screen = screen_of(&mut tui);
let results = row_with(&screen, "[2] Results");
assert!(results.starts_with('╭'), "{results}");
assert!(results.trim_end().ends_with('╮'), "{results}");
assert!(!results.contains("[1]"), "nothing beside it: {results}");
// The other three share the row underneath, in this order.
let row = row_with(&screen, "[1] Searches");
let searches = row.find("[1]").expect("searches");
let downloads = row.find("[3]").expect("downloads");
let info = row.find("[Info]").expect("info");
assert!(searches < downloads && downloads < info, "{row}");
}
#[test]
fn tab_and_shift_tab_walk_the_panes_in_legend_order() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Tab);
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
press(&mut tui, KeyCode::Tab);
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
press(&mut tui, KeyCode::Tab);
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
press(&mut tui, KeyCode::BackTab);
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
}
#[test]
fn w_hides_the_focused_pane_and_its_number_brings_it_back() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Char('w'));
let screen = screen_of(&mut tui);
assert!(!screen.contains("[1] Searches"), "{screen}");
assert!(screen.contains("[2] Results"), "{screen}");
assert!(screen.contains("[3] Downloads"), "{screen}");
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
assert_eq!(tui.state.searches_pane_area, None, "nothing to click");
// The transfers widen into the space.
let row = row_with(&screen, "[3] Downloads");
assert!(row.starts_with('╭'), "{row}");
press(&mut tui, KeyCode::Char('1'));
let screen = screen_of(&mut tui);
assert!(screen.contains("[1] Searches"), "{screen}");
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
}
#[test]
fn hiding_the_results_gives_the_row_the_whole_height() {
let mut tui = furnished_tui();
press(&mut tui, KeyCode::Char('w'));
let screen = screen_of(&mut tui);
assert!(!screen.contains("[2] Results"), "{screen}");
// The row now starts right under the status bar.
let status = screen
.lines()
.position(|line| line.contains("Status"))
.expect("status bar");
let row = screen
.lines()
.position(|line| line.contains("[1] Searches"))
.expect("searches");
assert_eq!(row, status + 3, "{screen}");
}
#[test]
fn the_last_pane_standing_cannot_be_hidden() {
// Results goes, then Downloads; Searches is what is left, and a
// third press leaves it there.
let mut tui = furnished_tui();
press(&mut tui, KeyCode::Char('w'));
press(&mut tui, KeyCode::Char('w'));
press(&mut tui, KeyCode::Char('w'));
let screen = screen_of(&mut tui);
assert!(screen.contains("[1] Searches"), "{screen}");
assert!(!screen.contains("[3] Downloads"), "{screen}");
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
}
#[test]
fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() {
let mut tui = furnished_tui();
press(&mut tui, KeyCode::Char('z'));
let screen = screen_of(&mut tui);
assert!(screen.contains("[2] Results"), "{screen}");
assert!(!screen.contains("[1] Searches"), "{screen}");
assert!(!screen.contains("[3] Downloads"), "{screen}");
assert!(!screen.contains("[Info]"), "{screen}");
assert!(screen.contains("[z → unzoom]"), "{screen}");
// Tab while zoomed switches which pane fills the window.
press(&mut tui, KeyCode::Tab);
let screen = screen_of(&mut tui);
assert!(screen.contains("[3] Downloads"), "{screen}");
assert!(!screen.contains("[2] Results"), "{screen}");
press(&mut tui, KeyCode::Esc);
assert!(!tui.state.layout.zoomed);
let screen = screen_of(&mut tui);
assert!(screen.contains("[2] Results"), "{screen}");
assert!(screen.contains("[Info]"), "{screen}");
press(&mut tui, KeyCode::Char('z'));
press(&mut tui, KeyCode::Char('z'));
assert!(!tui.state.layout.zoomed, "z toggles");
}
#[test]
fn a_search_brings_a_hidden_results_pane_back() {
let mut tui = furnished_tui();
press(&mut tui, KeyCode::Char('w'));
assert!(!tui.state.layout.is_visible(FocusedPane::Results));
press(&mut tui, KeyCode::Char('s'));
press(&mut tui, KeyCode::Char('x'));
press(&mut tui, KeyCode::Enter);
assert!(tui.state.layout.is_visible(FocusedPane::Results));
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
}
#[test]
fn question_mark_opens_the_keys_list_and_closes_it_again() {
let mut tui = furnished_tui();
press(&mut tui, KeyCode::Char('?'));
let screen = screen_of(&mut tui);
assert!(screen.contains("Keys"), "{screen}");
assert!(screen.contains("Tab / Shift-Tab"), "{screen}");
assert!(screen.contains("clear every finished one"), "{screen}");
// Other keys are swallowed while it is open.
press(&mut tui, KeyCode::Char('q'));
assert!(tui.state.exit.is_none(), "q closes the list, not the app");
assert!(!tui.state.show_help);
press(&mut tui, KeyCode::Char('?'));
press(&mut tui, KeyCode::Char('w'));
assert!(tui.state.show_help, "w did not reach the panes");
assert!(tui.state.layout.is_visible(FocusedPane::Results));
press(&mut tui, KeyCode::Esc);
assert!(!tui.state.show_help);
}
#[test]
fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() {
let mut tui = furnished_tui();
press(&mut tui, KeyCode::Char('?'));
let screen = screen_sized(&mut tui, 80, 24);
let panes = row_with(&screen, "Panes");
assert!(!panes.contains("Searches"), "one column: {panes}");
assert!(screen.contains("focus a pane, hidden or not"), "{screen}");
assert!(!screen.contains("next room, chat or user"), "{screen}");
press(&mut tui, KeyCode::End);
let screen = screen_sized(&mut tui, 80, 24);
assert!(screen.contains("next room, chat or user"), "{screen}");
assert!(!screen.contains("next / previous pane"), "{screen}");
press(&mut tui, KeyCode::Home);
let screen = screen_sized(&mut tui, 80, 24);
assert!(screen.contains("Tab / Shift-Tab"), "{screen}");
// A wide window shows both columns at once, nothing to scroll. It
// has to be tall enough to hold the taller column outright; in a
// shorter one End follows the tail and the headings scroll off.
press(&mut tui, KeyCode::End);
let screen = screen_sized(&mut tui, 160, 44);
let panes = row_with(&screen, "Panes");
assert!(panes.contains("Searches"), "two columns: {panes}");
assert!(screen.contains("Tab / Shift-Tab"), "{screen}");
}
#[test]
fn a_click_where_a_pane_just_hid_does_not_focus_it() {
let mut tui = furnished_tui();
let _ = screen_of(&mut tui);
let searches = tui.state.searches_pane_area.expect("laid out");
// Hidden, but not yet redrawn: the old area is still on record.
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Char('w'));
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent {
kind: ratatui::crossterm::event::MouseEventKind::Down(
ratatui::crossterm::event::MouseButton::Left,
),
column: searches.x + 1,
row: searches.y + 1,
modifiers: KeyModifiers::NONE,
});
assert_eq!(tui.state.focused_pane, FocusedPane::Results);
}
#[test]
fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() {
let mut tui = furnished_tui();
let screen = screen_of(&mut tui);
assert!(screen.contains("[q → quit]"), "{screen}");
let bar_rows = screen.lines().filter(|line| line.contains(" → ")).count();
assert_eq!(bar_rows, 2, "{screen}");
let wide = screen_sized(&mut tui, 300, 40);
let bar_rows = wide.lines().filter(|line| line.contains(" → ")).count();
assert_eq!(bar_rows, 1, "{wide}");
}
#[test]
fn the_searches_pane_lists_its_clear_all_and_rerun_keys() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches;
let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[C → clear all]"), "{screen}");
assert!(screen.contains("[S → search again]"), "{screen}");
press(&mut tui, KeyCode::Char('?'));
let screen = screen_of(&mut tui);
assert!(screen.contains("run the search again"), "{screen}");
}
#[test]
fn the_downloads_pane_lists_its_clear_everything_key() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Downloads;
let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[c → clear finished]"), "{screen}");
assert!(screen.contains("[C → clear all]"), "{screen}");
press(&mut tui, KeyCode::Char('?'));
let screen = screen_of(&mut tui);
assert!(screen.contains("cancelling the live ones"), "{screen}");
}
#[test]
fn shift_w_brings_every_pane_back_and_leaves_zoom() {
let mut tui = furnished_tui();
tui.state.focused_pane = FocusedPane::Searches;
assert!(!screen_sized(&mut tui, 300, 40).contains("[W → "));
press(&mut tui, KeyCode::Char('w'));
press(&mut tui, KeyCode::Char('z'));
assert!(tui.state.layout.zoomed);
assert!(!tui.state.layout.is_visible(FocusedPane::Searches));
let screen = screen_sized(&mut tui, 300, 40);
assert!(screen.contains("[W → reset layout]"), "{screen}");
press(&mut tui, KeyCode::Char('W'));
assert!(!tui.state.layout.zoomed);
assert!(
FocusedPane::ALL
.iter()
.all(|p| tui.state.layout.is_visible(*p))
);
}
#[test]
fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() {
let mut tui = furnished_tui();
let _ = screen_of(&mut tui);
let searches = tui.state.searches_pane_area.expect("laid out");
let inside = ratatui::crossterm::event::MouseEvent {
kind: ratatui::crossterm::event::MouseEventKind::Down(
ratatui::crossterm::event::MouseButton::Left,
),
column: searches.x + 1,
row: searches.y + 1,
modifiers: KeyModifiers::NONE,
};
tui.handle_mouse_event(inside);
assert_eq!(tui.state.focused_pane, FocusedPane::Searches);
press(&mut tui, KeyCode::Char('w'));
let _ = screen_of(&mut tui);
tui.handle_mouse_event(inside);
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
}
#[test]
fn dragging_the_border_under_results_resizes_its_row() {
let mut tui = furnished_tui();
let _ = screen_of(&mut tui);
let results = tui.state.results_pane_area.expect("laid out");
let divider = tui.state.hsplit_divider.expect("a divider");
let before = results.height;
let focused = tui.state.focused_pane;
// Two drags: the row shrinks by the rows the pointer moved up.
mouse_drag_row(
&mut tui,
(results.x + 1, divider.y),
(results.x + 1, divider.y - 2),
);
assert_eq!(tui.state.layout.top_height, Some(before - 2));
assert_eq!(
tui.state.focused_pane, focused,
"a grab is not a focus click"
);
let _ = screen_of(&mut tui);
assert_eq!(
tui.state.results_pane_area.expect("laid out").height,
before - 2
);
// A drag far past the row stops at the smallest pane, not zero.
mouse_drag_row(
&mut tui,
(results.x + 1, divider.y - 2),
(results.x + 1, 0),
);
assert!(tui.state.layout.top_height.expect("resized") >= 3);
press(&mut tui, KeyCode::Char('W'));
assert_eq!(tui.state.layout.top_height, None, "W resets the layout");
}
#[test]
fn dragging_a_row_panes_right_edge_gives_it_width() {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
let mut tui = furnished_tui();
let _ = screen_of(&mut tui);
let searches = tui.state.searches_pane_area.expect("laid out");
let seam = tui
.state
.vsplit_dividers
.iter()
.find(|(pane, _)| *pane == FocusedPane::Searches)
.map(|(_, rect)| *rect)
.expect("a seam beside Searches");
tui.state.focused_pane = FocusedPane::Downloads;
mouse_drag_row(
&mut tui,
(seam.x, searches.y + 1),
(seam.x + 4, searches.y + 1),
);
assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4));
assert_eq!(tui.state.focused_pane, FocusedPane::Downloads);
// The widths are applied, and Info narrows to whatever is left.
let _ = screen_of(&mut tui);
assert_eq!(
tui.state.searches_pane_area.expect("laid out").width,
searches.width + 4
);
assert!(tui.state.info_pane_area.expect("laid out").width >= 3);
// Releasing the button ends the drag: later motions just move the
// pointer.
mouse(
&mut tui,
MouseEventKind::Drag(MouseButton::Left),
seam.x + 40,
searches.y + 1,
);
assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4));
}
#[test]
fn the_info_pane_describes_the_highlighted_result() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename =
"@@x\\Music\\Album\\Second Track.mp3".to_string();
tui.state.results_items[1].bitrate = Some(320);
tui.state.results_table_state.select(Some(1));
let screen = screen_of(&mut tui);
assert!(screen.contains("@@x/Music/Album"), "{screen}");
assert!(screen.contains("Bitrate"), "{screen}");
tui.state.focused_pane = FocusedPane::Downloads;
let screen = screen_of(&mut tui);
assert!(!screen.contains("@@x/Music/Album"), "{screen}");
}
+560
View File
@@ -0,0 +1,560 @@
//! The window driven the way a terminal drives it: keys and mouse events in,
//! a rendered screen out, over a stand-in session.
//!
//! This file is the harness the topic modules below share — the session
//! double, the window constructors, and the press/screen helpers.
mod browse;
mod layout;
mod navigation;
mod rooms;
mod searches;
mod session;
mod settings;
mod transfers;
use super::*;
use crate::daemon::proto::ChatMessageDto;
use crate::models::{FocusedPane, MessageDirection, RoomsView, SearchStatus};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
/// A stand-in for a daemon: it already holds a conversation, a transfer
/// queue, and a search cache, none of which this window created.
#[derive(Default)]
struct TalkativeSession {
history: Vec<ChatMessageDto>,
downloads: Vec<soulseek_rs::types::Download>,
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
/// What `try_get_search_results` hands back to the window.
results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>,
/// Every query a window put on the wire, in order.
searched: std::sync::Mutex<Vec<String>>,
/// How many times a window asked for the search list.
all_searches_calls: std::sync::atomic::AtomicUsize,
/// How many times a window pulled a full result set across. Against a
/// daemon each pull is the whole set over the socket, so the tests
/// count them.
result_fetches: std::sync::atomic::AtomicUsize,
/// Whether this session is a daemon's. Local sessions do not sync.
shared: bool,
/// What `set_download_directory` was last asked for, if anything.
download_dir_set: std::sync::Mutex<Option<String>>,
/// What `set_shared_directories` was last asked for, if anything.
shares_set: std::sync::Mutex<Option<Vec<String>>>,
/// What `change_password` was last asked for, if anything.
password_set: std::sync::Mutex<Option<String>>,
/// The share index this session serves to peers.
listing: std::sync::Mutex<Vec<soulseek_rs::SharedDirectory>>,
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
download_removed: std::sync::Mutex<Vec<(String, String)>>,
}
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
at_status(username, filename, soulseek_rs::DownloadStatus::Queued)
}
fn completed(username: &str, filename: &str) -> soulseek_rs::types::Download {
at_status(username, filename, soulseek_rs::DownloadStatus::Completed)
}
fn uploading(username: &str, filename: &str) -> soulseek_rs::types::UploadInfo {
soulseek_rs::types::UploadInfo {
username: username.to_string(),
filename: filename.to_string(),
size: 10,
bytes_sent: 1,
speed_bytes_per_sec: 1.0,
status: soulseek_rs::types::UploadStatus::InProgress,
}
}
fn at_status(
username: &str,
filename: &str,
status: soulseek_rs::DownloadStatus,
) -> soulseek_rs::types::Download {
let (sender, _) = std::sync::mpsc::channel();
soulseek_rs::types::Download {
username: username.to_string(),
filename: filename.to_string(),
token: 1,
size: 4096,
download_directory: "/tmp".to_string(),
status,
sender,
queue_position: None,
metadata: soulseek_rs::types::DownloadMetadata::default(),
}
}
fn key(ch: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)
}
impl SessionApi for TalkativeSession {
fn message_history(&self) -> Vec<ChatMessageDto> {
self.history.clone()
}
fn daemon_endpoint(&self) -> Option<String> {
self.shared.then(|| "test-daemon".to_string())
}
fn username(&self) -> String {
"tester".to_string()
}
fn listen_port(&self) -> Option<u16> {
None
}
fn session_loss(&self) -> Option<soulseek_rs::SessionLoss> {
None
}
fn search_with_cancel(
&self,
query: &str,
_timeout: Duration,
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
self.searched
.lock()
.expect("not poisoned")
.push(query.to_string());
Ok(Vec::new())
}
fn get_search_results(&self, _key: &str) -> Vec<soulseek_rs::SearchResult> {
Vec::new()
}
fn all_searches(&self) -> Vec<crate::api::SessionSearch> {
self.all_searches_calls
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.searches.lock().expect("not poisoned").clone()
}
fn forget_search(&self, query: &str) -> bool {
let mut searches = self.searches.lock().expect("not poisoned");
let before = searches.len();
searches.retain(|search| search.query != query);
searches.len() != before
}
fn get_search_results_count(&self, _key: &str) -> usize {
0
}
fn try_get_search_results(
&self,
_key: &str,
) -> Option<Vec<soulseek_rs::SearchResult>> {
self.result_fetches
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Some(self.results.lock().expect("not poisoned").clone())
}
fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn wishlist_interval(&self) -> Duration {
Duration::from_mins(12)
}
fn download(
&self,
_filename: String,
_username: String,
_size: u64,
_directory: String,
) -> soulseek_rs::Result<(
soulseek_rs::types::Download,
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
)> {
Err(soulseek_rs::SoulseekRs::NotConnected)
}
fn download_with_metadata(
&self,
_filename: String,
_username: String,
_size: u64,
_directory: String,
_metadata: soulseek_rs::types::DownloadMetadata,
) -> soulseek_rs::Result<(
soulseek_rs::types::Download,
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
)> {
Err(soulseek_rs::SoulseekRs::NotConnected)
}
fn get_all_downloads(&self) -> Vec<soulseek_rs::types::Download> {
self.downloads.clone()
}
fn pause_download(&self, _u: &str, _f: &str) -> bool {
false
}
fn resume_download(&self, _u: &str, _f: &str) -> bool {
false
}
fn remove_queued_download(&self, _u: &str, _f: &str) -> bool {
false
}
fn remove_download(&self, u: &str, f: &str) -> bool {
self.download_removed
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn uploads(&self) -> Vec<soulseek_rs::UploadInfo> {
Vec::new()
}
fn take_upload_events(&self) -> Vec<soulseek_rs::UploadInfo> {
Vec::new()
}
fn cancel_upload(&self, u: &str, f: &str) -> bool {
self.upload_cancelled
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn cancel_download(&self, u: &str, f: &str) -> bool {
self.download_cancelled
.lock()
.expect("not poisoned")
.push((u.to_string(), f.to_string()));
true
}
fn set_upload_slots(&self, _slots: usize) {}
fn check_privileges(&self) -> soulseek_rs::Result<()> {
Ok(())
}
fn own_privilege_seconds(&self) -> Option<u32> {
None
}
fn request_room_list(&self) -> soulseek_rs::Result<()> {
Ok(())
}
fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn say_in_room(
&self,
_room: &str,
_message: &str,
) -> soulseek_rs::Result<()> {
Ok(())
}
fn room_members(&self, _room: &str) -> Vec<String> {
Vec::new()
}
fn room_member_stats(
&self,
_room: &str,
) -> Vec<soulseek_rs::types::RoomUserStats> {
Vec::new()
}
fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> {
Vec::new()
}
fn send_private_message(
&self,
_u: &str,
_m: &str,
) -> soulseek_rs::Result<()> {
Ok(())
}
fn take_private_messages(&self) -> Vec<soulseek_rs::UserMessage> {
Vec::new()
}
fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn take_browse_result(
&self,
_username: &str,
) -> Option<Vec<soulseek_rs::SharedDirectory>> {
None
}
fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> {
Ok(())
}
fn watched_users(&self) -> Vec<String> {
Vec::new()
}
fn user_info(&self, _username: &str) -> Option<soulseek_rs::UserInfo> {
None
}
fn shared_counts(&self) -> (u32, u32) {
(3, 12)
}
fn change_password(&self, password: &str) -> soulseek_rs::Result<()> {
*self.password_set.lock().expect("not poisoned") =
Some(password.to_string());
Ok(())
}
fn shared_listing(&self) -> Vec<soulseek_rs::SharedDirectory> {
self.listing.lock().expect("not poisoned").clone()
}
fn shared_directories(&self) -> Vec<String> {
Vec::new()
}
fn set_shared_directories(
&self,
directories: Vec<String>,
) -> soulseek_rs::Result<()> {
*self.shares_set.lock().expect("not poisoned") = Some(directories);
Ok(())
}
fn set_download_directory(
&self,
directory: String,
) -> soulseek_rs::Result<()> {
*self.download_dir_set.lock().expect("not poisoned") = Some(directory);
Ok(())
}
}
fn tui(history: Vec<ChatMessageDto>) -> MainTui {
with_session(TalkativeSession {
history,
shared: true,
..TalkativeSession::default()
})
}
fn shared(searches: Vec<crate::api::SessionSearch>) -> TalkativeSession {
TalkativeSession {
searches: std::sync::Mutex::new(searches),
shared: true,
..TalkativeSession::default()
}
}
fn search_of(query: &str, files: usize, age: u64) -> crate::api::SessionSearch {
crate::api::SessionSearch {
query: query.to_string(),
files,
started_secs_ago: Some(age),
}
}
fn with_session(session: TalkativeSession) -> MainTui {
attach(Arc::new(session))
}
fn attach(session: Arc<TalkativeSession>) -> MainTui {
MainTui::new(
session,
"/tmp".to_string(),
Duration::from_secs(1),
None,
None,
)
}
fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult {
soulseek_rs::SearchResult {
token: 0,
files: names
.iter()
.map(|name| soulseek_rs::types::File {
username: "bob".to_string(),
name: (*name).to_string(),
size: 42,
attribs: std::collections::HashMap::new(),
})
.collect(),
slots: 1,
speed: 0,
username: "bob".to_string(),
}
}
/// An attached window that selected a two-file search and has already
/// ticked past its one necessary fetch.
fn window_that_pulled_two_files() -> (Arc<TalkativeSession>, MainTui) {
let session = Arc::new(TalkativeSession {
results: std::sync::Mutex::new(vec![files_from_bob(&[
"@@x\\a.mp3",
"@@x\\b.mp3",
])]),
..shared(vec![search_of("aphex twin", 2, 9_999)])
});
let mut tui = attach(session.clone());
tui.state.selected_search_index = Some(0);
tui.update_search_results();
tui.update_search_results();
(session, tui)
}
fn results(n: usize) -> Vec<crate::models::FileDisplayData> {
(0..n)
.map(|i| crate::models::FileDisplayData {
filename: format!("@@x\\Music\\{i:02}.mp3"),
username: "bob".to_string(),
..Default::default()
})
.collect()
}
fn results_tui(n: usize) -> MainTui {
let mut tui = with_session(TalkativeSession::default());
tui.state.results_items = results(n);
tui.state.focused_pane = FocusedPane::Results;
// 88 cells wide gives the name column 11 cells and the folder 7.
tui.state.results_pane_area =
Some(ratatui::layout::Rect::new(0, 0, 88, 13));
tui
}
fn press(tui: &mut MainTui, code: KeyCode) {
tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
fn ctrl(tui: &mut MainTui, ch: char) {
tui.handle_key_event(KeyEvent::new(
KeyCode::Char(ch),
KeyModifiers::CONTROL,
));
}
fn selected(tui: &MainTui) -> Option<usize> {
tui.state.results_table_state.selected()
}
fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String {
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = ratatui::Terminal::new(backend).expect("backend");
terminal.draw(|frame| tui.render(frame)).expect("draw");
terminal.backend().to_string()
}
fn screen_of(tui: &mut MainTui) -> String {
screen_sized(tui, 160, 40)
}
fn mouse(
tui: &mut MainTui,
kind: ratatui::crossterm::event::MouseEventKind,
column: u16,
row: u16,
) {
tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
});
}
fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) {
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1);
mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1);
mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1);
}
/// The screen row carrying a pane's legend, `[2] Results` say, without
/// the quotes the test backend wraps each row in.
fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str {
let line = screen
.lines()
.find(|line| line.contains(needle))
.unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}"));
let start = line.find('"').map_or(0, |i| i + 1);
let end = line.rfind('"').unwrap_or(line.len());
&line[start..end]
}
/// A window with a search, its results, and a transfer, so every pane
/// has something to show.
fn furnished_tui() -> MainTui {
let mut tui = results_tui(3);
tui.state.searches.push(crate::models::SearchEntry {
query: "aphex twin".to_string(),
status: SearchStatus::Completed,
results: results(3),
known_files: 3,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.selected_search_index = Some(0);
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "@@x\\Music\\00.mp3"),
receiver: None,
});
tui
}
fn browsed(tui: &mut MainTui, files: usize) {
tui.state.browse.open("bob");
let listing = vec![soulseek_rs::SharedDirectory {
name: "Music".to_string(),
files: (0..files)
.map(|i| soulseek_rs::SharedFileEntry {
name: format!("{i:02}.mp3"),
size: 1,
attributes: Vec::new(),
})
.collect(),
}];
tui.state
.browse
.active_tab_mut()
.expect("tab")
.load(&listing);
tui.state.show_browse = true;
let _ = screen_of(tui);
}
fn in_a_room(tui: &mut MainTui, messages: usize) {
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Joined {
room: "jazz".to_string(),
users: (0..30).map(|i| format!("user{i:02}")).collect(),
},
None,
);
tui.state.rooms.focus_or_open("jazz");
for i in 0..messages {
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::Message {
room: "jazz".to_string(),
username: "alice".to_string(),
message: format!("line {i:03}"),
},
Some("jazz"),
);
}
tui.state.show_rooms = true;
let _ = screen_of(tui);
}
/// The queries the window has sent so far, once at least `count` of them
/// are out: a search goes to the session from a worker thread, so a test
/// has to give it a moment.
fn searched(session: &TalkativeSession, count: usize) -> Vec<String> {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let seen = session.searched.lock().expect("not poisoned").clone();
if seen.len() >= count || std::time::Instant::now() > deadline {
return seen;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// A window over last session's search list: the queries came back from
/// disk, with nothing under them.
fn restored(session: Arc<TalkativeSession>, queries: &[&str]) -> MainTui {
let mut tui = attach(session);
let queries: Vec<String> =
queries.iter().map(ToString::to_string).collect();
restore_searches(&mut tui.state, &queries);
tui.state.focused_pane = FocusedPane::Searches;
tui
}
@@ -0,0 +1,236 @@
//! Moving through a list: rows, pages, and the sideways scroll that
//! walks a name too long for its column.
use super::*;
#[test]
fn end_and_home_jump_to_the_last_and_first_result() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::End);
assert_eq!(selected(&tui), Some(29));
press(&mut tui, KeyCode::Home);
assert_eq!(selected(&tui), Some(0));
press(&mut tui, KeyCode::Char('G'));
assert_eq!(selected(&tui), Some(29));
press(&mut tui, KeyCode::Char('g'));
assert_eq!(selected(&tui), Some(0));
}
#[test]
fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::PageDown);
assert_eq!(selected(&tui), Some(10));
ctrl(&mut tui, 'f');
assert_eq!(selected(&tui), Some(20));
press(&mut tui, KeyCode::PageDown);
assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping");
press(&mut tui, KeyCode::PageUp);
assert_eq!(selected(&tui), Some(19));
ctrl(&mut tui, 'b');
assert_eq!(selected(&tui), Some(9));
press(&mut tui, KeyCode::PageUp);
assert_eq!(selected(&tui), Some(0));
assert!(
!tui.state.show_browse && !tui.state.command_bar_active,
"ctrl-b pages up instead of opening browse"
);
}
#[test]
fn ctrl_d_and_ctrl_u_move_half_a_pane() {
let mut tui = results_tui(30);
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), Some(5));
ctrl(&mut tui, 'u');
assert_eq!(selected(&tui), Some(0));
}
#[test]
fn navigation_keys_do_nothing_without_results() {
let mut tui = results_tui(0);
tui.state.results_table_state.select(None);
press(&mut tui, KeyCode::End);
press(&mut tui, KeyCode::PageDown);
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), None);
}
#[test]
fn l_and_h_scroll_the_highlighted_name_up_to_its_end() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.results_name_offset, 8);
press(&mut tui, KeyCode::Right);
assert_eq!(tui.state.results_name_offset, 16);
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.results_name_offset, 30, "stops at the end");
press(&mut tui, KeyCode::Char('h'));
assert_eq!(tui.state.results_name_offset, 22);
press(&mut tui, KeyCode::Left);
assert_eq!(tui.state.results_name_offset, 14);
press(&mut tui, KeyCode::Char('0'));
assert_eq!(tui.state.results_name_offset, 0);
press(&mut tui, KeyCode::Char('h'));
assert_eq!(tui.state.results_name_offset, 0);
}
#[test]
fn scrolling_follows_the_highlighted_row_not_the_longest_one() {
let mut tui = results_tui(3);
tui.state.results_items[0].filename = "short.mp3".to_string();
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_items[2].filename = "short.mp3".to_string();
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already");
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
tui.state.results_table_state.select(Some(2));
press(&mut tui, KeyCode::Char('h'));
assert_eq!(
tui.state.results_name_offset, 0,
"a step left lands within the row now highlighted"
);
}
#[test]
fn picking_another_search_starts_its_results_unscrolled() {
let mut tui = results_tui(3);
tui.state.results_items[1].filename = "x".repeat(40);
tui.state.results_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('$'));
assert_eq!(tui.state.results_name_offset, 30);
tui.state.searches.push(crate::models::SearchEntry {
query: "other".to_string(),
status: SearchStatus::Active,
results: results(2),
known_files: 2,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.searches_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Enter);
assert_eq!(tui.state.results_name_offset, 0);
}
#[test]
fn ctrl_keys_are_ignored_outside_the_results_pane() {
let mut tui = results_tui(3);
tui.state.searches.push(crate::models::SearchEntry {
query: "keep me".to_string(),
status: SearchStatus::Active,
results: Vec::new(),
known_files: 0,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.state.searches_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Searches;
ctrl(&mut tui, 'd');
assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d");
ctrl(&mut tui, 'b');
assert!(!tui.state.command_bar_active, "ctrl-b is not b");
ctrl(&mut tui, 'q');
assert!(tui.state.exit.is_none(), "ctrl-q is not q");
}
#[test]
fn ctrl_keys_page_while_a_filter_is_being_typed() {
let mut tui = results_tui(30);
press(&mut tui, KeyCode::Char('/'));
press(&mut tui, KeyCode::Char('m'));
ctrl(&mut tui, 'd');
assert_eq!(selected(&tui), Some(5));
assert_eq!(tui.state.results_filter_query, "m", "no d appended");
press(&mut tui, KeyCode::End);
assert_eq!(selected(&tui), Some(29));
}
#[test]
fn space_after_a_filter_that_matches_nothing_does_not_panic() {
let mut tui = results_tui(3);
press(&mut tui, KeyCode::Char('/'));
press(&mut tui, KeyCode::Char('z'));
press(&mut tui, KeyCode::Enter);
assert!(tui.state.results_filtered_items.is_empty());
press(&mut tui, KeyCode::Char(' '));
assert!(tui.state.results_selected_indices.is_empty());
}
#[test]
fn l_and_h_scroll_a_transfer_name_and_a_query_too() {
let mut tui = furnished_tui();
tui.state.downloads[0].download.filename = "y".repeat(120);
tui.state.searches[0].query = "z".repeat(60);
let _ = screen_of(&mut tui);
tui.state.focused_pane = FocusedPane::Downloads;
press(&mut tui, KeyCode::Char('l'));
assert_eq!(tui.state.downloads_name_offset, 8);
press(&mut tui, KeyCode::Char('$'));
let end = tui.state.downloads_name_offset;
assert!(end > 8, "the end is past a step: {end}");
let screen = screen_of(&mut tui);
assert!(row_with(&screen, "…yyyy").contains("…yyyy"), "{screen}");
press(&mut tui, KeyCode::Char('0'));
assert_eq!(tui.state.downloads_name_offset, 0);
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Right);
assert_eq!(tui.state.searches_query_offset, 8);
press(&mut tui, KeyCode::End);
assert_eq!(
tui.state.searches_table_state.selected(),
Some(0),
"End is still a row key, $ is the name key"
);
press(&mut tui, KeyCode::Char('$'));
assert!(tui.state.searches_query_offset > 8);
let screen = screen_of(&mut tui);
assert!(screen.contains("…zzzz"), "{screen}");
assert!(
tui.state.results_name_offset == 0,
"each list scrolls on its own"
);
}
#[test]
fn page_keys_work_in_the_transfers_and_searches_lists_too() {
let mut tui = furnished_tui();
for i in 0..30 {
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", &format!("{i}.mp3")),
receiver: None,
});
}
tui.state.focused_pane = FocusedPane::Downloads;
tui.state.downloads_table_state.select(Some(0));
let _ = screen_of(&mut tui); // lays the panes out, which sets the page size
press(&mut tui, KeyCode::End);
assert_eq!(tui.state.downloads_table_state.selected(), Some(30));
press(&mut tui, KeyCode::Home);
assert_eq!(tui.state.downloads_table_state.selected(), Some(0));
press(&mut tui, KeyCode::PageDown);
let page = tui.state.downloads_table_state.selected().expect("row");
assert!(page > 1 && page < 30, "a pane of rows, not one: {page}");
ctrl(&mut tui, 'd');
assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes");
assert!(tui.state.downloads_table_state.selected() > Some(page));
tui.state.focused_pane = FocusedPane::Searches;
press(&mut tui, KeyCode::Char('G'));
assert_eq!(tui.state.searches_table_state.selected(), Some(0));
press(&mut tui, KeyCode::Char('g'));
assert_eq!(tui.state.searches_table_state.selected(), Some(0));
assert_eq!(tui.state.searches.len(), 1, "g and G only move");
}
+164
View File
@@ -0,0 +1,164 @@
//! Rooms and conversations: their logs, their lists, and their filters.
use super::*;
#[test]
fn slash_and_u_filter_a_rooms_log_and_its_members() {
let mut tui = attach(Arc::new(TalkativeSession::default()));
in_a_room(&mut tui, 30);
press(&mut tui, KeyCode::Char('u'));
for c in "user2".chars() {
press(&mut tui, KeyCode::Char(c));
}
press(&mut tui, KeyCode::Enter);
press(&mut tui, KeyCode::Char('j'));
assert_eq!(
tui.state.rooms.selected_user().as_deref(),
Some("user21"),
"j moves within the matching members"
);
let screen = screen_of(&mut tui);
assert!(screen.contains("Users 10/30"), "{screen}");
press(&mut tui, KeyCode::Char('/'));
for c in "line 01".chars() {
press(&mut tui, KeyCode::Char(c));
}
let screen = screen_of(&mut tui);
assert!(screen.contains("line 010"), "{screen}");
assert!(!screen.contains("line 029"), "{screen}");
assert!(screen.contains("filter: line 01_"), "{screen}");
ctrl(&mut tui, 'u');
assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls");
press(&mut tui, KeyCode::Enter);
press(&mut tui, KeyCode::Esc);
assert!(tui.state.rooms.log_filter.is_empty());
assert!(tui.state.rooms.user_filter.is_empty());
assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room");
press(&mut tui, KeyCode::Esc);
assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list");
}
#[test]
fn slash_filters_the_open_conversation() {
let mut tui = attach(Arc::new(TalkativeSession::default()));
for text in ["hello there", "see you", "hello again"] {
tui.state.messages.push(crate::models::ChatMessage {
direction: MessageDirection::Incoming,
peer: "bob".to_string(),
text: text.to_string(),
at: chrono::Local::now(),
});
}
tui.state.chat_peer = Some("bob".to_string());
tui.state.show_messages = true;
press(&mut tui, KeyCode::Char('/'));
for c in "hello".chars() {
press(&mut tui, KeyCode::Char(c));
}
let screen = screen_of(&mut tui);
assert!(screen.contains("hello again"), "{screen}");
assert!(!screen.contains("see you"), "{screen}");
press(&mut tui, KeyCode::Enter);
press(&mut tui, KeyCode::Esc);
assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages);
press(&mut tui, KeyCode::Esc);
assert!(!tui.state.show_messages);
}
#[test]
fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() {
let mut tui = with_session(TalkativeSession::default());
in_a_room(&mut tui, 120);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 119"), "tails by default: {screen}");
assert!(!screen.contains("line 000"), "{screen}");
press(&mut tui, KeyCode::PageUp);
let screen = screen_of(&mut tui);
assert!(!screen.contains("line 119"), "scrolled back: {screen}");
press(&mut tui, KeyCode::Home);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 000"), "the oldest: {screen}");
press(&mut tui, KeyCode::PageUp);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 000"), "clamped at the top: {screen}");
press(&mut tui, KeyCode::End);
let screen = screen_of(&mut tui);
assert!(screen.contains("line 119"), "{screen}");
// The member list keeps its own keys, and ctrl-x is not x.
press(&mut tui, KeyCode::Down);
assert_eq!(tui.state.rooms.user_selected, 1);
ctrl(&mut tui, 'x');
assert_eq!(tui.state.rooms.open.len(), 1, "still in the room");
}
#[test]
fn page_keys_move_through_the_room_list() {
let mut tui = with_session(TalkativeSession::default());
tui.state.rooms.apply_event(
soulseek_rs::RoomEvent::List(
(0..60)
.map(|i| soulseek_rs::types::RoomInfo {
name: format!("room{i:02}"),
user_count: 60 - i,
})
.collect(),
),
None,
);
tui.state.show_rooms = true;
let _ = screen_of(&mut tui);
press(&mut tui, KeyCode::End);
assert_eq!(tui.state.rooms.list_selected, 59);
press(&mut tui, KeyCode::PageUp);
assert!(tui.state.rooms.list_selected < 59);
press(&mut tui, KeyCode::Char('g'));
assert_eq!(tui.state.rooms.list_selected, 0);
ctrl(&mut tui, 'f');
assert!(tui.state.rooms.list_selected > 1);
}
#[test]
fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() {
let mut tui = tui((0..80)
.map(|i| ChatMessageDto {
peer: "bob".into(),
outgoing: i % 2 == 0,
text: format!("msg {i:03}"),
at: i,
})
.chain(std::iter::once(ChatMessageDto {
peer: "carol".into(),
outgoing: false,
text: "hello".into(),
at: 100,
}))
.collect());
tui.state.chat_peer = Some("bob".to_string());
tui.state.show_messages = true;
let screen = screen_of(&mut tui);
assert!(screen.contains("msg 079"), "{screen}");
press(&mut tui, KeyCode::PageUp);
let screen = screen_of(&mut tui);
assert!(!screen.contains("msg 079"), "scrolled back: {screen}");
press(&mut tui, KeyCode::Char('g'));
let screen = screen_of(&mut tui);
assert!(screen.contains("msg 000"), "{screen}");
press(&mut tui, KeyCode::Tab);
assert_eq!(tui.state.active_chat_peer(), Some("carol"));
assert!(
tui.state.chat_view.following(),
"a fresh chat starts at its end"
);
press(&mut tui, KeyCode::BackTab);
let screen = screen_of(&mut tui);
assert!(screen.contains("msg 079"), "{screen}");
}
@@ -0,0 +1,89 @@
//! Running a search the list already holds.
use super::*;
#[test]
fn shift_s_runs_the_highlighted_search_again_in_its_place() {
// The list survives a restart, but a query on it was only ever a
// reminder: seeing what the network has now meant typing it again.
let session = Arc::new(TalkativeSession::default());
let mut tui =
restored(session.clone(), &["boards of canada", "aphex twin"]);
tui.state.searches_table_state.select(Some(1));
press(&mut tui, KeyCode::Char('S'));
assert_eq!(
searched(&session, 1),
vec!["aphex twin".to_string()],
"the highlighted query went out again, untyped"
);
let queries: Vec<&str> = tui
.state
.searches
.iter()
.map(|s| s.query.as_str())
.collect();
assert_eq!(
queries,
["boards of canada", "aphex twin"],
"it keeps its row: no duplicate, nothing moved"
);
assert_eq!(tui.state.searches[1].status, SearchStatus::Active);
assert_eq!(tui.state.selected_search_index, Some(1));
assert_eq!(
tui.state.focused_pane,
FocusedPane::Results,
"and the window turns to where the results will land"
);
assert!(tui.state.results_items.is_empty());
}
#[test]
fn shift_s_stops_a_run_still_collecting_before_starting_over() {
let mut tui = furnished_tui();
tui.state.searches[0].status = SearchStatus::Active;
tui.state.searches[0].start_time = std::time::Instant::now()
.checked_sub(Duration::from_secs(2))
.expect("two seconds ago");
let previous = tui.state.searches[0].cancel_flag.clone();
tui.state.focused_pane = FocusedPane::Searches;
tui.state.searches_table_state.select(Some(0));
press(&mut tui, KeyCode::Char('S'));
assert!(
previous.load(std::sync::atomic::Ordering::Relaxed),
"the worker that was collecting is told to stop"
);
let fresh = &tui.state.searches[0];
assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed));
assert!(fresh.results.is_empty(), "stale rows do not linger");
assert_eq!(tui.state.searches.len(), 1);
}
#[test]
fn shift_s_is_only_a_key_over_the_searches_list() {
let session = Arc::new(TalkativeSession::default());
let mut tui = restored(session.clone(), &["aphex twin"]);
// Over Results the same key means nothing, so a stray shift does not
// throw a finished search away.
tui.state.focused_pane = FocusedPane::Results;
press(&mut tui, KeyCode::Char('S'));
assert_eq!(tui.state.searches[0].status, SearchStatus::Completed);
std::thread::sleep(Duration::from_millis(50));
assert!(searched(&session, 0).is_empty(), "nothing went out");
}
#[test]
fn a_held_shift_s_is_one_search_not_one_per_repeat() {
let session = Arc::new(TalkativeSession::default());
let mut tui = restored(session.clone(), &["aphex twin"]);
press(&mut tui, KeyCode::Char('S'));
press(&mut tui, KeyCode::Char('1'));
press(&mut tui, KeyCode::Char('S'));
assert_eq!(searched(&session, 1).len(), 1);
std::thread::sleep(Duration::from_millis(50));
assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored");
}
@@ -0,0 +1,289 @@
//! A window over a session it did not start: what a daemon's state
//! looks like arriving in a window, and what the window pushes back.
use super::*;
#[test]
fn an_attached_session_opens_with_the_conversation_already_in_it() {
// A TUI attached to a daemon has no state file of its own; the
// conversation has to come from the session, or it starts blank while
// the daemon has been collecting messages for hours.
let tui = tui(vec![
ChatMessageDto {
peer: "bob".into(),
outgoing: false,
text: "hi".into(),
at: 1,
},
ChatMessageDto {
peer: "bob".into(),
outgoing: true,
text: "hello".into(),
at: 2,
},
]);
assert_eq!(tui.state.messages.len(), 2);
assert_eq!(tui.state.messages[0].peer, "bob");
assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming);
assert_eq!(
tui.state.messages[1].direction,
MessageDirection::Outgoing,
"both halves of the conversation, not just what arrived"
);
assert_eq!(tui.state.chat_peers(), ["bob"]);
}
#[test]
fn a_window_shows_transfers_it_did_not_start() {
// Two windows on one daemon are two views of one queue. A transfer
// queued in the other one has to appear here, or closing a window
// would look like losing its downloads.
let mut tui = with_session(TalkativeSession {
downloads: vec![queued("bob", "@@x\\theirs.mp3")],
shared: true,
..TalkativeSession::default()
});
assert!(
tui.state.downloads.is_empty(),
"nothing until the first poll"
);
tui.update_downloads();
assert_eq!(tui.state.downloads.len(), 1);
assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3");
assert!(
tui.state.downloads[0].receiver.is_none(),
"someone else's transfer has no channel of ours"
);
// Polling again must not double it up.
tui.update_downloads();
assert_eq!(tui.state.downloads.len(), 1);
}
#[test]
fn a_transfer_the_session_forgets_leaves_every_window() {
let mut tui = with_session(TalkativeSession {
downloads: vec![queued("bob", "gone.mp3")],
shared: true,
..TalkativeSession::default()
});
tui.update_downloads();
assert_eq!(tui.state.downloads.len(), 1);
// The session no longer has it — cancelled from the other window.
tui.client = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
tui.update_downloads();
assert!(
tui.state.downloads.is_empty(),
"a queue is shared in both directions"
);
}
#[test]
fn a_window_shows_searches_run_in_another() {
let mut tui =
with_session(shared(vec![search_of("their query", 7, 9_999)]));
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
assert_eq!(tui.state.searches[0].query, "their query");
// And it is not added a second time on the next frame.
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
}
#[test]
fn a_search_another_window_is_still_running_shows_as_active() {
// It had said "Done" the moment it appeared, because an imported
// search carried no notion of still collecting — so the other window
// looked finished while results were still arriving.
let mut tui = with_session(shared(vec![search_of("still going", 3, 0)]));
tui.update_search_results();
assert_eq!(tui.state.searches[0].status, SearchStatus::Active);
assert_eq!(
tui.state.searches[0].known_files, 3,
"and its count comes across before the results do"
);
}
#[test]
fn a_search_that_has_run_its_window_shows_as_done() {
let mut tui = with_session(shared(vec![search_of("finished", 12, 9_999)]));
tui.update_search_results();
assert_eq!(tui.state.searches[0].status, SearchStatus::Completed);
}
#[test]
fn removing_a_search_removes_it_from_the_session() {
// Removing it only from this window would last until the next sync,
// which would put it straight back — and in a shared session it would
// come back for every other window too.
let mut tui = with_session(shared(vec![search_of("unwanted", 1, 9_999)]));
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
tui.remove_search_at_index(0);
tui.update_search_results();
assert!(tui.state.searches.is_empty(), "and it stays gone");
}
#[test]
fn clearing_every_search_clears_them_in_the_session_too() {
let mut tui = with_session(shared(vec![
search_of("one", 1, 9_999),
search_of("two", 2, 9_999),
]));
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 2);
tui.clear_all_searches();
tui.update_search_results();
assert!(
tui.state.searches.is_empty(),
"clearing has to reach the session, or every row comes back"
);
}
#[test]
fn a_search_dismissed_in_another_window_leaves_this_one() {
let session = shared(vec![search_of("theirs", 4, 9_999)]);
let mut tui = with_session(session);
tui.update_search_results();
assert_eq!(tui.state.searches.len(), 1);
// The other window dismissed it.
tui.client.forget_search("theirs");
tui.update_search_results();
assert!(tui.state.searches.is_empty());
}
#[test]
fn a_tick_asks_the_session_for_its_searches_once() {
// Against a daemon every call is a round trip, and the daemon answers
// it by walking its whole search cache — twice per frame was half the
// cost of attaching at all.
let session = Arc::new(shared(vec![search_of("q", 0, 9_999)]));
let mut tui = attach(session.clone());
tui.update_search_results();
assert_eq!(
session
.all_searches_calls
.load(std::sync::atomic::Ordering::Relaxed),
1,
"one sync means one listing, not one per use of it"
);
}
#[test]
fn a_selected_search_with_nothing_new_is_not_pulled_across_again() {
// The result set only accumulates, so an unchanged count means a
// fetch would hand back what this window already holds — against a
// daemon that is the entire result set over the socket, every frame,
// forever.
let (session, tui) = window_that_pulled_two_files();
assert_eq!(tui.state.searches[0].results.len(), 2);
assert_eq!(
session
.result_fetches
.load(std::sync::atomic::Ordering::Relaxed),
1,
"the set crosses once; after that the unchanged count spares it"
);
}
#[test]
fn fresh_results_still_reach_a_window_that_skipped_a_fetch() {
let (session, mut tui) = window_that_pulled_two_files();
// A late responder lands in the session.
*session.searches.lock().expect("not poisoned") =
vec![search_of("aphex twin", 3, 9_999)];
*session.results.lock().expect("not poisoned") =
vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])];
tui.update_search_results();
assert_eq!(
tui.state.searches[0].results.len(),
3,
"a changed count must bring the new results across"
);
}
#[test]
fn a_local_window_fetches_results_without_counting_on_the_session() {
// Locally nothing maintains known_files, so the count gate must stay
// out of the way: a search whose count still reads zero has to get
// its results anyway.
let session = Arc::new(TalkativeSession {
results: std::sync::Mutex::new(vec![files_from_bob(&[
"@@x\\a.mp3",
"@@x\\b.mp3",
])]),
shared: false,
..TalkativeSession::default()
});
let mut tui = attach(session);
tui.state.searches.push(crate::models::SearchEntry {
query: "mine".to_string(),
status: SearchStatus::Active,
results: Vec::new(),
known_files: 0,
owned: true,
start_time: std::time::Instant::now(),
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
tui.update_search_results();
assert_eq!(
tui.state.searches[0].results.len(),
2,
"a zero count matching zero held rows must not read as \
nothing-to-fetch on a local session"
);
}
#[test]
fn a_window_with_its_own_session_is_left_alone() {
// Nothing to share, so nothing is synced — and in particular the
// completed transfers restored from disk, which the session has never
// heard of, are not mistaken for cancelled and deleted.
let mut tui = with_session(TalkativeSession {
downloads: Vec::new(),
shared: false,
..TalkativeSession::default()
});
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "from-disk.mp3"),
receiver: None,
});
tui.update_downloads();
assert_eq!(
tui.state.downloads.len(),
1,
"a local window's history must survive the tick"
);
}
#[test]
fn a_local_session_starts_from_its_own_snapshot_instead() {
// Locally the session knows no history and the state file is the whole
// record, so nothing is invented.
assert!(tui(Vec::new()).state.messages.is_empty());
}
@@ -0,0 +1,201 @@
//! The settings popup: the account it names, the folders it applies,
//! and the password and logout it can reach.
use super::*;
/// Attached, both folders in the settings form name paths on the
/// *daemon's* machine: they go over the socket exactly as typed, and
/// nothing is created or validated against this machine's filesystem.
#[test]
fn applying_settings_attached_pushes_both_folders_to_the_daemon() {
let session = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
let scratch = tempfile::tempdir().expect("a temp dir");
let config_path = scratch.path().join("config.toml");
let mut tui = MainTui::new(
session.clone(),
"/daemon/old".to_string(),
Duration::from_secs(1),
None,
Some(config_path.clone()),
);
let daemon_only = scratch.path().join("only-on-the-daemon");
let daemon_dir = daemon_only.to_str().expect("utf-8").to_string();
tui.open_settings();
{
let settings = tui.state.settings.as_mut().expect("open");
settings.download_dir.clone_from(&daemon_dir);
settings.share_dirs = vec!["/not-here-either".to_string()];
}
tui.apply_settings();
assert_eq!(
*session.download_dir_set.lock().expect("not poisoned"),
Some(daemon_dir.clone()),
"the folder is pushed to the daemon"
);
assert_eq!(
*session.shares_set.lock().expect("not poisoned"),
Some(vec!["/not-here-either".to_string()]),
"shares go over as typed — the daemon's filesystem judges them"
);
assert!(
!daemon_only.exists(),
"no directory is created on this machine for a daemon's path"
);
assert_eq!(tui.download_dir, daemon_dir);
let saved = crate::persist::config::FileConfig::load(&config_path)
.expect("config readable");
assert_eq!(
saved.download_dir.as_deref(),
Some(daemon_dir.as_str()),
"the change still lands in config.toml for the next restart"
);
}
/// Locally the same form works on this machine: the folder is created
/// here and share paths that do not exist here are dropped before the
/// session hears about them.
#[test]
fn applying_settings_locally_stays_on_this_machine() {
let session = Arc::new(TalkativeSession {
shared: false,
..TalkativeSession::default()
});
let scratch = tempfile::tempdir().expect("a temp dir");
let config_path = scratch.path().join("config.toml");
let share = scratch.path().join("music");
std::fs::create_dir_all(&share).expect("share dir");
let mut tui = MainTui::new(
session.clone(),
"/tmp".to_string(),
Duration::from_secs(1),
None,
Some(config_path),
);
let downloads = scratch.path().join("downloads");
let downloads_dir = downloads.to_str().expect("utf-8").to_string();
tui.open_settings();
{
let settings = tui.state.settings.as_mut().expect("open");
settings.download_dir.clone_from(&downloads_dir);
settings.share_dirs = vec![
share.to_str().expect("utf-8").to_string(),
"/definitely-not-a-real-share".to_string(),
];
}
tui.apply_settings();
// The seam is told in both modes; a local session accepts and takes
// the directory per download instead of storing it.
assert_eq!(
*session.download_dir_set.lock().expect("not poisoned"),
Some(downloads_dir.clone())
);
assert_eq!(
*session.shares_set.lock().expect("not poisoned"),
Some(vec![share.to_str().expect("utf-8").to_string()]),
"paths missing on this machine are dropped before applying"
);
assert!(downloads.is_dir(), "the folder is created here");
assert_eq!(tui.download_dir, downloads_dir);
}
/// The account block answers "who is this session, and what is it
/// offering" without a trip to another screen.
#[test]
fn the_settings_popup_names_the_account_and_its_shares() {
let mut tui = with_session(TalkativeSession::default());
press(&mut tui, KeyCode::Char('o'));
let screen = screen_sized(&mut tui, 100, 30);
assert!(screen.contains("tester"), "{screen}");
assert!(screen.contains("12 files in 3 folders"), "{screen}");
}
/// Typed twice the same way, the new password reaches the session — and
/// attached to a daemon it is the daemon that stores it.
#[test]
fn a_password_typed_twice_reaches_the_session() {
let session = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
let mut tui = attach(session.clone());
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Enter); // the change-password row is first
for _ in 0..2 {
for c in "hunter2".chars() {
press(&mut tui, KeyCode::Char(c));
}
press(&mut tui, KeyCode::Enter);
}
assert_eq!(
*session.password_set.lock().expect("not poisoned"),
Some("hunter2".to_string())
);
let settings = tui.state.settings.as_ref().expect("still open");
assert_eq!(settings.status.as_deref(), Some("Password changed"));
}
/// A mistyped repeat is caught here rather than on the server, where the
/// account would be left with a password nobody knows.
#[test]
fn a_password_typed_two_different_ways_is_not_sent() {
let session = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
let mut tui = attach(session.clone());
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Enter);
for typed in ["hunter2", "hunter3"] {
for c in typed.chars() {
press(&mut tui, KeyCode::Char(c));
}
press(&mut tui, KeyCode::Enter);
}
assert_eq!(*session.password_set.lock().expect("not poisoned"), None);
}
/// Logging out ends the window, but as a log-out: the caller brings the
/// login screen back rather than returning to the shell.
#[test]
fn logging_out_asks_first_and_then_closes_the_window() {
let mut tui = with_session(TalkativeSession::default());
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Down); // change password -> log out
press(&mut tui, KeyCode::Enter);
assert!(tui.state.exit.is_none(), "the confirm comes first");
press(&mut tui, KeyCode::Char('y'));
assert_eq!(tui.state.exit, Some(TuiExit::Logout));
}
/// Attached, the login is the daemon's: there is no logout row to land
/// on, so the row below the password is the download folder.
#[test]
fn an_attached_window_offers_no_logout() {
let session = Arc::new(TalkativeSession {
shared: true,
..TalkativeSession::default()
});
let mut tui = attach(session);
press(&mut tui, KeyCode::Char('o'));
press(&mut tui, KeyCode::Down);
press(&mut tui, KeyCode::Enter);
let settings = tui.state.settings.as_ref().expect("still open");
assert_eq!(
settings.mode,
crate::models::SettingsMode::EditingDownloadDir
);
assert!(tui.state.exit.is_none());
}
@@ -0,0 +1,239 @@
//! What the transfer lists do when a key lands on a row.
use super::*;
#[test]
fn x_cancels_the_highlighted_download() {
let session = Arc::new(TalkativeSession::default());
let mut tui = attach(session.clone());
tui.state.downloads.push(crate::models::DownloadEntry {
download: at_status(
"bob",
"song.mp3",
soulseek_rs::DownloadStatus::InProgress {
bytes_downloaded: 1,
total_bytes: 4096,
speed_bytes_per_sec: 1.0,
},
),
receiver: None,
});
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('x'));
assert_eq!(
*session.download_cancelled.lock().expect("not poisoned"),
vec![("bob".to_string(), "song.mp3".to_string())]
);
assert!(
session
.upload_cancelled
.lock()
.expect("not poisoned")
.is_empty(),
"the download row cancels no upload"
);
}
#[test]
fn shift_c_clears_the_whole_transfer_list() {
let session = Arc::new(TalkativeSession::default());
let mut tui = attach(session.clone());
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "waiting.mp3"),
receiver: None,
});
tui.state.downloads.push(crate::models::DownloadEntry {
download: at_status(
"carol",
"live.mp3",
soulseek_rs::DownloadStatus::InProgress {
bytes_downloaded: 1,
total_bytes: 4096,
speed_bytes_per_sec: 1.0,
},
),
receiver: None,
});
tui.state.downloads.push(crate::models::DownloadEntry {
download: completed("dave", "done.mp3"),
receiver: None,
});
tui.state.uploads.push(uploading("alice", "served.mp3"));
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('C'));
assert!(tui.state.downloads.is_empty(), "every row goes");
assert_eq!(
*session.download_cancelled.lock().expect("not poisoned"),
vec![
("bob".to_string(), "waiting.mp3".to_string()),
("carol".to_string(), "live.mp3".to_string()),
],
"live downloads are cancelled; the finished one is not"
);
assert_eq!(
*session.download_removed.lock().expect("not poisoned"),
vec![
("bob".to_string(), "waiting.mp3".to_string()),
("carol".to_string(), "live.mp3".to_string()),
("dave".to_string(), "done.mp3".to_string()),
],
"the session forgets every download, not just this window's rows"
);
assert_eq!(
*session.upload_cancelled.lock().expect("not poisoned"),
vec![("alice".to_string(), "served.mp3".to_string())],
"a streaming upload is cancelled too"
);
}
#[test]
fn d_clears_a_cancelled_row() {
let mut tui = with_session(TalkativeSession::default());
tui.state.downloads.push(crate::models::DownloadEntry {
download: at_status(
"bob",
"gone.mp3",
soulseek_rs::DownloadStatus::Cancelled,
),
receiver: None,
});
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('d'));
assert!(tui.state.downloads.is_empty(), "the row goes");
}
#[test]
fn x_on_a_finished_download_asks_nothing() {
let session = Arc::new(TalkativeSession::default());
let mut tui = attach(session.clone());
tui.state.downloads.push(crate::models::DownloadEntry {
download: completed("bob", "done.mp3"),
receiver: None,
});
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('x'));
assert!(
session
.download_cancelled
.lock()
.expect("not poisoned")
.is_empty(),
"a finished download is not cancelled"
);
}
#[test]
fn x_on_an_upload_row_still_cancels_the_upload() {
let session = Arc::new(TalkativeSession::default());
let mut tui = attach(session.clone());
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "song.mp3"),
receiver: None,
});
tui.state.uploads.push(uploading("alice", "served.mp3"));
tui.state.downloads_table_state.select(Some(1));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('x'));
assert_eq!(
*session.upload_cancelled.lock().expect("not poisoned"),
vec![("alice".to_string(), "served.mp3".to_string())]
);
assert!(
session
.download_cancelled
.lock()
.expect("not poisoned")
.is_empty(),
"the upload row cancels no download"
);
}
#[test]
fn b_browses_the_user_of_the_highlighted_download() {
let mut tui = with_session(TalkativeSession::default());
tui.state.downloads.push(crate::models::DownloadEntry {
download: queued("bob", "song.mp3"),
receiver: None,
});
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('b'));
assert!(tui.state.show_browse, "the browse popup opens");
assert_eq!(
tui.state.browse.active_tab().map(|b| b.username.as_str()),
Some("bob"),
"the download's user is the one browsed"
);
assert!(
!tui.state.command_bar_active,
"b on a transfer goes straight to the user, not the prompt"
);
}
#[test]
fn b_on_an_upload_row_browses_that_user() {
let mut tui = with_session(TalkativeSession::default());
tui.state.uploads.push(uploading("alice", "served.mp3"));
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('b'));
assert_eq!(
tui.state.browse.active_tab().map(|b| b.username.as_str()),
Some("alice")
);
}
#[test]
fn d_clears_a_finished_row_but_not_a_running_one() {
// Finished transfers are what a list fills up with after a restart, so
// `d` has to be able to dismiss one — restored or not. A running
// transfer is not ours to discard, and stays put.
let mut tui = with_session(TalkativeSession::default());
tui.state.downloads.push(crate::models::DownloadEntry {
download: completed("bob", "done.mp3"),
receiver: None,
});
tui.state.downloads_table_state.select(Some(0));
tui.state.focused_pane = FocusedPane::Downloads;
tui.handle_key_event(key('d'));
assert!(tui.state.downloads.is_empty(), "the row goes too");
assert_eq!(tui.state.downloads_table_state.selected(), None);
tui.state.downloads.push(crate::models::DownloadEntry {
download: at_status(
"bob",
"still.mp3",
soulseek_rs::DownloadStatus::InProgress {
bytes_downloaded: 1,
total_bytes: 2,
speed_bytes_per_sec: 1.0,
},
),
receiver: None,
});
tui.state.downloads_table_state.select(Some(0));
tui.handle_key_event(key('d'));
assert_eq!(tui.state.downloads.len(), 1, "it is not disposable");
}