modernize: migrate to Leptos 0.8 and Server Functions architecture, break backend->shared loop
Some checks failed
Build MIPS Binary / build (push) Failing after 1m27s

This commit is contained in:
spinline
2026-02-09 20:07:28 +03:00
parent 5a8f5169ea
commit e6d00e9d55
11 changed files with 1026 additions and 384 deletions

View File

@@ -1,8 +1,32 @@
[package]
name = "shared"
version = "0.1.0"
edition = "2024"
edition = "2021"
[features]
default = []
ssr = [
"dep:tokio",
"dep:bytes",
"dep:thiserror",
"dep:quick-xml",
"dep:leptos_axum",
"leptos/ssr",
"leptos_router/ssr",
]
hydrate = ["leptos/hydrate"]
[dependencies]
serde = { version = "1.0.228", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
utoipa = { version = "5.4.0", features = ["axum_extras"] }
# Leptos 0.8.7
leptos = { version = "0.8.7", features = ["nightly"] }
leptos_router = { version = "0.8.7", features = ["nightly"] }
leptos_axum = { version = "0.8.7", optional = true }
# SSR Dependencies (XML-RPC & SCGI)
tokio = { version = "1", features = ["full"], optional = true }
bytes = { version = "1", optional = true }
thiserror = { version = "2", optional = true }
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }

View File

@@ -1,6 +1,14 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[cfg(feature = "ssr")]
pub mod scgi;
#[cfg(feature = "ssr")]
pub mod xmlrpc;
pub mod server_fns;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
pub struct Torrent {
pub hash: String,
@@ -135,4 +143,4 @@ pub struct SetLabelRequest {
pub struct AddTorrentRequest {
#[schema(example = "magnet:?xt=urn:btih:...")]
pub uri: String,
}
}

129
shared/src/scgi.rs Normal file
View File

@@ -0,0 +1,129 @@
#![cfg(feature = "ssr")]
use bytes::Bytes;
use std::collections::HashMap;
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
#[derive(Error, Debug)]
pub enum ScgiError {
#[error("IO Error: {0}")]
Io(#[from] std::io::Error),
#[allow(dead_code)]
#[error("Protocol Error: {0}")]
Protocol(String),
#[error("Timeout: SCGI request took too long")]
Timeout,
}
pub struct ScgiRequest {
headers: HashMap<String, String>,
body: Vec<u8>,
}
impl ScgiRequest {
pub fn new() -> Self {
let mut headers = HashMap::new();
headers.insert("SCGI".to_string(), "1".to_string());
Self {
headers,
body: Vec::new(),
}
}
pub fn _header(mut self, key: &str, value: &str) -> Self {
self.headers.insert(key.to_string(), value.to_string());
self
}
pub fn body(mut self, body: Vec<u8>) -> Self {
self.body = body;
self.headers
.insert("CONTENT_LENGTH".to_string(), self.body.len().to_string());
self
}
pub fn encode(&self) -> Vec<u8> {
let mut headers_data = Vec::new();
let content_len = self.body.len().to_string();
headers_data.extend_from_slice(b"CONTENT_LENGTH");
headers_data.push(0);
headers_data.extend_from_slice(content_len.as_bytes());
headers_data.push(0);
headers_data.extend_from_slice(b"SCGI");
headers_data.push(0);
headers_data.extend_from_slice(b"1");
headers_data.push(0);
for (k, v) in &self.headers {
if k == "CONTENT_LENGTH" || k == "SCGI" {
continue;
}
headers_data.extend_from_slice(k.as_bytes());
headers_data.push(0);
headers_data.extend_from_slice(v.as_bytes());
headers_data.push(0);
}
let headers_len = headers_data.len();
let mut packet = Vec::new();
let len_str = headers_len.to_string();
packet.extend_from_slice(len_str.as_bytes());
packet.push(b':');
packet.extend(headers_data);
packet.push(b',');
packet.extend(&self.body);
packet
}
}
pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode();
stream.write_all(&data).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
Ok::<Vec<u8>, std::io::Error>(response)
};
let response = tokio::time::timeout(std::time::Duration::from_secs(10), perform_request)
.await
.map_err(|_| ScgiError::Timeout)??;
let mut response_vec = response;
// Improved header stripping: find the first occurrence of "<?xml" OR double newline
let patterns = [
&b"\r\n\r\n"[..],
&b"\n\n"[..],
&b"<?xml"[..] // If headers are missing or weird, find start of XML
];
let mut found_pos = None;
for (i, pattern) in patterns.iter().enumerate() {
if let Some(pos) = response_vec
.windows(pattern.len())
.position(|window| window == *pattern)
{
// For XML pattern, we keep it. For newlines, we skip them.
if i == 2 {
found_pos = Some(pos);
} else {
found_pos = Some(pos + pattern.len());
}
break;
}
}
if let Some(pos) = found_pos {
Ok(Bytes::from(response_vec.split_off(pos)))
} else {
Ok(Bytes::from(response_vec))
}
}

