Two TUIs on one daemon behaved as two separate windows that happened to share a login: each showed only the transfers it had queued and the searches it had run, and neither said it was attached to anything. The status bar now names the daemon, and both lists are views of the session: transfers and searches appear in every window, dismissing one dismisses it everywhere, and a window opened later starts from what is already running rather than blank. message read likewise starts from the conversation the daemon collected instead of only what arrives while it runs. Search state needed the daemon to say when a query went out. The window a search collects for belongs to whichever client started it, so without that every other window showed a running search as finished the moment it appeared — which is what it did. The summary carries a file count too, so a window has the right number before it has pulled the results across. Adds Client::forget_search, which the library had no way to do: a resident client otherwise holds every result set it has ever seen, and a window that dismissed a search had no way to say so. A review of the first cut found five defects, all fixed here and all covered: syncing ran locally too, where it cost a full copy of every result set per frame and deleted the completed-download history restored from disk; a transfer could be listed twice by racing its own registration; clearing all searches did not reach the session, so every row came back; a search dismissed elsewhere never left; and a wishlist search reported as finished the instant it went out.
405 lines
14 KiB
Rust
405 lines
14 KiB
Rust
//! The wire contract: JSON-RPC 2.0 framing and the data types that cross it.
|
|
//!
|
|
//! Library types never travel as themselves. Every payload is a DTO defined
|
|
//! here with an explicit conversion, so the wire format is versioned
|
|
//! independently of the library: a refactor inside `soulseek-rs-lib` cannot
|
|
//! silently change what a third-party script receives, and the compiler points
|
|
//! at this file when one would.
|
|
//!
|
|
//! The schemas published in `docs/openrpc.json` are derived from these types
|
|
//! (see the tests at the bottom), which is what keeps the advertised contract
|
|
//! and the running daemon in agreement.
|
|
|
|
/// Bumped when a change would break a client written against the old shape.
|
|
/// Sent in the `auth` reply so a mismatch is caught at connect time rather
|
|
/// than as a confusing failure three calls later.
|
|
pub const PROTOCOL_VERSION: u32 = 1;
|
|
|
|
/// The OpenRPC document, served by `rpc.discover` so a generator can point at
|
|
/// a running daemon instead of needing this repository.
|
|
pub const OPENRPC: &str = include_str!("../../../../docs/openrpc.json");
|
|
|
|
mod dto;
|
|
mod envelope;
|
|
mod method;
|
|
|
|
pub use dto::*;
|
|
pub use envelope::*;
|
|
pub use method::*;
|
|
|
|
/// Declare a wire vocabulary once: the enum, the complete list, and the
|
|
/// name mapping in both directions.
|
|
///
|
|
/// Everything that consumes one of these matches on the enum, so the match is
|
|
/// exhaustive and adding a line here is a compile error everywhere the new
|
|
/// member still has to be handled — the dispatcher, the published schema, the
|
|
/// remote client. A `&[&str]` table checked by a test would only fail after
|
|
/// the fact, and only if someone remembered to write the test.
|
|
macro_rules! wire_vocabulary {
|
|
($(#[$meta:meta])* $name:ident { $($variant:ident => $wire:literal),* $(,)? }) => {
|
|
$(#[$meta])*
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum $name {
|
|
$($variant),*
|
|
}
|
|
|
|
impl $name {
|
|
/// Every member, in published order.
|
|
pub const ALL: &'static [Self] = &[$(Self::$variant),*];
|
|
|
|
#[must_use]
|
|
pub const fn wire_name(self) -> &'static str {
|
|
match self {
|
|
$(Self::$variant => $wire),*
|
|
}
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn from_wire(name: &str) -> Option<Self> {
|
|
match name {
|
|
$($wire => Some(Self::$variant),)*
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for $name {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.wire_name())
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
wire_vocabulary! {
|
|
/// Every method the daemon answers.
|
|
Method {
|
|
Auth => "auth",
|
|
DaemonStatus => "daemon.status",
|
|
DaemonStop => "daemon.stop",
|
|
RpcDiscover => "rpc.discover",
|
|
SearchStart => "search.start",
|
|
SearchResults => "search.results",
|
|
SearchList => "search.list",
|
|
SearchForget => "search.forget",
|
|
SearchWishlist => "search.wishlist",
|
|
SearchWishlistInterval => "search.wishlist_interval",
|
|
DownloadStart => "download.start",
|
|
DownloadList => "download.list",
|
|
DownloadPause => "download.pause",
|
|
DownloadResume => "download.resume",
|
|
DownloadRemove => "download.remove",
|
|
DownloadRemoveQueued => "download.remove_queued",
|
|
UploadList => "upload.list",
|
|
UploadCancel => "upload.cancel",
|
|
UploadSlots => "upload.slots",
|
|
PrivilegesCheck => "privileges.check",
|
|
PrivilegesOwn => "privileges.own",
|
|
RoomListRequest => "room.list_request",
|
|
RoomJoin => "room.join",
|
|
RoomLeave => "room.leave",
|
|
RoomSay => "room.say",
|
|
RoomMembers => "room.members",
|
|
MessageSend => "message.send",
|
|
MessageHistory => "message.history",
|
|
BrowseUser => "browse.user",
|
|
UserRequest => "user.request",
|
|
UserInfoOf => "user.info",
|
|
SharesStatusOf => "shares.status",
|
|
SharesSet => "shares.set",
|
|
SharesReindex => "shares.reindex",
|
|
}
|
|
}
|
|
|
|
wire_vocabulary! {
|
|
/// Events the daemon pushes without being asked.
|
|
///
|
|
/// These exist because the library's `take_*` accessors are destructive:
|
|
/// with two clients attached, whichever drained first would steal the
|
|
/// others' events. The daemon is the single drainer and fans out.
|
|
Event {
|
|
Room => "event.room",
|
|
Message => "event.message",
|
|
Upload => "event.upload",
|
|
DownloadStatus => "event.download_status",
|
|
Browse => "event.browse",
|
|
SessionLoss => "event.session_loss",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::output::Exit;
|
|
use soulseek_rs::types::{UserPresence, UserStats, UserStatus};
|
|
use soulseek_rs::{
|
|
DownloadStatus, File, RoomEvent, RoomInfo, SearchResult,
|
|
SharedDirectory, UploadStatus, UserInfo, UserMessage,
|
|
};
|
|
use std::collections::HashMap;
|
|
|
|
#[test]
|
|
fn a_search_result_survives_the_round_trip() {
|
|
let mut attribs = HashMap::new();
|
|
attribs.insert(0, 320);
|
|
attribs.insert(1, 210);
|
|
let original = SearchResult {
|
|
token: 7,
|
|
slots: 2,
|
|
speed: 900,
|
|
username: "bob".into(),
|
|
files: vec![File {
|
|
username: "bob".into(),
|
|
name: "@@x\\a.mp3".into(),
|
|
size: 4096,
|
|
attribs,
|
|
}],
|
|
};
|
|
|
|
let json = serde_json::to_string(&SearchResultDto::from(&original))
|
|
.expect("a DTO serializes");
|
|
let back: SearchResult = serde_json::from_str::<SearchResultDto>(&json)
|
|
.unwrap()
|
|
.into();
|
|
|
|
assert_eq!(back.token, original.token);
|
|
assert_eq!(back.username, original.username);
|
|
assert_eq!(back.slots, original.slots);
|
|
assert_eq!(back.speed, original.speed);
|
|
assert_eq!(back.files.len(), 1);
|
|
assert_eq!(back.files[0].name, "@@x\\a.mp3");
|
|
assert_eq!(back.files[0].size, 4096);
|
|
assert_eq!(back.files[0].attribs.get(&0), Some(&320));
|
|
// The per-file username is carried by the parent result, so it has to
|
|
// be restored rather than lost.
|
|
assert_eq!(back.files[0].username, "bob");
|
|
}
|
|
|
|
#[test]
|
|
fn every_download_status_survives_the_round_trip() {
|
|
let statuses = [
|
|
DownloadStatus::Queued,
|
|
DownloadStatus::InProgress {
|
|
bytes_downloaded: 10,
|
|
total_bytes: 100,
|
|
speed_bytes_per_sec: 1.5,
|
|
},
|
|
DownloadStatus::Paused {
|
|
bytes_downloaded: 10,
|
|
total_bytes: 100,
|
|
},
|
|
DownloadStatus::Completed,
|
|
DownloadStatus::Failed(Some("gone".into())),
|
|
DownloadStatus::Failed(None),
|
|
DownloadStatus::TimedOut,
|
|
];
|
|
for status in &statuses {
|
|
let dto = DownloadStatusDto::from(status);
|
|
let json = serde_json::to_string(&dto).unwrap();
|
|
let back: DownloadStatusDto = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(dto, back, "{status:?} must survive the wire");
|
|
assert_eq!(
|
|
DownloadStatusDto::from(&DownloadStatus::from(back)),
|
|
dto,
|
|
"{status:?} must convert back to the same library value"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_upload_status_survives_the_round_trip() {
|
|
let statuses = [
|
|
UploadStatus::Queued(3),
|
|
UploadStatus::InProgress,
|
|
UploadStatus::Completed,
|
|
UploadStatus::Cancelled,
|
|
UploadStatus::Failed("nope".into()),
|
|
];
|
|
for status in &statuses {
|
|
let dto = UploadStatusDto::from(status);
|
|
let json = serde_json::to_string(&dto).unwrap();
|
|
let back: UploadStatus =
|
|
serde_json::from_str::<UploadStatusDto>(&json)
|
|
.unwrap()
|
|
.into();
|
|
assert_eq!(&back, status);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_room_event_survives_the_round_trip() {
|
|
let events = [
|
|
RoomEvent::List(vec![RoomInfo {
|
|
name: "lobby".into(),
|
|
user_count: 42,
|
|
}]),
|
|
RoomEvent::Joined {
|
|
room: "lobby".into(),
|
|
users: vec!["bob".into()],
|
|
},
|
|
RoomEvent::Left {
|
|
room: "lobby".into(),
|
|
},
|
|
RoomEvent::Message {
|
|
room: "lobby".into(),
|
|
username: "bob".into(),
|
|
message: "hi".into(),
|
|
},
|
|
RoomEvent::UserJoined {
|
|
room: "lobby".into(),
|
|
username: "sue".into(),
|
|
},
|
|
RoomEvent::UserLeft {
|
|
room: "lobby".into(),
|
|
username: "sue".into(),
|
|
},
|
|
];
|
|
for event in &events {
|
|
let json =
|
|
serde_json::to_string(&RoomEventDto::from(event)).unwrap();
|
|
let back: RoomEvent =
|
|
serde_json::from_str::<RoomEventDto>(&json).unwrap().into();
|
|
assert_eq!(&back, event);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_partly_answered_user_stays_partly_answered() {
|
|
// "the server has not said" must not arrive as "offline, shares
|
|
// nothing": that is the difference between waiting and giving up.
|
|
let pending = UserInfo::pending("bob".into());
|
|
let json = serde_json::to_string(&UserInfoDto::from(&pending)).unwrap();
|
|
let back: UserInfo =
|
|
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
|
|
assert_eq!(back.username, "bob");
|
|
assert!(back.presence.is_none());
|
|
assert!(back.stats.is_none());
|
|
assert!(!back.is_complete());
|
|
}
|
|
|
|
#[test]
|
|
fn a_complete_user_survives_the_round_trip() {
|
|
let mut info = UserInfo::pending("bob".into());
|
|
info.presence = Some(UserPresence {
|
|
status: UserStatus::Away,
|
|
privileged: true,
|
|
});
|
|
info.stats = Some(UserStats {
|
|
average_speed: 500,
|
|
shared_files: 10,
|
|
shared_folders: 2,
|
|
});
|
|
|
|
let json = serde_json::to_string(&UserInfoDto::from(&info)).unwrap();
|
|
let back: UserInfo =
|
|
serde_json::from_str::<UserInfoDto>(&json).unwrap().into();
|
|
assert_eq!(back, info);
|
|
}
|
|
|
|
#[test]
|
|
fn a_private_message_survives_the_round_trip() {
|
|
let message =
|
|
UserMessage::new(9, 1234, "bob".into(), "hi".into(), true);
|
|
let json =
|
|
serde_json::to_string(&UserMessageDto::from(&message)).unwrap();
|
|
let back: UserMessage = serde_json::from_str::<UserMessageDto>(&json)
|
|
.unwrap()
|
|
.into();
|
|
assert_eq!(back.id(), 9);
|
|
assert_eq!(back.timestamp(), 1234);
|
|
assert_eq!(back.username(), "bob");
|
|
assert_eq!(back.message(), "hi");
|
|
}
|
|
|
|
#[test]
|
|
fn a_shared_listing_survives_the_round_trip() {
|
|
let directory = SharedDirectory {
|
|
name: "@@x\\Music".into(),
|
|
files: vec![("a.mp3".into(), 4096)],
|
|
};
|
|
let json = serde_json::to_string(&SharedDirectoryDto::from(&directory))
|
|
.unwrap();
|
|
let back: SharedDirectory =
|
|
serde_json::from_str::<SharedDirectoryDto>(&json)
|
|
.unwrap()
|
|
.into();
|
|
assert_eq!(back.name, directory.name);
|
|
assert_eq!(back.files, directory.files);
|
|
}
|
|
|
|
#[test]
|
|
fn an_error_carries_the_exit_code_a_local_run_would_have_produced() {
|
|
for exit in [
|
|
Exit::Usage,
|
|
Exit::Connection,
|
|
Exit::NoResults,
|
|
Exit::Timeout,
|
|
Exit::Transfer,
|
|
Exit::SessionLost,
|
|
] {
|
|
let error = RpcError::application(exit, "nope");
|
|
let json = serde_json::to_string(&error).unwrap();
|
|
let back: RpcError = serde_json::from_str(&json).unwrap();
|
|
assert_eq!(back.exit(), exit);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_error_without_an_exit_code_is_a_generic_failure() {
|
|
let error = RpcError::new(CODE_METHOD_NOT_FOUND, "no such method");
|
|
assert_eq!(error.exit(), Exit::Failure);
|
|
}
|
|
|
|
#[test]
|
|
fn a_request_parses_without_optional_fields() {
|
|
let request: Request = serde_json::from_str(
|
|
r#"{"jsonrpc":"2.0","method":"daemon.status"}"#,
|
|
)
|
|
.expect("params and id are optional");
|
|
assert_eq!(request.method, "daemon.status");
|
|
assert!(request.id.is_none());
|
|
assert!(request.params.is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn an_error_always_carries_an_id_even_when_there_was_none() {
|
|
// JSON-RPC requires it, and without it a client following the
|
|
// documented "no id means a notification" rule drops the error and
|
|
// waits out its timeout for a reply that already arrived.
|
|
let json = serde_json::to_string(&Response::failure(
|
|
None,
|
|
RpcError::new(CODE_PARSE, "unparseable"),
|
|
))
|
|
.expect("a response is plain data");
|
|
assert!(json.contains("\"id\":null"), "{json}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_notification_carries_no_id_so_no_reply_is_expected() {
|
|
let notification =
|
|
Response::notification("event.room", serde_json::json!({"a": 1}));
|
|
let json = serde_json::to_string(¬ification).unwrap();
|
|
assert!(!json.contains("\"id\""), "{json}");
|
|
assert!(json.contains("\"method\":\"event.room\""), "{json}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_response_serializes_exactly_one_of_result_and_error() {
|
|
let ok = serde_json::to_string(&Response::result(
|
|
Some(serde_json::json!(1)),
|
|
serde_json::json!({}),
|
|
))
|
|
.unwrap();
|
|
assert!(ok.contains("\"result\""));
|
|
assert!(!ok.contains("\"error\""));
|
|
|
|
let failed = serde_json::to_string(&Response::failure(
|
|
Some(serde_json::json!(1)),
|
|
RpcError::application(Exit::NoResults, "nothing"),
|
|
))
|
|
.unwrap();
|
|
assert!(failed.contains("\"error\""));
|
|
assert!(!failed.contains("\"result\""));
|
|
}
|
|
}
|