diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ae9bb8..8549a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented here. The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`. +## [1.3.3] - 2026-09-09 + +### Fixed + +- Closed shell injection via `notify.message` — `notify-send` now uses direct args. +- Separated KDE monitor handling from lock — uses `kscreen-doctor --dpms off`. +- `pkill` and `notify-send` use `--` separator against option-injection. +- `is_dangerous` no longer false-positives on notify text and flags user-controlled shell. +- Edit error no longer loses input. +- `start_timer` validates config before spawning the thread. + +### Changed + +- TUI header centered and collapsed into `loaded config:`. +- Settings renamed/regrouped — screen off, mic mute, prevent sleep, power, kill processes. +- `enabled` suffix shown only for enabled actions. +- Edit input visible inline while editing. +- README synced: sway/i3, kscreen-doctor, notify. + ## [1.3.2] - 2026-08-07 ### Changed @@ -83,6 +102,7 @@ The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH` - Added YAML configuration, CLI `init`, `preview`, and `run` commands. - Added Gitea CI and tag-based release workflows. +[1.3.3]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.3.3 [1.3.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.3.2 [1.3.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.3.1 [1.2.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.2.2 diff --git a/Cargo.lock b/Cargo.lock index 1adad95..181d4a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,7 +537,7 @@ dependencies = [ [[package]] name = "gosleep-timer" -version = "1.3.2" +version = "1.3.3" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 683d221..1bcf535 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gosleep-timer" -version = "1.3.2" +version = "1.3.3" authors = ["forust"] edition = "2024" rust-version = "1.88" diff --git a/README.md b/README.md index 50639d7..41c3d11 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Rust TUI sleep timer for Linux desktop actions. -`gosleep-timer` waits for a configured duration, then runs post-countdown desktop commands such as switching workspace, stopping media, dimming brightness, muting audio, locking the session, killing selected processes, powering off/rebooting, or running custom shell commands. +`gosleep-timer` waits for a configured duration, then runs post-countdown desktop commands such as switching workspace, stopping media, dimming brightness, muting audio, locking the session, turning the screen off, sending a desktop notification, killing selected processes, powering off/rebooting, or running custom shell commands. ## Status @@ -18,7 +18,7 @@ Rust TUI sleep timer for Linux desktop actions. - CLI commands for init, status, validate, edit, preview, run, history, and stats. - Time-left display and progress bar. - Wrapped command preview for smaller terminals. -- Linux desktop action support for niri, Hyprland, KDE, playerctl, brightnessctl/light, PipeWire/PulseAudio, systemd, and custom shell commands. +- Linux desktop action support for niri, Hyprland, sway, i3, KDE, playerctl, brightnessctl/light, PipeWire/PulseAudio, systemd, desktop notifications, and custom shell commands. ## Install @@ -165,7 +165,7 @@ Supported values: | Field | Values | | --- | --- | | `duration` | Go-style duration strings such as `25m`, `45m`, `1h30m`, `10s` | -| `actions.workspace.backend` | `auto`, `niri`, `hyprland`, `kde` | +| `actions.workspace.backend` | `auto`, `niri`, `hyprland`, `sway`, `i3`, `kde` | | `actions.media.action` | `none`, `stop`, `pause`, `play-pause`, `next`, `previous` | | `actions.power.mode` | `none`, `poweroff`, `reboot` | @@ -176,15 +176,21 @@ Supported values: ```sh niri msg action focus-workspace hyprctl dispatch workspace +swaymsg workspace number +i3-msg workspace number qdbus org.kde.KWin /KWin org.kde.KWin.setCurrentDesktop ``` +Screen off (`monitor`) on KDE runs `kscreen-doctor --dpms off`, desktop notifications (`notify`) use `notify-send`, and `inhibit_sleep` maps to the TUI `prevent sleep` toggle. + Other actions use common Linux desktop tools: - `playerctl` - `brightnessctl` or `light` - `wpctl` or `pactl` - `loginctl` +- `kscreen-doctor` +- `notify-send` - `pkill` - `systemctl` diff --git a/VERSION b/VERSION index 1892b92..31e5c84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.2 +1.3.3 diff --git a/src/main.rs b/src/main.rs index 1ae6b7f..982aaaa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,7 @@ use crossterm::{ use ratatui::{ Frame, Terminal, backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect}, + layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, Wrap}, @@ -284,10 +284,19 @@ impl ActionCommand { } fn is_dangerous(&self) -> bool { + if self.label == "notify" { + return false; + } if matches!(self.label, "kill" | "power") { return true; } - let command = self.display().to_lowercase(); + let Some(shell) = &self.shell else { + return false; + }; + if matches!(self.label, "custom" | "workspace" | "monitor" | "lock") { + return true; + } + let command = shell.to_lowercase(); [ "rm -rf", "mkfs", @@ -716,11 +725,13 @@ fn build_action_commands(config: &Config) -> Vec { if actions.notify.enabled { commands.push(ActionCommand { label: "notify", - args: vec![], - shell: Some(format!( - "notify-send 'gosleep-timer' '{}'", - actions.notify.message - )), + args: vec![ + "notify-send".to_string(), + "gosleep-timer".to_string(), + "--".to_string(), + actions.notify.message.clone(), + ], + shell: None, }); } if actions.kill.enabled { @@ -729,7 +740,7 @@ fn build_action_commands(config: &Config) -> Vec { if !process.is_empty() { commands.push(ActionCommand { label: "kill", - args: vec!["pkill".to_string(), process.to_string()], + args: vec!["pkill".to_string(), "--".to_string(), process.to_string()], shell: None, }); } @@ -880,16 +891,10 @@ fn monitor_command(action: &MonitorAction) -> ActionCommand { }, "kde" => ActionCommand { label: "monitor", - args: vec![ - "qdbus", - "org.kde.ScreenSaver", - "/ScreenSaver", - "org.freedesktop.ScreenSaver.SetActive", - "true", - ] - .into_iter() - .map(str::to_string) - .collect(), + args: vec!["kscreen-doctor", "--dpms", "off"] + .into_iter() + .map(str::to_string) + .collect(), shell: None, }, "gnome" => ActionCommand { @@ -906,7 +911,7 @@ fn monitor_command(action: &MonitorAction) -> ActionCommand { label: "monitor", args: vec![], shell: Some( - "if command -v hyprctl >/dev/null 2>&1; then hyprctl dispatch dpms off; elif command -v niri >/dev/null 2>&1; then niri msg action power-off-monitors; elif command -v swaymsg >/dev/null 2>&1; then swaymsg output '*' dpms off; elif command -v qdbus >/dev/null 2>&1; then qdbus org.kde.ScreenSaver /ScreenSaver org.freedesktop.ScreenSaver.SetActive true; elif command -v gnome-screensaver-command >/dev/null 2>&1; then gnome-screensaver-command -l; elif command -v xset >/dev/null 2>&1; then xset dpms force off; fi".to_string() + "if command -v hyprctl >/dev/null 2>&1; then hyprctl dispatch dpms off; elif command -v niri >/dev/null 2>&1; then niri msg action power-off-monitors; elif command -v swaymsg >/dev/null 2>&1; then swaymsg output '*' dpms off; elif command -v kscreen-doctor >/dev/null 2>&1; then kscreen-doctor --dpms off; elif command -v gnome-screensaver-command >/dev/null 2>&1; then gnome-screensaver-command -l; elif command -v xset >/dev/null 2>&1; then xset dpms force off; fi".to_string() ), }, } @@ -1189,7 +1194,7 @@ struct EditState { impl App { fn new(path: PathBuf, config: Config) -> Self { Self { - status: format!("loaded {}", path.display()), + status: format!("loaded config: {}", path.display()), path, config, focus: 0, @@ -1255,7 +1260,7 @@ impl App { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(5), + Constraint::Length(4), Constraint::Min(5), Constraint::Length(1), ]) @@ -1263,11 +1268,8 @@ impl App { self.draw_header(frame, chunks[0]); self.draw_body(frame, chunks[1]); - let footer = if let Some(edit) = &self.edit { - format!( - "editing {}: {} | enter apply | esc cancel", - edit.field.label, edit.value - ) + let footer = if self.edit.is_some() { + "enter apply | esc cancel".to_string() } else { "space/enter edit-toggle | r run | p pause | x stop | s save | q quit".to_string() }; @@ -1283,10 +1285,10 @@ impl App { .direction(Direction::Vertical) .constraints([ Constraint::Min(1), - Constraint::Length(9), + Constraint::Length(10), Constraint::Min(1), Constraint::Length(2), - Constraint::Length(8), + Constraint::Length(7), Constraint::Length(1), ]) .split(area); @@ -1368,41 +1370,41 @@ impl App { Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), - Constraint::Length(1), ]) .split(area); frame.render_widget( - Paragraph::new("gosleep-timer").style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ), + Paragraph::new("gosleep-timer") + .alignment(Alignment::Center) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), header[0], ); frame.render_widget( - Paragraph::new(format!("config: {}", self.path.display())) - .style(Style::default().fg(Color::DarkGray)), + Paragraph::new(self.status.clone()) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::Yellow)) + .wrap(Wrap { trim: false }), header[1], ); - frame.render_widget( - Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)), - header[2], - ); frame.render_widget( Paragraph::new(format_time_left_text( self.timer.as_ref(), remaining, - header[3].width, + header[2].width, )) + .alignment(Alignment::Center) .style(Style::default().fg(Color::White)), - header[3], + header[2], ); frame.render_widget( Gauge::default() .gauge_style(Style::default().fg(Color::Magenta)) .label(format_gauge_label(progress)) .ratio(progress), - header[4], + header[3], ); } @@ -1444,8 +1446,19 @@ impl App { .map(|(index, field)| { let selected = index == self.focus; let marker = if selected { ">" } else { " " }; - let line = format!("{marker} {:<20} {}", field.label, self.field_value(field)); - let style = if selected { + let editing = self + .edit + .as_ref() + .is_some_and(|edit| edit.field.key == field.key); + let value = if editing { + format!("{}▌", self.edit.as_ref().expect("edit checked").value) + } else { + self.field_value(field) + }; + let line = format!("{marker} {:<20} {}", field.label, value); + let style = if editing { + Style::default().fg(Color::Black).bg(Color::Yellow) + } else if selected { Style::default().fg(Color::Black).bg(Color::Cyan) } else { Style::default() @@ -1518,10 +1531,13 @@ impl App { match key { KeyCode::Esc => self.edit = None, KeyCode::Enter => { - if let Some(edit) = self.edit.take() - && let Err(err) = self.apply_edit(edit.field, edit.value) - { - self.status = format!("edit failed: {err}"); + let (field, value) = match &self.edit { + Some(edit) => (edit.field, edit.value.clone()), + None => return, + }; + match self.apply_edit(field, value) { + Ok(()) => self.edit = None, + Err(err) => self.status = format!("edit failed: {err}"), } } KeyCode::Backspace => { @@ -1586,6 +1602,10 @@ impl App { fn start_timer(&mut self) { self.stop_timer(); + if let Err(err) = validate_config(&self.config) { + self.status = format!("timer failed: {err}"); + return; + } let duration = match parse_duration(&self.config.duration) { Ok(duration) => duration, Err(err) => { @@ -1846,10 +1866,35 @@ const FIELDS: &[Field] = &[ kind: FieldKind::Edit, }, Field { - label: "inhibit sleep", - key: FieldKey::InhibitSleep, + label: "media enabled", + key: FieldKey::MediaEnabled, kind: FieldKind::Bool, }, + Field { + label: "media key action", + key: FieldKey::MediaAction, + kind: FieldKind::Cycle(&["stop", "pause", "play-pause", "next", "previous", "none"]), + }, + Field { + label: "mute enabled", + key: FieldKey::MuteEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "mic mute enabled", + key: FieldKey::MuteInputEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "brightness enabled", + key: FieldKey::BrightnessEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "brightness value", + key: FieldKey::BrightnessValue, + kind: FieldKind::Int, + }, Field { label: "workspace enabled", key: FieldKey::WorkspaceEnabled, @@ -1870,36 +1915,6 @@ const FIELDS: &[Field] = &[ key: FieldKey::WorkspaceCommand, kind: FieldKind::Edit, }, - Field { - label: "media enabled", - key: FieldKey::MediaEnabled, - kind: FieldKind::Bool, - }, - Field { - label: "media action", - key: FieldKey::MediaAction, - kind: FieldKind::Cycle(&["stop", "pause", "play-pause", "next", "previous", "none"]), - }, - Field { - label: "brightness enabled", - key: FieldKey::BrightnessEnabled, - kind: FieldKind::Bool, - }, - Field { - label: "brightness value", - key: FieldKey::BrightnessValue, - kind: FieldKind::Int, - }, - Field { - label: "mute enabled", - key: FieldKey::MuteEnabled, - kind: FieldKind::Bool, - }, - Field { - label: "mute input enabled", - key: FieldKey::MuteInputEnabled, - kind: FieldKind::Bool, - }, Field { label: "lock enabled", key: FieldKey::LockEnabled, @@ -1925,32 +1940,37 @@ const FIELDS: &[Field] = &[ kind: FieldKind::Edit, }, Field { - label: "monitor enabled", + label: "screen poweroff", key: FieldKey::MonitorEnabled, kind: FieldKind::Bool, }, Field { - label: "monitor backend", + label: "screen poweroff backend", key: FieldKey::MonitorBackend, kind: FieldKind::Cycle(&["auto", "hyprland", "niri", "sway", "kde", "gnome", "x11"]), }, Field { - label: "monitor command", + label: "screen poweroff command", key: FieldKey::MonitorCommand, kind: FieldKind::Edit, }, Field { - label: "notify enabled", + label: "prevent sleep", + key: FieldKey::InhibitSleep, + kind: FieldKind::Bool, + }, + Field { + label: "notification", key: FieldKey::NotifyEnabled, kind: FieldKind::Bool, }, Field { - label: "notify message", + label: "notification message", key: FieldKey::NotifyMessage, kind: FieldKind::Edit, }, Field { - label: "kill enabled", + label: "kill processes", key: FieldKey::KillEnabled, kind: FieldKind::Bool, }, @@ -1960,7 +1980,7 @@ const FIELDS: &[Field] = &[ kind: FieldKind::Csv, }, Field { - label: "power mode", + label: "power", key: FieldKey::PowerMode, kind: FieldKind::Cycle(&[ "none", @@ -1977,7 +1997,7 @@ const FIELDS: &[Field] = &[ kind: FieldKind::Bool, }, Field { - label: "custom commands", + label: "custom shell cmds", key: FieldKey::CustomCommands, kind: FieldKind::Semi, }, @@ -2286,11 +2306,152 @@ mod tests { assert_eq!(preview.len(), 6); assert_eq!(preview[1], "playerctl stop"); assert_eq!(preview[2], "loginctl lock-session"); - assert_eq!(preview[3], "pkill firefox"); - assert_eq!(preview[4], "pkill telegram-desktop"); + assert_eq!(preview[3], "pkill -- firefox"); + assert_eq!(preview[4], "pkill -- telegram-desktop"); assert_eq!(preview[5], "echo done"); } + #[test] + fn notify_message_with_quote_stays_single_argument() { + let mut config = quiet_config("25m"); + config.actions.notify.enabled = true; + config.actions.notify.message = "it's done".to_string(); + + let commands = build_action_commands(&config); + assert_eq!(commands.len(), 1); + assert_eq!(commands[0].label, "notify"); + assert!(commands[0].shell.is_none()); + assert_eq!( + commands[0].args, + vec![ + "notify-send".to_string(), + "gosleep-timer".to_string(), + "--".to_string(), + "it's done".to_string(), + ] + ); + assert_eq!( + commands[0].display(), + "notify-send gosleep-timer -- it's done" + ); + assert_eq!(preview_commands(&config), vec![commands[0].display()]); + } + + #[test] + fn kill_process_with_leading_dash_uses_separator() { + let mut config = quiet_config("25m"); + config.actions.kill.enabled = true; + config.actions.kill.processes = vec!["-evil".to_string()]; + + let commands = build_action_commands(&config); + assert_eq!(commands.len(), 1); + assert_eq!( + commands[0].args, + vec!["pkill".to_string(), "--".to_string(), "-evil".to_string(),] + ); + assert!(commands[0].shell.is_none()); + } + + #[test] + fn dangerous_flags_cover_shell_overrides_without_notify_false_positives() { + let notify = ActionCommand { + label: "notify", + args: vec![ + "notify-send".to_string(), + "gosleep-timer".to_string(), + "--".to_string(), + "please rm -rf /".to_string(), + ], + shell: None, + }; + assert!(!notify.is_dangerous()); + + let custom = ActionCommand { + label: "custom", + args: vec![], + shell: Some("echo done".to_string()), + }; + assert!(custom.is_dangerous()); + + for label in ["workspace", "monitor", "lock"] { + let command = ActionCommand { + label, + args: vec![], + shell: Some("my-custom-command".to_string()), + }; + assert!(command.is_dangerous(), "{label} override must be flagged"); + } + + let workspace_args = workspace_command(&WorkspaceAction { + backend: "sway".to_string(), + number: 3, + ..WorkspaceAction::default() + }); + assert!(workspace_args.shell.is_none()); + assert!(!workspace_args.is_dangerous()); + + let media = ActionCommand { + label: "media", + args: vec!["playerctl".to_string(), "stop".to_string()], + shell: None, + }; + assert!(!media.is_dangerous()); + } + + #[test] + fn failed_edit_keeps_input_for_retry() { + let mut app = App::new( + PathBuf::from("/tmp/gosleep-timer-test.yaml"), + quiet_config("25m"), + ); + app.edit = Some(EditState { + field: Field { + label: "workspace number", + key: FieldKey::WorkspaceNumber, + kind: FieldKind::Int, + }, + value: "not-a-number".to_string(), + }); + + app.handle_edit_key(KeyCode::Enter); + + assert_eq!(app.edit.as_ref().expect("edit kept").value, "not-a-number"); + assert!(app.status.starts_with("edit failed:")); + } + + #[test] + fn successful_edit_clears_input() { + let mut app = App::new( + PathBuf::from("/tmp/gosleep-timer-test.yaml"), + quiet_config("25m"), + ); + app.edit = Some(EditState { + field: Field { + label: "workspace number", + key: FieldKey::WorkspaceNumber, + kind: FieldKind::Int, + }, + value: "5".to_string(), + }); + + app.handle_edit_key(KeyCode::Enter); + + assert!(app.edit.is_none()); + assert_eq!(app.config.actions.workspace.number, 5); + } + + #[test] + fn start_timer_rejects_invalid_config_without_spawning() { + let mut config = quiet_config("25m"); + config.actions.power.mode = "bogus".to_string(); + let mut app = App::new(PathBuf::from("/tmp/gosleep-timer-test.yaml"), config); + + app.start_timer(); + + assert!(app.timer.is_none()); + assert!(app.status.starts_with("timer failed:")); + } + #[test] fn wrap_command_keeps_lines_under_width() { let lines = wrap_command( @@ -2477,6 +2638,50 @@ mod tests { assert!(rows.iter().all(|row| row.len() == width)); } + #[test] + fn kde_monitor_uses_dpms_off_and_differs_from_lock() { + let monitor = MonitorAction { + backend: "kde".to_string(), + ..MonitorAction::default() + }; + let lock = LockAction { + backend: "kde".to_string(), + ..LockAction::default() + }; + + assert_eq!( + monitor_command(&monitor).display(), + "kscreen-doctor --dpms off" + ); + assert_ne!( + monitor_command(&monitor).display(), + lock_command(&lock).display() + ); + } + + #[test] + fn sway_and_i3_workspace_use_workspace_number() { + let sway = WorkspaceAction { + backend: "sway".to_string(), + number: 3, + ..WorkspaceAction::default() + }; + let i3 = WorkspaceAction { + backend: "i3".to_string(), + number: 3, + ..WorkspaceAction::default() + }; + + assert_eq!( + workspace_command(&sway).display(), + "swaymsg workspace number 3" + ); + assert_eq!( + workspace_command(&i3).display(), + "i3-msg workspace number 3" + ); + } + fn visible_field_keys(config: &Config) -> Vec { visible_fields(config) .into_iter()