git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/pages/services.rs (23.5K)

  1 use crate::app::{form_button, AppAction, PageContent};
  2 use cce_ui::context::UiContext;
  3 use cce_ui::widget::Handle;
  4 use cce_ui::widget::ScrollRegion;
  5 use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
  6 use cce_ui::scene::layout::Rect;
  7 use cce_ui::widget::{TextBox, InteractiveListItem};
  8 
  9 #[derive(Debug, Clone)]
 10 pub struct ServiceInfo {
 11     pub name: String,
 12     pub description: String,
 13     pub active_state: String,
 14     pub sub_state: String,
 15     pub is_system: bool,
 16 }
 17 
 18 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 19 pub enum ServiceTab {
 20     System,
 21     User,
 22 }
 23 
 24 impl Default for ServiceTab {
 25     fn default() -> Self {
 26         ServiceTab::System
 27     }
 28 }
 29 
 30 #[derive(Debug, Clone)]
 31 pub struct ServicesState {
 32     pub loaded: bool,
 33     pub services: Vec<ServiceInfo>,
 34     pub active_tab: ServiceTab,
 35     pub search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 36     pub list: ScrollRegion,
 37     pub items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
 38 }
 39 
 40 impl Default for ServicesState {
 41     fn default() -> Self {
 42         Self {
 43             loaded: false,
 44             services: Vec::new(),
 45             active_tab: ServiceTab::System,
 46             search_box: Handle::none(),
 47             list: ScrollRegion::new(36.0, 6.0).with_frame(false).with_sink_behind(true),
 48             items: Vec::new(),
 49         }
 50     }
 51 }
 52 
 53 impl ServicesState {
 54     /// The page's state, its widgets inserted into `ctx`.
 55     pub fn new(ctx: &mut UiContext) -> Self {
 56         Self {
 57             search_box: ctx.insert(TextBox::new(String::new()).with_label("Filter Services")),
 58             ..Self::default()
 59         }
 60     }
 61 }
 62 
 63 #[derive(Debug, Clone)]
 64 pub enum ServicesMessage {
 65     Refreshed(Vec<ServiceInfo>),
 66     SetTab(ServiceTab),
 67     Start(String, bool),
 68     Stop(String, bool),
 69     Restart(String, bool),
 70 }
 71 
 72 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 73 /// The least room the list keeps on a short window.
 74 const LIST_MIN_H: f32 = 120.0;
 75 
 76 pub fn view(state: &mut ServicesState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
 77     let mut final_pc = PageContent::new();
 78     let sec_w = 320.0f32;
 79     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 80 
 81     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
 82         let sec_w = sec.cw;
 83         let mut form = sec.form();
 84         if !state.loaded {
 85             form.column().text("Loading systemd services...", 12.0, TEXT_DIM);
 86             sec.place(form, ctx);
 87             return;
 88         }
 89         // The list fills the page: the well's floor lands at the page's bottom.
 90         form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
 91 
 92         let tab_colors = |on: bool| {
 93             let face = if on { [0.20, 0.40, 0.65, 0.4] } else { [0.10, 0.10, 0.16, 0.3] };
 94             (face, [0.20, 0.20, 0.25, 0.15], [0.90, 0.90, 0.95, 1.0])
 95         };
 96         let (label1, label2) = if sec_w < 250.0 { ("System", "User") } else { ("System Services", "User Services") };
 97 
 98         // Filter services
 99         let query = if ctx[state.search_box].editing {
100             ctx[state.search_box].edit_buffer.to_lowercase()
101         } else {
102             ctx[state.search_box].text.to_lowercase()
103         };
104         let filtered_services: Vec<&ServiceInfo> = state.services.iter()
105             .filter(|s| s.is_system == (state.active_tab == ServiceTab::System))
106             .filter(|s| s.name.to_lowercase().contains(&query) || s.description.to_lowercase().contains(&query))
107             .collect();
108         if state.items.len() != filtered_services.len() {
109             for h in state.items.drain(..) {
110                 ctx.remove(h);
111             }
112             for _ in 0..filtered_services.len() {
113                 state.items.push(ctx.insert(InteractiveListItem::new("")));
114             }
115         }
116 
117         let active_tab = state.active_tab;
118         let list = &mut state.list;
119         let items = &state.items;
120         let mut col = form.column();
121         col.row(|r| {
122             form_button(r, label1, 0.0, tab_colors(active_tab == ServiceTab::System),
123                 AppAction::Services(ServicesMessage::SetTab(ServiceTab::System)));
124             form_button(r, label2, 0.0, tab_colors(active_tab == ServiceTab::User),
125                 AppAction::Services(ServicesMessage::SetTab(ServiceTab::User)));
126         });
127         col.widget_h(ctx, state.search_box, cce_ui::layout::textbox_height());
128         col.fill(LIST_MIN_H, move |pc, rect, ctx| {
129             let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
130             // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
131             list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
132             list.update_bounds(filtered_services.len(), list_box_y, list_box_h);
133             list.push_prims(pc);
134             let item_h = list.item_height;
135 
136             pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
137                 for (idx, service) in filtered_services.iter().enumerate() {
138                     if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
139                         // Transport + Restart, on the LEFT where the status dot
140                         // used to be: the dot was reporting what the transport icon
141                         // already says (play = stopped, stop = running), so the
142                         // controls take the column it was using and the name/
143                         // description run from there to the row's right inset.
144                         //
145                         // Sized on whether the icon set is actually THERE:
146                         // `button_icon` falls back to the labels when it isn't, and
147                         // a square button doesn't clip a label so much as replace it
148                         // — the text centers, so both ends cut and "Restart" reads
149                         // "sta". `upload_icon` caches per (name, px), so asking
150                         // every row costs one hash lookup.
151                         let icons_ok = cce_ui::upload_icon("play", 32).is_some();
152                         let btn_h = cce_ui::layout::button_height();
153                         // Without the icon set the controls are word buttons, as wide as
154                         // their words.
155                         let (btn_w, r_btn_w) = if icons_ok {
156                             (btn_h, btn_h)
157                         } else {
158                             (crate::app::button_need("Start").max(crate::app::button_need("Stop")), crate::app::button_need("Restart"))
159                         };
160                         // The controls, then the name and description to the row's end
161                         // (no gutter for the scrollbar, which rides the list's centre line).
162                         let row = Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h };
163                         let cells = lay_row(row, &[Cell::fixed(btn_w, btn_h), Cell::fixed(r_btn_w, btn_h), Cell::grow(item_h)]);
164                         let (toggle_x, restart_x, btn_y) = (cells[0].x, cells[1].x, cells[0].y);
165                         let (item_x, item_w) = (cells[2].x, cells[2].width);
166 
167                         // Service description, truncated to the room the row's text
168                         // column actually has (the item insets its labels from its walls).
169                         let text_max_w = item_w - 2.0 * cce_ui::layout::CONTROL_TEXT_INSET;
170                         let max_chars = ((text_max_w / 6.0) as usize).max(10);
171                         // `failed` was the status dot's third state and has nowhere
172                         // else to show: a failed unit offers the same play button a
173                         // cleanly stopped one does, so without this the row gives no
174                         // sign it died. It leads the line, and it goes in BEFORE the
175                         // truncation so a long description can't be what pushes the
176                         // marker off the end.
177                         let failed = service.active_state == "failed" || service.sub_state == "failed";
178                         // Char-boundary safe: the byte slice this replaced would
179                         // panic whenever the cut landed inside a multi-byte
180                         // character, and unit descriptions are free text.
181                         let desc_truncated = cce_ui::widget::display::truncate_tail(
182                             &description_line(failed, &service.description),
183                             max_chars,
184                         );
185 
186                         // Render InteractiveListItem background and text labels
187                         // Rows dispatch as extra roots (the dissolved list is no parent).
188                         let item = items[idx];
189                         let item_btn = &mut ctx[item];
190                         item_btn.title = service.name.clone();
191                         item_btn.subtitle = Some(desc_truncated);
192                         render_widget_h(pc, item, item_x, draw_y, item_w, item_h, ctx);
193 
194                         let active_txt = [0.90, 0.90, 0.95, 1.0];
195                         // No per-action tints: both controls wear the DE's themed
196                         // button face, and what they DO is carried by the icon.
197                         // (The toolkit picks these itself for a `Button` with no
198                         // override; this host paints buttons from its own collected
199                         // colours, so it has to ask for them.)
200                         let face = cce_ui::colors::button_background_color();
201                         let face_hover = cce_ui::colors::button_hover_color();
202 
203                         // ONE transport button, showing the action it will take:
204                         // play on a stopped service, stop on a running one. The two
205                         // dimmed half-buttons this replaced were never both live —
206                         // exactly one of them did anything on any given row.
207                         //
208                         // `running` is NOT the `is_active` the status dot reads.
209                         // `update` sets a transitional state the instant the button
210                         // is clicked (activating/starting, deactivating/stopping),
211                         // and counting those as the state they are heading for is
212                         // what flips this icon under the pointer instead of leaving
213                         // it stale until the next refresh lands. The dot keeps
214                         // reporting the confirmed state: the button says what it
215                         // will do, the dot says what is true.
216                         let running = transport_running(&service.active_state, &service.sub_state);
217 
218                         // Fallback labels only — an icon face never draws them.
219                         // Without the icons a row says it in WORDS, narrow or
220                         // not: a symbol drawn as a character is exactly what the
221                         // icon set exists to replace, so the buttons keep the
222                         // width a word needs instead.
223                         let (start_lbl, stop_lbl, restart_lbl) = ("Start", "Stop", "Restart");
224 
225                         // Start/Stop, collapsed
226                         pc.button_icon(
227                             if running { "stop" } else { "play" },
228                             if running { stop_lbl } else { start_lbl },
229                             toggle_x,
230                             btn_y,
231                             btn_w,
232                             btn_h,
233                             face,
234                             face_hover,
235                             active_txt,
236                             1.0,
237                             crate::app::AppAction::Services(if running {
238                                 ServicesMessage::Stop(service.name.clone(), service.is_system)
239                             } else {
240                                 ServicesMessage::Start(service.name.clone(), service.is_system)
241                             }),
242                         );
243 
244                         // Restart button
245                         pc.button_icon(
246                             "refresh",
247                             restart_lbl,
248                             restart_x,
249                             btn_y,
250                             r_btn_w,
251                             btn_h,
252                             face,
253                             face_hover,
254                             active_txt,
255                             1.0,
256                             crate::app::AppAction::Services(ServicesMessage::Restart(service.name.clone(), service.is_system)),
257                         );
258                     }
259                 }
260             pc.pop_clip_rect();
261             // The scrollbar's fore copy, over the rows at the raise's fade.
262             list.push_scrollbar_fore(pc);
263 
264             if filtered_services.is_empty() {
265                 let inset = cce_ui::layout::plate_padding();
266                 pc.text("No services match the query", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
267             }
268         });
269         sec.place(form, ctx);
270     });
271 
272     final_pc
273 }
274 
275 /// The optimistic states [`update`] writes the instant a transport button is
276 /// clicked, before systemd has said anything back. Named because
277 /// [`transport_running`] has to agree with them: the whole point of writing
278 /// them is that the button's icon flips under the pointer instead of staying
279 /// stale until the next refresh lands.
280 const STARTING: (&str, &str) = ("activating", "starting");
281 const STOPPING: (&str, &str) = ("deactivating", "stopping");
282 const RESTARTING: (&str, &str) = ("activating", "restarting");
283 
284 /// The row's second line: the unit description, led by `failed` when it is.
285 ///
286 /// `failed` was the status dot's third state and has nowhere else to show — a
287 /// failed unit offers the same play button a cleanly stopped one does, so
288 /// without this the row gives no sign it died. The marker LEADS, and is
289 /// composed before the caller truncates, so a long description can never be
290 /// what pushes it off the end.
291 pub fn description_line(failed: bool, description: &str) -> String {
292     match (failed, description.is_empty()) {
293         (true, true) => "failed".to_string(),
294         (true, false) => format!("failed \u{2014} {description}"),
295         (false, true) => "No description".to_string(),
296         (false, false) => description.to_string(),
297     }
298 }
299 
300 /// Whether the row's single transport button offers Stop (`true`) or Start
301 /// (`false`) — which is also which icon it wears.
302 ///
303 /// A transitional state counts as the state it is heading FOR, not the one it
304 /// is leaving. That is what makes the click feel like a toggle: `update` marks
305 /// the unit `activating` the moment Start is pressed, and this reads that as
306 /// running, so the icon becomes Stop immediately.
307 ///
308 /// This is deliberately NOT the `is_active` the status dot reads. The dot
309 /// reports what is confirmed true; the button reports what it will do.
310 pub fn transport_running(active_state: &str, sub_state: &str) -> bool {
311     match active_state {
312         "active" | "activating" | "reloading" => true,
313         "deactivating" => false,
314         _ => sub_state == "running",
315     }
316 }
317 
318 pub fn update(state: &mut ServicesState, msg: ServicesMessage, ctx: &mut UiContext) {
319     match msg {
320         ServicesMessage::Refreshed(new_services) => {
321             state.loaded = true;
322             state.services = new_services;
323             for h in state.items.drain(..) {
324                 ctx.remove(h);
325             }
326         }
327         ServicesMessage::SetTab(tab) => {
328             state.active_tab = tab;
329             state.list.set_scroll_y(0.0);
330             for h in state.items.drain(..) {
331                 ctx.remove(h);
332             }
333         }
334         ServicesMessage::Start(name, is_system) => {
335             if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
336                 srv.active_state = STARTING.0.to_string();
337                 srv.sub_state = STARTING.1.to_string();
338             }
339             service_action(&name, "start", is_system);
340         }
341         ServicesMessage::Stop(name, is_system) => {
342             if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
343                 srv.active_state = STOPPING.0.to_string();
344                 srv.sub_state = STOPPING.1.to_string();
345             }
346             service_action(&name, "stop", is_system);
347         }
348         ServicesMessage::Restart(name, is_system) => {
349             if let Some(srv) = state.services.iter_mut().find(|s| s.name == name && s.is_system == is_system) {
350                 srv.active_state = RESTARTING.0.to_string();
351                 srv.sub_state = RESTARTING.1.to_string();
352             }
353             service_action(&name, "restart", is_system);
354         }
355     }
356 }
357 
358 // ── Background Fetching ──
359 
360 pub async fn fetch_services() -> Vec<ServiceInfo> {
361     let mut services = Vec::new();
362 
363     // 1. Fetch system-level services
364     if let Ok(output) = tokio::process::Command::new("systemctl")
365         .args(["list-units", "--type=service", "--all", "--no-legend"])
366         .output()
367         .await
368     {
369         let stdout = String::from_utf8_lossy(&output.stdout);
370         for line in stdout.lines() {
371             if let Some(info) = parse_service_line(line, true) {
372                 services.push(info);
373             }
374         }
375     }
376 
377     // 2. Fetch user-level services
378     if let Ok(output) = tokio::process::Command::new("systemctl")
379         .args(["--user", "list-units", "--type=service", "--all", "--no-legend"])
380         .output()
381         .await
382     {
383         let stdout = String::from_utf8_lossy(&output.stdout);
384         for line in stdout.lines() {
385             if let Some(info) = parse_service_line(line, false) {
386                 services.push(info);
387             }
388         }
389     }
390 
391     // Sort alphabetically by name
392     services.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
393     services
394 }
395 
396 fn parse_service_line(line: &str, is_system: bool) -> Option<ServiceInfo> {
397     let cleaned = line.trim_start_matches('●').trim();
398     if cleaned.is_empty() {
399         return None;
400     }
401     let parts: Vec<&str> = cleaned.split_whitespace().collect();
402     if parts.len() >= 4 && parts[0].ends_with(".service") {
403         let name = parts[0].to_string();
404         let _load = parts[1];
405         let active_state = parts[2].to_string();
406         let sub_state = parts[3].to_string();
407         let description = parts[4..].join(" ");
408         Some(ServiceInfo {
409             name,
410             description,
411             active_state,
412             sub_state,
413             is_system,
414         })
415     } else {
416         None
417     }
418 }
419 
420 fn service_action(name: &str, action: &str, is_system: bool) {
421     if is_system {
422         // System service needs root privilege, spawn via pkexec
423         let _ = tokio::process::Command::new("pkexec")
424             .args(["systemctl", action, name])
425             .spawn();
426     } else {
427         let _ = tokio::process::Command::new("systemctl")
428             .args(["--user", action, name])
429             .spawn();
430     }
431 }
432 
433 impl crate::pages::AppPage for ServicesState {
434     // Sections: [Services]
435     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
436         // Mirrors the view's `!loaded` branch: the search box is only painted (and
437         // so only registered) once the unit list has landed. The group count stays
438         // 1 either way — an empty outer Vec would kill the ctrl-nav entry point.
439         if self.loaded {
440             vec![vec![self.search_box.id()]]
441         } else {
442             vec![Vec::new()]
443         }
444     }
445 
446     fn view(
447         &mut self,
448         cx: f32,
449         cy: f32,
450         cw: f32,
451         ch: f32,
452         root_focused: bool,
453         sec_focused: &[bool],
454         layout: &mut PageFlow,
455         ctx: &mut cce_ui::context::UiContext,
456     ) -> crate::app::PageContent {
457         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
458     }
459 
460     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {}
461 
462     // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
463     // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
464     // hit-test against the row actually on screen. See the note in packages.rs.
465     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
466         let (list, items) = (&self.list, &self.items);
467         items
468             .iter()
469             .enumerate()
470             .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
471             .map(|(_, i)| i.id())
472             .collect()
473     }
474 
475 
476     fn handle_pointer_move(
477         &mut self,
478         lx: f32,
479         ly: f32,
480         _actions: &mut Vec<crate::app::AppAction>,
481         _ctx: &mut cce_ui::context::UiContext,
482     ) -> bool {
483         self.loaded && self.list.cursor_moved(lx, ly)
484     }
485 
486     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
487         self.loaded && self.list.press(lx, ly)
488     }
489 
490     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
491         self.list.release()
492     }
493 
494     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
495         self.loaded && self.list.wheel(delta, lx, ly)
496     }
497 
498     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
499         self.loaded && self.list.keyboard(event)
500     }
501 
502     fn tick(&mut self, dt: f32) -> bool {
503         self.list.tick(dt)
504     }
505 
506     fn frameless_lists(&self) -> Vec<&ScrollRegion> {
507         vec![&self.list]
508     }
509 }
510 
511 #[cfg(test)]
512 mod tests {
513     use super::{description_line, transport_running, RESTARTING, STARTING, STOPPING};
514 
515     #[test]
516     fn settled_states_pick_the_opposite_action() {
517         assert!(transport_running("active", "running"));
518         assert!(!transport_running("inactive", "dead"));
519         assert!(!transport_running("failed", "failed"));
520         // sub_state alone is enough — some units report it without
521         // active_state catching up.
522         assert!(transport_running("something-else", "running"));
523     }
524 
525     #[test]
526     fn a_click_flips_the_icon_before_any_refresh() {
527         // The exact states `update` writes optimistically. If these two ever
528         // disagree, the button stops feeling like a toggle: the icon would sit
529         // on the old action until systemd's next list lands, and a second
530         // click would re-send the action already in flight.
531         assert!(transport_running(STARTING.0, STARTING.1), "Start must show Stop immediately");
532         assert!(!transport_running(STOPPING.0, STOPPING.1), "Stop must show Start immediately");
533         // Restart keeps the unit running throughout, so the transport button
534         // must not flicker to Start while it cycles.
535         assert!(transport_running(RESTARTING.0, RESTARTING.1), "Restart must keep showing Stop");
536     }
537 
538     #[test]
539     fn the_failed_marker_leads_and_survives_truncation() {
540         assert_eq!(description_line(false, "Bluetooth service"), "Bluetooth service");
541         assert_eq!(description_line(false, ""), "No description");
542         assert_eq!(description_line(true, ""), "failed");
543         assert!(description_line(true, "Bluetooth service").starts_with("failed"));
544 
545         // The row truncates whatever this returns, so the marker is only
546         // guaranteed visible while it stays at the FRONT.
547         let long = description_line(true, &"x".repeat(500));
548         let shown = cce_ui::widget::display::truncate_tail(&long, 20);
549         assert!(shown.starts_with("failed"), "got {shown:?}");
550         assert!(shown.chars().count() <= 20);
551     }
552 }