View File

@@ -0,0 +1,26 @@
use leptos::*;
use leptos::prelude::*;
#[cfg(feature = "ssr")]
use crate::xmlrpc::{self, RtorrentClient};
#[server(GetVersion, "/api/server_fns")]
pub async fn get_version() -> Result<String, ServerFnError> {
let socket_path = std::env::var("RTORRENT_SOCKET").unwrap_or_else(|_| "/tmp/rtorrent.sock".to_string());
#[cfg(feature = "ssr")]
{
let client = RtorrentClient::new(&socket_path);
match client.call("system.client_version", &[]).await {
Ok(xml) => {
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
Ok(version)
},
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
}
}
#[cfg(not(feature = "ssr"))]
{
unreachable!()
}
}

351
shared/src/xmlrpc.rs Normal file
View File

@@ -0,0 +1,351 @@
#![cfg(feature = "ssr")]
use crate::scgi::{send_request, ScgiError, ScgiRequest};
use quick_xml::de::from_str;
use quick_xml::se::to_string;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum XmlRpcError {
#[error("SCGI Error: {0}")]
Scgi(#[from] ScgiError),
#[error("Serialization Error: {0}")]
Serialization(String),
#[error("Deserialization Error: {0}")]
Deserialization(#[from] quick_xml::de::DeError),
#[error("XML Parse Error: {0}")]
Parse(String),
}
// --- Request Parameters Enum ---
#[derive(Debug, Clone)]
pub enum RpcParam {
String(String),
Int(i64),
}
impl From<&str> for RpcParam {
fn from(s: &str) -> Self {
RpcParam::String(s.to_string())
}
}
impl From<String> for RpcParam {
fn from(s: String) -> Self {
RpcParam::String(s)
}
}
impl From<i64> for RpcParam {
fn from(i: i64) -> Self {
RpcParam::Int(i)
}
}
impl From<i32> for RpcParam {
fn from(i: i32) -> Self {
RpcParam::Int(i as i64)
}
}
// --- Request Models ---
#[derive(Debug, Serialize)]
#[serde(rename = "methodCall")]
struct MethodCall<'a> {
#[serde(rename = "methodName")]
method_name: &'a str,
params: RequestParams<'a>,
}
#[derive(Debug, Serialize)]
struct RequestParams<'a> {
param: Vec<RequestParam<'a>>,
}
#[derive(Debug, Serialize)]
struct RequestParam<'a> {
value: RequestValueInner<'a>,
}
#[derive(Debug, Serialize)]
struct RequestValueInner<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
string: Option<&'a str>,
// rTorrent standard is often i4 (32-bit signed int). i8 might not be supported by all XML-RPC libs.
// Casting i64 to i32 is safe for typical speed limits.
#[serde(skip_serializing_if = "Option::is_none")]
i4: Option<i32>,
}
// --- Response Models for d.multicall2 ---
#[derive(Debug, Deserialize)]
#[serde(rename = "methodResponse")]
struct MulticallResponse {
params: MulticallResponseParams,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseParams {
param: MulticallResponseParam,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseParam {
value: MulticallResponseValueArray,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseValueArray {
array: MulticallResponseDataOuter,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataOuter {
data: MulticallResponseDataOuterValue,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataOuterValue {
#[serde(rename = "value", default)]
values: Vec<MulticallRowValue>,
}
#[derive(Debug, Deserialize)]
struct MulticallRowValue {
array: MulticallResponseDataInner,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataInner {
data: MulticallResponseDataInnerValue,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataInnerValue {
#[serde(rename = "value", default)]
values: Vec<MulticallItemValue>,
}
#[derive(Debug, Deserialize)]
struct MulticallItemValue {
#[serde(rename = "string", default)]
string: Option<String>,
#[serde(rename = "i4", default)]
i4: Option<i64>,
#[serde(rename = "i8", default)]
i8: Option<i64>,
}
impl MulticallItemValue {
fn to_string_lossy(&self) -> String {
if let Some(s) = &self.string {
s.clone()
} else if let Some(i) = self.i4 {
i.to_string()
} else if let Some(i) = self.i8 {
i.to_string()
} else {
String::new()
}
}
}
// --- Response Model for simple string ---
#[derive(Debug, Deserialize)]
#[serde(rename = "methodResponse")]
struct StringResponse {
params: StringResponseParams,
}
#[derive(Debug, Deserialize)]
struct StringResponseParams {
param: StringResponseParam,
}
#[derive(Debug, Deserialize)]
struct StringResponseParam {
value: StringResponseValue,
}
#[derive(Debug, Deserialize)]
struct StringResponseValue {
string: String,
}
// --- Response Model for simple integer (i8/i4) ---
#[derive(Debug, Deserialize)]
#[serde(rename = "methodResponse")]
struct IntegerResponse {
params: IntegerResponseParams,
}
#[derive(Debug, Deserialize)]
struct IntegerResponseParams {
param: IntegerResponseParam,
}
#[derive(Debug, Deserialize)]
struct IntegerResponseParam {
value: IntegerResponseValue,
}
#[derive(Debug, Deserialize)]
struct IntegerResponseValue {
#[serde(rename = "i8", default)]
i8: Option<i64>,
#[serde(rename = "i4", default)]
i4: Option<i64>,
#[serde(rename = "string", default)]
string: Option<String>,
}
// --- Client Implementation ---
pub struct RtorrentClient {
socket_path: String,
}
impl RtorrentClient {
pub fn new(socket_path: &str) -> Self {
Self {
socket_path: socket_path.to_string(),
}
}
/// Helper to build and serialize XML-RPC method call
fn build_method_call(&self, method: &str, params: &[RpcParam]) -> Result<String, XmlRpcError> {
let req_params = RequestParams {
param: params
.iter()
.map(|p| RequestParam {
value: match p {
RpcParam::String(s) => RequestValueInner {
string: Some(s),
i4: None,
},
RpcParam::Int(i) => RequestValueInner {
string: None,
i4: Some(*i as i32),
},
},
})
.collect(),
};
let call = MethodCall {
method_name: method,
params: req_params,
};
let xml_body = to_string(&call).map_err(|e| XmlRpcError::Serialization(e.to_string()))?;
Ok(format!("<?xml version=\"1.0\"?>\n{}", xml_body))
}
pub async fn call(&self, method: &str, params: &[RpcParam]) -> Result<String, XmlRpcError> {
let xml = self.build_method_call(method, params)?;
let req = ScgiRequest::new().body(xml.into_bytes());
let bytes = send_request(&self.socket_path, req).await?;
let s = String::from_utf8_lossy(&bytes).to_string();
Ok(s)
}
}
pub fn parse_multicall_response(xml: &str) -> Result<Vec<Vec<String>>, XmlRpcError> {
let response: MulticallResponse = from_str(xml)?;
let mut result = Vec::new();
for row in response.params.param.value.array.data.values {
let mut row_vec = Vec::new();
for item in row.array.data.values {
row_vec.push(item.to_string_lossy());
}
result.push(row_vec);
}
Ok(result)
}
pub fn parse_string_response(xml: &str) -> Result<String, XmlRpcError> {
let response: StringResponse = from_str(xml)?;
Ok(response.params.param.value.string)
}
pub fn parse_i64_response(xml: &str) -> Result<i64, XmlRpcError> {
let response: IntegerResponse = from_str(xml)?;
if let Some(val) = response.params.param.value.i8 {
Ok(val)
} else if let Some(val) = response.params.param.value.i4 {
Ok(val)
} else if let Some(ref s) = response.params.param.value.string {
s.parse()
.map_err(|_| XmlRpcError::Parse("Not an integer string".to_string()))
} else {
Err(XmlRpcError::Parse("No integer value found".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_method_call() {
let client = RtorrentClient::new("dummy");
let params = vec![
RpcParam::String("".to_string()),
RpcParam::String("main".to_string()),
RpcParam::String("d.name=".to_string()),
];
let xml = client.build_method_call("d.multicall2", &params).unwrap();
assert!(xml.contains("<methodName>d.multicall2</methodName>"));
assert!(xml.contains("<value><string>main</string></value>"));
}
#[test]
fn test_build_method_call_int() {
let client = RtorrentClient::new("dummy");
let params = vec![RpcParam::Int(1024)];
let xml = client.build_method_call("test.int", &params).unwrap();
// Should produce <value><i4>1024</i4></value>
assert!(xml.contains("<i4>1024</i4>"));
}
#[test]
fn test_parse_multicall_response() {
let xml = r#"<methodResponse>
<params>
<param>
<value>
<array>
<data>
<value>
<array>
<data>
<value><string>HASH123</string></value>
<value><string>Ubuntu ISO</string></value>
<value><i4>1024</i4></value>
</data>
</array>
</value>
</data>
</array>
</value>
</param>
</params>
</methodResponse>
"#;
let result = parse_multicall_response(xml).expect("Failed to parse");
assert_eq!(result.len(), 1);
assert_eq!(result[0][0], "HASH123");
assert_eq!(result[0][1], "Ubuntu ISO");
assert_eq!(result[0][2], "1024");
}
}