1use std::sync::{
43 LazyLock,
44 atomic::{AtomicBool, Ordering},
45};
46
47use parking_lot::Mutex;
48
49use crate::{
50 input::{InputEvent, Key, Mouse, MouseButton, MouseReport},
51 pump::{DebugOp, DebugQuery, TerminalEvent},
52};
53
54pub const DEBUG_ENV: &str = "OMP_TUI_DEBUG";
56
57static SCREEN: Mutex<Option<ScreenSnapshot>> = Mutex::new(None);
60
61static RESPONSES: Mutex<Option<flume::Sender<(u64, serde_json::Value)>>> = Mutex::new(None);
63
64pub fn enabled() -> bool {
66 static ENABLED: LazyLock<bool> =
67 LazyLock::new(|| std::env::var_os(DEBUG_ENV).is_some_and(|value| !value.is_empty()));
68 *ENABLED
69}
70
71pub fn publishing() -> bool {
73 enabled()
74}
75
76pub fn respond_debug_query(id: u64, response: serde_json::Value) {
80 let sender = RESPONSES.lock().clone();
81 if let Some(sender) = sender {
82 let _ = sender.send((id, response));
83 }
84}
85
86pub fn publish_screen(snapshot: ScreenSnapshot) {
88 *SCREEN.lock() = Some(snapshot);
89}
90
91pub fn screen_snapshot() -> Option<ScreenSnapshot> {
93 SCREEN.lock().clone()
94}
95
96#[derive(Clone)]
98pub struct ScreenSnapshot {
99 pub lines: Vec<String>,
101 pub cursor: Option<(u16, u16)>,
103 pub window_top: u16,
105 pub cols: u16,
107 pub rows: u16,
109 pub doc_height: u16,
111 pub overlay: bool,
113}
114
115fn direct_response(request: DebugRequest) -> Result<serde_json::Value, DebugOp> {
120 use serde_json::json;
121 Ok(match request {
122 DebugRequest::Info => return Err(DebugOp::Info),
123 DebugRequest::Text => return Err(DebugOp::Text),
124 DebugRequest::Frame => return Err(DebugOp::Frame),
125 DebugRequest::Tree => return Err(DebugOp::Tree),
126 DebugRequest::Values => return Err(DebugOp::Values),
127 DebugRequest::Resize => return Err(DebugOp::Resize),
128 DebugRequest::Quit => return Err(DebugOp::Quit),
129 DebugRequest::Inject(events) => {
130 let injected = events.len();
131 if events
132 .into_iter()
133 .all(|event| crate::pump::send_event(TerminalEvent::Input(event)))
134 {
135 json!({ "ok": true, "injected": injected })
136 } else {
137 json!({ "ok": false, "error": "no live terminal to inject into" })
138 }
139 },
140 DebugRequest::Events(events) => {
141 let injected = events.len();
142 if events.into_iter().all(crate::pump::send_event) {
143 json!({ "ok": true, "injected": injected })
144 } else {
145 json!({ "ok": false, "error": "no live terminal to inject into" })
146 }
147 },
148 DebugRequest::Bytes(bytes) => {
149 let fed = bytes.len();
152 if crate::pump::inject_bytes(bytes) {
153 json!({ "ok": true, "fed": fed })
154 } else {
155 json!({ "ok": false, "error": "no live terminal to inject into" })
156 }
157 },
158 })
159}
160
161pub fn terminal_response(op: DebugOp) -> Option<serde_json::Value> {
169 use serde_json::json;
170 let snapshot = |build: fn(ScreenSnapshot) -> serde_json::Value| {
171 screen_snapshot()
172 .map_or_else(|| json!({ "ok": false, "error": "no frame painted yet" }), build)
173 };
174 match op {
175 DebugOp::Info => Some(snapshot(|snapshot| {
176 json!({
177 "ok": true,
178 "cols": snapshot.cols,
179 "rows": snapshot.rows,
180 "height": snapshot.doc_height,
181 "window_top": snapshot.window_top,
182 "alt_screen": crate::terminal::alt_screen_active(),
183 "overlay": snapshot.overlay,
184 })
185 })),
186 DebugOp::Text => Some(snapshot(|snapshot| {
187 json!({
188 "ok": true,
189 "lines": snapshot.lines,
190 "cursor": snapshot.cursor.map(|(row, col)| vec![row, col]),
191 "window_top": snapshot.window_top,
192 "alt_screen": crate::terminal::alt_screen_active(),
193 })
194 })),
195 DebugOp::Resize => {
196 crate::terminal::simulate_resize_signal();
197 Some(json!({ "ok": true, "signalled": true }))
198 },
199 DebugOp::Quit => Some(json!({ "ok": true, "injected": "C-c" })),
200 DebugOp::Frame | DebugOp::Tree | DebugOp::Values => None,
201 }
202}
203
204pub enum DebugRequest {
206 Info,
207 Text,
208 Frame,
209 Tree,
210 Values,
211 Inject(Vec<InputEvent>),
213 Events(Vec<TerminalEvent>),
215 Bytes(Vec<u8>),
217 Resize,
218 Quit,
219}
220
221pub fn parse_request(line: &[u8]) -> Result<DebugRequest, String> {
223 let value: serde_json::Value =
224 serde_json::from_slice(line).map_err(|error| format!("malformed request: {error}"))?;
225 let op = value
226 .get("op")
227 .and_then(serde_json::Value::as_str)
228 .ok_or_else(|| "missing \"op\"".to_owned())?;
229 match op {
230 "info" => Ok(DebugRequest::Info),
231 "text" => Ok(DebugRequest::Text),
232 "frame" => Ok(DebugRequest::Frame),
233 "tree" => Ok(DebugRequest::Tree),
234 "values" => Ok(DebugRequest::Values),
235 "keys" => {
236 let spec = value
237 .get("keys")
238 .and_then(serde_json::Value::as_str)
239 .ok_or_else(|| "keys op needs a \"keys\" string".to_owned())?;
240 Ok(DebugRequest::Inject(parse_keys(spec)?.into_iter().map(InputEvent::Key).collect()))
241 },
242 "bytes" => {
243 let data = value
244 .get("data")
245 .and_then(serde_json::Value::as_str)
246 .ok_or_else(|| "bytes op needs a \"data\" string".to_owned())?;
247 Ok(DebugRequest::Bytes(data.as_bytes().to_vec()))
248 },
249 "paste" => {
250 let text = value
251 .get("text")
252 .and_then(serde_json::Value::as_str)
253 .ok_or_else(|| "paste op needs a \"text\" string".to_owned())?;
254 Ok(DebugRequest::Inject(vec![InputEvent::Paste(text.into())]))
255 },
256 "mouse" => Ok(DebugRequest::Inject(vec![InputEvent::Mouse(parse_mouse(&value)?)])),
257 "event" | "events" => {
258 let payload = value
259 .get("event")
260 .or_else(|| value.get("events"))
261 .ok_or_else(|| "event op needs an \"event\" (or \"events\") field".to_owned())?;
262 let events = if payload.is_array() {
263 serde_json::from_value::<Vec<TerminalEvent>>(payload.clone())
264 } else {
265 serde_json::from_value::<TerminalEvent>(payload.clone()).map(|event| vec![event])
266 }
267 .map_err(|error| format!("malformed terminal event: {error}"))?;
268 Ok(DebugRequest::Events(events))
269 },
270 "resize" => Ok(DebugRequest::Resize),
271 "quit" => Ok(DebugRequest::Quit),
272 other => Err(format!("unknown op {other:?}")),
273 }
274}
275
276pub fn parse_keys(spec: &str) -> Result<Vec<Key>, String> {
283 let mut keys = Vec::new();
284 let mut rest = spec.trim_start();
285 while !rest.is_empty() {
286 if let Some(quote) = rest.chars().next().filter(|ch| matches!(ch, '\'' | '"')) {
287 let body = &rest[quote.len_utf8()..];
288 let end = body
289 .find(quote)
290 .ok_or_else(|| format!("unterminated quote in key spec: {rest:?}"))?;
291 keys.extend(body[..end].chars().map(literal_key));
292 rest = body[end + quote.len_utf8()..].trim_start();
293 continue;
294 }
295 let token = rest
296 .split_whitespace()
297 .next()
298 .expect("non-empty trimmed spec");
299 keys.push(parse_token(token)?);
300 rest = rest[token.len()..].trim_start();
301 }
302 Ok(keys)
303}
304
305const fn literal_key(ch: char) -> Key {
307 match ch {
308 ' ' => Key::Space,
309 _ => Key::Char(ch),
310 }
311}
312
313fn parse_token(token: &str) -> Result<Key, String> {
314 if token.chars().count() > 1 {
316 let lower = token.to_ascii_lowercase();
317 if let Some(ch) = strip_chord(&lower, &["c-m-", "m-c-", "ctrl-alt-"]) {
318 return Ok(Key::CtrlAlt(ch));
319 }
320 if let Some(ch) = strip_chord(&lower, &["c-", "ctrl-", "ctrl+"]) {
321 return Ok(Key::Ctrl(ch));
322 }
323 if let Some(ch) = strip_chord(&lower, &["m-", "a-", "alt-", "alt+"]) {
324 return Ok(Key::Alt(ch));
325 }
326 }
327 let named = match token.to_ascii_lowercase().as_str() {
328 "up" => Key::Up,
329 "down" => Key::Down,
330 "left" => Key::Left,
331 "right" => Key::Right,
332 "tab" => Key::Tab,
333 "backtab" | "shift-tab" => Key::BackTab,
334 "enter" | "return" | "cr" => Key::Enter,
335 "space" => Key::Space,
336 "esc" | "escape" => Key::Esc,
337 "backspace" | "bs" => Key::Backspace,
338 "delete" | "del" => Key::Delete,
339 "insert" => Key::Insert,
340 "home" => Key::Home,
341 "end" => Key::End,
342 "pgup" | "pageup" => Key::PageUp,
343 "pgdn" | "pagedown" => Key::PageDown,
344 "shift-enter" => Key::ShiftEnter,
345 "word-left" => Key::WordLeft,
346 "word-right" => Key::WordRight,
347 "word-delete" => Key::WordDelete,
348 other => {
349 if let Some(number) = other.strip_prefix('f')
350 && let Ok(number) = number.parse::<u8>()
351 && (1..=12).contains(&number)
352 {
353 return Ok(Key::Function(number));
354 }
355 let mut chars = token.chars();
356 return match (chars.next(), chars.next()) {
357 (Some(ch), None) => Ok(literal_key(ch)),
358 _ => Err(format!("unknown key token {token:?}")),
359 };
360 },
361 };
362 Ok(named)
363}
364
365fn strip_chord(token: &str, prefixes: &[&str]) -> Option<char> {
367 prefixes.iter().find_map(|prefix| {
368 let rest = token.strip_prefix(prefix)?;
369 let mut chars = rest.chars();
370 match (chars.next(), chars.next()) {
371 (Some(ch), None) => Some(ch),
372 _ => None,
373 }
374 })
375}
376
377fn parse_mouse(value: &serde_json::Value) -> Result<MouseReport, String> {
378 let coordinate = |name: &str| -> Result<u16, String> {
379 value
380 .get(name)
381 .and_then(serde_json::Value::as_u64)
382 .and_then(|number| u16::try_from(number).ok())
383 .ok_or_else(|| format!("mouse op needs a numeric \"{name}\""))
384 };
385 let col = coordinate("x")?;
386 let row = coordinate("y")?;
387 let action = value
388 .get("action")
389 .and_then(serde_json::Value::as_str)
390 .unwrap_or("click");
391 let (kind, button, pressed) = match action {
392 "click" | "press" => (Mouse::Click, MouseButton::Left, true),
393 "right-click" => (Mouse::RightClick, MouseButton::Right, true),
394 "middle-click" => (Mouse::MiddleClick, MouseButton::Middle, true),
395 "move" => (Mouse::Move, MouseButton::None, false),
396 "drag" => (Mouse::Drag, MouseButton::Left, true),
397 "release" => (Mouse::Release, MouseButton::Left, false),
398 "wheel-up" => (Mouse::WheelUp, MouseButton::WheelUp, true),
399 "wheel-down" => (Mouse::WheelDown, MouseButton::WheelDown, true),
400 "wheel-left" => (Mouse::WheelLeft, MouseButton::WheelLeft, true),
401 "wheel-right" => (Mouse::WheelRight, MouseButton::WheelRight, true),
402 other => return Err(format!("unknown mouse action {other:?}")),
403 };
404 Ok(MouseReport { kind, col, row, button, mods: Default::default(), pressed })
405}
406
407#[cfg(unix)]
409static SERVER_STARTED: AtomicBool = AtomicBool::new(false);
410
411#[cfg(unix)]
417pub fn ensure_server() -> std::io::Result<()> {
418 if !enabled() || SERVER_STARTED.swap(true, Ordering::AcqRel) {
419 return Ok(());
420 }
421 server::spawn_thread()
422}
423
424#[cfg(not(unix))]
425pub(crate) fn ensure_server() -> std::io::Result<()> {
426 Ok(())
427}
428
429#[cfg(unix)]
430mod server {
431 use std::{io, path::PathBuf, task::Poll, time::Duration};
432
433 use tokio::net::{UnixListener, UnixStream};
434
435 use super::{
436 DEBUG_ENV, DebugQuery, DebugRequest, RESPONSES, TerminalEvent, direct_response, parse_request,
437 };
438
439 const QUERY_TIMEOUT: Duration = Duration::from_secs(2);
443
444 pub(super) fn spawn_thread() -> io::Result<()> {
450 let path = PathBuf::from(
451 std::env::var_os(DEBUG_ENV).expect("enabled() checked the variable before spawning"),
452 );
453 match std::fs::remove_file(&path) {
454 Ok(()) => {},
455 Err(error) if error.kind() == io::ErrorKind::NotFound => {},
456 Err(error) => return Err(error),
457 }
458 let listener = std::os::unix::net::UnixListener::bind(&path)?;
459 listener.set_nonblocking(true)?;
460 let (responses_tx, responses_rx) = flume::unbounded();
461 *RESPONSES.lock() = Some(responses_tx);
462 std::thread::Builder::new()
463 .name("omp-tui-debug".into())
464 .spawn(move || {
465 let runtime = tokio::runtime::Builder::new_current_thread()
466 .enable_io()
467 .enable_time()
468 .build()
469 .expect("debug server runtime builds");
470 runtime.block_on(async move {
471 let Ok(listener) = UnixListener::from_std(listener) else {
472 return;
473 };
474 let mut server = DebugServer::new(listener);
475 serve_loop(&mut server, responses_rx).await;
476 });
477 })?;
478 Ok(())
479 }
480
481 struct PendingQuery {
483 id: u64,
484 client: u64,
485 expires: tokio::time::Instant,
486 }
487
488 async fn serve_loop(
495 server: &mut DebugServer,
496 responses: flume::Receiver<(u64, serde_json::Value)>,
497 ) {
498 let mut pending: Vec<PendingQuery> = Vec::new();
499 let mut next_id = 1_u64;
500 loop {
501 let expiry = pending.iter().map(|query| query.expires).min();
502 tokio::select! {
503 received = server.recv() => {
504 let (client, request) = received;
505 let request = match request {
506 Err(error) => {
507 server
508 .respond(client, &serde_json::json!({ "ok": false, "error": error }));
509 continue;
510 },
511 Ok(request) => request,
512 };
513 match direct_response(request) {
514 Ok(response) => server.respond(client, &response),
515 Err(op) => {
516 let id = next_id;
517 next_id += 1;
518 if crate::pump::send_event(TerminalEvent::Debug(DebugQuery { id, op })) {
519 pending.push(PendingQuery {
520 id,
521 client,
522 expires: tokio::time::Instant::now() + QUERY_TIMEOUT,
523 });
524 } else {
525 server.respond(client, &serde_json::json!({
526 "ok": false,
527 "error": "no live terminal to query",
528 }));
529 }
530 },
531 }
532 },
533 response = responses.recv_async() => {
534 let Ok((id, response)) = response else {
535 return;
536 };
537 if let Some(index) = pending.iter().position(|query| query.id == id) {
538 let query = pending.swap_remove(index);
539 server.respond(query.client, &response);
540 }
541 },
542 () = expire(expiry) => {
543 let now = tokio::time::Instant::now();
544 let mut index = 0;
545 while index < pending.len() {
546 if pending[index].expires <= now {
547 let query = pending.swap_remove(index);
548 server.respond(query.client, &serde_json::json!({
549 "ok": false,
550 "error": "no retained host answered; use `text` (omp_tui::App answers frame/tree/values)",
551 }));
552 } else {
553 index += 1;
554 }
555 }
556 },
557 }
558 }
559 }
560
561 async fn expire(at: Option<tokio::time::Instant>) {
563 match at {
564 Some(at) => tokio::time::sleep_until(at).await,
565 None => std::future::pending().await,
566 }
567 }
568
569 struct DebugServer {
571 listener: UnixListener,
572 conns: Vec<Conn>,
573 next_conn: u64,
574 }
575
576 struct Conn {
577 id: u64,
580 stream: UnixStream,
581 buf: Vec<u8>,
582 out: Vec<u8>,
583 dead: bool,
584 }
585
586 impl Conn {
587 fn take_line(&mut self) -> Option<Vec<u8>> {
589 let end = self.buf.iter().position(|byte| *byte == b'\n')?;
590 let line = self.buf[..end].to_vec();
591 self.buf.drain(..=end);
592 Some(line)
593 }
594
595 fn fill(&mut self) {
597 let mut bytes = [0_u8; 4096];
598 loop {
599 match self.stream.try_read(&mut bytes) {
600 Ok(0) => {
601 self.dead = true;
602 return;
603 },
604 Ok(read) => self.buf.extend_from_slice(&bytes[..read]),
605 Err(error) if error.kind() == io::ErrorKind::WouldBlock => return,
606 Err(_) => {
607 self.dead = true;
608 return;
609 },
610 }
611 }
612 }
613
614 fn flush(&mut self) {
617 while !self.out.is_empty() {
618 match self.stream.try_write(&self.out) {
619 Ok(0) => {
620 self.dead = true;
621 return;
622 },
623 Ok(written) => {
624 self.out.drain(..written);
625 },
626 Err(error) if error.kind() == io::ErrorKind::WouldBlock => return,
627 Err(_) => {
628 self.dead = true;
629 return;
630 },
631 }
632 }
633 }
634 }
635
636 impl DebugServer {
637 const fn new(listener: UnixListener) -> Self {
638 Self { listener, conns: Vec::new(), next_conn: 1 }
639 }
640
641 async fn recv(&mut self) -> (u64, Result<DebugRequest, String>) {
649 loop {
650 self.conns.retain(|conn| !conn.dead);
651 for conn in &mut self.conns {
652 if let Some(line) = conn.take_line() {
653 return (conn.id, parse_request(&line));
654 }
655 }
656
657 let Self { listener, conns, next_conn } = self;
658 tokio::select! {
659 accepted = listener.accept() => {
660 if let Ok((stream, _)) = accepted {
661 let id = *next_conn;
662 *next_conn += 1;
663 conns.push(Conn {
664 id,
665 stream,
666 buf: Vec::new(),
667 out: Vec::new(),
668 dead: false,
669 });
670 }
671 },
672 index = ready(conns) => {
673 conns[index].fill();
674 conns[index].flush();
675 },
676 }
677 }
678 }
679
680 fn respond(&mut self, client: u64, response: &serde_json::Value) {
685 let Some(conn) = self.conns.iter_mut().find(|conn| conn.id == client) else {
686 return;
687 };
688 serde_json::to_writer(&mut conn.out, response).expect("JSON responses serialize");
689 conn.out.push(b'\n');
690 conn.flush();
691 }
692 }
693
694 async fn ready(conns: &[Conn]) -> usize {
698 std::future::poll_fn(|cx| {
699 for (index, conn) in conns.iter().enumerate() {
700 if conn.stream.poll_read_ready(cx).is_ready()
701 || (!conn.out.is_empty() && conn.stream.poll_write_ready(cx).is_ready())
702 {
703 return Poll::Ready(index);
704 }
705 }
706 Poll::Pending
707 })
708 .await
709 }
710
711 #[cfg(test)]
712 mod tests {
713 use std::time::Duration;
714
715 use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader};
716
717 use super::{DebugServer, serve_loop};
718
719 #[tokio::test]
724 async fn pending_query_replies_follow_stable_client_ids() {
725 let ingress = crate::pump::publish_ingress_for_test();
726 let (responses_tx, responses_rx) = flume::unbounded();
727
728 let path =
729 std::env::temp_dir().join(format!("omp-tui-debug-idtest-{}.sock", std::process::id()));
730 let _ = std::fs::remove_file(&path);
731 let listener = std::os::unix::net::UnixListener::bind(&path).expect("test socket binds");
732 listener
733 .set_nonblocking(true)
734 .expect("nonblocking listener");
735 let listener = tokio::net::UnixListener::from_std(listener).expect("listener registers");
736 let mut server = DebugServer::new(listener);
737 let serve = tokio::spawn(async move { serve_loop(&mut server, responses_rx).await });
738
739 let mut first = tokio::net::UnixStream::connect(&path)
741 .await
742 .expect("first client connects");
743 first
744 .write_all(b"{\"op\":\"tree\"}\n")
745 .await
746 .expect("query sends");
747 tokio::time::timeout(Duration::from_secs(1), ingress.recv_async())
748 .await
749 .expect("query reaches the ingress")
750 .expect("ingress lives");
751 drop(first);
752
753 let second = tokio::net::UnixStream::connect(&path)
756 .await
757 .expect("second client connects");
758 let mut second = BufReader::new(second);
759 second
760 .get_mut()
761 .write_all(b"{\"op\":\"keys\",\"keys\":\"x\"}\n")
762 .await
763 .expect("injection sends");
764 let mut line = String::new();
765 tokio::time::timeout(Duration::from_secs(1), second.read_line(&mut line))
766 .await
767 .expect("injection is acknowledged")
768 .expect("ack line reads");
769 assert!(line.contains("\"injected\""), "unexpected ack: {line:?}");
770
771 responses_tx
773 .send((1, serde_json::json!({ "leak": "wrong-client", "ok": true })))
774 .expect("reply channel lives");
775 line.clear();
776 let stray =
777 tokio::time::timeout(Duration::from_millis(200), second.read_line(&mut line)).await;
778 assert!(
779 stray.is_err() || line.is_empty(),
780 "reply for a disconnected client reached the survivor: {line:?}"
781 );
782
783 serve.abort();
784 let _ = std::fs::remove_file(&path);
785 }
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn key_spec_tokens_chords_and_literals() {
795 let keys = parse_keys("tab C-c M-y 'hi there' x pgdn f5").expect("valid spec");
796 assert_eq!(keys, vec![
797 Key::Tab,
798 Key::Ctrl('c'),
799 Key::Alt('y'),
800 Key::Char('h'),
801 Key::Char('i'),
802 Key::Space,
803 Key::Char('t'),
804 Key::Char('h'),
805 Key::Char('e'),
806 Key::Char('r'),
807 Key::Char('e'),
808 Key::Char('x'),
809 Key::PageDown,
810 Key::Function(5),
811 ]);
812 }
813
814 #[test]
815 fn key_spec_rejects_unknown_and_unterminated() {
816 assert!(parse_keys("bogus-token").is_err());
817 assert!(parse_keys("'open").is_err());
818 }
819
820 #[test]
821 fn request_lines_parse_by_op() {
822 assert!(matches!(parse_request(br#"{"op":"text"}"#), Ok(DebugRequest::Text)));
823 assert!(matches!(
824 parse_request(br#"{"op":"keys","keys":"enter"}"#),
825 Ok(DebugRequest::Inject(events)) if events == vec![InputEvent::Key(Key::Enter)]
826 ));
827 assert!(parse_request(br#"{"op":"warp"}"#).is_err());
828 let mouse = parse_request(br#"{"op":"mouse","x":3,"y":7,"action":"wheel-down"}"#);
829 assert!(matches!(
830 mouse,
831 Ok(DebugRequest::Inject(events))
832 if matches!(&events[..], [InputEvent::Mouse(report)]
833 if report.kind == Mouse::WheelDown && report.col == 3 && report.row == 7)
834 ));
835 }
836}