Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "soulseek-client"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soulseek-proto = { path = "../soulseek-proto" }
|
||||
tokio.workspace = true
|
||||
tokio-util = "0.7"
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
igd-next = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
env_logger = "0.11"
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Live citizenship test: share our downloaded albums, join the distributed
|
||||
//! network, and report every search we answer for real peers.
|
||||
//!
|
||||
//! cargo run -p soulseek-client --example citizen
|
||||
|
||||
use soulseek_client::{Client, Config, Event};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let suffix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos()
|
||||
% 100_000;
|
||||
let username = format!("ssplusshare{suffix}");
|
||||
|
||||
let music = dirs_music().join("SoulSeek+");
|
||||
let mut config = Config::default();
|
||||
config.shared_folders = vec![music.clone()];
|
||||
config.upload_slots = 4;
|
||||
config.description = "SoulSeek+ dev share".into();
|
||||
|
||||
println!("sharing {}…", music.display());
|
||||
let (client, mut events) = Client::connect(config).await.expect("connect");
|
||||
let (dirs, files) = client.share_counts();
|
||||
println!("indexed {files} files in {dirs} folders");
|
||||
|
||||
client
|
||||
.login(&username, "ssplusdev-throwaway")
|
||||
.await
|
||||
.expect("login");
|
||||
println!("logged in as {username}; joining network, waiting for searches to answer…\n");
|
||||
|
||||
let mut answered = 0usize;
|
||||
let mut uploads = 0usize;
|
||||
let start = tokio::time::Instant::now();
|
||||
let deadline = start + std::time::Duration::from_secs(180);
|
||||
let mut next_tick = start + std::time::Duration::from_secs(20);
|
||||
loop {
|
||||
let ev = tokio::select! {
|
||||
ev = events.recv() => ev,
|
||||
_ = tokio::time::sleep_until(next_tick) => {
|
||||
next_tick += std::time::Duration::from_secs(20);
|
||||
println!(
|
||||
" … {}s: parent={}, {} searches seen, {answered} answered",
|
||||
start.elapsed().as_secs(),
|
||||
client.has_parent(),
|
||||
client.searches_seen(),
|
||||
);
|
||||
if tokio::time::Instant::now() >= deadline { break; }
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match ev {
|
||||
Some(Event::RespondedToSearch { username, files }) => {
|
||||
answered += 1;
|
||||
if answered <= 25 {
|
||||
println!(" ✓ answered {username} with {files} file(s)");
|
||||
}
|
||||
}
|
||||
Some(Event::Upload(u)) => {
|
||||
uploads += 1;
|
||||
println!(" ⬆ upload to {}: {:?} {}", u.username, u.state, u.filename);
|
||||
}
|
||||
Some(Event::Disconnected { reason }) => {
|
||||
println!("disconnected: {reason}");
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
println!(
|
||||
"\nRESULT: {} searches seen, {answered} answered, {uploads} upload events",
|
||||
client.searches_seen(),
|
||||
);
|
||||
client.shutdown();
|
||||
}
|
||||
|
||||
fn dirs_music() -> std::path::PathBuf {
|
||||
// %USERPROFILE%\Music
|
||||
let profile = std::env::var("USERPROFILE").unwrap_or_else(|_| ".".into());
|
||||
std::path::PathBuf::from(profile).join("Music")
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Live-network download smoke test: search, pick the best folder,
|
||||
//! download its files.
|
||||
//!
|
||||
//! cargo run -p soulseek-client --example download -- "search terms" [max_files]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use soulseek_client::{Client, Config, DownloadState, Event};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let query = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "daft punk discovery".into());
|
||||
let max_files: usize = std::env::args()
|
||||
.nth(2)
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(2);
|
||||
|
||||
let suffix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos()
|
||||
% 100_000;
|
||||
let username = format!("ssplusdev{suffix}");
|
||||
|
||||
let mut config = Config::default();
|
||||
config.downloads_dir = std::env::temp_dir().join("ssplus-download-test");
|
||||
|
||||
let (client, mut events) = Client::connect(config).await.expect("connect");
|
||||
client
|
||||
.login(&username, "ssplusdev-throwaway")
|
||||
.await
|
||||
.expect("login");
|
||||
println!("logged in as {username}");
|
||||
|
||||
let token = client.search(&query).expect("search");
|
||||
println!("searching {query:?}…");
|
||||
|
||||
// Collect responses for 12s, pick the best free-slot folder.
|
||||
let mut best: Option<(u64, soulseek_proto::types::SearchResponse)> = None;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(12);
|
||||
loop {
|
||||
let ev = tokio::select! {
|
||||
ev = events.recv() => ev,
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
};
|
||||
if let Some(Event::SearchResult { response, .. }) = ev {
|
||||
if !response.has_free_upload_slot || response.files.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let score = response.upload_speed as u64;
|
||||
if best.as_ref().map(|(s, _)| score > *s).unwrap_or(true) {
|
||||
best = Some((score, response));
|
||||
}
|
||||
}
|
||||
}
|
||||
client.stop_search(token);
|
||||
|
||||
let Some((_, pick)) = best else {
|
||||
println!("RESULT: no suitable candidates");
|
||||
return;
|
||||
};
|
||||
println!(
|
||||
"picked {} ({} files, speed {})",
|
||||
pick.username,
|
||||
pick.files.len(),
|
||||
pick.upload_speed
|
||||
);
|
||||
|
||||
let mut pending: HashMap<u64, String> = HashMap::new();
|
||||
for f in pick.files.iter().take(max_files) {
|
||||
let id = client.download(&pick.username, &f.filename, f.size);
|
||||
println!(
|
||||
" queued #{id}: {}",
|
||||
f.filename.rsplit('\\').next().unwrap()
|
||||
);
|
||||
pending.insert(id, f.filename.clone());
|
||||
}
|
||||
|
||||
let overall = tokio::time::Instant::now() + std::time::Duration::from_secs(180);
|
||||
let mut ok = 0;
|
||||
while !pending.is_empty() {
|
||||
let ev = tokio::select! {
|
||||
ev = events.recv() => ev,
|
||||
_ = tokio::time::sleep_until(overall) => { println!("TIMEOUT"); break; }
|
||||
};
|
||||
match ev {
|
||||
Some(Event::Download(u)) => match &u.state {
|
||||
DownloadState::Complete { local_path } => {
|
||||
println!(" ✔ #{} complete → {local_path}", u.id);
|
||||
pending.remove(&u.id);
|
||||
ok += 1;
|
||||
}
|
||||
DownloadState::Failed { reason } => {
|
||||
println!(" ✘ #{} failed: {reason}", u.id);
|
||||
pending.remove(&u.id);
|
||||
}
|
||||
DownloadState::Transferring if u.total > 0 => {
|
||||
println!(
|
||||
" … #{} {:.0}% ({}/{} bytes, {} KB/s)",
|
||||
u.id,
|
||||
u.received as f64 / u.total as f64 * 100.0,
|
||||
u.received,
|
||||
u.total,
|
||||
u.speed_bps / 1000
|
||||
);
|
||||
}
|
||||
s => println!(" · #{} {:?}", u.id, s),
|
||||
},
|
||||
Some(Event::Disconnected { reason }) => {
|
||||
println!("disconnected: {reason}");
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
println!("RESULT: {ok} downloads completed");
|
||||
client.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Smoke test against the live Soulseek network:
|
||||
//! login with a throwaway account, run one search, print results.
|
||||
//!
|
||||
//! cargo run -p soulseek-client --example probe -- "search terms"
|
||||
|
||||
use soulseek_client::{Client, Config, Event};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
let query = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "daft punk discovery".into());
|
||||
|
||||
let suffix = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos()
|
||||
% 100_000;
|
||||
let username = format!("ssplusdev{suffix}");
|
||||
|
||||
println!("connecting…");
|
||||
let (client, mut events) = Client::connect(Config::default()).await.expect("connect");
|
||||
println!("listener on port {}", client.listen_port());
|
||||
|
||||
match client.login(&username, "ssplusdev-throwaway").await {
|
||||
Ok(greeting) => println!("logged in as {username}: {}", greeting.trim()),
|
||||
Err(e) => {
|
||||
eprintln!("login failed: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let token = client.search(&query).expect("search");
|
||||
println!("searching for {query:?} (token {token}) — collecting for 25s…\n");
|
||||
|
||||
let mut responses = 0usize;
|
||||
let mut files = 0usize;
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(25);
|
||||
|
||||
loop {
|
||||
let ev = tokio::select! {
|
||||
ev = events.recv() => ev,
|
||||
_ = tokio::time::sleep_until(deadline) => break,
|
||||
};
|
||||
match ev {
|
||||
Some(Event::SearchResult { response, .. }) => {
|
||||
responses += 1;
|
||||
files += response.files.len();
|
||||
if responses <= 5 {
|
||||
let f = &response.files[0];
|
||||
println!(
|
||||
" [{}] {} files, e.g. {} ({} MB{}) slots={} speed={}",
|
||||
response.username,
|
||||
response.files.len(),
|
||||
f.filename.rsplit('\\').next().unwrap_or(&f.filename),
|
||||
f.size / 1_000_000,
|
||||
f.bitrate()
|
||||
.map(|b| format!(", {b} kbps"))
|
||||
.unwrap_or_default(),
|
||||
response.has_free_upload_slot,
|
||||
response.upload_speed,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(Event::Disconnected { reason }) => {
|
||||
println!("disconnected: {reason}");
|
||||
break;
|
||||
}
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nRESULT: {responses} responses, {files} files");
|
||||
client.shutdown();
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Two-instance upload test: instance A shares a known file; instance B
|
||||
//! downloads it from A. Verifies the upload responder + F-connection path.
|
||||
//!
|
||||
//! cargo run -p soulseek-client --example upload_test
|
||||
|
||||
use soulseek_client::{Client, Config, DownloadState, Event};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
// Build a share source with one real audio file.
|
||||
let src = std::env::temp_dir().join("ssplus-upload-src");
|
||||
let album = src.join("TestAlbum");
|
||||
std::fs::create_dir_all(&album).unwrap();
|
||||
let sample = find_sample().expect("need a downloaded audio file to share");
|
||||
let shared_file = album.join("sample.flac");
|
||||
std::fs::copy(&sample, &shared_file).unwrap();
|
||||
let size = std::fs::metadata(&shared_file).unwrap().len();
|
||||
let remote_path = "ssplus-upload-src\\TestAlbum\\sample.flac".to_string();
|
||||
println!("A shares {remote_path} ({size} bytes)");
|
||||
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.subsec_nanos()
|
||||
% 100_000;
|
||||
let user_a = format!("ssplusA{stamp}");
|
||||
let user_b = format!("ssplusB{stamp}");
|
||||
|
||||
// Instance A: sharer/uploader.
|
||||
let mut cfg_a = Config::default();
|
||||
cfg_a.shared_folders = vec![src.clone()];
|
||||
cfg_a.listen_port = 51300;
|
||||
let (client_a, mut events_a) = Client::connect(cfg_a).await.expect("A connect");
|
||||
client_a.login(&user_a, "pw").await.expect("A login");
|
||||
println!("A logged in as {user_a}");
|
||||
|
||||
// Spawn a task to log A's upload events.
|
||||
tokio::spawn(async move {
|
||||
while let Some(ev) = events_a.recv().await {
|
||||
if let Event::Upload(u) = ev {
|
||||
println!(
|
||||
" A upload → {}: {:?} {}/{}",
|
||||
u.username, u.state, u.sent, u.total
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Instance B: downloader. Different listen port to coexist on one host.
|
||||
let mut cfg_b = Config::default();
|
||||
cfg_b.listen_port = 52300;
|
||||
cfg_b.downloads_dir = std::env::temp_dir().join("ssplus-upload-dst");
|
||||
let _ = std::fs::remove_dir_all(&cfg_b.downloads_dir);
|
||||
let (client_b, mut events_b) = Client::connect(cfg_b).await.expect("B connect");
|
||||
client_b.login(&user_b, "pw").await.expect("B login");
|
||||
println!("B logged in as {user_b}");
|
||||
|
||||
// Both instances share this host's public IP, so the server-provided
|
||||
// address would need NAT hairpinning. Seed loopback endpoints so the P
|
||||
// and F connections go over localhost instead.
|
||||
client_b.set_peer_address(&user_a, "127.0.0.1:51300".parse().unwrap());
|
||||
client_a.set_peer_address(&user_b, "127.0.0.1:52300".parse().unwrap());
|
||||
|
||||
// Give the server a moment to register both listen ports.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let id = client_b.download(&user_a, &remote_path, size);
|
||||
println!("B requested download #{id} from A…\n");
|
||||
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(90);
|
||||
loop {
|
||||
let ev = tokio::select! {
|
||||
ev = events_b.recv() => ev,
|
||||
_ = tokio::time::sleep_until(deadline) => { println!("TIMEOUT"); break; }
|
||||
};
|
||||
match ev {
|
||||
Some(Event::Download(u)) => match &u.state {
|
||||
DownloadState::Complete { local_path } => {
|
||||
let got = std::fs::metadata(local_path).map(|m| m.len()).unwrap_or(0);
|
||||
println!("\nB COMPLETE: {local_path} ({got}/{size} bytes)");
|
||||
println!(
|
||||
"RESULT: {}",
|
||||
if got == size {
|
||||
"UPLOAD VERIFIED ✓"
|
||||
} else {
|
||||
"size mismatch ✗"
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
DownloadState::Failed { reason } => {
|
||||
println!("\nB FAILED: {reason}");
|
||||
println!("RESULT: upload not verified (likely NAT hairpin on same host)");
|
||||
break;
|
||||
}
|
||||
other => println!(" B: {:?} {}/{}", other, u.received, u.total),
|
||||
},
|
||||
Some(_) => {}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
client_a.shutdown();
|
||||
client_b.shutdown();
|
||||
}
|
||||
|
||||
/// Find any downloaded audio file to use as the shared sample.
|
||||
fn find_sample() -> Option<PathBuf> {
|
||||
let root = PathBuf::from(std::env::var("USERPROFILE").ok()?)
|
||||
.join("Music")
|
||||
.join("SoulSeek+");
|
||||
fn walk(dir: &std::path::Path) -> Option<PathBuf> {
|
||||
for e in std::fs::read_dir(dir).ok()?.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
if let Some(f) = walk(&p) {
|
||||
return Some(f);
|
||||
}
|
||||
} else if p.extension().and_then(|x| x.to_str()) == Some("flac") {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
walk(&root)
|
||||
}
|
||||
@@ -0,0 +1,668 @@
|
||||
//! Connection plumbing: server socket IO, peer listener, peer message
|
||||
//! loops, indirect (PierceFirewall) connections, and transfer-connection
|
||||
//! establishment in all four direct/indirect × inbound/outbound flavors.
|
||||
|
||||
use bytes::Bytes;
|
||||
use std::net::SocketAddrV4;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use soulseek_proto::{peer, server, MAX_MESSAGE_SIZE};
|
||||
|
||||
use crate::{ClientError, Event, Inner, PeerHandle, Result, TransferAck};
|
||||
|
||||
pub(crate) const PEER_CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
pub(crate) const INDIRECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Idle cutoff for peer message connections.
|
||||
const PEER_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const SERVER_PING_INTERVAL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Read one length-prefixed frame; returns the frame body (code + payload).
|
||||
pub(crate) async fn read_frame<R: AsyncReadExt + Unpin>(
|
||||
stream: &mut R,
|
||||
) -> std::io::Result<Option<Bytes>> {
|
||||
let mut len_buf = [0u8; 4];
|
||||
match stream.read_exact(&mut len_buf).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
if len == 0 || len > MAX_MESSAGE_SIZE {
|
||||
return Err(std::io::Error::other(format!("bad frame length {len}")));
|
||||
}
|
||||
let mut body = vec![0u8; len];
|
||||
stream.read_exact(&mut body).await?;
|
||||
Ok(Some(Bytes::from(body)))
|
||||
}
|
||||
|
||||
pub(crate) async fn bind_listener(preferred_port: u16) -> std::io::Result<TcpListener> {
|
||||
// Walk forward a few ports if the preferred one is taken.
|
||||
for offset in 0..10 {
|
||||
match TcpListener::bind(("0.0.0.0", preferred_port + offset)).await {
|
||||
Ok(l) => return Ok(l),
|
||||
Err(_) if offset < 9 => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- server IO
|
||||
|
||||
pub(crate) fn spawn_server_io(
|
||||
stream: TcpStream,
|
||||
mut outbound: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
mut shutdown: mpsc::UnboundedReceiver<()>,
|
||||
inner: Arc<Inner>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let (mut rd, mut wr) = stream.into_split();
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(frame) = outbound.recv().await {
|
||||
if wr.write_all(&frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let ping_inner = inner.clone();
|
||||
let ping = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(SERVER_PING_INTERVAL).await;
|
||||
if ping_inner.send_server(server::ping()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let reason = loop {
|
||||
let frame = tokio::select! {
|
||||
f = read_frame(&mut rd) => f,
|
||||
_ = shutdown.recv() => break "shutdown requested".to_string(),
|
||||
};
|
||||
match frame {
|
||||
Ok(Some(body)) => handle_server_frame(&inner, body),
|
||||
Ok(None) => break "server closed the connection".to_string(),
|
||||
Err(e) => break format!("server read error: {e}"),
|
||||
}
|
||||
};
|
||||
|
||||
writer.abort();
|
||||
ping.abort();
|
||||
let _ = inner.event_tx.send(Event::Disconnected { reason });
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_server_frame(inner: &Arc<Inner>, mut body: Bytes) {
|
||||
use bytes::Buf;
|
||||
if body.len() < 4 {
|
||||
return;
|
||||
}
|
||||
let code = body.get_u32_le();
|
||||
|
||||
let event = match server::decode(code, body) {
|
||||
Ok(ev) => ev,
|
||||
Err(e) => {
|
||||
log::warn!("failed to decode server message {code}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
server::ServerEvent::LoginOk {
|
||||
greeting, own_ip, ..
|
||||
} => {
|
||||
if let Some(tx) = inner.pending_login.lock().unwrap().take() {
|
||||
let _ = tx.send(Ok(greeting.clone()));
|
||||
}
|
||||
let _ = inner.event_tx.send(Event::LoggedIn { greeting, own_ip });
|
||||
}
|
||||
server::ServerEvent::LoginFailed { reason } => {
|
||||
if let Some(tx) = inner.pending_login.lock().unwrap().take() {
|
||||
let _ = tx.send(Err(reason.clone()));
|
||||
}
|
||||
let _ = inner.event_tx.send(Event::LoginFailed { reason });
|
||||
}
|
||||
server::ServerEvent::UserAddress { username, addr } => {
|
||||
if addr.ip().is_unspecified() || addr.port() == 0 {
|
||||
// Offline; let the waiters time out.
|
||||
inner.pending_addresses.lock().unwrap().remove(&username);
|
||||
return;
|
||||
}
|
||||
inner
|
||||
.address_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(username.clone(), addr);
|
||||
if let Some(waiters) = inner.pending_addresses.lock().unwrap().remove(&username) {
|
||||
for w in waiters {
|
||||
let _ = w.send(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
server::ServerEvent::ConnectToPeer {
|
||||
username,
|
||||
conn_type,
|
||||
addr,
|
||||
token,
|
||||
..
|
||||
} => {
|
||||
// A peer that can't reach us directly asked the server to relay:
|
||||
// we connect out and pierce with their token.
|
||||
let inner = inner.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = match conn_type.as_str() {
|
||||
peer::CONN_TYPE_PEER => {
|
||||
pierce_message_connection(inner, addr, token, username.clone()).await
|
||||
}
|
||||
peer::CONN_TYPE_TRANSFER => {
|
||||
pierce_transfer_connection(inner, addr, token, username.clone()).await
|
||||
}
|
||||
_ => Ok(()), // "D" (distributed) lands in M5.
|
||||
};
|
||||
if let Err(e) = result {
|
||||
log::debug!("pierce ({conn_type}) to {username} at {addr} failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
server::ServerEvent::ExcludedSearchPhrases(phrases) => {
|
||||
*inner.excluded_phrases.write().unwrap() = phrases.clone();
|
||||
let _ = inner.event_tx.send(Event::ExcludedSearchPhrases(phrases));
|
||||
}
|
||||
server::ServerEvent::NetInfo(parents) => {
|
||||
crate::distributed::on_net_info(inner.clone(), parents);
|
||||
}
|
||||
server::ServerEvent::ServerSearch {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
} => {
|
||||
crate::responder::respond_to_search(inner.clone(), username, token, query);
|
||||
}
|
||||
server::ServerEvent::Embedded(bytes) => {
|
||||
// Server-wrapped distributed search.
|
||||
if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
}) = soulseek_proto::dist::decode(bytes)
|
||||
{
|
||||
crate::responder::respond_to_search(inner.clone(), username, token, query);
|
||||
}
|
||||
}
|
||||
server::ServerEvent::CannotConnect { .. } | server::ServerEvent::Unhandled { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- listener
|
||||
|
||||
pub(crate) fn spawn_listener(
|
||||
listener: TcpListener,
|
||||
inner: Arc<Inner>,
|
||||
mut shutdown: mpsc::UnboundedReceiver<()>,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// On shutdown, break so `listener` drops and its port is freed
|
||||
// — otherwise every reconnect leaks a bound port (WSAEADDRINUSE).
|
||||
_ = shutdown.recv() => {
|
||||
log::debug!("listener shutting down, releasing port");
|
||||
break;
|
||||
}
|
||||
res = listener.accept() => match res {
|
||||
Ok((stream, addr)) => {
|
||||
let inner = inner.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_incoming(stream, inner).await {
|
||||
log::debug!("peer {addr} dropped: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("listener accept failed: {e}");
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Incoming connection: first frame identifies the peer and connection type.
|
||||
async fn handle_incoming(mut stream: TcpStream, inner: Arc<Inner>) -> std::io::Result<()> {
|
||||
stream.set_nodelay(true).ok();
|
||||
let first = tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("init timeout"))??;
|
||||
let Some(first) = first else { return Ok(()) };
|
||||
|
||||
match peer::decode_init(first) {
|
||||
Ok(peer::InitMessage::PeerInit {
|
||||
conn_type,
|
||||
username,
|
||||
..
|
||||
}) => {
|
||||
match conn_type.as_str() {
|
||||
peer::CONN_TYPE_PEER => run_peer_connection(stream, inner, username).await,
|
||||
peer::CONN_TYPE_TRANSFER => {
|
||||
// Inbound transfer connection: the peer identifies the
|
||||
// transfer with 4 raw token bytes next.
|
||||
let mut tok = [0u8; 4];
|
||||
tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("token timeout"))??;
|
||||
let token = u32::from_le_bytes(tok);
|
||||
deliver_transfer_connection(&inner, &username, token, stream);
|
||||
Ok(())
|
||||
}
|
||||
peer::CONN_TYPE_DISTRIBUTED => {
|
||||
// A parent connected to us directly; run the child loop.
|
||||
run_incoming_distributed(stream, inner).await
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
Ok(peer::InitMessage::PierceFirewall { token }) => {
|
||||
// Answer to one of our indirect solicitations (P or F).
|
||||
let waiter = inner.pending_pierce.lock().unwrap().remove(&token);
|
||||
if let Some(tx) = waiter {
|
||||
let _ = tx.send(stream);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A distributed parent connected to us directly. Read its relayed
|
||||
/// searches and answer them (same logic as an outbound parent).
|
||||
async fn run_incoming_distributed(mut stream: TcpStream, inner: Arc<Inner>) -> std::io::Result<()> {
|
||||
inner
|
||||
.has_parent
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let result = async {
|
||||
loop {
|
||||
let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(&mut stream)).await
|
||||
{
|
||||
Ok(f) => f?,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
let Some(frame) = frame else { return Ok(()) };
|
||||
if let Ok(soulseek_proto::dist::DistributedEvent::SearchRequest {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
}) = soulseek_proto::dist::decode(frame)
|
||||
{
|
||||
crate::responder::respond_to_search(inner.clone(), username, token, query);
|
||||
}
|
||||
}
|
||||
}
|
||||
.await;
|
||||
inner
|
||||
.has_parent
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
result
|
||||
}
|
||||
|
||||
/// Hand an established inbound transfer connection to the download that
|
||||
/// expects it.
|
||||
fn deliver_transfer_connection(inner: &Arc<Inner>, username: &str, token: u32, stream: TcpStream) {
|
||||
let expected = inner
|
||||
.expected_transfers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(username.to_string(), token));
|
||||
match expected {
|
||||
Some(tx) => {
|
||||
let _ = tx.send(stream);
|
||||
}
|
||||
None => {
|
||||
log::debug!("unexpected transfer connection from {username} token {token}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------- indirect (pierce) roles
|
||||
|
||||
/// Server told us a peer wants a message connection: connect + pierce, then
|
||||
/// treat like any peer message connection.
|
||||
async fn pierce_message_connection(
|
||||
inner: Arc<Inner>,
|
||||
addr: SocketAddrV4,
|
||||
token: u32,
|
||||
username: String,
|
||||
) -> std::io::Result<()> {
|
||||
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("connect timeout"))??;
|
||||
stream.set_nodelay(true).ok();
|
||||
stream.write_all(&peer::pierce_firewall(token)).await?;
|
||||
run_peer_connection(stream, inner, username).await
|
||||
}
|
||||
|
||||
/// Server told us a peer wants a transfer connection with us: connect,
|
||||
/// pierce, read the 4-byte remote token, hand off to the waiting download.
|
||||
async fn pierce_transfer_connection(
|
||||
inner: Arc<Inner>,
|
||||
addr: SocketAddrV4,
|
||||
token: u32,
|
||||
username: String,
|
||||
) -> std::io::Result<()> {
|
||||
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("connect timeout"))??;
|
||||
stream.set_nodelay(true).ok();
|
||||
stream.write_all(&peer::pierce_firewall(token)).await?;
|
||||
|
||||
let mut tok = [0u8; 4];
|
||||
tokio::time::timeout(PEER_CONNECT_TIMEOUT, stream.read_exact(&mut tok))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("token timeout"))??;
|
||||
let remote_token = u32::from_le_bytes(tok);
|
||||
deliver_transfer_connection(&inner, &username, remote_token, stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -------------------------------------------------- peer message connection
|
||||
|
||||
/// Run a peer message connection: writer drains a queue, reader routes
|
||||
/// messages. Registers in the peers map for reuse; deregisters on exit.
|
||||
async fn run_peer_connection(
|
||||
stream: TcpStream,
|
||||
inner: Arc<Inner>,
|
||||
username: String,
|
||||
) -> std::io::Result<()> {
|
||||
let (mut rd, mut wr) = stream.into_split();
|
||||
let (write_tx, mut write_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
|
||||
inner.peers.lock().unwrap().insert(
|
||||
username.clone(),
|
||||
PeerHandle {
|
||||
write_tx: write_tx.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(frame) = write_rx.recv().await {
|
||||
if wr.write_all(&frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = peer_message_loop(&mut rd, &inner, &username).await;
|
||||
|
||||
// Only deregister if the map still points at this connection.
|
||||
{
|
||||
let mut peers = inner.peers.lock().unwrap();
|
||||
if let Some(handle) = peers.get(&username) {
|
||||
if handle.write_tx.same_channel(&write_tx) {
|
||||
peers.remove(&username);
|
||||
}
|
||||
}
|
||||
}
|
||||
writer.abort();
|
||||
result
|
||||
}
|
||||
|
||||
async fn peer_message_loop(
|
||||
rd: &mut tokio::net::tcp::OwnedReadHalf,
|
||||
inner: &Arc<Inner>,
|
||||
username: &str,
|
||||
) -> std::io::Result<()> {
|
||||
loop {
|
||||
let frame = match tokio::time::timeout(PEER_READ_TIMEOUT, read_frame(rd)).await {
|
||||
Ok(f) => f?,
|
||||
Err(_) => return Ok(()), // idle
|
||||
};
|
||||
let Some(mut body) = frame else { return Ok(()) };
|
||||
|
||||
use bytes::Buf;
|
||||
if body.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let code = body.get_u32_le();
|
||||
|
||||
match peer::decode(code, body) {
|
||||
Ok(ev) => route_peer_event(inner, username, ev),
|
||||
Err(e) => log::debug!("bad peer message {code} from {username}: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn route_peer_event(inner: &Arc<Inner>, username: &str, ev: peer::PeerEvent) {
|
||||
use peer::PeerEvent::*;
|
||||
match ev {
|
||||
SearchResponse(resp) => {
|
||||
let wanted = inner.active_searches.lock().unwrap().contains(&resp.token);
|
||||
if wanted && !resp.files.is_empty() {
|
||||
let _ = inner.event_tx.send(Event::SearchResult {
|
||||
token: resp.token,
|
||||
response: resp,
|
||||
});
|
||||
}
|
||||
}
|
||||
TransferResponse {
|
||||
token,
|
||||
allowed,
|
||||
size,
|
||||
message,
|
||||
} => {
|
||||
let waiter = inner
|
||||
.transfer_waits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(username.to_string(), token));
|
||||
if let Some(tx) = waiter {
|
||||
let _ = tx.send(TransferAck {
|
||||
allowed,
|
||||
size,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
TransferRequest {
|
||||
direction,
|
||||
token,
|
||||
filename,
|
||||
size,
|
||||
} => {
|
||||
if direction == peer::DIRECTION_UPLOAD {
|
||||
let waiter = inner
|
||||
.upload_offers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(username.to_string(), filename.clone()));
|
||||
match waiter {
|
||||
Some(tx) => {
|
||||
let _ = tx.send(Ok((token, size)));
|
||||
}
|
||||
None => log::debug!("unsolicited upload offer from {username}: {filename}"),
|
||||
}
|
||||
} else {
|
||||
// Their request to download one of our files → we upload.
|
||||
crate::upload::on_transfer_request(
|
||||
inner.clone(),
|
||||
username.to_string(),
|
||||
token,
|
||||
filename,
|
||||
);
|
||||
}
|
||||
}
|
||||
QueueDownload { filename } => {
|
||||
crate::upload::on_queue_download(inner.clone(), username.to_string(), filename);
|
||||
}
|
||||
PlaceInQueueRequest { filename } => {
|
||||
crate::upload::on_place_query(inner, username, &filename);
|
||||
}
|
||||
BrowseRequest => {
|
||||
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
||||
crate::responder::respond_browse(inner, h);
|
||||
}
|
||||
}
|
||||
UserInfoRequest => {
|
||||
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
||||
crate::responder::respond_user_info(inner, h);
|
||||
}
|
||||
}
|
||||
FolderContentsRequest { token, folder } => {
|
||||
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
||||
crate::responder::respond_folder_contents(inner, h, token, &folder);
|
||||
}
|
||||
}
|
||||
UploadDenied { filename, reason } => {
|
||||
let waiter = inner
|
||||
.upload_offers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(username.to_string(), filename));
|
||||
if let Some(tx) = waiter {
|
||||
let _ = tx.send(Err(reason));
|
||||
}
|
||||
}
|
||||
UploadFailed { filename } => {
|
||||
let waiter = inner
|
||||
.upload_offers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(username.to_string(), filename));
|
||||
if let Some(tx) = waiter {
|
||||
let _ = tx.send(Err("remote upload failed".into()));
|
||||
}
|
||||
}
|
||||
PlaceInQueueResponse { filename, place } => {
|
||||
crate::transfer::update_queue_place(inner, username, &filename, place);
|
||||
}
|
||||
Unhandled { code } => log::trace!("unhandled peer message {code} from {username}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------- outbound peer connections
|
||||
|
||||
/// Resolve a user's endpoint via the server (with cache).
|
||||
pub(crate) async fn get_peer_address(inner: &Arc<Inner>, username: &str) -> Result<SocketAddrV4> {
|
||||
if let Some(addr) = inner.address_cache.lock().unwrap().get(username) {
|
||||
return Ok(*addr);
|
||||
}
|
||||
let (tx, rx) = oneshot::channel();
|
||||
inner
|
||||
.pending_addresses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(username.to_string())
|
||||
.or_default()
|
||||
.push(tx);
|
||||
inner.send_server(server::get_peer_address(username))?;
|
||||
|
||||
tokio::time::timeout(Duration::from_secs(10), rx)
|
||||
.await
|
||||
.map_err(|_| ClientError::UserOffline(username.to_string()))?
|
||||
.map_err(|_| ClientError::UserOffline(username.to_string()))
|
||||
}
|
||||
|
||||
/// Get (or establish) a message connection to a user. Tries direct first,
|
||||
/// then falls back to a server-relayed indirect connection.
|
||||
pub(crate) async fn get_or_connect_peer(inner: &Arc<Inner>, username: &str) -> Result<PeerHandle> {
|
||||
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
||||
return Ok(h.clone());
|
||||
}
|
||||
|
||||
let addr = get_peer_address(inner, username).await?;
|
||||
|
||||
// Direct attempt.
|
||||
let direct = async {
|
||||
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("connect timeout"))??;
|
||||
stream.set_nodelay(true).ok();
|
||||
let init = peer::peer_init(
|
||||
&inner.own_username(),
|
||||
peer::CONN_TYPE_PEER,
|
||||
inner.next_token(),
|
||||
);
|
||||
stream.write_all(&init).await?;
|
||||
Ok::<TcpStream, std::io::Error>(stream)
|
||||
};
|
||||
|
||||
let stream = match direct.await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::debug!("direct connect to {username} failed ({e}); trying indirect");
|
||||
indirect_connect(inner, username, peer::CONN_TYPE_PEER).await?
|
||||
}
|
||||
};
|
||||
|
||||
let inner2 = inner.clone();
|
||||
let username2 = username.to_string();
|
||||
tokio::spawn(async move {
|
||||
let _ = run_peer_connection(stream, inner2, username2).await;
|
||||
});
|
||||
|
||||
// The handle is registered synchronously at the top of
|
||||
// run_peer_connection, but the spawn may not have run yet — poll briefly.
|
||||
for _ in 0..50 {
|
||||
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
||||
return Ok(h.clone());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Err(ClientError::PeerUnreachable(username.to_string()))
|
||||
}
|
||||
|
||||
/// Ask the server to have `username` connect to us; await the pierce.
|
||||
pub(crate) async fn indirect_connect(
|
||||
inner: &Arc<Inner>,
|
||||
username: &str,
|
||||
conn_type: &str,
|
||||
) -> Result<TcpStream> {
|
||||
let token = inner.next_token();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
inner.pending_pierce.lock().unwrap().insert(token, tx);
|
||||
inner.send_server(server::connect_to_peer(token, username, conn_type))?;
|
||||
|
||||
let out = tokio::time::timeout(INDIRECT_TIMEOUT, rx).await;
|
||||
inner.pending_pierce.lock().unwrap().remove(&token);
|
||||
out.map_err(|_| ClientError::PeerUnreachable(username.to_string()))?
|
||||
.map_err(|_| ClientError::PeerUnreachable(username.to_string()))
|
||||
}
|
||||
|
||||
/// Establish an outbound transfer ("F") connection and negotiate the token.
|
||||
pub(crate) async fn connect_transfer(
|
||||
inner: &Arc<Inner>,
|
||||
username: &str,
|
||||
token: u32,
|
||||
) -> Result<TcpStream> {
|
||||
let addr = get_peer_address(inner, username).await?;
|
||||
|
||||
let direct = async {
|
||||
let mut stream = tokio::time::timeout(PEER_CONNECT_TIMEOUT, TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("connect timeout"))??;
|
||||
stream.set_nodelay(true).ok();
|
||||
let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_TRANSFER, token);
|
||||
stream.write_all(&init).await?;
|
||||
Ok::<TcpStream, std::io::Error>(stream)
|
||||
};
|
||||
|
||||
let mut stream = match direct.await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::debug!("direct F connect to {username} failed ({e}); trying indirect");
|
||||
indirect_connect(inner, username, peer::CONN_TYPE_TRANSFER).await?
|
||||
}
|
||||
};
|
||||
|
||||
// Both flavors: identify the transfer with 4 raw token bytes.
|
||||
stream
|
||||
.write_all(&token.to_le_bytes())
|
||||
.await
|
||||
.map_err(|_| ClientError::PeerUnreachable(username.to_string()))?;
|
||||
Ok(stream)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Distributed network: we join as a leaf child. The server sends parent
|
||||
//! candidates via NetInfo; we connect to one (type "D"), complete the
|
||||
//! branch handshake, and receive relayed `DistributedSearchRequest`
|
||||
//! messages, which we answer from our shares.
|
||||
//!
|
||||
//! We do not accept children (leaf only) — that keeps the implementation
|
||||
//! tractable while still making us a search-answering participant.
|
||||
|
||||
use std::net::SocketAddrV4;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use soulseek_proto::{dist, peer, server};
|
||||
|
||||
use crate::{conn, responder, Inner};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Try each parent candidate until one connects; on success, run the
|
||||
/// distributed read loop until it drops, then allow re-solicitation.
|
||||
pub(crate) fn on_net_info(inner: Arc<Inner>, parents: Vec<(String, SocketAddrV4)>) {
|
||||
if inner.has_parent.load(Ordering::Relaxed) || parents.is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
for (username, addr) in parents {
|
||||
if inner.has_parent.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
match connect_parent(&inner, &username, addr).await {
|
||||
Ok(stream) => {
|
||||
inner.has_parent.store(true, Ordering::Relaxed);
|
||||
log::info!("distributed parent: {username} ({addr})");
|
||||
let _ = run_parent(&inner, stream).await;
|
||||
inner.has_parent.store(false, Ordering::Relaxed);
|
||||
// Ask the server for fresh parents next time.
|
||||
let _ = inner.send_server(server::have_no_parents(true));
|
||||
return;
|
||||
}
|
||||
Err(e) => log::debug!("parent {username} ({addr}) failed: {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn connect_parent(
|
||||
inner: &Arc<Inner>,
|
||||
_username: &str,
|
||||
addr: SocketAddrV4,
|
||||
) -> std::io::Result<TcpStream> {
|
||||
let mut stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(addr))
|
||||
.await
|
||||
.map_err(|_| std::io::Error::other("connect timeout"))??;
|
||||
stream.set_nodelay(true).ok();
|
||||
|
||||
// Identify as a distributed peer.
|
||||
let token = inner.next_token();
|
||||
let init = peer::peer_init(&inner.own_username(), peer::CONN_TYPE_DISTRIBUTED, token);
|
||||
stream.write_all(&init).await?;
|
||||
|
||||
// Announce ourselves as a leaf: branch level 0, root = us.
|
||||
stream.write_all(&dist::branch_level(0)).await?;
|
||||
stream
|
||||
.write_all(&dist::branch_root(&inner.own_username()))
|
||||
.await?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
async fn run_parent(inner: &Arc<Inner>, mut stream: TcpStream) -> std::io::Result<()> {
|
||||
loop {
|
||||
let frame = match tokio::time::timeout(READ_TIMEOUT, conn::read_frame(&mut stream)).await {
|
||||
Ok(f) => f?,
|
||||
Err(_) => {
|
||||
// Keepalive ping; if it fails the connection is dead.
|
||||
if stream.write_all(&dist::ping()).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(frame) = frame else { return Ok(()) };
|
||||
|
||||
match dist::decode(frame) {
|
||||
Ok(dist::DistributedEvent::SearchRequest {
|
||||
username,
|
||||
token,
|
||||
query,
|
||||
}) => {
|
||||
responder::respond_to_search(inner.clone(), username, token, query);
|
||||
}
|
||||
Ok(dist::DistributedEvent::BranchLevel(level)) => {
|
||||
// Report our level (parent + 1) to the server.
|
||||
let _ = inner.send_server(server::branch_level(level + 1));
|
||||
}
|
||||
Ok(dist::DistributedEvent::BranchRoot(root)) => {
|
||||
let _ = inner.send_server(server::branch_root(&root));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::trace!("bad distributed message: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
//! Soulseek network client: server connection, peer connections, search,
|
||||
//! downloads.
|
||||
//!
|
||||
//! Behavioral reference: Soulseek.NET `SoulseekClient.cs` and `src/Network/**`.
|
||||
//!
|
||||
//! M1: connect + login, peer listener, indirect (PierceFirewall)
|
||||
//! connections both directions, live search.
|
||||
//! M2: downloads — TransferRequest/TransferResponse dance, F-type transfer
|
||||
//! connections (both directions), resume via offset, download manager.
|
||||
|
||||
mod conn;
|
||||
mod distributed;
|
||||
mod responder;
|
||||
mod share;
|
||||
mod transfer;
|
||||
mod upload;
|
||||
mod upnp;
|
||||
|
||||
pub use share::ShareIndex;
|
||||
pub use transfer::{DownloadState, DownloadUpdate};
|
||||
pub use upload::{UploadState, UploadUpdate};
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::net::{Ipv4Addr, SocketAddrV4};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use soulseek_proto::{server, types::SearchResponse};
|
||||
|
||||
pub const DEFAULT_SERVER: (&str, u16) = ("server.slsknet.org", 2242);
|
||||
pub const DEFAULT_LISTEN_PORT: u16 = 50300;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub server_host: String,
|
||||
pub server_port: u16,
|
||||
pub listen_port: u16,
|
||||
pub downloads_dir: PathBuf,
|
||||
/// Folders we share with the network.
|
||||
pub shared_folders: Vec<PathBuf>,
|
||||
/// Concurrent upload slots offered to peers.
|
||||
pub upload_slots: u32,
|
||||
/// User-info card description.
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
server_host: DEFAULT_SERVER.0.into(),
|
||||
server_port: DEFAULT_SERVER.1,
|
||||
listen_port: DEFAULT_LISTEN_PORT,
|
||||
downloads_dir: PathBuf::from("downloads"),
|
||||
shared_folders: Vec::new(),
|
||||
upload_slots: 2,
|
||||
description: "SoulStream user".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
LoggedIn {
|
||||
greeting: String,
|
||||
own_ip: Ipv4Addr,
|
||||
},
|
||||
LoginFailed {
|
||||
reason: String,
|
||||
},
|
||||
Disconnected {
|
||||
reason: String,
|
||||
},
|
||||
SearchResult {
|
||||
token: u32,
|
||||
response: SearchResponse,
|
||||
},
|
||||
ExcludedSearchPhrases(Vec<String>),
|
||||
Download(DownloadUpdate),
|
||||
Upload(UploadUpdate),
|
||||
/// We answered a peer's search with N files (for activity display).
|
||||
RespondedToSearch {
|
||||
username: String,
|
||||
files: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ClientError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("protocol: {0}")]
|
||||
Proto(#[from] soulseek_proto::ProtoError),
|
||||
#[error("not connected to server")]
|
||||
NotConnected,
|
||||
#[error("login failed: {0}")]
|
||||
LoginFailed(String),
|
||||
#[error("user {0} is offline or unknown")]
|
||||
UserOffline(String),
|
||||
#[error("could not reach peer {0}")]
|
||||
PeerUnreachable(String),
|
||||
#[error("transfer rejected: {0}")]
|
||||
TransferRejected(String),
|
||||
#[error("timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, ClientError>;
|
||||
|
||||
/// Write handle for an established peer message ("P") connection.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PeerHandle {
|
||||
pub write_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Peer's answer to our TransferRequest(download).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct TransferAck {
|
||||
pub allowed: bool,
|
||||
pub size: u64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Peer's TransferRequest(upload) once we reach their queue front —
|
||||
/// or the denial that arrived instead.
|
||||
pub(crate) type UploadOffer = std::result::Result<(u32, u64), String>; // (remote token, size)
|
||||
|
||||
pub(crate) struct Inner {
|
||||
pub server_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
pub event_tx: mpsc::UnboundedSender<Event>,
|
||||
pub username: Mutex<String>,
|
||||
pub active_searches: Mutex<HashSet<u32>>,
|
||||
pub pending_login: Mutex<Option<oneshot::Sender<std::result::Result<String, String>>>>,
|
||||
pub token_counter: AtomicU32,
|
||||
pub listen_port: u16,
|
||||
pub downloads_dir: PathBuf,
|
||||
|
||||
/// GetPeerAddress requests awaiting a server answer, by username.
|
||||
pub pending_addresses: Mutex<HashMap<String, Vec<oneshot::Sender<SocketAddrV4>>>>,
|
||||
/// Last known endpoint per user.
|
||||
pub address_cache: Mutex<HashMap<String, SocketAddrV4>>,
|
||||
/// Our indirect-connection solicitations: token → deliver the pierced stream.
|
||||
pub pending_pierce: Mutex<HashMap<u32, oneshot::Sender<TcpStream>>>,
|
||||
/// Inbound transfer connections we expect: (username, remote token) → stream.
|
||||
pub expected_transfers: Mutex<HashMap<(String, u32), oneshot::Sender<TcpStream>>>,
|
||||
/// Live peer message connections.
|
||||
pub peers: Mutex<HashMap<String, PeerHandle>>,
|
||||
/// TransferResponse waits: (username, our token).
|
||||
pub transfer_waits: Mutex<HashMap<(String, u32), oneshot::Sender<TransferAck>>>,
|
||||
/// TransferRequest(upload) waits: (username, remote filename).
|
||||
pub upload_offers: Mutex<HashMap<(String, String), oneshot::Sender<UploadOffer>>>,
|
||||
/// Active downloads by (username, remote filename) → (id, cancel).
|
||||
pub downloads_by_file: Mutex<HashMap<(String, String), (u64, CancellationToken)>>,
|
||||
pub download_counter: AtomicU64,
|
||||
|
||||
// ---- M5: sharing / uploads / distributed ----
|
||||
/// Files we share with the network.
|
||||
pub shares: Arc<ShareIndex>,
|
||||
/// Excluded phrases from the server; outgoing search results filter these.
|
||||
pub excluded_phrases: RwLock<Vec<String>>,
|
||||
/// Our user-info description.
|
||||
pub description: String,
|
||||
/// Total upload slots and how many are in use.
|
||||
pub upload_slots: u32,
|
||||
pub uploads_active: AtomicU32,
|
||||
/// Pending upload queue (username, remote path), FIFO.
|
||||
pub upload_queue: Mutex<VecDeque<(String, String)>>,
|
||||
/// Rolling average upload speed reported to peers (bytes/sec).
|
||||
pub upload_speed: AtomicU32,
|
||||
pub upload_counter: AtomicU64,
|
||||
/// Whether we currently have a distributed parent.
|
||||
pub has_parent: AtomicBool,
|
||||
/// De-dupe relayed searches so we don't answer the same token twice.
|
||||
pub answered_searches: Mutex<HashSet<u32>>,
|
||||
/// Count of relayed searches we've seen (instrumentation).
|
||||
pub searches_seen: AtomicU64,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
pub fn next_token(&self) -> u32 {
|
||||
self.token_counter.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn own_username(&self) -> String {
|
||||
self.username.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn send_server(&self, frame: Vec<u8>) -> Result<()> {
|
||||
self.server_tx
|
||||
.send(frame)
|
||||
.map_err(|_| ClientError::NotConnected)
|
||||
}
|
||||
|
||||
pub fn excluded(&self) -> Vec<String> {
|
||||
self.excluded_phrases.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn has_free_upload_slot(&self) -> bool {
|
||||
self.uploads_active.load(Ordering::Relaxed) < self.upload_slots
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to a running client. Dropping it does not tear down the
|
||||
/// connection; call [`Client::shutdown`].
|
||||
pub struct Client {
|
||||
inner: Arc<Inner>,
|
||||
shutdown_tx: mpsc::UnboundedSender<()>,
|
||||
listener_shutdown_tx: mpsc::UnboundedSender<()>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Connect the server socket and start the peer listener.
|
||||
/// Events (search results, download progress) arrive on the receiver.
|
||||
pub async fn connect(config: Config) -> Result<(Client, mpsc::UnboundedReceiver<Event>)> {
|
||||
let (event_tx, event_rx) = mpsc::unbounded_channel();
|
||||
let (server_tx, server_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, shutdown_rx) = mpsc::unbounded_channel::<()>();
|
||||
let (listener_shutdown_tx, listener_shutdown_rx) = mpsc::unbounded_channel::<()>();
|
||||
|
||||
let stream = TcpStream::connect((config.server_host.as_str(), config.server_port)).await?;
|
||||
stream.set_nodelay(true).ok();
|
||||
|
||||
// Bind the listener before login so SetListenPort is accurate.
|
||||
let listener = conn::bind_listener(config.listen_port).await?;
|
||||
let listen_port = listener.local_addr()?.port();
|
||||
|
||||
// Seed tokens off the clock so restarts don't reuse recent tokens.
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(1);
|
||||
|
||||
// Build the share index up front (blocking walk on a worker thread).
|
||||
let shares = Arc::new(ShareIndex::new());
|
||||
if !config.shared_folders.is_empty() {
|
||||
let shares2 = shares.clone();
|
||||
let folders = config.shared_folders.clone();
|
||||
let (dirs, files) = tokio::task::spawn_blocking(move || shares2.rebuild(&folders))
|
||||
.await
|
||||
.unwrap_or((0, 0));
|
||||
log::info!("share index: {files} files in {dirs} folders");
|
||||
}
|
||||
|
||||
let inner = Arc::new(Inner {
|
||||
server_tx,
|
||||
event_tx,
|
||||
username: Mutex::new(String::new()),
|
||||
active_searches: Mutex::new(HashSet::new()),
|
||||
pending_login: Mutex::new(None),
|
||||
token_counter: AtomicU32::new(seed),
|
||||
listen_port,
|
||||
downloads_dir: config.downloads_dir.clone(),
|
||||
pending_addresses: Mutex::new(HashMap::new()),
|
||||
address_cache: Mutex::new(HashMap::new()),
|
||||
pending_pierce: Mutex::new(HashMap::new()),
|
||||
expected_transfers: Mutex::new(HashMap::new()),
|
||||
peers: Mutex::new(HashMap::new()),
|
||||
transfer_waits: Mutex::new(HashMap::new()),
|
||||
upload_offers: Mutex::new(HashMap::new()),
|
||||
downloads_by_file: Mutex::new(HashMap::new()),
|
||||
download_counter: AtomicU64::new(1),
|
||||
shares,
|
||||
excluded_phrases: RwLock::new(Vec::new()),
|
||||
description: config.description.clone(),
|
||||
upload_slots: config.upload_slots,
|
||||
uploads_active: AtomicU32::new(0),
|
||||
upload_queue: Mutex::new(VecDeque::new()),
|
||||
upload_speed: AtomicU32::new(0),
|
||||
upload_counter: AtomicU64::new(1),
|
||||
has_parent: AtomicBool::new(false),
|
||||
answered_searches: Mutex::new(HashSet::new()),
|
||||
searches_seen: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
conn::spawn_server_io(stream, server_rx, shutdown_rx, inner.clone());
|
||||
conn::spawn_listener(listener, inner.clone(), listener_shutdown_rx);
|
||||
|
||||
// Best-effort UPnP mapping for direct connectivity (non-fatal).
|
||||
tokio::spawn(upnp::try_map_port(listen_port));
|
||||
|
||||
Ok((
|
||||
Client {
|
||||
inner,
|
||||
shutdown_tx,
|
||||
listener_shutdown_tx,
|
||||
},
|
||||
event_rx,
|
||||
))
|
||||
}
|
||||
|
||||
/// Log in; resolves when the server answers.
|
||||
pub async fn login(&self, username: &str, password: &str) -> Result<String> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
*self.inner.pending_login.lock().unwrap() = Some(tx);
|
||||
*self.inner.username.lock().unwrap() = username.to_string();
|
||||
|
||||
self.inner.send_server(server::login(username, password))?;
|
||||
|
||||
let outcome = tokio::time::timeout(std::time::Duration::from_secs(15), rx)
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|_| ClientError::NotConnected)?;
|
||||
|
||||
match outcome {
|
||||
Ok(greeting) => {
|
||||
// Post-login bookkeeping the server expects from clients.
|
||||
self.inner
|
||||
.send_server(server::set_listen_port(self.inner.listen_port as u32))?;
|
||||
let (dirs, files) = self.inner.shares.counts();
|
||||
self.inner
|
||||
.send_server(server::shared_folders_and_files(dirs, files))?;
|
||||
// Join the distributed network as a child so we receive
|
||||
// relayed searches to answer.
|
||||
self.inner.send_server(server::have_no_parents(true))?;
|
||||
self.inner
|
||||
.send_server(server::branch_root(&self.inner.own_username()))?;
|
||||
self.inner.send_server(server::branch_level(0))?;
|
||||
self.inner.send_server(server::accept_children(false))?;
|
||||
Ok(greeting)
|
||||
}
|
||||
Err(reason) => Err(ClientError::LoginFailed(reason)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-index the shared folders and report the new counts to the server.
|
||||
pub async fn update_shares(&self, folders: Vec<PathBuf>) -> (u32, u32) {
|
||||
let shares = self.inner.shares.clone();
|
||||
let (dirs, files) = tokio::task::spawn_blocking(move || shares.rebuild(&folders))
|
||||
.await
|
||||
.unwrap_or((0, 0));
|
||||
let _ = self
|
||||
.inner
|
||||
.send_server(server::shared_folders_and_files(dirs, files));
|
||||
(dirs, files)
|
||||
}
|
||||
|
||||
/// Current (folders, files) share counts.
|
||||
pub fn share_counts(&self) -> (u32, u32) {
|
||||
self.inner.shares.counts()
|
||||
}
|
||||
|
||||
/// How many relayed searches we've received (whether or not matched).
|
||||
pub fn searches_seen(&self) -> u64 {
|
||||
self.inner.searches_seen.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Seed a peer's endpoint, bypassing server address resolution. Useful
|
||||
/// for LAN peers and for tests where two instances share one public IP.
|
||||
pub fn set_peer_address(&self, username: &str, addr: SocketAddrV4) {
|
||||
self.inner
|
||||
.address_cache
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(username.to_string(), addr);
|
||||
}
|
||||
|
||||
/// Whether we currently hold a distributed parent connection.
|
||||
pub fn has_parent(&self) -> bool {
|
||||
self.inner.has_parent.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Start a network-wide file search. Results stream out as
|
||||
/// [`Event::SearchResult`] carrying this token.
|
||||
pub fn search(&self, query: &str) -> Result<u32> {
|
||||
let token = self.inner.next_token();
|
||||
self.inner.active_searches.lock().unwrap().insert(token);
|
||||
self.inner.send_server(server::file_search(token, query))?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Stop routing results for a search token.
|
||||
pub fn stop_search(&self, token: u32) {
|
||||
self.inner.active_searches.lock().unwrap().remove(&token);
|
||||
}
|
||||
|
||||
/// Start downloading `filename` (full remote path) from `username`.
|
||||
/// Progress streams out as [`Event::Download`]. Returns the download id.
|
||||
pub fn download(&self, username: &str, filename: &str, size: u64) -> u64 {
|
||||
transfer::start_download(self.inner.clone(), username, filename, size, None)
|
||||
}
|
||||
|
||||
/// Like [`download`](Self::download) but writing to an explicit local
|
||||
/// path (used for stream-to-cache).
|
||||
pub fn download_to(&self, username: &str, filename: &str, size: u64, target: PathBuf) -> u64 {
|
||||
transfer::start_download(self.inner.clone(), username, filename, size, Some(target))
|
||||
}
|
||||
|
||||
/// Cancel an active download by id.
|
||||
pub fn cancel_download(&self, id: u64) {
|
||||
let map = self.inner.downloads_by_file.lock().unwrap();
|
||||
for (did, cancel) in map.values() {
|
||||
if *did == id {
|
||||
cancel.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn listen_port(&self) -> u16 {
|
||||
self.inner.listen_port
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.send(());
|
||||
let _ = self.listener_shutdown_tx.send(());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Responder: answers other peers' requests — searches, browse, user-info,
|
||||
//! folder contents. Search replies open a fresh P connection to the
|
||||
//! searcher; the rest reply on the connection the request arrived on.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use soulseek_proto::peer;
|
||||
|
||||
use crate::{conn, Event, Inner, PeerHandle};
|
||||
|
||||
/// Max files we return in a single search reply.
|
||||
const MAX_SEARCH_RESULTS: usize = 100;
|
||||
|
||||
/// Match a relayed search against our shares and, if we have hits, send a
|
||||
/// PeerSearchResponse back to `username`. Runs on its own task.
|
||||
pub(crate) fn respond_to_search(inner: Arc<Inner>, username: String, token: u32, query: String) {
|
||||
inner.searches_seen.fetch_add(1, Ordering::Relaxed);
|
||||
// Never answer our own searches; de-dupe repeated relays of a token.
|
||||
if username == inner.own_username() {
|
||||
return;
|
||||
}
|
||||
{
|
||||
let mut answered = inner.answered_searches.lock().unwrap();
|
||||
if answered.contains(&token) {
|
||||
return;
|
||||
}
|
||||
answered.insert(token);
|
||||
// Bound the set so it can't grow without limit.
|
||||
if answered.len() > 20_000 {
|
||||
answered.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if inner.shares.file_count() == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::spawn(async move {
|
||||
let excluded = inner.excluded();
|
||||
let files = inner.shares.search(&query, &excluded, MAX_SEARCH_RESULTS);
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = peer::search_response(
|
||||
&inner.own_username(),
|
||||
token,
|
||||
&files,
|
||||
inner.has_free_upload_slot(),
|
||||
inner.upload_speed.load(Ordering::Relaxed),
|
||||
inner.upload_queue.lock().unwrap().len() as u32,
|
||||
);
|
||||
|
||||
// Search replies use a normal P connection to the searcher.
|
||||
match conn::get_or_connect_peer(&inner, &username).await {
|
||||
Ok(handle) => {
|
||||
if handle.write_tx.send(frame).is_ok() {
|
||||
let _ = inner.event_tx.send(Event::RespondedToSearch {
|
||||
username: username.clone(),
|
||||
files: files.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("couldn't reply to search from {username}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Answer a BrowseRequest on the same connection.
|
||||
pub(crate) fn respond_browse(inner: &Arc<Inner>, handle: &PeerHandle) {
|
||||
let dirs = inner.shares.browse();
|
||||
let _ = handle.write_tx.send(peer::browse_response(&dirs));
|
||||
}
|
||||
|
||||
/// Answer a UserInfoRequest.
|
||||
pub(crate) fn respond_user_info(inner: &Arc<Inner>, handle: &PeerHandle) {
|
||||
let frame = peer::user_info_response(
|
||||
&inner.description,
|
||||
inner.upload_slots,
|
||||
inner.upload_queue.lock().unwrap().len() as u32,
|
||||
inner.has_free_upload_slot(),
|
||||
);
|
||||
let _ = handle.write_tx.send(frame);
|
||||
}
|
||||
|
||||
/// Answer a FolderContentsRequest for one folder.
|
||||
pub(crate) fn respond_folder_contents(
|
||||
inner: &Arc<Inner>,
|
||||
handle: &PeerHandle,
|
||||
token: u32,
|
||||
folder: &str,
|
||||
) {
|
||||
if let Some(dir) = inner.shares.folder(folder) {
|
||||
let _ = handle
|
||||
.write_tx
|
||||
.send(peer::folder_contents_response(token, folder, &dir));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! Share index: the files we make available to the network. Built by
|
||||
//! walking configured folders; queried by the search and browse responders.
|
||||
//!
|
||||
//! Remote paths use backslashes and a virtual root so we never leak local
|
||||
//! absolute paths. Each shared folder is mounted under its own base name,
|
||||
//! e.g. local `D:\Music\Album` → remote `Music\Album\track.flac`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use soulseek_proto::types::{Directory, File};
|
||||
|
||||
const AUDIO_EXTS: [&str; 10] = [
|
||||
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
|
||||
];
|
||||
|
||||
/// One shared file: its remote (virtual) path and where it lives locally.
|
||||
#[derive(Debug, Clone)]
|
||||
struct SharedFile {
|
||||
remote_path: String, // backslash-separated
|
||||
local_path: PathBuf,
|
||||
size: u64,
|
||||
extension: String,
|
||||
lowercase: String, // remote_path lowercased, for matching
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Index {
|
||||
files: Vec<SharedFile>,
|
||||
/// remote folder (backslashes) → indices into `files`.
|
||||
folders: BTreeMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
pub struct ShareIndex {
|
||||
index: RwLock<Index>,
|
||||
}
|
||||
|
||||
impl Default for ShareIndex {
|
||||
fn default() -> Self {
|
||||
ShareIndex {
|
||||
index: RwLock::new(Index::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ShareIndex {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Rebuild the index from the given local folders. Returns (dirs, files).
|
||||
pub fn rebuild(&self, roots: &[PathBuf]) -> (u32, u32) {
|
||||
let mut index = Index::default();
|
||||
for root in roots {
|
||||
let base = root
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Shared".into());
|
||||
walk(root, &base, root, &mut index);
|
||||
}
|
||||
let dirs = index.folders.len() as u32;
|
||||
let files = index.files.len() as u32;
|
||||
*self.index.write().unwrap() = index;
|
||||
(dirs, files)
|
||||
}
|
||||
|
||||
pub fn counts(&self) -> (u32, u32) {
|
||||
let idx = self.index.read().unwrap();
|
||||
(idx.folders.len() as u32, idx.files.len() as u32)
|
||||
}
|
||||
|
||||
pub fn file_count(&self) -> u32 {
|
||||
self.index.read().unwrap().files.len() as u32
|
||||
}
|
||||
|
||||
/// Match a search query: every whitespace token must appear somewhere
|
||||
/// in the remote path (case-insensitive). Returns proto `File`s.
|
||||
/// `excluded` phrases veto a result if any appears in the path.
|
||||
pub fn search(&self, query: &str, excluded: &[String], limit: usize) -> Vec<File> {
|
||||
let terms: Vec<String> = query
|
||||
.split_whitespace()
|
||||
.map(|t| t.to_lowercase())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
if terms.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let idx = self.index.read().unwrap();
|
||||
let mut out = Vec::new();
|
||||
for f in &idx.files {
|
||||
if terms.iter().all(|t| f.lowercase.contains(t.as_str()))
|
||||
&& !excluded
|
||||
.iter()
|
||||
.any(|p| !p.is_empty() && f.lowercase.contains(&p.to_lowercase()))
|
||||
{
|
||||
out.push(to_proto_file(f));
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolve a remote path a peer asked to download to its local file.
|
||||
pub fn resolve(&self, remote_path: &str) -> Option<(PathBuf, u64)> {
|
||||
let idx = self.index.read().unwrap();
|
||||
idx.files
|
||||
.iter()
|
||||
.find(|f| f.remote_path.eq_ignore_ascii_case(remote_path))
|
||||
.map(|f| (f.local_path.clone(), f.size))
|
||||
}
|
||||
|
||||
/// Full directory listing for a browse response.
|
||||
pub fn browse(&self) -> Vec<Directory> {
|
||||
let idx = self.index.read().unwrap();
|
||||
idx.folders
|
||||
.iter()
|
||||
.map(|(name, indices)| Directory {
|
||||
name: name.clone(),
|
||||
files: indices
|
||||
.iter()
|
||||
.map(|&i| to_proto_file(&idx.files[i]))
|
||||
.collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Files in one folder (for FolderContentsResponse).
|
||||
pub fn folder(&self, name: &str) -> Option<Directory> {
|
||||
let idx = self.index.read().unwrap();
|
||||
idx.folders.get(name).map(|indices| Directory {
|
||||
name: name.to_string(),
|
||||
files: indices
|
||||
.iter()
|
||||
.map(|&i| to_proto_file(&idx.files[i]))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn to_proto_file(f: &SharedFile) -> File {
|
||||
// Search/browse responses send just the leaf filename in the File entry
|
||||
// for search, but the full path for browse. We store the remote path
|
||||
// and let the responder decide; here we return the remote path as the
|
||||
// filename (peers expect a full backslash path in search responses).
|
||||
File {
|
||||
filename: f.remote_path.clone(),
|
||||
size: f.size,
|
||||
extension: f.extension.clone(),
|
||||
attributes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, base: &str, root: &Path, index: &mut Index) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, base, root, index);
|
||||
} else if is_audio(&path) {
|
||||
let Ok(meta) = entry.metadata() else { continue };
|
||||
let remote_path = to_remote_path(&path, base, root);
|
||||
let folder = remote_path
|
||||
.rsplit_once('\\')
|
||||
.map(|(d, _)| d.to_string())
|
||||
.unwrap_or_else(|| base.to_string());
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
let file = SharedFile {
|
||||
lowercase: remote_path.to_lowercase(),
|
||||
remote_path,
|
||||
local_path: path,
|
||||
size: meta.len(),
|
||||
extension: ext,
|
||||
};
|
||||
let idx = index.files.len();
|
||||
index.folders.entry(folder).or_default().push(idx);
|
||||
index.files.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `<base>\<relative path with backslashes>`.
|
||||
fn to_remote_path(path: &Path, base: &str, root: &Path) -> String {
|
||||
let rel = path.strip_prefix(root).unwrap_or(path);
|
||||
let rel_str: String = rel
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\\");
|
||||
format!("{base}\\{rel_str}")
|
||||
}
|
||||
|
||||
fn is_audio(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| AUDIO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn index_search_and_resolve() {
|
||||
let dir = std::env::temp_dir().join("ssplus-share-test");
|
||||
let album = dir.join("Nevermind");
|
||||
std::fs::create_dir_all(&album).unwrap();
|
||||
std::fs::write(album.join("01 Lithium.flac"), b"fake").unwrap();
|
||||
std::fs::write(album.join("02 Polly.mp3"), b"fake2").unwrap();
|
||||
std::fs::write(album.join("cover.jpg"), b"img").unwrap(); // ignored
|
||||
|
||||
let share = ShareIndex::new();
|
||||
let (dirs, files) = share.rebuild(&[dir.clone()]);
|
||||
assert_eq!(files, 2);
|
||||
assert!(dirs >= 1);
|
||||
|
||||
// Matches on both terms across the path.
|
||||
let hits = share.search("nevermind lithium", &[], 10);
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].filename.ends_with("01 Lithium.flac"));
|
||||
|
||||
// Excluded phrase vetoes.
|
||||
let vetoed = share.search("lithium", &["nevermind".into()], 10);
|
||||
assert!(vetoed.is_empty());
|
||||
|
||||
// Resolve remote → local.
|
||||
let remote = &hits[0].filename;
|
||||
let (local, size) = share.resolve(remote).unwrap();
|
||||
assert!(local.ends_with("01 Lithium.flac"));
|
||||
assert_eq!(size, 4);
|
||||
|
||||
// Browse lists the folder.
|
||||
let browse = share.browse();
|
||||
assert!(browse
|
||||
.iter()
|
||||
.any(|d| d.name.contains("Nevermind") && d.files.len() == 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Download state machine, mirroring Soulseek.NET's `DownloadToStreamAsync`:
|
||||
//!
|
||||
//! 1. Send `TransferRequest(download, token, filename)` on the P connection.
|
||||
//! 2. Peer answers `TransferResponse`:
|
||||
//! - allowed → we initiate the F connection with our token.
|
||||
//! - "Queued" → wait for the peer's `TransferRequest(upload)`, ack it,
|
||||
//! await their inbound F connection (second-chance: initiate ours).
|
||||
//! 3. On the F connection: write 8-byte start offset, then read raw bytes.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use soulseek_proto::peer;
|
||||
|
||||
use crate::{conn, ClientError, Event, Inner, Result};
|
||||
|
||||
/// How long we stay in the remote queue before giving up.
|
||||
const QUEUE_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
|
||||
/// Ack / connection-establishment timeout.
|
||||
const NEGOTIATION_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
/// No bytes for this long mid-transfer → fail.
|
||||
const TRANSFER_STALL_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
/// Poll the remote queue position at this interval while queued.
|
||||
const PLACE_POLL_INTERVAL: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DownloadState {
|
||||
Connecting,
|
||||
Requested,
|
||||
Queued { place: Option<u32> },
|
||||
Transferring,
|
||||
Complete { local_path: String },
|
||||
Failed { reason: String },
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DownloadUpdate {
|
||||
pub id: u64,
|
||||
pub username: String,
|
||||
/// Full remote path (backslash separated).
|
||||
pub filename: String,
|
||||
pub state: DownloadState,
|
||||
pub received: u64,
|
||||
pub total: u64,
|
||||
pub speed_bps: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn start_download(
|
||||
inner: Arc<Inner>,
|
||||
username: &str,
|
||||
filename: &str,
|
||||
size: u64,
|
||||
target: Option<PathBuf>,
|
||||
) -> u64 {
|
||||
let id = inner
|
||||
.download_counter
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let cancel = CancellationToken::new();
|
||||
inner.downloads_by_file.lock().unwrap().insert(
|
||||
(username.to_string(), filename.to_string()),
|
||||
(id, cancel.clone()),
|
||||
);
|
||||
|
||||
let username = username.to_string();
|
||||
let filename = filename.to_string();
|
||||
tokio::spawn(async move {
|
||||
let mut dl = Download {
|
||||
inner: inner.clone(),
|
||||
id,
|
||||
username: username.clone(),
|
||||
filename: filename.clone(),
|
||||
target,
|
||||
total: size,
|
||||
received: 0,
|
||||
speed_bps: 0,
|
||||
cancel: cancel.clone(),
|
||||
};
|
||||
|
||||
let outcome = tokio::select! {
|
||||
r = dl.run() => r,
|
||||
_ = cancel.cancelled() => Err(ClientError::Timeout), // mapped below
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Ok(local_path) => dl.emit(DownloadState::Complete { local_path }),
|
||||
Err(_) if cancel.is_cancelled() => dl.emit(DownloadState::Cancelled),
|
||||
Err(e) => dl.emit(DownloadState::Failed {
|
||||
reason: e.to_string(),
|
||||
}),
|
||||
}
|
||||
|
||||
inner
|
||||
.downloads_by_file
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(username, filename));
|
||||
});
|
||||
id
|
||||
}
|
||||
|
||||
/// Emit a queue-place update for a download (from PlaceInQueueResponse).
|
||||
pub(crate) fn update_queue_place(inner: &Arc<Inner>, username: &str, filename: &str, place: u32) {
|
||||
let id = inner
|
||||
.downloads_by_file
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&(username.to_string(), filename.to_string()))
|
||||
.map(|(id, _)| *id);
|
||||
if let Some(id) = id {
|
||||
let _ = inner.event_tx.send(Event::Download(DownloadUpdate {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
filename: filename.to_string(),
|
||||
state: DownloadState::Queued { place: Some(place) },
|
||||
received: 0,
|
||||
total: 0,
|
||||
speed_bps: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
struct Download {
|
||||
inner: Arc<Inner>,
|
||||
id: u64,
|
||||
username: String,
|
||||
filename: String,
|
||||
/// Explicit local target (stream-to-cache); None → downloads dir layout.
|
||||
target: Option<PathBuf>,
|
||||
total: u64,
|
||||
received: u64,
|
||||
speed_bps: u64,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl Download {
|
||||
fn emit(&self, state: DownloadState) {
|
||||
let _ = self.inner.event_tx.send(Event::Download(DownloadUpdate {
|
||||
id: self.id,
|
||||
username: self.username.clone(),
|
||||
filename: self.filename.clone(),
|
||||
state,
|
||||
received: self.received,
|
||||
total: self.total,
|
||||
speed_bps: self.speed_bps,
|
||||
}));
|
||||
}
|
||||
|
||||
async fn run(&mut self) -> Result<String> {
|
||||
self.emit(DownloadState::Connecting);
|
||||
let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?;
|
||||
|
||||
// Register both waits before sending the request to avoid races:
|
||||
// the immediate ack, and the eventual upload offer if we get queued.
|
||||
let token = self.inner.next_token();
|
||||
let (ack_tx, ack_rx) = oneshot::channel();
|
||||
self.inner
|
||||
.transfer_waits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((self.username.clone(), token), ack_tx);
|
||||
|
||||
let (offer_tx, offer_rx) = oneshot::channel();
|
||||
self.inner
|
||||
.upload_offers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((self.username.clone(), self.filename.clone()), offer_tx);
|
||||
|
||||
handle
|
||||
.write_tx
|
||||
.send(peer::transfer_request_download(token, &self.filename))
|
||||
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
|
||||
self.emit(DownloadState::Requested);
|
||||
|
||||
let ack = tokio::time::timeout(NEGOTIATION_TIMEOUT, ack_rx)
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
|
||||
|
||||
let stream = if ack.allowed {
|
||||
// Free slot: we initiate the transfer connection with our token.
|
||||
if ack.size > 0 {
|
||||
self.total = ack.size;
|
||||
}
|
||||
self.emit(DownloadState::Transferring);
|
||||
conn::connect_transfer(&self.inner, &self.username, token).await?
|
||||
} else if ack
|
||||
.message
|
||||
.trim_end_matches('.')
|
||||
.eq_ignore_ascii_case("queued")
|
||||
{
|
||||
self.emit(DownloadState::Queued { place: None });
|
||||
self.await_queued_transfer(&handle, offer_rx).await?
|
||||
} else {
|
||||
return Err(ClientError::TransferRejected(ack.message));
|
||||
};
|
||||
|
||||
self.receive(stream).await
|
||||
}
|
||||
|
||||
/// We're in the remote queue: poll our place, wait for the peer's
|
||||
/// upload offer, ack it, and obtain the transfer connection.
|
||||
async fn await_queued_transfer(
|
||||
&mut self,
|
||||
handle: &crate::PeerHandle,
|
||||
offer_rx: oneshot::Receiver<crate::UploadOffer>,
|
||||
) -> Result<TcpStream> {
|
||||
// Queue-place polling until the offer arrives.
|
||||
let poll_handle = handle.write_tx.clone();
|
||||
let poll_filename = self.filename.clone();
|
||||
let poller = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(PLACE_POLL_INTERVAL).await;
|
||||
if poll_handle
|
||||
.send(peer::place_in_queue_request(&poll_filename))
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let offer = tokio::time::timeout(QUEUE_TIMEOUT, offer_rx).await;
|
||||
poller.abort();
|
||||
|
||||
let (remote_token, size) = offer
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?
|
||||
.map_err(ClientError::TransferRejected)?;
|
||||
|
||||
if size > 0 {
|
||||
self.total = size;
|
||||
}
|
||||
|
||||
// Expect the peer's inbound F connection carrying their token —
|
||||
// registered before we ack so the connection can't race us.
|
||||
let (conn_tx, conn_rx) = oneshot::channel();
|
||||
self.inner
|
||||
.expected_transfers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((self.username.clone(), remote_token), conn_tx);
|
||||
|
||||
// Re-resolve the peer connection in case the old one idled out.
|
||||
let handle = conn::get_or_connect_peer(&self.inner, &self.username).await?;
|
||||
handle
|
||||
.write_tx
|
||||
.send(peer::transfer_response_allowed(remote_token, self.total))
|
||||
.map_err(|_| ClientError::PeerUnreachable(self.username.clone()))?;
|
||||
|
||||
self.emit(DownloadState::Transferring);
|
||||
|
||||
match tokio::time::timeout(NEGOTIATION_TIMEOUT, conn_rx).await {
|
||||
Ok(Ok(stream)) => Ok(stream),
|
||||
_ => {
|
||||
// Second chance (Nicotine+ et al.): initiate from our end.
|
||||
self.inner
|
||||
.expected_transfers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(self.username.clone(), remote_token));
|
||||
log::debug!(
|
||||
"no inbound F connection from {}; initiating second-chance",
|
||||
self.username
|
||||
);
|
||||
conn::connect_transfer(&self.inner, &self.username, remote_token).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive the transfer connection: write start offset, stream to disk.
|
||||
async fn receive(&mut self, mut stream: TcpStream) -> Result<String> {
|
||||
let (final_path, partial_path) = self.local_paths()?;
|
||||
|
||||
// Resume from an existing partial file when its length is sane.
|
||||
let offset = match tokio::fs::metadata(&partial_path).await {
|
||||
Ok(m) if self.total > 0 && m.len() < self.total => m.len(),
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.open(&partial_path)
|
||||
.await?;
|
||||
file.set_len(offset).await?;
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
|
||||
stream.write_all(&offset.to_le_bytes()).await?;
|
||||
self.received = offset;
|
||||
self.emit(DownloadState::Transferring);
|
||||
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut window_start = Instant::now();
|
||||
let mut window_bytes: u64 = 0;
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
while self.total == 0 || self.received < self.total {
|
||||
let n = tokio::select! {
|
||||
_ = self.cancel.cancelled() => return Err(ClientError::Timeout),
|
||||
r = tokio::time::timeout(TRANSFER_STALL_TIMEOUT, stream.read(&mut buf)) => {
|
||||
r.map_err(|_| ClientError::Timeout)??
|
||||
}
|
||||
};
|
||||
if n == 0 {
|
||||
if self.total == 0 {
|
||||
break; // size unknown: EOF is completion
|
||||
}
|
||||
return Err(ClientError::PeerUnreachable(format!(
|
||||
"{} closed the connection early ({}/{} bytes)",
|
||||
self.username, self.received, self.total
|
||||
)));
|
||||
}
|
||||
file.write_all(&buf[..n]).await?;
|
||||
self.received += n as u64;
|
||||
window_bytes += n as u64;
|
||||
|
||||
let elapsed = window_start.elapsed();
|
||||
if elapsed >= Duration::from_secs(1) {
|
||||
self.speed_bps = (window_bytes as f64 / elapsed.as_secs_f64()) as u64;
|
||||
window_start = Instant::now();
|
||||
window_bytes = 0;
|
||||
}
|
||||
if last_emit.elapsed() >= Duration::from_millis(250) {
|
||||
last_emit = Instant::now();
|
||||
self.emit(DownloadState::Transferring);
|
||||
}
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
|
||||
let final_path = unique_path(final_path).await;
|
||||
tokio::fs::rename(&partial_path, &final_path).await?;
|
||||
Ok(final_path.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// `<downloads_dir>/<user>/<remote folder leaf>/<file>` (+ `.partial`),
|
||||
/// or the explicit target when set.
|
||||
fn local_paths(&self) -> Result<(PathBuf, PathBuf)> {
|
||||
if let Some(target) = &self.target {
|
||||
if let Some(dir) = target.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let partial = target.with_extension(format!(
|
||||
"{}partial",
|
||||
target
|
||||
.extension()
|
||||
.map(|e| format!("{}.", e.to_string_lossy()))
|
||||
.unwrap_or_default()
|
||||
));
|
||||
return Ok((target.clone(), partial));
|
||||
}
|
||||
let (folder, name) = match self.filename.rsplit_once('\\') {
|
||||
Some((dir, n)) => (dir.rsplit('\\').next().unwrap_or(""), n),
|
||||
None => ("", self.filename.as_str()),
|
||||
};
|
||||
let mut dir = self.inner.downloads_dir.clone();
|
||||
dir.push(sanitize(&self.username));
|
||||
if !folder.is_empty() {
|
||||
dir.push(sanitize(folder));
|
||||
}
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let final_path = dir.join(sanitize(name));
|
||||
let partial = dir.join(format!("{}.partial", sanitize(name)));
|
||||
Ok((final_path, partial))
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a remote path component safe for Windows.
|
||||
fn sanitize(part: &str) -> String {
|
||||
let cleaned: String = part
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||
c if (c as u32) < 0x20 => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
cleaned.trim_end_matches([' ', '.']).to_string()
|
||||
}
|
||||
|
||||
/// Avoid clobbering an existing file: append " (2)", " (3)", …
|
||||
async fn unique_path(path: PathBuf) -> PathBuf {
|
||||
if tokio::fs::metadata(&path).await.is_err() {
|
||||
return path;
|
||||
}
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| format!(".{}", e.to_string_lossy()))
|
||||
.unwrap_or_default();
|
||||
let dir = path.parent().map(PathBuf::from).unwrap_or_default();
|
||||
for i in 2.. {
|
||||
let candidate = dir.join(format!("{stem} ({i}){ext}"));
|
||||
if tokio::fs::metadata(&candidate).await.is_err() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_windows_reserved() {
|
||||
assert_eq!(sanitize("AC/DC: Back in Black?"), "AC_DC_ Back in Black_");
|
||||
assert_eq!(sanitize("trailing dots..."), "trailing dots");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
//! Upload servicing: when a peer requests one of our shared files, we send
|
||||
//! it over an F connection. Slot-limited with a simple FIFO queue.
|
||||
//!
|
||||
//! Two protocol sub-flows, mirroring the download side (M2):
|
||||
//!
|
||||
//! * **Immediate** — peer sends `TransferRequest(download, their_token)`.
|
||||
//! With a free slot we reply `TransferResponse(their_token, allowed)`,
|
||||
//! then await *their* inbound F connection (keyed by their token), read
|
||||
//! the 8-byte offset, and stream the file.
|
||||
//! * **Queued / proactive** — no slot yet (or a bare `QueueDownload`). We
|
||||
//! enqueue; when a slot frees we send `TransferRequest(upload, our_token)`,
|
||||
//! await their `TransferResponse`, open the F connection ourselves, read
|
||||
//! the offset, and stream.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use soulseek_proto::peer;
|
||||
|
||||
use crate::{conn, ClientError, Event, Inner, Result};
|
||||
|
||||
const ACK_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const STALL_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum UploadState {
|
||||
Queued,
|
||||
Transferring,
|
||||
Complete,
|
||||
Failed { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UploadUpdate {
|
||||
pub id: u64,
|
||||
pub username: String,
|
||||
pub filename: String,
|
||||
pub state: UploadState,
|
||||
pub sent: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
/// Peer sent `TransferRequest(download, their_token, filename)`.
|
||||
pub(crate) fn on_transfer_request(
|
||||
inner: Arc<Inner>,
|
||||
username: String,
|
||||
their_token: u32,
|
||||
remote_path: String,
|
||||
) {
|
||||
let Some((local, size)) = inner.shares.resolve(&remote_path) else {
|
||||
reply(
|
||||
&inner,
|
||||
&username,
|
||||
peer::transfer_response_rejected(their_token, "File not shared"),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
if inner.has_free_upload_slot() {
|
||||
// Accept: they will open the F connection with their token.
|
||||
reply(
|
||||
&inner,
|
||||
&username,
|
||||
peer::transfer_response_allowed(their_token, size),
|
||||
);
|
||||
spawn_immediate(inner, username, remote_path, local, size, their_token);
|
||||
} else {
|
||||
reply(
|
||||
&inner,
|
||||
&username,
|
||||
peer::transfer_response_rejected(their_token, "Queued"),
|
||||
);
|
||||
enqueue(&inner, &username, &remote_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Peer sent a bare `QueueDownload(filename)` (no token) — enqueue, or start
|
||||
/// the proactive flow immediately if a slot is free.
|
||||
pub(crate) fn on_queue_download(inner: Arc<Inner>, username: String, remote_path: String) {
|
||||
let Some((local, size)) = inner.shares.resolve(&remote_path) else {
|
||||
reply(
|
||||
&inner,
|
||||
&username,
|
||||
peer::upload_denied(&remote_path, "File not shared"),
|
||||
);
|
||||
return;
|
||||
};
|
||||
if inner.has_free_upload_slot() {
|
||||
spawn_proactive(inner, username, remote_path, local, size);
|
||||
} else {
|
||||
enqueue(&inner, &username, &remote_path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Report queue place when a peer asks (PlaceInQueueRequest).
|
||||
pub(crate) fn on_place_query(inner: &Arc<Inner>, username: &str, remote_path: &str) {
|
||||
let place = inner
|
||||
.upload_queue
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.position(|(u, p)| u == username && p == remote_path)
|
||||
.map(|i| i as u32 + 1)
|
||||
.unwrap_or(0);
|
||||
reply(
|
||||
inner,
|
||||
username,
|
||||
peer::place_in_queue_response(remote_path, place),
|
||||
);
|
||||
}
|
||||
|
||||
fn enqueue(inner: &Arc<Inner>, username: &str, remote_path: &str) {
|
||||
let mut q = inner.upload_queue.lock().unwrap();
|
||||
if !q.iter().any(|(u, p)| u == username && p == remote_path) && q.len() < 500 {
|
||||
q.push_back((username.to_string(), remote_path.to_string()));
|
||||
}
|
||||
let place = q.len() as u32;
|
||||
drop(q);
|
||||
reply(
|
||||
inner,
|
||||
username,
|
||||
peer::place_in_queue_response(remote_path, place),
|
||||
);
|
||||
}
|
||||
|
||||
fn reply(inner: &Arc<Inner>, username: &str, frame: Vec<u8>) {
|
||||
if let Some(h) = inner.peers.lock().unwrap().get(username) {
|
||||
let _ = h.write_tx.send(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a slot frees, start the next queued upload (proactive flow).
|
||||
fn pump_queue(inner: Arc<Inner>) {
|
||||
if !inner.has_free_upload_slot() {
|
||||
return;
|
||||
}
|
||||
let next = inner.upload_queue.lock().unwrap().pop_front();
|
||||
if let Some((username, remote_path)) = next {
|
||||
if let Some((local, size)) = inner.shares.resolve(&remote_path) {
|
||||
spawn_proactive(inner, username, remote_path, local, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------- immediate flow (peer opens the F connection) --------
|
||||
|
||||
fn spawn_immediate(
|
||||
inner: Arc<Inner>,
|
||||
username: String,
|
||||
remote_path: String,
|
||||
local: PathBuf,
|
||||
size: u64,
|
||||
their_token: u32,
|
||||
) {
|
||||
inner.uploads_active.fetch_add(1, Ordering::Relaxed);
|
||||
let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
// Register to receive their inbound F connection (keyed by their token).
|
||||
let (conn_tx, conn_rx) = oneshot::channel();
|
||||
inner
|
||||
.expected_transfers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((username.clone(), their_token), conn_tx);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let user_err = username.clone();
|
||||
run_upload(
|
||||
&inner,
|
||||
id,
|
||||
&username,
|
||||
&remote_path,
|
||||
&local,
|
||||
size,
|
||||
async move {
|
||||
let stream = tokio::time::timeout(ACK_TIMEOUT, conn_rx)
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|_| ClientError::PeerUnreachable(user_err))?;
|
||||
Ok::<TcpStream, ClientError>(stream)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
inner
|
||||
.expected_transfers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(username.clone(), their_token));
|
||||
inner.uploads_active.fetch_sub(1, Ordering::Relaxed);
|
||||
pump_queue(inner.clone());
|
||||
});
|
||||
}
|
||||
|
||||
// -------- proactive flow (we open the F connection) --------
|
||||
|
||||
fn spawn_proactive(
|
||||
inner: Arc<Inner>,
|
||||
username: String,
|
||||
remote_path: String,
|
||||
local: PathBuf,
|
||||
size: u64,
|
||||
) {
|
||||
inner.uploads_active.fetch_add(1, Ordering::Relaxed);
|
||||
let id = inner.upload_counter.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let inner2 = inner.clone();
|
||||
let user2 = username.clone();
|
||||
let path2 = remote_path.clone();
|
||||
run_upload(
|
||||
&inner,
|
||||
id,
|
||||
&username,
|
||||
&remote_path,
|
||||
&local,
|
||||
size,
|
||||
async move {
|
||||
// Offer the upload and wait for acceptance.
|
||||
let handle = conn::get_or_connect_peer(&inner2, &user2).await?;
|
||||
let token = inner2.next_token();
|
||||
let (ack_tx, ack_rx) = oneshot::channel();
|
||||
inner2
|
||||
.transfer_waits
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((user2.clone(), token), ack_tx);
|
||||
handle
|
||||
.write_tx
|
||||
.send(peer::transfer_request_upload(token, &path2, size))
|
||||
.map_err(|_| ClientError::PeerUnreachable(user2.clone()))?;
|
||||
let ack = tokio::time::timeout(ACK_TIMEOUT, ack_rx)
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)?
|
||||
.map_err(|_| ClientError::PeerUnreachable(user2.clone()))?;
|
||||
if !ack.allowed {
|
||||
return Err(ClientError::TransferRejected(ack.message));
|
||||
}
|
||||
conn::connect_transfer(&inner2, &user2, token).await
|
||||
},
|
||||
)
|
||||
.await;
|
||||
inner.uploads_active.fetch_sub(1, Ordering::Relaxed);
|
||||
pump_queue(inner.clone());
|
||||
});
|
||||
}
|
||||
|
||||
/// Shared upload driver: obtain the F stream via `get_stream`, read the
|
||||
/// 8-byte start offset, and stream the file from there.
|
||||
async fn run_upload<F>(
|
||||
inner: &Arc<Inner>,
|
||||
id: u64,
|
||||
username: &str,
|
||||
remote_path: &str,
|
||||
local: &PathBuf,
|
||||
size: u64,
|
||||
get_stream: F,
|
||||
) where
|
||||
F: std::future::Future<Output = Result<TcpStream>>,
|
||||
{
|
||||
let emit = |state: UploadState, sent: u64| {
|
||||
let _ = inner.event_tx.send(Event::Upload(UploadUpdate {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
filename: remote_path.to_string(),
|
||||
state,
|
||||
sent,
|
||||
total: size,
|
||||
}));
|
||||
};
|
||||
emit(UploadState::Transferring, 0);
|
||||
|
||||
let result = async {
|
||||
let mut stream = get_stream.await?;
|
||||
|
||||
let mut off = [0u8; 8];
|
||||
tokio::time::timeout(ACK_TIMEOUT, stream.read_exact(&mut off))
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)??;
|
||||
let offset = u64::from_le_bytes(off).min(size);
|
||||
|
||||
let mut file = tokio::fs::File::open(local).await?;
|
||||
file.seek(std::io::SeekFrom::Start(offset)).await?;
|
||||
|
||||
let mut sent = offset;
|
||||
let mut buf = vec![0u8; 64 * 1024];
|
||||
let mut window = Instant::now();
|
||||
let mut window_bytes: u64 = 0;
|
||||
let mut last_emit = Instant::now();
|
||||
|
||||
while sent < size {
|
||||
let n = file.read(&mut buf).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::timeout(STALL_TIMEOUT, stream.write_all(&buf[..n]))
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout)??;
|
||||
sent += n as u64;
|
||||
window_bytes += n as u64;
|
||||
|
||||
let elapsed = window.elapsed();
|
||||
if elapsed >= Duration::from_secs(2) {
|
||||
inner.upload_speed.store(
|
||||
(window_bytes as f64 / elapsed.as_secs_f64()) as u32,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
window = Instant::now();
|
||||
window_bytes = 0;
|
||||
}
|
||||
if last_emit.elapsed() >= Duration::from_millis(300) {
|
||||
last_emit = Instant::now();
|
||||
emit(UploadState::Transferring, sent);
|
||||
}
|
||||
}
|
||||
stream.flush().await?;
|
||||
Ok::<(), ClientError>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => emit(UploadState::Complete, size),
|
||||
Err(e) => emit(
|
||||
UploadState::Failed {
|
||||
reason: e.to_string(),
|
||||
},
|
||||
0,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Best-effort UPnP port mapping so firewalled peers can reach our
|
||||
//! listener directly. Failure is non-fatal — Soulseek's indirect
|
||||
//! (server-relayed) connection path still works without it.
|
||||
|
||||
use std::net::SocketAddrV4;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Try to map `port` (TCP) on the local gateway to ourselves. Returns the
|
||||
/// external address on success. Runs on a blocking thread.
|
||||
pub(crate) async fn try_map_port(port: u16) {
|
||||
let result = tokio::task::spawn_blocking(move || map_blocking(port)).await;
|
||||
match result {
|
||||
Ok(Ok(external)) => log::info!("UPnP: mapped port {port} (external {external})"),
|
||||
Ok(Err(e)) => log::info!("UPnP unavailable ({e}); relying on indirect connections"),
|
||||
Err(_) => log::debug!("UPnP task panicked"),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_blocking(port: u16) -> Result<SocketAddrV4, String> {
|
||||
use igd_next::{search_gateway, PortMappingProtocol, SearchOptions};
|
||||
|
||||
let gateway = search_gateway(SearchOptions {
|
||||
timeout: Some(Duration::from_secs(5)),
|
||||
..Default::default()
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Find our LAN address facing the gateway.
|
||||
let local_ip = local_ipv4().ok_or_else(|| "no local IPv4".to_string())?;
|
||||
let local = SocketAddrV4::new(local_ip, port);
|
||||
|
||||
gateway
|
||||
.add_port(
|
||||
PortMappingProtocol::TCP,
|
||||
port,
|
||||
std::net::SocketAddr::V4(local),
|
||||
0, // lease 0 = indefinite (until router reboots)
|
||||
"SoulStream",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match gateway.get_external_ip() {
|
||||
Ok(std::net::IpAddr::V4(ip)) => Ok(SocketAddrV4::new(ip, port)),
|
||||
_ => Ok(local),
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover our LAN IPv4 by opening a UDP socket toward a public address
|
||||
/// (no packets are actually sent).
|
||||
fn local_ipv4() -> Option<std::net::Ipv4Addr> {
|
||||
let sock = std::net::UdpSocket::bind("0.0.0.0:0").ok()?;
|
||||
sock.connect("8.8.8.8:80").ok()?;
|
||||
match sock.local_addr().ok()?.ip() {
|
||||
std::net::IpAddr::V4(ip) => Some(ip),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user