//! `cbd`: server and TUI bundled into one binary //! (see `architecture/cbd-bundle.md`). //! //! Starting `cbd` starts the crabidy server in-process, waits for it to //! accept connections, and runs the TUI against it — the same configs, //! the same localhost gRPC wire as the standalone pair. If a server is //! already listening (a standalone `crabidy-server`), `cbd` adopts it //! instead of failing. Quitting the TUI ends the process, and with it //! the in-process server; the current queue is persisted continuously, //! so the next start restores it. use std::error::Error; use std::sync::OnceLock; use std::time::Duration; use cbd_cli::{CbdCli, CbdCommand, RemoteCmd}; use cbd_tui::config::{self, Config}; use clap::{CommandFactory, Parser}; use crabidy_server::cli as server_cli; use tracing::{info, warn}; /// The config file name for the bundled binary (separate from `cbd-tui.toml`). const CONFIG_FILE: &str = "cbd.toml"; static CONFIG: OnceLock = OnceLock::new(); /// How long to wait for the server socket before giving up. Generous: /// the first server start may run a provider login flow. const READINESS_ATTEMPTS: u32 = 120; const READINESS_DELAY: Duration = Duration::from_millis(500); #[tokio::main] async fn main() -> Result<(), Box> { // `cbd` is the union of the server and client command surfaces // (architecture/cli.md D2): no subcommand runs the in-process server + // TUI, exactly as before. let cli = CbdCli::parse(); match cli.command { None => run_bundle(cli).await, Some(command) => { if let Err(err) = run_command(&cli.remote, command).await { eprintln!("error: {err}"); std::process::exit(1); } Ok(()) } } } /// Runs the bundled server + TUI (the no-subcommand default), unchanged from /// before save for the clap-based config load and flag overrides. async fn run_bundle(cli: CbdCli) -> Result<(), Box> { // Both halves share one file-based subscriber: the terminal belongs // to the TUI, so the server's usual stderr logging would corrupt it. let _log_guard = init_tracing(); // `cbd` reads its OWN config (`cbd.toml`), separate from the // standalone `cbd-tui`'s `cbd-tui.toml`. The two run side by side on // one machine — `cbd` self-contained against its in-process server, // `cbd-tui` pointed at a remote (e.g. a Raspberry Pi) — so a single // shared `address` would force one to follow the other. `cbd` // defaults to localhost, which matches its embedded server. let mut config = config::load_first_run(CONFIG_FILE); config::apply_overrides( &mut config, cli.remote.address, cli.remote.user, cli.remote.password, cli.spectrum, ); let config = CONFIG.get_or_init(|| config); let addr: std::net::SocketAddr = crabidy_server::LISTEN_ADDR.parse()?; let mut server = tokio::spawn(crabidy_server::serve(addr)); wait_for_server( &config.server.address, &mut server, READINESS_ATTEMPTS, READINESS_DELAY, ) .await?; cbd_tui::run(config).await } /// Dispatches a `cbd` subcommand: `guard`/`scan` reuse the server logic, /// `auth` writes `cbd.toml`, and `library`/`queue`/`global` run against a /// server over gRPC. async fn run_command( remote: &cbd_cli::RemoteArgs, command: CbdCommand, ) -> Result<(), Box> { match command { CbdCommand::Guard(args) => server_cli::guard(args).await, CbdCommand::Scan(args) => server_cli::scan(args).await, CbdCommand::Auth(args) => { let password = args .password .ok_or("missing password (pass it as an argument)")?; let path = config::write_auth( CONFIG_FILE, args.role.user_name(), &password, args.address.as_deref(), )?; println!( "wrote credentials for {} to {}", args.role.user_name(), path.display() ); Ok(()) } CbdCommand::Library(cmd) => { cbd_cli::run_remote(&connection(remote), RemoteCmd::Library(cmd)).await } CbdCommand::Queue(cmd) => { cbd_cli::run_remote(&connection(remote), RemoteCmd::Queue(cmd)).await } CbdCommand::Global(cmd) => { cbd_cli::run_remote(&connection(remote), RemoteCmd::Global(cmd)).await } CbdCommand::Completions(args) => { cbd_cli::print_completions(args.shell, &mut CbdCli::command(), "cbd"); Ok(()) } } } /// Resolves the connection for a remote command: the CLI flags win over /// `cbd.toml`, which supplies the fallback (address and credentials). fn connection(remote: &cbd_cli::RemoteArgs) -> cbd_cli::Connection { let config = config::load_first_run(CONFIG_FILE); cbd_cli::Connection { address: remote.address.clone().unwrap_or(config.server.address), user: remote.user.clone().unwrap_or(config.server.user), password: remote.password.clone().unwrap_or(config.server.password), } } /// Waits until something accepts TCP connections on the TUI's configured /// server address (scheme stripped): the in-process server coming up, or /// an already-running standalone one (in which case our `serve` fails /// with the port taken and is deliberately ignored). Fails when the /// in-process server dies while nothing is listening, or after /// `attempts` polls. async fn wait_for_server( address: &str, server: &mut tokio::task::JoinHandle>>, attempts: u32, delay: Duration, ) -> Result<(), Box> { let host_port = address .trim_start_matches("http://") .trim_start_matches("https://") .trim_end_matches('/'); for _ in 0..attempts { if tokio::net::TcpStream::connect(host_port).await.is_ok() { if server.is_finished() { warn!("a server is already listening; connecting to it instead"); } else { info!(address, "server is ready"); } return Ok(()); } if server.is_finished() { // Nothing listening and our server is gone: a real failure // (provider init, bad address), not an occupied port. return match server.await { Ok(Ok(())) => Err("the server exited before becoming ready".into()), Ok(Err(err)) => Err(err.to_string().into()), Err(err) => Err(err.to_string().into()), }; } tokio::time::sleep(delay).await; } Err(format!("no server reachable at {host_port} after {attempts} attempts").into()) } /// Logs to a file (`crabidy/cbd.log` in the state dir), like `cbd-tui` — /// but with the server crates' filter, since they run in-process here. fn init_tracing() -> Option { use tracing_subscriber::{prelude::*, EnvFilter}; let log_dir = dirs::state_dir() .or_else(dirs::cache_dir) .unwrap_or_else(std::env::temp_dir) .join("crabidy"); if let Err(err) = std::fs::create_dir_all(&log_dir) { eprintln!( "could not create log directory {}: {err}", log_dir.display() ); return None; } let file_appender = tracing_appender::rolling::daily(&log_dir, "cbd.log"); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new( "info,cbd=debug,cbd_tui=debug,crabidy_server=debug,crabidy_core=debug,tidaldy=debug,ytdy=debug,audio_player=debug", ) }); tracing_subscriber::registry() .with(env_filter) .with( tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_ansi(false) .with_target(true), ) .init(); Some(guard) } #[cfg(test)] mod tests { use super::*; /// A server task that never finishes, standing in for a healthy /// in-process server still starting up. fn pending_server() -> tokio::task::JoinHandle>> { tokio::spawn(async { std::future::pending::<()>().await; Ok(()) }) } #[tokio::test] async fn readiness_polls_until_the_socket_accepts() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let addr = listener.local_addr().expect("addr"); // The scheme prefix must be stripped like the TUI config's URL. let address = format!("http://{addr}/"); let mut server = pending_server(); wait_for_server(&address, &mut server, 10, Duration::from_millis(10)) .await .expect("socket accepts"); server.abort(); } #[tokio::test] async fn readiness_gives_up_and_reports_a_dead_server() { // Nothing listens on this address (bound, then dropped). let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let addr = listener.local_addr().expect("addr"); drop(listener); // A dead server task with nothing listening is a real failure. let mut dead: tokio::task::JoinHandle>> = tokio::spawn(async { Err("provider init failed".into()) }); let err = wait_for_server( &format!("http://{addr}"), &mut dead, 10, Duration::from_millis(10), ) .await .expect_err("dead server surfaces"); assert!(err.to_string().contains("provider init failed")); // A healthy-but-slow server just runs out of attempts. let mut server = pending_server(); let err = wait_for_server( &format!("http://{addr}"), &mut server, 3, Duration::from_millis(10), ) .await .expect_err("gives up eventually"); assert!(err.to_string().contains("after 3 attempts")); server.abort(); } }