use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::ipc::SendStream;
pub const SEND_SOCK_ENV: &str = "M4A_SEND_SOCK";
pub const DEFAULT_SOCK_NAME: &str = "web-client.sock";
pub const ENV_FILE_ENV: &str = "M4A_ENV_FILE";
pub const MAX_SEND_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendRequest {
#[serde(rename = "as")]
pub as_nick: String,
pub to: String,
pub text: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SendReply {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub room: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl SendReply {
pub fn failed(error: impl Into<String>) -> Self {
Self {
error: Some(error.into()),
..Self::default()
}
}
}
pub fn send_sock_path(get: impl FnMut(&str) -> Option<String>, store_root: &Path) -> PathBuf {
send_sock_path_named(get, store_root, DEFAULT_SOCK_NAME)
}
pub fn send_sock_path_named(
mut get: impl FnMut(&str) -> Option<String>,
store_root: &Path,
default_name: &str,
) -> PathBuf {
get(SEND_SOCK_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| store_root.join(default_name))
}
pub fn load_env_file() -> Option<PathBuf> {
load_env_file_named("web-client.env")
}
pub fn load_env_file_named(default_name: &str) -> Option<PathBuf> {
let path = std::env::var(ENV_FILE_ENV)
.ok()
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| {
home_dir_from(
std::env::var_os("HOME").as_deref(),
std::env::var_os("USERPROFILE").as_deref(),
)
.map(|home| home.join(".config/mail4agent").join(default_name))
})?;
let text = std::fs::read_to_string(&path).ok()?;
for (key, value) in parse_env_lines(&text) {
if std::env::var_os(&key).is_none() {
std::env::set_var(key, value);
}
}
Some(path)
}
fn home_dir_from(home: Option<&OsStr>, userprofile: Option<&OsStr>) -> Option<PathBuf> {
let chosen = home
.filter(|value| !value.is_empty())
.or_else(|| userprofile.filter(|value| !value.is_empty()))?;
Some(PathBuf::from(chosen))
}
fn parse_env_lines(text: &str) -> Vec<(String, String)> {
text.lines()
.filter_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (key, value) = line.split_once('=')?;
let key = key.trim();
if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
return None;
}
let value = value.trim();
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
.unwrap_or(value);
Some((key.to_string(), value.to_string()))
})
.collect()
}
pub fn send_via_socket(
sock: &Path,
request: &SendRequest,
wait: Duration,
) -> std::io::Result<SendReply> {
let mut stream = SendStream::connect(sock)?;
stream.set_read_timeout(Some(wait))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut line = serde_json::to_vec(request)?;
line.push(b'\n');
stream.write_all(&line)?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut answer = String::new();
reader.read_line(&mut answer)?;
if answer.trim().is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"client closed the socket without an answer",
));
}
serde_json::from_str(answer.trim())
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
}
pub(crate) enum Incoming {
Send(SendRequest),
Cmd(crate::CmdRequest),
}
pub(crate) fn read_incoming(stream: &mut SendStream) -> Result<Incoming, String> {
stream.set_nonblocking(false).map_err(|err| err.to_string())?;
stream.set_read_timeout(Some(Duration::from_secs(3))).map_err(|err| err.to_string())?;
let mut buf = Vec::new();
let mut limited = stream.take((MAX_SEND_BYTES * 2 + 1024) as u64);
let mut reader = BufReader::new(&mut limited);
reader.read_until(b'\n', &mut buf).map_err(|err| err.to_string())?;
let value: serde_json::Value =
serde_json::from_slice(&buf).map_err(|_| "request is not a JSON line".to_string())?;
if value.get("cmd").is_some() {
let cmd: crate::CmdRequest =
serde_json::from_value(value).map_err(|err| format!("bad command: {err}"))?;
return Ok(Incoming::Cmd(cmd));
}
let request: SendRequest =
serde_json::from_value(value).map_err(|_| "request is not a send JSON line".to_string())?;
if request.text.trim().is_empty() {
return Err("text is empty".to_string());
}
if request.text.len() > MAX_SEND_BYTES {
return Err(format!("text is longer than {MAX_SEND_BYTES} bytes"));
}
Ok(Incoming::Send(request))
}
pub fn send_cmd_via_socket(
sock: &Path,
request: &crate::CmdRequest,
wait: Duration,
) -> std::io::Result<crate::CmdReply> {
let mut stream = SendStream::connect(sock)?;
stream.set_read_timeout(Some(wait))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut line = serde_json::to_vec(request)?;
line.push(b'\n');
stream.write_all(&line)?;
stream.flush()?;
let mut reader = BufReader::new(stream);
let mut answer = String::new();
reader.read_line(&mut answer)?;
if answer.trim().is_empty() {
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "client closed the socket without an answer"));
}
serde_json::from_str(answer.trim()).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
}
pub(crate) fn write_cmd_reply(stream: &mut SendStream, reply: &crate::CmdReply) {
if let Ok(mut line) = serde_json::to_vec(reply) {
line.push(b'\n');
let _ = stream.set_write_timeout(Some(Duration::from_secs(3)));
let _ = stream.write_all(&line);
let _ = stream.flush();
}
}
#[cfg_attr(not(feature = "wake-grok"), allow(dead_code))]
pub(crate) fn read_request(stream: &mut SendStream) -> Result<SendRequest, String> {
stream
.set_nonblocking(false)
.map_err(|err| err.to_string())?;
stream
.set_read_timeout(Some(Duration::from_secs(3)))
.map_err(|err| err.to_string())?;
let mut buf = Vec::new();
let mut limited = stream.take((MAX_SEND_BYTES * 2 + 1024) as u64);
let mut reader = BufReader::new(&mut limited);
reader
.read_until(b'\n', &mut buf)
.map_err(|err| err.to_string())?;
let request: SendRequest =
serde_json::from_slice(&buf).map_err(|_| "request is not a send JSON line".to_string())?;
if request.text.trim().is_empty() {
return Err("text is empty".to_string());
}
if request.text.len() > MAX_SEND_BYTES {
return Err(format!("text is longer than {MAX_SEND_BYTES} bytes"));
}
Ok(request)
}
pub(crate) fn write_reply(stream: &mut SendStream, reply: &SendReply) {
if let Ok(mut line) = serde_json::to_vec(reply) {
line.push(b'\n');
let _ = stream.set_write_timeout(Some(Duration::from_secs(3)));
let _ = stream.write_all(&line);
let _ = stream.flush();
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use super::*;
#[test]
fn env_lines_skip_comments_and_strip_quotes() {
let parsed = parse_env_lines(
"# c\n\nM4A_A=1\nexport M4A_B=\"two words\"\nM4A_C='x'\nbad line\n=v\n",
);
assert_eq!(
parsed,
vec![
("M4A_A".to_string(), "1".to_string()),
("M4A_B".to_string(), "two words".to_string()),
("M4A_C".to_string(), "x".to_string()),
]
);
}
#[test]
fn home_dir_prefers_home_then_userprofile() {
assert_eq!(
home_dir_from(
Some(OsStr::new("/from-home")),
Some(OsStr::new("/from-profile"))
),
Some(PathBuf::from("/from-home"))
);
assert_eq!(
home_dir_from(None, Some(OsStr::new("/from-profile"))),
Some(PathBuf::from("/from-profile"))
);
assert_eq!(
home_dir_from(Some(OsStr::new("")), Some(OsStr::new("/from-profile"))),
Some(PathBuf::from("/from-profile"))
);
assert_eq!(home_dir_from(None, None), None);
assert_eq!(
home_dir_from(Some(OsStr::new("")), Some(OsStr::new(""))),
None
);
}
#[test]
fn request_uses_as_on_the_wire() {
let request = SendRequest {
as_nick: "alice".to_string(),
to: "privet-mir".to_string(),
text: "hi".to_string(),
};
let json = serde_json::to_value(&request).expect("json");
assert_eq!(json["as"], "alice");
assert_eq!(json["to"], "privet-mir");
}
}