|
| 1 | +//! Wire types and discovery paths shared by the producer (`tui::publish`) and |
| 2 | +//! the dashboard/aggregator ([`crate::dashboard`]). |
| 3 | +//! |
| 4 | +//! A producer streams [`ProducerSnapshot`]s over a unix socket; the aggregator |
| 5 | +//! folds them into one document keyed by `producer`. Both halves agree on these |
| 6 | +//! shapes and on where the sockets live ([`socket_dir`]), so neither side |
| 7 | +//! reaches into the other. |
| 8 | +
|
| 9 | +use std::path::PathBuf; |
| 10 | + |
| 11 | +use serde::{Deserialize, Serialize}; |
| 12 | + |
| 13 | +/// One terminal's rendered state at a single poll tick. |
| 14 | +/// |
| 15 | +/// The unit a producer streams and the dashboard renders. `id` is unique within |
| 16 | +/// its producer; the aggregator namespaces it by producer for a global key. |
| 17 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 18 | +pub struct TerminalFrame { |
| 19 | + /// Stable per-producer terminal id (the manager's UUID). |
| 20 | + pub id: String, |
| 21 | + /// The command that was spawned. |
| 22 | + pub command: String, |
| 23 | + /// Positional arguments, space-joined for display. |
| 24 | + pub args: String, |
| 25 | + /// Terminal height in rows. |
| 26 | + pub rows: u16, |
| 27 | + /// Terminal width in columns. |
| 28 | + pub cols: u16, |
| 29 | + /// Whether the child is still running. |
| 30 | + pub alive: bool, |
| 31 | + /// The visible screen, rows newline-joined, with minimal ANSI SGR runs |
| 32 | + /// encoding per-cell color and attributes. The dashboard parses the SGR |
| 33 | + /// back into styled spans; a plain reader still sees the text. |
| 34 | + pub screen: String, |
| 35 | + // These fields were added after the first wire shape. `#[serde(default)]` |
| 36 | + // keeps a mixed-version dashboard working: a producer built before this |
| 37 | + // change streams frames without them, and the aggregator drops a frame |
| 38 | + // whose JSON fails to parse, so without defaults those older producers' |
| 39 | + // terminals would silently vanish from the dashboard. |
| 40 | + /// Cursor row in viewport cell coordinates (0-based, top first). |
| 41 | + #[serde(default)] |
| 42 | + pub cursor_row: u16, |
| 43 | + /// Cursor column in viewport cell coordinates (0-based, left first). |
| 44 | + #[serde(default)] |
| 45 | + pub cursor_col: u16, |
| 46 | + /// Whether the screen is showing its cursor (the inverse of `CSI ?25l`). |
| 47 | + #[serde(default)] |
| 48 | + pub cursor_visible: bool, |
| 49 | + /// The cursor shape token: `"block"`, `"underline"`, or `"bar"`. |
| 50 | + #[serde(default)] |
| 51 | + pub cursor_shape: String, |
| 52 | + /// The child's exit code when it has exited with one, else `None` (still |
| 53 | + /// running, or terminated by a signal). |
| 54 | + #[serde(default)] |
| 55 | + pub exit_code: Option<i32>, |
| 56 | +} |
| 57 | + |
| 58 | +/// One producer process's terminals, as sent over its discovery socket. |
| 59 | +/// |
| 60 | +/// `producer` namespaces every terminal in `terminals` so many processes can |
| 61 | +/// share one aggregated document without key collisions. Each message carries |
| 62 | +/// the producer's full terminal set, so the latest message fully describes that |
| 63 | +/// producer and a late-joining reader needs no backlog. |
| 64 | +#[derive(Debug, Clone, Serialize, Deserialize)] |
| 65 | +pub struct ProducerSnapshot { |
| 66 | + /// Stable per-process id: `"<pid>-<short-uuid>"`. |
| 67 | + pub producer: String, |
| 68 | + /// Every terminal this producer currently tracks. |
| 69 | + pub terminals: Vec<TerminalFrame>, |
| 70 | +} |
| 71 | + |
| 72 | +/// The directory where producers expose their per-process sockets and the |
| 73 | +/// aggregator looks for them. |
| 74 | +/// |
| 75 | +/// Resolved in order: `$IX_TUI_DIR`, then `$XDG_RUNTIME_DIR/ix-tui`, then |
| 76 | +/// `/tmp/ix-tui-<user>`. Kept deliberately short: macOS caps a unix socket |
| 77 | +/// path (`sun_path`) at 104 bytes, and `$TMPDIR` on macOS is long enough to |
| 78 | +/// blow that budget once a filename is appended, so it is not used. |
| 79 | +#[must_use] |
| 80 | +pub fn socket_dir() -> PathBuf { |
| 81 | + if let Some(dir) = std::env::var_os("IX_TUI_DIR") { |
| 82 | + return PathBuf::from(dir); |
| 83 | + } |
| 84 | + if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") { |
| 85 | + return PathBuf::from(runtime).join("ix-tui"); |
| 86 | + } |
| 87 | + let user = std::env::var("USER").unwrap_or_else(|_| "shared".to_owned()); |
| 88 | + PathBuf::from(format!("/tmp/ix-tui-{user}")) |
| 89 | +} |
| 90 | + |
| 91 | +/// A unique socket path for the current process inside [`socket_dir`]. |
| 92 | +/// |
| 93 | +/// The filename is `"<pid>-<short-uuid>.sock"`: the pid is human-legible for |
| 94 | +/// debugging and the uuid suffix keeps it unique across pid reuse. |
| 95 | +#[must_use] |
| 96 | +pub fn socket_path() -> PathBuf { |
| 97 | + let short = uuid::Uuid::new_v4().simple().to_string(); |
| 98 | + socket_dir().join(format!( |
| 99 | + "{}-{}.sock", |
| 100 | + std::process::id(), |
| 101 | + &short[..8] |
| 102 | + )) |
| 103 | +} |
| 104 | + |
| 105 | +#[cfg(test)] |
| 106 | +mod tests { |
| 107 | + use super::TerminalFrame; |
| 108 | + |
| 109 | + /// A frame streamed by a producer built before the cursor/exit fields were |
| 110 | + /// added still deserializes: the new fields fall back to their defaults |
| 111 | + /// instead of failing the whole `ProducerSnapshot` parse and dropping the |
| 112 | + /// terminal from the dashboard. |
| 113 | + #[test] |
| 114 | + fn old_wire_shape_deserializes_with_field_defaults() { |
| 115 | + let old = r#"{ |
| 116 | + "id": "t1", "command": "vim", "args": "-u NONE", |
| 117 | + "rows": 24, "cols": 80, "alive": true, "screen": "hi" |
| 118 | + }"#; |
| 119 | + let frame: TerminalFrame = serde_json::from_str(old).expect("old shape parses"); |
| 120 | + assert_eq!(frame.screen, "hi"); |
| 121 | + assert_eq!(frame.cursor_row, 0); |
| 122 | + assert_eq!(frame.cursor_col, 0); |
| 123 | + assert!(!frame.cursor_visible); |
| 124 | + assert_eq!(frame.cursor_shape, ""); |
| 125 | + assert_eq!(frame.exit_code, None); |
| 126 | + } |
| 127 | +} |
0 commit comments