feat: multiple shared directories
Shares::scan_many merges several roots into one index, suffixing duplicate root names so virtual paths cannot collide, and skipping unreadable roots instead of failing. ClientSettings now takes shared_directories: Vec<String>; config gains a shared_dirs list (combined with the single shared_dir and --shared-dir, deduped), with an explicitly empty value still disabling sharing and the share-what-you-download default unchanged.
This commit is contained in:
@@ -23,27 +23,25 @@ impl Client {
|
|||||||
|
|
||||||
let listen_sender = sender.clone();
|
let listen_sender = sender.clone();
|
||||||
|
|
||||||
// Scan the shared directory once into the read-only index, and report
|
// Scan the shared directories once into the read-only index, and
|
||||||
// the real folder/file counts to the server on login.
|
// report the real folder/file counts to the server on login.
|
||||||
let shares = match self.shared_directory.as_deref() {
|
let roots: Vec<std::path::PathBuf> = self
|
||||||
Some(dir) if !dir.trim().is_empty() => {
|
.shared_directories
|
||||||
match Shares::scan(std::path::Path::new(dir)) {
|
.iter()
|
||||||
Ok(scanned) => {
|
.filter(|dir| !dir.trim().is_empty())
|
||||||
info!(
|
.map(std::path::PathBuf::from)
|
||||||
"Sharing {} files in {} folders from {}",
|
.collect();
|
||||||
scanned.file_count(),
|
let shares = if roots.is_empty() {
|
||||||
scanned.folder_count(),
|
Arc::new(Shares::empty())
|
||||||
dir
|
} else {
|
||||||
);
|
let scanned = Shares::scan_many(&roots);
|
||||||
Arc::new(scanned)
|
info!(
|
||||||
}
|
"Sharing {} files in {} folders from {} directories",
|
||||||
Err(e) => {
|
scanned.file_count(),
|
||||||
warn!("Failed to scan shared directory {}: {}", dir, e);
|
scanned.folder_count(),
|
||||||
Arc::new(Shares::empty())
|
roots.len()
|
||||||
}
|
);
|
||||||
}
|
Arc::new(scanned)
|
||||||
}
|
|
||||||
_ => Arc::new(Shares::empty()),
|
|
||||||
};
|
};
|
||||||
let shared_folder_count = shares.folder_count();
|
let shared_folder_count = shares.folder_count();
|
||||||
let shared_file_count = shares.file_count();
|
let shared_file_count = shares.file_count();
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ pub struct ClientSettings {
|
|||||||
pub server_address: PeerAddress,
|
pub server_address: PeerAddress,
|
||||||
pub enable_listen: bool,
|
pub enable_listen: bool,
|
||||||
pub listen_port: u16,
|
pub listen_port: u16,
|
||||||
/// Directory whose files are shared with (uploaded to) other peers.
|
/// Directories whose files are shared with (uploaded to) other peers.
|
||||||
/// `None` means nothing is shared.
|
/// Empty means nothing is shared.
|
||||||
pub shared_directory: Option<String>,
|
pub shared_directories: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientSettings {
|
impl ClientSettings {
|
||||||
@@ -126,7 +126,7 @@ impl Default for ClientSettings {
|
|||||||
),
|
),
|
||||||
enable_listen: true,
|
enable_listen: true,
|
||||||
listen_port: DEFAULT_LISTEN_PORT,
|
listen_port: DEFAULT_LISTEN_PORT,
|
||||||
shared_directory: None,
|
shared_directories: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,7 +617,7 @@ pub struct Client {
|
|||||||
address: PeerAddress,
|
address: PeerAddress,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
shared_directory: Option<String>,
|
shared_directories: Vec<String>,
|
||||||
server_handle: Option<ActorHandle<ServerMessage>>,
|
server_handle: Option<ActorHandle<ServerMessage>>,
|
||||||
context: Arc<RwLock<ClientContext>>,
|
context: Arc<RwLock<ClientContext>>,
|
||||||
}
|
}
|
||||||
@@ -639,16 +639,16 @@ impl Client {
|
|||||||
address: settings.server_address,
|
address: settings.server_address,
|
||||||
username: settings.username,
|
username: settings.username,
|
||||||
password: settings.password,
|
password: settings.password,
|
||||||
shared_directory: settings.shared_directory,
|
shared_directories: settings.shared_directories,
|
||||||
context: Arc::new(RwLock::new(ClientContext::new())),
|
context: Arc::new(RwLock::new(ClientContext::new())),
|
||||||
server_handle: None,
|
server_handle: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The directory whose files are shared with other peers, if configured.
|
/// The directories whose files are shared with other peers.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn shared_directory(&self) -> Option<&str> {
|
pub fn shared_directories(&self) -> &[String] {
|
||||||
self.shared_directory.as_deref()
|
&self.shared_directories
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,55 +44,55 @@ impl Shares {
|
|||||||
// Fail fast if the root is unreadable; deeper failures are skipped.
|
// Fail fast if the root is unreadable; deeper failures are skipped.
|
||||||
let _ = std::fs::read_dir(root)?;
|
let _ = std::fs::read_dir(root)?;
|
||||||
|
|
||||||
let root_name = root.file_name().map_or_else(
|
|
||||||
|| "shared".to_string(),
|
|
||||||
|n| n.to_string_lossy().into_owned(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut folders_with_files: HashSet<PathBuf> = HashSet::new();
|
let mut folder_count = 0;
|
||||||
let mut stack = vec![root.to_path_buf()];
|
scan_root(root, &root_display_name(root), &mut files, &mut folder_count);
|
||||||
|
Ok(Self::from_files(files, folder_count))
|
||||||
|
}
|
||||||
|
|
||||||
while let Some(dir) = stack.pop() {
|
/// Scan several roots into one merged index. Unreadable roots are
|
||||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
/// skipped (with a log line) instead of failing the whole scan, and
|
||||||
|
/// roots that share a directory name get a ` (2)`, ` (3)`… suffix so
|
||||||
|
/// their virtual paths cannot collide.
|
||||||
|
#[must_use]
|
||||||
|
pub fn scan_many(roots: &[PathBuf]) -> Self {
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut folder_count = 0;
|
||||||
|
let mut name_uses: HashMap<String, u32> = HashMap::new();
|
||||||
|
|
||||||
|
for root in roots {
|
||||||
|
if std::fs::read_dir(root).is_err() {
|
||||||
|
crate::warn!(
|
||||||
|
"Skipping unreadable shared directory {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let Ok(meta) = entry.metadata() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// `entry.metadata()` does not traverse the symlink itself.
|
|
||||||
if meta.file_type().is_symlink() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let path = entry.path();
|
|
||||||
if meta.is_dir() {
|
|
||||||
stack.push(path);
|
|
||||||
} else if meta.is_file() {
|
|
||||||
let virtual_path =
|
|
||||||
virtual_path_for(&root_name, root, &path);
|
|
||||||
files.push(SharedFile {
|
|
||||||
virtual_path,
|
|
||||||
real_path: path,
|
|
||||||
size: meta.len(),
|
|
||||||
attributes: Vec::new(),
|
|
||||||
});
|
|
||||||
folders_with_files.insert(dir.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
let base = root_display_name(root);
|
||||||
|
let uses = name_uses.entry(base.clone()).or_insert(0);
|
||||||
|
*uses += 1;
|
||||||
|
let name = if *uses == 1 {
|
||||||
|
base
|
||||||
|
} else {
|
||||||
|
format!("{base} ({uses})")
|
||||||
|
};
|
||||||
|
scan_root(root, &name, &mut files, &mut folder_count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Self::from_files(files, folder_count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_files(files: Vec<SharedFile>, folder_count: usize) -> Self {
|
||||||
let by_virtual = files
|
let by_virtual = files
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, f)| (f.virtual_path.clone(), i))
|
.map(|(i, f)| (f.virtual_path.clone(), i))
|
||||||
.collect();
|
.collect();
|
||||||
|
Self {
|
||||||
Ok(Self {
|
|
||||||
files,
|
files,
|
||||||
by_virtual,
|
by_virtual,
|
||||||
folder_count: folders_with_files.len(),
|
folder_count,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Files whose virtual path contains *every* whitespace-separated term of
|
/// Files whose virtual path contains *every* whitespace-separated term of
|
||||||
@@ -159,6 +159,54 @@ impl Shares {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn root_display_name(root: &Path) -> String {
|
||||||
|
root.file_name().map_or_else(
|
||||||
|
|| "shared".to_string(),
|
||||||
|
|n| n.to_string_lossy().into_owned(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively walk `root`, appending its files (under the virtual root name
|
||||||
|
/// `root_name`) to `files` and counting folders that contain files.
|
||||||
|
fn scan_root(
|
||||||
|
root: &Path,
|
||||||
|
root_name: &str,
|
||||||
|
files: &mut Vec<SharedFile>,
|
||||||
|
folder_count: &mut usize,
|
||||||
|
) {
|
||||||
|
let mut folders_with_files: HashSet<PathBuf> = HashSet::new();
|
||||||
|
let mut stack = vec![root.to_path_buf()];
|
||||||
|
|
||||||
|
while let Some(dir) = stack.pop() {
|
||||||
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let Ok(meta) = entry.metadata() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// `entry.metadata()` does not traverse the symlink itself.
|
||||||
|
if meta.file_type().is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if meta.is_dir() {
|
||||||
|
stack.push(path);
|
||||||
|
} else if meta.is_file() {
|
||||||
|
let virtual_path = virtual_path_for(root_name, root, &path);
|
||||||
|
files.push(SharedFile {
|
||||||
|
virtual_path,
|
||||||
|
real_path: path,
|
||||||
|
size: meta.len(),
|
||||||
|
attributes: Vec::new(),
|
||||||
|
});
|
||||||
|
folders_with_files.insert(dir.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*folder_count += folders_with_files.len();
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the peer-facing virtual path for `path` under `root`: the root's own
|
/// Build the peer-facing virtual path for `path` under `root`: the root's own
|
||||||
/// name followed by the backslash-separated components relative to it.
|
/// name followed by the backslash-separated components relative to it.
|
||||||
fn virtual_path_for(root_name: &str, root: &Path, path: &Path) -> String {
|
fn virtual_path_for(root_name: &str, root: &Path, path: &Path) -> String {
|
||||||
@@ -226,6 +274,57 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(root);
|
let _ = std::fs::remove_dir_all(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_many_merges_roots() {
|
||||||
|
let root_a = temp_tree();
|
||||||
|
let root_b = temp_tree();
|
||||||
|
let shares =
|
||||||
|
Shares::scan_many(&[root_a.clone(), root_b.clone()]);
|
||||||
|
assert_eq!(shares.file_count(), 6);
|
||||||
|
assert_eq!(shares.folder_count(), 4);
|
||||||
|
// Files from both roots are reachable under their root's name.
|
||||||
|
let name_a = root_a.file_name().unwrap().to_string_lossy();
|
||||||
|
let name_b = root_b.file_name().unwrap().to_string_lossy();
|
||||||
|
assert!(shares.get(&format!("{name_a}\\top.mp3")).is_some());
|
||||||
|
assert!(shares.get(&format!("{name_b}\\top.mp3")).is_some());
|
||||||
|
let _ = std::fs::remove_dir_all(root_a);
|
||||||
|
let _ = std::fs::remove_dir_all(root_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_many_disambiguates_equal_root_names() {
|
||||||
|
// Two different directories both named "music".
|
||||||
|
let parent_a = temp_tree();
|
||||||
|
let parent_b = temp_tree();
|
||||||
|
let music_a = parent_a.join("music");
|
||||||
|
let music_b = parent_b.join("music");
|
||||||
|
std::fs::create_dir_all(&music_a).unwrap();
|
||||||
|
std::fs::create_dir_all(&music_b).unwrap();
|
||||||
|
std::fs::write(music_a.join("a.mp3"), b"a").unwrap();
|
||||||
|
std::fs::write(music_b.join("b.mp3"), b"b").unwrap();
|
||||||
|
|
||||||
|
let shares =
|
||||||
|
Shares::scan_many(&[music_a.clone(), music_b.clone()]);
|
||||||
|
assert!(shares.get("music\\a.mp3").is_some());
|
||||||
|
assert!(
|
||||||
|
shares.get("music (2)\\b.mp3").is_some(),
|
||||||
|
"second root with the same name gets a numbered suffix"
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_dir_all(parent_a);
|
||||||
|
let _ = std::fs::remove_dir_all(parent_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scan_many_skips_unreadable_roots() {
|
||||||
|
let root = temp_tree();
|
||||||
|
let shares = Shares::scan_many(&[
|
||||||
|
std::path::PathBuf::from("/does/not/exist"),
|
||||||
|
root.clone(),
|
||||||
|
]);
|
||||||
|
assert_eq!(shares.file_count(), 3);
|
||||||
|
let _ = std::fs::remove_dir_all(root);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_shares_have_no_files_or_folders() {
|
fn empty_shares_have_no_files_or_folders() {
|
||||||
let shares = Shares::empty();
|
let shares = Shares::empty();
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ impl TestServer {
|
|||||||
server_address: PeerAddress::new(self.host.clone(), self.port),
|
server_address: PeerAddress::new(self.host.clone(), self.port),
|
||||||
enable_listen: false,
|
enable_listen: false,
|
||||||
listen_port: 0,
|
listen_port: 0,
|
||||||
shared_directory: None,
|
shared_directories: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1164,7 +1164,7 @@ fn two_real_clients_search_and_download() {
|
|||||||
|
|
||||||
let sharer_port = free_port().expect("sharer port");
|
let sharer_port = free_port().expect("sharer port");
|
||||||
let mut sharer = Client::with_settings(ClientSettings {
|
let mut sharer = Client::with_settings(ClientSettings {
|
||||||
shared_directory: Some(share_dir.display().to_string()),
|
shared_directories: vec![share_dir.display().to_string()],
|
||||||
..server.listening_settings("e2e_sharer", "pw", sharer_port)
|
..server.listening_settings("e2e_sharer", "pw", sharer_port)
|
||||||
});
|
});
|
||||||
sharer.connect().expect("sharer connect");
|
sharer.connect().expect("sharer connect");
|
||||||
@@ -1260,7 +1260,7 @@ fn browse_a_peers_shared_files() {
|
|||||||
|
|
||||||
let sharer_port = free_port().expect("sharer port");
|
let sharer_port = free_port().expect("sharer port");
|
||||||
let mut sharer = Client::with_settings(ClientSettings {
|
let mut sharer = Client::with_settings(ClientSettings {
|
||||||
shared_directory: Some(share_dir.display().to_string()),
|
shared_directories: vec![share_dir.display().to_string()],
|
||||||
..server.listening_settings("e2e_browsee", "pw", sharer_port)
|
..server.listening_settings("e2e_browsee", "pw", sharer_port)
|
||||||
});
|
});
|
||||||
sharer.connect().expect("sharer connect");
|
sharer.connect().expect("sharer connect");
|
||||||
@@ -1316,7 +1316,7 @@ fn browse_a_firewalled_peer_via_broker() {
|
|||||||
// Sharer does NOT listen (firewalled): the browser's direct dial will fail,
|
// Sharer does NOT listen (firewalled): the browser's direct dial will fail,
|
||||||
// forcing the server-brokered connect-back.
|
// forcing the server-brokered connect-back.
|
||||||
let mut sharer = Client::with_settings(ClientSettings {
|
let mut sharer = Client::with_settings(ClientSettings {
|
||||||
shared_directory: Some(share_dir.display().to_string()),
|
shared_directories: vec![share_dir.display().to_string()],
|
||||||
..server.settings("e2e_fw_sharer", "pw")
|
..server.settings("e2e_fw_sharer", "pw")
|
||||||
});
|
});
|
||||||
sharer.connect().expect("sharer connect");
|
sharer.connect().expect("sharer connect");
|
||||||
|
|||||||
@@ -10,5 +10,5 @@ pub struct SearchConfig {
|
|||||||
pub download_dir: String,
|
pub download_dir: String,
|
||||||
pub verbose: u8,
|
pub verbose: u8,
|
||||||
pub max_concurrent_downloads: usize,
|
pub max_concurrent_downloads: usize,
|
||||||
pub shared_directory: Option<String>,
|
pub shared_directories: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ pub fn resolve_shared_directory(
|
|||||||
Ok(Some(path))
|
Ok(Some(path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate several configured share paths; invalid ones are skipped with a
|
||||||
|
/// warning instead of aborting (the client just shares less).
|
||||||
|
pub fn resolve_shared_directories(dirs: &[String]) -> Vec<String> {
|
||||||
|
let mut resolved = Vec::new();
|
||||||
|
for dir in dirs {
|
||||||
|
match resolve_shared_directory(Some(dir)) {
|
||||||
|
Ok(Some(path)) => resolved.push(path.display().to_string()),
|
||||||
|
Ok(None) => {}
|
||||||
|
Err(e) => eprintln!("⚠️ Ignoring shared directory: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::resolve_shared_directory;
|
use super::resolve_shared_directory;
|
||||||
|
|||||||
+10
-24
@@ -72,17 +72,10 @@ fn main() -> Result<()> {
|
|||||||
|
|
||||||
let (server_host, server_port) = parse_server_address(&resolved.server)?;
|
let (server_host, server_port) = parse_server_address(&resolved.server)?;
|
||||||
|
|
||||||
// Resolve the optional shared/upload directory up front; a misconfigured
|
// Resolve the configured shared/upload directories up front; a
|
||||||
// one is a warning, not a fatal error (the client just shares nothing).
|
// misconfigured one is a warning, not a fatal error.
|
||||||
let shared_directory = match directories::resolve_shared_directory(
|
let shared_directories =
|
||||||
resolved.shared_dir.as_deref(),
|
directories::resolve_shared_directories(&resolved.shared_dirs);
|
||||||
) {
|
|
||||||
Ok(dir) => dir.map(|path| path.display().to_string()),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("⚠️ Ignoring shared directory: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let settings = ClientSettings {
|
let settings = ClientSettings {
|
||||||
username: username.clone(),
|
username: username.clone(),
|
||||||
@@ -90,7 +83,7 @@ fn main() -> Result<()> {
|
|||||||
server_address: PeerAddress::new(server_host.clone(), server_port),
|
server_address: PeerAddress::new(server_host.clone(), server_port),
|
||||||
enable_listen: !resolved.disable_listener,
|
enable_listen: !resolved.disable_listener,
|
||||||
listen_port: resolved.listener_port,
|
listen_port: resolved.listener_port,
|
||||||
shared_directory: shared_directory.clone(),
|
shared_directories: shared_directories.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
@@ -112,7 +105,7 @@ fn main() -> Result<()> {
|
|||||||
download_dir,
|
download_dir,
|
||||||
verbose: cli.verbose,
|
verbose: cli.verbose,
|
||||||
max_concurrent_downloads,
|
max_concurrent_downloads,
|
||||||
shared_directory,
|
shared_directories,
|
||||||
};
|
};
|
||||||
search_and_download(config)
|
search_and_download(config)
|
||||||
}
|
}
|
||||||
@@ -182,15 +175,8 @@ fn run_default_tui(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let shared_directory = match directories::resolve_shared_directory(
|
let shared_directories =
|
||||||
resolved.shared_dir.as_deref(),
|
directories::resolve_shared_directories(&resolved.shared_dirs);
|
||||||
) {
|
|
||||||
Ok(dir) => dir.map(|path| path.display().to_string()),
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("⚠️ Ignoring shared directory: {e}");
|
|
||||||
None
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let secret_store = persist::secret::KeyringStore;
|
let secret_store = persist::secret::KeyringStore;
|
||||||
let initial_password = persist::secret::resolve_password(
|
let initial_password = persist::secret::resolve_password(
|
||||||
@@ -217,7 +203,7 @@ fn run_default_tui(
|
|||||||
server_address: PeerAddress::new(server_host.clone(), server_port),
|
server_address: PeerAddress::new(server_host.clone(), server_port),
|
||||||
enable_listen,
|
enable_listen,
|
||||||
listen_port,
|
listen_port,
|
||||||
shared_directory: shared_directory.clone(),
|
shared_directories: shared_directories.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clear screen and enable mouse capture before initializing TUI
|
// Clear screen and enable mouse capture before initializing TUI
|
||||||
@@ -475,7 +461,7 @@ fn search_and_download(config: SearchConfig) -> Result<()> {
|
|||||||
),
|
),
|
||||||
enable_listen: config.enable_listener,
|
enable_listen: config.enable_listener,
|
||||||
listen_port: config.listener_port,
|
listen_port: config.listener_port,
|
||||||
shared_directory: config.shared_directory.clone(),
|
shared_directories: config.shared_directories.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let _port_mapper = settings
|
let _port_mapper = settings
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ pub struct FileConfig {
|
|||||||
pub listener_port: Option<u16>,
|
pub listener_port: Option<u16>,
|
||||||
pub disable_listener: Option<bool>,
|
pub disable_listener: Option<bool>,
|
||||||
pub download_dir: Option<String>,
|
pub download_dir: Option<String>,
|
||||||
|
/// Single shared folder (also what `--shared-dir` sets). Prefer
|
||||||
|
/// `shared_dirs` for multiple; both may be combined.
|
||||||
pub shared_dir: Option<String>,
|
pub shared_dir: Option<String>,
|
||||||
|
/// Multiple shared folders. An explicitly empty list disables sharing.
|
||||||
|
pub shared_dirs: Option<Vec<String>>,
|
||||||
pub max_concurrent_downloads: Option<usize>,
|
pub max_concurrent_downloads: Option<usize>,
|
||||||
pub search_timeout: Option<u64>,
|
pub search_timeout: Option<u64>,
|
||||||
/// Command whose stdout is the password (headless fallback, like mutt's
|
/// Command whose stdout is the password (headless fallback, like mutt's
|
||||||
@@ -61,7 +65,7 @@ pub struct Resolved {
|
|||||||
pub listener_port: u16,
|
pub listener_port: u16,
|
||||||
pub disable_listener: bool,
|
pub disable_listener: bool,
|
||||||
pub download_dir: String,
|
pub download_dir: String,
|
||||||
pub shared_dir: Option<String>,
|
pub shared_dirs: Vec<String>,
|
||||||
pub max_concurrent_downloads: usize,
|
pub max_concurrent_downloads: usize,
|
||||||
pub search_timeout: u64,
|
pub search_timeout: u64,
|
||||||
pub password_cmd: Option<String>,
|
pub password_cmd: Option<String>,
|
||||||
@@ -98,14 +102,7 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
|
|||||||
disable_listener: cli.disable_listener
|
disable_listener: cli.disable_listener
|
||||||
|| file.disable_listener.unwrap_or(false),
|
|| file.disable_listener.unwrap_or(false),
|
||||||
download_dir: download_dir.clone(),
|
download_dir: download_dir.clone(),
|
||||||
// Soulseek convention: share what you download. An explicitly empty
|
shared_dirs: resolve_shared_dirs(cli, file, &download_dir),
|
||||||
// shared_dir ("") opts out of sharing entirely.
|
|
||||||
shared_dir: cli
|
|
||||||
.shared_dir
|
|
||||||
.clone()
|
|
||||||
.or_else(|| file.shared_dir.clone())
|
|
||||||
.or(Some(download_dir))
|
|
||||||
.filter(|dir| !dir.trim().is_empty()),
|
|
||||||
max_concurrent_downloads: cli
|
max_concurrent_downloads: cli
|
||||||
.max_concurrent_downloads
|
.max_concurrent_downloads
|
||||||
.or(file.max_concurrent_downloads)
|
.or(file.max_concurrent_downloads)
|
||||||
@@ -118,6 +115,33 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sharing follows the Soulseek convention of sharing what you download:
|
||||||
|
/// with nothing configured, the download folder is shared. Configuring any
|
||||||
|
/// of `--shared-dir` / `shared_dir` / `shared_dirs` replaces that default
|
||||||
|
/// (their non-empty values are combined), and an explicitly empty value
|
||||||
|
/// (`shared_dir = ""` or `shared_dirs = []`) disables sharing entirely.
|
||||||
|
fn resolve_shared_dirs(
|
||||||
|
cli: &crate::cli::Cli,
|
||||||
|
file: &FileConfig,
|
||||||
|
download_dir: &str,
|
||||||
|
) -> Vec<String> {
|
||||||
|
if cli.shared_dir.is_none()
|
||||||
|
&& file.shared_dir.is_none()
|
||||||
|
&& file.shared_dirs.is_none()
|
||||||
|
{
|
||||||
|
return vec![download_dir.to_string()];
|
||||||
|
}
|
||||||
|
let mut dirs: Vec<String> = Vec::new();
|
||||||
|
let singles = cli.shared_dir.iter().chain(file.shared_dir.iter());
|
||||||
|
for dir in singles.chain(file.shared_dirs.iter().flatten()) {
|
||||||
|
let dir = dir.trim();
|
||||||
|
if !dir.is_empty() && !dirs.iter().any(|d| d == dir) {
|
||||||
|
dirs.push(dir.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -169,22 +193,19 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_dir_defaults_to_the_download_dir() {
|
fn sharing_defaults_to_the_download_dir() {
|
||||||
let resolved = resolve(&bare_cli(), &FileConfig::default());
|
let resolved = resolve(&bare_cli(), &FileConfig::default());
|
||||||
assert_eq!(
|
assert_eq!(resolved.shared_dirs, vec![resolved.download_dir.clone()]);
|
||||||
resolved.shared_dir.as_deref(),
|
|
||||||
Some(resolved.download_dir.as_str())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shared_dir_follows_a_customized_download_dir() {
|
fn sharing_follows_a_customized_download_dir() {
|
||||||
let file = FileConfig {
|
let file = FileConfig {
|
||||||
download_dir: Some("/music".into()),
|
download_dir: Some("/music".into()),
|
||||||
..FileConfig::default()
|
..FileConfig::default()
|
||||||
};
|
};
|
||||||
let resolved = resolve(&bare_cli(), &file);
|
let resolved = resolve(&bare_cli(), &file);
|
||||||
assert_eq!(resolved.shared_dir.as_deref(), Some("/music"));
|
assert_eq!(resolved.shared_dirs, vec!["/music".to_string()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -194,7 +215,45 @@ mod tests {
|
|||||||
..FileConfig::default()
|
..FileConfig::default()
|
||||||
};
|
};
|
||||||
let resolved = resolve(&bare_cli(), &file);
|
let resolved = resolve(&bare_cli(), &file);
|
||||||
assert_eq!(resolved.shared_dir, None);
|
assert!(resolved.shared_dirs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_shared_dirs_list_disables_sharing() {
|
||||||
|
let file = FileConfig {
|
||||||
|
shared_dirs: Some(Vec::new()),
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let resolved = resolve(&bare_cli(), &file);
|
||||||
|
assert!(resolved.shared_dirs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_dirs_list_replaces_the_default_share() {
|
||||||
|
let file = FileConfig {
|
||||||
|
shared_dirs: Some(vec!["/a".into(), "/b".into()]),
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let resolved = resolve(&bare_cli(), &file);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.shared_dirs,
|
||||||
|
vec!["/a".to_string(), "/b".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_shared_dir_combines_with_file_list_and_dedupes() {
|
||||||
|
let mut cli = bare_cli();
|
||||||
|
cli.shared_dir = Some("/a".into());
|
||||||
|
let file = FileConfig {
|
||||||
|
shared_dirs: Some(vec!["/a".into(), "/b".into()]),
|
||||||
|
..FileConfig::default()
|
||||||
|
};
|
||||||
|
let resolved = resolve(&cli, &file);
|
||||||
|
assert_eq!(
|
||||||
|
resolved.shared_dirs,
|
||||||
|
vec!["/a".to_string(), "/b".to_string()]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -206,6 +265,7 @@ mod tests {
|
|||||||
disable_listener: Some(true),
|
disable_listener: Some(true),
|
||||||
download_dir: Some("/music".into()),
|
download_dir: Some("/music".into()),
|
||||||
shared_dir: Some("/shared".into()),
|
shared_dir: Some("/shared".into()),
|
||||||
|
shared_dirs: None,
|
||||||
max_concurrent_downloads: Some(2),
|
max_concurrent_downloads: Some(2),
|
||||||
search_timeout: Some(30),
|
search_timeout: Some(30),
|
||||||
password_cmd: Some("pass show slsk".into()),
|
password_cmd: Some("pass show slsk".into()),
|
||||||
@@ -216,7 +276,7 @@ mod tests {
|
|||||||
assert_eq!(resolved.listener_port, 4321);
|
assert_eq!(resolved.listener_port, 4321);
|
||||||
assert!(resolved.disable_listener);
|
assert!(resolved.disable_listener);
|
||||||
assert_eq!(resolved.download_dir, "/music");
|
assert_eq!(resolved.download_dir, "/music");
|
||||||
assert_eq!(resolved.shared_dir.as_deref(), Some("/shared"));
|
assert_eq!(resolved.shared_dirs, vec!["/shared".to_string()]);
|
||||||
assert_eq!(resolved.max_concurrent_downloads, 2);
|
assert_eq!(resolved.max_concurrent_downloads, 2);
|
||||||
assert_eq!(resolved.search_timeout, 30);
|
assert_eq!(resolved.search_timeout, 30);
|
||||||
assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk"));
|
assert_eq!(resolved.password_cmd.as_deref(), Some("pass show slsk"));
|
||||||
|
|||||||
@@ -353,10 +353,13 @@ impl MainTui {
|
|||||||
|
|
||||||
let shortcuts_line = format_shortcuts_styled(&shortcuts);
|
let shortcuts_line = format_shortcuts_styled(&shortcuts);
|
||||||
// Surface our own sharing status in the block title.
|
// Surface our own sharing status in the block title.
|
||||||
let title = format!(
|
let shared = self.client.shared_directories();
|
||||||
"Shortcuts · Sharing: {}",
|
let sharing = match shared {
|
||||||
self.client.shared_directory().unwrap_or("off")
|
[] => "off".to_string(),
|
||||||
);
|
[only] => only.clone(),
|
||||||
|
more => format!("{} folders", more.len()),
|
||||||
|
};
|
||||||
|
let title = format!("Shortcuts · Sharing: {sharing}");
|
||||||
let shortcuts_widget = Paragraph::new(shortcuts_line).block(
|
let shortcuts_widget = Paragraph::new(shortcuts_line).block(
|
||||||
Block::default()
|
Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
|
|||||||
Reference in New Issue
Block a user