The window could show any peer's share tree but never this account's, so "am I actually sharing that?" had no answer short of asking a friend to browse you. The tree is already there; what was missing was feeding it from our own index. B opens it — the same collapsible tree, filter, and navigation the peer browse offers, built from `Shares::directories()`, which is the listing GetShareFileList answers with. So what it shows is what the network sees, not what the config meant to share. r re-scans the disk rather than re-asking a peer, o steps across to the folders that feed it, and nothing here queues a download from ourselves. Attached to a daemon the index lives on the daemon's host, so it comes back over a new `shares.files` method rather than being scanned locally; `shares files [--filter]` prints the same listing headless, through the `emit_listing` that `browse` now shares with it — the two are only comparable while they are built the same way. Two allocation fixes came out of the review, both on paths this reaches: the daemon hands its listing to the DTO instead of deep-copying one it already owns, and `Shares::directories()` allocates its map key once per folder rather than once per file, which the peer-facing serve path has been paying for all along.
697 lines
26 KiB
Rust
697 lines
26 KiB
Rust
//! Wiring the command line to a session: which config, which credentials, and
|
|
//! whether this run opens a session of its own or borrows a daemon's.
|
|
|
|
use crate::cli::{
|
|
Cli, Commands, ConfigCommand, DaemonCommand, SharesCommand, WishCommand,
|
|
parse_server_address,
|
|
};
|
|
use crate::commands::Ctx;
|
|
use crate::models::TuiExit;
|
|
use crate::output::{CliError, CliResult, Exit, Out};
|
|
use crate::persist::secret::SecretStore;
|
|
use crate::ui::launch_main_tui;
|
|
use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress};
|
|
use std::io::{BufRead, IsTerminal};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use std::{env, io};
|
|
|
|
pub fn run(mut cli: Cli, out: &Out) -> CliResult {
|
|
// Before anything opens a socket: every entry point that logs in — the
|
|
// TUI, `serve`, one-shot commands, the daemon — holds a socket per peer,
|
|
// and the soft limit a shell hands its children (256 on macOS) is gone
|
|
// after one busy search. The raise lived in the daemon first; the other
|
|
// entry points run the same peer machinery and crashed the same way.
|
|
match raise_open_file_limit() {
|
|
Ok(limit) if limit < WANTED_OPEN_FILES => out.warn(&format!(
|
|
"only {limit} open files allowed; a busy search wants \
|
|
{WANTED_OPEN_FILES}. Raise it with `ulimit -n \
|
|
{WANTED_OPEN_FILES}` first."
|
|
)),
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
out.warn(&format!("could not check the open-file limit: {e}"));
|
|
}
|
|
}
|
|
|
|
let config_path = config_path(&cli);
|
|
let file_config = match &config_path {
|
|
Some(path) => crate::persist::config::FileConfig::load(path)
|
|
.map_err(|e| CliError::usage(e.to_string()))?,
|
|
None => crate::persist::config::FileConfig::default(),
|
|
};
|
|
let resolved = crate::persist::config::resolve(&cli, &file_config);
|
|
|
|
let Some(command) = cli.command.take() else {
|
|
// Without a terminal the TUI would panic on its first draw. A script
|
|
// that reached this point wanted a subcommand, so say that instead of
|
|
// crashing.
|
|
if !std::io::stdout().is_terminal() {
|
|
return Err(CliError::usage(
|
|
"the interactive interface needs a terminal; run a subcommand \
|
|
for scripted use (see --help)",
|
|
));
|
|
}
|
|
// A running daemon already holds a session; the TUI attaches to it
|
|
// rather than logging in a second time, which the server would answer
|
|
// by cutting the daemon off.
|
|
if let Some(endpoint) = daemon_endpoint(&cli, &resolved) {
|
|
return run_attached_tui(&resolved, &endpoint, config_path);
|
|
}
|
|
return run_default_tui(&cli, &resolved, config_path);
|
|
};
|
|
|
|
// Anything that only reads the network stack or the config file runs
|
|
// before credentials are demanded: asking a script for a login it does not
|
|
// need would be a poor trade for a `config get`.
|
|
let store = crate::commands::settings::Store {
|
|
path: config_path,
|
|
config: file_config,
|
|
};
|
|
match command {
|
|
Commands::Portmap => {
|
|
return crate::commands::portmap(out, resolved.listener_port);
|
|
}
|
|
Commands::Skills(ref command) => {
|
|
return crate::commands::skills::run(out, command);
|
|
}
|
|
// Forgetting a password needs no session — least of all the one it is
|
|
// about to make unusable.
|
|
Commands::Account(crate::cli::AccountCommand::Logout) => {
|
|
return crate::commands::account::logout(
|
|
out,
|
|
resolved.username.as_deref(),
|
|
);
|
|
}
|
|
Commands::Completions(ref command) => {
|
|
return crate::commands::completions::run(out, command);
|
|
}
|
|
Commands::Config(ConfigCommand::Path) => {
|
|
return crate::commands::settings::config_path(out, &store);
|
|
}
|
|
Commands::Config(ConfigCommand::List) => {
|
|
crate::commands::settings::config_list(out, &store);
|
|
return Ok(());
|
|
}
|
|
Commands::Config(ConfigCommand::Get { ref key }) => {
|
|
return crate::commands::settings::config_get(out, &store, key);
|
|
}
|
|
Commands::Config(ConfigCommand::Set { ref key, ref value }) => {
|
|
return crate::commands::settings::config_set(
|
|
out,
|
|
&store,
|
|
key,
|
|
value,
|
|
|| live_daemon(&cli, &resolved),
|
|
);
|
|
}
|
|
Commands::Shares(SharesCommand::List) => {
|
|
return crate::commands::settings::shares_list(
|
|
out,
|
|
&store,
|
|
&resolved.shared_dirs,
|
|
);
|
|
}
|
|
Commands::Shares(SharesCommand::Add { ref directory }) => {
|
|
return crate::commands::settings::shares_add(
|
|
out,
|
|
&store,
|
|
directory,
|
|
&live_daemon(&cli, &resolved),
|
|
);
|
|
}
|
|
Commands::Shares(SharesCommand::Remove { ref directory }) => {
|
|
return crate::commands::settings::shares_remove(
|
|
out,
|
|
&store,
|
|
directory,
|
|
&live_daemon(&cli, &resolved),
|
|
);
|
|
}
|
|
Commands::Wish(WishCommand::Add { ref query }) => {
|
|
return crate::commands::wish::add(out, &store, query);
|
|
}
|
|
Commands::Wish(WishCommand::Remove { ref query }) => {
|
|
return crate::commands::wish::remove(out, &store, query);
|
|
}
|
|
Commands::Wish(WishCommand::List) => {
|
|
return crate::commands::wish::list(out, &store);
|
|
}
|
|
// Controlling a daemon needs no account of our own: the daemon is
|
|
// already logged in, and demanding credentials a script never had
|
|
// would turn "is it running?" into a configuration error.
|
|
Commands::Daemon(crate::cli::DaemonArgs {
|
|
command: Some(ref command),
|
|
..
|
|
}) => {
|
|
let endpoint = daemon_endpoint(&cli, &resolved);
|
|
let token = resolved.daemon_token;
|
|
return match command {
|
|
DaemonCommand::Token => crate::commands::daemon::token(out),
|
|
DaemonCommand::Status => crate::commands::daemon::status(
|
|
out,
|
|
endpoint.as_ref(),
|
|
token,
|
|
),
|
|
DaemonCommand::Stop => {
|
|
crate::commands::daemon::stop(out, endpoint.as_ref(), token)
|
|
}
|
|
};
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
let ctx = context(&cli, &resolved, out)?;
|
|
match command {
|
|
Commands::Shares(SharesCommand::Status) => {
|
|
crate::commands::settings::shares_status(&ctx)
|
|
}
|
|
Commands::Shares(SharesCommand::Files { ref filter }) => {
|
|
crate::commands::settings::shares_files(&ctx, filter.as_deref())
|
|
}
|
|
Commands::Shares(SharesCommand::Reindex) => {
|
|
crate::commands::settings::shares_reindex(&ctx)
|
|
}
|
|
Commands::Wish(WishCommand::Run(ref args)) => {
|
|
crate::commands::wish::run(&ctx, &store, args)
|
|
}
|
|
Commands::Daemon(ref args) => {
|
|
crate::daemon::run(&ctx, args.bind.as_deref(), args.upload_slots)
|
|
}
|
|
Commands::Serve(ref args) => {
|
|
let sweeper = args
|
|
.wishlist
|
|
.then(|| {
|
|
crate::commands::wish::Sweeper::new(store.config.wishes())
|
|
})
|
|
.flatten();
|
|
if args.wishlist && sweeper.is_none() {
|
|
out.warn(
|
|
"--wishlist has nothing to look for; add one with \
|
|
`wish add QUERY`",
|
|
);
|
|
}
|
|
crate::commands::peer::serve(&ctx, args, sweeper)
|
|
}
|
|
other => crate::commands::run(&ctx, other),
|
|
}
|
|
}
|
|
|
|
/// Which config file to read: an explicit `--config`, the platform default,
|
|
/// or none at all under `--no-config`.
|
|
fn config_path(cli: &Cli) -> Option<PathBuf> {
|
|
if cli.no_config {
|
|
return None;
|
|
}
|
|
cli.config
|
|
.clone()
|
|
.or_else(crate::persist::paths::config_file)
|
|
}
|
|
|
|
/// Assemble everything a one-shot command needs, demanding the credentials
|
|
/// the interactive login screen would otherwise ask for.
|
|
fn context(
|
|
cli: &Cli,
|
|
resolved: &crate::persist::config::Resolved,
|
|
out: &Out,
|
|
) -> CliResult<Ctx> {
|
|
let daemon = daemon_endpoint(cli, resolved);
|
|
// A run routed to a daemon needs no account of its own: the daemon is
|
|
// already logged in, and asking for credentials it will not use would
|
|
// stop a script that never had any.
|
|
let (username, password) = if daemon.is_some() {
|
|
(String::new(), String::new())
|
|
} else {
|
|
(
|
|
resolved.username.clone().ok_or_else(missing_username)?,
|
|
password(cli, resolved)?.ok_or_else(|| {
|
|
CliError::usage(
|
|
"no password: pass --password/--password-stdin, set \
|
|
SOULSEEK_PASSWORD, store one in the OS keychain, or set \
|
|
password_cmd",
|
|
)
|
|
})?,
|
|
)
|
|
};
|
|
let (host, port) =
|
|
parse_server_address(&resolved.server).map_err(CliError::usage)?;
|
|
|
|
// A daemon has its own server and its own filesystem. Asking it once, up
|
|
// front, is what keeps every command that reports a path or a server
|
|
// honest — `whoami` naming the right server, `download` naming the file
|
|
// where the bytes actually landed.
|
|
let described = daemon.as_ref().and_then(|endpoint| {
|
|
describe_daemon(endpoint, resolved.daemon_token.clone())
|
|
});
|
|
let server_address = described.as_ref().map_or_else(
|
|
|| PeerAddress::new(host, port),
|
|
|status| {
|
|
parse_server_address(&status.server).map_or_else(
|
|
|_| PeerAddress::new(status.server.clone(), 0),
|
|
|(host, port)| PeerAddress::new(host, port),
|
|
)
|
|
},
|
|
);
|
|
let download_dir = described.as_ref().map_or_else(
|
|
|| resolved.download_dir.clone(),
|
|
|status| status.download_dir.clone(),
|
|
);
|
|
|
|
Ok(Ctx {
|
|
out: out.clone(),
|
|
settings: ClientSettings {
|
|
username,
|
|
password,
|
|
server_address,
|
|
enable_listen: !resolved.disable_listener,
|
|
listen_port: resolved.listener_port,
|
|
shared_directories: shared_directories(resolved, out),
|
|
accept_children: resolved.accept_children,
|
|
version: ClientVersion::REFERENCE_CLIENT,
|
|
},
|
|
download_dir,
|
|
max_concurrent_downloads: resolved.max_concurrent_downloads,
|
|
search_timeout: Duration::from_secs(resolved.search_timeout),
|
|
daemon,
|
|
daemon_token: resolved.daemon_token.clone(),
|
|
session: std::sync::OnceLock::new(),
|
|
})
|
|
}
|
|
|
|
/// The daemon a config change should also reach, if one is running.
|
|
fn live_daemon(
|
|
cli: &Cli,
|
|
resolved: &crate::persist::config::Resolved,
|
|
) -> crate::commands::settings::LiveDaemon {
|
|
crate::commands::settings::LiveDaemon {
|
|
endpoint: daemon_endpoint(cli, resolved),
|
|
token: resolved.daemon_token.clone(),
|
|
}
|
|
}
|
|
|
|
/// Ask a daemon what it is, so this run describes the session it will actually
|
|
/// use. A daemon that cannot be reached is left to `Session::open` to report,
|
|
/// which produces a better message than anything this early probe could.
|
|
fn describe_daemon(
|
|
endpoint: &crate::remote::Endpoint,
|
|
token: Option<String>,
|
|
) -> Option<crate::daemon::proto::DaemonStatus> {
|
|
let token = token.or_else(crate::daemon::stored_token);
|
|
crate::remote::RemoteSession::connect(endpoint, token)
|
|
.ok()?
|
|
.ask_for(crate::daemon::proto::Method::DaemonStatus)
|
|
.ok()
|
|
}
|
|
|
|
/// Which daemon this run should use, if any.
|
|
///
|
|
/// An address given explicitly always wins. Otherwise a daemon listening on
|
|
/// the local socket is used automatically — the convention Docker and
|
|
/// ssh-agent set, and what lets an agent run a command with no flags at all.
|
|
/// With nothing listening, the run opens its own session exactly as it always
|
|
/// has.
|
|
fn daemon_endpoint(
|
|
cli: &Cli,
|
|
resolved: &crate::persist::config::Resolved,
|
|
) -> Option<crate::remote::Endpoint> {
|
|
if cli.no_daemon {
|
|
return None;
|
|
}
|
|
// Flag, then environment, then config file — `resolved` has already
|
|
// layered those — and otherwise a daemon on this machine, if one is up.
|
|
if let Some(address) = &resolved.daemon {
|
|
return Some(crate::remote::Endpoint::parse(address));
|
|
}
|
|
crate::daemon::local_daemon().map(crate::remote::Endpoint::Socket)
|
|
}
|
|
|
|
/// Resolve the password: stdin beats the flag/env, which beats the keychain,
|
|
/// which beats `password_cmd`.
|
|
fn password(
|
|
cli: &Cli,
|
|
resolved: &crate::persist::config::Resolved,
|
|
) -> CliResult<Option<String>> {
|
|
if cli.password_stdin {
|
|
return password_from_stdin().map(Some);
|
|
}
|
|
Ok(crate::persist::secret::resolve_password(
|
|
cli.password.as_deref(),
|
|
resolved.username.as_deref(),
|
|
resolved.password_cmd.as_deref(),
|
|
&crate::persist::secret::KeyringStore,
|
|
))
|
|
}
|
|
|
|
/// Read a password from the first line of stdin, the way `docker login` does.
|
|
pub fn password_from_stdin() -> CliResult<String> {
|
|
let mut line = String::new();
|
|
io::stdin()
|
|
.lock()
|
|
.read_line(&mut line)
|
|
.map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?;
|
|
let line = line.trim_end_matches(['\r', '\n']);
|
|
if line.is_empty() {
|
|
return Err(CliError::usage("no password on stdin"));
|
|
}
|
|
Ok(line.to_string())
|
|
}
|
|
|
|
/// What to say when nothing supplied a username.
|
|
#[must_use]
|
|
pub fn missing_username() -> CliError {
|
|
CliError::usage(
|
|
"no username: pass --username, set SOULSEEK_USERNAME, or put one in \
|
|
config.toml",
|
|
)
|
|
}
|
|
|
|
/// Validate the configured shares. A bad entry is a warning, not a failure:
|
|
/// the client simply shares less.
|
|
fn shared_directories(
|
|
resolved: &crate::persist::config::Resolved,
|
|
out: &Out,
|
|
) -> Vec<String> {
|
|
let mut valid = Vec::new();
|
|
for dir in &resolved.shared_dirs {
|
|
match crate::directories::resolve_shared_directory(Some(dir)) {
|
|
Ok(Some(path)) => valid.push(path.display().to_string()),
|
|
Ok(None) => {}
|
|
Err(e) => out.warn(&format!("ignoring shared directory: {e}")),
|
|
}
|
|
}
|
|
valid
|
|
}
|
|
|
|
/// Decide how much this run says on stderr.
|
|
///
|
|
/// `-v` raises the level; without it, an explicit `LOG_LEVEL`/`RUST_LOG` from
|
|
/// the environment is honoured, and otherwise logging stays at errors only so
|
|
/// stderr carries this program's own output and nothing else.
|
|
pub fn init_logging(cli: &Cli) {
|
|
let level = match cli.verbose {
|
|
0 => (env::var_os("LOG_LEVEL").is_none()
|
|
&& env::var_os("RUST_LOG").is_none())
|
|
.then_some("ERROR"),
|
|
1 => Some("WARN"),
|
|
2 => Some("INFO"),
|
|
3 => Some("DEBUG"),
|
|
_ => Some("TRACE"),
|
|
};
|
|
// SAFETY: called before any threads are spawned.
|
|
unsafe {
|
|
if let Some(level) = level {
|
|
env::set_var("LOG_LEVEL", level);
|
|
}
|
|
if let Some(log_file) = &cli.log_file {
|
|
env::set_var("LOG_FILE", log_file.as_os_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Clear the screen, turn on mouse reporting and take over the terminal.
|
|
fn enter_terminal() -> ratatui::DefaultTerminal {
|
|
use ratatui::crossterm::{
|
|
event::EnableMouseCapture,
|
|
execute,
|
|
terminal::{Clear, ClearType},
|
|
};
|
|
|
|
let _ =
|
|
execute!(std::io::stdout(), Clear(ClearType::All), EnableMouseCapture);
|
|
ratatui::init()
|
|
}
|
|
|
|
/// Run the interactive TUI (the default no-subcommand path): bring the
|
|
/// terminal up first, run the login/registration screen (skipped past when
|
|
/// stored credentials work), persist whatever logged in, then enter the
|
|
/// main UI. Logging out from the settings popup starts the round again.
|
|
fn run_default_tui(
|
|
cli: &Cli,
|
|
resolved: &crate::persist::config::Resolved,
|
|
config_path: Option<PathBuf>,
|
|
) -> CliResult {
|
|
let out = Out::new(false, false);
|
|
let (server_host, server_port) =
|
|
parse_server_address(&resolved.server).map_err(CliError::usage)?;
|
|
|
|
// Make sure the download folder exists up front: first-run defaults point
|
|
// download and shared at the same fresh Downloads/Soulseek folder, and
|
|
// shared-directory validation requires an existing path.
|
|
if let Err(e) = std::fs::create_dir_all(&resolved.download_dir) {
|
|
out.warn(&format!(
|
|
"could not create download directory {}: {e}",
|
|
resolved.download_dir
|
|
));
|
|
}
|
|
|
|
let shared_directories = shared_directories(resolved, &out);
|
|
let secret_store = crate::persist::secret::KeyringStore;
|
|
let mut initial_password = password(cli, resolved)?;
|
|
|
|
let enable_listen = !resolved.disable_listener;
|
|
let listen_port = resolved.listener_port;
|
|
let accept_children = resolved.accept_children;
|
|
let make_settings =
|
|
move |username: String, password: String| ClientSettings {
|
|
username,
|
|
password,
|
|
server_address: PeerAddress::new(server_host.clone(), server_port),
|
|
enable_listen,
|
|
listen_port,
|
|
shared_directories: shared_directories.clone(),
|
|
accept_children,
|
|
version: ClientVersion::REFERENCE_CLIENT,
|
|
};
|
|
|
|
// Logging out closes the window and comes straight back to the login
|
|
// screen, so one pass of this loop is one session: terminal, credentials,
|
|
// main UI. The port mapping and the state store are dropped at the end of
|
|
// the pass; the client goes with the last worker thread still holding it,
|
|
// so a search or browse in flight can keep the old session open a little
|
|
// past the logout.
|
|
let mut username = resolved.username.clone();
|
|
loop {
|
|
// Enable logger buffering BEFORE connection to prevent log artifacts.
|
|
// The window turns it off again when it gives the terminal back.
|
|
soulseek_rs::utils::logger::enable_buffering();
|
|
let mut terminal = enter_terminal();
|
|
|
|
let outcome = crate::ui::login::run_login_flow(
|
|
&mut terminal,
|
|
&make_settings,
|
|
username.clone(),
|
|
initial_password.take(),
|
|
);
|
|
|
|
let outcome = match outcome {
|
|
Ok(Some(outcome)) => outcome,
|
|
// The user cancelled at the login screen.
|
|
Ok(None) => return leave_terminal(Ok(())),
|
|
Err(e) => {
|
|
return leave_terminal(Err(CliError::new(
|
|
Exit::Failure,
|
|
e.to_string(),
|
|
)));
|
|
}
|
|
};
|
|
|
|
persist_credentials(&outcome, config_path.as_deref(), &secret_store);
|
|
username = Some(outcome.username.clone());
|
|
|
|
// Best-effort: make ourselves reachable behind a home router so
|
|
// firewalled peers can connect back. Mapped only once the listener is
|
|
// up, and for the port it really bound. Kept alive for the session.
|
|
let _port_mapper = outcome
|
|
.client
|
|
.listen_port()
|
|
.map(crate::port_mapping::PortMapper::spawn);
|
|
|
|
let store = crate::persist::paths::state_dir()
|
|
.map(crate::persist::state::StateStore::new);
|
|
|
|
let exit = launch_main_tui(
|
|
terminal,
|
|
Arc::new(outcome.client),
|
|
resolved.download_dir.clone(),
|
|
Duration::from_secs(resolved.search_timeout),
|
|
store,
|
|
config_path.clone(),
|
|
)
|
|
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))?;
|
|
|
|
if exit == TuiExit::Quit {
|
|
return Ok(());
|
|
}
|
|
|
|
// Logged out, so the stored password goes with the session: this pass
|
|
// is already asking for one, but the next start must ask too rather
|
|
// than walk straight back in.
|
|
if let Err(e) = secret_store.delete(&outcome.username) {
|
|
out.warn(&format!("could not clear the stored password: {e}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Give the terminal back and let logging through again, on the way out.
|
|
fn leave_terminal(result: CliResult) -> CliResult {
|
|
ratatui::restore();
|
|
soulseek_rs::utils::logger::disable_buffering();
|
|
result
|
|
}
|
|
|
|
/// Run the TUI against a daemon's session.
|
|
///
|
|
/// No login screen: the daemon is already logged in, so there is nothing to
|
|
/// ask for and nothing to store. Everything the UI shows comes back over the
|
|
/// socket, and what it persists stays daemon-side.
|
|
fn run_attached_tui(
|
|
resolved: &crate::persist::config::Resolved,
|
|
endpoint: &crate::remote::Endpoint,
|
|
config_path: Option<PathBuf>,
|
|
) -> CliResult {
|
|
// The resolved token, not the flag alone: `daemon_token` in config.toml
|
|
// is the documented remote setup, and the flag/env are layered into it.
|
|
let token = resolved
|
|
.daemon_token
|
|
.clone()
|
|
.or_else(crate::daemon::stored_token);
|
|
let session = crate::remote::RemoteSession::connect(endpoint, token)
|
|
.map_err(|e| {
|
|
CliError::connection(format!(
|
|
"cannot use the daemon at {endpoint}: {e}"
|
|
))
|
|
})?;
|
|
|
|
// The settings form shows and edits the *daemon's* download folder — the
|
|
// one transfers actually land in — not this machine's configured default.
|
|
// The connect above just succeeded, so a daemon that cannot answer this
|
|
// is a real problem to report, not one to paper over with a local path.
|
|
let download_dir = session
|
|
.ask_for::<crate::daemon::proto::DaemonStatus>(
|
|
crate::daemon::proto::Method::DaemonStatus,
|
|
)
|
|
.map(|status| status.download_dir)
|
|
.map_err(|e| {
|
|
CliError::connection(format!(
|
|
"the daemon at {endpoint} did not report its status: {e}"
|
|
))
|
|
})?;
|
|
|
|
soulseek_rs::utils::logger::enable_buffering();
|
|
let terminal = enter_terminal();
|
|
|
|
// No StateStore: the daemon owns what survives a restart, so a second
|
|
// writer here would fight it for the same files.
|
|
// The daemon owns the login, so this window has no logout to come back
|
|
// from: however it closed, the run is over.
|
|
launch_main_tui(
|
|
terminal,
|
|
Arc::new(session),
|
|
download_dir,
|
|
Duration::from_secs(resolved.search_timeout),
|
|
None,
|
|
config_path,
|
|
)
|
|
.map(|_| ())
|
|
.map_err(|e| CliError::new(Exit::Failure, e.to_string()))
|
|
}
|
|
|
|
/// After a successful login, remember the username in config.toml and — when
|
|
/// it was typed into the form — the password in the OS keychain. Both are
|
|
/// best-effort: failing to persist must not break a working session.
|
|
fn persist_credentials(
|
|
outcome: &crate::ui::login::LoginOutcome,
|
|
config_path: Option<&Path>,
|
|
secret_store: &dyn crate::persist::secret::SecretStore,
|
|
) {
|
|
if let Some(path) = config_path {
|
|
save_username(path, &outcome.username);
|
|
}
|
|
if outcome.entered_via_form
|
|
&& let Err(e) = secret_store.set(&outcome.username, &outcome.password)
|
|
{
|
|
eprintln!("⚠️ Could not store password in keychain: {e}");
|
|
}
|
|
}
|
|
|
|
/// Remember who logged in, for the next start to offer.
|
|
///
|
|
/// Read back from disk rather than from the copy this run started with: the
|
|
/// settings popup writes the same file, and a second login after a logout
|
|
/// would otherwise save a snapshot taken before those edits.
|
|
fn save_username(path: &Path, username: &str) {
|
|
let mut config = match crate::persist::config::FileConfig::load(path) {
|
|
Ok(config) => config,
|
|
Err(e) => return eprintln!("⚠️ Could not read config: {e}"),
|
|
};
|
|
if config.username.as_deref() == Some(username) {
|
|
return;
|
|
}
|
|
config.username = Some(username.to_string());
|
|
if let Err(e) = config.save(path) {
|
|
eprintln!("⚠️ Could not save config: {e}");
|
|
}
|
|
}
|
|
|
|
/// Open files a session asks the OS for at startup.
|
|
///
|
|
/// The client holds a socket per peer and will talk to a thousand of them
|
|
/// during a busy search, plus transfers, the share index, and control
|
|
/// connections. A shell hands its children a soft limit of 256 on macOS,
|
|
/// which a single search blows through.
|
|
const WANTED_OPEN_FILES: u64 = 8192;
|
|
|
|
/// Ask the OS for enough open files to run a busy session, and report what we
|
|
/// actually got.
|
|
///
|
|
/// Raising our own soft limit is what a service does: the limit a shell
|
|
/// inherits is sized for a shell. The hard limit is the ceiling, and asking
|
|
/// past it fails outright rather than clamping, so this asks for the smaller
|
|
/// of what we want and what we are allowed.
|
|
#[cfg(unix)]
|
|
fn raise_open_file_limit() -> std::io::Result<u64> {
|
|
// SAFETY: every call takes a pointer to a `rlimit` this function owns, and
|
|
// none of them retains it.
|
|
unsafe {
|
|
let mut limit = std::mem::zeroed::<libc::rlimit>();
|
|
if libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) != 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
if limit.rlim_cur >= WANTED_OPEN_FILES {
|
|
return Ok(limit.rlim_cur);
|
|
}
|
|
|
|
let previous = limit.rlim_cur;
|
|
let ceiling = if limit.rlim_max == libc::RLIM_INFINITY {
|
|
WANTED_OPEN_FILES
|
|
} else {
|
|
limit.rlim_max.min(WANTED_OPEN_FILES)
|
|
};
|
|
|
|
// macOS rejects an unlimited *hard* limit for open files, so raising
|
|
// the soft one while leaving `rlim_max` as it came back fails with
|
|
// EINVAL. Naming both is what it will accept.
|
|
for candidate in [
|
|
libc::rlimit {
|
|
rlim_cur: ceiling,
|
|
rlim_max: limit.rlim_max,
|
|
},
|
|
libc::rlimit {
|
|
rlim_cur: ceiling,
|
|
rlim_max: ceiling,
|
|
},
|
|
] {
|
|
if libc::setrlimit(libc::RLIMIT_NOFILE, &raw const candidate) == 0 {
|
|
return Ok(ceiling);
|
|
}
|
|
}
|
|
Ok(previous)
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
const fn raise_open_file_limit() -> std::io::Result<u64> {
|
|
Ok(WANTED_OPEN_FILES)
|
|
}
|