1use std::io::{BufRead, BufReader, Read};
22use std::process::{Command, Stdio};
23use std::sync::atomic::{AtomicBool, Ordering};
24use std::sync::{Arc, Mutex};
25use std::thread;
26use std::time::{Duration, Instant};
27
28use serde::Deserialize;
29
30use crate::config::{BuiltinWidget, WidgetConfig};
31
32const MAX_OUTPUT: u64 = 256 * 1024;
34const STOP_POLL: Duration = Duration::from_millis(100);
36
37#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
39#[serde(default)]
40pub struct WidgetOutput {
41 pub rows: Vec<WidgetRow>,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
46#[serde(default)]
47pub struct WidgetRow {
48 pub label: String,
50 #[serde(alias = "text")]
52 pub value: String,
53 pub gauge: Option<f32>,
55 pub level: Level,
57 pub heading: bool,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum Level {
65 #[default]
66 Normal,
67 Dim,
68 Ok,
69 Warn,
70 Bad,
71}
72
73pub fn parse_output(stdout: &str) -> WidgetOutput {
75 let trimmed = stdout.trim();
76 if trimmed.starts_with('{')
77 && let Ok(output) = serde_json::from_str::<WidgetOutput>(trimmed)
78 {
79 return output;
80 }
81 WidgetOutput {
82 rows: trimmed
83 .lines()
84 .map(|line| WidgetRow {
85 value: line.trim_end().to_owned(),
86 ..Default::default()
87 })
88 .collect(),
89 }
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct WidgetState {
95 pub output: Option<WidgetOutput>,
97 pub error: Option<String>,
99 pub updated: Option<Instant>,
101}
102
103pub struct Widget {
105 pub name: String,
106 pub source: WidgetSource,
107}
108
109pub enum WidgetSource {
111 Clock,
113 Command {
115 runner: CommandRunner,
116 interval: Duration,
117 },
118 Fixed(WidgetOutput),
120 Invalid(String),
122}
123
124impl Widget {
125 pub fn state(&self) -> Option<WidgetState> {
127 match &self.source {
128 WidgetSource::Command { runner, .. } => Some(runner.state()),
129 WidgetSource::Fixed(output) => Some(WidgetState {
130 output: Some(output.clone()),
131 error: None,
132 updated: Some(Instant::now()),
133 }),
134 WidgetSource::Clock | WidgetSource::Invalid(_) => None,
135 }
136 }
137
138 pub fn is_stale(&self, state: &WidgetState) -> bool {
140 match (&self.source, state.updated) {
141 (WidgetSource::Command { interval, .. }, Some(at)) => {
142 at.elapsed() > *interval * 3 + Duration::from_secs(5)
143 }
144 _ => false,
145 }
146 }
147}
148
149pub fn start_widgets<F>(configs: &[WidgetConfig], notify: F) -> Vec<Widget>
152where
153 F: Fn() + Clone + Send + 'static,
154{
155 configs
156 .iter()
157 .map(|config| {
158 let source = match (&config.builtin, &config.command) {
159 (Some(BuiltinWidget::Clock), None) => WidgetSource::Clock,
160 (None, Some(command)) => {
161 let interval = Duration::from_secs(config.interval_secs.max(1));
162 WidgetSource::Command {
163 runner: CommandRunner::spawn(
164 command.clone(),
165 interval,
166 Duration::from_secs(config.timeout_secs.max(1)),
167 notify.clone(),
168 ),
169 interval,
170 }
171 }
172 (Some(_), Some(_)) => {
173 WidgetSource::Invalid("set either `command` or `builtin`, not both".to_owned())
174 }
175 (None, None) => WidgetSource::Invalid("set `command` or `builtin`".to_owned()),
176 };
177 Widget {
178 name: config.name.clone(),
179 source,
180 }
181 })
182 .collect()
183}
184
185pub struct CommandRunner {
188 state: Arc<Mutex<WidgetState>>,
189 stop: Arc<AtomicBool>,
190}
191
192impl CommandRunner {
193 pub fn spawn<F>(command: String, interval: Duration, timeout: Duration, notify: F) -> Self
194 where
195 F: Fn() + Send + 'static,
196 {
197 let state = Arc::new(Mutex::new(WidgetState::default()));
198 let stop = Arc::new(AtomicBool::new(false));
199 let (shared, stopping) = (Arc::clone(&state), Arc::clone(&stop));
200 let spawned = thread::Builder::new()
201 .name("rigtop-widget".to_owned())
202 .spawn(move || {
203 while !stopping.load(Ordering::Relaxed) {
204 let result = run_command(&command, timeout);
205 {
206 let mut state = shared.lock().unwrap_or_else(|e| e.into_inner());
207 match result {
208 Ok(stdout) => {
209 state.output = Some(parse_output(&stdout));
210 state.error = None;
211 }
212 Err(error) => state.error = Some(error),
213 }
214 state.updated = Some(Instant::now());
215 }
216 notify();
217 let wake = Instant::now() + interval;
218 while Instant::now() < wake && !stopping.load(Ordering::Relaxed) {
219 thread::sleep(STOP_POLL);
220 }
221 }
222 });
223 if let Err(err) = spawned {
224 state.lock().unwrap_or_else(|e| e.into_inner()).error =
225 Some(format!("cannot start a thread: {err}"));
226 }
227 Self { state, stop }
228 }
229
230 pub fn state(&self) -> WidgetState {
231 self.state.lock().unwrap_or_else(|e| e.into_inner()).clone()
232 }
233}
234
235impl Drop for CommandRunner {
236 fn drop(&mut self) {
237 self.stop.store(true, Ordering::Relaxed);
238 }
239}
240
241pub fn run_command(command: &str, timeout: Duration) -> Result<String, String> {
244 let mut process = if cfg!(windows) {
245 let mut process = Command::new("cmd");
246 process.args(["/C", command]);
247 process
248 } else {
249 let mut process = Command::new("sh");
250 process.args(["-c", command]);
251 process
252 };
253 process
254 .stdin(Stdio::null())
255 .stdout(Stdio::piped())
256 .stderr(Stdio::piped());
257 #[cfg(windows)]
258 {
259 use std::os::windows::process::CommandExt;
260 process.creation_flags(0x0800_0000);
262 }
263 let mut child = process
264 .spawn()
265 .map_err(|err| format!("cannot run: {err}"))?;
266 let stdout = child.stdout.take().expect("piped stdout");
267 let stderr = child.stderr.take().expect("piped stderr");
268 let out = thread::spawn(move || {
270 let mut text = String::new();
271 let _ = stdout.take(MAX_OUTPUT).read_to_string(&mut text);
272 text
273 });
274 let err = thread::spawn(move || {
275 BufReader::new(stderr.take(MAX_OUTPUT))
276 .lines()
277 .map_while(Result::ok)
278 .filter(|l| !l.trim().is_empty())
279 .last()
280 });
281 let deadline = Instant::now() + timeout;
282 let status = loop {
283 match child.try_wait() {
284 Ok(Some(status)) => break status,
285 Ok(None) if Instant::now() >= deadline => {
286 let _ = child.kill();
287 let _ = child.wait();
288 return Err(format!("timed out after {}s", timeout.as_secs()));
289 }
290 Ok(None) => thread::sleep(Duration::from_millis(20)),
291 Err(err) => return Err(err.to_string()),
292 }
293 };
294 let stdout = out.join().unwrap_or_default();
295 let last_error = err.join().ok().flatten();
296 if status.success() {
297 Ok(stdout)
298 } else {
299 Err(last_error.unwrap_or_else(|| format!("exited with {status}")))
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn parses_json_and_plain_text() {
309 let json = r#"{"rows":[
310 {"label":"5h","value":"42%","gauge":0.42,"level":"ok"},
311 {"text":"rigtop","heading":true},
312 {}
313 ]}"#;
314 let output = parse_output(json);
315 assert_eq!(output.rows.len(), 3);
316 assert_eq!(output.rows[0].gauge, Some(0.42));
317 assert_eq!(output.rows[0].level, Level::Ok);
318 assert_eq!(output.rows[1].value, "rigtop", "`text` is `value`");
319 assert!(output.rows[1].heading);
320
321 let text = parse_output("USD/JPY 151.2\nEUR/JPY 163.9\n\n");
322 assert_eq!(text.rows.len(), 2);
323 assert_eq!(text.rows[1].value, "EUR/JPY 163.9");
324 assert_eq!(parse_output("{oops").rows[0].value, "{oops");
326 }
327
328 #[cfg(unix)]
329 #[test]
330 fn runs_commands_with_errors_and_timeouts() {
331 let second = Duration::from_secs(1);
332 assert_eq!(run_command("echo hello", second).unwrap().trim(), "hello");
333 assert_eq!(
334 run_command("echo first >&2; echo 'no data' >&2; exit 3", second),
335 Err("no data".to_owned())
336 );
337 assert!(run_command("exit 4", second).unwrap_err().contains("exit"));
338 let started = Instant::now();
339 assert!(
340 run_command("sleep 5", second)
341 .unwrap_err()
342 .contains("timed out")
343 );
344 assert!(started.elapsed() < Duration::from_secs(3));
345 }
346
347 #[cfg(unix)]
348 #[test]
349 fn widgets_from_configuration() {
350 let config =
351 |name: &str, command: Option<&str>, builtin: Option<BuiltinWidget>| WidgetConfig {
352 name: name.to_owned(),
353 command: command.map(str::to_owned),
354 builtin,
355 ..Default::default()
356 };
357 let (tx, rx) = std::sync::mpsc::channel();
358 let notify = move || {
359 let _ = tx.send(());
360 };
361 let widgets = start_widgets(
362 &[
363 config("Clock", None, Some(BuiltinWidget::Clock)),
364 config("Echo", Some(r#"echo '{"rows":[{"value":"hi"}]}'"#), None),
365 config("Empty", None, None),
366 ],
367 notify,
368 );
369 assert!(matches!(widgets[0].source, WidgetSource::Clock));
370 assert!(matches!(widgets[2].source, WidgetSource::Invalid(_)));
371 rx.recv_timeout(Duration::from_secs(5)).expect("first run");
372 let state = widgets[1].state().unwrap();
373 assert_eq!(state.output.unwrap().rows[0].value, "hi");
374 assert!(state.error.is_none());
375 }
376}