use crate::TeksiloAppBuilder;
pub trait TeksiloAppBuilderAutomationExt {
fn install_automation_bridge_in_debug(self) -> Self;
}
#[cfg(debug_assertions)]
impl TeksiloAppBuilderAutomationExt for TeksiloAppBuilder {
fn install_automation_bridge_in_debug(self) -> Self {
install(self)
}
}
#[cfg(not(debug_assertions))]
impl TeksiloAppBuilderAutomationExt for TeksiloAppBuilder {
fn install_automation_bridge_in_debug(self) -> Self {
self
}
}
#[cfg(debug_assertions)]
use std::sync::mpsc::SyncSender;
#[cfg(debug_assertions)]
use teksilo_automation::dto::{AutomationReply, SettleSpec};
#[cfg(debug_assertions)]
pub struct AutomationPayload {
pub window_id: Option<u64>,
pub request_id: u64,
pub op: teksilo_automation::dto::AutomationOp,
pub settle: SettleSpec,
pub reply_tx: SyncSender<AutomationReply>,
}
#[cfg(debug_assertions)]
const MAX_REQUEST_FRAME: usize = 16 * 1024 * 1024;
#[cfg(debug_assertions)]
fn socket_dir() -> String {
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
format!("{dir}/teksilo-automation-{}", std::process::id())
}
#[cfg(debug_assertions)]
fn socket_path() -> String {
format!("{}/sock", socket_dir())
}
#[cfg(debug_assertions)]
pub(crate) fn clamp_live_settle(settle: &SettleSpec) -> SettleSpec {
const MAX_ANIM_FRAMES: u32 = 120;
const MAX_TIMEOUT_MS: u64 = 2000;
SettleSpec {
max_anim_frames: settle.max_anim_frames.min(MAX_ANIM_FRAMES),
settle_timeout_ms: settle.settle_timeout_ms.min(MAX_TIMEOUT_MS),
..*settle
}
}
#[cfg(debug_assertions)]
fn install(builder: TeksiloAppBuilder) -> TeksiloAppBuilder {
let token = std::env::var("TEKSILO_AUTOMATION_TOKEN")
.unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
builder.on_ready(move |proxy| {
if let Err(e) = spawn_bridge_thread(proxy, token) {
eprintln!("teksilo-automation: bridge failed to start: {e}");
}
})
}
#[cfg(all(debug_assertions, unix))]
pub fn spawn_bridge_thread(proxy: crate::app::AppEventProxy, token: String) -> std::io::Result<()> {
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
use std::os::unix::net::UnixListener;
let dir = socket_dir();
let _ = std::fs::remove_dir_all(&dir);
std::fs::DirBuilder::new().mode(0o700).create(&dir)?;
let path = socket_path();
let _ = std::fs::remove_file(&path);
let listener = UnixListener::bind(&path)?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
let announce = token.clone();
std::thread::Builder::new()
.name("teksilo-automation-bridge".into())
.spawn(move || {
struct Cleanup(String);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let _cleanup = Cleanup(dir);
for conn in listener.incoming() {
match conn {
Ok(stream) => {
let _ = handle_connection(stream, &proxy, &token);
}
Err(_) => break,
}
}
})?;
eprintln!("teksilo-automation: bridge socket = {path}");
eprintln!("TEKSILO_AUTOMATION_TOKEN={announce}");
eprintln!(
"teksilo-automation: connect with `teksilo-automation-mcp --connect {path} --token {announce}`"
);
Ok(())
}
#[cfg(all(debug_assertions, not(unix)))]
pub fn spawn_bridge_thread(
_proxy: crate::app::AppEventProxy,
_token: String,
) -> std::io::Result<()> {
eprintln!(
"teksilo-automation: the live bridge needs a Unix-domain socket and is unavailable on \
this platform; use `teksilo-automation-mcp --headless` instead."
);
Ok(())
}
#[cfg(all(debug_assertions, unix))]
fn handle_connection(
stream: std::os::unix::net::UnixStream,
proxy: &crate::app::AppEventProxy,
token: &str,
) -> std::io::Result<()> {
use std::io::{BufRead, BufReader, Read};
use std::time::Duration;
stream.set_read_timeout(Some(Duration::from_secs(10)))?;
let mut writer = stream.try_clone()?;
let mut reader = BufReader::new(stream);
let mut token_line = String::new();
{
let mut limited = (&mut reader).take(512);
limited.read_line(&mut token_line)?;
}
if token_line.trim() != token {
return Ok(()); }
reader.get_ref().set_read_timeout(None)?;
let mut request_id: u64 = 0;
loop {
let mut len = [0u8; 4];
if reader.read_exact(&mut len).is_err() {
break; }
let frame_len = u32::from_le_bytes(len) as usize;
if frame_len > MAX_REQUEST_FRAME {
break; }
let mut buf = vec![0u8; frame_len];
reader.read_exact(&mut buf)?;
let req: teksilo_automation::dto::AutomationRequest = match serde_json::from_slice(&buf) {
Ok(r) => r,
Err(e) => {
let reply = AutomationReply::err("BAD_REQUEST", e.to_string());
write_frame(&mut writer, &serde_json::to_vec(&reply).unwrap())?;
continue;
}
};
request_id += 1;
let (tx, rx) = std::sync::mpsc::sync_channel(1);
let payload = AutomationPayload {
window_id: req.window_id,
request_id,
op: req.op,
settle: req.settle,
reply_tx: tx,
};
proxy.send_external(payload);
let reply = rx.recv().unwrap_or_else(|_| {
AutomationReply::err("BRIDGE_DROPPED", "the app dropped the automation reply")
});
write_frame(&mut writer, &serde_json::to_vec(&reply).unwrap())?;
}
Ok(())
}
#[cfg(all(debug_assertions, unix))]
fn write_frame(w: &mut impl std::io::Write, bytes: &[u8]) -> std::io::Result<()> {
w.write_all(&(bytes.len() as u32).to_le_bytes())?;
w.write_all(bytes)?;
w.flush()
}
#[cfg(debug_assertions)]
pub(crate) fn screenshot_reply(
rgba: &[u8],
w: u32,
h: u32,
warnings: Vec<String>,
) -> AutomationReply {
use base64::Engine;
let png = encode_png(rgba, w, h);
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
AutomationReply::ok(serde_json::json!({ "png_base64": b64, "warnings": warnings }))
}
#[cfg(debug_assertions)]
fn encode_png(rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
let mut buf = Vec::new();
{
let mut encoder = png::Encoder::new(&mut buf, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().expect("png header");
writer.write_image_data(rgba).expect("png data");
}
buf
}
#[cfg(all(debug_assertions, test))]
mod tests {
use super::clamp_live_settle;
use teksilo_automation::dto::SettleSpec;
#[test]
fn live_settle_is_clamped() {
let capped = clamp_live_settle(&SettleSpec {
clock_millis: 25,
max_anim_frames: 10_000,
layout_after: true,
settle_timeout_ms: 30_000,
});
assert_eq!(capped.max_anim_frames, 120);
assert_eq!(capped.settle_timeout_ms, 2000);
assert_eq!(capped.clock_millis, 25, "non-bound fields pass through");
let d = SettleSpec::default();
let small = clamp_live_settle(&d);
assert_eq!(small.settle_timeout_ms, d.settle_timeout_ms);
assert_eq!(small.max_anim_frames, d.max_anim_frames);
}
}