diff --git a/Cargo.lock b/Cargo.lock index ffc6dc9f..4d4e8ec5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 33884bbb..4d1b5f72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 93438a81..1fcb356c 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/lact-client/src/connection/unix.rs b/lact-client/src/connection/unix.rs index aefb456c..c8ccde0f 100644 --- a/lact-client/src/connection/unix.rs +++ b/lact-client/src/connection/unix.rs @@ -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, @@ -12,7 +12,7 @@ pub struct UnixConnection { impl UnixConnection { pub async fn connect(path: &Path) -> anyhow::Result> { - 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), diff --git a/lact-client/src/lib.rs b/lact-client/src/lib.rs index 1e167ae9..9c9e26f9 100644 --- a/lact-client/src/lib.rs +++ b/lact-client/src/lib.rs @@ -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>>, status_tx: broadcast::Sender, + reconnect: bool, pub embedded: bool, } impl DaemonClient { pub async fn connect() -> anyhow::Result { - 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 { + 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 { + self.make_request(Request::Ping).await + } + pub async fn list_devices(&self) -> anyhow::Result> { 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 { let root_path = PathBuf::from("/run/lactd.sock"); diff --git a/lact-daemon/Cargo.toml b/lact-daemon/Cargo.toml index 9146ca0f..11c1a7a9 100644 --- a/lact-daemon/Cargo.toml +++ b/lact-daemon/Cargo.toml @@ -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", diff --git a/lact-daemon/src/system.rs b/lact-daemon/src/system.rs index 0c9a7e60..ae59de1e 100644 --- a/lact-daemon/src/system.rs +++ b/lact-daemon/src/system.rs @@ -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 = LazyLock::new(|| { }); pub async fn info() -> anyhow::Result { - 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 { 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, diff --git a/lact-gui/Cargo.toml b/lact-gui/Cargo.toml index 966d745b..042eb46f 100644 --- a/lact-gui/Cargo.toml +++ b/lact-gui/Cargo.toml @@ -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", diff --git a/lact-gui/i18n/en/lact_gui.ftl b/lact-gui/i18n/en/lact_gui.ftl index 8bf32d02..fadccc73 100644 --- a/lact-gui/i18n/en/lact_gui.ftl +++ b/lact-gui/i18n/en/lact_gui.ftl @@ -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 GitHub 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 diff --git a/lact-gui/src/app.rs b/lact-gui/src/app.rs index cfe82272..0cb487f5 100644 --- a/lact-gui/src/app.rs +++ b/lact-gui/src/app.rs @@ -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::::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)> { +async fn create_connection( + root: &adw::ApplicationWindow, + sender: &relm4::AsyncComponentSender, +) -> 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 { + 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"); diff --git a/lact-gui/src/app/info_dialog.rs b/lact-gui/src/app/info_dialog.rs index 12510e2b..7c7baa1a 100644 --- a/lact-gui/src/app/info_dialog.rs +++ b/lact-gui/src/app/info_dialog.rs @@ -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, - pub selectable_text: Option, pub confirmation: Option, } @@ -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, diff --git a/lact-gui/src/app/msg.rs b/lact-gui/src/app/msg.rs index 3cdc179c..47a626c3 100644 --- a/lact-gui/src/app/msg.rs +++ b/lact-gui/src/app/msg.rs @@ -29,6 +29,7 @@ pub enum AppMsg { ShowPreferencesDialog, ShowAboutDialog, ShowOverdriveDialog, + ShowServiceSetupDialog, EnableOverdrive, DisableOverdrive, ResetConfig, diff --git a/lact-gui/src/app/pages/software_page.rs b/lact-gui/src/app/pages/software_page.rs index e8082255..f1092912 100644 --- a/lact-gui/src/app/pages/software_page.rs +++ b/lact-gui/src/app/pages/software_page.rs @@ -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, 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..456df509 --- /dev/null +++ b/lact-gui/src/service_setup.rs @@ -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, +} + +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; + 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 = >k::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!("{}", 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!("{}", fl!(I18N, "service-status")), + set_size_group: &label_size_group, + set_xalign: 0.0, + set_yalign: 0.0, + }, + + gtk::Label { + #[watch] + set_markup: &format!("{}", 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 = >k::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!("{}", 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 = >k::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 = >k::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, + ) -> AsyncComponentParts { + 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(¶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 { + self.setup_error = Some(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::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 { + match &self.connection_status { + ConnectionStatus::Connected { version, .. } => { + let mut text = format!("{}", 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) -> 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::() { + 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:#}")) +} diff --git a/lact-gui/src/service_setup/systemd.rs b/lact-gui/src/service_setup/systemd.rs new file mode 100644 index 00000000..e8662466 --- /dev/null +++ b/lact-gui/src/service_setup/systemd.rs @@ -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> { + 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; +} + +pub async fn fetch_logs() -> anyhow::Result { + 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") +} diff --git a/lact-schema/src/lib.rs b/lact-schema/src/lib.rs index b131d5df..33ba2ac3 100644 --- a/lact-schema/src/lib.rs +++ b/lact-schema/src/lib.rs @@ -73,15 +73,42 @@ pub struct Pong; #[skip_serializing_none] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SystemInfo { - pub version: String, - pub commit: Option, - pub profile: String, + #[serde(flatten)] + pub version: VersionInfo, pub distro: Option, pub kernel_version: String, pub amdgpu_overdrive_enabled: Option, pub amdgpu_params_configurator: Option, } +#[skip_serializing_none] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct VersionInfo { + pub version: String, + pub commit: Option, + 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 { diff --git a/lact-schema/src/tests.rs b/lact-schema/src/tests.rs index 8da4986e..56c8a06e 100644 --- a/lact-schema/src/tests.rs +++ b/lact-schema/src/tests.rs @@ -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);