From df089fbab31e55ef417d0e5d7b726e52a97c6768 Mon Sep 17 00:00:00 2001 From: Michel de Graaf Date: Mon, 27 Jul 2026 09:56:48 +0200 Subject: [PATCH] feat(daemon): wire protocol with a type-safe method vocabulary and a generated OpenRPC contract Methods and events are enums built by one macro, so every consumer matches exhaustively: adding a member is a compile error until the dispatcher, the published schema, and the remote client all handle it. docs/openrpc.json is derived from the DTOs rather than hand-written, and a test fails the build when the checked-in copy drifts. --- Cargo.lock | 63 + docs/openrpc.json | 1809 +++++++++++++++++++++++++++++ soulseek-rs/Cargo.toml | 1 + soulseek-rs/src/daemon/mod.rs | 7 + soulseek-rs/src/daemon/openrpc.rs | 318 +++++ soulseek-rs/src/daemon/proto.rs | 1280 ++++++++++++++++++++ soulseek-rs/src/lib.rs | 2 + soulseek-rs/src/main.rs | 1 + 8 files changed, 3481 insertions(+) create mode 100644 docs/openrpc.json create mode 100644 soulseek-rs/src/daemon/mod.rs create mode 100644 soulseek-rs/src/daemon/openrpc.rs create mode 100644 soulseek-rs/src/daemon/proto.rs diff --git a/Cargo.lock b/Cargo.lock index 9a1e759..041d018 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -847,6 +847,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.16.0" @@ -2196,6 +2202,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "regex" version = "1.13.1" @@ -2265,6 +2291,31 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2362,6 +2413,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2494,6 +2556,7 @@ dependencies = [ "igd-next", "keyring", "ratatui", + "schemars", "serde", "serde_json", "soulseek-rs-lib", diff --git a/docs/openrpc.json b/docs/openrpc.json new file mode 100644 index 0000000..cbda3bf --- /dev/null +++ b/docs/openrpc.json @@ -0,0 +1,1809 @@ +{ + "components": { + "schemas": { + "Ack": { + "description": "A void result. JSON-RPC allows a null result, but an object leaves room to\nadd a field later without changing the shape a client already parses.", + "properties": { + "ok": { + "type": "boolean" + } + }, + "required": [ + "ok" + ], + "type": "object" + }, + "AuthParams": { + "properties": { + "protocol": { + "default": 0, + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "token": { + "description": "Required on TCP. Omitted on a Unix socket, where the socket's own\npermissions already prove the caller is this user.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AuthResult": { + "properties": { + "daemon_version": { + "type": "string" + }, + "protocol": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "description": "The Soulseek account the daemon is logged in as.", + "type": "string" + } + }, + "required": [ + "protocol", + "daemon_version", + "username" + ], + "type": "object" + }, + "BrowseEvent": { + "description": "A peer answered a browse request. Carried whole rather than as a \"go ask\nfor it\" nudge, because the daemon's own copy is consumed when it drains.", + "properties": { + "directories": { + "items": { + "$ref": "#/components/schemas/SharedDirectoryDto" + }, + "type": "array" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "directories" + ], + "type": "object" + }, + "DaemonStatus": { + "description": "What the daemon is and what its session is doing, from `daemon.status`.", + "properties": { + "clients": { + "description": "Control connections currently attached.", + "format": "uint", + "minimum": 0, + "type": "integer" + }, + "daemon_version": { + "type": "string" + }, + "download_dir": { + "type": "string" + }, + "listen_port": { + "format": "uint16", + "maximum": 65535, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "protocol": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "server": { + "type": "string" + }, + "session_loss": { + "anyOf": [ + { + "$ref": "#/components/schemas/SessionLossDto" + }, + { + "type": "null" + } + ] + }, + "shared_files": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "shared_folders": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "uptime_secs": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "server", + "daemon_version", + "protocol", + "shared_folders", + "shared_files", + "download_dir", + "clients", + "uptime_secs" + ], + "type": "object" + }, + "DirectoriesParams": { + "properties": { + "directories": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "directories" + ], + "type": "object" + }, + "DownloadDto": { + "properties": { + "download_directory": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/DownloadMetadataDto", + "default": {} + }, + "queue_position": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/DownloadStatusDto" + }, + "token": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename", + "token", + "size", + "download_directory", + "status" + ], + "type": "object" + }, + "DownloadMetadataDto": { + "properties": { + "bitrate": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "length_seconds": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "peer_free_slots": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "peer_upload_speed": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "DownloadStartParams": { + "properties": { + "download_directory": { + "description": "Where the file lands *on the daemon's* filesystem. Defaults to the\ndaemon's configured download directory, which is the only sensible\nchoice for a remote caller that cannot see that filesystem.", + "type": [ + "string", + "null" + ] + }, + "filename": { + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/DownloadMetadataDto", + "default": {} + }, + "size": { + "default": 0, + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename" + ], + "type": "object" + }, + "DownloadStarted": { + "properties": { + "download": { + "$ref": "#/components/schemas/DownloadDto" + } + }, + "required": [ + "download" + ], + "type": "object" + }, + "DownloadStatusDto": { + "oneOf": [ + { + "properties": { + "state": { + "const": "queued", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "bytes_downloaded": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "speed_bytes_per_sec": { + "format": "double", + "type": "number" + }, + "state": { + "const": "in_progress", + "type": "string" + }, + "total_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "state", + "bytes_downloaded", + "total_bytes", + "speed_bytes_per_sec" + ], + "type": "object" + }, + { + "properties": { + "bytes_downloaded": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "state": { + "const": "paused", + "type": "string" + }, + "total_bytes": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "state", + "bytes_downloaded", + "total_bytes" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "completed", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "state": { + "const": "failed", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "timed_out", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + } + ] + }, + "DownloadStatusEvent": { + "description": "One step in a transfer this client started.", + "properties": { + "filename": { + "type": "string" + }, + "status": { + "$ref": "#/components/schemas/DownloadStatusDto" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename", + "status" + ], + "type": "object" + }, + "Downloads": { + "properties": { + "downloads": { + "items": { + "$ref": "#/components/schemas/DownloadDto" + }, + "type": "array" + } + }, + "required": [ + "downloads" + ], + "type": "object" + }, + "FileDto": { + "properties": { + "attribs": { + "additionalProperties": false, + "description": "Soulseek attribute codes (0 = bitrate, 1 = duration in seconds).", + "patternProperties": { + "^\\d+$": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "type": "object" + }, + "name": { + "type": "string" + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "size", + "attribs" + ], + "type": "object" + }, + "IntervalSeconds": { + "properties": { + "seconds": { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "seconds" + ], + "type": "object" + }, + "Members": { + "properties": { + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + }, + "MessageParams": { + "properties": { + "message": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "message" + ], + "type": "object" + }, + "Messages": { + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/UserMessageDto" + }, + "type": "array" + } + }, + "required": [ + "messages" + ], + "type": "object" + }, + "QueryParams": { + "properties": { + "query": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "RoomEventDto": { + "oneOf": [ + { + "properties": { + "event": { + "const": "list", + "type": "string" + }, + "rooms": { + "items": { + "$ref": "#/components/schemas/RoomInfoDto" + }, + "type": "array" + } + }, + "required": [ + "event", + "rooms" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "joined", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "left", + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "message", + "type": "string" + }, + "message": { + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "message" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "user_joined", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "user_left", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username" + ], + "type": "object" + } + ] + }, + "RoomInfoDto": { + "properties": { + "name": { + "type": "string" + }, + "user_count": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "name", + "user_count" + ], + "type": "object" + }, + "RoomRef": { + "properties": { + "room": { + "type": "string" + } + }, + "required": [ + "room" + ], + "type": "object" + }, + "SayParams": { + "properties": { + "message": { + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "room", + "message" + ], + "type": "object" + }, + "SearchResultDto": { + "properties": { + "files": { + "items": { + "$ref": "#/components/schemas/FileDto" + }, + "type": "array" + }, + "slots": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "speed": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "token": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "token", + "username", + "slots", + "speed", + "files" + ], + "type": "object" + }, + "SearchResults": { + "properties": { + "results": { + "items": { + "$ref": "#/components/schemas/SearchResultDto" + }, + "type": "array" + } + }, + "required": [ + "results" + ], + "type": "object" + }, + "Seconds": { + "properties": { + "seconds": { + "format": "uint32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "SessionLossDto": { + "enum": [ + "displaced", + "disconnected" + ], + "type": "string" + }, + "SessionLossEvent": { + "properties": { + "loss": { + "$ref": "#/components/schemas/SessionLossDto" + } + }, + "required": [ + "loss" + ], + "type": "object" + }, + "SharedDirectoryDto": { + "properties": { + "files": { + "description": "Basename and size; join with the directory to get a downloadable path.", + "items": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "type": "string" + }, + { + "format": "uint64", + "minimum": 0, + "type": "integer" + } + ], + "type": "array" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "files" + ], + "type": "object" + }, + "SharesStatus": { + "description": "What the share index holds, from `shares.status` / `shares.reindex`.", + "properties": { + "directories": { + "items": { + "type": "string" + }, + "type": "array" + }, + "files": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "folders": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "folders", + "files", + "directories" + ], + "type": "object" + }, + "SlotsParams": { + "properties": { + "slots": { + "format": "uint", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "slots" + ], + "type": "object" + }, + "TransferRef": { + "description": "Names one transfer, which is how both directions are addressed.", + "properties": { + "filename": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename" + ], + "type": "object" + }, + "UploadInfoDto": { + "properties": { + "bytes_sent": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "filename": { + "type": "string" + }, + "size": { + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "speed_bytes_per_sec": { + "format": "double", + "type": "number" + }, + "status": { + "$ref": "#/components/schemas/UploadStatusDto" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "filename", + "size", + "bytes_sent", + "status", + "speed_bytes_per_sec" + ], + "type": "object" + }, + "UploadStatusDto": { + "oneOf": [ + { + "description": "Waiting for a slot, at this 1-based place in the queue.", + "properties": { + "place": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "state": { + "const": "queued", + "type": "string" + } + }, + "required": [ + "state", + "place" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "in_progress", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "completed", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "const": "cancelled", + "type": "string" + } + }, + "required": [ + "state" + ], + "type": "object" + }, + { + "properties": { + "reason": { + "type": "string" + }, + "state": { + "const": "failed", + "type": "string" + } + }, + "required": [ + "state", + "reason" + ], + "type": "object" + } + ] + }, + "Uploads": { + "properties": { + "uploads": { + "items": { + "$ref": "#/components/schemas/UploadInfoDto" + }, + "type": "array" + } + }, + "required": [ + "uploads" + ], + "type": "object" + }, + "UserInfoDto": { + "description": "Each half is `None` until its reply lands, so a consumer can tell \"the\nserver said offline\" from \"the server has not answered yet\".", + "properties": { + "presence": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserPresenceDto" + }, + { + "type": "null" + } + ] + }, + "stats": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserStatsDto" + }, + { + "type": "null" + } + ] + }, + "username": { + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, + "UserMessageDto": { + "properties": { + "id": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "message": { + "type": "string" + }, + "new_message": { + "type": "boolean" + }, + "timestamp": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "username": { + "type": "string" + } + }, + "required": [ + "id", + "timestamp", + "username", + "message", + "new_message" + ], + "type": "object" + }, + "UserPresenceDto": { + "properties": { + "privileged": { + "type": "boolean" + }, + "status": { + "description": "`online`, `away`, or `offline`.", + "type": "string" + } + }, + "required": [ + "status", + "privileged" + ], + "type": "object" + }, + "UserRef": { + "properties": { + "username": { + "type": "string" + } + }, + "required": [ + "username" + ], + "type": "object" + }, + "UserResult": { + "properties": { + "user": { + "anyOf": [ + { + "$ref": "#/components/schemas/UserInfoDto" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "UserStatsDto": { + "properties": { + "average_speed": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "shared_files": { + "format": "uint32", + "minimum": 0, + "type": "integer" + }, + "shared_folders": { + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "average_speed", + "shared_files", + "shared_folders" + ], + "type": "object" + } + } + }, + "info": { + "description": "Control a running soulseek-rs daemon. Newline-delimited JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or TCP (remote, authenticated by the token in /daemon.token). `auth` must be the first request on a connection. Errors carry the CLI exit status in `error.data.exit`. Entries marked `x-notification` are pushed by the daemon and are never requested.", + "license": { + "name": "MIT" + }, + "title": "soulseek-rs daemon", + "version": "12.0.0" + }, + "methods": [ + { + "name": "auth", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/AuthParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/AuthResult" + } + }, + "summary": "Authenticate. Must be the first request on a connection." + }, + { + "name": "daemon.status", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DaemonStatus" + } + }, + "summary": "Who the daemon is logged in as and what its session is doing." + }, + { + "name": "daemon.stop", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the daemon to shut down." + }, + { + "name": "rpc.discover", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "type": "object" + } + }, + "summary": "This document." + }, + { + "name": "search.start", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/QueryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Put a search on the wire and return at once; results accumulate." + }, + { + "name": "search.results", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/QueryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SearchResults" + } + }, + "summary": "Everything that has answered a search so far." + }, + { + "name": "search.wishlist", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/QueryParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Send a query as a wishlist search (server code 103)." + }, + { + "name": "search.wishlist_interval", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/IntervalSeconds" + } + }, + "summary": "How long the server wants between wishlist searches." + }, + { + "name": "download.start", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/DownloadStartParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/DownloadStarted" + } + }, + "summary": "Queue a transfer. Progress arrives as event.download_status." + }, + { + "name": "download.list", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Downloads" + } + }, + "summary": "Every transfer the daemon knows about." + }, + { + "name": "download.pause", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/TransferRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Pause a transfer in progress." + }, + { + "name": "download.resume", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/TransferRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Resume a paused transfer." + }, + { + "name": "download.remove", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/TransferRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Forget a transfer entirely." + }, + { + "name": "download.remove_queued", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/TransferRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Drop a transfer that has not started." + }, + { + "name": "upload.list", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Uploads" + } + }, + "summary": "Uploads being served, active first." + }, + { + "name": "upload.cancel", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/TransferRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Stop serving an upload in progress." + }, + { + "name": "upload.slots", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/SlotsParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "How many uploads may run at once." + }, + { + "name": "privileges.check", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the server for this account's privilege time." + }, + { + "name": "privileges.own", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Seconds" + } + }, + "summary": "Privilege seconds left, or null if the server has not answered." + }, + { + "name": "room.list_request", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the server for the public room list; it arrives as event.room." + }, + { + "name": "room.join", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/RoomRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Join a chat room." + }, + { + "name": "room.leave", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/RoomRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Leave a chat room." + }, + { + "name": "room.say", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/SayParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Post a message to a room." + }, + { + "name": "room.members", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/RoomRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Members" + } + }, + "summary": "Who is currently in a room." + }, + { + "name": "message.send", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/MessageParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Send a private message." + }, + { + "name": "message.history", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Messages" + } + }, + "summary": "Private messages the daemon collected, including while nobody was attached." + }, + { + "name": "browse.user", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask a peer for its share listing; it arrives as event.browse." + }, + { + "name": "user.request", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Ask the server about a user; poll user.info for the answer." + }, + { + "name": "user.info", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserRef" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/UserResult" + } + }, + "summary": "What the server has said about a user so far." + }, + { + "name": "shares.status", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharesStatus" + } + }, + "summary": "What the share index currently holds." + }, + { + "name": "shares.set", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/DirectoriesParams" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharesStatus" + } + }, + "summary": "Replace the shared folders and re-index." + }, + { + "name": "shares.reindex", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharesStatus" + } + }, + "summary": "Re-scan the configured folders." + }, + { + "name": "event.room", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/RoomEventDto" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "Something happened in a room this session has joined.", + "x-notification": true + }, + { + "name": "event.message", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserMessageDto" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "A private message arrived.", + "x-notification": true + }, + { + "name": "event.upload", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/UploadInfoDto" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "An upload changed state.", + "x-notification": true + }, + { + "name": "event.download_status", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/DownloadStatusEvent" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "A transfer moved.", + "x-notification": true + }, + { + "name": "event.browse", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/BrowseEvent" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "A peer answered a browse request.", + "x-notification": true + }, + { + "name": "event.session_loss", + "paramStructure": "by-name", + "params": [ + { + "name": "params", + "required": true, + "schema": { + "$ref": "#/components/schemas/SessionLossEvent" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "null" + } + }, + "summary": "The server session ended; nothing seen after this means anything.", + "x-notification": true + } + ], + "openrpc": "1.3.2" +} diff --git a/soulseek-rs/Cargo.toml b/soulseek-rs/Cargo.toml index 4676414..d1d6c54 100644 --- a/soulseek-rs/Cargo.toml +++ b/soulseek-rs/Cargo.toml @@ -44,4 +44,5 @@ keyring = { version = "3", features = ["apple-native", "windows-native", "async- chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } [dev-dependencies] +schemars = "1.2.1" tempfile = "3" diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs new file mode 100644 index 0000000..3e9c555 --- /dev/null +++ b/soulseek-rs/src/daemon/mod.rs @@ -0,0 +1,7 @@ +//! The resident service: one logged-in session, controlled over a socket. + +/// Generates `docs/openrpc.json` from the wire types and checks it is current. +/// Test-only: the published document is served at runtime by `include_str!`. +#[cfg(test)] +mod openrpc; +pub mod proto; diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs new file mode 100644 index 0000000..8ba9f82 --- /dev/null +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -0,0 +1,318 @@ +//! Generates the published OpenRPC contract from the wire types themselves. +//! +//! `docs/openrpc.json` is what third parties generate clients from, and +//! `rpc.discover` serves it straight out of the binary. Hand-maintaining it +//! would drift the moment a DTO gained a field, so it is derived here from the +//! same structs the daemon actually serializes, and [`document_is_current`] +//! fails the build when the checked-in copy no longer matches. +//! +//! Regenerate with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`. + +use super::proto::{ + Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DownloadStartParams, + DownloadStatusEvent, Downloads, DownloadStarted, Event, IntervalSeconds, + MessageParams, Members, Messages, Method, QueryParams, RoomEventDto, + RoomRef, SayParams, SearchResults, Seconds, SessionLossEvent, SharesStatus, + SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, + UserResult, DirectoriesParams, +}; +use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; +use serde_json::{Value, json}; + +/// Where the generated `$ref`s point. OpenRPC keeps shared schemas under +/// `components/schemas`, the same place OpenAPI does. +const DEFINITIONS_PATH: &str = "#/components/schemas/"; + +/// A method that takes no parameters. +fn nothing() -> Value { + json!({ "type": "null" }) +} + +fn schema_of(generator: &mut SchemaGenerator) -> Value { + generator.subschema_for::().to_value() +} + +/// The parameter schema for one method. +/// +/// Exhaustive on purpose: a new [`Method`] variant does not compile until it is +/// described here, so the published contract cannot fall behind the daemon. +fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { + match method { + Method::Auth => schema_of::(generator), + Method::SearchStart + | Method::SearchResults + | Method::SearchWishlist => schema_of::(generator), + Method::DownloadStart => schema_of::(generator), + Method::DownloadPause + | Method::DownloadResume + | Method::DownloadRemove + | Method::DownloadRemoveQueued + | Method::UploadCancel => schema_of::(generator), + Method::UploadSlots => schema_of::(generator), + Method::RoomJoin | Method::RoomLeave | Method::RoomMembers => { + schema_of::(generator) + } + Method::RoomSay => schema_of::(generator), + Method::MessageSend => schema_of::(generator), + Method::BrowseUser | Method::UserRequest | Method::UserInfoOf => { + schema_of::(generator) + } + Method::SharesSet => schema_of::(generator), + Method::DaemonStatus + | Method::DaemonStop + | Method::RpcDiscover + | Method::SearchWishlistInterval + | Method::DownloadList + | Method::UploadList + | Method::PrivilegesCheck + | Method::PrivilegesOwn + | Method::RoomListRequest + | Method::MessageHistory + | Method::SharesStatusOf + | Method::SharesReindex => nothing(), + } +} + +/// The result schema for one method. Exhaustive for the same reason. +fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { + match method { + Method::Auth => schema_of::(generator), + Method::DaemonStatus => schema_of::(generator), + Method::RpcDiscover => json!({ "type": "object" }), + Method::SearchResults => schema_of::(generator), + Method::SearchWishlistInterval => { + schema_of::(generator) + } + Method::DownloadStart => schema_of::(generator), + Method::DownloadList => schema_of::(generator), + Method::UploadList => schema_of::(generator), + Method::PrivilegesOwn => schema_of::(generator), + Method::RoomMembers => schema_of::(generator), + Method::MessageHistory => schema_of::(generator), + Method::UserInfoOf => schema_of::(generator), + Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => { + schema_of::(generator) + } + // Everything else acknowledges and says nothing more. + Method::DaemonStop + | Method::SearchStart + | Method::SearchWishlist + | Method::DownloadPause + | Method::DownloadResume + | Method::DownloadRemove + | Method::DownloadRemoveQueued + | Method::UploadCancel + | Method::UploadSlots + | Method::PrivilegesCheck + | Method::RoomListRequest + | Method::RoomJoin + | Method::RoomLeave + | Method::RoomSay + | Method::MessageSend + | Method::BrowseUser + | Method::UserRequest => schema_of::(generator), + } +} + +/// What a method does, for the generated documentation. +fn summary(method: Method) -> &'static str { + match method { + Method::Auth => "Authenticate. Must be the first request on a connection.", + Method::DaemonStatus => "Who the daemon is logged in as and what its session is doing.", + Method::DaemonStop => "Ask the daemon to shut down.", + Method::RpcDiscover => "This document.", + Method::SearchStart => "Put a search on the wire and return at once; results accumulate.", + Method::SearchResults => "Everything that has answered a search so far.", + Method::SearchWishlist => "Send a query as a wishlist search (server code 103).", + Method::SearchWishlistInterval => "How long the server wants between wishlist searches.", + Method::DownloadStart => "Queue a transfer. Progress arrives as event.download_status.", + Method::DownloadList => "Every transfer the daemon knows about.", + Method::DownloadPause => "Pause a transfer in progress.", + Method::DownloadResume => "Resume a paused transfer.", + Method::DownloadRemove => "Forget a transfer entirely.", + Method::DownloadRemoveQueued => "Drop a transfer that has not started.", + Method::UploadList => "Uploads being served, active first.", + Method::UploadCancel => "Stop serving an upload in progress.", + Method::UploadSlots => "How many uploads may run at once.", + Method::PrivilegesCheck => "Ask the server for this account's privilege time.", + Method::PrivilegesOwn => "Privilege seconds left, or null if the server has not answered.", + Method::RoomListRequest => "Ask the server for the public room list; it arrives as event.room.", + Method::RoomJoin => "Join a chat room.", + Method::RoomLeave => "Leave a chat room.", + Method::RoomSay => "Post a message to a room.", + Method::RoomMembers => "Who is currently in a room.", + Method::MessageSend => "Send a private message.", + Method::MessageHistory => "Private messages the daemon collected, including while nobody was attached.", + Method::BrowseUser => "Ask a peer for its share listing; it arrives as event.browse.", + Method::UserRequest => "Ask the server about a user; poll user.info for the answer.", + Method::UserInfoOf => "What the server has said about a user so far.", + Method::SharesStatusOf => "What the share index currently holds.", + Method::SharesSet => "Replace the shared folders and re-index.", + Method::SharesReindex => "Re-scan the configured folders.", + } +} + +/// The payload schema for one pushed event. Exhaustive, like the methods. +fn event_schema(event: Event, generator: &mut SchemaGenerator) -> Value { + match event { + Event::Room => schema_of::(generator), + Event::Message => schema_of::(generator), + Event::Upload => schema_of::(generator), + Event::DownloadStatus => schema_of::(generator), + Event::Browse => schema_of::(generator), + Event::SessionLoss => schema_of::(generator), + } +} + +fn event_summary(event: Event) -> &'static str { + match event { + Event::Room => "Something happened in a room this session has joined.", + Event::Message => "A private message arrived.", + Event::Upload => "An upload changed state.", + Event::DownloadStatus => "A transfer moved.", + Event::Browse => "A peer answered a browse request.", + Event::SessionLoss => "The server session ended; nothing seen after this means anything.", + } +} + +/// Build the whole document. +fn document() -> Value { + let settings = SchemaSettings::draft2020_12().with(|settings| { + settings.definitions_path = DEFINITIONS_PATH.into(); + }); + let mut generator = SchemaGenerator::new(settings); + + let methods: Vec = Method::ALL + .iter() + .map(|&method| { + let params = params_schema(method, &mut generator); + let result = result_schema(method, &mut generator); + json!({ + "name": method.wire_name(), + "summary": summary(method), + "paramStructure": "by-name", + "params": if params == json!({ "type": "null" }) { + json!([]) + } else { + json!([{ + "name": "params", + "required": true, + "schema": params, + }]) + }, + "result": { + "name": "result", + "schema": result, + }, + }) + }) + .collect(); + + // OpenRPC has no first-class notion of a server-pushed notification, so + // the events are published as methods flagged with an extension field. + // A generator still emits their payload types; the delivery semantics are + // prose in docs/daemon-protocol.md. + let events: Vec = Event::ALL + .iter() + .map(|&event| { + json!({ + "name": event.wire_name(), + "summary": event_summary(event), + "x-notification": true, + "paramStructure": "by-name", + "params": [{ + "name": "params", + "required": true, + "schema": event_schema(event, &mut generator), + }], + "result": { "name": "result", "schema": { "type": "null" } }, + }) + }) + .collect(); + + let definitions: serde_json::Map = + generator.take_definitions(true).into_iter().collect(); + + json!({ + "openrpc": "1.3.2", + "info": { + "title": "soulseek-rs daemon", + "version": env!("CARGO_PKG_VERSION"), + "description": "Control a running soulseek-rs daemon. Newline-delimited \ +JSON-RPC 2.0 over a Unix socket (local, authenticated by socket permissions) or \ +TCP (remote, authenticated by the token in /daemon.token). `auth` must \ +be the first request on a connection. Errors carry the CLI exit status in \ +`error.data.exit`. Entries marked `x-notification` are pushed by the daemon and \ +are never requested.", + "license": { "name": "MIT" }, + }, + "methods": methods.into_iter().chain(events).collect::>(), + "components": { "schemas": definitions }, + }) +} + +/// Where the published document lives, relative to this source file. +fn document_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("docs") + .join("openrpc.json") +} + +#[test] +fn document_is_current() { + let generated = format!( + "{}\n", + serde_json::to_string_pretty(&document()) + .expect("the document is plain data") + ); + + let path = document_path(); + if std::env::var_os("UPDATE_OPENRPC").is_some() { + std::fs::write(&path, &generated).expect("docs/ is writable"); + return; + } + + let published = std::fs::read_to_string(&path).unwrap_or_default(); + assert_eq!( + published, generated, + "docs/openrpc.json is out of date with the wire types — regenerate it \ + with `UPDATE_OPENRPC=1 cargo test -p soulseek-rs openrpc`" + ); +} + +#[test] +fn every_method_and_event_is_published() { + let document = document(); + let published: Vec<&str> = document["methods"] + .as_array() + .expect("methods is an array") + .iter() + .map(|method| method["name"].as_str().expect("a name is a string")) + .collect(); + + for method in Method::ALL { + assert!( + published.contains(&method.wire_name()), + "{method} is missing from the published contract" + ); + } + for event in Event::ALL { + assert!( + published.contains(&event.wire_name()), + "{event} is missing from the published contract" + ); + } + assert_eq!(published.len(), Method::ALL.len() + Event::ALL.len()); +} + +#[test] +fn a_wire_name_round_trips_through_its_enum() { + for method in Method::ALL { + assert_eq!(Method::from_wire(method.wire_name()), Some(*method)); + } + for event in Event::ALL { + assert_eq!(Event::from_wire(event.wire_name()), Some(*event)); + } + assert_eq!(Method::from_wire("search.nope"), None); +} diff --git a/soulseek-rs/src/daemon/proto.rs b/soulseek-rs/src/daemon/proto.rs new file mode 100644 index 0000000..9f46e1d --- /dev/null +++ b/soulseek-rs/src/daemon/proto.rs @@ -0,0 +1,1280 @@ +//! 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. + +use crate::output::Exit; +use serde::{Deserialize, Serialize}; +use soulseek_rs::types::{ + Download, DownloadMetadata, UserPresence, UserStats, UserStatus, +}; +use soulseek_rs::{ + DownloadStatus, File, RoomEvent, RoomInfo, SearchResult, SessionLoss, + SharedDirectory, UploadInfo, UploadStatus, UserInfo, UserMessage, +}; +use std::collections::HashMap; + +/// 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"); + +// --------------------------------------------------------------------------- +// JSON-RPC envelope +// --------------------------------------------------------------------------- + +/// A request from a client. `id` is absent for a notification, which by +/// JSON-RPC rule gets no reply. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Request { + #[serde(default = "jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub method: String, + #[serde(default)] + pub params: serde_json::Value, +} + +/// A reply. Exactly one of `result`/`error` is set; `id` echoes the request, +/// and is absent on a daemon-initiated notification. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Response { + #[serde(default = "jsonrpc_version")] + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +fn jsonrpc_version() -> String { + "2.0".to_string() +} + +/// A failure, carrying the exit code the same command would have produced +/// locally so a remote run exits exactly as a local one does. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RpcError { + pub code: i32, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ErrorData { + /// The `Exit` discriminant this failure maps to. + pub exit: u8, +} + +/// Application errors all share one code; the exit status in `data` is what a +/// caller branches on. +pub const CODE_APPLICATION: i32 = -32000; +pub const CODE_PARSE: i32 = -32700; +pub const CODE_INVALID_REQUEST: i32 = -32600; +pub const CODE_METHOD_NOT_FOUND: i32 = -32601; +pub const CODE_INVALID_PARAMS: i32 = -32602; + +impl RpcError { + #[must_use] + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + /// An application failure that should reproduce `exit` on the client. + #[must_use] + pub fn application(exit: Exit, message: impl Into) -> Self { + Self { + code: CODE_APPLICATION, + message: message.into(), + data: Some(ErrorData { exit: exit.code() }), + } + } + + /// The exit status this error asks the client to produce, defaulting to a + /// generic failure when the daemon did not say. + #[must_use] + pub fn exit(&self) -> Exit { + match self.data.as_ref().map(|data| data.exit) { + Some(0) => Exit::Ok, + Some(2) => Exit::Usage, + Some(3) => Exit::Connection, + Some(4) => Exit::NoResults, + Some(5) => Exit::Timeout, + Some(6) => Exit::Transfer, + Some(7) => Exit::SessionLost, + _ => Exit::Failure, + } + } +} + +impl Response { + #[must_use] + pub fn result(id: Option, value: serde_json::Value) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id, + method: None, + params: None, + result: Some(value), + error: None, + } + } + + #[must_use] + pub fn failure(id: Option, error: RpcError) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id, + method: None, + params: None, + result: None, + error: Some(error), + } + } + + /// A daemon-initiated event: no id, so no reply is expected. + #[must_use] + pub fn notification(method: &str, params: serde_json::Value) -> Self { + Self { + jsonrpc: jsonrpc_version(), + id: None, + method: Some(method.to_string()), + params: Some(params), + result: None, + error: None, + } + } +} + +// --------------------------------------------------------------------------- +// Handshake +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct AuthParams { + /// Required on TCP. Omitted on a Unix socket, where the socket's own + /// permissions already prove the caller is this user. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(default)] + pub protocol: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct AuthResult { + pub protocol: u32, + pub daemon_version: String, + /// The Soulseek account the daemon is logged in as. + pub username: String, +} + +// --------------------------------------------------------------------------- +// Data types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct FileDto { + pub name: String, + pub size: u64, + /// Soulseek attribute codes (0 = bitrate, 1 = duration in seconds). + pub attribs: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SearchResultDto { + pub token: u32, + pub username: String, + pub slots: u8, + pub speed: u32, + pub files: Vec, +} + +impl From<&SearchResult> for SearchResultDto { + fn from(result: &SearchResult) -> Self { + Self { + token: result.token, + username: result.username.clone(), + slots: result.slots, + speed: result.speed, + files: result + .files + .iter() + .map(|file| FileDto { + name: file.name.clone(), + size: file.size, + attribs: file.attribs.clone(), + }) + .collect(), + } + } +} + +impl From for SearchResult { + fn from(dto: SearchResultDto) -> Self { + Self { + token: dto.token, + slots: dto.slots, + speed: dto.speed, + files: dto + .files + .into_iter() + .map(|file| File { + username: dto.username.clone(), + name: file.name, + size: file.size, + attribs: file.attribs, + }) + .collect(), + username: dto.username, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum DownloadStatusDto { + Queued, + InProgress { + bytes_downloaded: u64, + total_bytes: u64, + speed_bytes_per_sec: f64, + }, + Paused { + bytes_downloaded: u64, + total_bytes: u64, + }, + Completed, + Failed { + reason: Option, + }, + TimedOut, +} + +impl From<&DownloadStatus> for DownloadStatusDto { + fn from(status: &DownloadStatus) -> Self { + match status { + DownloadStatus::Queued => Self::Queued, + DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => Self::InProgress { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + speed_bytes_per_sec: *speed_bytes_per_sec, + }, + DownloadStatus::Paused { + bytes_downloaded, + total_bytes, + } => Self::Paused { + bytes_downloaded: *bytes_downloaded, + total_bytes: *total_bytes, + }, + DownloadStatus::Completed => Self::Completed, + DownloadStatus::Failed(reason) => Self::Failed { + reason: reason.clone(), + }, + DownloadStatus::TimedOut => Self::TimedOut, + } + } +} + +impl From for DownloadStatus { + fn from(dto: DownloadStatusDto) -> Self { + match dto { + DownloadStatusDto::Queued => Self::Queued, + DownloadStatusDto::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + } => Self::InProgress { + bytes_downloaded, + total_bytes, + speed_bytes_per_sec, + }, + DownloadStatusDto::Paused { + bytes_downloaded, + total_bytes, + } => Self::Paused { + bytes_downloaded, + total_bytes, + }, + DownloadStatusDto::Completed => Self::Completed, + DownloadStatusDto::Failed { reason } => Self::Failed(reason), + DownloadStatusDto::TimedOut => Self::TimedOut, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadMetadataDto { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bitrate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub length_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub peer_upload_speed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub peer_free_slots: Option, +} + +impl From<&DownloadMetadata> for DownloadMetadataDto { + fn from(metadata: &DownloadMetadata) -> Self { + Self { + bitrate: metadata.bitrate, + length_seconds: metadata.length_seconds, + peer_upload_speed: metadata.peer_upload_speed, + peer_free_slots: metadata.peer_free_slots, + } + } +} + +impl From for DownloadMetadata { + fn from(dto: DownloadMetadataDto) -> Self { + Self { + bitrate: dto.bitrate, + length_seconds: dto.length_seconds, + peer_upload_speed: dto.peer_upload_speed, + peer_free_slots: dto.peer_free_slots, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadDto { + pub username: String, + pub filename: String, + pub token: u32, + pub size: u64, + pub download_directory: String, + pub status: DownloadStatusDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub queue_position: Option, + #[serde(default)] + pub metadata: DownloadMetadataDto, +} + +impl From<&Download> for DownloadDto { + fn from(download: &Download) -> Self { + Self { + username: download.username.clone(), + filename: download.filename.clone(), + token: download.token, + size: download.size, + download_directory: download.download_directory.clone(), + status: DownloadStatusDto::from(&download.status), + queue_position: download.queue_position, + metadata: DownloadMetadataDto::from(&download.metadata), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum UploadStatusDto { + /// Waiting for a slot, at this 1-based place in the queue. + Queued { place: u32 }, + InProgress, + Completed, + Cancelled, + Failed { reason: String }, +} + +impl From<&UploadStatus> for UploadStatusDto { + fn from(status: &UploadStatus) -> Self { + match status { + UploadStatus::Queued(place) => Self::Queued { place: *place }, + UploadStatus::InProgress => Self::InProgress, + UploadStatus::Completed => Self::Completed, + UploadStatus::Cancelled => Self::Cancelled, + UploadStatus::Failed(reason) => Self::Failed { + reason: reason.clone(), + }, + } + } +} + +impl From for UploadStatus { + fn from(dto: UploadStatusDto) -> Self { + match dto { + UploadStatusDto::Queued { place } => Self::Queued(place), + UploadStatusDto::InProgress => Self::InProgress, + UploadStatusDto::Completed => Self::Completed, + UploadStatusDto::Cancelled => Self::Cancelled, + UploadStatusDto::Failed { reason } => Self::Failed(reason), + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UploadInfoDto { + pub username: String, + pub filename: String, + pub size: u64, + pub bytes_sent: u64, + pub status: UploadStatusDto, + pub speed_bytes_per_sec: f64, +} + +impl From<&UploadInfo> for UploadInfoDto { + fn from(upload: &UploadInfo) -> Self { + Self { + username: upload.username.clone(), + filename: upload.filename.clone(), + size: upload.size, + bytes_sent: upload.bytes_sent, + status: UploadStatusDto::from(&upload.status), + speed_bytes_per_sec: upload.speed_bytes_per_sec, + } + } +} + +impl From for UploadInfo { + fn from(dto: UploadInfoDto) -> Self { + Self { + username: dto.username, + filename: dto.filename, + size: dto.size, + bytes_sent: dto.bytes_sent, + status: dto.status.into(), + speed_bytes_per_sec: dto.speed_bytes_per_sec, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomInfoDto { + pub name: String, + pub user_count: u32, +} + +impl From<&RoomInfo> for RoomInfoDto { + fn from(room: &RoomInfo) -> Self { + Self { + name: room.name.clone(), + user_count: room.user_count, + } + } +} + +impl From for RoomInfo { + fn from(dto: RoomInfoDto) -> Self { + Self { + name: dto.name, + user_count: dto.user_count, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum RoomEventDto { + List { + rooms: Vec, + }, + Joined { + room: String, + users: Vec, + }, + Left { + room: String, + }, + Message { + room: String, + username: String, + message: String, + }, + UserJoined { + room: String, + username: String, + }, + UserLeft { + room: String, + username: String, + }, +} + +impl From<&RoomEvent> for RoomEventDto { + fn from(event: &RoomEvent) -> Self { + match event { + RoomEvent::List(rooms) => Self::List { + rooms: rooms.iter().map(RoomInfoDto::from).collect(), + }, + RoomEvent::Joined { room, users } => Self::Joined { + room: room.clone(), + users: users.clone(), + }, + RoomEvent::Left { room } => Self::Left { room: room.clone() }, + RoomEvent::Message { + room, + username, + message, + } => Self::Message { + room: room.clone(), + username: username.clone(), + message: message.clone(), + }, + RoomEvent::UserJoined { room, username } => Self::UserJoined { + room: room.clone(), + username: username.clone(), + }, + RoomEvent::UserLeft { room, username } => Self::UserLeft { + room: room.clone(), + username: username.clone(), + }, + } + } +} + +impl From for RoomEvent { + fn from(dto: RoomEventDto) -> Self { + match dto { + RoomEventDto::List { rooms } => { + Self::List(rooms.into_iter().map(RoomInfo::from).collect()) + } + RoomEventDto::Joined { room, users } => Self::Joined { room, users }, + RoomEventDto::Left { room } => Self::Left { room }, + RoomEventDto::Message { + room, + username, + message, + } => Self::Message { + room, + username, + message, + }, + RoomEventDto::UserJoined { room, username } => { + Self::UserJoined { room, username } + } + RoomEventDto::UserLeft { room, username } => { + Self::UserLeft { room, username } + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserPresenceDto { + /// `online`, `away`, or `offline`. + pub status: String, + pub privileged: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserStatsDto { + pub average_speed: u32, + pub shared_files: u32, + pub shared_folders: u32, +} + +/// Each half is `None` until its reply lands, so a consumer can tell "the +/// server said offline" from "the server has not answered yet". +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserInfoDto { + pub username: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub presence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stats: Option, +} + +impl From<&UserInfo> for UserInfoDto { + fn from(info: &UserInfo) -> Self { + Self { + username: info.username.clone(), + presence: info.presence.map(|presence| UserPresenceDto { + status: presence.status.to_string(), + privileged: presence.privileged, + }), + stats: info.stats.map(|stats| UserStatsDto { + average_speed: stats.average_speed, + shared_files: stats.shared_files, + shared_folders: stats.shared_folders, + }), + } + } +} + +impl From for UserInfo { + fn from(dto: UserInfoDto) -> Self { + let mut info = Self::pending(dto.username); + info.presence = dto.presence.map(|presence| UserPresence { + status: match presence.status.as_str() { + "online" => UserStatus::Online, + "away" => UserStatus::Away, + _ => UserStatus::Offline, + }, + privileged: presence.privileged, + }); + info.stats = dto.stats.map(|stats| UserStats { + average_speed: stats.average_speed, + shared_files: stats.shared_files, + shared_folders: stats.shared_folders, + }); + info + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedDirectoryDto { + pub name: String, + /// Basename and size; join with the directory to get a downloadable path. + pub files: Vec<(String, u64)>, +} + +impl From<&SharedDirectory> for SharedDirectoryDto { + fn from(directory: &SharedDirectory) -> Self { + Self { + name: directory.name.clone(), + files: directory.files.clone(), + } + } +} + +impl From for SharedDirectory { + fn from(dto: SharedDirectoryDto) -> Self { + Self { + name: dto.name, + files: dto.files, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserMessageDto { + pub id: u32, + pub timestamp: u32, + pub username: String, + pub message: String, + pub new_message: bool, +} + +impl From<&UserMessage> for UserMessageDto { + fn from(message: &UserMessage) -> Self { + Self { + id: message.id(), + timestamp: message.timestamp(), + username: message.username().to_string(), + message: message.message().to_string(), + new_message: message.is_new(), + } + } +} + +impl From for UserMessage { + fn from(dto: UserMessageDto) -> Self { + Self::new( + dto.id, + dto.timestamp, + dto.username, + dto.message, + dto.new_message, + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +#[serde(rename_all = "snake_case")] +pub enum SessionLossDto { + Displaced, + Disconnected, +} + +impl From for SessionLossDto { + fn from(loss: SessionLoss) -> Self { + match loss { + SessionLoss::Displaced => Self::Displaced, + SessionLoss::Disconnected => Self::Disconnected, + } + } +} + +impl From for SessionLoss { + fn from(dto: SessionLossDto) -> Self { + match dto { + SessionLossDto::Displaced => Self::Displaced, + SessionLossDto::Disconnected => Self::Disconnected, + } + } +} + +/// What the daemon is and what its session is doing, from `daemon.status`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DaemonStatus { + pub username: String, + pub server: String, + pub daemon_version: String, + pub protocol: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub listen_port: Option, + pub shared_folders: u32, + pub shared_files: u32, + pub download_dir: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_loss: Option, + /// Control connections currently attached. + pub clients: usize, + pub uptime_secs: u64, +} + +/// What the share index holds, from `shares.status` / `shares.reindex`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharesStatus { + pub folders: u32, + pub files: u32, + pub directories: Vec, +} + +// --------------------------------------------------------------------------- +// Method parameters +// --------------------------------------------------------------------------- + +/// A void result. JSON-RPC allows a null result, but an object leaves room to +/// add a field later without changing the shape a client already parses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Ack { + pub ok: bool, +} + +impl Ack { + pub const OK: Self = Self { ok: true }; +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct QueryParams { + pub query: String, +} + +/// Names one transfer, which is how both directions are addressed. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct TransferRef { + pub username: String, + pub filename: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomRef { + pub room: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserRef { + pub username: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SayParams { + pub room: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct MessageParams { + pub username: String, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStartParams { + pub username: String, + pub filename: String, + #[serde(default)] + pub size: u64, + /// Where the file lands *on the daemon's* filesystem. Defaults to the + /// daemon's configured download directory, which is the only sensible + /// choice for a remote caller that cannot see that filesystem. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub download_directory: Option, + #[serde(default)] + pub metadata: DownloadMetadataDto, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SlotsParams { + pub slots: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DirectoriesParams { + pub directories: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SearchResults { + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Downloads { + pub downloads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStarted { + pub download: DownloadDto, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Uploads { + pub uploads: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Members { + pub users: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Messages { + pub messages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct UserResult { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct Seconds { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct IntervalSeconds { + pub seconds: u64, +} + +// --------------------------------------------------------------------------- +// Event notifications +// --------------------------------------------------------------------------- + +/// 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 { + 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", + 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", + } +} + +/// A peer answered a browse request. Carried whole rather than as a "go ask +/// for it" nudge, because the daemon's own copy is consumed when it drains. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct BrowseEvent { + pub username: String, + pub directories: Vec, +} + +/// One step in a transfer this client started. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct DownloadStatusEvent { + pub username: String, + pub filename: String, + pub status: DownloadStatusDto, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SessionLossEvent { + pub loss: SessionLossDto, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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::(&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::(&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::(&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::(&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::(&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::(&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::(&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 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\"")); + } +} diff --git a/soulseek-rs/src/lib.rs b/soulseek-rs/src/lib.rs index 1ad8b30..db24d15 100644 --- a/soulseek-rs/src/lib.rs +++ b/soulseek-rs/src/lib.rs @@ -3,5 +3,7 @@ pub mod api; pub mod cli; +pub mod daemon; pub mod models; +pub mod output; pub mod persist; diff --git a/soulseek-rs/src/main.rs b/soulseek-rs/src/main.rs index 2a008d4..ffc4cfe 100644 --- a/soulseek-rs/src/main.rs +++ b/soulseek-rs/src/main.rs @@ -1,6 +1,7 @@ mod api; mod cli; mod commands; +mod daemon; mod directories; mod models; mod output;