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