feat: WIP service setup GUI

This commit is contained in:
Ilya Zlobintsev
2026-05-26 21:43:29 +03:00
parent 60e81fdb30
commit 915e10efc1
10 changed files with 393 additions and 47 deletions
Generated
+2
View File
@@ -1743,6 +1743,7 @@ dependencies = [
"anyhow",
"cairo-rs",
"divan",
"futures",
"gtk4",
"i18n-embed",
"i18n-embed-fl",
@@ -1765,6 +1766,7 @@ dependencies = [
"thread-priority",
"tracing",
"tracing-subscriber",
"zbus",
]
[[package]]
+1
View File
@@ -39,6 +39,7 @@ i18n-embed = { version = "0.16.0", features = [
] }
i18n-embed-fl = "0.10.0"
rust-embed = { version = "8.11.0", features = ["debug-embed"] }
zbus = { version = "5.14.0", default-features = false, features = ["tokio"] }
[profile.release]
strip = "symbols"
+9 -1
View File
@@ -19,7 +19,7 @@ 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, os::unix::net::UnixStream, path::PathBuf, pin::Pin, rc::Rc, time::Duration,
};
use tokio::{
net::ToSocketAddrs,
@@ -222,6 +222,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
@@ -36,7 +36,7 @@ serde_norway = { workspace = true }
nvml-wrapper = "0.12.1"
bitflags = "2.11.1"
pciid-parser = { version = "0.8", features = ["serde"] }
zbus = { version = "5.14.0", default-features = false, features = ["tokio"] }
zbus = { workspace = true }
libdrm_amdgpu_sys = { version = "0.8.13", default-features = false, features = [
"dynamic_loading",
] }
+3
View File
@@ -36,6 +36,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 = [
"line_series",
"full_palette",
+83 -45
View File
@@ -35,6 +35,10 @@ use crate::{
},
},
config::WindowSize,
service_setup::{
ServiceSetupDialog, ServiceSetupDialogParams,
systemd::{self, connect_unit_proxy},
},
};
use adw::prelude::*;
use anyhow::{Context, anyhow};
@@ -69,8 +73,8 @@ use relm4::{
css,
loading_widgets::LoadingWidgets,
new_action_group, new_stateless_action,
prelude::{AsyncComponent, AsyncComponentParts},
tokio::{self, time::sleep},
prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts},
tokio::{self, sync::oneshot, time::sleep},
view,
};
use relm4_components::{
@@ -156,6 +160,7 @@ impl AsyncComponent for AppModel {
&fl!(I18N, "dump-vbios") => DumpVBiosAction,
},
section! {
"Service Setup" => ServiceSetupAction,
&fl!(I18N, "preferences") => PreferencesAction,
&fl!(I18N, "about") => AboutAction,
},
@@ -364,6 +369,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,
@@ -373,7 +382,40 @@ impl AsyncComponent for AppModel {
error!("could not apply theme: {err:#}");
}
let (daemon_client, conn_err) = match args.tcp_address {
let daemon_client = match DaemonClient::connect().await {
Ok(client) => client,
Err(err) => {
let configured_client = match connect_unit_proxy().await {
Ok(unit_proxy) => {
let params = ServiceSetupDialogParams {
parent: root.clone().upcast(),
initial_error: err,
unit_proxy,
};
let service_setup =
ServiceSetupDialog::builder().launch(params).into_stream();
service_setup
.recv_one()
.await
.expect("Could not get client")
}
Err(_err) => {
// TODO: show error about no systemd
None
}
};
match configured_client {
Some(client) => client,
None => create_embedded_connection()
.await
.expect("Could not spawn embedded daemon"),
}
}
};
/*let (daemon_client, conn_err) = match args.tcp_address {
Some(remote_addr) => {
info!("establishing connection to {remote_addr}");
match DaemonClient::connect_tcp(&remote_addr).await {
@@ -390,7 +432,7 @@ impl AsyncComponent for AppModel {
None => create_connection()
.await
.expect("Could not establish any daemon connection"),
};
};*/
let mut conn_status_rx = daemon_client.status_receiver();
relm4::spawn_local(clone!(
@@ -468,13 +510,11 @@ 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, 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)),
}
@@ -550,21 +590,21 @@ 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(err) = conn_err {
// model
// .info_dialog
// .emit(InfoDialogMsg::Show(Box::new(InfoDialogData {
// id: InfoDialogId::EmbeddedDaemonInfo,
// heading: fl!(I19N, "daemon-info-heading"),
// body: fl!(
// I19N,
// "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)));
@@ -673,6 +713,15 @@ impl AppModel {
AppMsg::ShowOverdriveDialog => {
self.overdrive_dialog.emit(OverdriveDialogMsg::Show);
}
AppMsg::ShowServiceSetupDialog => {
let params = ServiceSetupDialogParams {
parent: root.clone().upcast(),
initial_error: anyhow!("TODO"),
unit_proxy: systemd::connect_unit_proxy().await?,
};
let mut controller = ServiceSetupDialog::builder().launch(params).detach();
controller.detach_runtime();
}
AppMsg::SelectProfile {
profile,
auto_switch,
@@ -877,7 +926,7 @@ impl AppModel {
}
AppMsg::ConnectionStatus(status) => match status {
ConnectionStatusMsg::Disconnected => {
widgets.reconnecting_dialog.present(Some(root))
// widgets.reconnecting_dialog.present(Some(root))
}
ConnectionStatusMsg::Reconnected => widgets.reconnecting_dialog.force_close(),
},
@@ -1396,36 +1445,25 @@ fn start_stats_update_loop(
})
}
async fn create_connection() -> anyhow::Result<(DaemonClient, Option<anyhow::Error>)> {
match DaemonClient::connect().await {
Ok(connection) => {
debug!("Established daemon connection");
Ok((connection, None))
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}");
}
Err(err) => {
info!("could not connect to socket: {err:#}");
info!("using a local daemon");
});
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}");
}
});
let client = DaemonClient::from_stream(client_stream, true)?;
Ok((client, Some(err)))
}
}
Ok(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 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");
+1
View File
@@ -28,6 +28,7 @@ pub enum AppMsg {
ShowPreferencesDialog,
ShowAboutDialog,
ShowOverdriveDialog,
ShowServiceSetupDialog,
EnableOverdrive,
DisableOverdrive,
ResetConfig,
+1
View File
@@ -1,5 +1,6 @@
mod app;
mod config;
mod service_setup;
use std::{
panic,
+222
View File
@@ -0,0 +1,222 @@
pub mod systemd;
use std::time::Duration;
use crate::service_setup::systemd::{START_MODE_REPLACE, UnitProxy};
use adw::prelude::*;
use anyhow::{Context as _, anyhow};
use futures::StreamExt as _;
use lact_client::DaemonClient;
use relm4::{
AsyncComponentSender, RelmWidgetExt,
prelude::{AsyncComponent, AsyncComponentParts},
tokio,
};
use tracing::{debug, warn};
pub struct ServiceSetupDialog {
current_client: anyhow::Result<DaemonClient>,
unit_proxy: UnitProxy<'static>,
service_state: String,
}
pub struct ServiceSetupDialogParams {
pub parent: gtk::ApplicationWindow,
pub initial_error: anyhow::Error,
pub unit_proxy: UnitProxy<'static>,
}
#[derive(Debug)]
pub enum ServiceSetupDialogMsg {
Reconnect,
StartService,
RestartService,
StopService,
ServiceState(String),
Close,
// Show,
}
#[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_follows_content_size: true,
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: 10,
set_margin_all: 10,
gtk::Label {
#[watch]
set_markup: &format!("Service Status: <tt>{}</tt>", model.service_state),
},
gtk::Label {
#[watch]
set_markup: &format!("Connection ok: <tt>{}</tt>", model.current_client.is_ok()),
},
gtk::Button {
set_label: "Start Service",
connect_clicked => ServiceSetupDialogMsg::StartService,
},
gtk::Button {
set_label: "Stop Service",
connect_clicked => ServiceSetupDialogMsg::StopService,
},
gtk::Button {
set_label: "Restart Service",
connect_clicked => ServiceSetupDialogMsg::RestartService,
},
},
add_bottom_bar = &gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 10,
set_halign: gtk::Align::Fill,
set_margin_horizontal: 10,
set_margin_bottom: 10,
gtk::Button {
set_halign: gtk::Align::End,
set_hexpand: true,
set_label: "Close",
connect_clicked[root] => move |_| {
root.close();
}
},
},
},
}
}
async fn init(
params: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let mut state_stream = params.unit_proxy.receive_active_state_changed().await;
let input_sender = sender.input_sender().clone();
relm4::spawn(async move {
while let Some(property) = state_stream.next().await {
match property.get().await {
Ok(state) => {
if input_sender
.send(ServiceSetupDialogMsg::ServiceState(state))
.is_err()
{
debug!("service setup dialog closed, exiting service state watcher");
break;
}
}
Err(err) => {
warn!("could not get service state: {err:#}");
}
}
}
});
let input_sender = sender.input_sender().clone();
relm4::spawn(async move {
loop {
tokio::time::sleep(Duration::from_millis(250)).await;
if input_sender.send(ServiceSetupDialogMsg::Reconnect).is_err() {
debug!("service setup dialog closed, exiting client watcher");
break;
}
}
});
let service_state = params
.unit_proxy
.active_state()
.await
.unwrap_or_else(|err| {
// TODO: show error, APP_BROKER does not work yet because app is not initialized
// APP_BROKER.send(AppMsg::Error(Arc::new(anyhow!("systemd error: {err:#}"))));
panic!("{err:#}");
});
let model = Self {
current_client: Err(params.initial_error),
unit_proxy: params.unit_proxy,
service_state,
};
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 {
// TODO
panic!("{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::ServiceState(state) => {
self.service_state = state;
}
ServiceSetupDialogMsg::Close => {
let client = self.current_client.as_ref().ok().cloned();
sender.output(client).unwrap();
return Ok(());
}
}
self.reconnect().await?;
Ok(())
}
async fn reconnect(&mut self) -> anyhow::Result<()> {
self.current_client = DaemonClient::connect().await;
Ok(())
}
}
+70
View File
@@ -0,0 +1,70 @@
//! # D-Bus interface proxy for: `org.freedesktop.systemd1.Manager`
use anyhow::Context;
use zbus::{proxy, zvariant::OwnedObjectPath};
const UNIT_NAME: &str = "lactd.service";
pub const UNIT_STATE_ACTIVE: &str = "active";
pub const UNIT_STATE_INACTIVE: &str = "inactive";
pub const UNIT_STATE_FAILED: &str = "failed";
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>;
}