use std::collections::VecDeque;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use tracing::{info, warn};
use crate::paths::socket_path;
use crate::protocol::{DaemonStatus, PushEvent, Request, Response};
use crate::state::EqBand;
#[derive(Debug)]
pub enum ClientError {
Disconnected,
Timeout,
Malformed(String),
Io(std::io::Error),
}
impl std::fmt::Display for ClientError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disconnected => write!(f, "daemon closed the connection"),
Self::Timeout => write!(f, "daemon did not respond in time"),
Self::Malformed(line) => write!(f, "malformed data from daemon: {line}"),
Self::Io(e) => write!(f, "I/O error: {e}"),
}
}
}
impl std::error::Error for ClientError {}
impl From<std::io::Error> for ClientError {
fn from(e: std::io::Error) -> Self {
match e.kind() {
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => Self::Timeout,
std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::BrokenPipe => Self::Disconnected,
_ => Self::Io(e),
}
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
format!("{}…", s.chars().take(max).collect::<String>())
}
}
pub struct DaemonClient {
stream: UnixStream,
reader: BufReader<UnixStream>,
pending_events: VecDeque<PushEvent>,
daemon_child: Option<std::process::Child>,
}
impl DaemonClient {
pub fn connect() -> crate::AppResult<Self> {
Self::connect_with_exe(&socket_path()?, None, &[])
}
fn connect_with_exe(
path: &Path,
exe: Option<&Path>,
spawn_env: &[(&str, &str)],
) -> crate::AppResult<Self> {
if let Ok(client) = Self::try_connect(path) {
info!("Connected to existing daemon");
return Ok(client);
}
info!("No daemon found — auto-launching");
let mut daemon_child = spawn_daemon(exe, spawn_env);
let timeout_ms = std::env::var("EQTUI_DAEMON_START_TIMEOUT_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(3000);
let attempts = (timeout_ms / 100).max(1);
for _ in 0..attempts {
std::thread::sleep(Duration::from_millis(100));
if let Some(child) = &mut daemon_child
&& let Some(status) = child.try_wait().ok().flatten()
{
return Err(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
format!("daemon exited immediately with {status}"),
)
.into());
}
if let Ok(mut client) = Self::try_connect(path) {
info!("Connected to auto-launched daemon");
client.daemon_child = daemon_child.take(); return Ok(client);
}
}
if let Some(mut child) = daemon_child {
warn!(pid = child.id(), "Daemon start timed out — killing");
let _ = child.kill(); let _ = child.wait(); }
Err(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
format!("Daemon failed to start within {timeout_ms}ms"),
)
.into())
}
fn try_connect(path: &Path) -> std::io::Result<Self> {
let stream = UnixStream::connect(path)?;
let timeout = Some(Duration::from_secs(5));
stream.set_read_timeout(timeout)?;
stream.set_write_timeout(timeout)?;
let reader = BufReader::new(stream.try_clone().map_err(|e| {
std::io::Error::new(
e.kind(),
format!("Failed to clone daemon socket for reading: {e}"),
)
})?);
Ok(Self {
stream,
reader,
pending_events: VecDeque::new(),
daemon_child: None,
})
}
pub fn request(&mut self, req: Request) -> Result<Response, ClientError> {
let json = serde_json::to_string(&req).map_err(|e| {
ClientError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e))
})?;
self.stream.write_all(json.as_bytes())?;
self.stream.write_all(b"\n")?;
self.stream.flush()?;
loop {
let mut line = String::new();
let n = self.reader.read_line(&mut line)?; if n == 0 {
return Err(ClientError::Disconnected); }
let trimmed = line.trim();
if let Ok(resp) = serde_json::from_str::<Response>(trimmed) {
return Ok(resp);
}
if let Ok(event) = serde_json::from_str::<PushEvent>(trimmed) {
self.pending_events.push_back(event);
continue;
}
return Err(ClientError::Malformed(truncate(trimmed, 200)));
}
}
pub fn try_read_event(&mut self) -> Result<Option<PushEvent>, ClientError> {
if let Some(child) = &mut self.daemon_child
&& let Ok(Some(_status)) = child.try_wait()
{
self.daemon_child = None;
}
if let Some(event) = self.pending_events.pop_front() {
return Ok(Some(event));
}
self.reader.get_mut().set_nonblocking(true)?;
let mut line = String::new();
let result = match self.reader.read_line(&mut line) {
Ok(0) => Err(ClientError::Disconnected), Ok(_) => match serde_json::from_str::<PushEvent>(line.trim()) {
Ok(event) => Ok(Some(event)),
Err(_) => Err(ClientError::Malformed(truncate(line.trim(), 200))),
},
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
Err(e) => Err(e.into()),
};
self.reader.get_mut().set_nonblocking(false)?;
result
}
pub fn get_status(&mut self) -> crate::AppResult<DaemonStatus> {
let resp = self.request(Request::GetStatus)?;
resp.status.ok_or_else(|| {
std::io::Error::other(resp.error.unwrap_or_else(|| "No status in response".into()))
.into()
})
}
pub fn set_bands(&mut self, bands: &[EqBand]) -> crate::AppResult<()> {
let resp = self.request(Request::SetBands {
bands: bands.to_vec(),
})?;
check_ok(resp)
}
pub fn set_preamp(&mut self, gain: f32) -> crate::AppResult<()> {
let resp = self.request(Request::SetPreamp { gain })?;
check_ok(resp)
}
pub fn set_bypass(&mut self, bypass: bool) -> crate::AppResult<()> {
let resp = self.request(Request::SetBypass { bypass })?;
check_ok(resp)
}
pub fn connect_device(&mut self, node_id: u32) -> crate::AppResult<()> {
let resp = self.request(Request::ConnectDevice { node_id })?;
check_ok(resp)
}
pub fn disconnect_device(&mut self, node_id: u32) -> crate::AppResult<()> {
let resp = self.request(Request::DisconnectDevice { node_id })?;
check_ok(resp)
}
pub fn shutdown(&mut self) -> crate::AppResult<()> {
let _ = self.request(Request::Shutdown)?;
Ok(())
}
#[cfg(test)]
pub(crate) fn from_stream(stream: UnixStream) -> std::io::Result<Self> {
let timeout = Some(Duration::from_secs(5));
stream.set_read_timeout(timeout)?;
stream.set_write_timeout(timeout)?;
let reader = BufReader::new(stream.try_clone()?);
Ok(Self {
stream,
reader,
pending_events: VecDeque::new(),
daemon_child: None,
})
}
}
fn check_ok(resp: Response) -> crate::AppResult<()> {
if resp.ok {
Ok(())
} else {
Err(std::io::Error::other(resp.error.unwrap_or_else(|| "Unknown error".into())).into())
}
}
fn spawn_daemon(exe: Option<&Path>, spawn_env: &[(&str, &str)]) -> Option<std::process::Child> {
let exe = if let Some(e) = exe {
e.to_path_buf()
} else if let Ok(e) = std::env::current_exe() {
e
} else {
warn!("Cannot determine own binary path — daemon auto-launch disabled");
return None;
};
match Command::new(exe)
.arg("daemon")
.envs(spawn_env.iter().copied())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(child) => {
info!(pid = child.id(), "Spawned daemon");
Some(child)
}
Err(e) => {
warn!(%e, "Failed to spawn daemon");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_fails_fast_when_daemon_exits_immediately() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("eqtui").join("eqtui.sock");
let start = std::time::Instant::now();
let Err(err) = DaemonClient::connect_with_exe(&socket, Some(Path::new("/bin/true")), &[])
else {
panic!("connect must fail when the daemon dies on startup");
};
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"fail-fast must not wait the full blind timeout"
);
assert!(
err.to_string().contains("exited immediately"),
"error should name the early exit, got: {err}"
);
}
#[test]
fn eof_in_request_is_disconnected() {
let (a, b) = UnixStream::pair().unwrap();
let mut client = DaemonClient::from_stream(a).unwrap();
drop(b); let err = client.request(Request::GetStatus).unwrap_err();
assert!(matches!(err, ClientError::Disconnected));
}
#[test]
fn eof_in_try_read_event_is_disconnected() {
let (a, b) = UnixStream::pair().unwrap();
let mut client = DaemonClient::from_stream(a).unwrap();
drop(b);
let err = client.try_read_event().unwrap_err();
assert!(matches!(err, ClientError::Disconnected));
}
#[test]
fn malformed_frame_is_named_not_silent() {
let (a, b) = UnixStream::pair().unwrap();
let mut client = DaemonClient::from_stream(a).unwrap();
use std::io::Write;
(&b).write_all(b"this is not json\n").unwrap();
let err = client.request(Request::GetStatus).unwrap_err();
assert!(matches!(err, ClientError::Malformed(_)));
}
#[test]
fn events_interleaved_with_response_are_buffered() {
let (a, b) = UnixStream::pair().unwrap();
let mut client = DaemonClient::from_stream(a).unwrap();
use std::io::Write;
writeln!(
&b,
"{}",
serde_json::json!({"event":"PeakUpdate","l":0.5,"r":0.5})
)
.unwrap();
writeln!(&b, "{}", serde_json::json!({"ok":true})).unwrap();
assert!(client.request(Request::GetStatus).unwrap().ok);
assert!(matches!(
client.try_read_event().unwrap(),
Some(PushEvent::PeakUpdate { .. })
));
}
#[test]
fn silence_is_timeout_not_disconnect() {
let (a, _b) = UnixStream::pair().unwrap(); let mut client = DaemonClient::from_stream(a).unwrap();
client
.stream
.set_read_timeout(Some(std::time::Duration::from_millis(100)))
.unwrap();
let err = client.request(Request::GetStatus).unwrap_err();
assert!(matches!(err, ClientError::Timeout));
}
}