teksilo_app/automation_bridge.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Debug-only in-app automation bridge.
5//!
6//! When a debug build calls
7//! [`install_automation_bridge_in_debug`](TeksiloAppBuilderAutomationExt::install_automation_bridge_in_debug),
8//! a background thread binds a private Unix-domain socket and lets
9//! `teksilo-automation-mcp --connect <sock> --token <uuid>` drive the *live*
10//! running app: it reads framed [`AutomationRequest`](teksilo_automation::dto::AutomationRequest)s, posts an
11//! [`AutomationPayload`] (carrying a `Send` reply channel) through the
12//! existing `AppEvent::External` path, and the winit main thread runs the
13//! op against the real window via
14//! [`execute`](teksilo_automation::execute) (or, for screenshots,
15//! [`PlatformWindow::capture_offscreen`](teksilo_platform::PlatformWindow::capture_offscreen)).
16//!
17//! Everything that touches the socket is additionally gated on
18//! `debug_assertions`: a *release* build with the `automation` feature on
19//! still contains no socket, token, or bridge — the install method is the
20//! identity. The framework itself stays runtime-free: this uses only
21//! `std::os::unix::net` plus the existing event-proxy plumbing.
22
23use crate::TeksiloAppBuilder;
24
25/// Adds [`install_automation_bridge_in_debug`](Self::install_automation_bridge_in_debug)
26/// to [`TeksiloAppBuilder`]. Mirrors `TeksiloAppBuilderInspectorExt`.
27pub trait TeksiloAppBuilderAutomationExt {
28 /// In a **debug** build: generate a per-process token, then on `on_ready`
29 /// bind a private `0600` Unix socket, print its path +
30 /// `TEKSILO_AUTOMATION_TOKEN=<uuid>` to stderr, and spawn the bridge
31 /// thread. The announcement follows the bind, so the printed path is
32 /// connectable the instant it appears. In a **release** build (or on a
33 /// non-Unix target): a no-op returning `self`.
34 fn install_automation_bridge_in_debug(self) -> Self;
35}
36
37#[cfg(debug_assertions)]
38impl TeksiloAppBuilderAutomationExt for TeksiloAppBuilder {
39 fn install_automation_bridge_in_debug(self) -> Self {
40 install(self)
41 }
42}
43
44#[cfg(not(debug_assertions))]
45impl TeksiloAppBuilderAutomationExt for TeksiloAppBuilder {
46 fn install_automation_bridge_in_debug(self) -> Self {
47 self
48 }
49}
50
51// ---------------------------------------------------------------------------
52// Debug-only machinery
53// ---------------------------------------------------------------------------
54
55#[cfg(debug_assertions)]
56use std::sync::mpsc::SyncSender;
57
58#[cfg(debug_assertions)]
59use teksilo_automation::dto::{AutomationReply, SettleSpec};
60
61/// The `Send` payload posted from the bridge thread to the winit main
62/// thread. Carries the op to run and a one-shot channel for the reply.
63#[cfg(debug_assertions)]
64pub struct AutomationPayload {
65 /// Target window (`TeksiloWindowId` raw); `None` → focused else primary.
66 pub window_id: Option<u64>,
67 /// Informational sequence number (single-inflight, so unused for
68 /// matching).
69 pub request_id: u64,
70 /// The operation to perform.
71 pub op: teksilo_automation::dto::AutomationOp,
72 /// Settle policy.
73 pub settle: SettleSpec,
74 /// Where the main thread sends the reply.
75 pub reply_tx: SyncSender<AutomationReply>,
76}
77
78/// Cap on a single inbound request frame (requests are small JSON ops — no
79/// images travel inbound). Bounds the `vec![0u8; len]` allocation against a
80/// client that sends a bogus 4-byte length (up to ~4 GiB otherwise).
81#[cfg(debug_assertions)]
82const MAX_REQUEST_FRAME: usize = 16 * 1024 * 1024;
83
84/// The per-process directory holding the bridge socket. Created `0700`, so the
85/// socket is unreachable by other local users even during the brief window
86/// before its own `0600` is applied (closes the bind→chmod TOCTOU).
87#[cfg(debug_assertions)]
88fn socket_dir() -> String {
89 let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
90 format!("{dir}/teksilo-automation-{}", std::process::id())
91}
92
93#[cfg(debug_assertions)]
94fn socket_path() -> String {
95 format!("{}/sock", socket_dir())
96}
97
98/// Clamp a settle for the LIVE bridge. The settle runs synchronously on the
99/// winit main thread, so an unbounded `settle_timeout_ms` / `max_anim_frames`
100/// (e.g. a `wait_for_condition` with a 30 s timeout) would freeze the running
101/// app's UI for that long. Cap both so the worst-case main-thread hitch is
102/// ~2 s; a longer wait should poll from the client or use the headless server
103/// (which has no UI to freeze and keeps the caller's values).
104#[cfg(debug_assertions)]
105pub(crate) fn clamp_live_settle(settle: &SettleSpec) -> SettleSpec {
106 const MAX_ANIM_FRAMES: u32 = 120;
107 const MAX_TIMEOUT_MS: u64 = 2000;
108 SettleSpec {
109 max_anim_frames: settle.max_anim_frames.min(MAX_ANIM_FRAMES),
110 settle_timeout_ms: settle.settle_timeout_ms.min(MAX_TIMEOUT_MS),
111 ..*settle
112 }
113}
114
115#[cfg(debug_assertions)]
116fn install(builder: TeksiloAppBuilder) -> TeksiloAppBuilder {
117 // A pinned `TEKSILO_AUTOMATION_TOKEN` lets a test / harness know the token
118 // up-front; otherwise generate a fresh per-process one.
119 let token = std::env::var("TEKSILO_AUTOMATION_TOKEN")
120 .unwrap_or_else(|_| uuid::Uuid::new_v4().to_string());
121 // The announcement belongs to `spawn_bridge_thread`, after the bind — see
122 // the comment there. Printing it here, at builder time, is what made the
123 // path a promise the process could not yet keep.
124 builder.on_ready(move |proxy| {
125 if let Err(e) = spawn_bridge_thread(proxy, token) {
126 eprintln!("teksilo-automation: bridge failed to start: {e}");
127 }
128 })
129}
130
131/// Bind the socket and spawn the bridge thread. Unix-only; on other targets
132/// this is a no-op (`Ok`) with an informational message — the headless MCP
133/// mode works everywhere, only the live socket is Unix-gated.
134#[cfg(all(debug_assertions, unix))]
135pub fn spawn_bridge_thread(proxy: crate::app::AppEventProxy, token: String) -> std::io::Result<()> {
136 use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
137 use std::os::unix::net::UnixListener;
138
139 let dir = socket_dir();
140 // Stale dir from a crashed prior run (PID reuse). Then create it `0700`
141 // atomically (mkdir mode) so the socket is never world-reachable.
142 let _ = std::fs::remove_dir_all(&dir);
143 std::fs::DirBuilder::new().mode(0o700).create(&dir)?;
144 let path = socket_path();
145 let _ = std::fs::remove_file(&path);
146 let listener = UnixListener::bind(&path)?;
147 // Belt + suspenders (the 0700 dir already gates access).
148 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
149
150 // The thread below takes the token; keep a copy to announce with.
151 let announce = token.clone();
152
153 std::thread::Builder::new()
154 .name("teksilo-automation-bridge".into())
155 .spawn(move || {
156 // Remove the whole per-process dir (socket included) on thread exit.
157 struct Cleanup(String);
158 impl Drop for Cleanup {
159 fn drop(&mut self) {
160 let _ = std::fs::remove_dir_all(&self.0);
161 }
162 }
163 let _cleanup = Cleanup(dir);
164
165 // Single connection at a time.
166 for conn in listener.incoming() {
167 match conn {
168 Ok(stream) => {
169 // Connection errors just end this connection; the
170 // loop waits for the next client.
171 let _ = handle_connection(stream, &proxy, &token);
172 }
173 Err(_) => break,
174 }
175 }
176 })?;
177
178 // Announce only now — after the bind AND after the accept thread exists.
179 // A client acts on this path the moment it reads it, so every failure has to
180 // happen first: printing at builder time (where this used to live) left
181 // every client racing the bind, and `teksilo-automation-mcp --connect` loses
182 // that race with ENOENT and `exit(1)` without speaking a byte of MCP, since
183 // it never retries. Printing merely after the bind would still be wrong for
184 // a rarer case — a failed `spawn` (thread limit) leaves a socket that is
185 // bound, so `connect` succeeds, but that nobody will ever `accept`, and the
186 // client has no read timeout to rescue it: it would hang rather than fail.
187 // `UnixListener::bind` has already called `listen`, so the kernel queues a
188 // connection arriving before the thread is scheduled. Clients therefore need
189 // no wait-for-socket loop; reading this line is enough.
190 eprintln!("teksilo-automation: bridge socket = {path}");
191 eprintln!("TEKSILO_AUTOMATION_TOKEN={announce}");
192 eprintln!(
193 "teksilo-automation: connect with `teksilo-automation-mcp --connect {path} --token {announce}`"
194 );
195 Ok(())
196}
197
198#[cfg(all(debug_assertions, not(unix)))]
199pub fn spawn_bridge_thread(
200 _proxy: crate::app::AppEventProxy,
201 _token: String,
202) -> std::io::Result<()> {
203 eprintln!(
204 "teksilo-automation: the live bridge needs a Unix-domain socket and is unavailable on \
205 this platform; use `teksilo-automation-mcp --headless` instead."
206 );
207 Ok(())
208}
209
210#[cfg(all(debug_assertions, unix))]
211fn handle_connection(
212 stream: std::os::unix::net::UnixStream,
213 proxy: &crate::app::AppEventProxy,
214 token: &str,
215) -> std::io::Result<()> {
216 use std::io::{BufRead, BufReader, Read};
217 use std::time::Duration;
218
219 // Bound the token handshake: a client that connects but never sends the
220 // token must not occupy the single connection slot forever.
221 stream.set_read_timeout(Some(Duration::from_secs(10)))?;
222 let mut writer = stream.try_clone()?;
223 let mut reader = BufReader::new(stream);
224
225 // Token handshake (one line, length-bounded so a stream of bytes without a
226 // newline can't exhaust memory).
227 let mut token_line = String::new();
228 {
229 let mut limited = (&mut reader).take(512);
230 limited.read_line(&mut token_line)?;
231 }
232 if token_line.trim() != token {
233 return Ok(()); // reject — bad/missing token
234 }
235 // Requests can arrive sporadically over a long-lived connection, so clear
236 // the read deadline now that the client is authenticated.
237 reader.get_ref().set_read_timeout(None)?;
238
239 let mut request_id: u64 = 0;
240 loop {
241 // 4-byte little-endian length prefix.
242 let mut len = [0u8; 4];
243 if reader.read_exact(&mut len).is_err() {
244 break; // clean EOF — client disconnected
245 }
246 let frame_len = u32::from_le_bytes(len) as usize;
247 if frame_len > MAX_REQUEST_FRAME {
248 break; // desynced / abusive client — drop the connection
249 }
250 let mut buf = vec![0u8; frame_len];
251 reader.read_exact(&mut buf)?;
252
253 let req: teksilo_automation::dto::AutomationRequest = match serde_json::from_slice(&buf) {
254 Ok(r) => r,
255 Err(e) => {
256 let reply = AutomationReply::err("BAD_REQUEST", e.to_string());
257 write_frame(&mut writer, &serde_json::to_vec(&reply).unwrap())?;
258 continue;
259 }
260 };
261
262 request_id += 1;
263 // One-slot channel: the main thread's `send` never blocks.
264 let (tx, rx) = std::sync::mpsc::sync_channel(1);
265 let payload = AutomationPayload {
266 window_id: req.window_id,
267 request_id,
268 op: req.op,
269 settle: req.settle,
270 reply_tx: tx,
271 };
272 proxy.send_external(payload);
273
274 let reply = rx.recv().unwrap_or_else(|_| {
275 AutomationReply::err("BRIDGE_DROPPED", "the app dropped the automation reply")
276 });
277 write_frame(&mut writer, &serde_json::to_vec(&reply).unwrap())?;
278 }
279 Ok(())
280}
281
282#[cfg(all(debug_assertions, unix))]
283fn write_frame(w: &mut impl std::io::Write, bytes: &[u8]) -> std::io::Result<()> {
284 w.write_all(&(bytes.len() as u32).to_le_bytes())?;
285 w.write_all(bytes)?;
286 w.flush()
287}
288
289/// Build a screenshot reply: PNG-encode the RGBA pixels, base64 them into a
290/// JSON object the `--connect` client rehydrates to an image block. Used by
291/// the main-thread screenshot arm in `app.rs`.
292#[cfg(debug_assertions)]
293pub(crate) fn screenshot_reply(
294 rgba: &[u8],
295 w: u32,
296 h: u32,
297 warnings: Vec<String>,
298) -> AutomationReply {
299 use base64::Engine;
300 let png = encode_png(rgba, w, h);
301 let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
302 AutomationReply::ok(serde_json::json!({ "png_base64": b64, "warnings": warnings }))
303}
304
305#[cfg(debug_assertions)]
306fn encode_png(rgba: &[u8], w: u32, h: u32) -> Vec<u8> {
307 let mut buf = Vec::new();
308 {
309 let mut encoder = png::Encoder::new(&mut buf, w, h);
310 encoder.set_color(png::ColorType::Rgba);
311 encoder.set_depth(png::BitDepth::Eight);
312 let mut writer = encoder.write_header().expect("png header");
313 writer.write_image_data(rgba).expect("png data");
314 }
315 buf
316}
317
318#[cfg(all(debug_assertions, test))]
319mod tests {
320 use super::clamp_live_settle;
321 use teksilo_automation::dto::SettleSpec;
322
323 #[test]
324 fn live_settle_is_clamped() {
325 // A long wait/settle must be capped so it can't freeze the main-thread UI.
326 let capped = clamp_live_settle(&SettleSpec {
327 clock_millis: 25,
328 max_anim_frames: 10_000,
329 layout_after: true,
330 settle_timeout_ms: 30_000,
331 });
332 assert_eq!(capped.max_anim_frames, 120);
333 assert_eq!(capped.settle_timeout_ms, 2000);
334 assert_eq!(capped.clock_millis, 25, "non-bound fields pass through");
335
336 // Values already under the cap are untouched.
337 let d = SettleSpec::default();
338 let small = clamp_live_settle(&d);
339 assert_eq!(small.settle_timeout_ms, d.settle_timeout_ms);
340 assert_eq!(small.max_anim_frames, d.max_anim_frames);
341 }
342}