runlatch_core/providers/
systemd.rs1use 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
19const 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#[derive(Debug, Clone, Copy)]
35enum Bus {
36 Session,
38 System,
40}
41
42type 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
66pub struct SystemdProvider {
68 bus: Bus,
69}
70
71impl SystemdProvider {
72 pub fn user() -> Self {
74 Self { bus: Bus::Session }
75 }
76
77 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
202fn 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 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}