use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
#[cfg(debug_assertions)]
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tao::event_loop::EventLoopProxy;
use crate::watcher::UserEvent;
#[cfg(debug_assertions)]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action")]
pub enum InteractAction {
#[serde(rename = "click")]
Click { selector: String },
#[serde(rename = "wait_for")]
WaitFor { selector: String, timeout_ms: u64 },
#[serde(rename = "query")]
Query { selector: String },
#[serde(rename = "fill")]
Fill { selector: String, value: String },
}
#[cfg(debug_assertions)]
#[derive(Debug, Serialize, Deserialize)]
pub struct ElementInfo {
pub tag: String,
pub text: String,
pub visible: bool,
pub attributes: HashMap<String, String>,
}
#[cfg(debug_assertions)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum InteractResult {
#[serde(rename = "ok")]
Ok,
#[serde(rename = "found")]
Found {
count: usize,
elements: Vec<ElementInfo>,
},
#[serde(rename = "not_found")]
NotFound { selector: String },
#[serde(rename = "timeout")]
Timeout { selector: String, timeout_ms: u64 },
#[serde(rename = "error")]
Error { message: String },
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SocketMessage {
#[serde(rename = "open")]
Open { path: String },
#[cfg(debug_assertions)]
#[serde(rename = "screenshot")]
Screenshot,
#[serde(rename = "info")]
Info,
#[cfg(debug_assertions)]
#[serde(rename = "eval")]
Eval { js: String },
#[cfg(debug_assertions)]
#[serde(rename = "interact")]
Interact(InteractAction),
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum SocketResponse {
#[serde(rename = "ok")]
Ok,
#[cfg(debug_assertions)]
#[serde(rename = "screenshot")]
Screenshot { path: String },
#[serde(rename = "info")]
Info {
binary: String,
pid: u32,
window_id: Option<i64>,
},
#[cfg(debug_assertions)]
#[serde(rename = "eval")]
Eval { result: String },
#[cfg(debug_assertions)]
#[serde(rename = "interact")]
Interact(InteractResult),
#[serde(rename = "error")]
Error { message: String },
}
fn runtime_dir() -> Result<PathBuf> {
#[cfg(debug_assertions)]
{
let exe = std::env::current_exe().context("could not determine executable path")?;
let namespace = short_exe_namespace(&exe);
Ok(PathBuf::from("/tmp").join(format!("attn-{namespace}")))
}
#[cfg(not(debug_assertions))]
{
let home = dirs::home_dir().context("could not determine home directory")?;
Ok(home.join(".attn"))
}
}
#[cfg(debug_assertions)]
fn short_exe_namespace(path: &std::path::Path) -> String {
let mut hash: u64 = 0xcbf29ce484222325;
for b in path.as_os_str().as_encoded_bytes() {
hash ^= u64::from(*b);
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn socket_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("attn.sock"))
}
fn ensure_runtime_dir() -> Result<()> {
let dir = runtime_dir()?;
if !dir.exists() {
std::fs::create_dir_all(&dir)
.with_context(|| format!("could not create {}", dir.display()))?;
}
Ok(())
}
fn binary_fingerprint() -> Result<String> {
let exe = std::env::current_exe().context("could not determine executable path")?;
let meta =
std::fs::metadata(&exe).with_context(|| format!("could not stat {}", exe.display()))?;
let mtime = meta
.modified()
.context("could not read mtime")?
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let size = meta.len();
Ok(format!("{mtime}:{size}"))
}
fn fingerprint_path() -> Result<PathBuf> {
Ok(runtime_dir()?.join("attn.fingerprint"))
}
pub fn write_fingerprint() -> Result<()> {
ensure_runtime_dir()?;
let fp = binary_fingerprint()?;
std::fs::write(fingerprint_path()?, fp).context("could not write fingerprint")?;
Ok(())
}
fn daemon_binary_matches() -> bool {
let Ok(stored) = std::fs::read_to_string(fingerprint_path().unwrap_or_default()) else {
return false;
};
let Ok(current) = binary_fingerprint() else {
return false;
};
stored.trim() == current.trim()
}
pub fn replace_stale_daemon() -> Result<bool> {
let sock = socket_path()?;
if !sock.exists() {
return Ok(false);
}
if daemon_binary_matches() {
return Ok(false);
}
match send_info() {
Ok(info) => {
eprintln!("attn: binary changed, replacing daemon (pid {})", info.pid);
let pid = nix::unistd::Pid::from_raw(info.pid as i32);
let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM);
let deadline = Instant::now() + Duration::from_secs(3);
while sock.exists() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
if sock.exists() {
let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL);
std::thread::sleep(Duration::from_millis(100));
let _ = std::fs::remove_file(&sock);
}
Ok(true)
}
Err(_) => {
let _ = std::fs::remove_file(&sock);
Ok(true)
}
}
}
pub fn try_send_to_existing(path: &str) -> Result<bool> {
let msg = SocketMessage::Open {
path: path.to_string(),
};
match send_command(&msg)? {
Some(_) => Ok(true),
None => Ok(false),
}
}
#[cfg(debug_assertions)]
pub fn send_screenshot() -> Result<String> {
match send_command(&SocketMessage::Screenshot)? {
Some(resp) => match resp {
SocketResponse::Screenshot { path } => Ok(path),
SocketResponse::Error { message } => bail!("screenshot failed: {message}"),
other => bail!("unexpected response: {other:?}"),
},
None => bail!("no daemon running"),
}
}
#[allow(dead_code)]
pub struct DaemonInfo {
pub binary: String,
pub pid: u32,
pub window_id: Option<i64>,
}
pub fn send_info() -> Result<DaemonInfo> {
match send_command(&SocketMessage::Info)? {
Some(resp) => match resp {
SocketResponse::Info {
binary,
pid,
window_id,
} => Ok(DaemonInfo {
binary,
pid,
window_id,
}),
SocketResponse::Error { message } => bail!("info failed: {message}"),
other => bail!("unexpected response: {other:?}"),
},
None => bail!("no daemon running"),
}
}
#[cfg(debug_assertions)]
pub fn send_eval(js: &str) -> Result<String> {
match send_command(&SocketMessage::Eval { js: js.to_string() })? {
Some(resp) => match resp {
SocketResponse::Eval { result } => Ok(result),
SocketResponse::Error { message } => bail!("eval failed: {message}"),
other => bail!("unexpected response: {other:?}"),
},
None => bail!("no daemon running"),
}
}
#[cfg(debug_assertions)]
pub fn send_interact(action: InteractAction) -> Result<InteractResult> {
match send_command(&SocketMessage::Interact(action))? {
Some(resp) => match resp {
SocketResponse::Interact(result) => Ok(result),
SocketResponse::Error { message } => bail!("interact failed: {message}"),
other => bail!("unexpected response: {other:?}"),
},
None => bail!("no daemon running"),
}
}
fn send_command(msg: &SocketMessage) -> Result<Option<SocketResponse>> {
let sock = socket_path()?;
match UnixStream::connect(&sock) {
Ok(mut stream) => {
let json = serde_json::to_string(msg).context("failed to serialize socket message")?;
writeln!(stream, "{json}").context("failed to send message to daemon")?;
stream
.shutdown(std::net::Shutdown::Write)
.context("failed to shutdown write")?;
let mut reader = BufReader::new(&stream);
let mut line = String::new();
reader
.read_line(&mut line)
.context("failed to read response from daemon")?;
if line.is_empty() {
return Ok(Some(SocketResponse::Ok));
}
let resp: SocketResponse =
serde_json::from_str(line.trim()).context("failed to parse daemon response")?;
Ok(Some(resp))
}
Err(e) => {
if e.kind() == std::io::ErrorKind::ConnectionRefused
|| e.kind() == std::io::ErrorKind::NotFound
{
if sock.exists() {
let _ = std::fs::remove_file(&sock);
}
Ok(None)
} else {
Err(e).context("unexpected error connecting to daemon socket")
}
}
}
}
pub fn maybe_fork(no_fork: bool) -> Result<()> {
if no_fork {
return Ok(());
}
#[cfg(target_os = "macos")]
{
use std::ffi::OsString;
use std::process::{Command, Stdio};
let exe = std::env::current_exe().context("could not determine executable path")?;
let mut args: Vec<OsString> = std::env::args_os()
.skip(1)
.filter(|arg| arg != "--no-fork")
.collect();
args.push(OsString::from("--no-fork"));
let mut cmd = Command::new(exe);
cmd.args(args).stdin(Stdio::null()).stdout(Stdio::null());
if let Ok(log_dir) = runtime_dir() {
let log_path = log_dir.join("attn.log");
if let Ok(log_file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
cmd.stderr(Stdio::from(log_file));
} else {
cmd.stderr(Stdio::null());
}
} else {
cmd.stderr(Stdio::null());
}
let _child = cmd.spawn().context("failed to spawn detached child")?;
std::process::exit(0);
}
#[cfg(not(target_os = "macos"))]
{
use nix::unistd::{ForkResult, close, dup2, fork, setsid};
use std::os::unix::io::{AsRawFd, IntoRawFd};
match unsafe { fork() }.context("fork failed")? {
ForkResult::Child => {
setsid().context("setsid failed")?;
if let Some(log_path) = runtime_dir().ok().map(|d| d.join("attn.log")) {
if let Ok(log_file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let fd = log_file.into_raw_fd();
let _ = dup2(fd, std::io::stderr().as_raw_fd());
let _ = close(fd);
let _ = close(std::io::stdin().as_raw_fd());
}
}
Ok(())
}
ForkResult::Parent { .. } => {
std::process::exit(0);
}
}
}
}
pub fn start_listener(proxy: EventLoopProxy<UserEvent>) -> Result<SocketCleanup> {
ensure_runtime_dir()?;
let sock = socket_path()?;
if sock.exists() {
std::fs::remove_file(&sock).context("could not remove stale socket")?;
}
let listener = UnixListener::bind(&sock)
.with_context(|| format!("could not bind socket at {}", sock.display()))?;
let sock_path = Arc::new(sock);
let cleanup_path = Arc::clone(&sock_path);
std::thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let proxy = proxy.clone();
handle_client(stream, &proxy);
}
Err(e) => {
eprintln!("attn: socket accept error: {e}");
break;
}
}
}
});
Ok(SocketCleanup { path: cleanup_path })
}
fn handle_client(mut stream: UnixStream, proxy: &EventLoopProxy<UserEvent>) {
let reader_stream = match stream.try_clone() {
Ok(s) => s,
Err(e) => {
eprintln!("attn: failed to clone stream: {e}");
return;
}
};
let reader = BufReader::new(reader_stream);
for line in reader.lines() {
let Ok(line) = line else { break };
if line.is_empty() {
continue;
}
match serde_json::from_str::<SocketMessage>(&line) {
Ok(SocketMessage::Open { path }) => {
let path = PathBuf::from(path);
let _ = proxy.send_event(UserEvent::SwitchProject(path));
let resp = SocketResponse::Ok;
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
#[cfg(debug_assertions)]
Ok(SocketMessage::Screenshot) => {
let (tx, rx) = std::sync::mpsc::channel();
let _ = proxy.send_event(UserEvent::Screenshot(tx));
match rx.recv_timeout(std::time::Duration::from_secs(5)) {
Ok(path) => {
let resp = SocketResponse::Screenshot { path };
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
Err(_) => {
let resp = SocketResponse::Error {
message: "screenshot timed out".to_string(),
};
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
}
}
Ok(SocketMessage::Info) => {
let (tx, rx) = std::sync::mpsc::channel();
let _ = proxy.send_event(UserEvent::Info(tx));
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
Ok(info_json) => {
let _ = writeln!(stream, "{info_json}");
}
Err(_) => {
let resp = SocketResponse::Error {
message: "info timed out".to_string(),
};
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
}
}
#[cfg(debug_assertions)]
Ok(SocketMessage::Eval { js }) => {
let (tx, rx) = std::sync::mpsc::channel();
let _ = proxy.send_event(UserEvent::Eval(js, tx));
match rx.recv_timeout(std::time::Duration::from_secs(10)) {
Ok(result) => {
let resp = SocketResponse::Eval { result };
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
Err(_) => {
let resp = SocketResponse::Error {
message: "eval timed out".to_string(),
};
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
}
}
#[cfg(debug_assertions)]
Ok(SocketMessage::Interact(action)) => {
let result = execute_interact(&action, proxy);
let resp = SocketResponse::Interact(result);
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
Err(e) => {
eprintln!("attn: invalid socket message: {e}");
let resp = SocketResponse::Error {
message: format!("invalid message: {e}"),
};
let _ = writeln!(
stream,
"{}",
serde_json::to_string(&resp).unwrap_or_default()
);
}
}
}
}
#[cfg(debug_assertions)]
fn interaction_js(action: &InteractAction) -> String {
let resolve_fn = r#"
function __resolve(sel) {
if (sel.startsWith('text=')) {
var target = sel.slice(5);
var all = document.querySelectorAll('button, a, [role=button], [data-sidebar], span, p, h1, h2, h3, h4, h5, h6, li, td, th, label, div');
var matched = [];
for (var i = 0; i < all.length; i++) {
if (all[i].textContent && all[i].textContent.trim() === target) matched.push(all[i]);
}
return matched;
}
return Array.from(document.querySelectorAll(sel));
}
"#;
match action {
InteractAction::Click { selector } => {
let sel_json = serde_json::to_string(selector).unwrap_or_default();
format!(
r#"(function() {{
{resolve_fn}
var els = __resolve({sel_json});
if (els.length === 0) return JSON.stringify({{status:'not_found',selector:{sel_json}}});
els[0].click();
return JSON.stringify({{status:'ok'}});
}})()"#,
)
}
InteractAction::Query { selector } => {
let sel_json = serde_json::to_string(selector).unwrap_or_default();
format!(
r#"(function() {{
{resolve_fn}
var els = __resolve({sel_json});
if (els.length === 0) return JSON.stringify({{status:'not_found',selector:{sel_json}}});
var elements = [];
for (var i = 0; i < els.length; i++) {{
var el = els[i];
var attrs = {{}};
for (var j = 0; j < el.attributes.length; j++) {{
attrs[el.attributes[j].name] = el.attributes[j].value;
}}
var rect = el.getBoundingClientRect();
elements.push({{
tag: el.tagName.toLowerCase(),
text: (el.textContent || '').trim(),
visible: rect.width > 0 && rect.height > 0,
attributes: attrs
}});
}}
return JSON.stringify({{status:'found',count:elements.length,elements:elements}});
}})()"#,
)
}
InteractAction::Fill { selector, value } => {
let sel_json = serde_json::to_string(selector).unwrap_or_default();
let val_json = serde_json::to_string(value).unwrap_or_default();
format!(
r#"(function() {{
{resolve_fn}
var els = __resolve({sel_json});
if (els.length === 0) return JSON.stringify({{status:'not_found',selector:{sel_json}}});
var el = els[0];
el.value = {val_json};
el.dispatchEvent(new Event('input', {{bubbles:true}}));
el.dispatchEvent(new Event('change', {{bubbles:true}}));
return JSON.stringify({{status:'ok'}});
}})()"#,
)
}
InteractAction::WaitFor { selector, .. } => {
let sel_json = serde_json::to_string(selector).unwrap_or_default();
format!(
r#"(function() {{
{resolve_fn}
var els = __resolve({sel_json});
if (els.length > 0) return JSON.stringify({{status:'ok'}});
return JSON.stringify({{status:'not_found',selector:{sel_json}}});
}})()"#,
)
}
}
}
#[cfg(debug_assertions)]
fn parse_interact_result(raw: &str) -> InteractResult {
if let Ok(result) = serde_json::from_str::<InteractResult>(raw) {
return result;
}
if let Ok(inner) = serde_json::from_str::<String>(raw)
&& let Ok(result) = serde_json::from_str::<InteractResult>(&inner)
{
return result;
}
InteractResult::Error {
message: format!("failed to parse interact result: {raw}"),
}
}
#[cfg(debug_assertions)]
fn execute_interact(action: &InteractAction, proxy: &EventLoopProxy<UserEvent>) -> InteractResult {
match action {
InteractAction::WaitFor {
selector,
timeout_ms,
} => {
let check_js = interaction_js(action);
let deadline = Instant::now() + Duration::from_millis(*timeout_ms);
loop {
let (tx, rx) = std::sync::mpsc::channel();
let _ = proxy.send_event(UserEvent::Eval(check_js.clone(), tx));
if let Result::Ok(result) = rx.recv_timeout(Duration::from_secs(2)) {
let parsed = parse_interact_result(&result);
if matches!(parsed, InteractResult::Ok) {
return InteractResult::Ok;
}
}
if Instant::now() >= deadline {
return InteractResult::Timeout {
selector: selector.clone(),
timeout_ms: *timeout_ms,
};
}
std::thread::sleep(Duration::from_millis(50));
}
}
_ => {
let js = interaction_js(action);
let (tx, rx) = std::sync::mpsc::channel();
let _ = proxy.send_event(UserEvent::Eval(js, tx));
match rx.recv_timeout(Duration::from_secs(10)) {
Result::Ok(result) => parse_interact_result(&result),
Err(_) => InteractResult::Error {
message: "eval timed out".to_string(),
},
}
}
}
}
pub struct SocketCleanup {
path: Arc<PathBuf>,
}
impl SocketCleanup {
pub fn cleanup(&self) {
let _ = std::fs::remove_file(self.path.as_ref());
}
}
impl Drop for SocketCleanup {
fn drop(&mut self) {
self.cleanup();
}
}