mirror of
https://github.com/Zedfrigg/ironbar.git
synced 2025-04-19 19:34:24 +02:00
Merge pull request #38 from JakeStanger/feat/common-options
feat: common module options (`show_if`, `on_click`, `tooltip`)
This commit is contained in:
commit
badfcc0c2d
15 changed files with 600 additions and 125 deletions
|
@ -1,6 +1,12 @@
|
|||
By default, you get a single bar at the bottom of all your screens.
|
||||
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
|
||||
|
||||
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).
|
||||
|
||||
The following table describes each of the top-level bar config options.
|
||||
For details on available modules and each of their config options, check the sidebar.
|
||||
Check [here](config) for an example config file for a fully configured bar in each format.
|
||||
|
||||
### 3.1 Top-level options
|
||||
|
||||
The following table lists each of the top-level bar config options:
|
||||
|
||||
| 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. |
|
||||
| `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
91
docs/Scripts.md
Normal 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>
|
|
@ -1,6 +1,7 @@
|
|||
# Guides
|
||||
|
||||
- [Configuration guide](configuration-guide)
|
||||
- [Scripts](scripts)
|
||||
- [Styling guide](styling-guide)
|
||||
|
||||
# Examples
|
||||
|
|
63
src/bar.rs
63
src/bar.rs
|
@ -6,7 +6,8 @@ use crate::modules::mpd::{PlayerCommand, SongUpdate};
|
|||
use crate::modules::workspaces::WorkspaceUpdate;
|
||||
use crate::modules::{Module, ModuleInfoBuilder, ModuleLocation, ModuleUpdateEvent, WidgetContext};
|
||||
use crate::popup::Popup;
|
||||
use crate::Config;
|
||||
use crate::script::{OutputStream, Script};
|
||||
use crate::{await_sync, Config};
|
||||
use chrono::{DateTime, Local};
|
||||
use color_eyre::Result;
|
||||
use gtk::gdk::Monitor;
|
||||
|
@ -16,8 +17,9 @@ use std::collections::HashMap;
|
|||
use std::sync::{Arc, RwLock};
|
||||
use stray::message::NotifierItemCommand;
|
||||
use stray::NotifierItemMessage;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, info};
|
||||
use tracing::{debug, error, info, trace};
|
||||
|
||||
/// Creates a new window for a bar,
|
||||
/// sets it up and adds its widgets.
|
||||
|
@ -81,7 +83,11 @@ pub fn create_bar(
|
|||
});
|
||||
|
||||
debug!("Showing bar");
|
||||
win.show_all();
|
||||
start.show();
|
||||
center.show();
|
||||
end.show();
|
||||
content.show();
|
||||
win.show();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -155,11 +161,60 @@ fn add_modules(
|
|||
controller_tx: ui_tx,
|
||||
};
|
||||
|
||||
let common = $module.common.clone();
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
if let Some(popup_content) = widget.popup {
|
||||
popup
|
||||
|
|
|
@ -7,6 +7,7 @@ use crate::modules::script::ScriptModule;
|
|||
use crate::modules::sysinfo::SysInfoModule;
|
||||
use crate::modules::tray::TrayModule;
|
||||
use crate::modules::workspaces::WorkspacesModule;
|
||||
use crate::script::ScriptInput;
|
||||
use color_eyre::eyre::{Context, ContextCompat};
|
||||
use color_eyre::{eyre, Help, Report};
|
||||
use dirs::config_dir;
|
||||
|
@ -18,6 +19,13 @@ use std::path::{Path, PathBuf};
|
|||
use std::{env, fs};
|
||||
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)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ModuleConfig {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::popup::Popup;
|
||||
use chrono::{DateTime, Local};
|
||||
|
@ -18,7 +19,10 @@ pub struct ClockModule {
|
|||
/// Detail on available tokens can be found here:
|
||||
/// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html>
|
||||
#[serde(default = "default_format")]
|
||||
pub(crate) format: String,
|
||||
format: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::popup::{ButtonGeometry, Popup};
|
||||
use crate::script::exec_command;
|
||||
use crate::config::CommonConfig;
|
||||
use color_eyre::{Report, Result};
|
||||
use crate::script::Script;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Label, Orientation};
|
||||
use serde::Deserialize;
|
||||
|
@ -17,6 +18,9 @@ pub struct CustomModule {
|
|||
bar: Vec<Widget>,
|
||||
/// Widgets to add to the popup container
|
||||
popup: Option<Vec<Widget>>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
/// Attempts to parse an `Orientation` from `String`
|
||||
|
@ -164,8 +168,11 @@ impl Module<gtk::Box> for CustomModule {
|
|||
spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
if event.cmd.starts_with('!') {
|
||||
debug!("executing command: '{}'", &event.cmd[1..]);
|
||||
if let Err(err) = exec_command(&event.cmd[1..]) {
|
||||
let script = Script::from(&event.cmd[1..]);
|
||||
|
||||
debug!("executing command: '{}'", script.cmd);
|
||||
// TODO: Migrate to use script.run
|
||||
if let Err(err) = script.get_output().await {
|
||||
error!("{err:?}");
|
||||
}
|
||||
} else if event.cmd == "popup:toggle" {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use crate::clients::wayland::{self, ToplevelChange};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::{await_sync, icon};
|
||||
use color_eyre::Result;
|
||||
|
@ -23,6 +24,9 @@ pub struct FocusedModule {
|
|||
icon_size: i32,
|
||||
/// GTK icon theme to use.
|
||||
icon_theme: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
const fn default_icon_size() -> i32 {
|
||||
|
|
|
@ -4,6 +4,7 @@ mod open_state;
|
|||
use self::item::{Item, ItemButton, Window};
|
||||
use self::open_state::OpenState;
|
||||
use crate::clients::wayland::{self, ToplevelChange};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::icon::find_desktop_file;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::{Help, Report};
|
||||
|
@ -33,6 +34,9 @@ pub struct LauncherModule {
|
|||
|
||||
/// Name of the GTK icon theme to use.
|
||||
icon_theme: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
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::popup::Popup;
|
||||
use color_eyre::Result;
|
||||
|
@ -65,6 +66,9 @@ pub struct MpdModule {
|
|||
/// Path to root of music directory.
|
||||
#[serde(default = "default_music_dir")]
|
||||
music_dir: PathBuf,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
fn default_socket() -> String {
|
||||
|
|
|
@ -1,39 +1,32 @@
|
|||
use crate::config::CommonConfig;
|
||||
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 gtk::prelude::*;
|
||||
use gtk::Label;
|
||||
use serde::Deserialize;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::time::sleep;
|
||||
use tokio::{select, spawn};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
enum Mode {
|
||||
Poll,
|
||||
Watch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ScriptModule {
|
||||
/// Path to script to execute.
|
||||
path: String,
|
||||
/// Script execution mode
|
||||
#[serde(default = "default_mode")]
|
||||
mode: Mode,
|
||||
mode: ScriptMode,
|
||||
/// Time in milliseconds between executions.
|
||||
#[serde(default = "default_interval")]
|
||||
interval: u64,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
/// `Mode::Poll`
|
||||
const fn default_mode() -> Mode {
|
||||
Mode::Poll
|
||||
const fn default_mode() -> ScriptMode {
|
||||
ScriptMode::Poll
|
||||
}
|
||||
|
||||
/// 5000ms
|
||||
|
@ -41,6 +34,16 @@ const fn default_interval() -> u64 {
|
|||
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 {
|
||||
type SendMessage = String;
|
||||
type ReceiveMessage = ();
|
||||
|
@ -51,78 +54,22 @@ impl Module<Label> for ScriptModule {
|
|||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
_rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> Result<()> {
|
||||
let interval = self.interval;
|
||||
let path = self.path.clone();
|
||||
let script: Script = self.into();
|
||||
|
||||
match self.mode {
|
||||
Mode::Poll => spawn(async move {
|
||||
loop {
|
||||
match exec_command(&path) {
|
||||
Ok(stdout) => tx
|
||||
.send(ModuleUpdateEvent::Update(stdout))
|
||||
.await
|
||||
.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)
|
||||
spawn(async move {
|
||||
script.run(move |(out, _)| match out {
|
||||
OutputStream::Stdout(stdout) => {
|
||||
tx.try_send(ModuleUpdateEvent::Update(stdout))
|
||||
.expect("Failed to send stdout"); }
|
||||
OutputStream::Stderr(stderr) => {
|
||||
error!("{:?}", Report::msg(stderr)
|
||||
.wrap_err("Watched script error:")
|
||||
.suggestion("Check the path to your script")
|
||||
.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")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}),
|
||||
};
|
||||
.suggestion("If you expect the script to write to stderr, consider redirecting its output to /dev/null to suppress these messages"));
|
||||
}
|
||||
}).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::Result;
|
||||
use gtk::prelude::*;
|
||||
|
@ -19,6 +20,9 @@ pub struct SysInfoModule {
|
|||
/// Number of seconds between refresh
|
||||
#[serde(default = "Interval::default")]
|
||||
interval: Interval,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Copy, Clone)]
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use crate::await_sync;
|
||||
use crate::clients::system_tray::get_tray_event_client;
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::Result;
|
||||
use gtk::prelude::*;
|
||||
|
@ -14,7 +15,10 @@ use tokio::sync::mpsc;
|
|||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct TrayModule;
|
||||
pub struct TrayModule {
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
/// Gets a GTK `Image` component
|
||||
/// for the status notifier item's icon.
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use crate::await_sync;
|
||||
use crate::clients::sway::{get_client, get_sub_client};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::{Report, Result};
|
||||
use gtk::prelude::*;
|
||||
|
@ -19,6 +20,9 @@ pub struct WorkspacesModule {
|
|||
/// Whether to display buttons for all monitors.
|
||||
#[serde(default = "crate::config::default_false")]
|
||||
all_monitors: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
376
src/script.rs
376
src/script.rs
|
@ -1,36 +1,354 @@
|
|||
use color_eyre::eyre::WrapErr;
|
||||
use color_eyre::{Help, Report, Result};
|
||||
use std::process::Command;
|
||||
use tracing::instrument;
|
||||
use color_eyre::{Report, Result};
|
||||
use serde::Deserialize;
|
||||
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,
|
||||
/// waiting for it to finish.
|
||||
/// If the command returns status 0,
|
||||
/// the `stdout` is returned.
|
||||
/// Otherwise, an `Err` variant
|
||||
/// 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")?;
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum ScriptInput {
|
||||
String(String),
|
||||
Struct(Script),
|
||||
}
|
||||
|
||||
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")?;
|
||||
#[derive(Debug, Deserialize, Clone, Copy, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScriptMode {
|
||||
Poll,
|
||||
Watch,
|
||||
}
|
||||
|
||||
Ok(stdout)
|
||||
} else {
|
||||
let stderr = String::from_utf8(output.stderr)
|
||||
.map(|output| output.trim().to_string())
|
||||
.wrap_err("Script stderr not valid UTF-8")?;
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OutputStream {
|
||||
Stdout(String),
|
||||
Stderr(String),
|
||||
}
|
||||
|
||||
Err(Report::msg(stderr)
|
||||
.wrap_err("Script returned non-zero error code")
|
||||
.suggestion("Check the path to your script")
|
||||
.suggestion("Check the script for errors"))
|
||||
impl From<&str> for ScriptMode {
|
||||
fn from(str: &str) -> Self {
|
||||
match str {
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue