From 915e10efc1da3979ad9d1250179bf77e56412198 Mon Sep 17 00:00:00 2001 From: Ilya Zlobintsev Date: Tue, 26 May 2026 21:41:28 +0300 Subject: [PATCH] feat: WIP service setup GUI --- Cargo.lock | 2 + Cargo.toml | 1 + lact-client/src/lib.rs | 10 +- lact-daemon/Cargo.toml | 2 +- lact-gui/Cargo.toml | 3 + lact-gui/src/app.rs | 128 +++++++++------ lact-gui/src/app/msg.rs | 1 + lact-gui/src/lib.rs | 1 + lact-gui/src/service_setup.rs | 222 ++++++++++++++++++++++++++ lact-gui/src/service_setup/systemd.rs | 70 ++++++++ 10 files changed, 393 insertions(+), 47 deletions(-) create mode 100644 lact-gui/src/service_setup.rs create mode 100644 lact-gui/src/service_setup/systemd.rs diff --git a/Cargo.lock b/Cargo.lock index 748b63c5..bff50bb4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 8713f98f..b3771a95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/lact-client/src/lib.rs b/lact-client/src/lib.rs index 2076a6b2..47fae295 100644 --- a/lact-client/src/lib.rs +++ b/lact-client/src/lib.rs @@ -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 { let root_path = PathBuf::from("/run/lactd.sock"); diff --git a/lact-daemon/Cargo.toml b/lact-daemon/Cargo.toml index 6055bb38..92babe2e 100644 --- a/lact-daemon/Cargo.toml +++ b/lact-daemon/Cargo.toml @@ -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", ] } diff --git a/lact-gui/Cargo.toml b/lact-gui/Cargo.toml index 37c08b3f..05eed52d 100644 --- a/lact-gui/Cargo.toml +++ b/lact-gui/Cargo.toml @@ -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", diff --git a/lact-gui/src/app.rs b/lact-gui/src/app.rs index 255cf5fe..5e86241d 100644 --- a/lact-gui/src/app.rs +++ b/lact-gui/src/app.rs @@ -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::::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)> { - match DaemonClient::connect().await { - Ok(connection) => { - debug!("Established daemon connection"); - Ok((connection, None)) +async fn create_embedded_connection() -> anyhow::Result { + 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"); diff --git a/lact-gui/src/app/msg.rs b/lact-gui/src/app/msg.rs index ee48b1e3..c0a506b3 100644 --- a/lact-gui/src/app/msg.rs +++ b/lact-gui/src/app/msg.rs @@ -28,6 +28,7 @@ pub enum AppMsg { ShowPreferencesDialog, ShowAboutDialog, ShowOverdriveDialog, + ShowServiceSetupDialog, EnableOverdrive, DisableOverdrive, ResetConfig, diff --git a/lact-gui/src/lib.rs b/lact-gui/src/lib.rs index 631e6718..7b0deb83 100644 --- a/lact-gui/src/lib.rs +++ b/lact-gui/src/lib.rs @@ -1,5 +1,6 @@ mod app; mod config; +mod service_setup; use std::{ panic, diff --git a/lact-gui/src/service_setup.rs b/lact-gui/src/service_setup.rs new file mode 100644 index 00000000..e6f7183f --- /dev/null +++ b/lact-gui/src/service_setup.rs @@ -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, + 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; + 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 = >k::Box { + set_orientation: gtk::Orientation::Vertical, + set_spacing: 10, + set_margin_all: 10, + + gtk::Label { + #[watch] + set_markup: &format!("Service Status: {}", model.service_state), + }, + + gtk::Label { + #[watch] + set_markup: &format!("Connection ok: {}", 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 = >k::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, + ) -> AsyncComponentParts { + 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(¶ms.parent)); + + AsyncComponentParts { model, widgets } + } + + async fn update( + &mut self, + msg: Self::Input, + sender: AsyncComponentSender, + _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, + ) -> 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(()) + } +} diff --git a/lact-gui/src/service_setup/systemd.rs b/lact-gui/src/service_setup/systemd.rs new file mode 100644 index 00000000..81a7a181 --- /dev/null +++ b/lact-gui/src/service_setup/systemd.rs @@ -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> { + 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; + + #[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; + + #[zbus(allow_interactive_auth)] + fn start(&self, mode: &str) -> zbus::Result; + + #[zbus(allow_interactive_auth)] + fn stop(&self, mode: &str) -> zbus::Result; + + #[zbus(property)] + fn active_state(&self) -> zbus::Result; +}