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

src/pages/timers.rs (39.5K)

  1 //! Systemd timers — the system's "cron". View system/user timers with their
  2 //! schedules, trigger the activated service immediately, and enable/disable
  3 //! timer units (system scope through pkexec).
  4 
  5 use crate::app::{button_need, form_button, AppAction, PageContent};
  6 use cce_ui::context::UiContext;
  7 use cce_ui::widget::Handle;
  8 use cce_ui::widget::ScrollRegion;
  9 use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
 10 use cce_ui::scene::layout::Rect;
 11 use cce_ui::widget::{StatusDot, DotStatus, InteractiveListItem, TextBox};
 12 
 13 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 14 pub enum TimerTab {
 15     System,
 16     User,
 17 }
 18 
 19 #[derive(Debug, Clone)]
 20 pub struct TimerInfo {
 21     pub unit: String,
 22     pub activates: String,
 23     /// Absolute epoch microseconds; None when the timer has no scheduled run.
 24     pub next_usec: Option<u64>,
 25     pub last_usec: Option<u64>,
 26     pub active: bool,
 27     /// systemd UnitFileState: enabled / disabled / static / ...
 28     pub file_state: String,
 29     pub is_system: bool,
 30     /// The unit file lives in ~/.config/systemd/user — safe to edit in place.
 31     pub editable: bool,
 32 }
 33 
 34 #[derive(Debug, Clone)]
 35 pub struct TimersState {
 36     pub loaded: bool,
 37     pub timers: Vec<TimerInfo>,
 38     pub active_tab: TimerTab,
 39     pub list: ScrollRegion,
 40     pub items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
 41     pub creating: bool,
 42     /// Base unit name (without .timer) being edited, form shared with create.
 43     pub editing: Option<String>,
 44     pub name_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 45     pub command_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 46     pub schedule_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 47     pub status_msg: Option<String>,
 48 }
 49 
 50 impl Default for TimersState {
 51     fn default() -> Self {
 52         Self {
 53             loaded: false,
 54             timers: Vec::new(),
 55             active_tab: TimerTab::System,
 56             list: ScrollRegion::new(36.0, 6.0).with_frame(false).with_sink_behind(true),
 57             items: Vec::new(),
 58             creating: false,
 59             editing: None,
 60             name_box: Handle::none(),
 61             command_box: Handle::none(),
 62             schedule_box: Handle::none(),
 63             status_msg: None,
 64         }
 65     }
 66 }
 67 
 68 impl TimersState {
 69     /// The page's state, its widgets inserted into `ctx`.
 70     pub fn new(ctx: &mut UiContext) -> Self {
 71         Self {
 72             name_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Name")
 73                 .with_placeholder("backup")),
 74             command_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Command")
 75                 .with_placeholder("/home/me/bin/backup.sh --fast")),
 76             schedule_box: ctx.insert(TextBox::new(String::new()).with_draw_bg_border(true).with_label("Schedule (OnCalendar)")
 77                 .with_placeholder("daily \u{2022} Mon 09:00 \u{2022} *-*-* 03:00:00")),
 78             ..Self::default()
 79         }
 80     }
 81 }
 82 
 83 #[derive(Debug, Clone)]
 84 pub enum TimersMessage {
 85     Refreshed(Vec<TimerInfo>),
 86     SetTab(TimerTab),
 87     /// Start the timer's activated service right now.
 88     RunNow(String, bool),
 89     Enable(String, bool),
 90     Disable(String, bool),
 91     CreateStart,
 92     CreateCancel,
 93     CreateSave,
 94     /// Open the form pre-filled from the unit files (user timers only).
 95     EditStart(String),
 96 }
 97 
 98 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 99 /// The least room the list keeps on a short window.
