pub mod notation;
pub mod screen;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use crate::interface::control::screen::ScreenReport;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case")]
pub enum Command {
Screen,
Press { keys: String },
Type { text: String },
Run { title: String },
Open { issue: String },
Quit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reply {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screen: Option<serde_json::Value>,
}
impl Reply {
pub fn screen(report: &ScreenReport) -> Self {
Self {
error: None,
screen: serde_json::to_value(report).ok(),
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
error: Some(message.into()),
screen: None,
}
}
pub fn done() -> Self {
Self {
error: None,
screen: None,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Envelope {
token: String,
#[serde(flatten)]
command: Command,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Endpoint {
pub port: u16,
pub token: String,
}
pub type Asked = (Command, oneshot::Sender<Reply>);
pub fn endpoint_path(snapshot: &Path) -> PathBuf {
snapshot.with_extension("control")
}
pub async fn listen(path: PathBuf) -> Result<(mpsc::UnboundedReceiver<Asked>, EndpointFile)> {
let listener = TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.await
.context("listening for agents on the loopback interface")?;
let endpoint = Endpoint {
port: listener.local_addr()?.port(),
token: token(),
};
crate::private_file::write(&path, serde_json::to_string(&endpoint)?.as_bytes())?;
let (tx, rx) = mpsc::unbounded_channel();
let token = endpoint.token.clone();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let tx = tx.clone();
let token = token.clone();
tokio::spawn(async move {
if let Err(e) = serve(stream, &token, tx).await {
tracing::debug!("control connection: {e:#}");
}
});
}
});
Ok((rx, EndpointFile(path)))
}
pub struct EndpointFile(PathBuf);
impl Drop for EndpointFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn token() -> String {
use rand::Rng;
let bytes: [u8; 16] = rand::rng().random();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
async fn serve(stream: TcpStream, token: &str, tx: mpsc::UnboundedSender<Asked>) -> Result<()> {
let (read, mut write) = stream.into_split();
let mut line = String::new();
BufReader::new(read).read_line(&mut line).await?;
let reply = match serde_json::from_str::<Envelope>(&line) {
Err(e) => Reply::error(format!("not a command: {e}")),
Ok(envelope) if envelope.token != token => Reply::error("wrong token"),
Ok(envelope) => {
let (answer, answered) = oneshot::channel();
tx.send((envelope.command, answer))
.map_err(|_| anyhow::anyhow!("linear-tui is quitting"))?;
answered
.await
.unwrap_or_else(|_| Reply::error("linear-tui quit before answering"))
}
};
let mut out = serde_json::to_string(&reply)?;
out.push('\n');
write.write_all(out.as_bytes()).await?;
Ok(())
}
const SEND_TIMEOUT: Duration = Duration::from_secs(60);
pub async fn send(path: &Path, command: Command) -> Result<Reply> {
let text = std::fs::read_to_string(path).with_context(|| {
format!(
"linear-tui is not accepting commands here ({} is missing)",
path.display()
)
})?;
let endpoint: Endpoint = serde_json::from_str(&text)?;
let stream = TcpStream::connect(SocketAddr::from(([127, 0, 0, 1], endpoint.port)))
.await
.context("linear-tui did not answer on its control port")?;
let (read, mut write) = stream.into_split();
let mut line = serde_json::to_string(&Envelope {
token: endpoint.token,
command,
})?;
line.push('\n');
write.write_all(line.as_bytes()).await?;
let mut answer = String::new();
tokio::time::timeout(SEND_TIMEOUT, BufReader::new(read).read_line(&mut answer))
.await
.context("linear-tui did not answer in time")??;
if answer.is_empty() {
bail!("linear-tui closed the connection without answering");
}
Ok(serde_json::from_str(&answer)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("linear-tui-control-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join("1.json")
}
#[test]
fn a_command_is_one_line_of_json_with_its_token() {
let line = serde_json::to_string(&Envelope {
token: "t".into(),
command: Command::Press { keys: "g m".into() },
})
.unwrap();
assert_eq!(line, r#"{"token":"t","command":"press","keys":"g m"}"#);
}
#[tokio::test]
async fn a_command_reaches_the_main_loop_and_its_answer_comes_back() {
let path = endpoint_path(&scratch("roundtrip"));
let (mut rx, file) = listen(path.clone()).await.unwrap();
let loop_side = tokio::spawn(async move {
let (command, answer) = rx.recv().await.unwrap();
assert_eq!(
command,
Command::Run {
title: "Refresh".into()
}
);
answer.send(Reply::error("no view yet")).unwrap();
});
let reply = send(
&path,
Command::Run {
title: "Refresh".into(),
},
)
.await
.unwrap();
assert_eq!(reply.error.as_deref(), Some("no view yet"));
loop_side.await.unwrap();
drop(file);
assert!(!path.exists(), "the control file goes with the listener");
}
#[tokio::test]
async fn a_wrong_token_is_refused_before_the_main_loop_sees_it() {
let path = endpoint_path(&scratch("token"));
let (mut rx, _file) = listen(path.clone()).await.unwrap();
let mut endpoint: Endpoint =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
endpoint.token = "forged".into();
std::fs::write(&path, serde_json::to_string(&endpoint).unwrap()).unwrap();
let reply = send(&path, Command::Screen).await.unwrap();
assert_eq!(reply.error.as_deref(), Some("wrong token"));
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn no_control_file_says_linear_tui_is_not_listening() {
let error = send(Path::new("/nonexistent/1.control"), Command::Screen)
.await
.unwrap_err();
assert!(
format!("{error:#}").contains("not accepting commands"),
"{error:#}"
);
}
}