Skip to main content

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}