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 }