feat: new service setup GUI (#1043)

* feat: WIP service setup GUI

* fix: avoid crash when closing dialog with app already running

* wip

* feat: make it more usable

* wip logs

* feat: working logs display

* feat: use new dialog for version mismatch

* chore: cleanup

* fix: tcp functionality

* feat: handle setup errors

* perf: avoid refetching system info

* fix test
This commit is contained in:
Ilya Zlobintsev
2026-07-26 18:54:32 +03:00
committed by GitHub
parent 504d300d02
commit b3efeb273f
18 changed files with 761 additions and 113 deletions
Generated
+2
View File
@@ -1847,6 +1847,7 @@ dependencies = [
"anyhow",
"cairo-rs",
"divan",
"futures",
"gtk4",
"i18n-embed",
"i18n-embed-fl",
@@ -1870,6 +1871,7 @@ dependencies = [
"thread-priority",
"tracing",
"tracing-subscriber",
"zbus",
]
[[package]]
+1
View File
@@ -40,6 +40,7 @@ i18n-embed = { version = "0.16.0", features = [
] }
i18n-embed-fl = "0.10.0"
rust-embed = { version = "8.12.0", features = ["debug-embed"] }
zbus = { version = "5.14.0", default-features = false, features = ["tokio"] }
[profile.release]
strip = "symbols"
+3 -1
View File
@@ -138,6 +138,8 @@ On most desktop configurations (such as the default setup on Arch-based, most
Debian-based or Fedora systems) this includes the default user, so you do not
need to configure this.
This is not needed if you are using the Flatpak, the flatpak service setup handles permissions.
However, some systems may have different user configuration. In particular, this
has been reported to be a problem on OpenSUSE.
@@ -145,7 +147,7 @@ To fix socket permissions in such configurations, edit `/etc/lact/config.yaml`
and under the `daemon` section either:
- Set `admin_user` to your username
- Set `admin_group` to a group that your user is a part of Then restart the
- Set `admin_group` to a group that your user is a part of, then restart the
service (`sudo systemctl restart lactd`).
# Overclocking (AMD)
+2 -2
View File
@@ -4,7 +4,7 @@ use futures::future::BoxFuture;
use std::os::unix::net::UnixStream as StdUnixStream;
use std::path::Path;
use tokio::{io::BufReader, net::UnixStream};
use tracing::info;
use tracing::debug;
pub struct UnixConnection {
inner: BufReader<UnixStream>,
@@ -12,7 +12,7 @@ pub struct UnixConnection {
impl UnixConnection {
pub async fn connect(path: &Path) -> anyhow::Result<Box<Self>> {
info!("connecting to service at {path:?}");
debug!("connecting to service at {path:?}");
let inner = UnixStream::connect(path).await?;
Ok(Box::new(Self {
inner: BufReader::new(inner),
+31 -5
View File
@@ -4,7 +4,7 @@ mod macros;
pub use lact_schema as schema;
use lact_schema::{
DeviceApiInfo, DisplaysInfo, ProcessList, ProfileRule,
DeviceApiInfo, DisplaysInfo, Pong, ProcessList, ProfileRule,
config::{GpuConfig, Profile, ProfileHooks},
};
@@ -19,7 +19,8 @@ use schema::{
};
use serde::de::DeserializeOwned;
use std::{
future::Future, os::unix::net::UnixStream, path::PathBuf, pin::Pin, rc::Rc, time::Duration,
fmt, future::Future, io, os::unix::net::UnixStream, path::PathBuf, pin::Pin, rc::Rc,
time::Duration,
};
use tokio::{
net::ToSocketAddrs,
@@ -34,18 +35,24 @@ const RECONNECT_INTERVAL_MS: u64 = 500;
pub struct DaemonClient {
stream: Rc<Mutex<Box<dyn DaemonConnection>>>,
status_tx: broadcast::Sender<ConnectionStatusMsg>,
reconnect: bool,
pub embedded: bool,
}
impl DaemonClient {
pub async fn connect() -> anyhow::Result<Self> {
let path =
get_socket_path().context("Could not connect to daemon: socket file not found")?;
Self::connect_with_reconnect(true).await
}
pub async fn connect_with_reconnect(reconnect: bool) -> anyhow::Result<Self> {
let path = get_socket_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "socket file not found"))?;
let stream = UnixConnection::connect(&path).await?;
Ok(Self {
stream: Rc::new(Mutex::new(stream)),
embedded: false,
reconnect,
status_tx: broadcast::Sender::new(STATUS_MSG_CHANNEL_SIZE),
})
}
@@ -56,6 +63,7 @@ impl DaemonClient {
Ok(Self {
stream: Rc::new(Mutex::new(stream)),
embedded: false,
reconnect: true,
status_tx: broadcast::Sender::new(STATUS_MSG_CHANNEL_SIZE),
})
}
@@ -65,6 +73,7 @@ impl DaemonClient {
Ok(Self {
stream: Rc::new(Mutex::new(Box::new(connection))),
embedded,
reconnect: false,
status_tx: broadcast::Sender::new(STATUS_MSG_CHANNEL_SIZE),
})
}
@@ -94,9 +103,14 @@ impl DaemonClient {
}
}
Err(err) => {
error!("Could not make request: {err}, reconnecting to socket");
let _ = self.status_tx.send(ConnectionStatusMsg::Disconnected);
if !self.reconnect {
return Err(err);
}
error!("Could not make request: {err}, reconnecting to socket");
loop {
match stream.new_connection().await {
Ok(new_connection) => {
@@ -122,6 +136,10 @@ impl DaemonClient {
})
}
pub async fn ping(&self) -> anyhow::Result<Pong> {
self.make_request(Request::Ping).await
}
pub async fn list_devices(&self) -> anyhow::Result<Vec<DeviceListEntry>> {
self.make_request(Request::ListDevices).await
}
@@ -237,6 +255,14 @@ impl DaemonClient {
}
}
impl fmt::Debug for DaemonClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DaemonClient")
.field("embedded", &self.embedded)
.finish()
}
}
fn get_socket_path() -> Option<PathBuf> {
let root_path = PathBuf::from("/run/lactd.sock");
+1 -1
View File
@@ -37,7 +37,7 @@ serde_norway = { workspace = true }
nvml-wrapper = { workspace = true }
bitflags = "2.13.0"
pciid-parser = { version = "0.8", features = ["serde"] }
zbus = { version = "5.17.0", default-features = false, features = ["tokio"] }
zbus = { workspace = true }
zbus_polkit = { version = "5.0.0", default-features = false, features = ["tokio"] }
libdrm_amdgpu_sys = { version = "0.8.16", default-features = false, features = [
"dynamic_loading",
+2 -12
View File
@@ -2,7 +2,7 @@ pub mod power_profiles_daemon;
use anyhow::{Context, anyhow, bail, ensure};
use lact_schema::{
AmdgpuParamsConfigurator, BootArgConfigurator, GIT_COMMIT, InitramfsType, SystemInfo,
AmdgpuParamsConfigurator, BootArgConfigurator, InitramfsType, SystemInfo, VersionInfo,
};
use nix::sys::{
socket::{
@@ -45,14 +45,6 @@ static MODULE_CONF_PATH: LazyLock<PathBuf> = LazyLock::new(|| {
});
pub async fn info() -> anyhow::Result<SystemInfo> {
let version = DAEMON_VERSION.to_owned();
let profile = if cfg!(debug_assertions) {
"debug"
} else {
"release"
}
.to_owned();
let kernel_version = uname().map_or_else(
|err| {
error!("could not fetch kernel version: {err}");
@@ -70,12 +62,10 @@ pub async fn info() -> anyhow::Result<SystemInfo> {
let os_release = get_os_release().inspect_err(|err| error!("Could not detect distro: {err}"));
Ok(SystemInfo {
version,
profile,
version: VersionInfo::current(),
kernel_version,
distro: os_release.as_ref().map(|release| release.name.clone()).ok(),
amdgpu_overdrive_enabled,
commit: Some(GIT_COMMIT.to_owned()),
amdgpu_params_configurator: match os_release {
Ok(release) => detect_amdgpu_configurator(&release).await.ok(),
Err(_) => None,
+3
View File
@@ -37,6 +37,9 @@ i18n-embed = { workspace = true }
i18n-embed-fl = { workspace = true }
rust-embed = { workspace = true }
futures = { workspace = true }
zbus = { workspace = true }
plotters = { version = "0.3.7", default-features = false, features = [
"area_series",
"line_series",
+19 -8
View File
@@ -230,18 +230,29 @@ edit-graph-sensors = Edit Graph Sensors
error-heading = Error
daemon-info-heading = Daemon info
reconnecting-to-daemon = Daemon connection lost, reconnecting...
reconnecting-to-daemon = Service connection lost, reconnecting...
daemon-connection-lost = Connection Lost
embedded-daemon-info =
Could not connect to daemon, running in embedded mode.
Please make sure the lactd service is running.
Using embedded mode, you will not be able to change any settings.
service-explanation =
LACT requires a system service in order to apply GPU settings.
It is possible to skip the setup use the application in standalone mode, if you wish to use it only for information and monitoring.
service-connection-status = Connection Status:
service-status = Service Status:
service-not-running = Service is not running
service-permission-denied =
Permission denied, service is not configured to allow connections from your user.
See <a href="https://github.com/ilya-zlobintsev/lact#configuration">GitHub</a> for more information
service-connected = Connected
service-version = Service Version
service-version-mismatch = mismatched
service-logs = Service Logs
service-start = Start
service-stop = Stop
service-restart = Restart
{$error_info}To enable the daemon, run the following command, then restart LACT:
version-mismatch = Version mismatch
version-mismatch-description =
Version mismatch between GUI and Daemon ({$gui_version}-{$gui_commit} vs {$daemon_version}-{$daemon_commit})!
If you have updated LACT, you need to restart the service with:
If you have updated LACT, you need to restart the service.
plot-show-detailed-info = Show detailed info
+75 -69
View File
@@ -12,7 +12,7 @@ mod profiles;
pub(crate) mod utils;
use crate::{
APP_ID, CONFIG, GUI_VERSION, I18N,
APP_ID, CONFIG, I18N,
app::{
about_dialog::{AboutDialog, AboutDialogMsg},
components::loader,
@@ -31,6 +31,10 @@ use crate::{
utils::ext::RelmLaunchable as _,
},
config::WindowSize,
service_setup::{
ServiceSetupDialog, ServiceSetupDialogParams,
systemd::{self, connect_unit_proxy},
},
};
use adw::prelude::*;
use anyhow::{Context, anyhow};
@@ -42,7 +46,7 @@ use gtk::{
use i18n_embed_fl::fl;
use lact_client::{ConnectionStatusMsg, DaemonClient};
use lact_schema::{
DeviceApiInfo, DeviceFlag, DeviceListEntry, DeviceStats, DeviceType, GIT_COMMIT, SystemInfo,
DeviceApiInfo, DeviceFlag, DeviceListEntry, DeviceStats, DeviceType, SystemInfo,
args::GuiArgs,
config::{GpuConfig, Profile},
request::{ConfirmCommand, ProfileBase, SetClocksCommand},
@@ -64,7 +68,7 @@ use relm4::{
css,
loading_widgets::LoadingWidgets,
new_action_group, new_stateless_action,
prelude::{AsyncComponent, AsyncComponentParts},
prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts},
tokio::{self, time::sleep},
view,
};
@@ -155,6 +159,7 @@ impl AsyncComponent for AppModel {
&fl!(I18N, "dump-vbios") => DumpVBiosAction,
},
section! {
"Service Setup" => ServiceSetupAction,
&fl!(I18N, "preferences") => PreferencesAction,
&fl!(I18N, "about") => AboutAction,
},
@@ -357,6 +362,10 @@ impl AsyncComponent for AppModel {
// 5. build child components,
// 6. load profiles and initial GPU data.
let application = root
.application()
.expect("Failed to get application from root window");
relm4::set_global_css_with_priority(
styles::COMBINED_CSS,
STYLE_PROVIDER_PRIORITY_APPLICATION,
@@ -367,23 +376,20 @@ impl AsyncComponent for AppModel {
}
CONFIG.read().color_scheme.apply();
let (daemon_client, conn_err) = match args.tcp_address {
let daemon_client = match args.tcp_address {
Some(remote_addr) => {
info!("establishing connection to {remote_addr}");
match DaemonClient::connect_tcp(&remote_addr).await {
Ok(conn) => (conn, None),
Ok(conn) => conn,
Err(err) => {
error!("TCP connection error: {err:#}");
let (conn, _) = create_connection()
.await
.expect("Could not create fallback connection");
(conn, Some(err))
sender.input(AppMsg::Error(
anyhow!("TCP connection failed, falling back to local: {err:#}").into(),
));
create_connection(&root, &sender).await
}
}
}
None => create_connection()
.await
.expect("Could not establish any daemon connection"),
None => create_connection(&root, &sender).await,
};
let mut conn_status_rx = daemon_client.status_receiver();
@@ -412,22 +418,9 @@ impl AsyncComponent for AppModel {
.expect("Could not list devices");
let initial_gpu_id = AppModel::init_gpu_selection(&devices);
let version_mismatch_info = (system_info.version != GUI_VERSION
|| system_info.commit.as_deref() != Some(GIT_COMMIT))
.then(|| InfoDialogData {
id: InfoDialogId::VersionMismatch,
heading: fl!(I18N, "version-mismatch"),
body: fl!(
I18N,
"version-mismatch-description",
gui_version = GUI_VERSION,
gui_commit = GIT_COMMIT,
daemon_version = system_info.version.as_str(),
daemon_commit = system_info.commit.as_deref().unwrap_or_default()
),
selectable_text: Some("sudo systemctl restart lactd".to_string()),
..Default::default()
});
if !system_info.version.is_current() {
sender.input(AppMsg::ShowServiceSetupDialog);
}
let info_page = InformationPage::detach_default();
@@ -464,14 +457,12 @@ impl AsyncComponent for AppModel {
// create action group and actions for app menu
// action group and actions are declared at the bottom of the file
let mut actions = RelmActionGroup::<AppActionGroup>::new();
let application = root
.application()
.expect("Failed to get application from root window");
setup_actions! {
(actions, ProcessMonitorAction, APP_BROKER.send(AppMsg::ShowProcessMonitor)),
(actions, HistoricalGraphsAction, APP_BROKER.send(AppMsg::ShowGraphsWindow)),
(actions, GenerateDebugSnapshotAction, APP_BROKER.send(AppMsg::DebugSnapshot)),
(actions, PreferencesAction, APP_BROKER.send(AppMsg::ShowPreferencesDialog)),
(actions, ServiceSetupAction, APP_BROKER.send(AppMsg::ShowServiceSetupDialog)),
(actions, AboutAction, APP_BROKER.send(AppMsg::ShowAboutDialog)),
(actions, QuitAction, APP_BROKER.send(AppMsg::Quit)),
}
@@ -551,26 +542,6 @@ impl AsyncComponent for AppModel {
.set_title(&page.title().unwrap_or_default());
}
if let Some(err) = conn_err {
model
.info_dialog
.emit(InfoDialogMsg::Show(Box::new(InfoDialogData {
id: InfoDialogId::EmbeddedDaemonInfo,
heading: fl!(I18N, "daemon-info-heading"),
body: fl!(
I18N,
"embedded-daemon-info",
error_info = format!("Error info: {err:#}\n\n")
),
selectable_text: Some("sudo systemctl enable --now lactd".to_string()),
..Default::default()
})));
}
if let Some(info) = version_mismatch_info {
model.info_dialog.emit(InfoDialogMsg::Show(Box::new(info)));
}
let task_sender = sender.clone();
sender.command(move |_, shutdown| {
shutdown
@@ -691,6 +662,15 @@ impl AppModel {
AppMsg::ShowOverdriveDialog => {
self.overdrive_dialog.emit(OverdriveDialogMsg::Show);
}
AppMsg::ShowServiceSetupDialog => {
let params = ServiceSetupDialogParams {
parent: root.clone().upcast(),
initial_client: Ok((self.daemon_client.clone(), self.system_info.clone())),
unit_proxy: systemd::connect_unit_proxy().await?,
};
let mut controller = ServiceSetupDialog::builder().launch(params).detach();
controller.detach_runtime();
}
AppMsg::SelectProfile {
profile,
auto_switch,
@@ -1435,37 +1415,63 @@ fn start_stats_update_loop(
})
}
async fn create_connection() -> anyhow::Result<(DaemonClient, Option<anyhow::Error>)> {
async fn create_connection(
root: &adw::ApplicationWindow,
sender: &relm4::AsyncComponentSender<AppModel>,
) -> DaemonClient {
match DaemonClient::connect().await {
Ok(connection) => {
debug!("Established daemon connection");
Ok((connection, None))
}
Ok(client) => client,
Err(err) => {
info!("could not connect to socket: {err:#}");
info!("using a local daemon");
let configured_client = match connect_unit_proxy().await {
Ok(unit_proxy) => {
let params = ServiceSetupDialogParams {
parent: root.clone().upcast(),
initial_client: Err(err),
unit_proxy,
};
let service_setup = ServiceSetupDialog::builder().launch(params).into_stream();
let (server_stream, client_stream) = UnixStream::pair()?;
client_stream.set_nonblocking(true)?;
server_stream.set_nonblocking(true)?;
std::thread::spawn(move || {
if let Err(err) = lact_daemon::run_embedded(server_stream) {
error!("Builtin daemon error: {err}");
service_setup
.recv_one()
.await
.expect("Could not get client")
}
});
Err(setup_err) => {
sender.input(AppMsg::Error(anyhow!("Could not connect to daemon: {err:#}\nPlease make sure the daemon is set up and running.\nGuided setup not available: {setup_err}").into()));
None
}
};
let client = DaemonClient::from_stream(client_stream, true)?;
Ok((client, Some(err)))
match configured_client {
Some(client) => client,
None => create_embedded_connection()
.await
.expect("Could not spawn embedded daemon"),
}
}
}
}
async fn create_embedded_connection() -> anyhow::Result<DaemonClient> {
let (server_stream, client_stream) = UnixStream::pair()?;
client_stream.set_nonblocking(true)?;
server_stream.set_nonblocking(true)?;
std::thread::spawn(move || {
if let Err(err) = lact_daemon::run_embedded(server_stream) {
error!("Builtin daemon error: {err}");
}
});
DaemonClient::from_stream(client_stream, true)
}
new_action_group!(pub AppActionGroup, "app");
new_stateless_action!(pub ProcessMonitorAction, AppActionGroup, "show-process-monitor");
new_stateless_action!(pub HistoricalGraphsAction, AppActionGroup, "show-historical-graphs");
new_stateless_action!(pub GenerateDebugSnapshotAction, AppActionGroup, "generate-debug-snapshot");
new_stateless_action!(pub DumpVBiosAction, AppActionGroup, "dump-vbios");
new_stateless_action!(pub ServiceSetupAction, AppActionGroup, "service-setup");
new_stateless_action!(pub PreferencesAction, AppActionGroup, "preferences");
new_stateless_action!(pub AboutAction, AppActionGroup, "about");
new_stateless_action!(pub QuitAction, AppActionGroup, "quit");
-9
View File
@@ -16,9 +16,7 @@ pub enum InfoDialogId {
#[default]
Unknown,
Error,
EmbeddedDaemonInfo,
ResetConfigConfirmation,
VersionMismatch,
}
#[derive(Clone, Debug, Default)]
@@ -27,7 +25,6 @@ pub struct InfoDialogData {
pub heading: String,
pub body: String,
pub stacktrace: Option<String>,
pub selectable_text: Option<String>,
pub confirmation: Option<InfoDialogConfirmation>,
}
@@ -161,12 +158,6 @@ impl relm4::Component for InfoDialogEntry {
set_xalign: 0.0,
},
gtk::Entry {
set_visible: model.data.selectable_text.is_some(),
set_text: model.data.selectable_text.as_deref().unwrap_or_default(),
set_editable: false,
},
gtk::ScrolledWindow {
set_visible: cfg!(debug_assertions) && model.data.stacktrace.is_some(),
set_min_content_width: 600,
+1
View File
@@ -29,6 +29,7 @@ pub enum AppMsg {
ShowPreferencesDialog,
ShowAboutDialog,
ShowOverdriveDialog,
ShowServiceSetupDialog,
EnableOverdrive,
DisableOverdrive,
ResetConfig,
+5 -2
View File
@@ -268,11 +268,14 @@ impl relm4::SimpleComponent for SoftwarePage {
device_api_info: None,
};
let mut daemon_version = format!("{}-{}", system_info.version, system_info.profile);
let mut daemon_version = format!(
"{}-{}",
system_info.version.version, system_info.version.profile
);
if embedded {
daemon_version.push_str("-embedded");
}
if let Some(commit) = &system_info.commit {
if let Some(commit) = &system_info.version.commit {
let daemon_commit_link = format!("{REPO_URL}/commit/{commit}");
write!(
daemon_version,
+1
View File
@@ -1,5 +1,6 @@
mod app;
mod config;
mod service_setup;
use std::{
panic,
+493
View File
@@ -0,0 +1,493 @@
pub mod systemd;
use crate::service_setup::systemd::{START_MODE_REPLACE, UnitProxy};
use crate::{GUI_VERSION, I18N};
use adw::prelude::*;
use anyhow::{Context as _, anyhow};
use i18n_embed_fl::fl;
use lact_client::DaemonClient;
use lact_schema::{GIT_COMMIT, SystemInfo, VersionInfo};
use relm4::css::{self, WARNING};
use relm4::{
AsyncComponentSender, RelmWidgetExt,
css::{ERROR, SUCCESS},
prelude::{AsyncComponent, AsyncComponentParts},
tokio,
};
use std::fmt::Write;
use std::io;
use std::time::Duration;
use tracing::debug;
pub struct ServiceSetupDialog {
connection_status: ConnectionStatus,
unit_proxy: UnitProxy<'static>,
service_logs: gtk::TextBuffer,
service_state: String,
setup_error: Option<anyhow::Error>,
}
pub struct ServiceSetupDialogParams {
pub parent: gtk::ApplicationWindow,
pub initial_client: anyhow::Result<(DaemonClient, SystemInfo)>,
pub unit_proxy: UnitProxy<'static>,
}
#[derive(Debug)]
pub enum ServiceSetupDialogMsg {
Reconnect,
StartService,
RestartService,
StopService,
Close,
}
#[relm4::component(pub, async)]
impl AsyncComponent for ServiceSetupDialog {
type Init = ServiceSetupDialogParams;
type Input = ServiceSetupDialogMsg;
type Output = Option<DaemonClient>;
type CommandOutput = ();
view! {
adw::Dialog {
set_content_width: 500,
set_title: "Service Setup",
connect_closed => ServiceSetupDialogMsg::Close,
#[wrap(Some)]
set_child = &adw::ToolbarView {
add_top_bar = &adw::HeaderBar {},
#[wrap(Some)]
set_content = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 5,
set_margin_horizontal: 15,
set_margin_vertical: 5,
gtk::Label {
set_markup: &fl!(I18N, "service-explanation"),
set_wrap: true,
set_xalign: 0.0,
set_margin_all: 10,
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 5,
set_hexpand: true,
set_margin_horizontal: 10,
gtk::Label {
set_markup: &format!("<b>{}</b>", fl!(I18N, "service-connection-status")),
set_size_group: &label_size_group,
set_xalign: 0.0,
set_yalign: 0.0,
},
gtk::Label {
#[watch]
set_markup: &match &model.connection_status {
ConnectionStatus::Connected {..} => fl!(I18N, "service-connected"),
ConnectionStatus::Error(msg) => msg.clone(),
},
#[watch]
set_css_classes: if model.connection_status.is_connected() { &[SUCCESS] } else { &[ERROR] },
set_selectable: true,
set_hexpand: true,
set_halign: gtk::Align::End,
},
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 5,
set_hexpand: true,
set_margin_horizontal: 10,
gtk::Label {
set_markup: &format!("<b>{}</b>", fl!(I18N, "service-status")),
set_size_group: &label_size_group,
set_xalign: 0.0,
set_yalign: 0.0,
},
gtk::Label {
#[watch]
set_markup: &format!("<tt>{}</tt>", model.service_state),
set_wrap: true,
set_hexpand: true,
set_halign: gtk::Align::End,
},
gtk::MenuButton {
set_icon_name: "utilities-terminal-symbolic",
add_css_class: css::FLAT,
set_valign: gtk::Align::Center,
#[wrap(Some)]
set_popover = &gtk::Popover {
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 5,
set_margin_all: 10,
gtk::Label {
set_label: &fl!(I18N, "service-logs"),
},
gtk::ScrolledWindow {
set_min_content_width: 650,
set_min_content_height: 250,
gtk::TextView {
set_editable: false,
set_buffer: Some(&model.service_logs),
set_top_margin: 5,
set_bottom_margin: 5,
set_left_margin: 5,
set_right_margin: 5,
},
},
},
},
},
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 5,
set_hexpand: true,
set_margin_horizontal: 10,
gtk::Label {
set_markup: &format!("<b>{}</b>", fl!(I18N, "service-version")),
set_size_group: &label_size_group,
set_xalign: 0.0,
set_yalign: 0.0,
},
gtk::Label {
#[watch]
set_markup: &model.service_version_text().unwrap_or_else(|| fl!(I18N, "missing-stat")),
#[watch]
set_css_classes: match &model.connection_status {
ConnectionStatus::Connected { version, .. } => {
if version.is_current() {
&[SUCCESS]
} else {
&[WARNING]
}
}
ConnectionStatus::Error(_) => &[],
},
set_wrap: true,
set_hexpand: true,
set_halign: gtk::Align::End,
},
gtk::MenuButton {
set_icon_name: "dialog-warning-symbolic",
add_css_class: css::FLAT,
set_valign: gtk::Align::Center,
#[watch]
set_visible: model.daemon_version().is_some_and(|version| !version.is_current()),
#[wrap(Some)]
set_popover = &gtk::Popover {
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 5,
set_margin_all: 5,
gtk::Label {
#[watch]
set_label: &fl!(
I18N,
"version-mismatch-description",
gui_version = GUI_VERSION,
gui_commit = GIT_COMMIT,
daemon_version = model.daemon_version().map(|version| version.version.as_str()).unwrap_or_default(),
daemon_commit = model.daemon_version().and_then(|version| version.commit.as_deref()).unwrap_or_default(),
),
},
},
},
}
},
gtk::Label {
#[watch]
set_visible: model.setup_error.is_some(),
#[watch]
set_text: &model.setup_error.as_ref().map(|err| format!("Setup error: {err}")).unwrap_or_default(),
set_css_classes: &[ERROR],
set_selectable: true,
set_hexpand: true,
},
},
add_bottom_bar = &gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 5,
set_halign: gtk::Align::End,
set_margin_horizontal: 25,
set_margin_vertical: 20,
gtk::Button {
set_label: &fl!(I18N, "service-start"),
connect_clicked => ServiceSetupDialogMsg::StartService,
add_css_class: "suggested-action",
#[watch]
set_visible: model.service_state != systemd::UNIT_STATE_ACTIVE,
},
gtk::Button {
set_label: &fl!(I18N, "service-stop"),
connect_clicked => ServiceSetupDialogMsg::StopService,
#[watch]
set_visible: model.service_state == systemd::UNIT_STATE_ACTIVE,
},
gtk::Button {
set_label: &fl!(I18N, "service-restart"),
connect_clicked => ServiceSetupDialogMsg::RestartService,
},
},
},
}
}
async fn init(
params: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let input_sender = sender.input_sender().clone();
relm4::spawn(async move {
loop {
if input_sender.send(ServiceSetupDialogMsg::Reconnect).is_err() {
debug!("service setup dialog closed, exiting client watcher");
break;
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
});
let (service_state, setup_error) = params
.unit_proxy
.active_state()
.await
.map(|state| (state, None))
.unwrap_or_else(|err| {
(
"unknown".to_owned(),
Some(anyhow!("Could not fetch service status: {err}")),
)
});
let service_logs_handle = tokio::spawn(service_logs_text());
let connection_status = match params.initial_client {
Ok((client, info)) => ConnectionStatus::Connected {
client: client.clone(),
version: info.version,
},
Err(err) => ConnectionStatus::from_result(Err(err)).await,
};
let model = Self {
connection_status,
unit_proxy: params.unit_proxy,
service_logs: gtk::TextBuffer::builder()
.text(service_logs_handle.await.unwrap())
.build(),
service_state,
setup_error,
};
let label_size_group = gtk::SizeGroup::new(gtk::SizeGroupMode::Horizontal);
let widgets = view_output!();
root.present(Some(&params.parent));
AsyncComponentParts { model, widgets }
}
async fn update(
&mut self,
msg: Self::Input,
sender: AsyncComponentSender<Self>,
_root: &Self::Root,
) {
if let Err(err) = self.handle_msg(msg, sender).await {
self.setup_error = Some(err);
}
}
}
impl ServiceSetupDialog {
async fn handle_msg(
&mut self,
msg: ServiceSetupDialogMsg,
sender: AsyncComponentSender<Self>,
) -> anyhow::Result<()> {
match msg {
ServiceSetupDialogMsg::Reconnect => (),
ServiceSetupDialogMsg::StartService => {
self.unit_proxy.start(START_MODE_REPLACE).await?;
}
ServiceSetupDialogMsg::RestartService => {
self.unit_proxy.restart(START_MODE_REPLACE).await?;
}
ServiceSetupDialogMsg::StopService => {
self.unit_proxy.stop(START_MODE_REPLACE).await?;
}
ServiceSetupDialogMsg::Close => {
let client = match &self.connection_status {
ConnectionStatus::Connected { client, .. } => Some(client.clone()),
ConnectionStatus::Error(_) => None,
};
let _ = sender.output(client);
return Ok(());
}
}
self.reconnect().await?;
Ok(())
}
async fn reconnect(&mut self) -> anyhow::Result<()> {
let logs_handle = tokio::spawn(service_logs_text());
let mut changed = false;
let new_state = self
.unit_proxy
.active_state()
.await
.context("Could not update unit state")?;
if self.service_state != new_state {
self.service_state = new_state;
changed = true;
}
let client = DaemonClient::connect_with_reconnect(false).await;
let connection_status = ConnectionStatus::from_result(client).await;
changed |= !self.connection_status.roughly_eq(&connection_status);
self.connection_status = connection_status;
let logs = logs_handle.await.unwrap();
let current_text = self.service_logs.slice(
&self.service_logs.start_iter(),
&self.service_logs.end_iter(),
true,
);
if logs != current_text.as_str() {
self.service_logs.set_text(&logs);
}
if changed {
self.setup_error = None;
}
Ok(())
}
fn service_version_text(&self) -> Option<String> {
match &self.connection_status {
ConnectionStatus::Connected { version, .. } => {
let mut text = format!("<tt>{}</tt>", format_version(version));
if !version.is_current() {
write!(text, " ({})", fl!(I18N, "service-version-mismatch")).unwrap();
}
Some(text)
}
ConnectionStatus::Error(_) => None,
}
}
fn daemon_version(&self) -> Option<&VersionInfo> {
match &self.connection_status {
ConnectionStatus::Connected { version, .. } => Some(version),
ConnectionStatus::Error(_) => None,
}
}
}
enum ConnectionStatus {
Connected {
client: DaemonClient,
version: VersionInfo,
},
Error(String),
}
impl ConnectionStatus {
async fn from_result(result: anyhow::Result<DaemonClient>) -> Self {
match result {
Ok(client) => match client.get_system_info().await {
Ok(info) => Self::Connected {
client,
version: info.version,
},
Err(err) => Self::from_err(err),
},
Err(err) => Self::from_err(err),
}
}
fn from_err(err: anyhow::Error) -> Self {
let msg = if let Some(io_err) = err.downcast_ref::<std::io::Error>() {
match io_err.kind() {
io::ErrorKind::NotFound => fl!(I18N, "service-not-running"),
io::ErrorKind::PermissionDenied => fl!(I18N, "service-permission-denied"),
_ => format!("{} (IO {io_err:#})", fl!(I18N, "error-heading")),
}
} else {
format!("{} ({err:#})", fl!(I18N, "error-heading"))
};
Self::Error(msg)
}
fn is_connected(&self) -> bool {
match self {
Self::Connected { .. } => true,
Self::Error(_) => false,
}
}
fn roughly_eq(&self, other: &Self) -> bool {
match (self, other) {
(
ConnectionStatus::Connected {
version: version_l, ..
},
ConnectionStatus::Connected {
version: version_r, ..
},
) => version_l == version_r,
(ConnectionStatus::Error(err_l), ConnectionStatus::Error(err_r)) => err_l == err_r,
_ => false,
}
}
}
fn format_version(version: &VersionInfo) -> String {
format!("{}-{}", version.version, version.profile)
}
async fn service_logs_text() -> String {
systemd::fetch_logs()
.await
.unwrap_or_else(|err| format!("Could not fetch logs: {err:#}"))
}
+91
View File
@@ -0,0 +1,91 @@
//! # D-Bus interface proxy for: `org.freedesktop.systemd1.Manager`
use anyhow::{Context, bail};
use relm4::tokio::process::Command;
use zbus::{proxy, zvariant::OwnedObjectPath};
const UNIT_NAME: &str = "lactd.service";
pub const UNIT_STATE_ACTIVE: &str = "active";
pub const START_MODE_REPLACE: &str = "replace";
pub async fn connect_unit_proxy() -> anyhow::Result<UnitProxy<'static>> {
let conn = zbus::Connection::system()
.await
.context("Could not establish DBus connection")?;
let manager = ManagerProxy::new(&conn)
.await
.context("Could not connect to systemd manager interface")?;
let path = manager
.get_unit(UNIT_NAME)
.await
.context("Could not get lact systemd unit")?;
let unit = UnitProxy::builder(&conn)
.path(path)?
.build()
.await
.context("Could not connect to systemd unit interface")?;
Ok(unit)
}
#[proxy(
interface = "org.freedesktop.systemd1.Manager",
default_service = "org.freedesktop.systemd1",
default_path = "/org/freedesktop/systemd1"
)]
pub trait Manager {
#[zbus(allow_interactive_auth)]
fn get_unit(&self, name: &str) -> zbus::Result<OwnedObjectPath>;
#[zbus(allow_interactive_auth)]
fn enable_unit_files(
&self,
files: &[&str],
runtime: bool,
force: bool,
) -> zbus::Result<(bool, Vec<(String, String, String)>)>;
}
#[proxy(
interface = "org.freedesktop.systemd1.Unit",
default_service = "org.freedesktop.systemd1"
)]
pub trait Unit {
#[zbus(allow_interactive_auth)]
fn restart(&self, mode: &str) -> zbus::Result<OwnedObjectPath>;
#[zbus(allow_interactive_auth)]
fn start(&self, mode: &str) -> zbus::Result<OwnedObjectPath>;
#[zbus(allow_interactive_auth)]
fn stop(&self, mode: &str) -> zbus::Result<OwnedObjectPath>;
#[zbus(property)]
fn active_state(&self) -> zbus::Result<String>;
}
pub async fn fetch_logs() -> anyhow::Result<String> {
let output = Command::new("journalctl")
.args([
"-I",
"--no-hostname",
"--no-pager",
"-o",
"short",
"-u",
UNIT_NAME,
])
.output()
.await
.context("Could not run journalctl")?;
if !output.status.success() {
bail!("journalctl exited with status {}", output.status);
}
String::from_utf8(output.stdout).context("Could not parse journalctl output")
}
+30 -3
View File
@@ -73,15 +73,42 @@ pub struct Pong;
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SystemInfo {
pub version: String,
pub commit: Option<String>,
pub profile: String,
#[serde(flatten)]
pub version: VersionInfo,
pub distro: Option<String>,
pub kernel_version: String,
pub amdgpu_overdrive_enabled: Option<bool>,
pub amdgpu_params_configurator: Option<AmdgpuParamsConfigurator>,
}
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct VersionInfo {
pub version: String,
pub commit: Option<String>,
pub profile: String,
}
impl VersionInfo {
pub fn current() -> Self {
let profile = if cfg!(debug_assertions) {
"debug"
} else {
"release"
}
.to_owned();
Self {
version: env!("CARGO_PKG_VERSION").to_owned(),
commit: Some(GIT_COMMIT.to_owned()),
profile,
}
}
pub fn is_current(&self) -> bool {
*self == Self::current()
}
}
#[skip_serializing_none]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DeviceListEntry {
+1 -1
View File
@@ -17,7 +17,7 @@ fn ping_requset() {
fn pong_response() {
let expected_response = json!({
"status": "ok",
"data": null
"data": null,
});
let response = Response::Ok(Pong);