Cleanup notifs, sys state, ac/bat commands

This commit is contained in:
Luke D. Jones
2024-04-18 13:48:23 +12:00
parent a94a8ca28d
commit 519f6bd46b
10 changed files with 33 additions and 599 deletions

View File

@@ -9,7 +9,7 @@ use crate::AnimeType;
/// Mostly intended to be used with ASUS gifs, but can be used for other
/// purposes (like images)
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct AnimeDiagonal(AnimeType, Vec<Vec<u8>>, Option<Duration>);
impl AnimeDiagonal {

View File

@@ -702,7 +702,7 @@
power_zones: [Keyboard],
),
(
board_name: "GU605M",
device_name: "GU605M",
product_id: "",
layout_name: "ga401q",
basic_modes: [Static, Breathe, Strobe, Rainbow, Pulse],

View File

@@ -3,7 +3,7 @@ use std::fs::create_dir;
use config_traits::{StdConfig, StdConfigLoad1};
use serde_derive::{Deserialize, Serialize};
use crate::update_and_notify::EnabledNotifications;
use crate::notify::EnabledNotifications;
const CFG_DIR: &str = "rog";
const CFG_FILE_NAME: &str = "rog-control-center.cfg";
@@ -15,14 +15,13 @@ pub struct Config {
pub enable_tray_icon: bool,
pub ac_command: String,
pub bat_command: String,
pub enable_notifications: bool,
pub dark_mode: bool,
// intended for use with devices like the ROG Ally
pub start_fullscreen: bool,
pub fullscreen_width: u32,
pub fullscreen_height: u32,
// This field must be last
pub enabled_notifications: EnabledNotifications,
pub notifications: EnabledNotifications,
}
impl Default for Config {
@@ -30,13 +29,12 @@ impl Default for Config {
Self {
run_in_background: true,
startup_in_background: false,
enable_notifications: true,
enable_tray_icon: true,
dark_mode: true,
start_fullscreen: false,
fullscreen_width: 1920,
fullscreen_height: 1080,
enabled_notifications: EnabledNotifications::default(),
notifications: EnabledNotifications::default(),
ac_command: String::new(),
bat_command: String::new(),
}
@@ -94,8 +92,7 @@ impl From<Config461> for Config {
start_fullscreen: false,
fullscreen_width: 1920,
fullscreen_height: 1080,
enable_notifications: c.enable_notifications,
enabled_notifications: c.enabled_notifications,
notifications: c.enabled_notifications,
}
}
}

View File

@@ -17,11 +17,10 @@ pub mod config;
pub mod error;
#[cfg(feature = "mocking")]
pub mod mocking;
pub mod system_state;
pub mod notify;
pub mod tray;
pub mod types;
pub mod ui;
pub mod update_and_notify;
use nix::sys::stat;
use nix::unistd;

View File

@@ -14,11 +14,10 @@ use log::{info, LevelFilter};
use rog_control_center::cli_options::CliStart;
use rog_control_center::config::Config;
use rog_control_center::error::Result;
use rog_control_center::notify::start_notifications;
use rog_control_center::slint::ComponentHandle;
use rog_control_center::system_state::SystemState;
use rog_control_center::tray::init_tray;
use rog_control_center::ui::setup_window;
use rog_control_center::update_and_notify::{start_notifications, EnabledNotifications};
use rog_control_center::{
get_ipc_file, on_tmp_dir_exists, print_versions, MainWindow, QUIT_APP, SHOWING_GUI, SHOW_GUI,
};
@@ -102,7 +101,7 @@ async fn main() -> Result<()> {
}
if is_rog_ally {
config.enable_notifications = false;
config.notifications.enabled = false;
config.enable_tray_icon = false;
config.run_in_background = false;
config.startup_in_background = false;
@@ -115,16 +114,12 @@ async fn main() -> Result<()> {
}
config.write();
let enabled_notifications = EnabledNotifications::tokio_mutex(&config);
// TODO: config mutex to share config in various places
let states = setup_page_state_and_notifs(&enabled_notifications, &config).await?;
let enable_tray_icon = config.enable_tray_icon;
let startup_in_background = config.startup_in_background;
let config = Arc::new(Mutex::new(config));
start_notifications(config.clone())?;
if enable_tray_icon {
init_tray(supported_properties, states.clone(), config.clone());
init_tray(supported_properties, config.clone());
}
thread_local! { pub static UI: std::cell::RefCell<Option<MainWindow>> = Default::default()};
@@ -216,24 +211,6 @@ async fn main() -> Result<()> {
Ok(())
}
async fn setup_page_state_and_notifs(
enabled_notifications: &Arc<Mutex<EnabledNotifications>>,
config: &Config,
) -> Result<Arc<Mutex<SystemState>>> {
let page_states = Arc::new(Mutex::new(
SystemState::new(
enabled_notifications.clone(),
config.enable_tray_icon,
config.run_in_background,
)
.await?,
));
start_notifications(config, &page_states, enabled_notifications)?;
Ok(page_states)
}
// /// Bah.. the icon dosn't work on wayland anyway, but we'll leave it in for
// now. fn load_icon() -> IconData {
// let path = PathBuf::from(APP_ICON_PATH);

View File

@@ -1,91 +0,0 @@
use std::sync::{Arc, Mutex};
use log::error;
use supergfxctl::pci_device::{GfxMode, GfxPower};
#[cfg(not(feature = "mocking"))]
use supergfxctl::zbus_proxy::DaemonProxy as GfxProxy;
use zbus::Connection;
use crate::error::Result;
#[cfg(feature = "mocking")]
use crate::mocking::DaemonProxyBlocking as GfxProxyBlocking;
use crate::update_and_notify::EnabledNotifications;
#[derive(Clone, Debug)]
pub struct GfxState {
pub has_supergfx: bool,
pub mode: GfxMode,
pub power_status: GfxPower,
}
impl GfxState {
pub async fn new(dbus: &GfxProxy<'_>) -> Result<Self> {
Ok(Self {
has_supergfx: dbus.mode().await.is_ok(),
mode: dbus.mode().await.unwrap_or(GfxMode::None),
power_status: dbus.power().await.unwrap_or(GfxPower::Unknown),
})
}
}
impl Default for GfxState {
fn default() -> Self {
Self {
has_supergfx: false,
mode: GfxMode::None,
power_status: GfxPower::Unknown,
}
}
}
/// State stored from system daemons. This is shared with: tray, zbus
/// notifications thread and the GUI app thread.
pub struct SystemState {
pub enabled_notifications: Arc<Mutex<EnabledNotifications>>,
pub gfx_state: GfxState,
pub error: Option<String>,
/// Specific field for the tray only so that we can know when it does need
/// update. The tray should set this to false when done.
pub tray_should_update: bool,
pub app_should_update: bool,
pub tray_enabled: bool,
pub run_in_bg: bool,
}
impl SystemState {
/// Creates self, including the relevant dbus connections and proixies for
/// internal use
pub async fn new(
enabled_notifications: Arc<Mutex<EnabledNotifications>>,
tray_enabled: bool,
run_in_bg: bool,
) -> Result<Self> {
let conn = Connection::system().await?;
let gfx_dbus = GfxProxy::builder(&conn)
.destination(":org.supergfxctl.Daemon")?
.build()
.await
.expect("Couldn't connect to supergfxd");
Ok(Self {
enabled_notifications,
gfx_state: GfxState::new(&gfx_dbus)
.await
.map_err(|e| {
let e = format!("Could not get supergfxd state: {e}");
error!("{e}");
})
.unwrap_or_default(),
error: None,
tray_should_update: true,
app_should_update: true,
tray_enabled,
run_in_bg,
})
}
pub fn set_notified(&mut self) {
self.tray_should_update = true;
self.app_should_update = true;
}
}

View File

@@ -17,7 +17,6 @@ use supergfxctl::zbus_proxy::DaemonProxyBlocking as GfxProxy;
use versions::Versioning;
use crate::config::Config;
use crate::system_state::SystemState;
use crate::{get_ipc_file, QUIT_APP, SHOW_GUI};
const TRAY_LABEL: &str = "ROG Control Center";
@@ -88,13 +87,17 @@ fn do_action(event: TrayEvent<TrayAction>) {
}
}
fn set_tray_icon_and_tip(lock: &SystemState, tray: &TrayIcon<TrayAction>, supergfx_active: bool) {
fn set_tray_icon_and_tip(
mode: GfxMode,
power: GfxPower,
tray: &mut TrayIcon<TrayAction>,
supergfx_active: bool,
) {
if let Some(icons) = ICONS.get() {
let gpu_status = lock.gfx_state.power_status;
match gpu_status {
match power {
GfxPower::Suspended => tray.set_icon(Some(icons.rog_blue.clone())),
GfxPower::Off => {
if lock.gfx_state.mode == GfxMode::Vfio {
if mode == GfxMode::Vfio {
tray.set_icon(Some(icons.rog_red.clone()))
} else {
tray.set_icon(Some(icons.rog_green.clone()))
@@ -113,24 +116,16 @@ fn set_tray_icon_and_tip(lock: &SystemState, tray: &TrayIcon<TrayAction>, superg
}
};
let current_gpu_mode = lock.gfx_state.mode;
tray.set_tooltip(format!(
"ROG: gpu mode = {current_gpu_mode:?}, gpu power = {gpu_status:?}"
));
tray.set_tooltip(format!("ROG: gpu mode = {mode:?}, gpu power = {power:?}"));
}
}
/// The tray is controlled somewhat by `Arc<Mutex<SystemState>>`
pub fn init_tray(
_supported_properties: Vec<Properties>,
states: Arc<Mutex<SystemState>>,
config: Arc<Mutex<Config>>,
) {
pub fn init_tray(_supported_properties: Vec<Properties>, config: Arc<Mutex<Config>>) {
std::thread::spawn(move || {
let rog_red = read_icon(&PathBuf::from("asus_notif_red.png"));
if let Ok(tray) = TrayIconBuilder::<TrayAction>::new()
if let Ok(mut tray) = TrayIconBuilder::<TrayAction>::new()
.with_icon(rog_red.clone())
.with_tooltip(TRAY_LABEL)
.with_menu(build_menu())
@@ -170,15 +165,14 @@ pub fn init_tray(
info!("Started ROGTray");
loop {
if let Ok(mut lock) = states.lock() {
if lock.tray_should_update {
set_tray_icon_and_tip(&lock, &tray, supergfx_active);
lock.tray_should_update = false;
if let Ok(lock) = config.try_lock() {
if !lock.enable_tray_icon {
return;
}
}
if let Ok(lock) = config.try_lock() {
if !lock.enable_tray_icon {
return;
}
}
if let Ok(mode) = gfx_proxy.mode() {
if let Ok(power) = gfx_proxy.power() {
set_tray_icon_and_tip(mode, power, &mut tray, supergfx_active);
}
}
sleep(Duration::from_millis(50));

View File

@@ -166,7 +166,7 @@ pub fn setup_app_settings_page(ui: &MainWindow, config: Arc<Mutex<Config>>) {
let config_copy = config.clone();
global.on_set_enable_notifications(move |enable| {
if let Ok(mut lock) = config_copy.try_lock() {
lock.enable_notifications = enable;
lock.notifications.enabled = enable;
lock.write();
}
});
@@ -175,6 +175,6 @@ pub fn setup_app_settings_page(ui: &MainWindow, config: Arc<Mutex<Config>>) {
global.set_run_in_background(lock.run_in_background);
global.set_startup_in_background(lock.startup_in_background);
global.set_enable_tray_icon(lock.enable_tray_icon);
global.set_enable_notifications(lock.enable_notifications);
global.set_enable_notifications(lock.notifications.enabled);
}
}

View File

@@ -1,442 +0,0 @@
//! `update_and_notify` is responsible for both notifications *and* updating
//! stored statuses about the system state. This is done through either direct,
//! intoify, zbus notifications or similar methods.
//!
//! This module very much functions like a stand-alone app on its own thread.
use std::fmt::Display;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use log::{error, info, trace, warn};
use notify_rust::{Hint, Notification, NotificationHandle, Urgency};
use rog_dbus::zbus_platform::PlatformProxy;
use rog_platform::platform::GpuMode;
use serde::{Deserialize, Serialize};
use supergfxctl::actions::UserActionRequired as GfxUserAction;
use supergfxctl::pci_device::{GfxMode, GfxPower};
use supergfxctl::zbus_proxy::DaemonProxy as SuperProxy;
use tokio::time::sleep;
use zbus::export::futures_util::StreamExt;
use crate::config::Config;
use crate::error::Result;
use crate::system_state::SystemState;
const NOTIF_HEADER: &str = "ROG Control";
static mut POWER_AC_CMD: Option<Command> = None;
static mut POWER_BAT_CMD: Option<Command> = None;
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct EnabledNotifications {
pub receive_power_states: bool,
pub receive_notify_gfx: bool,
pub receive_notify_gfx_status: bool,
}
impl EnabledNotifications {
pub fn tokio_mutex(config: &Config) -> Arc<Mutex<Self>> {
Arc::new(Mutex::new(config.enabled_notifications.clone()))
}
}
fn gpu_to_gfx(value: GpuMode) -> GfxMode {
match value {
GpuMode::Optimus => GfxMode::Hybrid,
GpuMode::Integrated => GfxMode::Integrated,
GpuMode::Egpu => GfxMode::AsusEgpu,
GpuMode::Vfio => GfxMode::Vfio,
GpuMode::Ultimate => GfxMode::AsusMuxDgpu,
GpuMode::Error => GfxMode::None,
GpuMode::NotSupported => GfxMode::None,
}
}
// TODO: drop the macro and use generics plus closure
macro_rules! recv_notif {
($proxy:ident,
$signal:ident,
$last_notif:ident,
$notif_enabled:ident,
$page_states:ident,
($($args: tt)*),
($($out_arg:tt)+),
$msg:literal,
$notifier:ident) => {
let notifs_enabled1 = $notif_enabled.clone();
let page_states1 = $page_states.clone();
tokio::spawn(async move {
let conn = zbus::Connection::system().await.map_err(|e| {
log::error!("zbus signal: {}: {e}", stringify!($signal));
e
}).unwrap();
let proxy = $proxy::builder(&conn).build().await.map_err(|e| {
log::error!("zbus signal: {}: {e}", stringify!($signal));
e
}).unwrap();
if let Ok(mut p) = proxy.$signal().await {
info!("Started zbus signal thread: {}", stringify!($signal));
while let Some(e) = p.next().await {
if let Ok(out) = e.args() {
if let Ok(config) = notifs_enabled1.lock() {
if config.$signal {
trace!("zbus signal {}", stringify!($signal));
$notifier($msg, &out.$($out_arg)+()).ok();
}
}
if let Ok(mut lock) = page_states1.lock() {
lock.$($args)+ = *out.$($out_arg)+();
lock.set_notified();
}
}
sleep(Duration::from_millis(500)).await;
}
};
});
};
}
pub fn start_notifications(
config: &Config,
page_states: &Arc<Mutex<SystemState>>,
enabled_notifications: &Arc<Mutex<EnabledNotifications>>,
) -> Result<()> {
// Setup the AC/BAT commands that will run on poweer status change
unsafe {
let prog: Vec<&str> = config.ac_command.split_whitespace().collect();
if prog.len() > 1 {
let mut cmd = Command::new(prog[0]);
for arg in prog.iter().skip(1) {
cmd.arg(*arg);
}
POWER_AC_CMD = Some(cmd);
}
}
unsafe {
let prog: Vec<&str> = config.bat_command.split_whitespace().collect();
if prog.len() > 1 {
let mut cmd = Command::new(prog[0]);
for arg in prog.iter().skip(1) {
cmd.arg(*arg);
}
POWER_BAT_CMD = Some(cmd);
}
}
let page_states1 = page_states.clone();
tokio::spawn(async move {
let conn = zbus::Connection::system()
.await
.map_err(|e| {
error!("zbus signal: receive_notify_gpu_mux_mode: {e}");
e
})
.unwrap();
let proxy = PlatformProxy::new(&conn)
.await
.map_err(|e| {
error!("zbus signal: receive_notify_gpu_mux_mode: {e}");
e
})
.unwrap();
let mut actual_mux_mode = GpuMode::Error;
if let Ok(mode) = proxy.gpu_mux_mode().await {
actual_mux_mode = GpuMode::from(mode);
}
info!("Started zbus signal thread: receive_notify_gpu_mux_mode");
while let Some(e) = proxy.receive_gpu_mux_mode_changed().await.next().await {
if let Ok(out) = e.get().await {
let mode = GpuMode::from(out);
if mode == actual_mux_mode {
continue;
}
if let Ok(mut lock) = page_states1.lock() {
lock.gfx_state.mode = gpu_to_gfx(mode);
lock.set_notified();
}
do_mux_notification("Reboot required. BIOS GPU MUX mode set to", &mode).ok();
}
}
});
use supergfxctl::pci_device::Device;
let dev = Device::find().unwrap_or_default();
let mut found_dgpu = false; // just for logging
for dev in dev {
if dev.is_dgpu() {
let notifs_enabled1 = enabled_notifications.clone();
let page_states1 = page_states.clone();
// Plain old thread is perfectly fine since most of this is potentially blocking
tokio::spawn(async move {
let mut last_status = GfxPower::Unknown;
loop {
if let Ok(status) = dev.get_runtime_status() {
if status != GfxPower::Unknown && status != last_status {
if let Ok(config) = notifs_enabled1.lock() {
if config.receive_notify_gfx_status {
// Required check because status cycles through
// active/unknown/suspended
do_gpu_status_notif("dGPU status changed:", &status).ok();
}
}
if let Ok(mut lock) = page_states1.lock() {
lock.set_notified();
}
}
if let Ok(mut lock) = page_states1.lock() {
lock.gfx_state.power_status = status;
}
last_status = status;
}
sleep(Duration::from_millis(500)).await;
}
});
found_dgpu = true;
break;
}
}
if !found_dgpu {
warn!("Did not find a dGPU on this system, dGPU status won't be avilable");
}
let page_states1 = page_states.clone();
let enabled_notifications1 = enabled_notifications.clone();
tokio::spawn(async move {
let conn = zbus::Connection::system()
.await
.map_err(|e| {
error!("zbus signal: receive_notify_action: {e}");
e
})
.unwrap();
let proxy = SuperProxy::builder(&conn)
.build()
.await
.map_err(|e| {
error!("zbus signal: receive_notify_action: {e}");
e
})
.unwrap();
if let Ok(mode) = proxy.mode().await {
if let Ok(mut lock) = page_states1.lock() {
lock.gfx_state.mode = mode;
lock.gfx_state.has_supergfx = true;
}
} else {
info!("supergfxd not running or not responding");
return;
}
let page_states2 = page_states1.clone();
recv_notif!(
SuperProxy,
receive_notify_gfx,
last_notification,
enabled_notifications1,
page_states2,
(gfx_state.mode),
(mode),
"Gfx mode changed to",
do_notification
);
if let Ok(mut p) = proxy.receive_notify_action().await {
info!("Started zbus signal thread: receive_notify_action");
while let Some(e) = p.next().await {
if let Ok(out) = e.args() {
let action = out.action();
let mode = if let Ok(lock) = page_states1.lock() {
convert_gfx_mode(lock.gfx_state.mode)
} else {
GpuMode::Error
};
match action {
supergfxctl::actions::UserActionRequired::Reboot => {
do_mux_notification("Graphics mode change requires reboot", &mode)
}
_ => do_gfx_action_notif(<&str>::from(action), *action, mode),
}
.map_err(|e| {
error!("zbus signal: do_gfx_action_notif: {e}");
e
})
.ok();
}
}
};
});
Ok(())
}
fn convert_gfx_mode(gfx: GfxMode) -> GpuMode {
match gfx {
GfxMode::Hybrid => GpuMode::Optimus,
GfxMode::Integrated => GpuMode::Integrated,
GfxMode::NvidiaNoModeset => GpuMode::Optimus,
GfxMode::Vfio => GpuMode::Vfio,
GfxMode::AsusEgpu => GpuMode::Egpu,
GfxMode::AsusMuxDgpu => GpuMode::Ultimate,
GfxMode::None => GpuMode::Error,
}
}
fn base_notification<T>(message: &str, data: &T) -> Notification
where
T: Display,
{
let mut notif = Notification::new();
notif
.summary(NOTIF_HEADER)
.body(&format!("{message} {data}"))
.timeout(-1)
//.hint(Hint::Resident(true))
.hint(Hint::Category("device".into()));
notif
}
fn do_notification<T>(message: &str, data: &T) -> Result<NotificationHandle>
where
T: Display,
{
Ok(base_notification(message, data).show()?)
}
// TODO:
fn _ac_power_notification(message: &str, on: &bool) -> Result<NotificationHandle> {
let data = if *on {
unsafe {
if let Some(cmd) = POWER_AC_CMD.as_mut() {
if let Err(e) = cmd.spawn() {
error!("AC power command error: {e}");
}
}
}
"plugged".to_owned()
} else {
unsafe {
if let Some(cmd) = POWER_BAT_CMD.as_mut() {
if let Err(e) = cmd.spawn() {
error!("Battery power command error: {e}");
}
}
}
"unplugged".to_owned()
};
Ok(base_notification(message, &data).show()?)
}
fn do_gpu_status_notif(message: &str, data: &GfxPower) -> Result<NotificationHandle> {
// eww
let mut notif = base_notification(message, &<&str>::from(data).to_owned());
let icon = match data {
GfxPower::Suspended => "asus_notif_blue",
GfxPower::Off => "asus_notif_green",
GfxPower::AsusDisabled => "asus_notif_white",
GfxPower::AsusMuxDiscreet | GfxPower::Active => "asus_notif_red",
GfxPower::Unknown => "gpu-integrated",
};
notif.icon(icon);
Ok(Notification::show(&notif)?)
}
fn do_gfx_action_notif(message: &str, action: GfxUserAction, mode: GpuMode) -> Result<()> {
if matches!(action, GfxUserAction::Reboot) {
do_mux_notification("Graphics mode change requires reboot", &mode).ok();
return Ok(());
}
let mut notif = Notification::new();
notif
.summary(NOTIF_HEADER)
.body(&format!("Changing to {mode}. {message}"))
.timeout(2000)
//.hint(Hint::Resident(true))
.hint(Hint::Category("device".into()))
.urgency(Urgency::Critical)
.timeout(-1)
.icon("dialog-warning")
.hint(Hint::Transient(true));
if matches!(action, GfxUserAction::Logout) {
notif.action("gfx-mode-session-action", "Logout");
let handle = notif.show()?;
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
if desktop.to_lowercase() == "gnome" {
handle.wait_for_action(|id| {
if id == "gfx-mode-session-action" {
let mut cmd = Command::new("gnome-session-quit");
cmd.spawn().ok();
} else if id == "__closed" {
// TODO: cancel the switching
}
});
} else if desktop.to_lowercase() == "kde" {
handle.wait_for_action(|id| {
if id == "gfx-mode-session-action" {
let mut cmd = Command::new("qdbus");
cmd.args(["org.kde.ksmserver", "/KSMServer", "logout", "1", "0", "0"]);
cmd.spawn().ok();
} else if id == "__closed" {
// TODO: cancel the switching
}
});
} else {
// todo: handle alternatives
}
}
} else {
notif.show()?;
}
Ok(())
}
/// Actual `GpuMode` unused as data is never correct until switched by reboot
fn do_mux_notification(message: &str, m: &GpuMode) -> Result<()> {
let mut notif = base_notification(message, &m.to_string());
notif
.action("gfx-mode-session-action", "Reboot")
.urgency(Urgency::Critical)
.icon("system-reboot-symbolic")
.hint(Hint::Transient(true));
let handle = notif.show()?;
std::thread::spawn(|| {
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
if desktop.to_lowercase() == "gnome" {
handle.wait_for_action(|id| {
if id == "gfx-mode-session-action" {
let mut cmd = Command::new("gnome-session-quit");
cmd.arg("--reboot");
cmd.spawn().ok();
} else if id == "__closed" {
// TODO: cancel the switching
}
});
} else if desktop.to_lowercase() == "kde" {
handle.wait_for_action(|id| {
if id == "gfx-mode-session-action" {
let mut cmd = Command::new("qdbus");
cmd.args(["org.kde.ksmserver", "/KSMServer", "logout", "1", "1", "0"]);
cmd.spawn().ok();
} else if id == "__closed" {
// TODO: cancel the switching
}
});
}
}
});
Ok(())
}

View File

@@ -2,7 +2,7 @@
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"POT-Creation-Date: 2024-04-17 09:39+0000\n"
"POT-Creation-Date: 2024-04-18 01:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"