Use system-wide directories when run as root
This commit is contained in:
parent
603fff0094
commit
9143d30de3
9 changed files with 103 additions and 96 deletions
21
Cargo.lock
generated
21
Cargo.lock
generated
|
@ -342,15 +342,6 @@ dependencies = [
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "directories"
|
|
||||||
version = "4.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f51c5d4ddabd36886dd3e1438cb358cdcb0d7c499cb99cb4ac2e38e18b5cb210"
|
|
||||||
dependencies = [
|
|
||||||
"dirs-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs"
|
name = "dirs"
|
||||||
version = "4.0.0"
|
version = "4.0.0"
|
||||||
|
@ -681,7 +672,6 @@ dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
"battery",
|
"battery",
|
||||||
"directories",
|
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"exitcode",
|
"exitcode",
|
||||||
"image",
|
"image",
|
||||||
|
@ -697,6 +687,7 @@ dependencies = [
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
"users",
|
||||||
"validator",
|
"validator",
|
||||||
"void",
|
"void",
|
||||||
"zbus",
|
"zbus",
|
||||||
|
@ -1992,6 +1983,16 @@ dependencies = [
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "users"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "validator"
|
name = "validator"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
name = "hassliebe"
|
name = "hassliebe"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
homepage = "https://git.moritzruth.de/moritzruth/Hassliebe"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
@ -12,7 +13,6 @@ dry_run = [] # will prevent some actions like shutting down
|
||||||
anyhow = "1.0.69"
|
anyhow = "1.0.69"
|
||||||
base64 = "0.21.0"
|
base64 = "0.21.0"
|
||||||
battery = "0.7.8"
|
battery = "0.7.8"
|
||||||
directories = "4.0.1"
|
|
||||||
env_logger = "0.10.0"
|
env_logger = "0.10.0"
|
||||||
exitcode = "1.1.2"
|
exitcode = "1.1.2"
|
||||||
image = "0.24.5"
|
image = "0.24.5"
|
||||||
|
@ -28,6 +28,7 @@ serde_json = "1.0.93"
|
||||||
sysinfo = { version = "0.28.2", default-features = false }
|
sysinfo = { version = "0.28.2", default-features = false }
|
||||||
tokio = { version = "1.25.0", features = ["full"] }
|
tokio = { version = "1.25.0", features = ["full"] }
|
||||||
toml = "0.7.2"
|
toml = "0.7.2"
|
||||||
|
users = "0.11.0"
|
||||||
validator = { version = "0.16.0", features = ["derive"] }
|
validator = { version = "0.16.0", features = ["derive"] }
|
||||||
void = "1.0.2"
|
void = "1.0.2"
|
||||||
zbus = { version = "3.10.0", default-features = false, features = ["tokio"] }
|
zbus = { version = "3.10.0", default-features = false, features = ["tokio"] }
|
||||||
|
|
12
README.md
12
README.md
|
@ -22,10 +22,10 @@ Ideas:
|
||||||
|
|
||||||
TBD
|
TBD
|
||||||
|
|
||||||
If the configuration file does not exist, an example file is created and the program exits.
|
Hassliebe uses different paths depending on whether it is run as root or as a normal user:
|
||||||
|
|
||||||
The default path is `$XDG_CONFIG_HOME/hassliebe/config.toml`.
|
- `[data]`: `/var/lib/hassliebe` / `$XDG_DATA_HOME/hassliebe`
|
||||||
It can be changed using the `HASSLIEBE_CONFIG` environment variable.
|
- `[config]`: `/etc/hassliebe/config.toml` / `$XDG_CONFIG_HOME/hassliebe/config.toml`
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
@ -61,7 +61,7 @@ Hassliebe needs a way to uniquely identify a machine.
|
||||||
`friendly_id` is not suited well for this because the user may want to change it from time to time.
|
`friendly_id` is not suited well for this because the user may want to change it from time to time.
|
||||||
|
|
||||||
If `/etc/machine-id` exists (as is the case with systemd-based systems), it will be used.
|
If `/etc/machine-id` exists (as is the case with systemd-based systems), it will be used.
|
||||||
Otherwise, a random ID will be generated on the first run and stored in `$XDG_DATA_HOME/hassliebe/machine_id`.
|
Otherwise, a random ID will be generated on the first run and stored in `[data]/machine_id`.
|
||||||
The latter always takes precedence.
|
The latter always takes precedence.
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
@ -100,6 +100,8 @@ battery = 60 # updates every 60 seconds
|
||||||
|
|
||||||
### Notifications
|
### Notifications
|
||||||
|
|
||||||
|
**Not available when running as a system service.**
|
||||||
|
|
||||||
The notifications module allows sending notifications according to the
|
The notifications module allows sending notifications according to the
|
||||||
[Desktop Notifications Specification](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup).
|
[Desktop Notifications Specification](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup).
|
||||||
|
|
||||||
|
@ -128,7 +130,7 @@ Complex messages have these properties:
|
||||||
- `long_content` (optional) — Optional additional content
|
- `long_content` (optional) — Optional additional content
|
||||||
which [supports a subset of HTML](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup).
|
which [supports a subset of HTML](https://specifications.freedesktop.org/notification-spec/notification-spec-latest.html#markup).
|
||||||
- `transient` (optional) — Boolean indicating whether to bypass the notification server’s persistence capability.
|
- `transient` (optional) — Boolean indicating whether to bypass the notification server’s persistence capability.
|
||||||
- `encoded_image` — Optional padded base64-encoded image attached to the notification.
|
- `encoded_image` (optional) — Padded Base64-encoded image attached to the notification.
|
||||||
- `actions` (optional) — Object with the keys being IDs and the values being labels.
|
- `actions` (optional) — Object with the keys being IDs and the values being labels.
|
||||||
|
|
||||||
#### Actions
|
#### Actions
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
use std::borrow::ToOwned;
|
|
||||||
use std::fs;
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{ErrorKind, Read, Write};
|
use std::io::{ErrorKind, Read};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::modules;
|
use crate::modules;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct Mqtt {
|
pub struct Mqtt {
|
||||||
#[validate(length(min = 1))]
|
#[validate(length(min = 1))]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
|
@ -21,19 +19,19 @@ pub struct Mqtt {
|
||||||
pub credentials: Option<MqttCredentials>,
|
pub credentials: Option<MqttCredentials>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct MqttCredentials {
|
pub struct MqttCredentials {
|
||||||
pub user: String,
|
pub user: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct Internal {
|
pub struct Internal {
|
||||||
#[validate(length(min = 2))]
|
#[validate(length(min = 2))]
|
||||||
pub stable_id: String,
|
pub stable_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[validate(custom = "crate::util::validate_hass_id")]
|
#[validate(custom = "crate::util::validate_hass_id")]
|
||||||
pub friendly_id: String,
|
pub friendly_id: String,
|
||||||
|
@ -48,49 +46,27 @@ pub struct Config {
|
||||||
pub modules: modules::Config,
|
pub modules: modules::Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_example_config() -> Config {
|
|
||||||
Config {
|
|
||||||
friendly_id: "my_pc".to_owned(),
|
|
||||||
display_name: "My PC".to_owned(),
|
|
||||||
mqtt: Mqtt {
|
|
||||||
host: "".to_owned(),
|
|
||||||
port: 1883,
|
|
||||||
credentials: None,
|
|
||||||
},
|
|
||||||
modules: modules::Config::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(config_file_path: &Path) -> Result<Option<Config>> {
|
pub fn load(config_file_path: &Path) -> Result<Option<Config>> {
|
||||||
match File::open(config_file_path) {
|
match File::open(config_file_path) {
|
||||||
Ok(mut file) => {
|
Ok(mut file) => {
|
||||||
log::info!("Reading config file: {}", config_file_path.to_string_lossy());
|
log::info!("Reading configuration file: {}", config_file_path.to_string_lossy());
|
||||||
|
|
||||||
let mut string_content = String::new();
|
let mut string_content = String::new();
|
||||||
file.read_to_string(&mut string_content).context("while reading the config file")?;
|
file.read_to_string(&mut string_content).context("while reading the configuration file")?;
|
||||||
|
|
||||||
let parsed = toml::from_str::<Config>(string_content.as_str()).context("while parsing the config file")?;
|
let parsed = toml::from_str::<Config>(string_content.as_str()).context("while parsing the configuration file")?;
|
||||||
parsed.validate().context("while validating the config file")?;
|
parsed.validate().context("while validating the configuration file")?;
|
||||||
|
|
||||||
Ok(Some(parsed))
|
Ok(Some(parsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
Err(error) if error.kind() == ErrorKind::NotFound => {
|
||||||
if let Some(parent) = config_file_path.parent() {
|
log::error!("The configuration file does not exist: {}", config_file_path.to_string_lossy());
|
||||||
fs::create_dir_all(parent)?;
|
log::info!("See the documentation: {}", env!("CARGO_PKG_HOMEPAGE"));
|
||||||
}
|
|
||||||
|
|
||||||
let mut file = File::create(config_file_path).context("while creating the default config file")?;
|
|
||||||
let default = toml::to_string::<Config>(&create_example_config()).expect("create_example_config() should be valid");
|
|
||||||
|
|
||||||
file.write_all(default.as_bytes())?;
|
|
||||||
|
|
||||||
log::warn!("Created an example config file at {}", config_file_path.to_string_lossy());
|
|
||||||
log::warn!("Make sure to edit the file before running again.");
|
|
||||||
|
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(error) => bail!("Config file could not be opened: {}", error),
|
Err(error) => Err(error).context("while opening the configuration file"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
45
src/main.rs
45
src/main.rs
|
@ -1,9 +1,10 @@
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::env;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
use crate::modules::{InitializationContext, ModuleContext, ModuleContextMqtt};
|
use crate::modules::{InitializationContext, ModuleContext, ModuleContextMqtt};
|
||||||
use crate::mqtt::OwnedTopicsService;
|
use crate::mqtt::OwnedTopicsService;
|
||||||
|
@ -14,28 +15,46 @@ mod modules;
|
||||||
mod mqtt;
|
mod mqtt;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
struct Paths {
|
struct Paths {
|
||||||
data_directory: Box<Path>,
|
data_directory: Box<Path>,
|
||||||
config: Box<Path>,
|
config: Box<Path>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_paths_and_create_directories() -> Result<Paths> {
|
async fn get_paths_and_create_directories() -> Result<Paths> {
|
||||||
let project_dirs = directories::ProjectDirs::from("", "", "Hassliebe");
|
let is_root: bool = users::get_current_uid() == 0;
|
||||||
|
|
||||||
let paths = Paths {
|
let paths = if is_root {
|
||||||
config: std::env::var_os("HASS_CONFIG")
|
Paths {
|
||||||
|
config: PathBuf::from("/etc/hassliebe/config.toml").into_boxed_path(),
|
||||||
|
data_directory: PathBuf::from("/var/lib/hassliebe").into_boxed_path(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let home = env::var_os("HOME").map(PathBuf::from);
|
||||||
|
|
||||||
|
if let Some(home) = home {
|
||||||
|
Paths {
|
||||||
|
config: env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
// I don’t like this clone
|
.unwrap_or_else(|| home.clone().join(".config"))
|
||||||
.or(project_dirs.clone().map(|d| d.config_dir().to_path_buf().join("config.toml")))
|
.join("hassliebe/config.toml")
|
||||||
.ok_or_else(|| anyhow!("Please specify a config file via HASS_CONFIG"))?
|
|
||||||
.into_boxed_path(),
|
.into_boxed_path(),
|
||||||
data_directory: std::env::var_os("HASS_DATA_DIR")
|
|
||||||
|
data_directory: env::var_os("XDG_DATA_HOME")
|
||||||
|
.filter(|p| !p.is_empty())
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.or(project_dirs.map(|d| d.data_dir().to_path_buf()))
|
.unwrap_or_else(|| home.clone().join(".local/share"))
|
||||||
.ok_or_else(|| anyhow!("Please specify a data directory via HASS_DATA_DIR"))?
|
.join("hassliebe")
|
||||||
.into_boxed_path(),
|
.into_boxed_path(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bail!("The HOME environment variable must be set when not running as root.")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
log::debug!("{:?}", &paths);
|
||||||
|
|
||||||
if let Some(p) = paths.config.parent() {
|
if let Some(p) = paths.config.parent() {
|
||||||
tokio::fs::create_dir_all(p).await?;
|
tokio::fs::create_dir_all(p).await?;
|
||||||
}
|
}
|
||||||
|
@ -83,6 +102,12 @@ async fn load_machine_id(paths: &Paths) -> Result<String> {
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
initialize_logger();
|
initialize_logger();
|
||||||
|
|
||||||
|
if env::args().any(|a| a == "-v" || a == "--version" || a == "-h" || a == "--help") {
|
||||||
|
println!("Hassliebe v{} is licensed under the Blue Oak Model License 1.0.0.", env!("CARGO_PKG_VERSION"));
|
||||||
|
println!("See https://git.moritzruth.de/moritzruth/Hassliebe for more information.");
|
||||||
|
exit(exitcode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
let paths = get_paths_and_create_directories().await?;
|
let paths = get_paths_and_create_directories().await?;
|
||||||
let machine_id = load_machine_id(&paths).await?;
|
let machine_id = load_machine_id(&paths).await?;
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@ use crate::modules::InitializationContext;
|
||||||
const MODULE_ID: &str = "buttons";
|
const MODULE_ID: &str = "buttons";
|
||||||
const BUTTON_TRIGGER_TEXT: &str = "press";
|
const BUTTON_TRIGGER_TEXT: &str = "press";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate, Clone)]
|
#[derive(Deserialize, Validate, Clone)]
|
||||||
pub struct ButtonConfig {
|
pub struct ButtonConfig {
|
||||||
#[validate(custom = "crate::util::validate_hass_id")]
|
#[validate(custom = "crate::util::validate_hass_id")]
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
@ -23,7 +23,7 @@ pub struct ButtonConfig {
|
||||||
pub run_in_shell: bool,
|
pub run_in_shell: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|
|
@ -3,7 +3,7 @@ use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use rumqttc::QoS;
|
use rumqttc::QoS;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use sysinfo::{CpuExt, System, SystemExt};
|
use sysinfo::{CpuExt, System, SystemExt};
|
||||||
use tokio::time::MissedTickBehavior;
|
use tokio::time::MissedTickBehavior;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
@ -12,7 +12,7 @@ use crate::modules::InitializationContext;
|
||||||
|
|
||||||
const MODULE_ID: &str = "info";
|
const MODULE_ID: &str = "info";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
@ -79,6 +79,8 @@ pub async fn init(context: &mut InitializationContext) -> Result<()> {
|
||||||
if config.battery != 0 {
|
if config.battery != 0 {
|
||||||
let battery_dirs = fs::read_dir("/sys/class/power_supply")?.filter_map(|d| d.ok()).collect::<Vec<_>>();
|
let battery_dirs = fs::read_dir("/sys/class/power_supply")?.filter_map(|d| d.ok()).collect::<Vec<_>>();
|
||||||
|
|
||||||
|
// TODO: Filter battery_dirs by the existence of "capacity"
|
||||||
|
|
||||||
if let Some(dir) = battery_dirs.first() {
|
if let Some(dir) = battery_dirs.first() {
|
||||||
log::debug!("Found {} batteries, using {}", battery_dirs.len(), dir.file_name().to_string_lossy());
|
log::debug!("Found {} batteries, using {}", battery_dirs.len(), dir.file_name().to_string_lossy());
|
||||||
let path = dir.path();
|
let path = dir.path();
|
||||||
|
|
|
@ -4,14 +4,14 @@ use std::sync::Arc;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use json::JsonValue;
|
use json::JsonValue;
|
||||||
use rumqttc::{AsyncClient as MqttClient, ClientError, QoS};
|
use rumqttc::{AsyncClient as MqttClient, ClientError, QoS};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
mod buttons;
|
mod buttons;
|
||||||
mod info;
|
mod info;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate, Default)]
|
#[derive(Deserialize, Validate, Default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[validate]
|
#[validate]
|
||||||
pub buttons: Option<buttons::Config>,
|
pub buttons: Option<buttons::Config>,
|
||||||
|
|
|
@ -5,7 +5,7 @@ use anyhow::{Context, Result};
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use notify_rust::Hint;
|
use notify_rust::Hint;
|
||||||
use rumqttc::QoS;
|
use rumqttc::QoS;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use tokio::task::spawn_blocking;
|
use tokio::task::spawn_blocking;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ use crate::util::{generate_alphanumeric_id, hash_string_to_u32, spawn_nonessenti
|
||||||
|
|
||||||
const MODULE_ID: &str = "notifications";
|
const MODULE_ID: &str = "notifications";
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Validate)]
|
#[derive(Deserialize, Validate)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|
Loading…
Add table
Reference in a new issue