100 const LIST_MIN_H: f32 = 120.0;
101 /// A row's status dot: a small LED, not a control.
102 const DOT: f32 = 10.0;
103 
104 /// "46min" / "3h" / "5d 3h" — coarse two-unit humanization.
105 fn humanize(secs: u64) -> String {
106     let (d, h, m) = (secs / 86_400, (secs % 86_400) / 3600, (secs % 3600) / 60);
107     if d > 0 {
108         if h > 0 { format!("{}d {}h", d, h) } else { format!("{}d", d) }
109     } else if h > 0 {
110         if m > 0 { format!("{}h {}min", h, m) } else { format!("{}h", h) }
111     } else if m > 0 {
112         format!("{}min", m)
113     } else {
114         format!("{}s", secs)
115     }
116 }
117 
118 fn now_usec() -> u64 {
119     std::time::SystemTime::now()
120         .duration_since(std::time::UNIX_EPOCH)
121         .map(|d| d.as_micros() as u64)
122         .unwrap_or(0)
123 }
124 
125 fn schedule_line(t: &TimerInfo, now: u64) -> String {
126     let next = match t.next_usec {
127         Some(n) if n > now => format!("next in {}", humanize((n - now) / 1_000_000)),
128         Some(_) => "next imminent".to_string(),
129         None => "no run scheduled".to_string(),
130     };
131     let last = match t.last_usec {
132         Some(l) if l > 0 && l <= now => format!("last {} ago", humanize((now - l) / 1_000_000)),
133         _ => "never ran".to_string(),
134     };
135     format!("{} \u{2022} {} \u{2022} {}", t.activates, next, last)
136 }
137 
138 // ── Background fetching ──
139 
140 async fn fetch_scope(user: bool) -> Vec<TimerInfo> {
141     let mut args: Vec<&str> = Vec::new();
142     if user {
143         args.push("--user");
144     }
145     args.extend(["list-timers", "--all", "--output=json"]);
146     let out = match tokio::process::Command::new("systemctl").args(&args).output().await {
147         Ok(o) => String::from_utf8_lossy(&o.stdout).to_string(),
148         Err(_) => return Vec::new(),
149     };
150     let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap_or_default();
151     let mut timers: Vec<TimerInfo> = parsed
152         .iter()
153         .filter_map(|v| {
154             Some(TimerInfo {
155                 unit: v.get("unit")?.as_str()?.to_string(),
156                 activates: v.get("activates").and_then(|a| a.as_str()).unwrap_or("").to_string(),
157                 next_usec: v.get("next").and_then(|n| n.as_u64()),
158                 last_usec: v.get("last").and_then(|n| n.as_u64()),
159                 active: false,
160                 file_state: String::new(),
161                 is_system: !user,
162                 editable: false,
163             })
164         })
165         .collect();
166     if timers.is_empty() {
167         return timers;
168     }
169 
170     // One batched `show` for active/enabled state, blocks split by blank lines.
171     let mut show_args: Vec<String> = Vec::new();
172     if user {
173         show_args.push("--user".into());
174     }
175     show_args.push("show".into());
176     show_args.extend(timers.iter().map(|t| t.unit.clone()));
177     show_args.extend(["-p".into(), "Id,ActiveState,UnitFileState".into()]);
178     if let Ok(o) = tokio::process::Command::new("systemctl").args(&show_args).output().await {
179         let text = String::from_utf8_lossy(&o.stdout).to_string();
180         for block in text.split("\n\n") {
181             let mut id = None;
182             let mut active = false;
183             let mut file_state = String::new();
184             for line in block.lines() {
185                 if let Some((k, v)) = line.split_once('=') {
186                     match k {
187                         "Id" => id = Some(v.to_string()),
188                         "ActiveState" => active = v == "active",
189                         "UnitFileState" => file_state = v.to_string(),
190                         _ => {}
191                     }
192                 }
193             }
194             if let Some(id) = id {
195                 if let Some(t) = timers.iter_mut().find(|t| t.unit == id) {
196                     t.active = active;
197                     t.file_state = file_state;
198                 }
199             }
200         }
201     }
202     if user {
203         if let Some(dir) = user_unit_dir() {
204             for t in timers.iter_mut() {
205                 t.editable = dir.join(&t.unit).exists();
206             }
207         }
208     }
209     timers.sort_by(|a, b| a.unit.to_lowercase().cmp(&b.unit.to_lowercase()));
210     timers
211 }
212 
213 fn user_unit_dir() -> Option<std::path::PathBuf> {
214     std::env::var("HOME").ok().map(|h| std::path::PathBuf::from(h).join(".config/systemd/user"))
215 }
216 
217 pub async fn fetch_timers() -> Vec<TimerInfo> {
218     let mut all = fetch_scope(false).await;
219     all.extend(fetch_scope(true).await);
220     all
221 }
222 
223 /// A TextBox's live contents: the in-progress edit buffer while focused, the
224 /// committed text otherwise (the recurring TextBox landmine).
225 fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> String {
226     if tb.editing {
227         tb.edit_buffer.trim().to_string()
228     } else {
229         tb.text.trim().to_string()
230     }
231 }
232 
233 /// Create and enable a USER timer: `<name>.service` + `<name>.timer` under
234 /// ~/.config/systemd/user, schedule validated by `systemd-analyze calendar`.
235 fn create_user_timer(name: &str, command: &str, schedule: &str) -> Result<String, String> {
236     if name.is_empty() || command.is_empty() || schedule.is_empty() {
237         return Err("All fields are required".into());
238     }
239     if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
240         return Err("Name: letters, digits, - and _ only".into());
241     }
242     let valid = std::process::Command::new("systemd-analyze")
243         .args(["calendar", schedule])
244         .output()
245         .map(|o| o.status.success())
246         .unwrap_or(false);
247     if !valid {
248         return Err(format!("Invalid OnCalendar expression: {}", schedule));
249     }
250 
251     let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
252     let dir = std::path::PathBuf::from(home).join(".config/systemd/user");
253     std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
254     let timer_path = dir.join(format!("{}.timer", name));
255     if timer_path.exists() {
256         return Err(format!("{}.timer already exists", name));
257     }
258 
259     let service = format!(
260         "[Unit]\nDescription={name} (created by cce-system-interface)\n\n[Service]\nType=oneshot\nExecStart={exec}\n",
261         name = name,
262         exec = exec_start_for(command),
263     );
264     let timer = format!(
265         "[Unit]\nDescription={name} schedule\n\n[Timer]\nOnCalendar={schedule}\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n",
266     );
267     std::fs::write(dir.join(format!("{}.service", name)), service).map_err(|e| e.to_string())?;
268     std::fs::write(&timer_path, timer).map_err(|e| e.to_string())?;
269 
270     let _ = tokio::process::Command::new("sh")
271         .args(["-c", &format!("systemctl --user daemon-reload && systemctl --user enable --now {}.timer", name)])
272         .spawn();
273     Ok(format!("Created and enabled {}.timer", name))
274 }
275 
276 /// The `ExecStart=` value that runs `command` through `/bin/sh -c`.
277 ///
278 /// systemd parses `ExecStart` itself before any shell sees it: `%` starts a
279 /// specifier, `$` an environment substitution, and inside the quotes `\` is
280 /// a C escape. Until 2026-10-02 the command was quoted for a SHELL
281 /// (`'` → `'\''`) and nothing else, so `date +%F > file` wrote a unit
282 /// systemd refused to load ("Failed to resolve unit specifiers"), a `$HOME`
283 /// was expanded by systemd from its own environment, and the page still
284 /// said "Created and enabled". Escaped here for systemd, the command reaches
285 /// `sh -c` exactly as typed.
286 fn exec_start_for(command: &str) -> String {
287     let mut out = String::from("/bin/sh -c '");
288     for c in command.chars() {
289         match c {
290             '\\' => out.push_str("\\\\"),
291             '\'' => out.push_str("\\'"),
292             '\n' => out.push_str("\\n"),
293             '%' => out.push_str("%%"),
294             '$' => out.push_str("$$"),
295             c => out.push(c),
296         }
297     }
298     out.push('\'');
299     out
300 }
301 
302 /// The command an `ExecStart=` value written by `exec_start_for` runs, or
303 /// None for any other value (a unit this page did not write, or one written
304 /// before 2026-10-02 with shell-style quoting), which the edit form then
305 /// shows and saves raw.
306 fn shell_command_of(exec_start: &str) -> Option<String> {
307     let body = exec_start.strip_prefix("/bin/sh -c '")?.strip_suffix('\'')?;
308     let mut out = String::new();
309     let mut chars = body.chars().peekable();
310     while let Some(c) = chars.next() {
311         match c {
312             '\\' => match chars.next()? {
313                 '\\' => out.push('\\'),
314                 '\'' => out.push('\''),
315                 'n' => out.push('\n'),
316                 _ => return None,
317             },
318             '%' if chars.peek() == Some(&'%') => {
319                 chars.next();
320                 out.push('%');
321             }
322             '$' if chars.peek() == Some(&'$') => {
323                 chars.next();
324                 out.push('$');
325             }
326             // An unescaped quote, `%` or `$` is not something exec_start_for
327             // writes: a shell-quoted legacy unit, or a hand-written one.
328             '\'' | '%' | '$' => return None,
329             c => out.push(c),
330         }
331     }
332     Some(out)
333 }
334 
335 /// First `Key=value` in a unit file, or None.
336 fn read_unit_field(path: &std::path::Path, key: &str) -> Option<String> {
337     let content = std::fs::read_to_string(path).ok()?;
338     content
339         .lines()
340         .find_map(|l| l.trim().strip_prefix(key).and_then(|r| r.strip_prefix('=')).map(|v| v.trim().to_string()))
341 }
342 
343 /// Replace the first `key=` line's value in-place, preserving everything else;
344 /// errors when the file has no such line (an unusual unit we shouldn't rewrite).
345 fn replace_unit_field(path: &std::path::Path, key: &str, value: &str) -> Result<(), String> {
346     let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
347     let mut replaced = false;
348     let out: Vec<String> = content
349         .lines()
350         .map(|l| {
351             if !replaced && l.trim_start().starts_with(key) && l.trim_start()[key.len()..].starts_with('=') {
352                 replaced = true;
353                 format!("{}={}", key, value)
354             } else {
355                 l.to_string()
356             }
357         })
358         .collect();
359     if !replaced {
360         return Err(format!("{} has no {}= line", path.display(), key));
361     }
362     std::fs::write(path, out.join("\n") + "\n").map_err(|e| e.to_string())
363 }
364 
365 /// Edit an existing USER timer in place: swap the .service ExecStart and the
366 /// .timer OnCalendar lines, keeping the rest of both files untouched.
367 fn update_user_timer(base: &str, command: &str, schedule: &str) -> Result<String, String> {
368     if command.is_empty() || schedule.is_empty() {
369         return Err("Command and schedule are required".into());
370     }
371     let valid = std::process::Command::new("systemd-analyze")
372         .args(["calendar", schedule])
373         .output()
374         .map(|o| o.status.success())
375         .unwrap_or(false);
376     if !valid {
377         return Err(format!("Invalid OnCalendar expression: {}", schedule));
378     }
379     let dir = user_unit_dir().ok_or("HOME not set")?;
380     replace_unit_field(&dir.join(format!("{}.timer", base)), "OnCalendar", schedule)?;
381     let service_path = dir.join(format!("{}.service", base));
382     if service_path.exists() {
383         // The form showed this unit's command decoded when it was one
384         // `exec_start_for` wrote (see EditStart), so it goes back encoded;
385         // any other ExecStart was shown raw and is written back raw.
386         let ours = read_unit_field(&service_path, "ExecStart")
387             .is_some_and(|v| shell_command_of(&v).is_some());
388         let value = if ours { exec_start_for(command) } else { command.to_string() };
389         replace_unit_field(&service_path, "ExecStart", &value)?;
390     }
391     let _ = tokio::process::Command::new("sh")
392         .args(["-c", &format!("systemctl --user daemon-reload && systemctl --user try-restart {}.timer", base)])
393         .spawn();
394     Ok(format!("Updated {}.timer", base))
395 }
396 
397 fn systemctl_action(args: &[&str], is_system: bool) {
398     if is_system {
399         let mut full = vec!["systemctl"];
400         full.extend(args);
401         let _ = tokio::process::Command::new("pkexec").args(&full).spawn();
402     } else {
403         let mut full = vec!["--user"];
404         full.extend(args);
405         let _ = tokio::process::Command::new("systemctl").args(&full).spawn();
406     }
407 }
408 
409 /// A timer row's button: the `icon` glyph (tinted `label_color`, so the
410 /// red Disable stays red) when `compact`, else the `word`. `word` is also
411 /// the fallback a glyph face shows when the icon set is missing.
412 #[allow(clippy::too_many_arguments)]
413 fn button_glyph(pc: &mut PageContent, compact: bool, icon: &str, word: &str, x: f32, y: f32, w: f32, h: f32,
414                 bg: [f32; 4], hover_bg: [f32; 4], label_color: [f32; 4], action: crate::app::AppAction) {
415     if compact {
416         pc.button_icon_tinted(icon, word, x, y, w, h, bg, hover_bg, label_color, action);
417     } else {
418         pc.button(word, x, y, w, h, bg, hover_bg, label_color, action);
419     }
420 }
421 
422 pub fn view(state: &mut TimersState, cx: f32, cy: f32, cw: f32, ch: f32, _root_focused: bool, sec_focused: &[bool], layout: &mut PageFlow, ctx: &mut cce_ui::context::UiContext) -> PageContent {
423     let mut final_pc = PageContent::new();
424     let sec_w = 320.0f32;
425     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
426 
427     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
428         let sec_w = sec.cw;
429         let mut form = sec.form();
430         if !state.loaded {
431             form.column().text("Loading systemd timers...", 12.0, TEXT_DIM);
432             sec.place(form, ctx);
433             return;
434         }
435         // The list fills the page like the services list: the well's floor lands at the
436         // page's bottom.
437         form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
438 
439         let active_bg = [0.20, 0.40, 0.65, 0.4];
440         let tab_colors = |on: bool| {
441             let face = if on { active_bg } else { [0.10, 0.10, 0.16, 0.3] };
442             (face, [0.20, 0.20, 0.25, 0.15], [0.90, 0.90, 0.95, 1.0])
443         };
444         let (label1, label2) = if sec_w < 250.0 { ("System", "User") } else { ("System Timers", "User Timers") };
445         let text_on = [0.90, 0.90, 0.95, 1.0];
446 
447         let now = now_usec();
448         let filtered: Vec<&TimerInfo> = state.timers.iter()
449             .filter(|t| t.is_system == (state.active_tab == TimerTab::System))
450             .collect();
451         if state.items.len() != filtered.len() {
452             for h in state.items.drain(..) {
453                 ctx.remove(h);
454             }
455             for _ in 0..filtered.len() {
456                 state.items.push(ctx.insert(InteractiveListItem::new("")));
457             }
458         }
459 
460         let active_tab = state.active_tab;
461         let list = &mut state.list;
462         let items = &state.items;
463         let mut col = form.column();
464         col.row(|r| {
465             form_button(r, label1, 0.0, tab_colors(active_tab == TimerTab::System),
466                 AppAction::Timers(TimersMessage::SetTab(TimerTab::System)));
467             form_button(r, label2, 0.0, tab_colors(active_tab == TimerTab::User),
468                 AppAction::Timers(TimersMessage::SetTab(TimerTab::User)));
469         });
470 
471         // New Timer (user scope) — a button as wide as its label; the form unfolds below.
472         let new_bg = if state.creating { active_bg } else { [0.13, 0.18, 0.14, 1.0] };
473         col.row(|r| {
474             form_button(r, "New Timer", button_need("New Timer"), (new_bg, [0.25, 0.30, 0.26, 1.0], text_on),
475                 AppAction::Timers(TimersMessage::CreateStart));
476         });
477 
478         if state.creating || state.editing.is_some() {
479             let field_h = cce_ui::layout::spinbox_height();
480             let note = match &state.editing {
481                 Some(base) => format!("Editing {}.timer \u{2014} command and schedule rewrite in place.", base),
482                 None => "New user timer \u{2014} runs the command on the schedule.".to_string(),
483             };
484             col.text(note, 11.0, TEXT_DIM);
485             if state.creating {
486                 col.widget_h(ctx, state.name_box, field_h);
487             }
488             col.widget_h(ctx, state.command_box, field_h);
489             col.widget_h(ctx, state.schedule_box, field_h);
490             let save_label = if state.editing.is_some() { "Save" } else { "Create" };
491             col.row(|r| {
492                 form_button(r, save_label, button_need(save_label), ([0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0], text_on),
493                     AppAction::Timers(TimersMessage::CreateSave));
494                 form_button(r, "Cancel", button_need("Cancel"), ([0.15, 0.15, 0.20, 1.0], [0.22, 0.22, 0.28, 1.0], text_on),
495                     AppAction::Timers(TimersMessage::CreateCancel));
496             });
497         }
498 
499         if let Some(ref msg) = state.status_msg {
500             col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
501         }
502 
503         col.fill(LIST_MIN_H, move |pc, rect, ctx| {
504             let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
505             list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
506             list.update_bounds(filtered.len(), list_box_y, list_box_h);
507             list.push_prims(pc);
508             let item_h = list.item_height;
509 
510             pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
511                 for (idx, timer) in filtered.iter().enumerate() {
512                     if let Some(draw_y) = list.get_item_draw_y(idx, 4.0) {
513                         let is_small = sec_w < 350.0;
514                         // A narrow row's buttons are cce-icons glyphs (pencil,
515                         // play, stop, circle) — but only when the icon set is
516                         // THERE: without it they fall back to words, and a word
517                         // needs the wide button. `upload_icon` caches per
518                         // (name, px), so asking every row is one hash lookup.
519                         let compact = is_small && cce_ui::upload_icon("play", 32).is_some();
520                         // Glyph buttons are square at the control height.
521                         let btn_h = cce_ui::layout::button_height();
522                         // Word buttons are as wide as their words.
523                         let run_label = if is_small { "Run" } else { "Run Now" };
524                         let word_w = |words: &[&str]| words.iter().map(|w| crate::app::button_need(w)).fold(0.0, f32::max);
525                         let run_w = if compact { btn_h } else { word_w(&[run_label]) };
526                         let en_w = if compact { btn_h } else { word_w(&["Disable", "Enable", "static"]) };
527                         let edit_w = if compact { btn_h } else { word_w(&["Edit"]) };
528 
529                         // The status dot, the name and schedule, then the buttons at the
530                         // row's end (Edit only for an editable unit).
531                         let row = Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h };
532                         let mut layout = vec![Cell::fixed(DOT, DOT), Cell::grow(item_h)];
533                         if timer.editable {
534                             layout.push(Cell::fixed(edit_w, btn_h));
535                         }
536                         layout.push(Cell::fixed(run_w, btn_h));
537                         layout.push(Cell::fixed(en_w, btn_h));
538                         let cells = lay_row(row, &layout);
539                         let (dot_rect, item_rect) = (cells[0], cells[1]);
540                         let en_x = cells[cells.len() - 1].x;
541                         let run_x = cells[cells.len() - 2].x;
542                         let edit_x = if timer.editable { cells[2].x } else { run_x };
543                         let btn_y = cells[cells.len() - 1].y;
544 
545                         // Title + schedule subtitle, truncated to the item's text room.
546                         let text_max_w = item_rect.width - 2.0 * cce_ui::layout::CONTROL_TEXT_INSET;
547                         let max_chars = ((text_max_w / 6.0) as usize).max(10);
548                         let subtitle_full = schedule_line(timer, now);
549                         let subtitle = if subtitle_full.len() > max_chars {
550                             format!("{}...", &subtitle_full[..subtitle_full.char_indices().take(max_chars.saturating_sub(3)).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(0)])
551                         } else {
552                             subtitle_full
553                         };
554 
555                         let item = items[idx];
556                         let item_btn = &mut ctx[item];
557                         item_btn.title = timer.unit.clone();
558                         item_btn.subtitle = Some(subtitle);
559                         // Cut at its cell, so a long schedule stops short of the buttons.
560                         pc.push_clip_rect(item_rect.x, item_rect.y, item_rect.width, item_rect.height);
561                         render_widget_h(pc, item, item_rect.x, item_rect.y, item_rect.width, item_rect.height, ctx);
562                         pc.pop_clip_rect();
563 
564                         let dot_state = if timer.active { DotStatus::Active } else { DotStatus::Inactive };
565                         // Drawn this frame only: out of the context once it is placed.
566                         let dot = ctx.insert(StatusDot::new(dot_state));
567                         render_widget_h(pc, dot, dot_rect.x, dot_rect.y, dot_rect.width, dot_rect.height, ctx);
568                         ctx.remove(dot);
569 
570                         let active_txt = [0.90, 0.90, 0.95, 1.0];
571 
572                         // Edit: only for user units living in ~/.config/systemd/user.
573                         if timer.editable {
574                             // `button_glyph` below: the glyph when compact,
575                             // else (or with no icon set) the word.
576                             button_glyph(
577                                 pc,
578                                 compact,
579                                 "pencil",
580                                 "Edit",
581                                 edit_x,
582                                 btn_y,
583                                 edit_w,
584                                 btn_h,
585                                 [0.15, 0.15, 0.20, 1.0],
586                                 [0.22, 0.22, 0.28, 1.0],
587                                 active_txt,
588                                 crate::app::AppAction::Timers(TimersMessage::EditStart(timer.unit.clone())),
589                             );
590                         }
591 
592                         // Run Now: start the activated service immediately.
593                         button_glyph(
594                             pc,
595                             compact,
596                             "play",
597                             if is_small { "Run" } else { "Run Now" },
598                             run_x,
599                             btn_y,
600                             run_w,
601                             btn_h,
602                             [0.16, 0.35, 0.18, 0.4],
603                             [0.22, 0.45, 0.25, 0.6],
604                             active_txt,
605                             crate::app::AppAction::Timers(TimersMessage::RunNow(timer.activates.clone(), timer.is_system)),
606                         );
607 
608                         // Enable/Disable the timer unit; static units have no toggle.
609                         match timer.file_state.as_str() {
610                             "enabled" | "enabled-runtime" => {
611                                 button_glyph(
612                                     pc,
613                                     compact,
614                                     "stop",
615                                     "Disable",
616                                     en_x,
617                                     btn_y,
618                                     en_w,
619                                     btn_h,
620                                     [0.25, 0.14, 0.14, 1.0],
621                                     [0.40, 0.20, 0.20, 1.0],
622                                     [0.95, 0.55, 0.55, 1.0],
623                                     crate::app::AppAction::Timers(TimersMessage::Disable(timer.unit.clone(), timer.is_system)),
624                                 );
625                             }
626                             "disabled" => {
627                                 // `circle`, the filled dot the narrow row
628                                 // always used: `play` is Run Now's beside it.
629                                 button_glyph(
630                                     pc,
631                                     compact,
632                                     "circle",
633                                     "Enable",
634                                     en_x,
635                                     btn_y,
636                                     en_w,
637                                     btn_h,
638                                     [0.15, 0.15, 0.20, 1.0],
639                                     [0.22, 0.22, 0.28, 1.0],
640                                     active_txt,
641                                     crate::app::AppAction::Timers(TimersMessage::Enable(timer.unit.clone(), timer.is_system)),
642                                 );
643                             }
644                             _ => {
645                                 pc.text("static", en_x + cce_ui::layout::CONTROL_TEXT_INSET, crate::app::label_y_in(btn_y, btn_h, 11.0, None), 11.0, TEXT_DIM);
646                             }
647                         }
648                     }
649                 }
650             pc.pop_clip_rect();
651             // The scrollbar's fore copy, over the rows at the raise's fade.
652             list.push_scrollbar_fore(pc);
653 
654             if filtered.is_empty() {
655                 let inset = cce_ui::layout::plate_padding();
656                 pc.text("No timers in this scope", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
657             }
658         });
659         sec.place(form, ctx);
660     });
661 
662     final_pc
663 }
664 
665 pub fn update(state: &mut TimersState, msg: TimersMessage, ctx: &mut UiContext) {
666     match msg {
667         TimersMessage::Refreshed(timers) => {
668             state.loaded = true;
669             state.timers = timers;
670             for h in state.items.drain(..) {
671                 ctx.remove(h);
672             }
673         }
674         TimersMessage::SetTab(tab) => {
675             state.active_tab = tab;
676             state.list.set_scroll_y(0.0);
677             for h in state.items.drain(..) {
678                 ctx.remove(h);
679             }
680         }
681         TimersMessage::RunNow(service, is_system) => {
682             if !service.is_empty() {
683                 systemctl_action(&["start", &service], is_system);
684             }
685         }
686         TimersMessage::Enable(unit, is_system) => {
687             if let Some(t) = state.timers.iter_mut().find(|t| t.unit == unit && t.is_system == is_system) {
688                 t.file_state = "enabled".to_string();
689             }
690             systemctl_action(&["enable", "--now", &unit], is_system);
691         }
692         TimersMessage::Disable(unit, is_system) => {
693             if let Some(t) = state.timers.iter_mut().find(|t| t.unit == unit && t.is_system == is_system) {
694                 t.file_state = "disabled".to_string();
695             }
696             systemctl_action(&["disable", "--now", &unit], is_system);
697         }
698         TimersMessage::CreateStart => {
699             state.creating = true;
700             state.editing = None;
701             state.status_msg = None;
702             for tb in [state.name_box, state.command_box, state.schedule_box] {
703                 let tb = &mut ctx[tb];
704                 tb.text = String::new();
705                 tb.edit_buffer = String::new();
706             }
707         }
708         TimersMessage::CreateCancel => {
709             state.creating = false;
710             state.editing = None;
711             state.status_msg = None;
712         }
713         TimersMessage::EditStart(unit) => {
714             let base = unit.trim_end_matches(".timer").to_string();
715             let dir = user_unit_dir();
716             let schedule = dir.as_ref()
717                 .and_then(|d| read_unit_field(&d.join(format!("{}.timer", base)), "OnCalendar"))
718                 .unwrap_or_default();
719             let command = dir.as_ref()
720                 .and_then(|d| read_unit_field(&d.join(format!("{}.service", base)), "ExecStart"))
721                 .map(|raw| shell_command_of(&raw).unwrap_or(raw))
722                 .unwrap_or_default();
723             state.creating = false;
724             state.editing = Some(base.clone());
725             state.status_msg = None;
726             ctx[state.name_box].text = base.clone();
727             ctx[state.name_box].edit_buffer = base;
728             ctx[state.command_box].text = command.clone();
729             ctx[state.command_box].edit_buffer = command;
730             ctx[state.schedule_box].text = schedule.clone();
731             ctx[state.schedule_box].edit_buffer = schedule;
732         }
733         TimersMessage::CreateSave => {
734             let command = live_text(&ctx[state.command_box]);
735             let schedule = live_text(&ctx[state.schedule_box]);
736             let result = if let Some(base) = state.editing.clone() {
737                 update_user_timer(&base, &command, &schedule)
738             } else {
739                 create_user_timer(&live_text(&ctx[state.name_box]), &command, &schedule)
740             };
741             match result {
742                 Ok(msg) => {
743                     state.creating = false;
744                     state.editing = None;
745                     state.status_msg = Some(msg);
746                     // Show the unit where it (re)appears on the next refresh.
747                     state.active_tab = TimerTab::User;
748                     for h in state.items.drain(..) {
749                         ctx.remove(h);
750                     }
751                 }
752                 Err(e) => {
753                     state.status_msg = Some(e);
754                 }
755             }
756         }
757     }
758 }
759 
760 impl crate::pages::AppPage for TimersState {
761     // Sections: [Timers] — the form boxes join the group while it is open
762     // (name box only on create; edits keep the unit name fixed).
763     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
764         if self.creating {
765             vec![vec![
766                 self.name_box.id(),
767                 self.command_box.id(),
768                 self.schedule_box.id(),
769             ]]
770         } else if self.editing.is_some() {
771             vec![vec![
772                 self.command_box.id(),
773                 self.schedule_box.id(),
774             ]]
775         } else {
776             vec![Vec::new()]
777         }
778     }
779 
780     fn view(
781         &mut self,
782         cx: f32,
783         cy: f32,
784         cw: f32,
785         ch: f32,
786         root_focused: bool,
787         sec_focused: &[bool],
788         layout: &mut PageFlow,
789         ctx: &mut cce_ui::context::UiContext,
790     ) -> crate::app::PageContent {
791         view(self, cx, cy, cw, ch, root_focused, sec_focused, layout, ctx)
792     }
793 
794     fn propagate_widget_changes(&mut self, _actions: &mut Vec<crate::app::AppAction>, _ctx: &mut UiContext) {}
795 
796     // Filtered by `get_item_draw_y`, the same predicate the view's paint loop virtualizes
797     // on — a scrolled-out row keeps its last-drawn rect and would otherwise win the
798     // hit-test against the row actually on screen. See the note in packages.rs.
799     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
800         let (list, items) = (&self.list, &self.items);
801         items
802             .iter()
803             .enumerate()
804             .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
805             .map(|(_, i)| i.id())
806             .collect()
807     }
808 
809 
810     fn handle_pointer_move(
811         &mut self,
812         lx: f32,
813         ly: f32,
814         _actions: &mut Vec<crate::app::AppAction>,
815         _ctx: &mut cce_ui::context::UiContext,
816     ) -> bool {
817         self.loaded && self.list.cursor_moved(lx, ly)
818     }
819 
820     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
821         self.loaded && self.list.press(lx, ly)
822     }
823 
824     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
825         self.list.release()
826     }
827 
828     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
829         self.loaded && self.list.wheel(delta, lx, ly)
830     }
831 
832     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
833         self.loaded && self.list.keyboard(event)
834     }
835 
836     fn tick(&mut self, dt: f32) -> bool {
837         self.list.tick(dt)
838     }
839 
840     fn frameless_lists(&self) -> Vec<&ScrollRegion> {
841         vec![&self.list]
842     }
843 }
844 
845 #[cfg(test)]
846 mod tests {
847     use super::*;
848 
849     const AWKWARD: [&str; 6] = [
850         "date +%F > /tmp/x",
851         "echo it's $HOME",
852         r"printf 'a\tb\n' | tr '\t' ,",
853         "rsync -a ~/a/ /mnt/b/ && notify-send 'done 100%'",
854         "echo $$ ${USER} %h %%",
855         "plain-command --flag",
856     ];
857 
858     #[test]
859     fn a_timer_command_round_trips_through_its_exec_start() {
860         for cmd in AWKWARD {
861             let exec = exec_start_for(cmd);
862             assert_eq!(shell_command_of(&exec).as_deref(), Some(cmd), "{exec}");
863             // No bare specifier or substitution survives for systemd to act on.
864             let body = exec.trim_start_matches("/bin/sh -c '");
865             assert!(!body.replace("%%", "").contains('%'), "{exec}");
866             assert!(!body.replace("$$", "").contains('$'), "{exec}");
867         }
868     }
869 
870     #[test]
871     fn a_unit_this_page_did_not_write_is_edited_raw() {
872         // The old shell-style quoting, and hand-written lines.
873         for raw in [r"/bin/sh -c 'echo it'\''s'", "/usr/bin/backup --all", "/bin/sh -c 'echo 100%'"] {
874             assert_eq!(shell_command_of(raw), None, "{raw}");
875         }
876     }
877 
878     /// Asks systemd itself: `cargo test -p cce-system-interface --lib
879     /// timers -- --ignored`. Writes units to a temp dir only and loads none.
880     #[test]
881     #[ignore]
882     fn systemd_accepts_every_written_unit() {
883         let dir = std::env::temp_dir().join(format!("cce-timer-units-{}", std::process::id()));
884         std::fs::create_dir_all(&dir).unwrap();
885         for (i, cmd) in AWKWARD.iter().enumerate() {
886             let path = dir.join(format!("t{i}.service"));
887             std::fs::write(&path, format!("[Service]\nType=oneshot\nExecStart={}\n", exec_start_for(cmd))).unwrap();
888             let out = std::process::Command::new("systemd-analyze")
889                 .args(["--user", "verify"])
890                 .arg(&path)
891                 .output()
892                 .unwrap();
893             let err = String::from_utf8_lossy(&out.stderr);
894             assert!(!err.contains("Failed to resolve") && !err.contains("fatal"), "{cmd:?}: {err}");
895         }
896         let _ = std::fs::remove_dir_all(&dir);
897     }
898 
899     #[test]
900     fn humanize_ranges() {
901         assert_eq!(humanize(30), "30s");
902         assert_eq!(humanize(46 * 60), "46min");
903         assert_eq!(humanize(3 * 3600), "3h");
904         assert_eq!(humanize(3 * 3600 + 20 * 60), "3h 20min");
905         assert_eq!(humanize(5 * 86_400 + 3 * 3600), "5d 3h");
906     }
907 
908     #[test]
909     fn unit_field_read_and_replace() {
910         let dir = std::env::temp_dir().join("cce-timer-edit-test");
911         std::fs::create_dir_all(&dir).unwrap();
912         let p = dir.join("t.timer");
913         std::fs::write(&p, "[Unit]\nDescription=x\n\n[Timer]\nOnCalendar=daily\nPersistent=true\n").unwrap();
914 
915         assert_eq!(read_unit_field(&p, "OnCalendar").as_deref(), Some("daily"));
916         replace_unit_field(&p, "OnCalendar", "Mon 09:00").unwrap();
917         let content = std::fs::read_to_string(&p).unwrap();
918         assert!(content.contains("OnCalendar=Mon 09:00"), "{content}");
919         assert!(content.contains("Persistent=true"), "rest preserved: {content}");
920         assert!(replace_unit_field(&p, "Nonexistent", "x").is_err());
921     }
922 
923     #[test]
924     fn create_timer_validation_rejects_before_side_effects() {
925         assert!(create_user_timer("", "echo hi", "daily").is_err());
926         assert!(create_user_timer("backup", "", "daily").is_err());
927         assert!(create_user_timer("backup", "echo hi", "").is_err());
928         assert!(create_user_timer("bad name!", "echo hi", "daily").is_err());
929     }
930 
931     #[test]
932     fn schedule_line_composes() {
933         let now = 1_000_000_000_000_000u64;
934         let t = TimerInfo {
935             unit: "x.timer".into(),
936             activates: "x.service".into(),
937             next_usec: Some(now + 46 * 60 * 1_000_000),
938             last_usec: Some(now - 16 * 3600 * 1_000_000),
939             active: true,
940             file_state: "enabled".into(),
941             is_system: true,
942             editable: false,
943         };
944         let line = schedule_line(&t, now);
945         assert!(line.contains("x.service"), "{line}");
946         assert!(line.contains("next in 46min"), "{line}");
947         assert!(line.contains("last 16h ago"), "{line}");
948 
949         let t2 = TimerInfo { next_usec: None, last_usec: None, ..t };
950         let line2 = schedule_line(&t2, now);
951         assert!(line2.contains("no run scheduled"), "{line2}");
952         assert!(line2.contains("never ran"), "{line2}");
953     }
954 }