Skip to main content

runlatch_core/providers/
systemd.rs

1//! systemd provider for both user (session bus) and system (system bus) units.
2//!
3//! A single [`SystemdProvider`] struct handles both scopes — the only differences
4//! are which D-Bus connection to open and which id/scope to report. Use
5//! [`SystemdProvider::user()`] and [`SystemdProvider::system()`] to construct.
6//!
7//! Unit file metadata (Description, ExecStart / timer schedule) is read directly
8//! from disk using the paths returned by `ListUnitFiles`.
9
10use anyhow::{Context, Result, bail};
11use async_trait::async_trait;
12use futures::future::join_all;
13use zbus::{Connection, proxy, zvariant::OwnedObjectPath};
14
15use crate::desktop_file::DesktopFile;
16use crate::model::{AutostartEntry, Scope};
17use crate::provider::AutostartProvider;
18
19/// Unit-file states with an `[Install]` section that can actually be
20/// enabled/disabled. Everything else (`static`, `alias`, `generated`, `transient`,
21/// `bad`) is excluded from the autostart view.
22const ENABLEABLE_STATES: &[&str] = &[
23    "enabled",
24    "enabled-runtime",
25    "disabled",
26    "linked",
27    "linked-runtime",
28    "masked",
29    "masked-runtime",
30    "indirect",
31];
32
33/// Which D-Bus bus — and therefore which systemd instance — the provider talks to.
34#[derive(Debug, Clone, Copy)]
35enum Bus {
36    /// `org.freedesktop.systemd1` on the **session** bus — user units.
37    Session,
38    /// `org.freedesktop.systemd1` on the **system** bus — system units.
39    System,
40}
41
42/// A systemd "unit file change": `(change_type, file_path, source)`.
43type UnitFileChange = (String, String, String);
44
45#[proxy(
46    interface = "org.freedesktop.systemd1.Manager",
47    default_service = "org.freedesktop.systemd1",
48    default_path = "/org/freedesktop/systemd1"
49)]
50trait Manager {
51    fn list_unit_files(&self) -> zbus::Result<Vec<(String, String)>>;
52    fn enable_unit_files(
53        &self,
54        files: &[&str],
55        runtime: bool,
56        force: bool,
57    ) -> zbus::Result<(bool, Vec<UnitFileChange>)>;
58    fn disable_unit_files(
59        &self,
60        files: &[&str],
61        runtime: bool,
62    ) -> zbus::Result<Vec<UnitFileChange>>;
63    fn start_unit(&self, name: &str, mode: &str) -> zbus::Result<OwnedObjectPath>;
64}
65
66/// Provider for systemd units, covering either the user or system scope.
67pub struct SystemdProvider {
68    bus: Bus,
69}
70
71impl SystemdProvider {
72    /// Provider for `--user` units (session bus).
73    pub fn user() -> Self {
74        Self { bus: Bus::Session }
75    }
76
77    /// Provider for system units (system bus). Enable/disable may require
78    /// polkit elevation; listing is always read-only.
79    pub fn system() -> Self {
80        Self { bus: Bus::System }
81    }
82
83    async fn connect(&self) -> Result<Connection> {
84        match self.bus {
85            Bus::Session => Connection::session()
86                .await
87                .context("connecting to the session bus"),
88            Bus::System => Connection::system()
89                .await
90                .context("connecting to the system bus"),
91        }
92    }
93
94    async fn manager(&self) -> Result<ManagerProxy<'static>> {
95        let conn = self.connect().await?;
96        ManagerProxy::new(&conn)
97            .await
98            .context("building systemd1 manager proxy")
99    }
100}
101
102#[async_trait]
103impl AutostartProvider for SystemdProvider {
104    fn id(&self) -> &'static str {
105        match self.bus {
106            Bus::Session => "systemd-user",
107            Bus::System => "systemd-system",
108        }
109    }
110
111    fn scope(&self) -> Scope {
112        match self.bus {
113            Bus::Session => Scope::User,
114            Bus::System => Scope::System,
115        }
116    }
117
118    async fn is_available(&self) -> bool {
119        self.manager().await.is_ok()
120    }
121
122    async fn entries(&self) -> Result<Vec<AutostartEntry>> {
123        let manager = self.manager().await?;
124        let unit_files = manager
125            .list_unit_files()
126            .await
127            .context("listing systemd unit files")?;
128
129        let enableable: Vec<(String, String)> = unit_files
130            .into_iter()
131            .filter(|(_, state)| ENABLEABLE_STATES.contains(&state.as_str()))
132            .collect();
133
134        let reads = enableable
135            .iter()
136            .map(|(path, _)| async move { tokio::fs::read_to_string(path).await.ok() });
137        let file_texts = join_all(reads).await;
138
139        let source = self.id().to_string();
140        let scope = self.scope();
141        let entries = enableable
142            .iter()
143            .zip(file_texts)
144            .map(|((path, state), text)| {
145                let name = unit_name(path);
146                let enabled = matches!(state.as_str(), "enabled" | "enabled-runtime");
147                let (description, command) =
148                    text.as_deref().map(unit_file_meta).unwrap_or_default();
149                AutostartEntry {
150                    id: name.clone(),
151                    display_name: description.clone().unwrap_or_else(|| name.clone()),
152                    description,
153                    command: command.unwrap_or_default(),
154                    icon: None,
155                    enabled,
156                    source: source.clone(),
157                    scope,
158                }
159            })
160            .collect();
161
162        Ok(entries)
163    }
164
165    async fn enable(&self, id: &str) -> Result<()> {
166        self.manager()
167            .await?
168            .enable_unit_files(&[id], false, true)
169            .await
170            .with_context(|| format!("enabling unit '{id}'"))?;
171        Ok(())
172    }
173
174    async fn disable(&self, id: &str) -> Result<()> {
175        self.manager()
176            .await?
177            .disable_unit_files(&[id], false)
178            .await
179            .with_context(|| format!("disabling unit '{id}'"))?;
180        Ok(())
181    }
182
183    async fn add(&self, _entry: &AutostartEntry) -> Result<()> {
184        bail!(
185            "adding units is not supported for {} in this pass",
186            self.id()
187        );
188    }
189
190    async fn remove(&self, _id: &str) -> Result<()> {
191        bail!(
192            "removing units is not supported for {} in this pass",
193            self.id()
194        );
195    }
196}
197
198fn unit_name(path: &str) -> String {
199    path.rsplit('/').next().unwrap_or(path).to_string()
200}
201
202/// Extract `Description` from `[Unit]` and the primary command from `[Service]`
203/// or schedule from `[Timer]`.
204fn unit_file_meta(text: &str) -> (Option<String>, Option<String>) {
205    let file = DesktopFile::parse(text);
206    let description = file
207        .get("Unit", "Description")
208        .filter(|s| !s.is_empty())
209        .map(str::to_string);
210
211    // Services: ExecStart.
212    // Timers: no ExecStart — show the schedule (OnCalendar is most readable).
213    // Sockets: show the listen address so the row isn't blank.
214    let command = file
215        .get("Service", "ExecStart")
216        .or_else(|| file.get("Timer", "OnCalendar"))
217        .or_else(|| file.get("Timer", "OnBootSec"))
218        .or_else(|| file.get("Timer", "OnStartupSec"))
219        .or_else(|| file.get("Timer", "OnUnitActiveSec"))
220        .or_else(|| file.get("Timer", "OnActiveSec"))
221        .or_else(|| file.get("Socket", "ListenStream"))
222        .or_else(|| file.get("Socket", "ListenDatagram"))
223        .or_else(|| file.get("Socket", "ListenSequentialPacket"))
224        .or_else(|| file.get("Socket", "ListenFIFO"))
225        .filter(|s| !s.is_empty())
226        .map(str::to_string);
227
228    (description, command)
229}