1
0
Fork 0
mirror of https://github.com/Zedfrigg/ironbar.git synced 2025-07-01 10:41:03 +02:00

feat: common module options (show_if, on_click, tooltip)

The first three of many options that are common to all modules.

Resolves #36. Resolves partially #34.
This commit is contained in:
Jake Stanger 2022-11-28 21:55:08 +00:00
parent a3f90adaf1
commit c9e66d4664
No known key found for this signature in database
GPG key ID: C51FC8F9CB0BEA61
15 changed files with 600 additions and 125 deletions

View file

@ -1,6 +1,12 @@
By default, you get a single bar at the bottom of all your screens. By default, you get a single bar at the bottom of all your screens.
To change that, you'll unsurprisingly need a config file. To change that, you'll unsurprisingly need a config file.
This page details putting together the skeleton for your config to get you to a stage where you can start configuring modules.
It may look long and overwhelming, but that is just because the bar supports a lot of scenarios!
If you want to see some ready-to-go config files check the [examples folder](https://github.com/JakeStanger/ironbar/tree/master/examples)
and the example pages in the sidebar.
## 1. Create config file ## 1. Create config file
The config file lives inside the `ironbar` directory in your XDG_CONFIG_DIR, which is usually `~/.config/ironbar`. The config file lives inside the `ironbar` directory in your XDG_CONFIG_DIR, which is usually `~/.config/ironbar`.
@ -253,8 +259,11 @@ monitors:
Once you have the basic config structure set up, it's time to actually configure your bar(s). Once you have the basic config structure set up, it's time to actually configure your bar(s).
The following table describes each of the top-level bar config options. Check [here](config) for an example config file for a fully configured bar in each format.
For details on available modules and each of their config options, check the sidebar.
### 3.1 Top-level options
The following table lists each of the top-level bar config options:
| Name | Type | Default | Description | | Name | Type | Default | Description |
|-------------------|----------------------------------------|----------|-----------------------------------------------------------------------------------------| |-------------------|----------------------------------------|----------|-----------------------------------------------------------------------------------------|
@ -265,4 +274,15 @@ For details on available modules and each of their config options, check the sid
| `center` | `Module[]` | `[]` | Array of center modules. | | `center` | `Module[]` | `[]` | Array of center modules. |
| `end` | `Module[]` | `[]` | Array of right or bottom modules. | | `end` | `Module[]` | `[]` | Array of right or bottom modules. |
Check [here](config) for an example config file for a fully configured bar in each format. ### 3.2 Module-level options
The following table lists each of the module-level options that are present on **all** modules.
For details on available modules and each of their config options, check the sidebar.
For information on the `Script` type, see [here](script).
| Name | Type | Default | Description |
|------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------|
| `show_if` | `Script [polling]` | `null` | Polls the script to check its exit code. If exit code is zero, the module is shown. For other codes, it is hidden. |
| `on_click` | `Script [polling]` | `null` | Runs the script when the module is clicked. |
| `tooltip` | `string` | `null` | Shows this text on hover. |

91
docs/Scripts.md Normal file
View file

@ -0,0 +1,91 @@
There are various places inside the configuration (other than the `script` module)
that allow script input to dynamically set values.
Scripts are passed to `sh -c`.
Two types of scripts exist: polling and watching:
- Polling scripts will run and wait for exit.
Normally they will repeat this at an interval, hence the name, although in some cases they may only run on a user
event.
If the script exited code 0, the `stdout` will be used. Otherwise, `stderr` will be printed to the log.
- Watching scripts start a long-running process. Every time the process writes to `stdout`, the last line is captured
and used.
One should prefer to use watch-mode where possible, as it removes the overhead of regularly spawning processes.
That said, there are some cases which only support polling. These are indicated by `Script [polling]` as the option
type.
## Writing script configs
There are two available config formats for scripts, shorthand as a string, or longhand as an object.
Shorthand can be used in all cases, but there are some cases (such as embedding scripts inside strings) where longhand
cannot be used.
In both formats, `mode` is one of `poll` or `watch` and `interval` is the number of milliseconds to wait between
spawning the script.
Both `mode` and `interval` are optional and can be excluded to fall back to their defaults of `poll` and `5000`
respectively.
### Shorthand (string)
Shorthand scripts should be written in the format:
```
mode:interval:script
```
For example:
```
poll:5000:uptime -p | cut -d ' ' -f2-
```
### Longhand (object)
An object consisting of the `cmd` key and optionally the `mode` and/or `interval` keys.
<details>
<summary>JSON</summary>
```json
{
"mode": "poll",
"interval": 5000,
"cmd": "uptime -p | cut -d ' ' -f2-"
}
```
</details>
<details>
<summary>YAML</summary>
```yaml
mode: poll
interval: 5000
cmd: "uptime -p | cut -d ' ' -f2-"
```
</details>
<details>
<summary>YAML</summary>
```toml
mode = "poll"
interval = 5000
cmd = "uptime -p | cut -d ' ' -f2-"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
mode = "poll"
interval = 5000
cmd = "uptime -p | cut -d ' ' -f2-"
}
```
</details>

View file

@ -1,6 +1,7 @@
# Guides # Guides
- [Configuration guide](configuration-guide) - [Configuration guide](configuration-guide)
- [Scripts](scripts)
- [Styling guide](styling-guide) - [Styling guide](styling-guide)
# Examples # Examples

View file

@ -6,7 +6,8 @@ use crate::modules::mpd::{PlayerCommand, SongUpdate};
use crate::modules::workspaces::WorkspaceUpdate; use crate::modules::workspaces::WorkspaceUpdate;
use crate::modules::{Module, ModuleInfoBuilder, ModuleLocation, ModuleUpdateEvent, WidgetContext}; use crate::modules::{Module, ModuleInfoBuilder, ModuleLocation, ModuleUpdateEvent, WidgetContext};
use crate::popup::Popup; use crate::popup::Popup;
use crate::Config; use crate::script::{OutputStream, Script};
use crate::{await_sync, Config};
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use color_eyre::Result; use color_eyre::Result;
use gtk::gdk::Monitor; use gtk::gdk::Monitor;
@ -16,8 +17,9 @@ use std::collections::HashMap;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
use stray::message::NotifierItemCommand; use stray::message::NotifierItemCommand;
use stray::NotifierItemMessage; use stray::NotifierItemMessage;
use tokio::spawn;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tracing::{debug, info}; use tracing::{debug, error, info, trace};
/// Creates a new window for a bar, /// Creates a new window for a bar,
/// sets it up and adds its widgets. /// sets it up and adds its widgets.
@ -81,7 +83,11 @@ pub fn create_bar(
}); });
debug!("Showing bar"); debug!("Showing bar");
win.show_all(); start.show();
center.show();
end.show();
content.show();
win.show();
Ok(()) Ok(())
} }
@ -155,11 +161,60 @@ fn add_modules(
controller_tx: ui_tx, controller_tx: ui_tx,
}; };
let common = $module.common.clone();
let widget = $module.into_widget(context, &info)?; let widget = $module.into_widget(context, &info)?;
content.add(&widget.widget); let container = gtk::EventBox::new();
container.add(&widget.widget);
content.add(&container);
widget.widget.set_widget_name(info.module_name); widget.widget.set_widget_name(info.module_name);
if let Some(show_if) = common.show_if {
let script = Script::new_polling(show_if);
let container = container.clone();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
script
.run(|(_, success)| {
tx.send(success)
.expect("Failed to send widget visibility toggle message");
})
.await;
});
rx.attach(None, move |success| {
if success {
container.show_all()
} else {
container.hide()
};
Continue(true)
});
} else {
container.show_all();
}
if let Some(on_click) = common.on_click {
let script = Script::new_polling(on_click);
container.connect_button_press_event(move |_, _| {
trace!("Running on-click script");
match await_sync(async { script.get_output().await }) {
Ok((OutputStream::Stderr(out), _)) => error!("{out}"),
Err(err) => error!("{err:?}"),
_ => {}
}
Inhibit(false)
});
}
if let Some(tooltip) = common.tooltip {
container.set_tooltip_text(Some(&tooltip));
}
let has_popup = widget.popup.is_some(); let has_popup = widget.popup.is_some();
if let Some(popup_content) = widget.popup { if let Some(popup_content) = widget.popup {
popup popup

View file

@ -7,6 +7,7 @@ use crate::modules::script::ScriptModule;
use crate::modules::sysinfo::SysInfoModule; use crate::modules::sysinfo::SysInfoModule;
use crate::modules::tray::TrayModule; use crate::modules::tray::TrayModule;
use crate::modules::workspaces::WorkspacesModule; use crate::modules::workspaces::WorkspacesModule;
use crate::script::ScriptInput;
use color_eyre::eyre::{Context, ContextCompat}; use color_eyre::eyre::{Context, ContextCompat};
use color_eyre::{eyre, Help, Report}; use color_eyre::{eyre, Help, Report};
use dirs::config_dir; use dirs::config_dir;
@ -18,6 +19,13 @@ use std::path::{Path, PathBuf};
use std::{env, fs}; use std::{env, fs};
use tracing::instrument; use tracing::instrument;
#[derive(Debug, Deserialize, Clone)]
pub struct CommonConfig {
pub show_if: Option<ScriptInput>,
pub on_click: Option<ScriptInput>,
pub tooltip: Option<String>,
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "kebab-case")] #[serde(tag = "type", rename_all = "kebab-case")]
pub enum ModuleConfig { pub enum ModuleConfig {

View file

@ -1,3 +1,4 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup; use crate::popup::Popup;
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
@ -18,7 +19,10 @@ pub struct ClockModule {
/// Detail on available tokens can be found here: /// Detail on available tokens can be found here:
/// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html> /// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html>
#[serde(default = "default_format")] #[serde(default = "default_format")]
pub(crate) format: String, format: String,
#[serde(flatten)]
pub common: CommonConfig,
} }
fn default_format() -> String { fn default_format() -> String {

View file

@ -1,7 +1,8 @@
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::{ButtonGeometry, Popup}; use crate::popup::{ButtonGeometry, Popup};
use crate::script::exec_command; use crate::config::CommonConfig;
use color_eyre::{Report, Result}; use color_eyre::{Report, Result};
use crate::script::Script;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{Button, Label, Orientation}; use gtk::{Button, Label, Orientation};
use serde::Deserialize; use serde::Deserialize;
@ -17,6 +18,9 @@ pub struct CustomModule {
bar: Vec<Widget>, bar: Vec<Widget>,
/// Widgets to add to the popup container /// Widgets to add to the popup container
popup: Option<Vec<Widget>>, popup: Option<Vec<Widget>>,
#[serde(flatten)]
pub common: CommonConfig,
} }
/// Attempts to parse an `Orientation` from `String` /// Attempts to parse an `Orientation` from `String`
@ -164,8 +168,11 @@ impl Module<gtk::Box> for CustomModule {
spawn(async move { spawn(async move {
while let Some(event) = rx.recv().await { while let Some(event) = rx.recv().await {
if event.cmd.starts_with('!') { if event.cmd.starts_with('!') {
debug!("executing command: '{}'", &event.cmd[1..]); let script = Script::from(&event.cmd[1..]);
if let Err(err) = exec_command(&event.cmd[1..]) {
debug!("executing command: '{}'", script.cmd);
// TODO: Migrate to use script.run
if let Err(err) = script.get_output().await {
error!("{err:?}"); error!("{err:?}");
} }
} else if event.cmd == "popup:toggle" { } else if event.cmd == "popup:toggle" {

View file

@ -1,4 +1,5 @@
use crate::clients::wayland::{self, ToplevelChange}; use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{await_sync, icon}; use crate::{await_sync, icon};
use color_eyre::Result; use color_eyre::Result;
@ -23,6 +24,9 @@ pub struct FocusedModule {
icon_size: i32, icon_size: i32,
/// GTK icon theme to use. /// GTK icon theme to use.
icon_theme: Option<String>, icon_theme: Option<String>,
#[serde(flatten)]
pub common: CommonConfig,
} }
const fn default_icon_size() -> i32 { const fn default_icon_size() -> i32 {

View file

@ -4,6 +4,7 @@ mod open_state;
use self::item::{Item, ItemButton, Window}; use self::item::{Item, ItemButton, Window};
use self::open_state::OpenState; use self::open_state::OpenState;
use crate::clients::wayland::{self, ToplevelChange}; use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::icon::find_desktop_file; use crate::icon::find_desktop_file;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::{Help, Report}; use color_eyre::{Help, Report};
@ -33,6 +34,9 @@ pub struct LauncherModule {
/// Name of the GTK icon theme to use. /// Name of the GTK icon theme to use.
icon_theme: Option<String>, icon_theme: Option<String>,
#[serde(flatten)]
pub common: CommonConfig,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

View file

@ -1,4 +1,5 @@
use crate::clients::mpd::{get_client, get_duration, get_elapsed, MpdConnectionError}; use crate::clients::mpd::{get_client, get_duration, get_elapsed, MpdConnectionError};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup; use crate::popup::Popup;
use color_eyre::Result; use color_eyre::Result;
@ -65,6 +66,9 @@ pub struct MpdModule {
/// Path to root of music directory. /// Path to root of music directory.
#[serde(default = "default_music_dir")] #[serde(default = "default_music_dir")]
music_dir: PathBuf, music_dir: PathBuf,
#[serde(flatten)]
pub common: CommonConfig,
} }
fn default_socket() -> String { fn default_socket() -> String {

View file

@ -1,39 +1,32 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::script::exec_command; use crate::script::{OutputStream, Script, ScriptMode};
use color_eyre::{Help, Report, Result}; use color_eyre::{Help, Report, Result};
use gtk::prelude::*; use gtk::prelude::*;
use gtk::Label; use gtk::Label;
use serde::Deserialize; use serde::Deserialize;
use std::process::Stdio; use tokio::spawn;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::mpsc::{Receiver, Sender};
use tokio::time::sleep;
use tokio::{select, spawn};
use tracing::error; use tracing::error;
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
enum Mode {
Poll,
Watch,
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct ScriptModule { pub struct ScriptModule {
/// Path to script to execute. /// Path to script to execute.
path: String, path: String,
/// Script execution mode /// Script execution mode
#[serde(default = "default_mode")] #[serde(default = "default_mode")]
mode: Mode, mode: ScriptMode,
/// Time in milliseconds between executions. /// Time in milliseconds between executions.
#[serde(default = "default_interval")] #[serde(default = "default_interval")]
interval: u64, interval: u64,
#[serde(flatten)]
pub common: CommonConfig,
} }
/// `Mode::Poll` /// `Mode::Poll`
const fn default_mode() -> Mode { const fn default_mode() -> ScriptMode {
Mode::Poll ScriptMode::Poll
} }
/// 5000ms /// 5000ms
@ -41,6 +34,16 @@ const fn default_interval() -> u64 {
5000 5000
} }
impl From<&ScriptModule> for Script {
fn from(module: &ScriptModule) -> Self {
Self {
mode: module.mode,
cmd: module.path.clone(),
interval: module.interval,
}
}
}
impl Module<Label> for ScriptModule { impl Module<Label> for ScriptModule {
type SendMessage = String; type SendMessage = String;
type ReceiveMessage = (); type ReceiveMessage = ();
@ -51,78 +54,22 @@ impl Module<Label> for ScriptModule {
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>, tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
_rx: Receiver<Self::ReceiveMessage>, _rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> { ) -> Result<()> {
let interval = self.interval; let script: Script = self.into();
let path = self.path.clone();
match self.mode { spawn(async move {
Mode::Poll => spawn(async move { script.run(move |(out, _)| match out {
loop { OutputStream::Stdout(stdout) => {
match exec_command(&path) { tx.try_send(ModuleUpdateEvent::Update(stdout))
Ok(stdout) => tx .expect("Failed to send stdout"); }
.send(ModuleUpdateEvent::Update(stdout)) OutputStream::Stderr(stderr) => {
.await error!("{:?}", Report::msg(stderr)
.expect("Failed to send stdout"),
Err(err) => error!("{:?}", err),
}
sleep(tokio::time::Duration::from_millis(interval)).await;
}
}),
Mode::Watch => spawn(async move {
loop {
let mut handle = Command::new("sh")
.args(["-c", &path])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.spawn()
.expect("Failed to spawn process");
let mut stdout_lines = BufReader::new(
handle
.stdout
.take()
.expect("Failed to take script handle stdout"),
)
.lines();
let mut stderr_lines = BufReader::new(
handle
.stderr
.take()
.expect("Failed to take script handle stderr"),
)
.lines();
loop {
select! {
_ = handle.wait() => break,
Ok(Some(line)) = stdout_lines.next_line() => {
tx.send(ModuleUpdateEvent::Update(line.to_string()))
.await
.expect("Failed to send stdout");
}
Ok(Some(line)) = stderr_lines.next_line() => {
error!("{:?}", Report::msg(line)
.wrap_err("Watched script error:") .wrap_err("Watched script error:")
.suggestion("Check the path to your script") .suggestion("Check the path to your script")
.suggestion("Check the script for errors") .suggestion("Check the script for errors")
.suggestion("If you expect the script to write to stderr, consider redirecting its output to /dev/null to suppress these messages") .suggestion("If you expect the script to write to stderr, consider redirecting its output to /dev/null to suppress these messages"));
) }
} }).await;
} });
}
while let Ok(Some(line)) = stdout_lines.next_line().await {
tx.send(ModuleUpdateEvent::Update(line.to_string()))
.await
.expect("Failed to send stdout");
}
sleep(tokio::time::Duration::from_millis(interval)).await;
}
}),
};
Ok(()) Ok(())
} }

View file

@ -1,3 +1,4 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::Result; use color_eyre::Result;
use gtk::prelude::*; use gtk::prelude::*;
@ -19,6 +20,9 @@ pub struct SysInfoModule {
/// Number of seconds between refresh /// Number of seconds between refresh
#[serde(default = "Interval::default")] #[serde(default = "Interval::default")]
interval: Interval, interval: Interval,
#[serde(flatten)]
pub common: CommonConfig,
} }
#[derive(Debug, Deserialize, Copy, Clone)] #[derive(Debug, Deserialize, Copy, Clone)]

View file

@ -1,5 +1,6 @@
use crate::await_sync; use crate::await_sync;
use crate::clients::system_tray::get_tray_event_client; use crate::clients::system_tray::get_tray_event_client;
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::Result; use color_eyre::Result;
use gtk::prelude::*; use gtk::prelude::*;
@ -14,7 +15,10 @@ use tokio::sync::mpsc;
use tokio::sync::mpsc::{Receiver, Sender}; use tokio::sync::mpsc::{Receiver, Sender};
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct TrayModule; pub struct TrayModule {
#[serde(flatten)]
pub common: CommonConfig,
}
/// Gets a GTK `Image` component /// Gets a GTK `Image` component
/// for the status notifier item's icon. /// for the status notifier item's icon.

View file

@ -1,5 +1,6 @@
use crate::await_sync; use crate::await_sync;
use crate::clients::sway::{get_client, get_sub_client}; use crate::clients::sway::{get_client, get_sub_client};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::{Report, Result}; use color_eyre::{Report, Result};
use gtk::prelude::*; use gtk::prelude::*;
@ -19,6 +20,9 @@ pub struct WorkspacesModule {
/// Whether to display buttons for all monitors. /// Whether to display buttons for all monitors.
#[serde(default = "crate::config::default_false")] #[serde(default = "crate::config::default_false")]
all_monitors: bool, all_monitors: bool,
#[serde(flatten)]
pub common: CommonConfig,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View file

@ -1,36 +1,354 @@
use color_eyre::eyre::WrapErr; use color_eyre::eyre::WrapErr;
use color_eyre::{Help, Report, Result}; use color_eyre::{Report, Result};
use std::process::Command; use serde::Deserialize;
use tracing::instrument; use std::fmt::{Display, Formatter};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::sleep;
use tokio::{select, spawn};
use tracing::{error, warn};
/// Attempts to execute a given command, #[derive(Debug, Deserialize, Clone)]
/// waiting for it to finish. #[serde(untagged)]
/// If the command returns status 0, pub enum ScriptInput {
/// the `stdout` is returned. String(String),
/// Otherwise, an `Err` variant Struct(Script),
/// containing the `stderr` is returned. }
#[instrument]
pub fn exec_command(command: &str) -> Result<String> {
let output = Command::new("sh")
.arg("-c")
.arg(command)
.output()
.wrap_err("Failed to get script output")?;
if output.status.success() { #[derive(Debug, Deserialize, Clone, Copy, Eq, PartialEq)]
let stdout = String::from_utf8(output.stdout) #[serde(rename_all = "snake_case")]
.map(|output| output.trim().to_string()) pub enum ScriptMode {
.wrap_err("Script stdout not valid UTF-8")?; Poll,
Watch,
}
Ok(stdout) #[derive(Debug, Clone)]
} else { pub enum OutputStream {
let stderr = String::from_utf8(output.stderr) Stdout(String),
.map(|output| output.trim().to_string()) Stderr(String),
.wrap_err("Script stderr not valid UTF-8")?; }
Err(Report::msg(stderr) impl From<&str> for ScriptMode {
.wrap_err("Script returned non-zero error code") fn from(str: &str) -> Self {
.suggestion("Check the path to your script") match str {
.suggestion("Check the script for errors")) "poll" | "p" => Self::Poll,
"watch" | "w" => Self::Watch,
_ => {
warn!("Invalid script mode: '{str}', falling back to polling");
ScriptMode::Poll
}
}
}
}
impl Default for ScriptMode {
fn default() -> Self {
Self::Poll
}
}
impl Display for ScriptMode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
ScriptMode::Poll => "poll",
ScriptMode::Watch => "watch",
}
)
}
}
impl ScriptMode {
fn try_parse(str: &str) -> Result<Self> {
match str {
"poll" | "p" => Ok(Self::Poll),
"watch" | "w" => Ok(Self::Watch),
_ => Err(Report::msg(format!("Invalid script mode: {str}"))),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Script {
#[serde(default = "ScriptMode::default")]
pub(crate) mode: ScriptMode,
pub cmd: String,
#[serde(default = "default_interval")]
pub(crate) interval: u64,
}
const fn default_interval() -> u64 {
5000
}
impl Default for Script {
fn default() -> Self {
Self {
mode: ScriptMode::default(),
interval: default_interval(),
cmd: String::new(),
}
}
}
impl From<ScriptInput> for Script {
fn from(input: ScriptInput) -> Self {
match input {
ScriptInput::String(string) => Self::from(string.as_str()),
ScriptInput::Struct(script) => script,
}
}
}
#[derive(Debug)]
enum ScriptInputToken {
Mode(ScriptMode),
Interval(u64),
Cmd(String),
Colon,
}
impl From<&str> for Script {
fn from(str: &str) -> Self {
let mut script = Self::default();
let mut tokens = vec![];
let mut chars = str.chars().collect::<Vec<_>>();
while !chars.is_empty() {
let char = chars[0];
let (token, skip) = match char {
':' => (ScriptInputToken::Colon, 1),
// interval
'0'..='9' => {
let interval_str = chars
.iter()
.take_while(|c| c.is_ascii_digit())
.collect::<String>();
(
ScriptInputToken::Interval(
interval_str.parse::<u64>().expect("Invalid interval"),
),
interval_str.len(),
)
}
// watching or polling
'w' | 'p' => {
let mode_str = chars.iter().take_while(|&c| c != &':').collect::<String>();
let len = mode_str.len();
let token = ScriptMode::try_parse(&mode_str)
.map_or(ScriptInputToken::Cmd(mode_str), |mode| {
ScriptInputToken::Mode(mode)
});
(token, len)
}
_ => {
let cmd_str = chars.iter().take_while(|_| true).collect::<String>();
let len = cmd_str.len();
(ScriptInputToken::Cmd(cmd_str), len)
}
};
tokens.push(token);
chars.drain(..skip);
}
for token in tokens {
match token {
ScriptInputToken::Mode(mode) => script.mode = mode,
ScriptInputToken::Interval(interval) => script.interval = interval,
ScriptInputToken::Cmd(cmd) => script.cmd = cmd,
ScriptInputToken::Colon => {}
}
}
script
}
}
impl Script {
pub fn new_polling(input: ScriptInput) -> Self {
let mut script = Self::from(input);
script.mode = ScriptMode::Poll;
script
}
pub async fn run<F>(&self, callback: F)
where
F: Fn((OutputStream, bool)),
{
loop {
match self.mode {
ScriptMode::Poll => match self.get_output().await {
Ok(output) => callback(output),
Err(err) => error!("{err:?}"),
},
ScriptMode::Watch => match self.spawn().await {
Ok(mut rx) => {
while let Some(msg) = rx.recv().await {
callback((msg, true));
}
}
Err(err) => error!("{err:?}"),
},
};
sleep(tokio::time::Duration::from_millis(self.interval)).await;
}
}
/// Attempts to execute a given command,
/// waiting for it to finish.
/// If the command returns status 0,
/// the `stdout` is returned.
/// Otherwise, an `Err` variant
/// containing the `stderr` is returned.
pub async fn get_output(&self) -> Result<(OutputStream, bool)> {
let output = Command::new("sh")
.args(["-c", &self.cmd])
.output()
.await
.wrap_err("Failed to get script output")?;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)
.map(|output| output.trim().to_string())
.wrap_err("Script stdout not valid UTF-8")?;
Ok((OutputStream::Stdout(stdout), true))
} else {
let stderr = String::from_utf8(output.stderr)
.map(|output| output.trim().to_string())
.wrap_err("Script stderr not valid UTF-8")?;
Ok((OutputStream::Stderr(stderr), false))
}
}
pub async fn spawn(&self) -> Result<mpsc::Receiver<OutputStream>> {
let mut handle = Command::new("sh")
.args(["-c", &self.cmd])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.stdin(Stdio::null())
.spawn()?;
let mut stdout_lines = BufReader::new(
handle
.stdout
.take()
.expect("Failed to take script handle stdout"),
)
.lines();
let mut stderr_lines = BufReader::new(
handle
.stderr
.take()
.expect("Failed to take script handle stderr"),
)
.lines();
let (tx, rx) = mpsc::channel(32);
spawn(async move {
loop {
select! {
_ = handle.wait() => break,
Ok(Some(line)) = stdout_lines.next_line() => {
tx.send(OutputStream::Stdout(line)).await.expect("Failed to send stdout");
}
Ok(Some(line)) = stderr_lines.next_line() => {
tx.send(OutputStream::Stderr(line)).await.expect("Failed to send stderr");
}
}
}
});
Ok(rx)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic() {
let cmd = "echo 'hello'";
let script = Script::from(cmd);
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, default_interval());
assert_eq!(script.mode, ScriptMode::default());
}
#[test]
fn test_parse_full() {
let cmd = "echo 'hello'";
let mode = ScriptMode::Watch;
let interval = 300;
let full_cmd = format!("{mode}:{interval}:{cmd}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, cmd);
assert_eq!(script.mode, mode);
assert_eq!(script.interval, interval);
}
#[test]
fn test_parse_interval_and_cmd() {
let cmd = "echo 'hello'";
let interval = 300;
let full_cmd = format!("{interval}:{cmd}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, interval);
assert_eq!(script.mode, ScriptMode::default());
}
#[test]
fn test_parse_mode_and_cmd() {
let cmd = "echo 'hello'";
let mode = ScriptMode::Watch;
let full_cmd = format!("{mode}:{cmd}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, default_interval());
assert_eq!(script.mode, mode);
}
#[test]
fn test_parse_cmd_with_colon() {
let cmd = "uptime | awk '{print \"Uptime: \" $1}'";
let script = Script::from(cmd);
assert_eq!(script.cmd, cmd);
assert_eq!(script.interval, default_interval());
assert_eq!(script.mode, ScriptMode::default());
}
#[test]
fn test_no_cmd() {
let mode = ScriptMode::Watch;
let interval = 300;
let full_cmd = format!("{mode}:{interval}");
let script = Script::from(full_cmd.as_str());
assert_eq!(script.cmd, ""); // TODO: Probably better handle this case
assert_eq!(script.interval, interval);
assert_eq!(script.mode, mode);
} }
} }